mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
639 lines
24 KiB
TypeScript
639 lines
24 KiB
TypeScript
/**
|
|
* Unit Tests: MCP Resources
|
|
*
|
|
* Tests: getResourceDefinitions, getResourceTemplates, readResource
|
|
* - Static resource definitions
|
|
* - Dynamic resource templates
|
|
* - URI parsing and dispatch
|
|
* - Error handling for invalid URIs
|
|
* - Resource handlers with mocked backend
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import {
|
|
getResourceDefinitions,
|
|
getResourceTemplates,
|
|
parseResourceUri,
|
|
readResource,
|
|
} from '../../src/mcp/resources.js';
|
|
import type { RepoMeta } from '../../src/storage/repo-manager.js';
|
|
|
|
// Mock loadMeta so getContextResource doesn't hit the filesystem (#2438 fix).
|
|
// Default: returns null (simulates no on-disk meta — falls back to cached handle).
|
|
const { loadMetaMock } = vi.hoisted(() => ({ loadMetaMock: vi.fn().mockResolvedValue(null) }));
|
|
vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../src/storage/repo-manager.js')>();
|
|
return { ...actual, loadMeta: loadMetaMock };
|
|
});
|
|
|
|
// ─── Minimal mock backend ──────────────────────────────────────────
|
|
|
|
function createMockBackend(overrides: Partial<Record<string, any>> = {}): any {
|
|
return {
|
|
listRepos: vi.fn().mockResolvedValue(overrides.repos ?? []),
|
|
resolveRepo: vi.fn().mockResolvedValue(
|
|
overrides.resolvedRepo ?? {
|
|
name: 'test-repo',
|
|
repoPath: '/tmp/test-repo',
|
|
storagePath: '/tmp/test-repo/.gitnexus',
|
|
lbugPath: '/tmp/test-repo/.gitnexus/lbug',
|
|
lastCommit: 'abc1234',
|
|
},
|
|
),
|
|
getContext: vi.fn().mockReturnValue(overrides.context ?? null),
|
|
queryClusters: vi.fn().mockResolvedValue(overrides.clusters ?? { clusters: [] }),
|
|
queryProcesses: vi.fn().mockResolvedValue(overrides.processes ?? { processes: [] }),
|
|
queryClusterDetail: vi
|
|
.fn()
|
|
.mockResolvedValue(overrides.clusterDetail ?? { error: 'Not found' }),
|
|
queryProcessDetail: vi
|
|
.fn()
|
|
.mockResolvedValue(overrides.processDetail ?? { error: 'Not found' }),
|
|
readGroupContractsResource: vi
|
|
.fn()
|
|
.mockResolvedValue(overrides.groupContractsBody ?? 'contracts: []\n'),
|
|
readGroupStatusResource: vi
|
|
.fn()
|
|
.mockResolvedValue(overrides.groupStatusBody ?? 'group: mock\n'),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ─── Static definitions ─────────────────────────────────────────────
|
|
|
|
describe('getResourceDefinitions', () => {
|
|
it('returns 2 static resources', () => {
|
|
const defs = getResourceDefinitions();
|
|
expect(defs).toHaveLength(2);
|
|
});
|
|
|
|
it('includes repos resource', () => {
|
|
const defs = getResourceDefinitions();
|
|
const repos = defs.find((d) => d.uri === 'gitnexus://repos');
|
|
expect(repos).toBeDefined();
|
|
expect(repos!.mimeType).toBe('text/yaml');
|
|
});
|
|
|
|
it('includes setup resource', () => {
|
|
const defs = getResourceDefinitions();
|
|
const setup = defs.find((d) => d.uri === 'gitnexus://setup');
|
|
expect(setup).toBeDefined();
|
|
expect(setup!.mimeType).toBe('text/markdown');
|
|
});
|
|
|
|
it('each definition has uri, name, description, mimeType', () => {
|
|
for (const def of getResourceDefinitions()) {
|
|
expect(def.uri).toBeTruthy();
|
|
expect(def.name).toBeTruthy();
|
|
expect(def.description).toBeTruthy();
|
|
expect(def.mimeType).toBeTruthy();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('getResourceTemplates', () => {
|
|
it('returns 8 dynamic templates', () => {
|
|
const templates = getResourceTemplates();
|
|
expect(templates).toHaveLength(8);
|
|
});
|
|
|
|
it('includes context, clusters, processes, schema, cluster detail, process detail, group contracts/status', () => {
|
|
const templates = getResourceTemplates();
|
|
const uris = templates.map((t) => t.uriTemplate);
|
|
expect(uris).toContain('gitnexus://repo/{name}/context');
|
|
expect(uris).toContain('gitnexus://repo/{name}/clusters');
|
|
expect(uris).toContain('gitnexus://repo/{name}/processes');
|
|
expect(uris).toContain('gitnexus://repo/{name}/schema');
|
|
expect(uris).toContain('gitnexus://repo/{name}/cluster/{clusterName}');
|
|
expect(uris).toContain('gitnexus://repo/{name}/process/{processName}');
|
|
expect(uris).toContain('gitnexus://group/{name}/contracts');
|
|
expect(uris).toContain('gitnexus://group/{name}/status');
|
|
});
|
|
|
|
it('each template has uriTemplate, name, description, mimeType', () => {
|
|
for (const tmpl of getResourceTemplates()) {
|
|
expect(tmpl.uriTemplate).toBeTruthy();
|
|
expect(tmpl.name).toBeTruthy();
|
|
expect(tmpl.description).toBeTruthy();
|
|
expect(tmpl.mimeType).toBeTruthy();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('parseResourceUri', () => {
|
|
it('parses group contracts without query', () => {
|
|
const p = parseResourceUri('gitnexus://group/acme/contracts');
|
|
expect(p).toEqual({
|
|
kind: 'group',
|
|
groupName: 'acme',
|
|
resourceType: 'contracts',
|
|
contractsFilter: {},
|
|
});
|
|
});
|
|
|
|
it('parses nested group name and contracts query params', () => {
|
|
const p = parseResourceUri(
|
|
'gitnexus://group/acme/billing/contracts?type=http&repo=app%2Fapi&unmatchedOnly=true',
|
|
);
|
|
expect(p.kind).toBe('group');
|
|
if (p.kind !== 'group' || p.resourceType !== 'contracts') throw new Error('unexpected');
|
|
expect(p.groupName).toBe('acme/billing');
|
|
expect(p.contractsFilter).toEqual({
|
|
type: 'http',
|
|
repo: 'app/api',
|
|
unmatchedOnly: true,
|
|
});
|
|
});
|
|
|
|
it('coerces unmatchedOnly false from string', () => {
|
|
const p = parseResourceUri('gitnexus://group/g1/contracts?unmatchedOnly=false');
|
|
expect(p.kind).toBe('group');
|
|
if (p.kind !== 'group' || p.resourceType !== 'contracts') throw new Error('unexpected');
|
|
expect(p.contractsFilter.unmatchedOnly).toBe(false);
|
|
});
|
|
|
|
it('parses group status', () => {
|
|
const p = parseResourceUri('gitnexus://group/my/product/status');
|
|
expect(p).toEqual({
|
|
kind: 'group',
|
|
groupName: 'my/product',
|
|
resourceType: 'status',
|
|
});
|
|
});
|
|
|
|
it('round-trips repo URI like legacy regex', () => {
|
|
const p = parseResourceUri('gitnexus://repo/my%20project/schema');
|
|
expect(p).toEqual({
|
|
kind: 'repo',
|
|
repoName: 'my project',
|
|
resourceType: 'schema',
|
|
});
|
|
});
|
|
|
|
it('rejects unknown group resource tail', () => {
|
|
expect(() => parseResourceUri('gitnexus://group/foo/bar')).toThrow('Unknown group resource');
|
|
});
|
|
});
|
|
|
|
// ─── readResource URI parsing ────────────────────────────────────────
|
|
|
|
describe('readResource', () => {
|
|
it('routes gitnexus://repos to listRepos', async () => {
|
|
const backend = createMockBackend({
|
|
repos: [
|
|
{
|
|
name: 'my-project',
|
|
path: '/home/me/my-project',
|
|
indexedAt: '2024-01-01',
|
|
lastCommit: 'abc1234',
|
|
stats: { files: 10, nodes: 50, processes: 5 },
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await readResource('gitnexus://repos', backend);
|
|
expect(backend.listRepos).toHaveBeenCalled();
|
|
expect(result).toContain('my-project');
|
|
});
|
|
|
|
it('returns empty message when no repos', async () => {
|
|
const backend = createMockBackend({ repos: [] });
|
|
const result = await readResource('gitnexus://repos', backend);
|
|
expect(result).toContain('No repositories indexed');
|
|
});
|
|
|
|
it('routes gitnexus://setup to setup resource', async () => {
|
|
const backend = createMockBackend({
|
|
repos: [
|
|
{
|
|
name: 'proj',
|
|
path: '/tmp/proj',
|
|
indexedAt: '2024-01-01',
|
|
lastCommit: 'abc',
|
|
stats: { nodes: 10, edges: 20, processes: 3 },
|
|
},
|
|
],
|
|
});
|
|
const result = await readResource('gitnexus://setup', backend);
|
|
expect(result).toContain('GitNexus MCP');
|
|
expect(result).toContain('proj');
|
|
});
|
|
|
|
it('returns fallback when setup has no repos', async () => {
|
|
const backend = createMockBackend({ repos: [] });
|
|
const result = await readResource('gitnexus://setup', backend);
|
|
expect(result).toContain('No repositories indexed');
|
|
});
|
|
|
|
it('routes group contracts resource through backend', async () => {
|
|
const backend = createMockBackend();
|
|
const uri = 'gitnexus://group/g1/contracts?type=http&unmatchedOnly=true';
|
|
await readResource(uri, backend);
|
|
expect(backend.readGroupContractsResource).toHaveBeenCalledWith('g1', {
|
|
type: 'http',
|
|
unmatchedOnly: true,
|
|
});
|
|
});
|
|
|
|
it('routes group status resource through backend', async () => {
|
|
const backend = createMockBackend();
|
|
await readResource('gitnexus://group/acme/status', backend);
|
|
expect(backend.readGroupStatusResource).toHaveBeenCalledWith('acme');
|
|
});
|
|
|
|
it('routes gitnexus://repo/{name}/context correctly', async () => {
|
|
const backend = createMockBackend({
|
|
context: {
|
|
projectName: 'test-project',
|
|
stats: { fileCount: 10, functionCount: 50, communityCount: 3, processCount: 5 },
|
|
},
|
|
});
|
|
|
|
const result = await readResource('gitnexus://repo/test-project/context', backend);
|
|
expect(backend.resolveRepo).toHaveBeenCalledWith('test-project');
|
|
expect(result).toContain('test-project');
|
|
expect(result).toContain('files: 10');
|
|
});
|
|
|
|
it('returns error when context has no codebase loaded', async () => {
|
|
const backend = createMockBackend({ context: null });
|
|
const result = await readResource('gitnexus://repo/test-project/context', backend);
|
|
expect(result).toContain('error');
|
|
});
|
|
|
|
it('routes gitnexus://repo/{name}/schema to static schema', async () => {
|
|
const backend = createMockBackend();
|
|
const result = await readResource('gitnexus://repo/any/schema', backend);
|
|
expect(result).toContain('GitNexus Graph Schema');
|
|
expect(result).toContain('CALLS');
|
|
expect(result).toContain('IMPORTS');
|
|
});
|
|
|
|
it('routes gitnexus://repo/{name}/clusters correctly', async () => {
|
|
const backend = createMockBackend({
|
|
clusters: {
|
|
clusters: [{ heuristicLabel: 'Auth', symbolCount: 10, cohesion: 0.9 }],
|
|
},
|
|
});
|
|
const result = await readResource('gitnexus://repo/test/clusters', backend);
|
|
expect(backend.queryClusters).toHaveBeenCalledWith('test', 100);
|
|
expect(result).toContain('Auth');
|
|
});
|
|
|
|
it('returns empty modules when no clusters', async () => {
|
|
const backend = createMockBackend({ clusters: { clusters: [] } });
|
|
const result = await readResource('gitnexus://repo/test/clusters', backend);
|
|
expect(result).toContain('modules: []');
|
|
});
|
|
|
|
it('handles cluster query error gracefully', async () => {
|
|
const backend = createMockBackend();
|
|
backend.queryClusters = vi.fn().mockRejectedValue(new Error('DB locked'));
|
|
const result = await readResource('gitnexus://repo/test/clusters', backend);
|
|
expect(result).toContain('DB locked');
|
|
});
|
|
|
|
it('routes gitnexus://repo/{name}/processes correctly', async () => {
|
|
const backend = createMockBackend({
|
|
processes: {
|
|
processes: [{ heuristicLabel: 'LoginFlow', processType: 'intra_community', stepCount: 3 }],
|
|
},
|
|
});
|
|
const result = await readResource('gitnexus://repo/test/processes', backend);
|
|
expect(backend.queryProcesses).toHaveBeenCalledWith('test', 50);
|
|
expect(result).toContain('LoginFlow');
|
|
});
|
|
|
|
it('handles process query error gracefully', async () => {
|
|
const backend = createMockBackend();
|
|
backend.queryProcesses = vi.fn().mockRejectedValue(new Error('timeout'));
|
|
const result = await readResource('gitnexus://repo/test/processes', backend);
|
|
expect(result).toContain('timeout');
|
|
});
|
|
|
|
it('routes gitnexus://repo/{name}/cluster/{clusterName} correctly', async () => {
|
|
const backend = createMockBackend({
|
|
clusterDetail: {
|
|
cluster: { heuristicLabel: 'Auth', symbolCount: 5, cohesion: 0.85 },
|
|
members: [{ name: 'login', type: 'Function', filePath: 'src/auth.ts' }],
|
|
},
|
|
});
|
|
const result = await readResource('gitnexus://repo/test/cluster/Auth', backend);
|
|
expect(backend.queryClusterDetail).toHaveBeenCalledWith('Auth', 'test');
|
|
expect(result).toContain('Auth');
|
|
expect(result).toContain('login');
|
|
});
|
|
|
|
it('handles cluster detail error', async () => {
|
|
const backend = createMockBackend({
|
|
clusterDetail: { error: 'Cluster not found' },
|
|
});
|
|
const result = await readResource('gitnexus://repo/test/cluster/Missing', backend);
|
|
expect(result).toContain('Cluster not found');
|
|
});
|
|
|
|
it('routes gitnexus://repo/{name}/process/{processName} correctly', async () => {
|
|
const backend = createMockBackend({
|
|
processDetail: {
|
|
process: { heuristicLabel: 'LoginFlow', processType: 'intra_community', stepCount: 3 },
|
|
steps: [
|
|
{ step: 1, name: 'login', filePath: 'src/auth.ts' },
|
|
{ step: 2, name: 'validate', filePath: 'src/validate.ts' },
|
|
],
|
|
},
|
|
});
|
|
const result = await readResource('gitnexus://repo/test/process/LoginFlow', backend);
|
|
expect(backend.queryProcessDetail).toHaveBeenCalledWith('LoginFlow', 'test');
|
|
expect(result).toContain('LoginFlow');
|
|
expect(result).toContain('login');
|
|
expect(result).toContain('validate');
|
|
});
|
|
|
|
it('handles process detail error', async () => {
|
|
const backend = createMockBackend({
|
|
processDetail: { error: 'Process not found' },
|
|
});
|
|
const result = await readResource('gitnexus://repo/test/process/Missing', backend);
|
|
expect(result).toContain('Process not found');
|
|
});
|
|
|
|
it('throws for unknown resource URI', async () => {
|
|
const backend = createMockBackend();
|
|
await expect(readResource('gitnexus://unknown', backend)).rejects.toThrow(
|
|
'Unknown resource URI',
|
|
);
|
|
});
|
|
|
|
it('throws for unknown repo-scoped resource type', async () => {
|
|
const backend = createMockBackend();
|
|
await expect(readResource('gitnexus://repo/test/nonexistent', backend)).rejects.toThrow(
|
|
'Unknown resource',
|
|
);
|
|
});
|
|
|
|
it('decodes URI-encoded repo names', async () => {
|
|
const backend = createMockBackend();
|
|
await readResource('gitnexus://repo/my%20project/schema', backend);
|
|
// Should not throw — the schema resource is static
|
|
});
|
|
|
|
it('decodes URI-encoded cluster names', async () => {
|
|
const backend = createMockBackend({
|
|
clusterDetail: {
|
|
cluster: { heuristicLabel: 'Auth Module', symbolCount: 5 },
|
|
members: [],
|
|
},
|
|
});
|
|
await readResource('gitnexus://repo/test/cluster/Auth%20Module', backend);
|
|
expect(backend.queryClusterDetail).toHaveBeenCalledWith('Auth Module', 'test');
|
|
});
|
|
|
|
it('repos resource shows multi-repo hint for multiple repos', async () => {
|
|
const backend = createMockBackend({
|
|
repos: [
|
|
{ name: 'proj-a', path: '/a', indexedAt: '2024-01-01', lastCommit: 'abc' },
|
|
{ name: 'proj-b', path: '/b', indexedAt: '2024-01-02', lastCommit: 'def' },
|
|
],
|
|
});
|
|
const result = await readResource('gitnexus://repos', backend);
|
|
expect(result).toContain('Multiple repos indexed');
|
|
expect(result).toContain('process.cwd()');
|
|
expect(result).toContain('unindexed nested Git checkout');
|
|
expect(result).toContain('mutating tools without an MCP default');
|
|
expect(result).toContain('pass repo explicitly');
|
|
// The example must use a registered tool name, not the unregistered
|
|
// `gitnexus_search` / `gitnexus_*` prefix (#2059).
|
|
// #2175: advertise the renamed param, not the legacy "query" key.
|
|
expect(result).toContain('query({search_query: "auth"');
|
|
expect(result).not.toContain('query({query:');
|
|
expect(result).not.toMatch(/gitnexus_/);
|
|
});
|
|
});
|
|
|
|
// ─── Context resource freshness (#2438) ─────────────────────────────────────
|
|
//
|
|
// After an out-of-process `analyze --index-only` refresh, the RepoHandle cached
|
|
// by LocalBackend is stale (lastCommit and stats come from the registry snapshot
|
|
// taken at init time). getContextResource must read from disk on every call so
|
|
// the staleness banner and stats always reflect the actual on-disk state.
|
|
|
|
describe('context resource freshness after out-of-process analyze (#2438)', () => {
|
|
beforeEach(() => {
|
|
loadMetaMock.mockReset();
|
|
loadMetaMock.mockResolvedValue(null); // default: no fresh meta
|
|
});
|
|
|
|
const CONTEXT = {
|
|
projectName: 'test-project',
|
|
stats: { fileCount: 100, functionCount: 500, communityCount: 3, processCount: 10 },
|
|
};
|
|
|
|
it('uses fresh lastCommit from disk meta for staleness check', async () => {
|
|
// Simulate: the cached handle has an old commit, but the on-disk meta has
|
|
// been updated to the current HEAD by an out-of-process analyze.
|
|
loadMetaMock.mockResolvedValue({
|
|
repoPath: '/tmp/test-repo',
|
|
lastCommit: 'fresh-head-commit',
|
|
indexedAt: new Date().toISOString(),
|
|
stats: { files: 200, nodes: 1000, processes: 20 },
|
|
});
|
|
|
|
const backend = createMockBackend({
|
|
resolvedRepo: {
|
|
name: 'test-project',
|
|
repoPath: '/tmp/test-repo',
|
|
storagePath: '/tmp/test-repo/.gitnexus',
|
|
lbugPath: '/tmp/test-repo/.gitnexus/lbug',
|
|
lastCommit: 'old-stale-commit', // stale cached value
|
|
},
|
|
context: CONTEXT,
|
|
});
|
|
|
|
// loadMeta is called with storagePath, not lbugPath
|
|
await readResource('gitnexus://repo/test-project/context', backend);
|
|
expect(loadMetaMock).toHaveBeenCalledWith('/tmp/test-repo/.gitnexus');
|
|
});
|
|
|
|
it('shows fresh stats from disk meta after out-of-process analyze', async () => {
|
|
// Cached stats are stale (100 files, 500 symbols); disk meta has refreshed stats
|
|
loadMetaMock.mockResolvedValue({
|
|
repoPath: '/tmp/test-repo',
|
|
lastCommit: 'current-head',
|
|
indexedAt: new Date().toISOString(),
|
|
stats: { files: 250, nodes: 1500, processes: 25 },
|
|
});
|
|
|
|
const backend = createMockBackend({
|
|
context: CONTEXT, // stale: fileCount:100, functionCount:500
|
|
});
|
|
|
|
const result = await readResource('gitnexus://repo/test-project/context', backend);
|
|
// Fresh stats from disk override the cached context stats
|
|
expect(result).toContain('files: 250');
|
|
expect(result).toContain('symbols: 1500');
|
|
expect(result).toContain('processes: 25');
|
|
// Stale cached values should NOT appear
|
|
expect(result).not.toContain('files: 100');
|
|
expect(result).not.toContain('symbols: 500');
|
|
});
|
|
|
|
it('exposes the full indexed commit and typed runner receipt from fresh metadata', async () => {
|
|
const runnerIdentity = {
|
|
schemaVersion: 4 as const,
|
|
runtime: {
|
|
executablePath: '/usr/bin/node',
|
|
version: 'v22.0.0',
|
|
platform: 'linux',
|
|
architecture: 'x64',
|
|
modulesAbi: '127',
|
|
libc: 'glibc:2.39',
|
|
},
|
|
cliVersion: '1.6.9',
|
|
invokedArtifact: { path: '/opt/gitnexus/dist/cli/index.js', digest: 'sha256:entry' },
|
|
build: {
|
|
kind: 'distribution' as const,
|
|
rootPath: '/opt/gitnexus/dist',
|
|
canonicalization: 'gitnexus-analyzer-build-v2' as const,
|
|
digest: 'sha256:build',
|
|
},
|
|
dependencyRuntime: {
|
|
manifestPath: '/opt/gitnexus/package.json',
|
|
lockfilePath: '/opt/package-lock.json',
|
|
canonicalization: 'gitnexus-analyzer-dependency-runtime-v4' as const,
|
|
packageCount: 42,
|
|
artifactCount: 12,
|
|
digest: 'sha256:dependencies',
|
|
},
|
|
};
|
|
loadMetaMock.mockResolvedValue({
|
|
repoPath: '/tmp/test-repo',
|
|
lastCommit: '0123456789abcdef0123456789abcdef01234567',
|
|
indexedAt: '2026-07-18T12:00:00.000Z',
|
|
runnerIdentity,
|
|
});
|
|
|
|
const result = await readResource(
|
|
'gitnexus://repo/test-project/context',
|
|
createMockBackend({ context: CONTEXT }),
|
|
);
|
|
expect(result).toContain('index:');
|
|
expect(result).toContain('commit: "0123456789abcdef0123456789abcdef01234567"');
|
|
expect(result).toContain(`runner_identity: ${JSON.stringify(runnerIdentity)}`);
|
|
expect(result).toContain('runner_identity_schema_status: "current"');
|
|
});
|
|
|
|
it('marks an older indexed runner receipt schema as legacy', async () => {
|
|
loadMetaMock.mockResolvedValue({
|
|
repoPath: '/tmp/test-repo',
|
|
lastCommit: '0123456789abcdef0123456789abcdef01234567',
|
|
indexedAt: '2026-07-18T12:00:00.000Z',
|
|
runnerIdentity: { schemaVersion: 1 },
|
|
} as unknown as RepoMeta);
|
|
|
|
const result = await readResource(
|
|
'gitnexus://repo/test-project/context',
|
|
createMockBackend({ context: CONTEXT }),
|
|
);
|
|
expect(result).toContain('runner_identity_schema_status: "legacy-or-unknown"');
|
|
});
|
|
|
|
it('exposes the same machine-readable incomplete reasons as status', async () => {
|
|
loadMetaMock.mockResolvedValue({
|
|
repoPath: '/tmp/test-repo',
|
|
lastCommit: 'current-head',
|
|
indexedAt: '2026-07-18T12:00:00.000Z',
|
|
incrementalInProgress: { startedAt: 1, toWriteCount: 2 },
|
|
scopeExtractionReceipt: 1,
|
|
embeddingCheckpoint: {
|
|
at: '2026-07-18T12:00:00.000Z',
|
|
nodesProcessed: 1,
|
|
totalNodes: 2,
|
|
chunksProcessed: 1,
|
|
model: 'fixture',
|
|
dimensions: 3,
|
|
provider: 'local',
|
|
},
|
|
});
|
|
|
|
const result = await readResource(
|
|
'gitnexus://repo/test-project/context',
|
|
createMockBackend({ context: CONTEXT }),
|
|
);
|
|
expect(result).toContain(
|
|
'incomplete_reasons: ["incremental-in-progress","embedding-checkpoint-pending"]',
|
|
);
|
|
});
|
|
|
|
it('exposes Spring Actuator index metadata to agents', async () => {
|
|
loadMetaMock.mockResolvedValue({
|
|
repoPath: '/tmp/test-repo',
|
|
lastCommit: 'current-head',
|
|
indexedAt: '2026-08-31T20:00:00.000Z',
|
|
springActuator: {
|
|
enabled: true,
|
|
repoRelativeInputs: ['runtime-actuator'],
|
|
},
|
|
});
|
|
|
|
const result = await readResource(
|
|
'gitnexus://repo/test-project/context',
|
|
createMockBackend({ context: CONTEXT }),
|
|
);
|
|
|
|
expect(result).toContain(
|
|
'spring_actuator: {"enabled":true,"repoRelativeInputs":["runtime-actuator"]}',
|
|
);
|
|
});
|
|
|
|
it('falls back to cached stats when loadMeta returns null', async () => {
|
|
// loadMeta returns null (e.g. pre-analyze state or missing gitnexus.json)
|
|
loadMetaMock.mockResolvedValue(null);
|
|
|
|
const backend = createMockBackend({ context: CONTEXT });
|
|
const result = await readResource('gitnexus://repo/test-project/context', backend);
|
|
// Must still show the cached stats (no crash, no blank output)
|
|
expect(result).toContain('files: 100');
|
|
expect(result).toContain('symbols: 500');
|
|
expect(result).toContain('processes: 10');
|
|
});
|
|
|
|
it('falls back to cached lastCommit when loadMeta throws', async () => {
|
|
// loadMeta throws (e.g. permissions error)
|
|
loadMetaMock.mockRejectedValue(new Error('EACCES: permission denied'));
|
|
|
|
const backend = createMockBackend({ context: CONTEXT });
|
|
// Should not throw — falls back gracefully
|
|
const result = await readResource('gitnexus://repo/test-project/context', backend);
|
|
expect(result).toContain('test-project');
|
|
expect(result).toContain('stats:');
|
|
});
|
|
|
|
it('does not show staleness banner when fresh lastCommit matches HEAD', async () => {
|
|
// After analyze completes, lastCommit in meta equals HEAD → no stale banner.
|
|
// We simulate this by returning a fresh meta; checkStaleness will be called
|
|
// with the fresh commit but the /tmp path has no git repo so it returns safe.
|
|
loadMetaMock.mockResolvedValue({
|
|
repoPath: '/tmp/test-repo',
|
|
lastCommit: 'head-after-analyze',
|
|
indexedAt: new Date().toISOString(),
|
|
stats: { files: 200, nodes: 1000, processes: 20 },
|
|
});
|
|
|
|
const backend = createMockBackend({
|
|
resolvedRepo: {
|
|
name: 'test-project',
|
|
repoPath: '/tmp/test-repo',
|
|
storagePath: '/tmp/test-repo/.gitnexus',
|
|
lbugPath: '/tmp/test-repo/.gitnexus/lbug',
|
|
lastCommit: 'old-stale-commit', // stale, would show banner if used
|
|
},
|
|
context: CONTEXT,
|
|
});
|
|
|
|
const result = await readResource('gitnexus://repo/test-project/context', backend);
|
|
// With a non-git path checkStaleness errors → no banner even with stale commit.
|
|
// What matters: the fresh commit was passed to checkStaleness, not the old one.
|
|
// (The staleness banner itself requires a live git repo, tested in integration.)
|
|
expect(result).toContain('test-project');
|
|
expect(result).not.toContain('error:');
|
|
});
|
|
});
|