GitNexus/gitnexus/test/unit/move/move-ingest-query-errors.test.ts

221 lines
7.3 KiB
TypeScript

/**
* Package-query failure classification in the moveIngest phase.
*
* A package build failure is skip-and-warn by default (GITNEXUS_MOVE_STRICT=1
* restores the fatal path — covered in move-ingest-skip-and-warn.test.ts).
* Timeouts and transport crashes still abort after the client's transport
* retry. Supplemental function-usage failures are reported without discarding
* the compiler graph.
*/
import { describe, it, expect } from 'vitest';
import path from 'node:path';
import {
MoveFlowTimeoutError,
MoveFlowToolCallError,
type MoveFlowClient,
} from '../../../src/core/move/mcp-client.js';
import {
makeMoveFlowClientStub,
moveFunctionFact,
runMoveIngestPhase,
} from '../../helpers/move-ingest-harness.js';
const REPO_ROOT = path.resolve('/repo');
const runPhase = (client: MoveFlowClient) =>
runMoveIngestPhase(client, REPO_ROOT, ['pkg/Move.toml', 'pkg/sources/t.move']);
describe('moveIngest package-query failure classification', () => {
it('queries move-flow again on every ingest run', async () => {
let factsCall = 0;
const client = makeMoveFlowClientStub({
callGraph: async () => ({}),
facts: async () => {
factsCall += 1;
return {
'0xa::t': {
file: path.join(REPO_ROOT, 'pkg/sources/t.move'),
functions: [
moveFunctionFact(factsCall === 1 ? 'first' : 'second', { visibility: 'public' }),
],
},
};
},
});
const first = await runPhase(client);
const second = await runPhase(client);
expect(client.counts).toMatchObject({ capabilities: 2, callGraph: 2, facts: 2 });
expect(first.functionNodeMap.has('0xa::t::first')).toBe(true);
expect(second.functionNodeMap.has('0xa::t::second')).toBe(true);
});
it('serializes call-graph and facts requests on one move-flow client', async () => {
const order: string[] = [];
const client = makeMoveFlowClientStub({
callGraph: async () => {
order.push('callGraph:start');
await Promise.resolve();
order.push('callGraph:end');
return {};
},
facts: async () => {
order.push('facts:start');
return {};
},
});
await runPhase(client);
expect(order).toEqual(['callGraph:start', 'callGraph:end', 'facts:start']);
});
it('reports supplemental function-usage failures without aborting ingestion', async () => {
const client = makeMoveFlowClientStub({
facts: async () => ({
'0xa::t': {
file: path.join(REPO_ROOT, 'pkg/sources/t.move'),
functions: [
moveFunctionFact('with_callback', {
visibility: 'public',
params: [],
returnTypes: ['|u64|u64'],
}),
moveFunctionFact('after_failure', { visibility: 'public', params: [] }),
],
},
}),
functionUsage: async () => {
throw new MoveFlowToolCallError('function usage unavailable');
},
});
const output = await runPhase(client);
expect(output.functionNodeMap.has('0xa::t::with_callback')).toBe(true);
expect(client.counts.functionUsage).toBeLessThanOrEqual(2);
expect(output.functionUsageFailures).toHaveLength(1);
expect(output.consistencyIssues).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: 'function-usage-query-failed',
severity: 'warning',
}),
]),
);
});
it('re-arms supplemental queries for later packages and aggregates one failure per package', async () => {
const client = makeMoveFlowClientStub({
facts: async (packageRoot) => {
const moduleName = path.basename(packageRoot);
return {
[`0xa::${moduleName}`]: {
file: path.join(packageRoot, 'sources/t.move'),
functions: [moveFunctionFact('run', { visibility: 'public', params: [] })],
},
};
},
functionUsage: async () => {
throw new MoveFlowToolCallError('function usage unavailable');
},
});
const output = await runMoveIngestPhase(client, REPO_ROOT, [
'pkg_a/Move.toml',
'pkg_a/sources/t.move',
'pkg_b/Move.toml',
'pkg_b/sources/t.move',
]);
expect(client.counts.functionUsage).toBe(2);
expect(output.functionUsageFailures).toHaveLength(2);
});
it('does not call function_usage when the capability is absent', async () => {
const client = makeMoveFlowClientStub({
capabilities: async () => ({
hasFactsQuery: true,
hasFunctionUsageQuery: false,
hasStatusTool: true,
}),
facts: async () => ({
'0xa::t': {
file: path.join(REPO_ROOT, 'pkg/sources/t.move'),
functions: [moveFunctionFact('run', { visibility: 'public' })],
},
}),
});
const output = await runPhase(client);
expect(client.counts.functionUsage).toBe(0);
expect(output.functionUsageFailures).toEqual([]);
});
it('marks a timeout operator-actionable without a phase-level retry', async () => {
const client = makeMoveFlowClientStub({
callGraph: async () => {
throw new MoveFlowTimeoutError(
"move-flow 'move_package_query' timed out after 300000ms " +
'(raise GITNEXUS_MOVE_FLOW_TIMEOUT_MS for large packages)',
);
},
});
await expect(runPhase(client)).rejects.toMatchObject({
userActionable: true,
message: expect.stringContaining('timed out'),
});
expect(client.counts.callGraph).toBe(1);
});
it('skips a package that fails to build (skip-and-warn) instead of aborting', async () => {
const client = makeMoveFlowClientStub({
callGraph: async () => {
throw new MoveFlowToolCallError('failed to build package `pkg`: missing dependency');
},
});
// Default is skip-and-warn: the analyze continues, the package is left
// un-ingested, and a persistent operator warning is surfaced instead of a
// throw (the fatal GITNEXUS_MOVE_STRICT path lives in the skip-and-warn suite).
const output = await runPhase(client);
expect(output.functionNodeMap.size).toBe(0);
expect(output.consistencyIssues).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'package-build-failed', severity: 'warning' }),
]),
);
expect(output.ingestWarnings ?? []).toEqual(
expect.arrayContaining([expect.stringContaining('could not build it')]),
);
expect(client.counts.callGraph).toBe(1);
});
it('skips isNative functions in the function_usage scan', async () => {
const client = makeMoveFlowClientStub({
facts: async () => ({
'0xa::m': {
file: 'a.move',
functions: [moveFunctionFact('nat', { isNative: true }), moveFunctionFact('reg')],
structs: [],
constants: [],
},
}),
callGraph: async () => ({}),
});
await runMoveIngestPhase(client, '/repo', ['a.move', 'Move.toml']);
expect(client.counts.functionUsage).toBe(1);
});
it('propagates a transport crash unchanged (the client already retried)', async () => {
const crash = new Error('move-flow exited unexpectedly (code 137)');
const client = makeMoveFlowClientStub({
callGraph: async () => {
throw crash;
},
});
await expect(runPhase(client)).rejects.toBe(crash);
expect(client.counts.callGraph).toBe(1);
});
});