mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
* 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>
449 lines
17 KiB
TypeScript
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);
|
|
});
|
|
});
|