GitNexus/gitnexus/test/unit/group/service-group-sync-payload.test.ts
Gergő Magyar 66b44afe8c
fix(group): make degraded links, sync warnings and UID-only impact actually work (#3113)
* feat(group-surface): impact selector pass-through + degraded links + sync hygiene

- @group impact forwards target_uid/file_path/kind through service port
  and cross-impact impactParams (was dead-wired: params accepted at MCP
  boundary then dropped at validation).
- crossLinks with unresolved provider symbols carry degraded: true,
  derived at the persistence boundary after merge/dedupe; sync reports
  'degraded links: N' and per-repo extraction failures instead of
  swallowing them; bridge write failures surface as sync warnings;
  contracts.json passes through dedupeContracts.
- Absolute-URL branch restores %7B/%7D around {param} after URL parsing.
- tests: consumer matrix + wildcard folding + degraded pins (261 new);
  SCHEMA_BUMP pin 47 -> 48 (wildcardImports cache shape); sync.ts NUL
  byte rewritten as text escape (no longer binary to git).

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

* fix(group): impact selector pass-through, degraded links, sync failure hygiene

- @group impact forwards target_uid/file_path/kind through the service
  port into cross-impact impactParams. These were accepted at the MCP
  boundary and then dropped in validation — a dead wire: disambiguating
  an ambiguous impact target never actually reached the per-member impact.
- Cross-links whose provider endpoint never resolves to a graph symbol are
  marked degraded: true at the single persistence boundary (post
  merge/dedupe, before re-export), counted as SyncResult.degradedLinks,
  and surfaced by the sync summary ('degraded links: N') — the remedy
  (re-analyze the provider repo) is documented on the field.
- Sync failure hygiene: a repo whose per-repo extraction throws records
  its reason in SyncResult.failedRepos (still lands in missingRepos, so
  downstream semantics are unchanged) instead of the old silent swallow
  that could persist half a repo's contracts; operator warnings
  accumulate in SyncResult.warnings.

Tests: cross-impact selector threading, degraded-link marking, per-repo
failure reporting.

* style: prettier

* fix(group): make degraded links, sync warnings and UID-only impact actually work

The three fixes this branch claims were wired at the type and payload level
but never at the boundary that produces the values:

- `degraded` was only ever cleared by the exported `dedupeCrossLinks`, which
  the sync path does not use, so `degradedLinks` was always 0. Derivation now
  lives in one exported `applyDegradedFlag` that both the sync finalize and
  the post-merge re-derivation call.
- The bridge-write catch logged an operator warning and dropped it, leaving
  `warnings` permanently `[]`.
- `@group impact` rejected a UID-only call before it parsed `target_uid`, so
  the documented "re-call with target_uid" disambiguation loop was
  unreachable in group mode even though the selectors were forwarded.
- `failedRepos[].repo` reported the registry display name while the repo
  landed in `unreadableRepos` under its group path, so the two lists could
  not be joined; the JSDoc also pointed at the wrong list.
- Restored the truncated `READ THE RESULT:` heading in the group_sync tool
  description and documented degradedLinks / failedRepos / warnings.

Tests pin each value at the boundary that produces it, including the exact
group_sync wire shape, which previously omitted all three new fields.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: l.cx <l.cx@winning.com.cn>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: ChunxueLi <mecoloud@users.noreply.gitee.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 20:03:53 +00:00

449 lines
17 KiB
TypeScript

/**
* What `group_sync` and `group_contracts` PUT ON THE WIRE.
*
* Both tools document fields an agent is expected to branch on, and both build
* their payload by hand — a literal per field, each one a line that can be
* deleted without breaking a type or a build. Nothing asserted either payload,
* so dropping `unreadableRepos` or `registryOutcome` from the sync response, or
* the truncation triple from the contract listing, was a silent change: the
* caller simply stopped being told, and every existing test stayed green.
*
* Hence exact-shape assertions throughout. `toMatchObject` — which is what the
* one existing `groupSync` assertion uses, in
* `test/integration/group/group-service-sync-lazy-import.test.ts` — passes
* happily on a payload that has lost a key, which is precisely the regression
* this file exists to catch.
*
* The tri-state these cases pin, established by the sibling commits in this PR:
*
* - an ABSENT `unreadableRepos` means the sync never recorded which repos it
* could read, so any answer derived from the artifact is a floor;
* - an EMPTY list is a measurement — this sync accounted for every repo;
* - a POPULATED list names the repos whose contracts are not in there.
*
* `groupContracts` therefore OMITS the key in the absent case rather than
* inventing `[]`, and pairs it with `truncated: true` +
* `truncationReason: 'incomplete-sync'` + `riskEpistemic: 'lower-bound'`. An
* exact-shape assertion is the only kind that can see the difference between
* omitting a key and normalizing it to empty.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { SyncResult } from '../../../src/core/group/sync.js';
import type { GroupToolPort, GroupRepoHandle } from '../../../src/core/group/service.js';
import type { CrossLink } from '../../../src/core/group/types.js';
import { makeContract } from './fixtures.js';
/**
* `GroupService.groupSync` reaches `syncGroup` through a dynamic
* `await import('./sync.js')`; vitest resolves that to the same module id as
* the specifier below, so this factory serves it. Mocked because the two
* forwarded fields are what is under test and a real sync cannot be steered to
* an arbitrary `registryOutcome` without an indexed repo — the real import is
* pinned separately, and deliberately unmocked, in
* `test/integration/group/group-service-sync-lazy-import.test.ts`.
*/
const syncGroupMock = vi.fn<() => Promise<SyncResult>>();
vi.mock('../../../src/core/group/sync.js', () => ({
syncGroup: (...args: unknown[]) => syncGroupMock(...(args as [])),
formatGroupSyncAmbiguousError: (err: Error) => err.message,
}));
const { GroupService } = await import('../../../src/core/group/service.js');
const port: GroupToolPort = {
resolveRepo: vi.fn(
async (name?: string): Promise<GroupRepoHandle> => ({
id: name ?? 'repo',
name: name ?? 'repo',
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' as const,
symbol: { filePath: 'src/routes.ts', uid: 'uid-1', name: 'getUsers' },
})),
};
const GROUP = 'payload';
/** Every field of a `SyncResult`, overridable one at a time. */
const syncResult = (overrides: Partial<SyncResult> = {}): SyncResult => ({
contracts: [],
crossLinks: [],
unmatched: [],
missingRepos: [],
unreadableRepos: [],
degradedLinks: 0,
failedRepos: [],
warnings: [],
repoSnapshots: {},
suppressedMatchStages: [],
registryOutcome: 'written',
...overrides,
});
const WIRE_SYNC_QUALITY = {
degradedLinks: 0,
failedRepos: [],
warnings: [],
} as const;
/**
* `syncGroupMock` is declared zero-arg, so `mock.calls` is typed as an array of
* the empty tuple and indexing `[1]` does not type-check. The runtime call
* genuinely has two arguments (config, options); this reads the second without
* restating a signature the rest of the suite does not need.
*/
const syncOptsOf = (call: number): Record<string, unknown> =>
(syncGroupMock.mock.calls[call] as unknown as unknown[])[1] as Record<string, unknown>;
const CONTRACT = makeContract({ repo: 'app/backend' });
const CROSS_LINK: CrossLink = {
contractId: CONTRACT.contractId,
type: 'http',
matchType: 'exact',
confidence: 1,
from: {
repo: 'app/frontend',
symbolUid: 'uid-2',
symbolRef: { filePath: 'src/client.ts', name: 'callUsers' },
},
to: {
repo: 'app/backend',
symbolUid: 'uid-1',
symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' },
},
};
let home: string;
let groupDir: string;
beforeEach(() => {
syncGroupMock.mockReset();
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-payload-'));
groupDir = path.join(home, 'groups', GROUP);
fs.mkdirSync(groupDir, { recursive: true });
fs.writeFileSync(
path.join(groupDir, 'group.yaml'),
`version: 1
name: ${GROUP}
description: ""
repos:
app/backend: payload-backend
app/frontend: payload-frontend
`,
'utf8',
);
vi.stubEnv('GITNEXUS_HOME', home);
});
afterEach(() => {
vi.unstubAllEnvs();
fs.rmSync(home, { recursive: true, force: true });
});
const seedRegistry = (registry: Record<string, unknown>): void =>
fs.writeFileSync(path.join(groupDir, 'contracts.json'), JSON.stringify(registry), 'utf8');
const BASE_REGISTRY = {
version: 1,
generatedAt: '2026-01-01T00:00:00.000Z',
repoSnapshots: {},
missingRepos: [],
contracts: [CONTRACT],
crossLinks: [CROSS_LINK],
};
describe('group_sync forwards what the sync learned about the repos and the file', () => {
it('carries the unreadable list and the registry outcome, by exact shape', async () => {
// The headline case: a sync that could read nothing and therefore kept the
// previous registry. An agent that calls `group_sync` and then
// `group_contracts` a moment later otherwise sees contract counts that
// disagree with this payload, with nothing here explaining why the write
// was skipped — and no way to tell "the group has no contracts" from "this
// run could not read the repos that hold them".
syncGroupMock.mockResolvedValue(
syncResult({
contracts: [CONTRACT],
crossLinks: [CROSS_LINK],
unmatched: [CONTRACT],
missingRepos: ['app/frontend'],
unreadableRepos: ['app/backend'],
registryOutcome: 'preserved',
}),
);
const payload = await new GroupService(port).groupSync({ name: GROUP });
// `toEqual`, not `toMatchObject`: deleting either forwarded line from the
// return literal leaves a payload that a partial match still accepts.
expect(payload).toEqual({
contracts: 1,
crossLinks: 1,
unmatched: 1,
missingRepos: ['app/frontend'],
unreadableRepos: ['app/backend'],
suppressedMatchStages: [],
registryOutcome: 'preserved',
...WIRE_SYNC_QUALITY,
});
});
it('reports an empty unreadable list as the measurement it is', async () => {
// `[]` here is "this sync accounted for every repo", and it has to arrive
// as `[]` rather than as an absent key: on the response boundary the two
// are the difference between a clean result and an unmeasured one.
syncGroupMock.mockResolvedValue(syncResult({ registryOutcome: 'written' }));
const payload = await new GroupService(port).groupSync({ name: GROUP });
expect(payload).toEqual({
contracts: 0,
crossLinks: 0,
unmatched: 0,
missingRepos: [],
unreadableRepos: [],
suppressedMatchStages: [],
registryOutcome: 'written',
...WIRE_SYNC_QUALITY,
});
});
it('names each write outcome the sync can reach', async () => {
// `registryOutcome` is a union of four, and the CLI's outcome chain has no
// fallback branch — a value that never reached the wire would fall through
// it silently. Forwarding is verbatim, so this pins that too.
const outcomes: SyncResult['registryOutcome'][] = [
'written',
'preserved',
'no-prior-registry',
'not-attempted',
];
const seen: unknown[] = [];
for (const registryOutcome of outcomes) {
syncGroupMock.mockResolvedValue(syncResult({ registryOutcome }));
const payload = (await new GroupService(port).groupSync({ name: GROUP })) as Record<
string,
unknown
>;
seen.push(payload.registryOutcome);
}
expect(seen).toEqual(outcomes);
});
});
describe('group_contracts forwards its structured incompleteness', () => {
it('omits the unreadable list, and calls the listing a floor, when the sync never recorded one', async () => {
// Provenance unknown. The registry predates the field (or held something
// that was not a list of repo paths), so this listing cannot say which
// repos the sync failed to read — and therefore cannot claim to be
// complete. Inventing `[]` here would report an unmeasured state as a clean
// one, which is the conflation the whole tri-state removes.
seedRegistry(BASE_REGISTRY);
const payload = await new GroupService(port).groupContracts({ name: GROUP });
expect(payload).toEqual({
contracts: [CONTRACT],
crossLinks: [CROSS_LINK],
missingRepos: [],
truncated: true,
truncationReason: 'incomplete-sync',
riskEpistemic: 'lower-bound',
});
// The same claim stated directly, because it is an ABSENCE and absence is
// the one thing a reader of the assertion above has to infer.
expect(payload).not.toHaveProperty('unreadableRepos');
});
it('returns the measured empty list, and calls the listing complete', async () => {
// The middle state, and the only one that may answer `truncated: false`.
seedRegistry({ ...BASE_REGISTRY, unreadableRepos: [] });
const payload = await new GroupService(port).groupContracts({ name: GROUP });
expect(payload).toEqual({
contracts: [CONTRACT],
crossLinks: [CROSS_LINK],
missingRepos: [],
unreadableRepos: [],
truncated: false,
});
// `truncationReason` and `riskEpistemic` ride `truncated: true` and must not
// appear beside a complete answer — an agent that branches on either one
// being present would read this listing as a floor.
expect(payload).not.toHaveProperty('truncationReason');
expect(payload).not.toHaveProperty('riskEpistemic');
});
it('names the repos, and marks the listing a floor, when the sync recorded some', async () => {
// The populated state. `truncated` alone says the answer was cut short;
// `unreadableRepos` is what says WHERE, and it is the field that turns "this
// listing is incomplete" into something an operator can act on.
seedRegistry({ ...BASE_REGISTRY, unreadableRepos: ['app/backend'] });
const payload = await new GroupService(port).groupContracts({ name: GROUP });
expect(payload).toEqual({
contracts: [CONTRACT],
crossLinks: [CROSS_LINK],
missingRepos: [],
unreadableRepos: ['app/backend'],
truncated: true,
truncationReason: 'incomplete-sync',
riskEpistemic: 'lower-bound',
});
});
it('marks the listing a floor for a repo the registry recorded as missing too', async () => {
// The two lists are independent diagnostics with one consequence — none of
// those repos' contracts are in the artifact — so the completeness fold
// reads both. A `truncated` derived from `unreadableRepos` alone would call
// this listing complete while a whole member is unaccounted for.
seedRegistry({ ...BASE_REGISTRY, missingRepos: ['app/frontend'], unreadableRepos: [] });
const payload = await new GroupService(port).groupContracts({ name: GROUP });
expect(payload).toEqual({
contracts: [CONTRACT],
crossLinks: [CROSS_LINK],
missingRepos: ['app/frontend'],
unreadableRepos: [],
truncated: true,
truncationReason: 'incomplete-sync',
riskEpistemic: 'lower-bound',
});
});
});
/**
* What `group_sync` REFUSES to run on.
*
* The MCP SDK does not enforce a tool's advertised `inputSchema` and
* `callTool` is reachable directly, so this service method is the real
* validation boundary. Two consequences this block pins:
*
* - `exactOnly` now gates a matching stage, so `Boolean(params.exactOnly)`
* turned the string `"false"` — a common shape for an LLM caller emitting
* JSON — into `true` and persisted a registry with the wildcard stage
* suppressed. The opposite of what the caller asked for, written to disk.
* - `skipEmbeddings` and `allowStale` were retired. The CLI rejects them
* outright; the MCP path accepted and silently dropped them, so an agent
* working from a cached schema was told nothing.
*
* Every case asserts `syncGroupMock` was NOT called: a rejection that still
* runs the sync is the failure mode, and an error string alone cannot tell
* the two apart.
*/
describe('group_sync rejects malformed and retired parameters', () => {
it.each([['false'], ['true'], [0], [1], [null], [{}], [[]]])(
'rejects a non-boolean exactOnly (%j) and runs no sync',
async (bad) => {
const payload = await new GroupService(port).groupSync({ name: GROUP, exactOnly: bad });
expect(payload).toEqual({
error: `Invalid "exactOnly": expected true or false, got ${JSON.stringify(bad)}.`,
});
expect(syncGroupMock).not.toHaveBeenCalled();
},
);
// `verbose` is no longer part of this tool's surface: not advertised, not
// validated, not forwarded. A caller that still sends it is ignored rather
// than refused — it was never a documented parameter, so there is nothing to
// reject on behalf of, and the retired-name guard is reserved for parameters
// this tool actually withdrew.
it('ignores verbose entirely rather than validating or forwarding it', async () => {
syncGroupMock.mockResolvedValue(syncResult());
const payload = (await new GroupService(port).groupSync({
name: GROUP,
verbose: 'not-a-boolean',
})) as Record<string, unknown>;
expect(payload.error).toBeUndefined();
expect(syncGroupMock).toHaveBeenCalledTimes(1);
expect(syncOptsOf(0)).not.toHaveProperty('verbose');
});
// The error path must not throw. `JSON.stringify` — the right renderer here,
// because it distinguishes the string "false" from the boolean — throws on a
// BigInt and on a cyclic object, and `callTool` is reachable directly, so a
// validator that rejects instead of returning `{ error }` breaks its own
// contract on inputs a caller can actually send.
it('returns a structured error rather than throwing on an unserializable value', async () => {
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
const fromBigInt = (await new GroupService(port).groupSync({
name: GROUP,
exactOnly: 1n,
})) as Record<string, unknown>;
const fromCyclic = (await new GroupService(port).groupSync({
name: GROUP,
exactOnly: cyclic,
})) as Record<string, unknown>;
expect(String(fromBigInt.error)).toContain('Invalid "exactOnly"');
expect(String(fromCyclic.error)).toContain('Invalid "exactOnly"');
expect(syncGroupMock).not.toHaveBeenCalled();
});
it.each([['skipEmbeddings'], ['allowStale']])(
'rejects the retired %s parameter by name and runs no sync',
async (retired) => {
const payload = await new GroupService(port).groupSync({ name: GROUP, [retired]: true });
expect(payload).toEqual({
error: `"${retired}" was removed and is no longer accepted. Drop it from the call.`,
});
expect(syncGroupMock).not.toHaveBeenCalled();
},
);
it.each([[true], [false]])(
'passes a real boolean exactOnly (%j) through unchanged',
async (ok) => {
syncGroupMock.mockResolvedValue(syncResult());
await new GroupService(port).groupSync({ name: GROUP, exactOnly: ok });
expect(syncGroupMock).toHaveBeenCalledTimes(1);
expect(syncOptsOf(0)).toMatchObject({ exactOnly: ok });
},
);
it('treats an omitted exactOnly as false', async () => {
syncGroupMock.mockResolvedValue(syncResult());
await new GroupService(port).groupSync({ name: GROUP });
expect(syncOptsOf(0)).toMatchObject({ exactOnly: false });
});
// control: the guards above reject specific shapes, not every call. Without
// this, deleting the whole method body and returning an error would pass.
it('control: a valid call with only a name still syncs', async () => {
syncGroupMock.mockResolvedValue(syncResult({ registryOutcome: 'written' }));
const payload = (await new GroupService(port).groupSync({ name: GROUP })) as Record<
string,
unknown
>;
expect(payload.error).toBeUndefined();
expect(payload.registryOutcome).toBe('written');
expect(syncGroupMock).toHaveBeenCalledTimes(1);
});
});