GitNexus/gitnexus/test/unit/group/service.test.ts
Ankit Verma 8bd71c8335
feat(staleness): report diverged and unknown index state instead of fresh (#3257)
* feat(staleness): report diverged and unknown index state instead of fresh

A staleness check collapsed every git failure into { isStale: false,
commitsBehind: 0 }. On a branch-pinned serve clone, a failed re-index
leaves the recorded commit orphaned by the --depth 1 fetch; once the
reflog expires and gc prunes it, rev-list fails and the index silently
reads as fresh while still behind.

checkStaleness / checkStalenessAsync now return an additive status:
current, behind, diverged (rev-list failed but HEAD resolved and differs
from lastCommit) or unknown (HEAD unresolvable, no lastCommit, timeout).
isStale and commitsBehind keep their values in every case.

One payload builder (core/staleness-status.ts) feeds MCP list_repos, the
hot read tools and /api/repos + /api/repo. diverged carries a hint and no
invented count; unknown appears on listings only. The helpers live in a
pure module so existing vi.mock stubs of git-staleness stay valid.

gitnexus group status no longer prints "-1 commits behind".

Fixes #3256

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Address PR review feedback (#3257)

- Document the `current` arm `fromHead` can return after a failed `rev-list`.
  The `staleness-status.ts` status list, the `fromHead` docstring and the
  `stalenessForTool` comment each named only `diverged`/`unknown`, so all three
  described a contract the helpers do not have.
- Document both `unknown` rows on the group status type: no recorded commit
  (`indexStale: true`, `commitsBehind: -1`) and a git probe that could not
  answer (`indexStale: false`, `commitsBehind: 0`), which do not agree on
  either field.
- Update both shipped `gitnexus-guide` copies to the new wire shape
  (`{ status, commitsBehind?, hint? }`), add a `diverged` example carrying no
  count, and state `unknown` is reported only by the `list_repos` listing.
  Presence now means "not `current`", not "behind N". Pinned in
  shipped-skills-sync.
- Lock the timed-out `rev-list` short-circuit with staleness-timeout.test.ts:
  it asserts `unknown` from exactly one git spawn, so removing the `killed`
  guard (which would probe HEAD again and double the #3232 bound) now fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Claim only the uncountable gap in the diverged hint (#3257)

`fromHead` reaches the `diverged` branch on any non-timeout `rev-list` failure
where `rev-parse HEAD` resolves to a different SHA. It compares the two SHAs and
runs no reachability check, so a transient object-read failure lands there while
`lastCommit` is still an ancestor of HEAD — and the hint told the operator the
commit was gone from history. `staleness-status.ts` already documents `diverged`
as only "provably not at HEAD; the count is unknown", so the string was also the
one place contradicting its own contract.

The hint now states what the check established and hedges the usual cause:
"Index is not at HEAD and the commit gap could not be counted — the recorded
commit may no longer be in this clone's history."

Updated with it: the `staleness.test.ts` diverged assertion, and the `diverged`
example plus its lead-in sentence in both shipped `gitnexus-guide` copies (still
byte-identical). `mcp/resources.ts` needs no change — it interpolates
`staleness.hint`, holding no copy of the text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(staleness): cover the remaining #3256 review test gaps

The tri-review's lower-priority gaps, each now locked:

- staleness-fallback.test.ts: after a failed (not timed-out) rev-list,
  both helpers report `current` when HEAD alone still resolves to the
  indexed commit, `diverged` when it resolves elsewhere, and `unknown`
  when it cannot be read, each from exactly rev-list + rev-parse. This
  complements staleness-timeout.test.ts: a timeout spawns once, any other
  failure probes HEAD.
- list_repos carries the listing-only `unknown` and a count-free
  `diverged`; `current` carries nothing.
- group status: a resolvable repo with no recorded commit composes to
  indexStale: true, commitsBehind: -1, status: unknown and renders as
  "STALE (? commits behind)", through the real groupStatus path rather
  than a hand-built row.

Each test was checked against a mutation of the code it guards (the
fromHead current arm, the group no-commit literal, list_repos
includeUnknown); every mutation fails its test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: tech-admin3 <tech-admin@kodnest.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2026-09-11 08:07:37 +01:00

584 lines
19 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {
GroupService,
type GroupToolPort,
type GroupRepoHandle,
} from '../../../src/core/group/service.js';
import { writeContractRegistry } from '../../../src/core/group/storage.js';
import { formatIndexStatusCell } from '../../../src/cli/group-status-format.js';
import type { ContractRegistry, StoredContract, CrossLink } from '../../../src/core/group/types.js';
function makeTmpGroup(): { tmpDir: string; groupDir: string; cleanup: () => void } {
const tmpDir = path.join(os.tmpdir(), `gitnexus-svc-${Date.now()}`);
const groupDir = path.join(tmpDir, 'groups', 'test-group');
fs.mkdirSync(groupDir, { recursive: true });
const yaml = `version: 1
name: test-group
description: Test
repos:
app/backend: test-backend
app/frontend: test-frontend
`;
fs.writeFileSync(path.join(groupDir, 'group.yaml'), yaml);
return {
tmpDir,
groupDir,
cleanup: () => fs.rmSync(tmpDir, { recursive: true, force: true }),
};
}
function makePort(overrides: Partial<GroupToolPort> = {}): GroupToolPort {
return {
resolveRepo: vi.fn(
async (name?: string): Promise<GroupRepoHandle> => ({
id: name || 'test',
name: name || 'test',
repoPath: '/tmp/repo',
storagePath: '/tmp/repo/.gitnexus',
}),
),
impact: vi.fn(async () => ({ symbols: [] })),
query: vi.fn(async () => ({ processes: [] })),
impactByUid: vi.fn(async () => null),
context: vi.fn(async () => ({
status: 'found',
symbol: { filePath: 'services/auth/x.ts', uid: 'u1', name: 'X' },
})),
...overrides,
};
}
function makeContract(id: string, role: 'provider' | 'consumer', repo: string): StoredContract {
return {
contractId: id,
type: 'http',
role,
symbolUid: `uid-${repo}-${id}`,
symbolRef: { filePath: `src/${repo}.ts`, name: `fn-${id}` },
symbolName: `fn-${id}`,
confidence: 0.8,
meta: {},
repo,
};
}
function makeRegistry(contracts: StoredContract[], crossLinks: CrossLink[] = []): ContractRegistry {
return {
version: 1,
generatedAt: new Date().toISOString(),
repoSnapshots: {},
missingRepos: [],
contracts,
crossLinks,
};
}
describe('GroupService', () => {
describe('groupList', () => {
it('test_groupList_without_name_returns_group_names', async () => {
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const svc = new GroupService(makePort());
const result = (await svc.groupList({})) as { groups: string[] };
expect(result.groups).toContain('test-group');
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('test_groupList_with_name_returns_config_details', async () => {
const { cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const svc = new GroupService(makePort());
const result = (await svc.groupList({ name: 'test-group' })) as {
name: string;
repos: Record<string, string>;
};
expect(result.name).toBe('test-group');
expect(result.repos['app/backend']).toBe('test-backend');
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
});
describe('groupContracts', () => {
it('test_groupContracts_returns_error_when_name_empty', async () => {
const svc = new GroupService(makePort());
const result = (await svc.groupContracts({})) as { error: string };
expect(result.error).toContain('name is required');
});
it('test_groupContracts_returns_error_when_no_registry', async () => {
const { cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const svc = new GroupService(makePort());
const result = (await svc.groupContracts({ name: 'test-group' })) as { error: string };
expect(result.error).toContain('No contracts.json');
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('test_groupContracts_returns_all_contracts', async () => {
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const contracts = [
makeContract('http::GET::/api/users', 'provider', 'app/backend'),
makeContract('http::GET::/api/users', 'consumer', 'app/frontend'),
];
await writeContractRegistry(groupDir, makeRegistry(contracts));
const svc = new GroupService(makePort());
const result = (await svc.groupContracts({ name: 'test-group' })) as {
contracts: StoredContract[];
};
expect(result.contracts).toHaveLength(2);
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('test_groupContracts_filters_by_type', async () => {
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const contracts = [
makeContract('http::GET::/api/users', 'provider', 'app/backend'),
{
...makeContract('grpc::auth.AuthService/Login', 'provider', 'app/backend'),
type: 'grpc' as const,
},
];
await writeContractRegistry(groupDir, makeRegistry(contracts));
const svc = new GroupService(makePort());
const result = (await svc.groupContracts({ name: 'test-group', type: 'grpc' })) as {
contracts: StoredContract[];
};
expect(result.contracts).toHaveLength(1);
expect(result.contracts[0].type).toBe('grpc');
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('test_groupContracts_filters_by_repo', async () => {
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const contracts = [
makeContract('http::GET::/api/users', 'provider', 'app/backend'),
makeContract('http::GET::/api/users', 'consumer', 'app/frontend'),
];
await writeContractRegistry(groupDir, makeRegistry(contracts));
const svc = new GroupService(makePort());
const result = (await svc.groupContracts({ name: 'test-group', repo: 'app/backend' })) as {
contracts: StoredContract[];
};
expect(result.contracts).toHaveLength(1);
expect(result.contracts[0].repo).toBe('app/backend');
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('test_groupContracts_unmatchedOnly_filters_matched', async () => {
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const provider = makeContract('http::GET::/api/users', 'provider', 'app/backend');
const consumer = makeContract('http::GET::/api/users', 'consumer', 'app/frontend');
const orphan = makeContract('http::GET::/api/health', 'provider', 'app/backend');
const crossLink: CrossLink = {
from: {
repo: 'app/frontend',
symbolUid: 'uid-c',
symbolRef: { filePath: 'f.ts', name: 'fn' },
},
to: {
repo: 'app/backend',
symbolUid: 'uid-p',
symbolRef: { filePath: 'f.ts', name: 'fn' },
},
type: 'http',
contractId: 'http::GET::/api/users',
matchType: 'exact',
confidence: 1.0,
};
await writeContractRegistry(
groupDir,
makeRegistry([provider, consumer, orphan], [crossLink]),
);
const svc = new GroupService(makePort());
const result = (await svc.groupContracts({ name: 'test-group', unmatchedOnly: true })) as {
contracts: StoredContract[];
};
expect(result.contracts).toHaveLength(1);
expect(result.contracts[0].contractId).toBe('http::GET::/api/health');
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('test_groupContracts_skips_corrupt_contract_rows', async () => {
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const badJson = `{
"version": 1,
"generatedAt": "2026-01-01T00:00:00.000Z",
"repoSnapshots": {},
"missingRepos": [],
"contracts": [
{ "not": "a-contract" },
{
"contractId": "http::GET::/ok",
"type": "http",
"repo": "app/backend",
"role": "provider",
"symbolUid": "u",
"symbolRef": { "filePath": "a.ts", "name": "f" },
"symbolName": "f",
"confidence": 1,
"meta": {}
}
],
"crossLinks": []
}`;
fs.writeFileSync(path.join(groupDir, 'contracts.json'), badJson, 'utf-8');
const svc = new GroupService(makePort());
const result = (await svc.groupContracts({ name: 'test-group' })) as {
contracts: unknown[];
skippedCorrupt?: number;
};
expect(result.contracts).toHaveLength(1);
expect(result.skippedCorrupt).toBe(1);
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
});
describe('groupSync', () => {
it('test_groupSync_returns_error_when_name_empty', async () => {
const svc = new GroupService(makePort());
const result = (await svc.groupSync({})) as { error: string };
expect(result.error).toContain('name is required');
});
});
describe('groupQuery', () => {
it('test_groupQuery_returns_error_when_params_missing', async () => {
const svc = new GroupService(makePort());
const result = (await svc.groupQuery({})) as { error: string };
expect(result.error).toContain('name and query are required');
});
it('test_groupQuery_merges_results_across_repos', async () => {
const { cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const port = makePort({
query: vi.fn(async () => ({
processes: [{ name: 'process1', score: 0.9 }],
})),
});
const svc = new GroupService(port);
const result = (await svc.groupQuery({ name: 'test-group', query: 'auth flow' })) as {
group: string;
query: string;
results: unknown[];
per_repo: Array<{ repo: string; count: number }>;
};
expect(result.group).toBe('test-group');
expect(result.query).toBe('auth flow');
expect(result.results.length).toBeGreaterThan(0);
expect(result.per_repo).toHaveLength(2);
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('test_groupQuery_handles_failing_repo_gracefully', async () => {
const { cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const port = makePort({
resolveRepo: vi.fn(async (name?: string) => {
if (name === 'test-backend') throw new Error('not indexed');
return { id: 'fe', name: 'fe', repoPath: '/tmp', storagePath: '/tmp/.gitnexus' };
}),
query: vi.fn(async () => ({ processes: [{ name: 'p1' }] })),
});
const svc = new GroupService(port);
const result = (await svc.groupQuery({ name: 'test-group', query: 'test' })) as {
per_repo: Array<{ repo: string; count: number }>;
};
const backendRepo = result.per_repo.find((r) => r.repo === 'app/backend');
expect(backendRepo?.count).toBe(0);
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('test_groupQuery_respects_subgroup_filter', async () => {
const { cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const port = makePort({
query: vi.fn(async () => ({ processes: [{ name: 'p1' }] })),
});
const svc = new GroupService(port);
const result = (await svc.groupQuery({
name: 'test-group',
query: 'test',
subgroup: 'app/backend',
})) as { per_repo: Array<{ repo: string; count: number }> };
expect(result.per_repo).toHaveLength(1);
expect(result.per_repo[0].repo).toBe('app/backend');
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('test_groupQuery_subgroupExact_skips_descendant_member_paths', async () => {
const tmpDir = path.join(os.tmpdir(), `gitnexus-svc-nest-${Date.now()}`);
const groupDir = path.join(tmpDir, 'groups', 'nest-group');
fs.mkdirSync(groupDir, { recursive: true });
fs.writeFileSync(
path.join(groupDir, 'group.yaml'),
`version: 1
name: nest-group
repos:
app/frontend: fe-root
app/frontend/mobile: fe-nested
app/backend: be1
`,
);
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const query = vi.fn(async () => ({ processes: [{ name: 'p1' }] }));
const port = makePort({ query });
const svc = new GroupService(port);
const prefixOnly = (await svc.groupQuery({
name: 'nest-group',
query: 'x',
subgroup: 'app/frontend',
})) as { per_repo: Array<{ repo: string }> };
expect(prefixOnly.per_repo.map((r) => r.repo).sort()).toEqual([
'app/frontend',
'app/frontend/mobile',
]);
const exact = (await svc.groupQuery({
name: 'nest-group',
query: 'x',
subgroup: 'app/frontend',
subgroupExact: true,
})) as { per_repo: Array<{ repo: string }> };
expect(exact.per_repo).toHaveLength(1);
expect(exact.per_repo[0].repo).toBe('app/frontend');
} finally {
vi.unstubAllEnvs();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
describe('groupImpact', () => {
it('test_groupImpact_returns_validation_error', async () => {
const svc = new GroupService(makePort());
const r = (await svc.groupImpact({})) as { error: string };
expect(r.error).toContain('name');
});
});
describe('groupContext', () => {
it('test_groupContext_requires_target_or_uid', async () => {
const svc = new GroupService(makePort());
const r = await svc.groupContext({ name: 'test-group' });
expect(r.error).toContain('target');
});
it('test_groupContext_iterates_repos', async () => {
const { cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const port = makePort();
const svc = new GroupService(port);
const r = await svc.groupContext({ name: 'test-group', target: 'MySym' });
expect(r.group).toBe('test-group');
expect(r.results).toHaveLength(2);
expect(port.context).toHaveBeenCalledTimes(2);
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('test_groupContext_subgroupExact_skips_descendant_member_paths', async () => {
const tmpDir = path.join(os.tmpdir(), `gitnexus-ctx-nest-${Date.now()}`);
const groupDir = path.join(tmpDir, 'groups', 'nest-group');
fs.mkdirSync(groupDir, { recursive: true });
fs.writeFileSync(
path.join(groupDir, 'group.yaml'),
`version: 1
name: nest-group
repos:
app/frontend: fe-root
app/frontend/mobile: fe-nested
`,
);
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const port = makePort();
const svc = new GroupService(port);
await svc.groupContext({
name: 'nest-group',
target: 'X',
subgroup: 'app/frontend',
subgroupExact: true,
});
expect(port.context).toHaveBeenCalledTimes(1);
} finally {
vi.unstubAllEnvs();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('test_groupContext_service_prefix_filters_payload', async () => {
const { cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const port = makePort({
context: vi.fn(async () => ({
status: 'found',
symbol: { filePath: 'other/path/x.ts', uid: 'u1', name: 'X' },
})),
});
const svc = new GroupService(port);
const r = await svc.groupContext({
name: 'test-group',
target: 'MySym',
service: 'services/auth',
});
expect(r.results.every((x) => Object.keys(x.payload as object).length === 0)).toBe(true);
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
});
describe('groupStatus', () => {
it('test_groupStatus_returns_error_when_name_empty', async () => {
const svc = new GroupService(makePort());
const result = (await svc.groupStatus({})) as { error: string };
expect(result.error).toContain('name is required');
});
it('test_groupStatus_marks_unresolvable_repos_as_missing', async () => {
const { cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const port = makePort({
resolveRepo: vi.fn(async () => {
throw new Error('repo not found');
}),
});
const svc = new GroupService(port);
const result = (await svc.groupStatus({ name: 'test-group' })) as {
group: string;
repos: Record<string, { missing: boolean }>;
};
expect(result.group).toBe('test-group');
expect(result.repos['app/backend'].missing).toBe(true);
expect(result.repos['app/frontend'].missing).toBe(true);
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
// #3256: a resolvable repo with no recorded commit. The formatter tests
// hand-build this row; this one drives the real composition, so swapping
// the no-commit literal for the git-probe `unknown()` (indexStale: false,
// commitsBehind: 0) would print `OK` here and fail.
it('test_groupStatus_reports_no_recorded_commit_as_unknown_and_renders_it_as_?', async () => {
const { cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
// Resolves, but its storage holds no meta.json, so nothing was recorded.
const storagePath = path.join(tmpDir, 'no-meta', '.gitnexus');
fs.mkdirSync(storagePath, { recursive: true });
const port = makePort({
resolveRepo: vi.fn(
async (name?: string): Promise<GroupRepoHandle> => ({
id: name || 'test',
name: name || 'test',
repoPath: path.join(tmpDir, 'no-meta'),
storagePath,
}),
),
});
const svc = new GroupService(port);
const result = (await svc.groupStatus({ name: 'test-group' })) as {
repos: Record<
string,
{ indexStale: boolean; commitsBehind?: number; status?: string; missing: boolean }
>;
};
const row = result.repos['app/backend'];
expect(row).toMatchObject({
missing: false,
indexStale: true,
commitsBehind: -1,
status: 'unknown',
});
expect(formatIndexStatusCell(row)).toBe('STALE (? commits behind)');
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
});
});