From b291b3079598f41485a94008daadec4680481b73 Mon Sep 17 00:00:00 2001 From: Randy Wilson Date: Sun, 23 Aug 2026 20:14:30 -0400 Subject: [PATCH] Index git history by time window and rank file ownership from authorship The flat 200-commit cap becomes a persisted history window: 365 days and 10000 commits by default, widen-only, configurable from the MCP reindex action, the REST parse endpoint, and the CLI. Two latent data-loss bugs are fixed along the way: an incremental backlog larger than the cap could skip commits forever, and a forced full reindex silently severed all history edges. A new analyze action, ownership, ranks per-file contributors from the author identity we already index, with honest caveats and history coverage. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 13 +- e2e/parse.spec.ts | 44 +++ .../api/src/__tests__/analysis-route.test.ts | 142 ++++++++ .../api/src/__tests__/parse-route.test.ts | 92 +++++ packages/api/src/routes/analysis.ts | 88 ++++- packages/api/src/routes/parse.ts | 51 ++- packages/cli/src/commands/extract.ts | 56 ++- .../src/__tests__/analysis-service.test.ts | 2 + .../gitsync-history-coverage.test.ts | 168 +++++++-- .../src/__tests__/gitsync-repo-root.test.ts | 4 +- .../src/__tests__/gitsync-root-commit.test.ts | 2 +- .../gitsync-symlink-namespace.test.ts | 2 +- ...er-git-edges-preserved.integration.test.ts | 227 ++++++++++++- .../indexer-project-link-order.test.ts | 41 ++- packages/core/src/gitSync.ts | 225 ++++++++++--- packages/core/src/index.ts | 2 +- packages/core/src/indexer.ts | 80 +++-- packages/core/src/service.ts | 11 +- .../core/src/services/analysis-service.ts | 6 + .../analysis-queries.integration.test.ts | 4 +- .../src/__tests__/analysis-queries.test.ts | 107 +++++- .../ownership-queries.integration.test.ts | 87 +++++ .../src/__tests__/ownership-queries.test.ts | 244 ++++++++++++++ packages/graph/src/analysis-queries.ts | 80 +++-- packages/graph/src/index.ts | 14 +- packages/graph/src/operations.ts | 12 + packages/graph/src/ownership-queries.ts | 318 ++++++++++++++++++ .../mcp-server/src/__tests__/analyze.test.ts | 80 ++++- .../mcp-server/src/__tests__/codebase.test.ts | 83 ++++- .../src/__tests__/consolidated.test.ts | 2 +- .../mcp-server/src/__tests__/legacy.test.ts | 44 ++- packages/mcp-server/src/personas/analyze.ts | 93 ++++- packages/mcp-server/src/personas/codebase.ts | 53 ++- packages/mcp-server/src/tools/reindex.ts | 100 ++++-- packages/mcp-server/src/tools/router.ts | 2 + packages/types/src/analysis.ts | 46 +++ packages/types/src/history.ts | 34 ++ packages/types/src/index.ts | 2 + packages/types/src/nodes.ts | 4 + 39 files changed, 2430 insertions(+), 235 deletions(-) create mode 100644 packages/api/src/__tests__/parse-route.test.ts create mode 100644 packages/graph/src/__tests__/ownership-queries.integration.test.ts create mode 100644 packages/graph/src/__tests__/ownership-queries.test.ts create mode 100644 packages/graph/src/ownership-queries.ts create mode 100644 packages/types/src/analysis.ts create mode 100644 packages/types/src/history.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3b953aa1..4368a69f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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. @@ -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:** ``` @@ -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) diff --git a/e2e/parse.spec.ts b/e2e/parse.spec.ts index bceab21e..125c52fb 100644 --- a/e2e/parse.spec.ts +++ b/e2e/parse.spec.ts @@ -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`, { diff --git a/packages/api/src/__tests__/analysis-route.test.ts b/packages/api/src/__tests__/analysis-route.test.ts index bc0235ba..c6b5ce84 100644 --- a/packages/api/src/__tests__/analysis-route.test.ts +++ b/packages/api/src/__tests__/analysis-route.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ getUnreferencedExports: vi.fn(), getHotspots: vi.fn(), getChangeCoupling: vi.fn(), + getOwnership: vi.fn(), })); vi.mock('@codegraph/core', () => { @@ -45,6 +46,7 @@ describe('analysis routes', () => { mocks.getUnreferencedExports, mocks.getHotspots, mocks.getChangeCoupling, + mocks.getOwnership, ]) { method.mockResolvedValue({ caveats: ['Static analysis is incomplete.'], @@ -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); @@ -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) => { @@ -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'); @@ -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'), diff --git a/packages/api/src/__tests__/parse-route.test.ts b/packages/api/src/__tests__/parse-route.test.ts new file mode 100644 index 00000000..b3bfd2ac --- /dev/null +++ b/packages/api/src/__tests__/parse-route.test.ts @@ -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): Promise<{ status: number; body: Record }> { + 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 }; +} + +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', + }); + }); +}); diff --git a/packages/api/src/routes/analysis.ts b/packages/api/src/routes/analysis.ts index 9af598e0..8a742bd3 100644 --- a/packages/api/src/routes/analysis.ts +++ b/packages/api/src/routes/analysis.ts @@ -1,9 +1,10 @@ import { AnalysisQueryInputError, codeGraphService } from '@codegraph/core'; import { Hono } from 'hono'; +import { isAbsolute, relative, resolve, win32 } from 'node:path'; import { safeErrorMessage } from '../safe-error.js'; const SYMBOL_ID_PATTERN = /^sym:v1:[a-f0-9]{64}$/; -const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2}))?$/; +const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(?:Z|[+-]\d{2}:\d{2}))?$/; const RESULT_LIMIT_MAX = 1000; function isNotFoundResult(result: object): boolean { @@ -48,14 +49,40 @@ function parseEnum( return { valid: true, value: raw as T }; } +function hasExactCalendarFields(raw: string): boolean { + const match = ISO_DATE_PATTERN.exec(raw); + if (match === null) return false; + const [, year, month, day, hour = '0', minute = '0', second = '0', fraction = '0'] = match; + const expected = { + year: Number(year), + month: Number(month), + day: Number(day), + hour: Number(hour), + minute: Number(minute), + second: Number(second), + millisecond: Number(fraction.padEnd(3, '0')), + }; + const reconstructed = new Date(0); + reconstructed.setUTCFullYear(expected.year, expected.month - 1, expected.day); + reconstructed.setUTCHours( + expected.hour, + expected.minute, + expected.second, + expected.millisecond, + ); + return reconstructed.getUTCFullYear() === expected.year + && reconstructed.getUTCMonth() === expected.month - 1 + && reconstructed.getUTCDate() === expected.day + && reconstructed.getUTCHours() === expected.hour + && reconstructed.getUTCMinutes() === expected.minute + && reconstructed.getUTCSeconds() === expected.second + && reconstructed.getUTCMilliseconds() === expected.millisecond; +} + function parseSince(raw: string | undefined): { valid: true; value?: string } | { valid: false; error: string } { if (raw === undefined) return { valid: true }; const timestamp = Date.parse(raw); - if (!ISO_DATE_PATTERN.test(raw) || !Number.isFinite(timestamp)) { - return { valid: false, error: 'since must be a valid ISO 8601 date or timestamp' }; - } - const dateOnlyIsExact = raw.includes('T') || new Date(timestamp).toISOString().slice(0, 10) === raw; - if (!dateOnlyIsExact) { + if (!hasExactCalendarFields(raw) || !Number.isFinite(timestamp)) { return { valid: false, error: 'since must be a valid ISO 8601 date or timestamp' }; } return { valid: true, value: raw }; @@ -65,6 +92,29 @@ function normalizeRootPath(rootPath: string): string { return rootPath === '/' ? rootPath : rootPath.replace(/\/+$/, ''); } +function parsePathPrefix( + raw: string | undefined, + rootPath: string, +): { valid: true; value?: string } | { valid: false; error: string } { + if (raw === undefined || raw === '') return { valid: true }; + const normalizedSeparators = raw.trim().replaceAll('\\', '/'); + if (isAbsolute(normalizedSeparators) || win32.isAbsolute(raw)) { + return { valid: false, error: 'pathPrefix must be project-relative' }; + } + if (normalizedSeparators.split('/').includes('..')) { + return { valid: false, error: 'pathPrefix must not contain .. traversal segments' }; + } + const resolvedPrefix = resolve(rootPath, normalizedSeparators).replaceAll('\\', '/'); + const relativePrefix = relative(rootPath, resolvedPrefix).replaceAll('\\', '/'); + if (relativePrefix === '..' || relativePrefix.startsWith('../') || isAbsolute(relativePrefix)) { + return { valid: false, error: 'pathPrefix must resolve within projectPath' }; + } + return { + valid: true, + ...(relativePrefix === '' ? {} : { value: relativePrefix }), + }; +} + export const analysisRoutes = new Hono(); analysisRoutes.get('/api/analysis/blast-radius', async (c) => { @@ -254,3 +304,29 @@ analysisRoutes.get('/api/analysis/change-coupling', async (c) => { }, 500); } }); + +analysisRoutes.get('/api/analysis/ownership', async (c) => { + try { + const since = parseSince(c.req.query('since')); + if (!since.valid) return c.json({ error: since.error }, 400); + const project = await resolveProjectRequest(c.req.query('projectId'), c.req.query('limit'), 500); + if (!project.valid) return c.json({ error: project.error }, project.status); + const pathPrefix = parsePathPrefix(c.req.query('pathPrefix'), project.value.rootPath); + if (!pathPrefix.valid) return c.json({ error: pathPrefix.error }, 400); + + return c.json(await codeGraphService.getOwnership({ + ...project.value, + ...(since.value === undefined ? {} : { since: since.value }), + ...(pathPrefix.value === undefined ? {} : { pathPrefix: pathPrefix.value }), + })); + } catch (error) { + if (error instanceof AnalysisQueryInputError) return c.json({ error: error.message }, 400); + return c.json({ + error: safeErrorMessage( + 'GET /api/analysis/ownership', + error, + 'Failed to analyze ownership.', + ), + }, 500); + } +}); diff --git a/packages/api/src/routes/parse.ts b/packages/api/src/routes/parse.ts index 8ef66fe6..80485e13 100644 --- a/packages/api/src/routes/parse.ts +++ b/packages/api/src/routes/parse.ts @@ -3,16 +3,59 @@ import { indexProject } from '@codegraph/core'; import { safeErrorMessage } from '../safe-error.js'; export const parseRoutes = new Hono(); +const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2}))?$/; + +function isValidIsoDateOrTimestamp(value: string): boolean { + if (!ISO_DATE_PATTERN.test(value) || !Number.isFinite(Date.parse(value))) return false; + const year = Number(value.slice(0, 4)); + const month = Number(value.slice(5, 7)); + const day = Number(value.slice(8, 10)); + const reconstructed = new Date(0); + reconstructed.setUTCHours(0, 0, 0, 0); + reconstructed.setUTCFullYear(year, month - 1, day); + return reconstructed.getUTCFullYear() === year + && reconstructed.getUTCMonth() === month - 1 + && reconstructed.getUTCDate() === day; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function validHistorySince(value: unknown): value is string | undefined { + if (value === undefined) return true; + if (typeof value !== 'string') return false; + return isValidIsoDateOrTimestamp(value); +} + +function validHistoryMaxCommits(value: unknown): value is number | undefined { + return value === undefined + || (typeof value === 'number' && Number.isSafeInteger(value) && value >= 1 && value <= 100_000); +} /** POST /api/parse/project — index a project directory */ parseRoutes.post('/api/parse/project', async (c) => { try { - const body = await c.req.json(); - const path = body.path as string; + const rawBody: unknown = await c.req.json(); + const body = isRecord(rawBody) ? rawBody : {}; + const path = body['path']; - if (!path) return c.json({ error: 'path field is required' }, 400); + if (typeof path !== 'string' || path.length === 0) { + return c.json({ error: 'path field is required' }, 400); + } + const historySince = body['historySince']; + if (!validHistorySince(historySince)) { + return c.json({ error: 'historySince must be a valid ISO 8601 date or timestamp' }, 400); + } + const historyMaxCommits = body['historyMaxCommits']; + if (!validHistoryMaxCommits(historyMaxCommits)) { + return c.json({ error: 'historyMaxCommits must be a safe integer between 1 and 100000' }, 400); + } - const result = await indexProject(path); + const result = await indexProject(path, { + ...(historySince !== undefined && { historySince }), + ...(historyMaxCommits !== undefined && { historyMaxCommits }), + }); // indexProject reports a bad path as success: false with the reason in // errorMessages. Reporting that as 200 with parsed: true told callers the diff --git a/packages/cli/src/commands/extract.ts b/packages/cli/src/commands/extract.ts index 06716a17..57a4fcb8 100644 --- a/packages/cli/src/commands/extract.ts +++ b/packages/cli/src/commands/extract.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import { createLogger } from '@codegraph/logger'; -import { indexProject, syncGitHistory } from '@codegraph/core'; +import { indexProject } from '@codegraph/core'; import { initParser, parseFile, @@ -19,6 +19,38 @@ import { resolve } from 'path'; import { getGraphClient } from '@codegraph/core'; const logger = createLogger({ namespace: 'cli:extract' }); +const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2}))?$/; + +function isValidIsoDateOrTimestamp(value: string): boolean { + if (!ISO_DATE_PATTERN.test(value) || !Number.isFinite(Date.parse(value))) return false; + const year = Number(value.slice(0, 4)); + const month = Number(value.slice(5, 7)); + const day = Number(value.slice(8, 10)); + const reconstructed = new Date(0); + reconstructed.setUTCHours(0, 0, 0, 0); + reconstructed.setUTCFullYear(year, month - 1, day); + return reconstructed.getUTCFullYear() === year + && reconstructed.getUTCMonth() === month - 1 + && reconstructed.getUTCDate() === day; +} + +function parseHistorySince(raw: unknown): string | undefined { + if (raw === undefined) return undefined; + if (typeof raw !== 'string') throw new Error('historySince must be a valid ISO 8601 date or timestamp'); + if (!isValidIsoDateOrTimestamp(raw)) { + throw new Error('historySince must be a valid ISO 8601 date or timestamp'); + } + return raw; +} + +function parseHistoryMaxCommits(raw: unknown): number | undefined { + if (raw === undefined) return undefined; + const value = typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : Number.NaN; + if (!Number.isSafeInteger(value) || value < 1 || value > 100_000) { + throw new Error('historyMaxCommits must be a safe integer between 1 and 100000'); + } + return value; +} export const extractCommand = new Command('extract') .description('Parse source files and populate the code graph') @@ -30,6 +62,8 @@ export const extractCommand = new Command('extract') .option('--exclude ', 'Exclude glob patterns (comma-separated)') .option('--deep', 'Enable deep analysis (call/render edges, complexity)') .option('--no-git', 'Skip git history sync') + .option('--history-since ', 'Inclusive ISO 8601 cutoff for the persisted git history window') + .option('--history-max-commits ', 'Initial-backfill safety ceiling (1-100000)') .option('--dry-run', 'Parse without writing to database') .action(async (targetPath, options) => { const startTime = Date.now(); @@ -39,6 +73,8 @@ export const extractCommand = new Command('extract') logger.info(`Graph: ${options.graph} @ ${options.host}:${options.port}`); try { + const historySince = parseHistorySince(options.historySince); + const historyMaxCommits = parseHistoryMaxCommits(options.historyMaxCommits); // Ensure plugins are registered before querying extensions registerPlugins(); @@ -112,6 +148,9 @@ export const extractCommand = new Command('extract') deepAnalysis: !!options.deep, ignorePatterns: excludePatterns, client, + gitSync: options.git !== false, + ...(historySince !== undefined && { historySince }), + ...(historyMaxCommits !== undefined && { historyMaxCommits }), }; if (options.include) { indexOpts!.includePatterns = includePatterns; @@ -125,17 +164,12 @@ export const extractCommand = new Command('extract') console.log(`${result.stats.errors} files failed to parse`); } - // Sync git history (unless --no-git) if (options.git !== false) { - console.log('\nSyncing git history...'); - const gitResult = await syncGitHistory(absPath, client); - if (gitResult.commitsProcessed > 0) { - console.log(`Git: ${gitResult.commitsProcessed} commits, ${gitResult.edgesCreated} file→commit edges`); - } else if (gitResult.errors.length > 0) { - console.log(`Git: ${gitResult.errors[0]}`); - } else { - console.log('Git: already up to date'); - } + const commitsProcessed = result.stats.commitsProcessed ?? 0; + const gitEdges = result.stats.gitEdges ?? 0; + console.log(commitsProcessed > 0 + ? `Git: ${commitsProcessed} commits, ${gitEdges} file→commit edges` + : 'Git: already up to date'); } } else { console.error(`Indexing failed: ${result.errorMessages.join('; ')}`); diff --git a/packages/core/src/__tests__/analysis-service.test.ts b/packages/core/src/__tests__/analysis-service.test.ts index 1f10fa6c..b86e6bcb 100644 --- a/packages/core/src/__tests__/analysis-service.test.ts +++ b/packages/core/src/__tests__/analysis-service.test.ts @@ -7,6 +7,7 @@ const analysisMethods = vi.hoisted(() => ({ getUnreferencedExports: vi.fn(), getHotspots: vi.fn(), getChangeCoupling: vi.fn(), + getOwnership: vi.fn(), })); const createAnalysisQueries = vi.hoisted(() => vi.fn(() => analysisMethods)); @@ -44,6 +45,7 @@ describe('core analysis service facade', () => { ['getUnreferencedExports', { rootPath: '/repo', limit: 10 }, { items: [] }], ['getHotspots', { rootPath: '/repo', scoreBy: 'degree', limit: 10 }, { items: [], historyCoverage: {} }], ['getChangeCoupling', { rootPath: '/repo', minSupport: 2, limit: 10 }, { items: [], historyCoverage: {} }], + ['getOwnership', { rootPath: '/repo', pathPrefix: '/repo/src', limit: 10 }, { items: [], historyCoverage: {} }], ] as const)('exposes %s with the frozen input object unchanged', async (methodName, input, expected) => { analysisMethods[methodName].mockResolvedValueOnce(expected); diff --git a/packages/core/src/__tests__/gitsync-history-coverage.test.ts b/packages/core/src/__tests__/gitsync-history-coverage.test.ts index fd68c295..f6439e74 100644 --- a/packages/core/src/__tests__/gitsync-history-coverage.test.ts +++ b/packages/core/src/__tests__/gitsync-history-coverage.test.ts @@ -1,9 +1,9 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { execFileSync } from 'node:child_process'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { GraphClient } from '@codegraph/graph'; +import { createOperations, type GraphClient } from '@codegraph/graph'; const opsMocks = vi.hoisted(() => ({ upsertCommit: vi.fn().mockResolvedValue(undefined), @@ -18,71 +18,189 @@ vi.mock('@codegraph/graph', () => ({ import { syncGitHistory } from '../gitSync'; -function git(cwd: string, args: string[]): void { - execFileSync('git', args, { cwd, stdio: 'pipe' }); +function git(cwd: string, args: string[], env?: NodeJS.ProcessEnv): string { + return execFileSync('git', args, { cwd, env: { ...process.env, ...env }, encoding: 'utf8' }).trim(); } -function commit(repoRoot: string, value: number): void { +function commit(repoRoot: string, value: number, date: string): void { writeFileSync(join(repoRoot, 'value.ts'), `export const value = ${value};\n`); git(repoRoot, ['add', '-A']); - git(repoRoot, ['-c', 'user.name=Coverage Test', '-c', 'user.email=coverage@example.com', 'commit', '-q', '-m', `commit ${value}`]); + git( + repoRoot, + ['-c', 'user.name=Coverage Test', '-c', 'user.email=coverage@example.com', 'commit', '-q', '-m', `commit ${value}`], + { GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date }, + ); } -function makeRepo(commitCount: number): string { +function makeRepo(dates: string[]): string { const repoRoot = mkdtempSync(join(tmpdir(), 'codegraph-gitsync-coverage-')); git(repoRoot, ['init', '-q']); - for (let index = 1; index <= commitCount; index += 1) commit(repoRoot, index); + dates.forEach((date, index) => commit(repoRoot, index + 1, date)); return repoRoot; } -function makeClient(): GraphClient { - return { +function makeClient(): { client: GraphClient; metadata: Map } { + const metadata = new Map(); + const roQuery = vi.fn().mockImplementation(async (_cypher: string, options?: { params?: Record }) => { + const key = options?.params?.['key']; + const value = typeof key === 'string' ? metadata.get(key) : undefined; + return { data: value === undefined ? [] : [{ value }], metadata: [] }; + }); + const query = vi.fn().mockImplementation(async (_cypher: string, options?: { params?: Record }) => { + const key = options?.params?.['key']; + const value = options?.params?.['value']; + if (typeof key === 'string' && typeof value === 'string') metadata.set(key, value); + return { data: [], metadata: [] }; + }); + const client = { graph: null, graphName: 'test', dialect: {}, - query: vi.fn().mockResolvedValue({ data: [], metadata: [] }), - roQuery: vi.fn().mockResolvedValue({ data: [], metadata: [] }), + query, + roQuery, ensureIndexes: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), } as unknown as GraphClient; + return { client, metadata }; } const repositories: string[] = []; +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createOperations).mockReturnValue(opsMocks as never); +}); + afterEach(() => { + vi.restoreAllMocks(); for (const repository of repositories.splice(0)) { rmSync(repository, { recursive: true, force: true }); } }); -describe('syncGitHistory history coverage', () => { - it('reports complete history when the repository fits within the configured window', async () => { - const repoRoot = makeRepo(1); +describe('syncGitHistory persisted history window', () => { + it('resolves the default cutoff once to exactly 365 days before the first sync start', async () => { + const repoRoot = makeRepo(['2026-08-01T12:00:00Z']); repositories.push(repoRoot); + const { client, metadata } = makeClient(); + vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-08-23T12:00:00.000Z')); - const result = await syncGitHistory(repoRoot, makeClient(), { maxCommits: 2 }); + const first = await syncGitHistory(repoRoot, client); + vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-09-23T12:00:00.000Z')); + const second = await syncGitHistory(repoRoot, client); - expect(result).toMatchObject({ - commitsProcessed: 1, - totalCommits: 1, - historyWindowSize: 2, - historyTruncated: false, - historyComplete: true, + expect(first.historySince).toBe('2025-08-23T12:00:00.000Z'); + expect(first.historyMaxCommits).toBe(10_000); + expect(first.historyWindowSize).toBe(10_000); + expect(second.historySince).toBe(first.historySince); + expect(metadata.get(`historySince:${repoRoot}`)).toBe(first.historySince); + expect(metadata.get(`historyMaxCommits:${repoRoot}`)).toBe('10000'); + }); + + it('uses an explicit ISO cutoff for initial history selection', async () => { + const repoRoot = makeRepo([ + '2024-01-01T00:00:00Z', + '2025-01-01T00:00:00Z', + '2026-01-01T00:00:00Z', + ]); + repositories.push(repoRoot); + + const result = await syncGitHistory(repoRoot, makeClient().client, { + historySince: '2025-06-01T00:00:00Z', + historyMaxCommits: 10, }); + + expect(result.commitsProcessed).toBe(1); + expect(result.historySince).toBe('2025-06-01T00:00:00Z'); + expect(result.historyTruncated).toBe(true); + expect(result.historyComplete).toBe(false); }); - it('reports truncation when the repository exceeds the configured window', async () => { - const repoRoot = makeRepo(3); + it('records the earliest actually indexed date when the max truncates a backfill', async () => { + const repoRoot = makeRepo([ + '2025-01-01T00:00:00Z', + '2025-02-01T00:00:00Z', + '2025-03-01T00:00:00Z', + ]); repositories.push(repoRoot); + const { client, metadata } = makeClient(); - const result = await syncGitHistory(repoRoot, makeClient(), { maxCommits: 2 }); + const result = await syncGitHistory(repoRoot, client, { + historySince: '2024-01-01T00:00:00Z', + historyMaxCommits: 2, + }); expect(result).toMatchObject({ commitsProcessed: 2, totalCommits: 3, + historyMaxCommits: 2, historyWindowSize: 2, historyTruncated: true, historyComplete: false, }); + expect(new Date(result.earliestIndexedCommitDate as string).toISOString()).toBe('2025-02-01T00:00:00.000Z'); + expect(metadata.get(`historyEarliestIndexedDate:${repoRoot}`)).toBe(result.earliestIndexedCommitDate); + }); + + it('replays a widened max with merge-safe inputs and becomes idempotent', async () => { + const repoRoot = makeRepo([ + '2025-01-01T00:00:00Z', + '2025-02-01T00:00:00Z', + '2025-03-01T00:00:00Z', + '2025-04-01T00:00:00Z', + ]); + repositories.push(repoRoot); + const { client } = makeClient(); + + const first = await syncGitHistory(repoRoot, client, { + historySince: '2024-01-01T00:00:00Z', + historyMaxCommits: 2, + }); + const widened = await syncGitHistory(repoRoot, client, { historyMaxCommits: 4 }); + const repeated = await syncGitHistory(repoRoot, client, { historyMaxCommits: 4 }); + + expect(first.commitsProcessed).toBe(2); + expect(widened).toMatchObject({ commitsProcessed: 4, historyMaxCommits: 4, historyTruncated: false, historyComplete: true }); + expect(repeated).toMatchObject({ commitsProcessed: 0, historyMaxCommits: 4, historyTruncated: false, historyComplete: true }); + }); + + it('keeps the persisted union when callers request a narrower window', async () => { + const repoRoot = makeRepo(['2025-01-01T00:00:00Z', '2025-02-01T00:00:00Z']); + repositories.push(repoRoot); + const { client } = makeClient(); + + await syncGitHistory(repoRoot, client, { + historySince: '2024-01-01T00:00:00Z', + historyMaxCommits: 20, + }); + const result = await syncGitHistory(repoRoot, client, { + historySince: '2025-01-15T00:00:00Z', + historyMaxCommits: 1, + }); + + expect(result.historySince).toBe('2024-01-01T00:00:00Z'); + expect(result.historyMaxCommits).toBe(20); + expect(result.commitsProcessed).toBe(0); + }); + + it('processes an incremental backlog larger than the initial cap without skipping commits', async () => { + const repoRoot = makeRepo(['2025-01-01T00:00:00Z']); + repositories.push(repoRoot); + const { client, metadata } = makeClient(); + + const initial = await syncGitHistory(repoRoot, client, { + historySince: '2024-01-01T00:00:00Z', + historyMaxCommits: 1, + }); + for (let index = 2; index <= 6; index += 1) { + commit(repoRoot, index, `2025-01-0${index}T00:00:00Z`); + } + const incremental = await syncGitHistory(repoRoot, client, { historyMaxCommits: 1 }); + const head = git(repoRoot, ['rev-parse', 'HEAD']); + + expect(initial.commitsProcessed).toBe(1); + expect(incremental.commitsProcessed).toBe(5); + expect(incremental.lastCommitHash).toBe(head); + expect(metadata.get(`lastCommitSynced:${repoRoot}`)).toBe(head); }); }); diff --git a/packages/core/src/__tests__/gitsync-repo-root.test.ts b/packages/core/src/__tests__/gitsync-repo-root.test.ts index 7d7decf7..05243716 100644 --- a/packages/core/src/__tests__/gitsync-repo-root.test.ts +++ b/packages/core/src/__tests__/gitsync-repo-root.test.ts @@ -99,7 +99,7 @@ beforeEach(() => { describe('syncGitHistory: joins git paths against the repo root, not the indexed subdirectory', () => { it('links MODIFIED_IN edges using the real absolute path of files under the indexed root', async () => { - const result = await syncGitHistory(indexedRoot, fakeClient, { maxCommits: 10, includeStats: true }); + const result = await syncGitHistory(indexedRoot, fakeClient, { historyMaxCommits: 10, includeStats: true }); expect(result.errors).toEqual([]); expect(result.commitsProcessed).toBe(2); @@ -115,7 +115,7 @@ describe('syncGitHistory: joins git paths against the repo root, not the indexed }); it('skips files outside the indexed root instead of mislinking them', async () => { - await syncGitHistory(indexedRoot, fakeClient, { maxCommits: 10, includeStats: true }); + await syncGitHistory(indexedRoot, fakeClient, { historyMaxCommits: 10, includeStats: true }); const modifiedPaths = opsMocks.createModifiedInEdge.mock.calls.map((call) => call[0] as string); diff --git a/packages/core/src/__tests__/gitsync-root-commit.test.ts b/packages/core/src/__tests__/gitsync-root-commit.test.ts index 0291cbac..6d91f9b0 100644 --- a/packages/core/src/__tests__/gitsync-root-commit.test.ts +++ b/packages/core/src/__tests__/gitsync-root-commit.test.ts @@ -78,7 +78,7 @@ afterAll(() => { describe('syncGitHistory: root commit diffing', () => { it('produces MODIFIED_IN and INTRODUCED_IN edges for files added in the very first commit', async () => { - const result = await syncGitHistory(repoRoot, fakeClient, { maxCommits: 10, includeStats: true }); + const result = await syncGitHistory(repoRoot, fakeClient, { historyMaxCommits: 10, includeStats: true }); expect(result.commitsProcessed).toBe(1); diff --git a/packages/core/src/__tests__/gitsync-symlink-namespace.test.ts b/packages/core/src/__tests__/gitsync-symlink-namespace.test.ts index e27c4d53..124e8bf4 100644 --- a/packages/core/src/__tests__/gitsync-symlink-namespace.test.ts +++ b/packages/core/src/__tests__/gitsync-symlink-namespace.test.ts @@ -94,7 +94,7 @@ afterAll(() => { describe('syncGitHistory: preserves the caller original path namespace', () => { it('creates MODIFIED_IN edges even when the indexed root sits under a symlink, using a filePath that matches File.filePath', async () => { - const result = await syncGitHistory(indexedRoot, fakeClient, { maxCommits: 10, includeStats: true }); + const result = await syncGitHistory(indexedRoot, fakeClient, { historyMaxCommits: 10, includeStats: true }); expect(result.commitsProcessed).toBe(2); diff --git a/packages/core/src/__tests__/indexer-git-edges-preserved.integration.test.ts b/packages/core/src/__tests__/indexer-git-edges-preserved.integration.test.ts index 057f9458..e6f716a7 100644 --- a/packages/core/src/__tests__/indexer-git-edges-preserved.integration.test.ts +++ b/packages/core/src/__tests__/indexer-git-edges-preserved.integration.test.ts @@ -27,16 +27,33 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { execFileSync } from 'node:child_process'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { resolve } from 'node:path'; import { createClient, resolveEmbeddedBinaryPaths, type GraphClient } from '@codegraph/graph'; import { indexProject } from '../indexer'; // The embedded driver ships binaries for darwin-arm64 and linux-x64 only. const describeIfAvailable = resolveEmbeddedBinaryPaths() ? describe : describe.skip; -function git(cwd: string, args: string[]): void { - execFileSync('git', args, { cwd, stdio: 'pipe' }); +function git(cwd: string, args: string[], env?: NodeJS.ProcessEnv): void { + execFileSync('git', args, { cwd, env: { ...process.env, ...env }, stdio: 'pipe' }); +} + +function commitFile(repoRoot: string, filePath: string, value: number, date: string): void { + writeFileSync(filePath, `export const value = ${value};\n`); + git(repoRoot, ['add', '-A']); + git(repoRoot, ['commit', '-q', '-m', `commit ${value}`], { + GIT_AUTHOR_DATE: date, + GIT_COMMITTER_DATE: date, + }); +} + +async function modifiedInCount(client: GraphClient, filePath: string): Promise { + const result = await client.roQuery<{ edgeCount: number }>( + `MATCH (f:File {filePath: $filePath})-[:MODIFIED_IN]->(:Commit) + RETURN count(*) AS edgeCount`, + { params: { filePath } }, + ); + return result.data?.[0]?.edgeCount ?? 0; } describeIfAvailable('indexProject: incremental reindex preserves prior git-history edges', () => { @@ -53,14 +70,14 @@ describeIfAvailable('indexProject: incremental reindex preserves prior git-histo previousEmbeddingProvider = process.env['CODEGRAPH_EMBEDDING_PROVIDER']; process.env['CODEGRAPH_EMBEDDING_PROVIDER'] = 'none'; - dataDir = await mkdtemp(join(tmpdir(), 'cg-git-edges-')); + dataDir = await mkdtemp('/tmp/cgb1-db-'); client = await createClient({ driver: 'falkordblite', databasePath: dataDir, graphName: 'git_edges_regression', } as never); - repoRoot = mkdtempSync(join(tmpdir(), 'codegraph-git-edges-repo-')); + repoRoot = mkdtempSync('/tmp/cgb1-repo-'); git(repoRoot, ['init', '-q']); git(repoRoot, ['config', 'user.email', 'test@example.com']); git(repoRoot, ['config', 'user.name', 'Test']); @@ -93,6 +110,23 @@ describeIfAvailable('indexProject: incremental reindex preserves prior git-histo }); expect(first.success).toBe(true); + const projectMetadata = await client.roQuery<{ + historySince: string; + historyMaxCommits: number; + historyWindowSize: number; + }>( + `MATCH (p:Project {rootPath: $rootPath}) + RETURN p.gitHistorySince AS historySince, + p.gitHistoryMaxCommits AS historyMaxCommits, + p.gitHistoryWindowSize AS historyWindowSize`, + { params: { rootPath: repoRoot } }, + ); + expect(projectMetadata.data?.[0]).toMatchObject({ + historyMaxCommits: 10_000, + historyWindowSize: 10_000, + }); + expect(projectMetadata.data?.[0]?.historySince).toMatch(/^\d{4}-\d{2}-\d{2}T/); + // Edit the file and commit again. writeFileSync(filePath, 'export function foo(): number {\n return 2;\n}\n'); git(repoRoot, ['add', '-A']); @@ -117,4 +151,185 @@ describeIfAvailable('indexProject: incremental reindex preserves prior git-histo const hashes = (result.data ?? []).map((row) => row.hash); expect(hashes).toHaveLength(2); }); + + it('replays the persisted history window after a forced full reindex recreates File nodes', async () => { + const forced = await indexProject(repoRoot, { + client, + includePatterns: ['*.ts'], + embeddings: false, + force: true, + }); + expect(forced.success).toBe(true); + + const result = await client.roQuery<{ hash: string }>( + `MATCH (f:File {filePath: $filePath})-[:MODIFIED_IN]->(c:Commit) RETURN c.hash AS hash`, + { params: { filePath } }, + ); + + expect((result.data ?? []).map((row) => row.hash)).toHaveLength(2); + }); + + it('rebuilds every previously indexed edge after incremental history grows beyond the backfill ceiling', async () => { + const replayRepo = mkdtempSync('/tmp/cgb1-replay-'); + const replayFile = resolve(replayRepo, 'history.ts'); + try { + git(replayRepo, ['init', '-q']); + git(replayRepo, ['config', 'user.email', 'test@example.com']); + git(replayRepo, ['config', 'user.name', 'Test']); + for (let value = 1; value <= 4; value += 1) { + commitFile(replayRepo, replayFile, value, `2025-01-${String(value).padStart(2, '0')}T00:00:00Z`); + } + + const initial = await indexProject(replayRepo, { + client, + includePatterns: ['*.ts'], + embeddings: false, + force: true, + historySince: '2025-01-01T00:00:00Z', + historyMaxCommits: 2, + }); + expect(initial.success).toBe(true); + expect(await modifiedInCount(client, replayFile)).toBe(2); + + const widened = await indexProject(replayRepo, { + client, + includePatterns: ['*.ts'], + embeddings: false, + historyMaxCommits: 10, + }); + expect(widened.success).toBe(true); + expect(await modifiedInCount(client, replayFile)).toBe(4); + + for (let value = 5; value <= 18; value += 1) { + commitFile(replayRepo, replayFile, value, `2025-02-${String(value - 4).padStart(2, '0')}T00:00:00Z`); + } + const firstIncremental = await indexProject(replayRepo, { + client, + includePatterns: ['*.ts'], + embeddings: false, + historyMaxCommits: 1, + }); + expect(firstIncremental.stats.commitsProcessed).toBe(14); + + for (let value = 19; value <= 23; value += 1) { + commitFile(replayRepo, replayFile, value, `2025-03-0${value - 18}T00:00:00Z`); + } + const secondIncremental = await indexProject(replayRepo, { + client, + includePatterns: ['*.ts'], + embeddings: false, + historyMaxCommits: 1, + }); + expect(secondIncremental.stats.commitsProcessed).toBe(5); + expect(await modifiedInCount(client, replayFile)).toBe(23); + + const forced = await indexProject(replayRepo, { + client, + includePatterns: ['*.ts'], + embeddings: false, + force: true, + }); + expect(forced.success).toBe(true); + expect(await modifiedInCount(client, replayFile)).toBe(23); + + commitFile(replayRepo, replayFile, 24, '2025-03-06T00:00:00Z'); + const finalIncremental = await indexProject(replayRepo, { + client, + includePatterns: ['*.ts'], + embeddings: false, + historyMaxCommits: 1, + }); + expect(finalIncremental.stats.commitsProcessed).toBe(1); + expect(await modifiedInCount(client, replayFile)).toBe(24); + } finally { + rmSync(replayRepo, { recursive: true, force: true }); + } + }, 120_000); + + it('persists complete zero-commit coverage and syncs normally after the first commit', async () => { + const unbornRepo = mkdtempSync('/tmp/cgb1-unborn-'); + const unbornFile = resolve(unbornRepo, 'unborn.ts'); + try { + git(unbornRepo, ['init', '-q']); + git(unbornRepo, ['config', 'user.email', 'test@example.com']); + git(unbornRepo, ['config', 'user.name', 'Test']); + writeFileSync(unbornFile, 'export const value = 0;\n'); + + const initial = await indexProject(unbornRepo, { + client, + includePatterns: ['*.ts'], + embeddings: false, + force: true, + historySince: '2025-01-01T00:00:00Z', + historyMaxCommits: 2, + }); + expect(initial.success).toBe(true); + + const initialCoverage = await client.roQuery<{ + total: number; + truncated: boolean; + complete: boolean; + since: string; + max: number; + }>( + `MATCH (p:Project {rootPath: $rootPath}) + RETURN p.gitHistoryTotalCommits AS total, + p.gitHistoryTruncated AS truncated, + p.gitHistoryComplete AS complete, + p.gitHistorySince AS since, + p.gitHistoryMaxCommits AS max`, + { params: { rootPath: unbornRepo } }, + ); + expect(initialCoverage.data?.[0]).toEqual({ + total: 0, + truncated: false, + complete: true, + since: '2025-01-01T00:00:00Z', + max: 2, + }); + + git(unbornRepo, ['add', '-A']); + git(unbornRepo, ['commit', '-q', '-m', 'first commit'], { + GIT_AUTHOR_DATE: '2025-01-02T00:00:00Z', + GIT_COMMITTER_DATE: '2025-01-02T00:00:00Z', + }); + const afterFirstCommit = await indexProject(unbornRepo, { + client, + includePatterns: ['*.ts'], + embeddings: false, + }); + expect(afterFirstCommit.success).toBe(true); + expect(afterFirstCommit.stats.commitsProcessed).toBe(1); + expect(await modifiedInCount(client, unbornFile)).toBe(1); + } finally { + rmSync(unbornRepo, { recursive: true, force: true }); + } + }, 30_000); + + it.each([ + '2026-02-30T00:00:00Z', + '2026-04-31T12:00:00Z', + '2025-02-29T00:00:00Z', + ])('rejects impossible history timestamp %s at indexProject', async (historySince) => { + const result = await indexProject(repoRoot, { + client, + includePatterns: ['*.ts'], + embeddings: false, + historySince, + }); + + expect(result.success).toBe(false); + expect(result.errorMessages).toEqual(['historySince must be a valid ISO 8601 date or timestamp']); + }); + + it('accepts a valid leap-day history timestamp at indexProject', async () => { + const result = await indexProject(repoRoot, { + client, + includePatterns: ['*.ts'], + embeddings: false, + historySince: '2024-02-29T00:00:00Z', + }); + + expect(result.success).toBe(true); + }); }); diff --git a/packages/core/src/__tests__/indexer-project-link-order.test.ts b/packages/core/src/__tests__/indexer-project-link-order.test.ts index 2d6acea6..56a95cde 100644 --- a/packages/core/src/__tests__/indexer-project-link-order.test.ts +++ b/packages/core/src/__tests__/indexer-project-link-order.test.ts @@ -68,6 +68,9 @@ const gitSyncMock = vi.hoisted(() => vi.fn().mockResolvedValue({ edgesCreated: 2, lastCommitHash: 'newest', totalCommits: 3, + historySince: '2024-01-01T00:00:00Z', + historyMaxCommits: 2, + earliestIndexedCommitDate: '2025-01-01T00:00:00Z', historyWindowSize: 2, historyTruncated: true, historyComplete: false, @@ -79,7 +82,10 @@ vi.mock('@codegraph/graph', () => ({ createOperations: vi.fn().mockReturnValue(opsMocks), })); -vi.mock('../gitSync', () => ({ syncGitHistory: gitSyncMock })); +vi.mock('../gitSync', () => ({ + syncGitHistory: gitSyncMock, + validateHistoryWindowOptions: vi.fn().mockReturnValue(null), +})); vi.mock('../pipeline', () => ({ initParser: vi.fn().mockResolvedValue(undefined), @@ -159,6 +165,7 @@ beforeEach(() => { opsMocks.upsertProject.mockClear(); opsMocks.linkProjectFiles.mockClear(); opsMocks.deleteProject.mockClear(); + gitSyncMock.mockClear(); vi.mocked(fakeClient.ensureIndexes).mockClear(); }); @@ -284,9 +291,41 @@ describe('indexProject: Project node must exist before linkProjectFiles', () => expect(result.success).toBe(true); expect(opsMocks.upsertProject).toHaveBeenLastCalledWith(expect.objectContaining({ gitHistoryTotalCommits: 3, + gitHistorySince: '2024-01-01T00:00:00Z', + gitHistoryMaxCommits: 2, gitHistoryWindowSize: 2, gitHistoryTruncated: true, gitHistoryComplete: false, })); }); + + it('forwards the history window and requests a replay for a forced existing-project reindex', async () => { + const existingProject: ProjectEntity = { + id: randomUUID(), + name: 'fixture', + rootPath: projectDir, + createdAt: new Date().toISOString(), + lastParsed: new Date().toISOString(), + gitHistorySince: '2025-01-01T00:00:00Z', + gitHistoryMaxCommits: 100, + }; + opsMocks.getProjectByRoot.mockResolvedValue(existingProject); + + await indexProject(projectDir, { + client: fakeClient, + includePatterns: ['*.ts'], + embeddings: false, + force: true, + historySince: '2024-01-01T00:00:00Z', + historyMaxCommits: 500, + }); + + expect(gitSyncMock).toHaveBeenCalledOnce(); + expect(gitSyncMock).toHaveBeenCalledWith(projectDir, fakeClient, { + historySince: '2024-01-01T00:00:00Z', + historyMaxCommits: 500, + includeStats: true, + rebuildHistoryEdges: true, + }); + }); }); diff --git a/packages/core/src/gitSync.ts b/packages/core/src/gitSync.ts index 9ff200b9..9fbe161c 100644 --- a/packages/core/src/gitSync.ts +++ b/packages/core/src/gitSync.ts @@ -7,12 +7,30 @@ import simpleGit, { type SimpleGit, type LogResult, type DefaultLogFields } from 'simple-git'; import { createOperations, type GraphClient } from '@codegraph/graph'; -import type { CommitEntity } from '@codegraph/types'; +import type { CommitEntity, HistoryWindowOptions } from '@codegraph/types'; import { createLogger } from '@codegraph/logger'; import { relative, resolve, join } from 'node:path'; import { realpath } from 'node:fs/promises'; const logger = createLogger({ namespace: 'core:gitSync' }); +const DEFAULT_HISTORY_MAX_COMMITS = 10_000; +const MAX_HISTORY_MAX_COMMITS = 100_000; +const DEFAULT_HISTORY_DAYS = 365; +const DAY_MS = 24 * 60 * 60 * 1000; +const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2}))?$/; + +function isValidIsoDateOrTimestamp(value: string): boolean { + if (!ISO_DATE_PATTERN.test(value) || !Number.isFinite(Date.parse(value))) return false; + const year = Number(value.slice(0, 4)); + const month = Number(value.slice(5, 7)); + const day = Number(value.slice(8, 10)); + const reconstructed = new Date(0); + reconstructed.setUTCHours(0, 0, 0, 0); + reconstructed.setUTCFullYear(year, month - 1, day); + return reconstructed.getUTCFullYear() === year + && reconstructed.getUTCMonth() === month - 1 + && reconstructed.getUTCDate() === day; +} // ============================================================================ // Types @@ -23,6 +41,10 @@ export interface GitSyncResult { edgesCreated: number; lastCommitHash: string | null; totalCommits: number | null; + historySince: string; + historyMaxCommits: number; + earliestIndexedCommitDate: string | null; + /** Deprecated compatibility alias for historyMaxCommits. */ historyWindowSize: number; historyTruncated: boolean; historyComplete: boolean; @@ -30,17 +52,42 @@ export interface GitSyncResult { errors: string[]; } -export interface GitSyncOptions { - /** Maximum number of commits to process (default: 100) */ - maxCommits?: number; +export interface GitSyncOptions extends HistoryWindowOptions { /** Only process commits after this hash */ sinceCommit?: string; + /** Replay the effective persisted window after File nodes were recreated. */ + rebuildHistoryEdges?: boolean; /** Include file change stats (linesAdded/linesRemoved) on MODIFIED_IN edges */ includeStats?: boolean; /** GraphClient to use (uses default if not provided) */ client?: GraphClient; } +export function validateHistoryWindowOptions(options: HistoryWindowOptions): string | null { + if (options.historySince !== undefined) { + if (!isValidIsoDateOrTimestamp(options.historySince)) { + return 'historySince must be a valid ISO 8601 date or timestamp'; + } + } + if (options.historyMaxCommits !== undefined + && (!Number.isSafeInteger(options.historyMaxCommits) + || options.historyMaxCommits < 1 + || options.historyMaxCommits > MAX_HISTORY_MAX_COMMITS)) { + return 'historyMaxCommits must be a safe integer between 1 and 100000'; + } + return null; +} + +function earlierIso(left: string, right: string): string { + return Date.parse(left) <= Date.parse(right) ? left : right; +} + +function parseStoredInteger(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; +} + // ============================================================================ // Metadata helpers (Metadata node table for graph-level state) // ============================================================================ @@ -159,7 +206,10 @@ export async function syncGitHistory( options: GitSyncOptions = {}, ): Promise { const startTime = Date.now(); - const { maxCommits = 100, sinceCommit, includeStats = true } = options; + const validationError = validateHistoryWindowOptions(options); + if (validationError) throw new RangeError(validationError); + const { sinceCommit, includeStats = true, rebuildHistoryEdges = false } = options; + const defaultHistorySince = new Date(startTime - DEFAULT_HISTORY_DAYS * DAY_MS).toISOString(); const errors: string[] = []; try { @@ -172,7 +222,10 @@ export async function syncGitHistory( edgesCreated: 0, lastCommitHash: null, totalCommits: null, - historyWindowSize: maxCommits, + historySince: options.historySince ?? defaultHistorySince, + historyMaxCommits: options.historyMaxCommits ?? DEFAULT_HISTORY_MAX_COMMITS, + earliestIndexedCommitDate: null, + historyWindowSize: options.historyMaxCommits ?? DEFAULT_HISTORY_MAX_COMMITS, historyTruncated: false, historyComplete: false, durationMs: Date.now() - startTime, @@ -190,7 +243,12 @@ export async function syncGitHistory( // nodes, instead of naively joining repoPath with git's relative path. const repoRoot = (await git.revparse(['--show-toplevel'])).trim(); const indexedRoot = resolve(repoPath); - const totalCommits = Number.parseInt((await git.raw(['rev-list', '--count', 'HEAD'])).trim(), 10); + const hasHead = await git.raw(['rev-parse', '--verify', 'HEAD']) + .then(() => true) + .catch(() => false); + const totalCommits = hasHead + ? Number.parseInt((await git.raw(['rev-list', '--count', 'HEAD'])).trim(), 10) + : 0; // `git rev-parse --show-toplevel` resolves symlinks. On macOS, // os.tmpdir() lives under /var/folders, itself a symlink to @@ -212,44 +270,102 @@ export async function syncGitHistory( realIndexedRoot = indexedRoot; } - // Determine starting point for incremental sync - let fromCommit = sinceCommit; - if (!fromCommit) { - fromCommit = await getMetadata(client, `lastCommitSynced:${repoPath}`); + const metadataPrefix = (name: string): string => `${name}:${repoPath}`; + const [savedCheckpoint, storedHistorySince, storedHistoryMaxRaw, storedEarliestIndexedDate, + previousHistoryComplete, previousHistoryTruncated] = await Promise.all([ + getMetadata(client, metadataPrefix('lastCommitSynced')), + getMetadata(client, metadataPrefix('historySince')), + getMetadata(client, metadataPrefix('historyMaxCommits')), + getMetadata(client, metadataPrefix('historyEarliestIndexedDate')), + getMetadata(client, metadataPrefix('historyComplete')), + getMetadata(client, metadataPrefix('historyTruncated')), + ]); + const storedHistoryMax = parseStoredInteger(storedHistoryMaxRaw); + const requestedHistorySince = options.historySince; + const historySince = storedHistorySince + ? requestedHistorySince ? earlierIso(storedHistorySince, requestedHistorySince) : storedHistorySince + : requestedHistorySince ?? defaultHistorySince; + const historyMaxCommits = Math.max( + storedHistoryMax ?? 0, + options.historyMaxCommits ?? (storedHistoryMax === undefined ? DEFAULT_HISTORY_MAX_COMMITS : 0), + ); + const sinceWidened = storedHistorySince !== undefined && Date.parse(historySince) < Date.parse(storedHistorySince); + const maxWidened = storedHistoryMax !== undefined && historyMaxCommits > storedHistoryMax; + const establishingWindow = storedHistorySince === undefined || storedHistoryMax === undefined; + const replayWindow = rebuildHistoryEdges || !savedCheckpoint || establishingWindow || sinceWidened || maxWidened; + const fromCommit = sinceCommit !== undefined && !rebuildHistoryEdges + ? sinceCommit + : replayWindow ? undefined : savedCheckpoint; + const replaySince = rebuildHistoryEdges + ? storedEarliestIndexedDate ?? storedHistorySince ?? historySince + : historySince; + + await Promise.all([ + setMetadata(client, metadataPrefix('historySince'), historySince), + setMetadata(client, metadataPrefix('historyMaxCommits'), String(historyMaxCommits)), + ]); + + if (!hasHead) { + await Promise.all([ + setMetadata(client, metadataPrefix('historyComplete'), 'true'), + setMetadata(client, metadataPrefix('historyTruncated'), 'false'), + ]); + return { + commitsProcessed: 0, + edgesCreated: 0, + lastCommitHash: null, + totalCommits: 0, + historySince, + historyMaxCommits, + earliestIndexedCommitDate: storedEarliestIndexedDate ?? null, + historyWindowSize: historyMaxCommits, + historyTruncated: false, + historyComplete: true, + durationMs: Date.now() - startTime, + errors: [], + }; } - const previousHistoryComplete = fromCommit - ? await getMetadata(client, `historyComplete:${repoPath}`) - : undefined; - const previousHistoryTruncated = fromCommit - ? await getMetadata(client, `historyTruncated:${repoPath}`) - : undefined; + const commitsAvailable = fromCommit ? Number.parseInt((await git.raw(['rev-list', '--count', `${fromCommit}..HEAD`])).trim(), 10) - : totalCommits; - - // Build git log options - const logOptions: Parameters[0] = { - maxCount: maxCommits, - '--name-only': null, - }; - - if (fromCommit) { - logOptions.from = fromCommit; - logOptions.to = 'HEAD'; - } + : Number.parseInt((await git.raw(['rev-list', '--count', `--since=${replaySince}`, 'HEAD'])).trim(), 10); + + // The safety ceiling applies only when first backfilling a range that + // has not been indexed. Incremental runs must drain the checkpoint + // range, and a forced full reindex must replay every commit from the + // earliest date that was actually indexed so recreated File nodes get + // all of their previous history edges back. + const logOptions: Parameters[0] = fromCommit + ? { from: fromCommit, to: 'HEAD', '--name-only': null } + : rebuildHistoryEdges + ? { '--since': replaySince, '--name-only': null } + : { maxCount: historyMaxCommits, '--since': replaySince, '--name-only': null }; const log: LogResult = await git.log(logOptions); if (log.all.length === 0) { logger.info('No new commits to sync'); + const historyTruncated = fromCommit + ? previousHistoryTruncated === 'true' + : totalCommits > 0; + const historyComplete = fromCommit + ? previousHistoryComplete === 'true' + : totalCommits === 0; + await Promise.all([ + setMetadata(client, metadataPrefix('historyComplete'), String(historyComplete)), + setMetadata(client, metadataPrefix('historyTruncated'), String(historyTruncated)), + ]); return { commitsProcessed: 0, edgesCreated: 0, lastCommitHash: fromCommit ?? null, totalCommits, - historyWindowSize: maxCommits, - historyTruncated: previousHistoryTruncated === 'true', - historyComplete: fromCommit ? previousHistoryComplete === 'true' : commitsAvailable === 0, + historySince, + historyMaxCommits, + earliestIndexedCommitDate: storedEarliestIndexedDate ?? null, + historyWindowSize: historyMaxCommits, + historyTruncated, + historyComplete, durationMs: Date.now() - startTime, errors: [], }; @@ -259,7 +375,8 @@ export async function syncGitHistory( let commitsProcessed = 0; let edgesCreated = 0; - const newestCommitHash = log.all[0]?.hash ?? null; + let newestProcessedHash: string | null = null; + let earliestProcessedDate: string | null = null; // Process oldest first for proper ordering const commits = [...log.all].reverse(); @@ -276,6 +393,10 @@ export async function syncGitHistory( await ops.upsertCommit(commitEntity); commitsProcessed++; + newestProcessedHash = commit.hash; + earliestProcessedDate = earliestProcessedDate === null + ? commit.date + : earlierIso(earliestProcessedDate, commit.date); // Get files changed in this commit with status (A=added, M=modified, D=deleted). // diffBase is the commit's parent, or the empty tree for a root commit @@ -369,29 +490,44 @@ export async function syncGitHistory( const errorMsg = `Error processing commit ${commit.hash}: ${commitError}`; logger.warn(errorMsg); errors.push(errorMsg); + break; } } - // Track last synced commit for incremental sync - if (newestCommitHash) { - await setMetadata(client, `lastCommitSynced:${repoPath}`, newestCommitHash); + // The checkpoint advances only through the contiguous successfully + // processed prefix. A failed commit stops the loop above. + if (newestProcessedHash) { + await setMetadata(client, metadataPrefix('lastCommitSynced'), newestProcessedHash); + } + const earliestIndexedCommitDate = earliestProcessedDate === null + ? storedEarliestIndexedDate ?? null + : storedEarliestIndexedDate + ? earlierIso(storedEarliestIndexedDate, earliestProcessedDate) + : earliestProcessedDate; + if (earliestIndexedCommitDate) { + await setMetadata(client, metadataPrefix('historyEarliestIndexedDate'), earliestIndexedCommitDate); } const coveredAvailableCommits = commitsProcessed === commitsAvailable && errors.length === 0; - const historyTruncated = previousHistoryTruncated === 'true' || commitsAvailable > maxCommits; + const historyTruncated = fromCommit + ? previousHistoryTruncated === 'true' + : totalCommits > commitsProcessed; const historyComplete = fromCommit ? previousHistoryComplete === 'true' && coveredAvailableCommits - : coveredAvailableCommits; - await setMetadata(client, `historyComplete:${repoPath}`, String(historyComplete)); - await setMetadata(client, `historyTruncated:${repoPath}`, String(historyTruncated)); + : totalCommits === commitsProcessed && errors.length === 0; + await setMetadata(client, metadataPrefix('historyComplete'), String(historyComplete)); + await setMetadata(client, metadataPrefix('historyTruncated'), String(historyTruncated)); logger.info(`Git sync complete: ${commitsProcessed} commits, ${edgesCreated} edges`); return { commitsProcessed, edgesCreated, - lastCommitHash: newestCommitHash, + lastCommitHash: newestProcessedHash, totalCommits, - historyWindowSize: maxCommits, + historySince, + historyMaxCommits, + earliestIndexedCommitDate, + historyWindowSize: historyMaxCommits, historyTruncated, historyComplete, durationMs: Date.now() - startTime, @@ -406,7 +542,10 @@ export async function syncGitHistory( edgesCreated: 0, lastCommitHash: null, totalCommits: null, - historyWindowSize: options.maxCommits ?? 100, + historySince: options.historySince ?? defaultHistorySince, + historyMaxCommits: options.historyMaxCommits ?? DEFAULT_HISTORY_MAX_COMMITS, + earliestIndexedCommitDate: null, + historyWindowSize: options.historyMaxCommits ?? DEFAULT_HISTORY_MAX_COMMITS, historyTruncated: false, historyComplete: false, durationMs: Date.now() - startTime, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cd7665c1..909a46e6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -32,7 +32,7 @@ export type { MCPContextConfig, ProjectInfo } from './config'; // Indexer export { indexProject, indexSingleFile, isProjectIndexed, getIndexProgressState } from './indexer'; -export type { IndexStats, IndexResult, IndexProgressPhase, IndexProgressState } from './indexer'; +export type { IndexStats, IndexResult, IndexProgressPhase, IndexProgressState, IndexProjectOptions } from './indexer'; // Embedding pass (generates + stores embeddings during indexing pipeline) export { embedParsedEntities, embedAllParsedEntities } from './embed-pass'; diff --git a/packages/core/src/indexer.ts b/packages/core/src/indexer.ts index 6db3a101..9186b944 100644 --- a/packages/core/src/indexer.ts +++ b/packages/core/src/indexer.ts @@ -37,7 +37,7 @@ import { import { extractReExports, extractLocalExportedNames, type ReExportEntity } from '@codegraph/plugin-typescript'; import { parseMarkdownContent } from '@codegraph/plugin-markdown'; import { createOperations, type GraphClient } from '@codegraph/graph'; -import type { ProjectEntity, ExtractedDocumentEntities } from '@codegraph/types'; +import type { ProjectEntity, ExtractedDocumentEntities, HistoryWindowOptions } from '@codegraph/types'; import { getEmbeddingProfile, type EmbeddingConfig } from '@codegraph/plugin-nlp'; import { getGraphClient } from './graphClient'; import { loadGitignorePatterns } from './watchService'; @@ -47,7 +47,7 @@ import { getEmbeddingPassState as getRemainingEmbeddingPassState, scheduleEmbeddingPass as scheduleRemainingEmbeddingPass, } from './embed-pass'; -import { syncGitHistory } from './gitSync'; +import { syncGitHistory, validateHistoryWindowOptions } from './gitSync'; import { createLogger } from '@codegraph/logger'; import { stat, readFile } from 'node:fs/promises'; import { basename, extname, relative, resolve } from 'node:path'; @@ -201,6 +201,31 @@ export interface IndexResult { errorMessages: string[]; } +export interface IndexProjectOptions extends HistoryWindowOptions { + /** Re-parse all files even if hashes match (default: false) */ + force?: boolean; + /** Enable deep analysis for call/render edges (default: true) */ + deepAnalysis?: boolean; + /** Include external library references (default: false) */ + includeExternals?: boolean; + /** Additional ignore patterns (merged with DEFAULT_IGNORE_PATTERNS) */ + ignorePatterns?: string[]; + /** Custom include patterns (overrides supported-extension globs) */ + includePatterns?: string[]; + /** Use this client instead of the shared singleton */ + client?: GraphClient; + /** Embedding configuration. Set to false to disable embedding generation. */ + embeddings?: EmbeddingConfig | false; + /** Number of files to process in parallel. */ + concurrency?: number; + /** Run embedding pass in background without blocking index return. */ + deferEmbeddings?: boolean; + /** Sync git commit history into the graph. */ + gitSync?: boolean; + /** Receives durable setup phases and item counts. */ + onProgress?: (progress: IndexProgressState) => void; +} + export type IndexProgressPhase = | 'storage' | 'discovering' @@ -474,32 +499,19 @@ export async function buildBarrelResolutionIndexes( */ export async function indexProject( rootPath: string, - options: { - /** Re-parse all files even if hashes match (default: false) */ - force?: boolean; - /** Enable deep analysis for call/render edges (default: true) */ - deepAnalysis?: boolean; - /** Include external library references (default: false) */ - includeExternals?: boolean; - /** Additional ignore patterns (merged with DEFAULT_IGNORE_PATTERNS) */ - ignorePatterns?: string[]; - /** Custom include patterns (overrides SUPPORTED_EXTENSIONS-based globs) */ - includePatterns?: string[]; - /** Use this client instead of the shared singleton */ - client?: GraphClient; - /** Embedding configuration. Set to false to disable embedding generation. */ - embeddings?: EmbeddingConfig | false; - /** Number of files to process in parallel (default: 20) */ - concurrency?: number; - /** Run embedding pass in background without blocking index return (default: false) */ - deferEmbeddings?: boolean; - /** Sync git commit history into the graph (default: true). Set false for fixtures inside an unrelated repo. */ - gitSync?: boolean; - /** Receives durable setup phases and item counts for UI polling or direct observation. */ - onProgress?: (progress: IndexProgressState) => void; - } = {}, + options: IndexProjectOptions = {}, ): Promise { const startTime = Date.now(); + const historyValidationError = validateHistoryWindowOptions(options); + if (historyValidationError) { + return { + success: false, + projectId: '', + projectName: basename(rootPath), + stats: { files: 0, entities: 0, edges: 0, errors: 1, durationMs: Date.now() - startTime }, + errorMessages: [historyValidationError], + }; + } const progressId = randomUUID(); const progressStartedAt = new Date().toISOString(); const reportProgress = ( @@ -964,9 +976,21 @@ export async function indexProject( let commitsProcessed = 0; let gitEdges = 0; if (options.gitSync !== false) try { + const requestedSince = options.historySince; + const existingSince = existingProject?.gitHistorySince; + const historySince = requestedSince && existingSince + ? Date.parse(requestedSince) < Date.parse(existingSince) ? requestedSince : existingSince + : requestedSince ?? existingSince; + const requestedMax = options.historyMaxCommits; + const existingMax = existingProject?.gitHistoryMaxCommits ?? existingProject?.gitHistoryWindowSize; + const historyMaxCommits = requestedMax !== undefined && existingMax !== undefined + ? Math.max(requestedMax, existingMax) + : requestedMax ?? existingMax; const gitResult = await syncGitHistory(rootPath, graphClient, { - maxCommits: 200, + ...(historySince !== undefined && { historySince }), + ...(historyMaxCommits !== undefined && { historyMaxCommits }), includeStats: true, + rebuildHistoryEdges: force && existingProject !== null, }); commitsProcessed = gitResult.commitsProcessed; gitEdges = gitResult.edgesCreated; @@ -974,6 +998,8 @@ export async function indexProject( project.gitHistoryTotalCommits = gitResult.totalCommits; } project.gitHistoryWindowSize = gitResult.historyWindowSize; + project.gitHistorySince = gitResult.historySince; + project.gitHistoryMaxCommits = gitResult.historyMaxCommits; project.gitHistoryTruncated = gitResult.historyTruncated; project.gitHistoryComplete = gitResult.historyComplete; if (commitsProcessed > 0) { diff --git a/packages/core/src/service.ts b/packages/core/src/service.ts index 490586c4..7d2f7f16 100644 --- a/packages/core/src/service.ts +++ b/packages/core/src/service.ts @@ -1,5 +1,5 @@ /** - * CodeGraphService — Thin Facade + * CodeGraphService: Thin Facade * * Delegates to: * - SearchService: search (enrichedSearchV2) @@ -51,6 +51,7 @@ import { getUnreferencedExportsImpl, getHotspotsImpl, getChangeCouplingImpl, + getOwnershipImpl, } from './services/analysis-service'; // Import types needed for method signatures @@ -82,11 +83,13 @@ import type { HotspotsResult, ChangeCouplingInput, ChangeCouplingResult, + OwnershipInput, + OwnershipResult, } from '@codegraph/graph'; import type { ProjectEntity } from '@codegraph/types'; // ============================================================================ -// CodeGraphService — Thin Facade +// CodeGraphService: Thin Facade // ============================================================================ class CodeGraphServiceImpl { @@ -149,6 +152,10 @@ class CodeGraphServiceImpl { return getChangeCouplingImpl(input); } + async getOwnership(input: OwnershipInput): Promise { + return getOwnershipImpl(input); + } + // --- Context Building --- async buildFileTree(options?: FileTreeOptions): Promise { diff --git a/packages/core/src/services/analysis-service.ts b/packages/core/src/services/analysis-service.ts index 917bdb17..388e6aa1 100644 --- a/packages/core/src/services/analysis-service.ts +++ b/packages/core/src/services/analysis-service.ts @@ -12,6 +12,8 @@ import type { HotspotsResult, ChangeCouplingInput, ChangeCouplingResult, + OwnershipInput, + OwnershipResult, } from '@codegraph/graph'; import { getGraphClient } from '../graphClient'; @@ -42,3 +44,7 @@ export async function getChangeCouplingImpl( ): Promise { return createAnalysisQueries(await getGraphClient()).getChangeCoupling(input); } + +export async function getOwnershipImpl(input: OwnershipInput): Promise { + return createAnalysisQueries(await getGraphClient()).getOwnership(input); +} diff --git a/packages/graph/src/__tests__/analysis-queries.integration.test.ts b/packages/graph/src/__tests__/analysis-queries.integration.test.ts index 625bcc17..f40e9021 100644 --- a/packages/graph/src/__tests__/analysis-queries.integration.test.ts +++ b/packages/graph/src/__tests__/analysis-queries.integration.test.ts @@ -21,7 +21,7 @@ describeIfAvailable('analysis queries with FalkorDBLite', () => { queries = createAnalysisQueries(client); await client.query(` - CREATE (project:Project {id: 'project-repo', rootPath: '/repo', name: 'repo', gitHistoryTotalCommits: 3, gitHistoryWindowSize: 200, gitHistoryTruncated: false, gitHistoryComplete: true}) + CREATE (project:Project {id: 'project-repo', rootPath: '/repo', name: 'repo', gitHistoryTotalCommits: 3, gitHistorySince: '2024-01-01T00:00:00.000Z', gitHistoryMaxCommits: 200, gitHistoryWindowSize: 200, gitHistoryTruncated: false, gitHistoryComplete: true}) CREATE (siblingProject:Project {id: 'project-sibling', rootPath: '/repo2', name: 'repo2'}) CREATE (targetFile:File {id: 'file-target', filePath: '/repo/target.ts', name: 'target.ts'}) @@ -167,6 +167,8 @@ describeIfAvailable('analysis queries with FalkorDBLite', () => { earliestCommitDate: '2025-01-01T00:00:00Z', latestCommitDate: '2025-01-03T00:00:00Z', totalCommitCount: 3, + historySince: '2024-01-01T00:00:00.000Z', + historyMaxCommits: 200, historyWindowSize: 200, historyTruncated: false, historyComplete: true, diff --git a/packages/graph/src/__tests__/analysis-queries.test.ts b/packages/graph/src/__tests__/analysis-queries.test.ts index bc8f360d..481299cc 100644 --- a/packages/graph/src/__tests__/analysis-queries.test.ts +++ b/packages/graph/src/__tests__/analysis-queries.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { GraphClient } from '../client'; -import { createAnalysisQueries } from '../analysis-queries'; +import { AnalysisQueryInputError, createAnalysisQueries } from '../analysis-queries'; const dialect = { driverType: 'falkordb', @@ -235,6 +235,8 @@ describe('analysis queries', () => { earliestCommitDate: '2025-01-02T00:00:00Z', latestCommitDate: '2025-02-01T00:00:00Z', totalCommitCount: 3, + historySince: '2024-01-01T00:00:00.000Z', + historyMaxCommits: 200, historyWindowSize: 200, historyTruncated: false, historyComplete: true, @@ -246,7 +248,7 @@ describe('analysis queries', () => { rootPath: '/repo/', since: '2025-01-01T00:00:00-05:00', scoreBy: 'degree', - limit: 0, + limit: 1, }); expect(client.roQuery).toHaveBeenCalledTimes(2); @@ -272,12 +274,53 @@ describe('analysis queries', () => { earliestCommitDate: '2025-01-02T00:00:00Z', latestCommitDate: '2025-02-01T00:00:00Z', totalCommitCount: 3, + historySince: '2024-01-01T00:00:00.000Z', + historyMaxCommits: 200, historyWindowSize: 200, historyTruncated: false, historyComplete: true, }); expect(result.caveats.some((caveat) => caveat.includes('200'))).toBe(false); }); + + it.each([0, 501, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( + 'rejects invalid limit %s before graph access', + async (limit) => { + const client = mockClient([]); + + await expect(createAnalysisQueries(client).getHotspots({ + rootPath: '/repo', + limit, + })).rejects.toBeInstanceOf(AnalysisQueryInputError); + expect(client.roQuery).not.toHaveBeenCalled(); + }, + ); + + it.each([ + '2026-02-30T00:00:00Z', + '2026-04-31T12:00:00Z', + '2025-02-29T00:00:00Z', + '2026-1-1', + ])('rejects invalid since %s before graph access', async (since) => { + const client = mockClient([]); + + await expect(createAnalysisQueries(client).getHotspots({ + rootPath: '/repo', + since, + })).rejects.toBeInstanceOf(AnalysisQueryInputError); + expect(client.roQuery).not.toHaveBeenCalled(); + }); + + it('accepts a valid leap-day timestamp', async () => { + const client = mockClient([]); + + const result = await createAnalysisQueries(client).getHotspots({ + rootPath: '/repo', + since: '2024-02-29T00:00:00Z', + }); + + expect(result.input.since).toBe('2024-02-29T00:00:00.000Z'); + }); }); describe('getChangeCoupling', () => { @@ -304,6 +347,8 @@ describe('analysis queries', () => { earliestCommitDate: '2025-01-01T00:00:00Z', latestCommitDate: '2025-07-19T00:00:00Z', totalCommitCount: 240, + historySince: '2025-01-01T00:00:00.000Z', + historyMaxCommits: 200, historyWindowSize: 200, historyTruncated: true, historyComplete: false, @@ -314,7 +359,7 @@ describe('analysis queries', () => { const result = await createAnalysisQueries(client).getChangeCoupling({ rootPath: '/repo', minSupport: 500, - limit: 0, + limit: 1, }); expect(client.roQuery).toHaveBeenCalledTimes(3); @@ -345,6 +390,8 @@ describe('analysis queries', () => { earliestCommitDate: '2025-01-01T00:00:00Z', latestCommitDate: '2025-07-19T00:00:00Z', totalCommitCount: 240, + historySince: '2025-01-01T00:00:00.000Z', + historyMaxCommits: 200, historyWindowSize: 200, historyTruncated: true, historyComplete: false, @@ -352,5 +399,59 @@ describe('analysis queries', () => { expect(result.caveats.join(' ')).toContain('correlation'); expect(result.caveats.join(' ')).toContain('200'); }); + + it.each([0, 501, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( + 'rejects invalid limit %s before graph access', + async (limit) => { + const client = mockClient([]); + + await expect(createAnalysisQueries(client).getChangeCoupling({ + rootPath: '/repo', + limit, + })).rejects.toBeInstanceOf(AnalysisQueryInputError); + expect(client.roQuery).not.toHaveBeenCalled(); + }, + ); + }); + + describe('getOwnership', () => { + it('delegates to the ownership query and preserves the frozen result contract', async () => { + const client = mockClient([]); + vi.mocked(client.roQuery) + .mockResolvedValueOnce({ data: [], metadata: [] }) + .mockResolvedValueOnce({ + data: [{ + commitCount: 0, + earliestCommitDate: null, + latestCommitDate: null, + totalCommitCount: null, + historySince: null, + historyMaxCommits: null, + historyWindowSize: null, + historyTruncated: false, + historyComplete: false, + unknownIdentityCommitCount: 0, + }], + metadata: [], + }); + + const result = await createAnalysisQueries(client).getOwnership({ + rootPath: '/repo', + pathPrefix: 'src', + }); + + expect(result.input).toEqual({ + rootPath: '/repo', + since: null, + pathPrefix: '/repo/src', + limit: 50, + }); + expect(result.items).toEqual([]); + expect(result.historyCoverage).toMatchObject({ + historySince: null, + historyMaxCommits: null, + historyWindowSize: null, + }); + }); }); }); diff --git a/packages/graph/src/__tests__/ownership-queries.integration.test.ts b/packages/graph/src/__tests__/ownership-queries.integration.test.ts new file mode 100644 index 00000000..7d2e8852 --- /dev/null +++ b/packages/graph/src/__tests__/ownership-queries.integration.test.ts @@ -0,0 +1,87 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { createClient, type GraphClient } from '../client'; +import { resolveEmbeddedBinaryPaths } from '../drivers/falkordblite'; +import { createOwnershipQuery } from '../ownership-queries'; + +const describeIfAvailable = resolveEmbeddedBinaryPaths() ? describe : describe.skip; + +describeIfAvailable('ownership query with FalkorDBLite', () => { + let client: GraphClient; + let dataDir: string; + + beforeAll(async () => { + dataDir = await mkdtemp('/tmp/cg-ownership-'); + client = await createClient({ + driver: 'falkordblite', + databasePath: dataDir, + graphName: 'ownership_integration', + }); + + await client.query(` + CREATE (project:Project { + id: 'project-repo', rootPath: '/repo', name: 'repo', + gitHistoryTotalCommits: 5, gitHistorySince: '2024-01-01T00:00:00.000Z', + gitHistoryMaxCommits: 200, gitHistoryWindowSize: 200, + gitHistoryTruncated: false, gitHistoryComplete: true + }) + CREATE (a:File {id: 'file-a', filePath: '/repo/src/a.ts', name: 'a.ts'}) + CREATE (b:File {id: 'file-b', filePath: '/repo/src/b.ts', name: 'b.ts'}) + CREATE (outside:File {id: 'file-outside', filePath: '/repo/test/outside.ts', name: 'outside.ts'}) + CREATE (project)-[:HAS_FILE]->(a) + CREATE (project)-[:HAS_FILE]->(b) + CREATE (project)-[:HAS_FILE]->(outside) + + CREATE (c1:Commit {hash: 'c1', date: '2025-01-01T00:00:00Z', author: 'Alex', email: 'alex@example.com'}) + CREATE (c2:Commit {hash: 'c2', date: '2025-01-02T00:00:00Z', author: 'Alex', email: 'alex@example.com'}) + CREATE (c3:Commit {hash: 'c3', date: '2025-01-03T00:00:00Z', author: 'Alex Alias', email: 'alex@example.com'}) + CREATE (c4:Commit {hash: 'c4', date: '2025-01-04T00:00:00Z', author: 'Bea', email: 'bea@example.com'}) + CREATE (c5:Commit {hash: 'c5', date: '2025-01-05T00:00:00Z'}) + CREATE (a)-[:MODIFIED_IN]->(c1) + CREATE (a)-[:MODIFIED_IN]->(c2) + CREATE (a)-[:MODIFIED_IN]->(c3) + CREATE (a)-[:MODIFIED_IN]->(c4) + CREATE (b)-[:MODIFIED_IN]->(c1) + CREATE (b)-[:MODIFIED_IN]->(c5) + CREATE (outside)-[:MODIFIED_IN]->(c5) + `, { params: {} }); + }, 60_000); + + afterAll(async () => { + await client?.close(); + if (dataDir) await rm(dataDir, { recursive: true, force: true }); + }, 30_000); + + it('groups exact author identities and computes percentages within a path prefix', async () => { + const result = await createOwnershipQuery(client)({ + rootPath: '/repo', + pathPrefix: 'src', + limit: 10, + }); + + expect(result.items.map((item) => [item.filePath, item.commitCount])).toEqual([ + ['/repo/src/a.ts', 4], + ['/repo/src/b.ts', 2], + ]); + expect(result.items[0]?.contributors).toEqual([ + { authorName: 'Alex', authorEmail: 'alex@example.com', commitCount: 2, sharePercentage: 50 }, + { authorName: 'Alex Alias', authorEmail: 'alex@example.com', commitCount: 1, sharePercentage: 25 }, + { authorName: 'Bea', authorEmail: 'bea@example.com', commitCount: 1, sharePercentage: 25 }, + ]); + expect(result.items[1]?.contributors).toEqual([ + { authorName: 'Alex', authorEmail: 'alex@example.com', commitCount: 1, sharePercentage: 50 }, + ]); + expect(result.unknownIdentityCommitCount).toBe(1); + expect(result.historyCoverage).toMatchObject({ + commitCount: 5, + earliestCommitDate: '2025-01-01T00:00:00Z', + latestCommitDate: '2025-01-05T00:00:00Z', + totalCommitCount: 5, + historySince: '2024-01-01T00:00:00.000Z', + historyMaxCommits: 200, + historyWindowSize: 200, + historyTruncated: false, + historyComplete: true, + }); + }); +}); diff --git a/packages/graph/src/__tests__/ownership-queries.test.ts b/packages/graph/src/__tests__/ownership-queries.test.ts new file mode 100644 index 00000000..38d5852e --- /dev/null +++ b/packages/graph/src/__tests__/ownership-queries.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { GraphClient } from '../client'; +import { AnalysisQueryInputError } from '../analysis-queries'; +import { createOwnershipQuery } from '../ownership-queries'; + +const dialect = { + driverType: 'falkordb', + labelsExpr: (alias: string): string => `labels(${alias})`, + firstLabelExpr: (alias: string): string => `labels(${alias})[0]`, + typeExpr: (alias: string): string => `type(${alias})`, + labelCheckExpr: (alias: string, label: string): string => `${alias}:${label}`, + labelCaseExpr: (alias: string, label: string): string => `${alias}:${label}`, + supportsOnCreateOnMatch: true, + normalizeNode: (raw: unknown) => ({ labels: [], properties: raw as Record }), + normalizeEdge: (raw: unknown) => ({ type: '', properties: raw as Record }), +}; + +function mockClient(): GraphClient { + return { + graph: null, + graphName: 'ownership-test', + dialect, + roQuery: vi.fn(), + query: vi.fn(), + ensureIndexes: vi.fn(), + close: vi.fn(), + } as unknown as GraphClient; +} + +describe('ownership query', () => { + it('normalizes scope and bounds the file candidate query with deterministic ordering', async () => { + const client = mockClient(); + vi.mocked(client.roQuery) + .mockResolvedValueOnce({ + data: [ + { filePath: '/repo/src/a.ts', commitCount: 4 }, + { filePath: '/repo/src/b.ts', commitCount: 2 }, + ], + metadata: [], + }) + .mockResolvedValueOnce({ data: [], metadata: [] }) + .mockResolvedValueOnce({ data: [], metadata: [] }) + .mockResolvedValueOnce({ + data: [{ + commitCount: 4, + earliestCommitDate: '2025-01-01T00:00:00Z', + latestCommitDate: '2025-01-04T00:00:00Z', + totalCommitCount: 9, + historySince: '2024-01-01T00:00:00.000Z', + historyMaxCommits: 200, + historyWindowSize: 200, + historyTruncated: true, + historyComplete: false, + unknownIdentityCommitCount: 0, + }], + metadata: [], + }); + + const result = await createOwnershipQuery(client)({ + rootPath: '/repo/', + since: '2025-01-01T00:00:00-05:00', + pathPrefix: 'src\\', + limit: 1, + }); + + const [fileCypher, fileOptions] = vi.mocked(client.roQuery).mock.calls[0]!; + expect(fileCypher).toContain('count(DISTINCT c) AS commitCount'); + expect(fileCypher).toContain('ORDER BY commitCount DESC, filePath ASC'); + expect(fileCypher).toContain('LIMIT $rowLimit'); + expect(fileOptions?.params).toEqual({ + rootPath: '/repo', + rootPathPrefix: '/repo/', + since: '2025-01-01T05:00:00.000Z', + pathPrefix: '/repo/src', + pathPrefixWithSeparator: '/repo/src/', + rowLimit: 2, + }); + expect(result.input).toEqual({ + rootPath: '/repo', + since: '2025-01-01T05:00:00.000Z', + pathPrefix: '/repo/src', + limit: 1, + }); + expect(result.items.map((item) => item.filePath)).toEqual(['/repo/src/a.ts']); + expect(result.truncated).toBe(true); + }); + + it.each([0, 501, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( + 'rejects invalid limit %s before graph access', + async (limit) => { + const client = mockClient(); + + await expect(createOwnershipQuery(client)({ + rootPath: '/repo', + limit, + })).rejects.toBeInstanceOf(AnalysisQueryInputError); + expect(client.roQuery).not.toHaveBeenCalled(); + }, + ); + + it.each([ + '2026-02-30T00:00:00Z', + '2026-04-31T12:00:00Z', + '2025-02-29T00:00:00Z', + '2026-1-1', + ])('rejects invalid since %s before graph access', async (since) => { + const client = mockClient(); + + await expect(createOwnershipQuery(client)({ + rootPath: '/repo', + since, + })).rejects.toBeInstanceOf(AnalysisQueryInputError); + expect(client.roQuery).not.toHaveBeenCalled(); + }); + + it('accepts a valid leap-day timestamp', async () => { + const client = mockClient(); + vi.mocked(client.roQuery) + .mockResolvedValueOnce({ data: [], metadata: [] }) + .mockResolvedValueOnce({ data: [], metadata: [] }); + + const result = await createOwnershipQuery(client)({ + rootPath: '/repo', + since: '2024-02-29T00:00:00Z', + }); + + expect(result.input.since).toBe('2024-02-29T00:00:00.000Z'); + }); + + it.each([ + '/absolute/path', + '../outside', + 'src/../outside', + 'C:\\outside', + '\\\\server\\share', + ])('rejects invalid project-relative pathPrefix %s before graph access', async (pathPrefix) => { + const client = mockClient(); + + await expect(createOwnershipQuery(client)({ + rootPath: '/repo', + pathPrefix, + })).rejects.toBeInstanceOf(AnalysisQueryInputError); + expect(client.roQuery).not.toHaveBeenCalled(); + }); + + it('ranks exact author pairs, rounds shares, truncates contributors, and counts unknown identities', async () => { + const client = mockClient(); + const contributorRows = [ + { authorName: 'Alex A', authorEmail: 'alex@example.com', commitCount: 3 }, + { authorName: 'Alex Alias', authorEmail: 'alex@example.com', commitCount: 2 }, + { authorName: 'Bea', authorEmail: 'bea@example.com', commitCount: 1 }, + { authorName: 'C1', authorEmail: 'c1@example.com', commitCount: 1 }, + { authorName: 'C2', authorEmail: 'c2@example.com', commitCount: 1 }, + { authorName: 'C3', authorEmail: 'c3@example.com', commitCount: 1 }, + { authorName: 'C4', authorEmail: 'c4@example.com', commitCount: 1 }, + { authorName: 'C5', authorEmail: 'c5@example.com', commitCount: 1 }, + { authorName: 'C6', authorEmail: 'c6@example.com', commitCount: 1 }, + { authorName: 'C7', authorEmail: 'c7@example.com', commitCount: 1 }, + { authorName: 'C8', authorEmail: 'c8@example.com', commitCount: 1 }, + ]; + vi.mocked(client.roQuery) + .mockResolvedValueOnce({ + data: [{ filePath: '/repo/a.ts', commitCount: 7 }], + metadata: [], + }) + .mockResolvedValueOnce({ data: contributorRows, metadata: [] }) + .mockResolvedValueOnce({ + data: [{ + commitCount: 7, + earliestCommitDate: '2025-01-01T00:00:00Z', + latestCommitDate: '2025-01-07T00:00:00Z', + totalCommitCount: 7, + historySince: null, + historyMaxCommits: null, + historyWindowSize: null, + historyTruncated: false, + historyComplete: true, + unknownIdentityCommitCount: 1, + }], + metadata: [], + }); + + const result = await createOwnershipQuery(client)({ rootPath: '/repo', limit: 50 }); + + const [contributorCypher, contributorOptions] = vi.mocked(client.roQuery).mock.calls[1]!; + expect(contributorCypher).toContain('c.email AS authorEmail'); + expect(contributorCypher).toContain('c.author AS authorName'); + expect(contributorCypher).toContain('ORDER BY commitCount DESC, authorEmail ASC, authorName ASC'); + expect(contributorOptions?.params).toMatchObject({ filePath: '/repo/a.ts', rowLimit: 11 }); + expect(result.items[0]?.contributors).toEqual([ + { authorName: 'Alex A', authorEmail: 'alex@example.com', commitCount: 3, sharePercentage: 42.86 }, + { authorName: 'Alex Alias', authorEmail: 'alex@example.com', commitCount: 2, sharePercentage: 28.57 }, + { authorName: 'Bea', authorEmail: 'bea@example.com', commitCount: 1, sharePercentage: 14.29 }, + ...contributorRows.slice(3, 10).map((row) => ({ ...row, sharePercentage: 14.29 })), + ]); + expect(result.items[0]?.contributorsTruncated).toBe(true); + expect(result.unknownIdentityCommitCount).toBe(1); + expect(result.caveats).toContain( + 'Some indexed commits have no usable author identity. Reindex history to backfill them.', + ); + }); + + it('maps complete coverage and omits the unknown-identity caveat when every identity is usable', async () => { + const client = mockClient(); + vi.mocked(client.roQuery) + .mockResolvedValueOnce({ data: [], metadata: [] }) + .mockResolvedValueOnce({ + data: [{ + commitCount: 0, + earliestCommitDate: null, + latestCommitDate: null, + totalCommitCount: 0, + historySince: null, + historyMaxCommits: 500, + historyWindowSize: 500, + historyTruncated: false, + historyComplete: true, + unknownIdentityCommitCount: 0, + }], + metadata: [], + }); + + const result = await createOwnershipQuery(client)({ rootPath: '/repo' }); + + expect(result.historyCoverage).toEqual({ + commitCount: 0, + earliestCommitDate: null, + latestCommitDate: null, + totalCommitCount: 0, + historySince: null, + historyMaxCommits: 500, + historyWindowSize: 500, + historyTruncated: false, + historyComplete: true, + }); + expect(result.caveats).toEqual([ + 'Ownership is inferred from authorship in indexed git history, not from CODEOWNERS, review activity, expertise, or current team assignment.', + 'Results cover only the indexed history and the requested filters. Indexed history includes the complete reachable branch history available at the last history sync.', + 'Bot and automation commits are included and can dominate rankings.', + "Renames and moves can split or undercount a file's history because history edges are attached to indexed File paths.", + 'Author aliases that remain after git mailmap are ranked separately.', + ]); + }); +}); diff --git a/packages/graph/src/analysis-queries.ts b/packages/graph/src/analysis-queries.ts index ba264115..58452279 100644 --- a/packages/graph/src/analysis-queries.ts +++ b/packages/graph/src/analysis-queries.ts @@ -1,5 +1,13 @@ import type { GraphClient } from './client'; +import type { HistoryCoverage, OwnershipInput, OwnershipResult } from '@codegraph/types'; import { resolve } from 'node:path'; +import { + AnalysisQueryInputError, + createOwnershipQuery, + normalizeAnalysisSince, +} from './ownership-queries'; + +export { AnalysisQueryInputError } from './ownership-queries'; const STATIC_ANALYSIS_CAVEATS = [ 'Results describe static graph relationships, not runtime behavior.', @@ -53,6 +61,7 @@ export interface AnalysisQueries { getUnreferencedExports(input: UnreferencedExportsInput): Promise; getHotspots(input: HotspotsInput): Promise; getChangeCoupling(input: ChangeCouplingInput): Promise; + getOwnership(input: OwnershipInput): Promise; } export interface ChangeCouplingInput { @@ -60,7 +69,7 @@ export interface ChangeCouplingInput { since?: string; /** Minimum shared commits. Defaults to 2 and is clamped to 1 through 200. */ minSupport?: number; - /** Maximum returned pairs. Defaults to 50 and is clamped to 1 through 500. */ + /** Maximum returned pairs. Defaults to 50 and must be an integer from 1 through 500. */ limit?: number; } @@ -91,32 +100,13 @@ export interface ChangeCouplingResult { caveats: string[]; } -export class AnalysisQueryInputError extends Error { - readonly code = 'INVALID_ANALYSIS_INPUT'; - - constructor(message: string) { - super(message); - this.name = 'AnalysisQueryInputError'; - } -} - -export interface HistoryCoverage { - commitCount: number; - earliestCommitDate: string | null; - latestCommitDate: string | null; - totalCommitCount: number | null; - historyWindowSize: number | null; - historyTruncated: boolean; - historyComplete: boolean; -} - export type HotspotScore = 'complexity' | 'degree'; export interface HotspotsInput { rootPath: string; since?: string; scoreBy?: HotspotScore; - /** Maximum returned files. Defaults to 50 and is clamped to 1 through 500. */ + /** Maximum returned files. Defaults to 50 and must be an integer from 1 through 500. */ limit?: number; } @@ -270,6 +260,14 @@ function boundedInteger(value: number | undefined, fallback: number, minimum: nu return Math.min(Math.max(Math.trunc(value), minimum), maximum); } +function strictLimit(value: number | undefined, fallback: number, maximum: number): number { + if (value === undefined) return fallback; + if (!Number.isSafeInteger(value) || value < 1 || value > maximum) { + throw new AnalysisQueryInputError(`limit must be an integer between 1 and ${maximum}`); + } + return value; +} + function normalizeRootPath(rootPath: string): string { return resolve(rootPath); } @@ -278,30 +276,26 @@ function rootPathPrefix(rootPath: string): string { return rootPath === '/' ? '/' : `${rootPath}/`; } -function normalizeSince(since: string | undefined): string | null { - if (since === undefined || since.trim() === '') return null; - const date = new Date(since); - if (Number.isNaN(date.getTime())) { - throw new AnalysisQueryInputError('since must be a valid ISO 8601 date'); - } - return date.toISOString(); -} - function historyCoverage(row: { commitCount?: number; earliestCommitDate?: string | null; latestCommitDate?: string | null; totalCommitCount?: number | null; + historySince?: string | null; + historyMaxCommits?: number | null; historyWindowSize?: number | null; historyTruncated?: boolean | null; historyComplete?: boolean | null; } | undefined): HistoryCoverage { + const historyMaxCommits = row?.historyMaxCommits ?? row?.historyWindowSize ?? null; return { commitCount: row?.commitCount ?? 0, earliestCommitDate: row?.earliestCommitDate ?? null, latestCommitDate: row?.latestCommitDate ?? null, totalCommitCount: row?.totalCommitCount ?? null, - historyWindowSize: row?.historyWindowSize ?? null, + historySince: row?.historySince ?? null, + historyMaxCommits, + historyWindowSize: historyMaxCommits, historyTruncated: row?.historyTruncated === true, historyComplete: row?.historyComplete === true, }; @@ -642,9 +636,9 @@ class AnalysisQueriesImpl implements AnalysisQueries { async getHotspots(input: HotspotsInput): Promise { const rootPath = normalizeRootPath(input.rootPath); - const since = normalizeSince(input.since); + const since = normalizeAnalysisSince(input.since); const scoreBy = input.scoreBy ?? 'complexity'; - const limit = boundedInteger(input.limit, 50, 1, 500); + const limit = strictLimit(input.limit, 50, 500); const params = { rootPath, rootPathPrefix: rootPathPrefix(rootPath), @@ -687,6 +681,8 @@ class AnalysisQueriesImpl implements AnalysisQueries { earliestCommitDate: string | null; latestCommitDate: string | null; totalCommitCount: number | null; + historySince: string | null; + historyMaxCommits: number | null; historyWindowSize: number | null; historyTruncated: boolean | null; historyComplete: boolean | null; @@ -699,7 +695,9 @@ class AnalysisQueriesImpl implements AnalysisQueries { min(c.date) AS earliestCommitDate, max(c.date) AS latestCommitDate, project.gitHistoryTotalCommits AS totalCommitCount, - project.gitHistoryWindowSize AS historyWindowSize, + project.gitHistorySince AS historySince, + coalesce(project.gitHistoryMaxCommits, project.gitHistoryWindowSize) AS historyMaxCommits, + coalesce(project.gitHistoryMaxCommits, project.gitHistoryWindowSize) AS historyWindowSize, project.gitHistoryTruncated AS historyTruncated, project.gitHistoryComplete AS historyComplete `, { params }), @@ -726,9 +724,9 @@ class AnalysisQueriesImpl implements AnalysisQueries { async getChangeCoupling(input: ChangeCouplingInput): Promise { const rootPath = normalizeRootPath(input.rootPath); - const since = normalizeSince(input.since); + const since = normalizeAnalysisSince(input.since); const minSupport = boundedInteger(input.minSupport, 2, 1, 200); - const limit = boundedInteger(input.limit, 50, 1, 500); + const limit = strictLimit(input.limit, 50, 500); const coverageParams = { rootPath, rootPathPrefix: rootPathPrefix(rootPath), @@ -790,6 +788,8 @@ class AnalysisQueriesImpl implements AnalysisQueries { earliestCommitDate: string | null; latestCommitDate: string | null; totalCommitCount: number | null; + historySince: string | null; + historyMaxCommits: number | null; historyWindowSize: number | null; historyTruncated: boolean | null; historyComplete: boolean | null; @@ -802,7 +802,9 @@ class AnalysisQueriesImpl implements AnalysisQueries { min(c.date) AS earliestCommitDate, max(c.date) AS latestCommitDate, project.gitHistoryTotalCommits AS totalCommitCount, - project.gitHistoryWindowSize AS historyWindowSize, + project.gitHistorySince AS historySince, + coalesce(project.gitHistoryMaxCommits, project.gitHistoryWindowSize) AS historyMaxCommits, + coalesce(project.gitHistoryMaxCommits, project.gitHistoryWindowSize) AS historyWindowSize, project.gitHistoryTruncated AS historyTruncated, project.gitHistoryComplete AS historyComplete `, { params: coverageParams }), @@ -826,6 +828,10 @@ class AnalysisQueriesImpl implements AnalysisQueries { ], }; } + + async getOwnership(input: OwnershipInput): Promise { + return createOwnershipQuery(this.client)(input); + } } export function createAnalysisQueries(client: GraphClient): AnalysisQueries { diff --git a/packages/graph/src/index.ts b/packages/graph/src/index.ts index ff6d40b0..8cbfe2be 100644 --- a/packages/graph/src/index.ts +++ b/packages/graph/src/index.ts @@ -1,6 +1,6 @@ /** * @codegraph/graph - * Graph database operations for CodeGraph — FalkorDB primary engine + * Graph database operations for CodeGraph: FalkorDB primary engine */ // Client exports @@ -26,10 +26,10 @@ export { type CypherDialect, } from './driver'; -// FalkorDB driver (primary — remote, Docker) +// FalkorDB driver (primary: remote, Docker) export { FalkorDBDriver, falkorDialect } from './drivers/falkordb'; -// FalkorDBLite driver (embedded — no Docker needed) +// FalkorDBLite driver (embedded: no Docker needed) export { FalkorDBLiteDriver, resolveEmbeddedBinaryPaths, @@ -85,7 +85,6 @@ export { type NormalizedUnreferencedExportsInput, type UnreferencedExportItem, type UnreferencedExportsResult, - type HistoryCoverage, type HotspotScore, type HotspotsInput, type NormalizedHotspotsInput, @@ -96,6 +95,7 @@ export { type ChangeCouplingItem, type ChangeCouplingResult, } from './analysis-queries'; +export { createOwnershipQuery, type OwnershipQuery } from './ownership-queries'; // Knowledge graph exports (NLC merger) export { @@ -169,4 +169,10 @@ export type { SubgraphData, GraphStats, SearchResult, + HistoryCoverage, + OwnershipInput, + NormalizedOwnershipInput, + OwnershipContributor, + FileOwnershipItem, + OwnershipResult, } from '@codegraph/types'; diff --git a/packages/graph/src/operations.ts b/packages/graph/src/operations.ts index de96d54d..d19a62b3 100644 --- a/packages/graph/src/operations.ts +++ b/packages/graph/src/operations.ts @@ -634,6 +634,8 @@ const CYPHER = { p.lastParsed = $lastParsed, p.fileCount = $fileCount, p.gitHistoryTotalCommits = $gitHistoryTotalCommits, + p.gitHistorySince = $gitHistorySince, + p.gitHistoryMaxCommits = $gitHistoryMaxCommits, p.gitHistoryWindowSize = $gitHistoryWindowSize, p.gitHistoryTruncated = $gitHistoryTruncated, p.gitHistoryComplete = $gitHistoryComplete, @@ -2655,6 +2657,8 @@ class GraphOperationsImpl implements GraphOperations { lastParsed: project.lastParsed, fileCount: project.fileCount ?? 0, gitHistoryTotalCommits: project.gitHistoryTotalCommits ?? null, + gitHistorySince: project.gitHistorySince ?? null, + gitHistoryMaxCommits: project.gitHistoryMaxCommits ?? null, gitHistoryWindowSize: project.gitHistoryWindowSize ?? null, gitHistoryTruncated: project.gitHistoryTruncated ?? null, gitHistoryComplete: project.gitHistoryComplete ?? null, @@ -2912,6 +2916,14 @@ class GraphOperationsImpl implements GraphOperations { if (typeof gitHistoryWindowSize === 'number') { entity.gitHistoryWindowSize = gitHistoryWindowSize; } + const gitHistorySince = properties['gitHistorySince']; + if (typeof gitHistorySince === 'string') { + entity.gitHistorySince = gitHistorySince; + } + const gitHistoryMaxCommits = properties['gitHistoryMaxCommits']; + if (typeof gitHistoryMaxCommits === 'number') { + entity.gitHistoryMaxCommits = gitHistoryMaxCommits; + } const gitHistoryComplete = properties['gitHistoryComplete']; const gitHistoryTruncated = properties['gitHistoryTruncated']; if (typeof gitHistoryTruncated === 'boolean') { diff --git a/packages/graph/src/ownership-queries.ts b/packages/graph/src/ownership-queries.ts new file mode 100644 index 00000000..7e40f947 --- /dev/null +++ b/packages/graph/src/ownership-queries.ts @@ -0,0 +1,318 @@ +import type { + FileOwnershipItem, + HistoryCoverage, + NormalizedOwnershipInput, + OwnershipInput, + OwnershipResult, +} from '@codegraph/types'; +import { isAbsolute, relative, resolve, win32 } from 'node:path'; +import type { GraphClient } from './client'; + +const CONTRIBUTOR_LIMIT = 10; +const CONTRIBUTOR_QUERY_CONCURRENCY = 5; +const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(?:Z|[+-]\d{2}:\d{2}))?$/; + +interface FileCandidateRow { + filePath: string; + commitCount: number; +} + +interface ContributorRow { + authorName: string; + authorEmail: string; + commitCount: number; +} + +interface OwnershipCoverageRow { + commitCount?: number; + earliestCommitDate?: string | null; + latestCommitDate?: string | null; + totalCommitCount?: number | null; + historySince?: string | null; + historyMaxCommits?: number | null; + historyWindowSize?: number | null; + historyTruncated?: boolean | null; + historyComplete?: boolean | null; + unknownIdentityCommitCount?: number; +} + +export class AnalysisQueryInputError extends Error { + readonly code = 'INVALID_ANALYSIS_INPUT'; + + constructor(message: string) { + super(message); + this.name = 'AnalysisQueryInputError'; + } +} + +function strictInteger( + value: number | undefined, + fallback: number, + minimum: number, + maximum: number, +): number { + if (value === undefined) return fallback; + if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw new AnalysisQueryInputError( + `limit must be an integer between ${minimum} and ${maximum}`, + ); + } + return value; +} + +function normalizeRootPath(rootPath: string): string { + return resolve(rootPath).replaceAll('\\', '/'); +} + +function pathWithSeparator(path: string): string { + return path === '/' ? '/' : `${path}/`; +} + +function hasExactCalendarFields(value: string): boolean { + const match = ISO_DATE_PATTERN.exec(value); + if (match === null) return false; + + const [, year, month, day, hour = '0', minute = '0', second = '0', fraction = '0'] = match; + const expected = { + year: Number(year), + month: Number(month), + day: Number(day), + hour: Number(hour), + minute: Number(minute), + second: Number(second), + millisecond: Number(fraction.padEnd(3, '0')), + }; + const reconstructed = new Date(0); + reconstructed.setUTCFullYear(expected.year, expected.month - 1, expected.day); + reconstructed.setUTCHours( + expected.hour, + expected.minute, + expected.second, + expected.millisecond, + ); + return reconstructed.getUTCFullYear() === expected.year + && reconstructed.getUTCMonth() === expected.month - 1 + && reconstructed.getUTCDate() === expected.day + && reconstructed.getUTCHours() === expected.hour + && reconstructed.getUTCMinutes() === expected.minute + && reconstructed.getUTCSeconds() === expected.second + && reconstructed.getUTCMilliseconds() === expected.millisecond; +} + +export function normalizeAnalysisSince(since: string | undefined): string | null { + if (since === undefined) return null; + if (typeof since !== 'string' || !hasExactCalendarFields(since)) { + throw new AnalysisQueryInputError('since must be a valid ISO 8601 date'); + } + const date = new Date(since); + if (Number.isNaN(date.getTime())) { + throw new AnalysisQueryInputError('since must be a valid ISO 8601 date'); + } + return date.toISOString(); +} + +export function normalizeOwnershipPathPrefix( + rootPath: string, + pathPrefix: string | undefined, +): string | null { + if (pathPrefix === undefined || pathPrefix === '') return null; + if (typeof pathPrefix !== 'string') { + throw new AnalysisQueryInputError('pathPrefix must be a string'); + } + + const normalizedSeparators = pathPrefix.trim().replaceAll('\\', '/'); + if (isAbsolute(normalizedSeparators) || win32.isAbsolute(pathPrefix)) { + throw new AnalysisQueryInputError('pathPrefix must be project-relative'); + } + if (normalizedSeparators.split('/').includes('..')) { + throw new AnalysisQueryInputError('pathPrefix must not contain .. traversal segments'); + } + + const normalizedRoot = normalizeRootPath(rootPath); + const resolvedPrefix = resolve(normalizedRoot, normalizedSeparators).replaceAll('\\', '/'); + const relativePrefix = relative(normalizedRoot, resolvedPrefix).replaceAll('\\', '/'); + if (relativePrefix === '..' || relativePrefix.startsWith('../') || isAbsolute(relativePrefix)) { + throw new AnalysisQueryInputError('pathPrefix must resolve within projectPath'); + } + return resolvedPrefix; +} + +function toHistoryCoverage(row: OwnershipCoverageRow | undefined): HistoryCoverage { + const historyMaxCommits = row?.historyMaxCommits ?? row?.historyWindowSize ?? null; + return { + commitCount: row?.commitCount ?? 0, + earliestCommitDate: row?.earliestCommitDate ?? null, + latestCommitDate: row?.latestCommitDate ?? null, + totalCommitCount: row?.totalCommitCount ?? null, + historySince: row?.historySince ?? null, + historyMaxCommits, + historyWindowSize: historyMaxCommits, + historyTruncated: row?.historyTruncated === true, + historyComplete: row?.historyComplete === true, + }; +} + +function observedRange(coverage: HistoryCoverage): string { + if (coverage.earliestCommitDate === null || coverage.latestCommitDate === null) { + return 'no matching commit dates were observed'; + } + return `observed matching commits span ${coverage.earliestCommitDate} through ${coverage.latestCommitDate}`; +} + +function coverageSentence(coverage: HistoryCoverage): string { + if (coverage.historyTruncated) { + const ceiling = coverage.historyMaxCommits === null + ? 'an unavailable commit ceiling' + : `at most ${coverage.historyMaxCommits} commits`; + return `Indexed history is truncated to ${ceiling}; ${observedRange(coverage)}.`; + } + if (coverage.historyComplete) { + return 'Indexed history includes the complete reachable branch history available at the last history sync.'; + } + return 'Indexed history completeness could not be verified.'; +} + +function ownershipCaveats( + coverage: HistoryCoverage, + unknownIdentityCommitCount: number, +): string[] { + return [ + 'Ownership is inferred from authorship in indexed git history, not from CODEOWNERS, review activity, expertise, or current team assignment.', + `Results cover only the indexed history and the requested filters. ${coverageSentence(coverage)}`, + 'Bot and automation commits are included and can dominate rankings.', + "Renames and moves can split or undercount a file's history because history edges are attached to indexed File paths.", + 'Author aliases that remain after git mailmap are ranked separately.', + ...(unknownIdentityCommitCount > 0 + ? ['Some indexed commits have no usable author identity. Reindex history to backfill them.'] + : []), + ]; +} + +async function mapWithConcurrency( + values: readonly T[], + concurrency: number, + mapper: (value: T) => Promise, +): Promise { + const results = new Array(values.length); + let nextIndex = 0; + + const worker = async (): Promise => { + while (nextIndex < values.length) { + const index = nextIndex; + nextIndex += 1; + const value = values[index]; + if (value !== undefined) results[index] = await mapper(value); + } + }; + + const workerCount = Math.min(concurrency, values.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + return results; +} + +export type OwnershipQuery = (input: OwnershipInput) => Promise; + +export function createOwnershipQuery(client: GraphClient): OwnershipQuery { + return async (input: OwnershipInput): Promise => { + const rootPath = normalizeRootPath(input.rootPath); + const since = normalizeAnalysisSince(input.since); + const pathPrefix = normalizeOwnershipPathPrefix(rootPath, input.pathPrefix); + const limit = strictInteger(input.limit, 50, 1, 500); + const normalizedInput: NormalizedOwnershipInput = { rootPath, since, pathPrefix, limit }; + const scopeParams = { + rootPath, + rootPathPrefix: pathWithSeparator(rootPath), + since, + pathPrefix, + pathPrefixWithSeparator: pathPrefix === null ? null : pathWithSeparator(pathPrefix), + }; + + const fileResult = await client.roQuery(` + MATCH (f:File)-[:MODIFIED_IN]->(c:Commit) + WHERE (f.filePath = $rootPath OR f.filePath STARTS WITH $rootPathPrefix) + AND ($pathPrefix IS NULL OR f.filePath = $pathPrefix OR f.filePath STARTS WITH $pathPrefixWithSeparator) + AND ($since IS NULL OR c.date >= $since) + WITH f.filePath AS filePath, count(DISTINCT c) AS commitCount + RETURN filePath, commitCount + ORDER BY commitCount DESC, filePath ASC + LIMIT $rowLimit + `, { params: { ...scopeParams, rowLimit: limit + 1 } }); + + const fileRows = fileResult.data ?? []; + const returnedFiles = fileRows.slice(0, limit); + const itemsPromise = mapWithConcurrency( + returnedFiles, + CONTRIBUTOR_QUERY_CONCURRENCY, + async (file): Promise => { + const contributorResult = await client.roQuery(` + MATCH (f:File {filePath: $filePath})-[:MODIFIED_IN]->(c:Commit) + WHERE ($since IS NULL OR c.date >= $since) + AND trim(coalesce(c.author, '')) <> '' + AND trim(coalesce(c.email, '')) <> '' + WITH c.email AS authorEmail, + c.author AS authorName, + count(DISTINCT c) AS commitCount + RETURN authorName, authorEmail, commitCount + ORDER BY commitCount DESC, authorEmail ASC, authorName ASC + LIMIT $rowLimit + `, { + params: { + filePath: file.filePath, + since, + rowLimit: CONTRIBUTOR_LIMIT + 1, + }, + }); + const contributorRows = contributorResult.data ?? []; + return { + filePath: file.filePath, + commitCount: file.commitCount, + contributors: contributorRows.slice(0, CONTRIBUTOR_LIMIT).map((contributor) => ({ + ...contributor, + sharePercentage: Math.round( + (contributor.commitCount / file.commitCount) * 10_000, + ) / 100, + })), + contributorsTruncated: contributorRows.length > CONTRIBUTOR_LIMIT, + }; + }, + ); + + const coveragePromise = client.roQuery(` + MATCH (project:Project {rootPath: $rootPath}) + OPTIONAL MATCH (f:File)-[:MODIFIED_IN]->(c:Commit) + WHERE (f.filePath = $rootPath OR f.filePath STARTS WITH $rootPathPrefix) + AND ($pathPrefix IS NULL OR f.filePath = $pathPrefix OR f.filePath STARTS WITH $pathPrefixWithSeparator) + AND ($since IS NULL OR c.date >= $since) + RETURN count(DISTINCT c) AS commitCount, + min(c.date) AS earliestCommitDate, + max(c.date) AS latestCommitDate, + project.gitHistoryTotalCommits AS totalCommitCount, + project.gitHistorySince AS historySince, + coalesce(project.gitHistoryMaxCommits, project.gitHistoryWindowSize) AS historyMaxCommits, + coalesce(project.gitHistoryMaxCommits, project.gitHistoryWindowSize) AS historyWindowSize, + project.gitHistoryTruncated AS historyTruncated, + project.gitHistoryComplete AS historyComplete, + count(DISTINCT CASE + WHEN c IS NOT NULL AND ( + trim(coalesce(c.author, '')) = '' OR trim(coalesce(c.email, '')) = '' + ) THEN c.hash + ELSE null + END) AS unknownIdentityCommitCount + `, { params: scopeParams }); + + const [items, coverageResult] = await Promise.all([itemsPromise, coveragePromise]); + const coverageRow = coverageResult.data?.[0]; + const historyCoverage = toHistoryCoverage(coverageRow); + const unknownIdentityCommitCount = coverageRow?.unknownIdentityCommitCount ?? 0; + + return { + input: normalizedInput, + projectRoot: rootPath, + items, + truncated: fileRows.length > limit, + unknownIdentityCommitCount, + historyCoverage, + caveats: ownershipCaveats(historyCoverage, unknownIdentityCommitCount), + }; + }; +} diff --git a/packages/mcp-server/src/__tests__/analyze.test.ts b/packages/mcp-server/src/__tests__/analyze.test.ts index be9d498f..1f0d3102 100644 --- a/packages/mcp-server/src/__tests__/analyze.test.ts +++ b/packages/mcp-server/src/__tests__/analyze.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ getUnreferencedExports: vi.fn(), getHotspots: vi.fn(), getChangeCoupling: vi.fn(), + getOwnership: vi.fn(), })); vi.mock('@codegraph/core', () => ({ @@ -30,6 +31,7 @@ describe('analyze persona', () => { mocks.getUnreferencedExports, mocks.getHotspots, mocks.getChangeCoupling, + mocks.getOwnership, ]) { method.mockResolvedValue({ caveats: ['Results describe static relationships.'], @@ -71,6 +73,12 @@ describe('analyze persona', () => { 'getChangeCoupling', { rootPath: '/repo/project', minSupport: 2, limit: 50 }, ], + [ + 'ownership', + { projectPath: '/repo/project' }, + 'getOwnership', + { rootPath: '/repo/project', limit: 50 }, + ], ] as const)( 'maps %s to %s with public defaults', async (action, input, methodName, expected) => { @@ -91,23 +99,26 @@ describe('analyze persona', () => { }); it('preserves truthful complete-history coverage and caveats', async () => { - mocks.getHotspots.mockResolvedValue({ + mocks.getOwnership.mockResolvedValue({ items: [], truncated: false, + unknownIdentityCommitCount: 0, historyCoverage: { commitCount: 1, earliestCommitDate: '2026-03-01T12:00:00Z', latestCommitDate: '2026-03-01T12:00:00Z', totalCommitCount: 1, + historySince: null, + historyMaxCommits: 200, historyWindowSize: 200, historyTruncated: false, historyComplete: true, }, - caveats: ['Scores use the complete branch history available at the last history sync.'], + caveats: ['Results cover only the indexed history and the requested filters. Indexed history includes the complete reachable branch history available at the last history sync.'], }); const result = await handleAnalyze({ - action: 'hotspots', + action: 'ownership', projectPath: '/repo/project', }) as Record; @@ -117,10 +128,56 @@ describe('analyze persona', () => { historyComplete: true, })); expect(result.caveats).toEqual([ - 'Scores use the complete branch history available at the last history sync.', + 'Results cover only the indexed history and the requested filters. Indexed history includes the complete reachable branch history available at the last history sync.', ]); }); + it('normalizes and forwards explicit ownership filters', async () => { + await handleAnalyze({ + action: 'ownership', + projectPath: '/repo/project/', + since: '2026-01-01', + pathPrefix: 'src\\features', + limit: 12, + }); + + expect(mocks.getOwnership).toHaveBeenCalledWith({ + rootPath: '/repo/project', + since: '2026-01-01', + pathPrefix: 'src/features', + limit: 12, + }); + }); + + it.each([ + '2026-02-30T00:00:00Z', + '2026-04-31T12:00:00Z', + '2025-02-29T00:00:00Z', + ])('rejects impossible ownership since %s before graph access', async (since) => { + const result = await handleAnalyze({ + action: 'ownership', + projectPath: '/repo/project', + since, + }); + + expect(result).toEqual({ error: 'since must be a valid ISO 8601 date or timestamp' }); + expect(mocks.getOwnership).not.toHaveBeenCalled(); + }); + + it('accepts a valid ownership leap-day timestamp', async () => { + await handleAnalyze({ + action: 'ownership', + projectPath: '/repo/project', + since: '2024-02-29T00:00:00Z', + }); + + expect(mocks.getOwnership).toHaveBeenCalledWith({ + rootPath: '/repo/project', + since: '2024-02-29T00:00:00Z', + limit: 50, + }); + }); + it.each([ [{ action: 'impact' }, 'id is required for impact action'], [{ action: 'impact', id: 'not-persisted' }, 'id must be a persisted sym:v1 identifier'], @@ -132,6 +189,9 @@ describe('analyze persona', () => { [{ action: 'change_coupling', projectPath: '/repo/project', minSupport: 201 }, 'minSupport must be an integer between 1 and 200'], [{ action: 'hotspots', projectPath: '/repo/project', limit: 501 }, 'limit must be an integer between 1 and 500'], [{ action: 'dead_code', projectPath: '/repo/project', limit: 1001 }, 'limit must be an integer between 1 and 1000'], + [{ action: 'ownership', projectPath: '/repo/project', pathPrefix: '/absolute' }, 'pathPrefix must be project-relative'], + [{ action: 'ownership', projectPath: '/repo/project', pathPrefix: 'src/../secret' }, 'pathPrefix must not contain .. traversal segments'], + [{ action: 'ownership', projectPath: '/repo/project', pathPrefix: 'src', limit: 501 }, 'limit must be an integer between 1 and 500'], ])('rejects invalid input before graph access', async (input, error) => { const result = await handleAnalyze(input); @@ -143,12 +203,13 @@ describe('analyze persona', () => { mocks.getUnreferencedExports, mocks.getHotspots, mocks.getChangeCoupling, + mocks.getOwnership, ]) { expect(method).not.toHaveBeenCalled(); } }); - it.each(['import_cycles', 'dead_code', 'hotspots', 'change_coupling'])( + it.each(['import_cycles', 'dead_code', 'hotspots', 'change_coupling', 'ownership'])( 'requires projectPath for %s', async (action) => { const result = await handleAnalyze({ action }); @@ -186,13 +247,18 @@ describe('analyze persona', () => { }); it('returns a stable unknown action error', async () => { - const result = await handleAnalyze({ action: 'ownership' }); + const result = await handleAnalyze({ action: 'missing' }); expect(result).toEqual({ - error: 'Unknown analyze action: ownership. Use: impact, import_cycles, call_hierarchy, dead_code, hotspots, change_coupling', + error: 'Unknown analyze action: missing. Use: impact, import_cycles, call_hierarchy, dead_code, hotspots, change_coupling, ownership', }); }); + it('declares ownership pathPrefix in the public schema', () => { + expect(analyzePersonaDefinition.inputSchema.properties).toHaveProperty('pathPrefix'); + expect(analyzePersonaDefinition.inputSchema.properties.action.enum).toContain('ownership'); + }); + it('declares every example key in the input schema', () => { const declaredProperties = new Set( Object.keys(analyzePersonaDefinition.inputSchema.properties), diff --git a/packages/mcp-server/src/__tests__/codebase.test.ts b/packages/mcp-server/src/__tests__/codebase.test.ts index efbab74e..114c33b0 100644 --- a/packages/mcp-server/src/__tests__/codebase.test.ts +++ b/packages/mcp-server/src/__tests__/codebase.test.ts @@ -19,10 +19,13 @@ vi.mock('@codegraph/core', () => ({ getGraphClient: vi.fn(), getSetupStatus: vi.fn(), readSourceFile: vi.fn(), + indexProject: vi.fn(), + indexSingleFile: vi.fn(), + getActiveProjectPaths: vi.fn(), })); -import { codeGraphService, getGraphClient, getSetupStatus } from '@codegraph/core'; -import { handleIndex } from '../personas/codebase'; +import { codeGraphService, getGraphClient, getSetupStatus, indexProject } from '@codegraph/core'; +import { handleIndex, indexPersonaDefinition } from '../personas/codebase'; /** * The true File node property set, as upserted by packages/graph/src/schema.ts @@ -46,6 +49,7 @@ const PHANTOM_FILE_PROPERTIES = ['f.path', 'f.language']; const mockGetGraphStats = vi.mocked(codeGraphService.getGraphStats); const mockGetGraphClient = vi.mocked(getGraphClient); const mockGetSetupStatus = vi.mocked(getSetupStatus); +const mockIndexProject = vi.mocked(indexProject); function makeRoQuery() { return vi.fn().mockImplementation(async (cypher: string) => { @@ -294,3 +298,78 @@ describe('codebase persona: profile action projectPath boundary safety', () => { expect(matchesFilter('/tmp/x/project')).toBe(true); }); }); + +describe('codebase persona: reindex history window', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIndexProject.mockResolvedValue({ + success: true, + projectId: 'project', + projectName: 'repo', + stats: { + files: 2, + entities: 3, + edges: 4, + errors: 0, + durationMs: 5, + commitsProcessed: 7, + gitEdges: 8, + embedded: 0, + }, + errorMessages: [], + }); + }); + + it('declares both history inputs in the public schema', () => { + expect(indexPersonaDefinition.inputSchema.properties).toMatchObject({ + historySince: { type: 'string' }, + historyMaxCommits: { type: 'number', minimum: 1, maximum: 100000 }, + }); + }); + + it('forwards history options once and uses indexProject git counts as authoritative', async () => { + const result = await handleIndex({ + action: 'reindex', + mode: 'full', + scope: '/tmp', + historySince: '2025-01-01T00:00:00Z', + historyMaxCommits: 2500, + }) as Record; + + expect(mockIndexProject).toHaveBeenCalledOnce(); + expect(mockIndexProject).toHaveBeenCalledWith('/tmp', expect.objectContaining({ + force: true, + historySince: '2025-01-01T00:00:00Z', + historyMaxCommits: 2500, + })); + expect(result['gitCommitsSynced']).toBe(7); + expect(result['gitEdgesCreated']).toBe(8); + }); + + it.each([ + [{ historySince: '2026-02-30' }, 'historySince must be a valid ISO 8601 date or timestamp'], + [{ historySince: '2026-02-30T00:00:00Z' }, 'historySince must be a valid ISO 8601 date or timestamp'], + [{ historySince: '2026-04-31T12:00:00Z' }, 'historySince must be a valid ISO 8601 date or timestamp'], + [{ historySince: '2025-02-29T00:00:00Z' }, 'historySince must be a valid ISO 8601 date or timestamp'], + [{ historyMaxCommits: 0 }, 'historyMaxCommits must be a safe integer between 1 and 100000'], + [{ historyMaxCommits: 100001 }, 'historyMaxCommits must be a safe integer between 1 and 100000'], + ])('rejects invalid history input before indexing', async (extra, error) => { + const result = await handleIndex({ action: 'reindex', scope: '/tmp', ...extra }) as Record; + + expect(result['error']).toBe(error); + expect(mockIndexProject).not.toHaveBeenCalled(); + }); + + it('accepts a valid leap-day history timestamp', async () => { + const result = await handleIndex({ + action: 'reindex', + scope: '/tmp', + historySince: '2024-02-29T00:00:00Z', + }) as Record; + + expect(result['error']).toBeUndefined(); + expect(mockIndexProject).toHaveBeenCalledWith('/tmp', expect.objectContaining({ + historySince: '2024-02-29T00:00:00Z', + })); + }); +}); diff --git a/packages/mcp-server/src/__tests__/consolidated.test.ts b/packages/mcp-server/src/__tests__/consolidated.test.ts index 3e9c0f63..6e57dff0 100644 --- a/packages/mcp-server/src/__tests__/consolidated.test.ts +++ b/packages/mcp-server/src/__tests__/consolidated.test.ts @@ -32,7 +32,7 @@ describe('public persona tools', () => { const actionSchema = tool.inputSchema.properties.action as { enum?: unknown[] } | undefined; return total + (actionSchema?.enum?.length ?? 1); }, 0); - expect(actionCount).toBe(24); + expect(actionCount).toBe(25); }); }); diff --git a/packages/mcp-server/src/__tests__/legacy.test.ts b/packages/mcp-server/src/__tests__/legacy.test.ts index 12b73876..8a9bdcbb 100644 --- a/packages/mcp-server/src/__tests__/legacy.test.ts +++ b/packages/mcp-server/src/__tests__/legacy.test.ts @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { handleToolCall } from '../tools/router'; import { registerPlugins } from '@codegraph/core'; -import { triggerReindex } from '../tools/reindex'; +import { reindexToolDefinition, triggerReindex } from '../tools/reindex'; import { teardownGraphClient, assertNoError } from './helpers'; let fixtureDirectory: string; @@ -92,6 +92,48 @@ describe('query (Cypher)', () => { // ─── trigger_reindex ───────────────────────────────────────────────────────── describe('trigger_reindex', () => { + it('exposes and validates the persisted history window inputs', async () => { + expect(reindexToolDefinition.inputSchema.properties).toHaveProperty('historySince'); + expect(reindexToolDefinition.inputSchema.properties).toHaveProperty('historyMaxCommits'); + + const result = await triggerReindex({ + mode: 'incremental', + scope: fixtureDirectory, + historySince: '2026-02-30', + historyMaxCommits: 0, + }); + + expect(result.success).toBe(false); + expect(result.errors).toEqual(expect.arrayContaining([ + expect.stringContaining('historySince'), + ])); + }); + + it.each([ + '2026-02-30T00:00:00Z', + '2026-04-31T12:00:00Z', + '2025-02-29T00:00:00Z', + ])('rejects impossible raw history timestamp %s before indexing', async (historySince) => { + const result = await triggerReindex({ + mode: 'incremental', + scope: fixtureDirectory, + historySince, + }); + + expect(result.success).toBe(false); + expect(result.errors).toEqual(['historySince must be a valid ISO 8601 date or timestamp']); + }); + + it('accepts a valid leap-day raw history timestamp', async () => { + const result = await triggerReindex({ + mode: 'incremental', + scope: fixtureDirectory, + historySince: '2024-02-29T00:00:00Z', + }); + + expect(result.success).toBe(true); + }); + it('returns error for non-existent scope path', async () => { const result = (await handleToolCall('trigger_reindex', { mode: 'incremental', diff --git a/packages/mcp-server/src/personas/analyze.ts b/packages/mcp-server/src/personas/analyze.ts index d99ce5d7..59e94a6d 100644 --- a/packages/mcp-server/src/personas/analyze.ts +++ b/packages/mcp-server/src/personas/analyze.ts @@ -1,12 +1,12 @@ import { codeGraphService } from '@codegraph/core'; import { createLogger } from '@codegraph/logger'; -import { isAbsolute } from 'node:path'; +import { isAbsolute, relative, resolve, win32 } from 'node:path'; import type { ToolDefinition } from '../tools/router'; import { validateFilePath } from './validation'; const logger = createLogger({ namespace: 'MCP:Persona:Analyze' }); const SYMBOL_ID_PATTERN = /^sym:v1:[a-f0-9]{64}$/; -const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2}))?$/; +const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(?:Z|[+-]\d{2}:\d{2}))?$/; const ACTIONS = [ 'impact', 'import_cycles', @@ -14,6 +14,7 @@ const ACTIONS = [ 'dead_code', 'hotspots', 'change_coupling', + 'ownership', ] as const; type AnalyzeAction = typeof ACTIONS[number]; @@ -34,6 +35,8 @@ export const analyzePersonaDefinition: ToolDefinition = { Params: projectPath (required, absolute), since, scoreBy, limit - **change_coupling**: Find file pairs that changed together within indexed history. Params: projectPath (required, absolute), since, minSupport, limit +- **ownership**: Rank per-file authorship contributors from indexed git history. + Params: projectPath (required, absolute), since, pathPrefix, limit Every response includes display-ready caveats and truncation metadata from the analysis layer. Git-backed actions also include historyCoverage. Static results do not prove runtime behavior, and git-backed results cover indexed history only. @@ -43,7 +46,8 @@ Every response includes display-ready caveats and truncation metadata from the a - Call hierarchy: { action: "call_hierarchy", id: "sym:v1:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", direction: "both", limit: 100 } - Unreferenced exports: { action: "dead_code", projectPath: "/workspace/project", limit: 100 } - Hotspots: { action: "hotspots", projectPath: "/workspace/project", since: "2026-01-01", scoreBy: "complexity", limit: 100 } -- Change coupling: { action: "change_coupling", projectPath: "/workspace/project", since: "2026-01-01", minSupport: 2, limit: 100 }`, +- Change coupling: { action: "change_coupling", projectPath: "/workspace/project", since: "2026-01-01", minSupport: 2, limit: 100 } +- Ownership: { action: "ownership", projectPath: "/workspace/project", since: "2026-01-01", pathPrefix: "src", limit: 50 }`, inputSchema: { type: 'object', properties: { @@ -77,6 +81,10 @@ Every response includes display-ready caveats and truncation metadata from the a type: 'string', description: 'Optional ISO 8601 date or timestamp for git-backed actions', }, + pathPrefix: { + type: 'string', + description: 'Optional project-relative file path prefix for ownership analysis', + }, scoreBy: { type: 'string', enum: ['complexity', 'degree'], @@ -88,7 +96,7 @@ Every response includes display-ready caveats and truncation metadata from the a }, limit: { type: 'number', - description: 'Maximum results. Import cycles, hotspots, and change coupling allow 1 through 500 with default 50. Other actions allow 1 through 1000 with default 100.', + description: 'Maximum results. Import cycles, hotspots, change coupling, and ownership allow 1 through 500 with default 50. Other actions allow 1 through 1000 with default 100.', }, }, required: ['action'], @@ -142,16 +150,43 @@ function enumValue( return { valid: true, value: raw as T }; } +function hasExactCalendarFields(raw: string): boolean { + const match = ISO_DATE_PATTERN.exec(raw); + if (match === null) return false; + const [, year, month, day, hour = '0', minute = '0', second = '0', fraction = '0'] = match; + const expected = { + year: Number(year), + month: Number(month), + day: Number(day), + hour: Number(hour), + minute: Number(minute), + second: Number(second), + millisecond: Number(fraction.padEnd(3, '0')), + }; + const reconstructed = new Date(0); + reconstructed.setUTCFullYear(expected.year, expected.month - 1, expected.day); + reconstructed.setUTCHours( + expected.hour, + expected.minute, + expected.second, + expected.millisecond, + ); + return reconstructed.getUTCFullYear() === expected.year + && reconstructed.getUTCMonth() === expected.month - 1 + && reconstructed.getUTCDate() === expected.day + && reconstructed.getUTCHours() === expected.hour + && reconstructed.getUTCMinutes() === expected.minute + && reconstructed.getUTCSeconds() === expected.second + && reconstructed.getUTCMilliseconds() === expected.millisecond; +} + function sinceValue(raw: unknown): ValidationResult { if (raw === undefined) return { valid: true, value: undefined }; if (typeof raw !== 'string') { return { valid: false, error: 'since must be a valid ISO 8601 date or timestamp' }; } const timestamp = Date.parse(raw); - if (!ISO_DATE_PATTERN.test(raw) || !Number.isFinite(timestamp)) { - return { valid: false, error: 'since must be a valid ISO 8601 date or timestamp' }; - } - if (!raw.includes('T') && new Date(timestamp).toISOString().slice(0, 10) !== raw) { + if (!hasExactCalendarFields(raw) || !Number.isFinite(timestamp)) { return { valid: false, error: 'since must be a valid ISO 8601 date or timestamp' }; } return { valid: true, value: raw }; @@ -170,6 +205,29 @@ async function projectRoot(raw: unknown, action: AnalyzeAction): Promise { + if (raw === undefined || raw === '') return { valid: true, value: undefined }; + if (typeof raw !== 'string') { + return { valid: false, error: 'pathPrefix must be a string' }; + } + const normalizedSeparators = raw.trim().replaceAll('\\', '/'); + if (isAbsolute(normalizedSeparators) || win32.isAbsolute(raw)) { + return { valid: false, error: 'pathPrefix must be project-relative' }; + } + if (normalizedSeparators.split('/').includes('..')) { + return { valid: false, error: 'pathPrefix must not contain .. traversal segments' }; + } + const resolvedPrefix = resolve(rootPath, normalizedSeparators).replaceAll('\\', '/'); + const relativePrefix = relative(rootPath, resolvedPrefix).replaceAll('\\', '/'); + if (relativePrefix === '..' || relativePrefix.startsWith('../') || isAbsolute(relativePrefix)) { + return { valid: false, error: 'pathPrefix must resolve within projectPath' }; + } + return { + valid: true, + value: relativePrefix === '' ? undefined : relativePrefix, + }; +} + function withMeta( result: object, action: AnalyzeAction, @@ -304,6 +362,25 @@ export async function handleAnalyze(args: Record): Promise>> { const setup = await getSetupStatus(); @@ -106,6 +120,25 @@ function validateProjectPath( return { valid: true }; } +function historySinceValue(raw: unknown): { valid: true; value?: string } | { valid: false; error: string } { + if (raw === undefined) return { valid: true }; + if (typeof raw !== 'string') { + return { valid: false, error: 'historySince must be a valid ISO 8601 date or timestamp' }; + } + if (!isValidIsoDateOrTimestamp(raw)) { + return { valid: false, error: 'historySince must be a valid ISO 8601 date or timestamp' }; + } + return { valid: true, value: raw }; +} + +function historyMaxCommitsValue(raw: unknown): { valid: true; value?: number } | { valid: false; error: string } { + if (raw === undefined) return { valid: true }; + if (typeof raw !== 'number' || !Number.isSafeInteger(raw) || raw < 1 || raw > 100_000) { + return { valid: false, error: 'historyMaxCommits must be a safe integer between 1 and 100000' }; + } + return { valid: true, value: raw }; +} + export const indexPersonaDefinition: ToolDefinition = { name: 'codebase', description: `Manage the codebase index — configure projects, trigger indexing, check status, read source. @@ -114,7 +147,7 @@ export const indexPersonaDefinition: ToolDefinition = { - **configure**: View and manage active codebases. First-time setup. Params: projectAction (list|set|add|remove|status), projects (string[], auto-detected if omitted) - **reindex**: Re-index codebase (incremental or full). - Params: mode (incremental|full), scope (optional file/directory path) + Params: mode (incremental|full), scope (optional file/directory path), historySince (optional ISO 8601 cutoff), historyMaxCommits (optional initial-backfill ceiling) - **status**: Get current indexing status (file/function/class counts, last indexed). Params: repo (optional repository path) - **stats**: Get graph-wide statistics (node/edge counts, largest files, most connected). @@ -129,7 +162,7 @@ export const indexPersonaDefinition: ToolDefinition = { **Examples:** - Check status: { action: "status" } - Configure projects: { action: "configure", projectAction: "status" } -- Re-index: { action: "reindex", mode: "incremental" } +- Re-index with wider history: { action: "reindex", mode: "full", historySince: "2024-01-01T00:00:00Z", historyMaxCommits: 20000 } - Read source: { action: "source", path: "/path/to/file.ts", startLine: 1, endLine: 50 } - Get profile: { action: "profile", projectPath: "/your/project", limit: 10 }`, inputSchema: { @@ -161,6 +194,16 @@ export const indexPersonaDefinition: ToolDefinition = { type: 'string', description: 'File/directory path to scope reindex', }, + historySince: { + type: 'string', + description: 'Inclusive ISO 8601 cutoff for the persisted git history window', + }, + historyMaxCommits: { + type: 'number', + minimum: 1, + maximum: 100000, + description: 'Initial-backfill safety ceiling; incremental sync is uncapped', + }, // status params repo: { type: 'string', @@ -213,10 +256,16 @@ export async function handleIndex(args: Record): Promise { const logger = createLogger({ namespace: 'MCP:Reindex' }); // Input schema -export interface ReindexInput { +export interface ReindexInput extends Pick { mode?: 'incremental' | 'full'; scope?: string; concurrency?: number; @@ -39,6 +40,36 @@ export interface ReindexInput { deferEmbeddings?: boolean; } +const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2}))?$/; + +function isValidIsoDateOrTimestamp(value: string): boolean { + if (!ISO_DATE_PATTERN.test(value) || !Number.isFinite(Date.parse(value))) return false; + const year = Number(value.slice(0, 4)); + const month = Number(value.slice(5, 7)); + const day = Number(value.slice(8, 10)); + const reconstructed = new Date(0); + reconstructed.setUTCHours(0, 0, 0, 0); + reconstructed.setUTCFullYear(year, month - 1, day); + return reconstructed.getUTCFullYear() === year + && reconstructed.getUTCMonth() === month - 1 + && reconstructed.getUTCDate() === day; +} + +function validateHistoryInput(input: ReindexInput): string | null { + if (input.historySince !== undefined) { + if (!isValidIsoDateOrTimestamp(input.historySince)) { + return 'historySince must be a valid ISO 8601 date or timestamp'; + } + } + if (input.historyMaxCommits !== undefined + && (!Number.isSafeInteger(input.historyMaxCommits) + || input.historyMaxCommits < 1 + || input.historyMaxCommits > 100_000)) { + return 'historyMaxCommits must be a safe integer between 1 and 100000'; + } + return null; +} + // Output type export interface ReindexOutput { success: boolean; @@ -80,6 +111,16 @@ export const reindexToolDefinition: ToolDefinition = { default: false, description: 'When true, return immediately and run embeddings in background. Default false: block until embeddings complete.', }, + historySince: { + type: 'string', + description: 'Inclusive ISO 8601 cutoff for the persisted git history window.', + }, + historyMaxCommits: { + type: 'number', + minimum: 1, + maximum: 100000, + description: 'Initial-backfill safety ceiling. Incremental sync is not capped.', + }, }, required: [], }, @@ -95,6 +136,19 @@ export async function triggerReindex(input: ReindexInput): Promise { - try { - const client = await getGraphClient(); - const gitResult = await syncGitHistory(rootPath, client); - if (gitResult.errors.length > 0) { - errors.push(...gitResult.errors); - } - if (gitResult.commitsProcessed > 0) { - logger.info(`Git sync: ${gitResult.commitsProcessed} commits, ${gitResult.edgesCreated} edges for ${rootPath}`); - } - return { commits: gitResult.commitsProcessed, edges: gitResult.edgesCreated }; - } catch (err) { - const msg = `Git sync failed for ${rootPath}: ${err instanceof Error ? err.message : err}`; - logger.warn(msg); - errors.push(msg); - return { commits: 0, edges: 0 }; - } -} diff --git a/packages/mcp-server/src/tools/router.ts b/packages/mcp-server/src/tools/router.ts index c0f92ed0..fe5a0ded 100644 --- a/packages/mcp-server/src/tools/router.ts +++ b/packages/mcp-server/src/tools/router.ts @@ -318,6 +318,8 @@ const rawHandlers: Record = { mode: (args.mode as 'incremental' | 'full') || 'incremental', } as ReindexInput; if (args.scope != null) input.scope = args.scope as string; + if (args.historySince != null) input.historySince = args.historySince as string; + if (args.historyMaxCommits != null) input.historyMaxCommits = args.historyMaxCommits as number; return triggerReindex(input); }, diff --git a/packages/types/src/analysis.ts b/packages/types/src/analysis.ts new file mode 100644 index 00000000..6308fce1 --- /dev/null +++ b/packages/types/src/analysis.ts @@ -0,0 +1,46 @@ +/** + * Ownership analysis contracts. + * Frozen by the orchestrator for the history/ownership batch. Ownership is + * inferred authorship from indexed git history; the result contract keeps + * that framing explicit (authorName/authorEmail, never a bare "owner"). + */ + +import type { HistoryCoverage } from './history'; + +export interface OwnershipInput { + rootPath: string; + since?: string; + pathPrefix?: string; + limit?: number; +} + +export interface NormalizedOwnershipInput { + rootPath: string; + since: string | null; + pathPrefix: string | null; + limit: number; +} + +export interface OwnershipContributor { + authorName: string; + authorEmail: string; + commitCount: number; + sharePercentage: number; +} + +export interface FileOwnershipItem { + filePath: string; + commitCount: number; + contributors: OwnershipContributor[]; + contributorsTruncated: boolean; +} + +export interface OwnershipResult { + input: NormalizedOwnershipInput; + projectRoot: string; + items: FileOwnershipItem[]; + truncated: boolean; + unknownIdentityCommitCount: number; + historyCoverage: HistoryCoverage; + caveats: string[]; +} diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts new file mode 100644 index 00000000..c10df560 --- /dev/null +++ b/packages/types/src/history.ts @@ -0,0 +1,34 @@ +/** + * Git history window and coverage contracts. + * Frozen by the orchestrator for the history/ownership batch; consumed by + * core git sync (window resolution and persistence) and graph analysis + * queries (coverage reporting). Do not extend without updating both sides. + */ + +export interface HistoryWindowOptions { + /** Inclusive ISO 8601 lower bound for initial history backfill. */ + historySince?: string; + /** Safety ceiling for initial history backfill. */ + historyMaxCommits?: number; +} + +export interface HistoryCoverage { + /** Distinct indexed commits observed after the analysis filters. */ + commitCount: number; + /** Earliest observed commit date after the analysis filters. */ + earliestCommitDate: string | null; + /** Latest observed commit date after the analysis filters. */ + latestCommitDate: string | null; + /** Reachable commits reported by git at the last history sync. */ + totalCommitCount: number | null; + /** Persisted effective lower bound for history indexing. */ + historySince: string | null; + /** Persisted initial-backfill safety ceiling. */ + historyMaxCommits: number | null; + /** Deprecated compatibility alias for historyMaxCommits. */ + historyWindowSize: number | null; + /** True when the effective indexed window omitted reachable history. */ + historyTruncated: boolean; + /** True when all reachable branch history was indexed at the last sync. */ + historyComplete: boolean; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 8eddffac..c1b32798 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -11,3 +11,5 @@ export * from './plugin'; export * from './document'; export * from './nlp'; export * from './labels'; +export * from './history'; +export * from './analysis'; diff --git a/packages/types/src/nodes.ts b/packages/types/src/nodes.ts index 79415870..ea05c7c9 100644 --- a/packages/types/src/nodes.ts +++ b/packages/types/src/nodes.ts @@ -358,6 +358,10 @@ export interface ProjectEntity extends ProvenanceFields { gitHistoryTruncated?: boolean; /** Whether the indexed history covered every commit reachable from the synced branch */ gitHistoryComplete?: boolean; + /** Persisted effective ISO 8601 lower bound for indexed git history. */ + gitHistorySince?: string; + /** Persisted initial-backfill safety ceiling for indexed git history. */ + gitHistoryMaxCommits?: number; } // ============================================================================