mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
fix(move): skip unbuildable packages, placeholder pre-flight, degraded-build warning
- A Move package move-flow cannot build no longer aborts the whole analyze: it is skipped with a persistent warning naming the package and the first compiler diagnostic (GITNEXUS_MOVE_STRICT=1 restores the fatal behavior). Previously one broken test fixture among aptos-core's 479 packages killed the entire run at 60%. - "_" placeholder named addresses in Move.toml are caught pre-flight with an actionable message instead of a guaranteed compile-and-fail (move-flow has no dev-mode build). - After every facts ingestion the package build status is probed: builds with compiler errors emit a degraded-fidelity warning, because move-flow silently omits acquiresInferred from erroring builds while returning otherwise complete facts (aptos-labs/aptos-ai#18 #19; tracking #2659). - Warnings ride a new language-neutral ingestWarnings channel (StandaloneIngestOutput -> PipelineResult -> AnalyzeResult -> CLI summary), mirroring the persistent FTS warning so they survive the progress bar. - README: "Analyzing real-world Move repositories" section documenting the new behavior, GITNEXUS_MOVE_STRICT, .gitnexusignore scoping, and the GITNEXUS_MOVE_FLOW_TIMEOUT_MS knob for cold git-dependency builds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
5868b10d37
commit
3483aeb6f8
10 changed files with 537 additions and 12 deletions
|
|
@ -375,6 +375,31 @@ described in [Move compiler provisioning](#move-compiler-provisioning) —
|
|||
including the `MOVE_FLOW` override and `GITNEXUS_SKIP_MOVE_FLOW=1` for
|
||||
air-gapped hosts.
|
||||
|
||||
### Analyzing real-world Move repositories
|
||||
|
||||
Real repos routinely contain Move packages that cannot build standalone (test
|
||||
fixtures, examples, fuzzer corpora — `aptos-core` alone has 470+). `analyze`
|
||||
handles them per package instead of giving up:
|
||||
|
||||
- **Unbuildable packages are skipped with a warning**, not fatal: their
|
||||
`.move` files stay out of the graph and the final summary names each skipped
|
||||
package with the compiler's first diagnostic. Set `GITNEXUS_MOVE_STRICT=1`
|
||||
to make any build failure abort the analyze instead.
|
||||
- **`_` placeholder addresses** (`econia = "_"` in `[addresses]`) are caught
|
||||
pre-flight — MoveFlow has no dev-mode build, so set concrete addresses in
|
||||
`Move.toml` or exclude the package.
|
||||
- **Builds with compiler errors still yield facts, at reduced fidelity**: the
|
||||
MoveFlow compiler silently omits inferred `acquires` data from erroring
|
||||
builds, so such packages are ingested with a persistent
|
||||
"compiled with errors" warning. A common cause is a framework dependency
|
||||
newer than MoveFlow's pinned compiler (e.g. unrecognized spec pragmas).
|
||||
- **`.gitnexusignore`** (gitignore syntax, repo root) excludes directories from
|
||||
analysis entirely — the fastest way to scope large repos to the packages you
|
||||
care about, and the remedy the skip warnings suggest.
|
||||
- **Cold builds of git-based framework dependencies can exceed the 5-minute
|
||||
compile budget**; raise it with `GITNEXUS_MOVE_FLOW_TIMEOUT_MS` (e.g.
|
||||
`1800000` for 30 min) for the first analyze.
|
||||
|
||||
## Release candidates
|
||||
|
||||
Stable releases publish to the default `latest` dist-tag. When a pull request
|
||||
|
|
|
|||
|
|
@ -1570,6 +1570,15 @@ const analyzeCommandImpl = async (
|
|||
);
|
||||
}
|
||||
|
||||
// Standalone-ingest warnings (skipped/degraded Move packages) share the
|
||||
// FTS warning's rationale: mid-run progress lines scroll away, so anything
|
||||
// the operator must act on has to reappear in the final summary.
|
||||
if (result.ingestWarnings && result.ingestWarnings.length > 0) {
|
||||
for (const warning of result.ingestWarnings) {
|
||||
console.log(`\n Warning: ${warning}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(getGlobalRegistryPath());
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ import type { PipelinePhase } from './types.js';
|
|||
*/
|
||||
export interface StandaloneIngestOutput {
|
||||
readonly ingestedFiles: ReadonlySet<string>;
|
||||
/**
|
||||
* Operator-actionable warnings the ingester wants surfaced in the persistent
|
||||
* CLI summary (e.g. a package it had to skip or ingest at degraded fidelity).
|
||||
* Language-neutral: the pipeline passes these through without interpreting.
|
||||
*/
|
||||
readonly ingestWarnings?: readonly string[];
|
||||
}
|
||||
|
||||
/** Default no-op used when the caller does not supply a standalone ingester. */
|
||||
|
|
|
|||
|
|
@ -321,6 +321,16 @@ export const runPipelineFromRepo = async (
|
|||
|
||||
let communityResult: CommunitiesOutput['communityResult'] | undefined;
|
||||
let processResult: ProcessesOutput['processResult'] | undefined;
|
||||
// Standalone-ingest warnings, passed through opaquely (language-neutral).
|
||||
let ingestWarnings: readonly string[] | undefined;
|
||||
try {
|
||||
ingestWarnings = getPhaseOutput<StandaloneIngestOutput>(
|
||||
results,
|
||||
'standaloneIngest',
|
||||
).ingestWarnings;
|
||||
} catch {
|
||||
/* phase filtered out of this run — nothing to surface */
|
||||
}
|
||||
const scopeResolutionOutput = getPhaseOutput<ScopeResolutionOutput>(results, 'scopeResolution');
|
||||
const resolutionOutcomes = scopeResolutionOutput.resolutionOutcomes;
|
||||
// Streamed PDG-emit manifest (#2202): present only when streaming was on.
|
||||
|
|
@ -354,5 +364,6 @@ export const runPipelineFromRepo = async (
|
|||
resolutionOutcomes,
|
||||
usedWorkerPool,
|
||||
pdgEmitManifest,
|
||||
ingestWarnings,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,7 +13,15 @@ export interface MoveConsistencyIssue {
|
|||
| 'unresolved-resource-target'
|
||||
/** Package with .move sources returned facts `{}` - severity policy in
|
||||
* `emptyFactsIssue` below. */
|
||||
| 'empty-package-facts';
|
||||
| 'empty-package-facts'
|
||||
/** Package skipped: move-flow could not build it (skip-and-warn, #2624). */
|
||||
| 'package-build-failed'
|
||||
/** Package skipped pre-flight: Move.toml [addresses] has `_` placeholders
|
||||
* move-flow cannot resolve (it has no dev-mode build). */
|
||||
| 'unresolved-named-address'
|
||||
/** Package ingested, but its build carries compiler errors - move-flow
|
||||
* silently omits inferred facts (acquires) from such builds. */
|
||||
| 'degraded-package-facts';
|
||||
severity: MoveConsistencySeverity;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
|
|
@ -67,6 +75,107 @@ export function emptyFactsIssue(pkg: EmptyFactsPackage): MoveConsistencyIssue {
|
|||
};
|
||||
}
|
||||
|
||||
/** First non-empty line of a compiler diagnostic blob (for one-line summaries). */
|
||||
function firstDiagnosticLine(diagnostics: string | undefined): string {
|
||||
if (!diagnostics) return '';
|
||||
for (const line of diagnostics.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* A package skipped because move-flow could not build it (skip-and-warn).
|
||||
* Warning, not error: the analyze continues and the skip is surfaced in the
|
||||
* CLI summary; GITNEXUS_MOVE_STRICT=1 restores the historical fatal behavior.
|
||||
*/
|
||||
export function buildFailedIssue(pkg: {
|
||||
pkgRoot: string;
|
||||
moveFileCount: number;
|
||||
diagnostics: string;
|
||||
}): MoveConsistencyIssue {
|
||||
const firstLine = firstDiagnosticLine(pkg.diagnostics);
|
||||
return {
|
||||
code: 'package-build-failed',
|
||||
severity: 'warning',
|
||||
message:
|
||||
`Move package skipped — move-flow could not build it` +
|
||||
(firstLine ? ` (${firstLine})` : '') +
|
||||
`: ${pkg.pkgRoot}. Fix the package or exclude its directory via .gitnexusignore; ` +
|
||||
`set GITNEXUS_MOVE_STRICT=1 to make build failures fatal.`,
|
||||
details: {
|
||||
packageRoot: pkg.pkgRoot,
|
||||
moveFileCount: pkg.moveFileCount,
|
||||
diagnostics: pkg.diagnostics,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A package skipped pre-flight: its Move.toml `[addresses]` contains `_`
|
||||
* placeholders. move-flow's `move_package_query` has no dev-mode, so the build
|
||||
* would always fail with "Unresolved addresses" - skip with the remedy instead.
|
||||
*/
|
||||
export function unresolvedAddressIssue(pkg: {
|
||||
pkgRoot: string;
|
||||
moveFileCount: number;
|
||||
placeholders: string[];
|
||||
}): MoveConsistencyIssue {
|
||||
return {
|
||||
code: 'unresolved-named-address',
|
||||
severity: 'warning',
|
||||
message:
|
||||
`Move package skipped — named address(es) ${pkg.placeholders.join(', ')} are "_" ` +
|
||||
`placeholders in Move.toml (move-flow cannot build dev-mode): ${pkg.pkgRoot}. ` +
|
||||
`Set concrete addresses in [addresses] or exclude the directory via .gitnexusignore.`,
|
||||
details: {
|
||||
packageRoot: pkg.pkgRoot,
|
||||
moveFileCount: pkg.moveFileCount,
|
||||
placeholders: pkg.placeholders,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A package that WAS ingested but whose build carries compiler errors.
|
||||
* move-flow still serves structurally complete facts for such builds but
|
||||
* silently drops inference-stage output (`acquiresInferred`), so the graph is
|
||||
* missing ACQUIRES edges/properties — surface it instead of implying full
|
||||
* fidelity. (Commonly: a framework dependency newer than move-flow's pinned
|
||||
* compiler, e.g. spec pragmas it does not recognize.)
|
||||
*/
|
||||
export function degradedFactsIssue(pkg: {
|
||||
pkgRoot: string;
|
||||
diagnostics: string;
|
||||
}): MoveConsistencyIssue {
|
||||
const firstLine = firstDiagnosticLine(pkg.diagnostics);
|
||||
return {
|
||||
code: 'degraded-package-facts',
|
||||
severity: 'warning',
|
||||
message:
|
||||
`Move package compiled with errors — compiler-inferred facts (acquires) may be ` +
|
||||
`incomplete` +
|
||||
(firstLine ? ` (${firstLine})` : '') +
|
||||
`: ${pkg.pkgRoot}`,
|
||||
details: { packageRoot: pkg.pkgRoot, diagnostics: pkg.diagnostics },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The persistent CLI-summary warnings for a run's Move issues: the three
|
||||
* skip/degrade codes are operator-actionable and must survive past the
|
||||
* scrolling progress bar (same rationale as the FTS warning, #1161).
|
||||
*/
|
||||
export function cliWarningsFromIssues(issues: readonly MoveConsistencyIssue[]): string[] {
|
||||
const surfaced: MoveConsistencyIssue['code'][] = [
|
||||
'package-build-failed',
|
||||
'unresolved-named-address',
|
||||
'degraded-package-facts',
|
||||
];
|
||||
return issues.filter((i) => surfaced.includes(i.code)).map((i) => i.message);
|
||||
}
|
||||
|
||||
export function validateMoveIngestOutput(
|
||||
graph: KnowledgeGraph,
|
||||
moveIngest: MoveIngestOutput,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type {
|
||||
PipelinePhase,
|
||||
PipelineContext,
|
||||
|
|
@ -51,7 +52,11 @@ import {
|
|||
import type { CallGraphMap, MoveFactsMap } from './compiler-facts.js';
|
||||
import { moveModuleNodeId, moveModuleQualifiedName, moveRelId } from './symbol-id.js';
|
||||
import {
|
||||
buildFailedIssue,
|
||||
cliWarningsFromIssues,
|
||||
degradedFactsIssue,
|
||||
emptyFactsIssue,
|
||||
unresolvedAddressIssue,
|
||||
validateMoveIngestOutput,
|
||||
type EmptyFactsPackage,
|
||||
type MoveConsistencyIssue,
|
||||
|
|
@ -79,6 +84,9 @@ export interface MoveIngestOutput extends StandaloneIngestOutput {
|
|||
droppedResourceRefs?: { fnNodeId: string; target: string }[];
|
||||
/** Non-fatal consistency issues found after Move ingestion. */
|
||||
consistencyIssues: MoveConsistencyIssue[];
|
||||
/** Operator-actionable warnings for the persistent CLI summary (skipped or
|
||||
* degraded packages). Part of the neutral StandaloneIngestOutput contract. */
|
||||
ingestWarnings?: readonly string[];
|
||||
}
|
||||
|
||||
/** Mutable accumulator shared while ingesting every package. */
|
||||
|
|
@ -130,9 +138,48 @@ function toOutput(
|
|||
callGraphByPackage: state.callGraphByPackage,
|
||||
droppedResourceRefs: state.droppedResourceRefs,
|
||||
consistencyIssues,
|
||||
ingestWarnings: cliWarningsFromIssues(consistencyIssues),
|
||||
};
|
||||
}
|
||||
|
||||
/** GITNEXUS_MOVE_STRICT=1|true restores the historical fatal-on-build-failure
|
||||
* behavior instead of skip-and-warn. */
|
||||
function isStrictMove(): boolean {
|
||||
const v = process.env.GITNEXUS_MOVE_STRICT?.trim().toLowerCase();
|
||||
return v === '1' || v === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Named addresses assigned the `_` placeholder in a package's Move.toml
|
||||
* `[addresses]` section. Deliberately a line-oriented scan, not a TOML parser:
|
||||
* the two token shapes involved (`[section]`, `name = "_"`) are stable across
|
||||
* every Move manifest and a full parser dependency buys nothing here.
|
||||
* Unreadable/absent manifest → `[]` (the build itself will surface that).
|
||||
*/
|
||||
async function findPlaceholderAddresses(pkgRoot: string): Promise<string[]> {
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(path.join(pkgRoot, 'Move.toml'), 'utf8');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const placeholders: string[] = [];
|
||||
let section = '';
|
||||
for (const raw of text.split('\n')) {
|
||||
const line = raw.replace(/#.*$/, '').trim();
|
||||
if (!line) continue;
|
||||
const sectionMatch = line.match(/^\[(.+)\]$/);
|
||||
if (sectionMatch) {
|
||||
section = sectionMatch[1].trim();
|
||||
continue;
|
||||
}
|
||||
if (section !== 'addresses') continue;
|
||||
const kv = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*["']_["']$/);
|
||||
if (kv) placeholders.push(kv[1]);
|
||||
}
|
||||
return placeholders;
|
||||
}
|
||||
|
||||
/** Add a mapped package's nodes/edges to the graph and merge its identity maps. */
|
||||
function applyMapped(
|
||||
graph: KnowledgeGraph,
|
||||
|
|
@ -215,6 +262,8 @@ export function createMoveIngestPhase(
|
|||
}
|
||||
|
||||
const emptyFactsPackages: EmptyFactsPackage[] = [];
|
||||
const packageIssues: MoveConsistencyIssue[] = [];
|
||||
const strictMove = isStrictMove();
|
||||
|
||||
// Pass 1: per-package nodes/edges (all packages first, so cross-package
|
||||
// CALLS in Pass 2 can resolve callees in later packages).
|
||||
|
|
@ -226,6 +275,18 @@ export function createMoveIngestPhase(
|
|||
stats: { filesProcessed: 0, totalFiles, nodesCreated: ctx.graph.nodeCount },
|
||||
});
|
||||
|
||||
const pkgMoveFiles = moveFilesByPackage.get(pkgRoot) ?? [];
|
||||
|
||||
// Pre-flight: `_` placeholder addresses always fail the build (move-flow
|
||||
// has no dev-mode), so skip before spending a compile on the known outcome.
|
||||
const placeholders = await findPlaceholderAddresses(pkgRoot);
|
||||
if (placeholders.length > 0) {
|
||||
packageIssues.push(
|
||||
unresolvedAddressIssue({ pkgRoot, moveFileCount: pkgMoveFiles.length, placeholders }),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let callGraphData: CallGraphMap;
|
||||
let factsMap: MoveFactsMap;
|
||||
try {
|
||||
|
|
@ -233,18 +294,36 @@ export function createMoveIngestPhase(
|
|||
factsMap = await client.facts(pkgRoot);
|
||||
} catch (err) {
|
||||
if (err instanceof MoveFlowToolCallError) {
|
||||
// userActionable: rendered as a one-liner without a stack - a Move
|
||||
// package that does not build (bad manifest, missing dependency,
|
||||
// nonexistent path) is an operator problem, not a code bug.
|
||||
throw Object.assign(
|
||||
new Error(`move-flow could not build Move package ${pkgRoot}: ${err.message}`),
|
||||
{ userActionable: true },
|
||||
// A Move package that does not build (bad manifest, missing
|
||||
// dependency, unresolved address) is an operator problem, not a
|
||||
// code bug. Default: skip the package (its files stay un-ingested,
|
||||
// like the empty-facts path) and surface a persistent warning —
|
||||
// one broken auxiliary package must not abort the whole analyze.
|
||||
if (strictMove) {
|
||||
// userActionable: rendered as a one-liner without a stack.
|
||||
throw Object.assign(
|
||||
new Error(`move-flow could not build Move package ${pkgRoot}: ${err.message}`),
|
||||
{ userActionable: true },
|
||||
);
|
||||
}
|
||||
packageIssues.push(
|
||||
buildFailedIssue({
|
||||
pkgRoot,
|
||||
moveFileCount: pkgMoveFiles.length,
|
||||
diagnostics: err.message,
|
||||
}),
|
||||
);
|
||||
ctx.onProgress({
|
||||
phase: 'moveIngest',
|
||||
percent: 18,
|
||||
message: `Skipping Move package (build failed): ${path.basename(pkgRoot)}`,
|
||||
stats: { filesProcessed: 0, totalFiles, nodesCreated: ctx.graph.nodeCount },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const pkgMoveFiles = moveFilesByPackage.get(pkgRoot) ?? [];
|
||||
if (Object.keys(factsMap).length === 0 && pkgMoveFiles.length > 0) {
|
||||
// Facts `{}` is ambiguous: syntax-broken packages return it as a
|
||||
// SUCCESS (the compiler diagnostic only surfaces via
|
||||
|
|
@ -265,6 +344,15 @@ export function createMoveIngestPhase(
|
|||
for (const rel of pkgMoveFiles) state.ingestedFiles.add(rel);
|
||||
|
||||
applyMapped(ctx.graph, mapFactsToGraph(factsMap, pkgRoot, ctx.repoPath), pkgRoot, state);
|
||||
|
||||
// Facts arrived, but move-flow serves structurally complete facts even
|
||||
// for builds with compiler errors — and such builds silently lose the
|
||||
// inference stage (`acquiresInferred`, hence ACQUIRES edges). Probe the
|
||||
// build status so the degraded fidelity is surfaced, not implied away.
|
||||
const status = await probePackageStatus(client, pkgRoot, hasStatusTool);
|
||||
if (status && !status.ok) {
|
||||
packageIssues.push(degradedFactsIssue({ pkgRoot, diagnostics: status.diagnostics }));
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2+: link edges that need the full cross-package node index.
|
||||
|
|
@ -277,10 +365,17 @@ export function createMoveIngestPhase(
|
|||
const output = toOutput(state, packageRoots);
|
||||
createMoveEntryPointEdges(ctx.graph, output);
|
||||
|
||||
const consistencyIssues: MoveConsistencyIssue[] = emptyFactsPackages.map(emptyFactsIssue);
|
||||
const consistencyIssues: MoveConsistencyIssue[] = [
|
||||
...packageIssues,
|
||||
...emptyFactsPackages.map(emptyFactsIssue),
|
||||
];
|
||||
consistencyIssues.push(...validateMoveIngestOutput(ctx.graph, output));
|
||||
reportConsistencyIssues(ctx, consistencyIssues);
|
||||
return { ...output, consistencyIssues };
|
||||
return {
|
||||
...output,
|
||||
consistencyIssues,
|
||||
ingestWarnings: cliWarningsFromIssues(consistencyIssues),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -368,6 +368,12 @@ export interface AnalyzeResult {
|
|||
* the persisted meta surface the degraded state instead of reporting healthy.
|
||||
*/
|
||||
ftsSkipped?: boolean;
|
||||
/**
|
||||
* Operator-actionable warnings from the standalone ingest phase (e.g. Move
|
||||
* packages skipped or ingested at degraded fidelity). Rendered persistently
|
||||
* in the CLI summary — same rationale as the FTS warning (#1161).
|
||||
*/
|
||||
ingestWarnings?: readonly string[];
|
||||
/**
|
||||
* True when the index this run produced/validated is the flat workspace
|
||||
* slot (#2106 R2, inverted by #2354 to follow the checked-out branch).
|
||||
|
|
@ -2685,6 +2691,7 @@ export async function runFullAnalysis(
|
|||
stats: meta.stats,
|
||||
pipelineResult,
|
||||
ftsSkipped: !ftsReady,
|
||||
ingestWarnings: pipelineResult.ingestWarnings,
|
||||
isPrimaryBranch: !placement.branch,
|
||||
};
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -36,4 +36,11 @@ export interface PipelineResult {
|
|||
* layer (if any) is resident in `graph` and persists via the whole-graph emit.
|
||||
*/
|
||||
pdgEmitManifest?: PdgEmitManifest;
|
||||
/**
|
||||
* Operator-actionable warnings from the standalone ingest phase (skipped or
|
||||
* degraded-fidelity packages). Passed through opaquely — the pipeline does
|
||||
* not know which language produced them — so the CLI summary can render them
|
||||
* persistently (same rationale as the FTS warning).
|
||||
*/
|
||||
ingestWarnings?: readonly string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,7 +90,11 @@ describe('moveIngest empty-facts discrimination', () => {
|
|||
expect(issues[0].message).toContain('does it compile?');
|
||||
});
|
||||
|
||||
it('does not probe status when facts are non-empty', async () => {
|
||||
it('probes status once even when facts are non-empty (degraded-build detection, #2624)', async () => {
|
||||
// Contract change from the original "never probe on success": move-flow
|
||||
// serves complete-looking facts for erroring builds while silently dropping
|
||||
// `acquiresInferred`, so the ONLY way to detect degraded fidelity is a
|
||||
// status probe after ingestion. A clean status must stay warning-free.
|
||||
let statusCalls = 0;
|
||||
const output = await runPhase(
|
||||
makeClient({
|
||||
|
|
@ -112,8 +116,11 @@ describe('moveIngest empty-facts discrimination', () => {
|
|||
}),
|
||||
);
|
||||
|
||||
expect(statusCalls).toBe(0);
|
||||
expect(statusCalls).toBe(1);
|
||||
expect(emptyFactsIssues(output)).toHaveLength(0);
|
||||
expect(
|
||||
output.consistencyIssues.filter((i) => i.code === 'degraded-package-facts'),
|
||||
).toHaveLength(0);
|
||||
expect(output.ingestedFiles.has('pkg/sources/t.move')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
249
gitnexus/test/unit/move/move-ingest-skip-and-warn.test.ts
Normal file
249
gitnexus/test/unit/move/move-ingest-skip-and-warn.test.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
/**
|
||||
* Skip-and-warn behavior of the moveIngest phase (#2624).
|
||||
*
|
||||
* A Move package that move-flow cannot build must not abort the whole analyze:
|
||||
* the phase skips it (files stay un-ingested, like the empty-facts path),
|
||||
* records a `package-build-failed` warning, and surfaces it via
|
||||
* `ingestWarnings` for the persistent CLI summary. GITNEXUS_MOVE_STRICT=1
|
||||
* restores the historical fatal behavior. `_` placeholder addresses in
|
||||
* Move.toml are caught pre-flight (`unresolved-named-address`) without
|
||||
* spending a compile. Packages whose build carries compiler errors but still
|
||||
* serve facts are ingested WITH a `degraded-package-facts` warning, because
|
||||
* move-flow silently drops `acquiresInferred` from erroring builds.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { MoveFlowToolCallError, type MoveFlowClient } from '../../../src/core/move/mcp-client.js';
|
||||
import { runMoveIngestPhase } from '../../helpers/move-ingest-harness.js';
|
||||
|
||||
const REPO_ROOT = path.resolve('/repo');
|
||||
|
||||
function makeClient(overrides: Partial<MoveFlowClient> = {}): MoveFlowClient {
|
||||
return {
|
||||
facts: async () => ({}),
|
||||
callGraph: async () => ({}),
|
||||
packageStatus: async () => ({ ok: true, diagnostics: 'no errors or warnings' }),
|
||||
capabilities: async () => ({ hasFactsQuery: true, hasStatusTool: true }),
|
||||
shutdown: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal non-empty facts map for a package rooted at `pkgDir` (absolute). */
|
||||
function factsFor(pkgDir: string, moduleQn = '0xa::m') {
|
||||
return {
|
||||
[moduleQn]: {
|
||||
file: path.join(pkgDir, 'sources', 't.move'),
|
||||
span: [1, 3] as [number, number],
|
||||
friends: [],
|
||||
attributes: [],
|
||||
functions: [],
|
||||
structs: [],
|
||||
constants: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.GITNEXUS_MOVE_STRICT;
|
||||
});
|
||||
|
||||
describe('moveIngest skip-and-warn on build failure', () => {
|
||||
it('skips the broken package, keeps the rest, and surfaces a warning', async () => {
|
||||
const brokenRoot = path.join(REPO_ROOT, 'broken');
|
||||
const goodRoot = path.join(REPO_ROOT, 'good');
|
||||
const client = makeClient({
|
||||
callGraph: async (pkg) => {
|
||||
if (pkg === brokenRoot) {
|
||||
throw new MoveFlowToolCallError(
|
||||
'failed to build package `broken`: Unresolved addresses found: [abi]',
|
||||
);
|
||||
}
|
||||
return {};
|
||||
},
|
||||
facts: async (pkg) => (pkg === goodRoot ? factsFor(goodRoot) : {}),
|
||||
});
|
||||
|
||||
const output = await runMoveIngestPhase(client, REPO_ROOT, [
|
||||
'broken/Move.toml',
|
||||
'broken/sources/b.move',
|
||||
'good/Move.toml',
|
||||
'good/sources/t.move',
|
||||
]);
|
||||
|
||||
// The good package is fully ingested; the broken one has zero footprint.
|
||||
expect(output.ingestedFiles.has('good/sources/t.move')).toBe(true);
|
||||
expect(output.ingestedFiles.has('broken/sources/b.move')).toBe(false);
|
||||
expect(output.callGraphByPackage.has(brokenRoot)).toBe(false);
|
||||
|
||||
const issues = output.consistencyIssues.filter((i) => i.code === 'package-build-failed');
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].severity).toBe('warning');
|
||||
expect(issues[0].message).toContain('Unresolved addresses found');
|
||||
expect(issues[0].message).toContain('.gitnexusignore');
|
||||
expect(issues[0].details?.packageRoot).toBe(brokenRoot);
|
||||
|
||||
// The warning reaches the neutral CLI-summary channel.
|
||||
expect(output.ingestWarnings?.some((w) => w.includes('move-flow could not build'))).toBe(true);
|
||||
});
|
||||
|
||||
it('GITNEXUS_MOVE_STRICT=1 restores the fatal behavior', async () => {
|
||||
process.env.GITNEXUS_MOVE_STRICT = '1';
|
||||
const client = makeClient({
|
||||
callGraph: async () => {
|
||||
throw new MoveFlowToolCallError('failed to build package `pkg`: boom');
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
runMoveIngestPhase(client, REPO_ROOT, ['pkg/Move.toml', 'pkg/sources/t.move']),
|
||||
).rejects.toThrow(/move-flow could not build Move package/);
|
||||
});
|
||||
|
||||
it('non-tool-call errors still abort (transport faults are not skippable)', async () => {
|
||||
const client = makeClient({
|
||||
callGraph: async () => {
|
||||
throw new Error('move-flow exited unexpectedly (code 137)');
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
runMoveIngestPhase(client, REPO_ROOT, ['pkg/Move.toml', 'pkg/sources/t.move']),
|
||||
).rejects.toThrow(/exited unexpectedly/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveIngest placeholder-address pre-flight', () => {
|
||||
let tmpRepo: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (tmpRepo) await rm(tmpRepo, { recursive: true, force: true });
|
||||
tmpRepo = undefined;
|
||||
});
|
||||
|
||||
async function writeManifest(addressesSection: string): Promise<string> {
|
||||
tmpRepo = await mkdtemp(path.join(os.tmpdir(), 'gitnexus-move-preflight-'));
|
||||
const pkgDir = path.join(tmpRepo, 'pkg');
|
||||
await mkdir(path.join(pkgDir, 'sources'), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(pkgDir, 'Move.toml'),
|
||||
`[package]\nname = "P"\nversion = "1.0.0"\n\n${addressesSection}\n`,
|
||||
'utf8',
|
||||
);
|
||||
await writeFile(path.join(pkgDir, 'sources', 't.move'), 'module 0x1::t {}\n', 'utf8');
|
||||
return tmpRepo;
|
||||
}
|
||||
|
||||
it('skips a package whose [addresses] holds "_" placeholders without building it', async () => {
|
||||
const repo = await writeManifest(
|
||||
`[addresses]\neconia = "_"\nuser = "0x1234"\n\n[dev-addresses]\neconia = "0xff"`,
|
||||
);
|
||||
let buildCalls = 0;
|
||||
const client = makeClient({
|
||||
callGraph: async () => {
|
||||
buildCalls += 1;
|
||||
return {};
|
||||
},
|
||||
});
|
||||
|
||||
const output = await runMoveIngestPhase(client, repo, ['pkg/Move.toml', 'pkg/sources/t.move']);
|
||||
|
||||
expect(buildCalls).toBe(0);
|
||||
const issues = output.consistencyIssues.filter((i) => i.code === 'unresolved-named-address');
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].severity).toBe('warning');
|
||||
expect(issues[0].message).toContain('econia');
|
||||
expect(issues[0].message).toContain('.gitnexusignore');
|
||||
expect(issues[0].details?.placeholders).toEqual(['econia']);
|
||||
expect(output.ingestedFiles.size).toBe(0);
|
||||
expect(output.ingestWarnings?.some((w) => w.includes('"_"'))).toBe(true);
|
||||
});
|
||||
|
||||
it('builds normally when every named address is concrete', async () => {
|
||||
const repo = await writeManifest(`[addresses]\neconia = "0xc0deb00c"\nuser = "0x1234"`);
|
||||
let buildCalls = 0;
|
||||
const pkgDir = path.join(repo, 'pkg');
|
||||
const client = makeClient({
|
||||
callGraph: async () => {
|
||||
buildCalls += 1;
|
||||
return {};
|
||||
},
|
||||
facts: async () => factsFor(pkgDir, '0xc0deb00c::t'),
|
||||
});
|
||||
|
||||
const output = await runMoveIngestPhase(client, repo, ['pkg/Move.toml', 'pkg/sources/t.move']);
|
||||
|
||||
expect(buildCalls).toBe(1);
|
||||
expect(
|
||||
output.consistencyIssues.filter((i) => i.code === 'unresolved-named-address'),
|
||||
).toHaveLength(0);
|
||||
expect(output.ingestedFiles.has('pkg/sources/t.move')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveIngest degraded-build detection', () => {
|
||||
it('ingests but warns when the build has compiler errors (acquires may be missing)', async () => {
|
||||
const pkgRoot = path.join(REPO_ROOT, 'pkg');
|
||||
const diagnostics =
|
||||
'error: property `map_add_all` is not valid in this context\n spec pragma ...';
|
||||
const client = makeClient({
|
||||
facts: async () => factsFor(pkgRoot),
|
||||
packageStatus: async () => ({ ok: false, diagnostics }),
|
||||
});
|
||||
|
||||
const output = await runMoveIngestPhase(client, REPO_ROOT, [
|
||||
'pkg/Move.toml',
|
||||
'pkg/sources/t.move',
|
||||
]);
|
||||
|
||||
// Still ingested — degraded fidelity is a warning, not a skip.
|
||||
expect(output.ingestedFiles.has('pkg/sources/t.move')).toBe(true);
|
||||
|
||||
const issues = output.consistencyIssues.filter((i) => i.code === 'degraded-package-facts');
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].severity).toBe('warning');
|
||||
expect(issues[0].message).toContain('acquires');
|
||||
expect(issues[0].message).toContain('map_add_all');
|
||||
expect(output.ingestWarnings?.some((w) => w.includes('compiled with errors'))).toBe(true);
|
||||
});
|
||||
|
||||
it('emits no degraded warning when the build is clean', async () => {
|
||||
const pkgRoot = path.join(REPO_ROOT, 'pkg');
|
||||
const client = makeClient({ facts: async () => factsFor(pkgRoot) });
|
||||
|
||||
const output = await runMoveIngestPhase(client, REPO_ROOT, [
|
||||
'pkg/Move.toml',
|
||||
'pkg/sources/t.move',
|
||||
]);
|
||||
|
||||
expect(
|
||||
output.consistencyIssues.filter((i) => i.code === 'degraded-package-facts'),
|
||||
).toHaveLength(0);
|
||||
expect(output.ingestWarnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('tolerates a missing status tool (no probe, no warning, no throw)', async () => {
|
||||
const pkgRoot = path.join(REPO_ROOT, 'pkg');
|
||||
let statusCalls = 0;
|
||||
const client = makeClient({
|
||||
facts: async () => factsFor(pkgRoot),
|
||||
capabilities: async () => ({ hasFactsQuery: true, hasStatusTool: false }),
|
||||
packageStatus: async () => {
|
||||
statusCalls += 1;
|
||||
return { ok: false, diagnostics: 'should not be called' };
|
||||
},
|
||||
});
|
||||
|
||||
const output = await runMoveIngestPhase(client, REPO_ROOT, [
|
||||
'pkg/Move.toml',
|
||||
'pkg/sources/t.move',
|
||||
]);
|
||||
|
||||
expect(statusCalls).toBe(0);
|
||||
expect(
|
||||
output.consistencyIssues.filter((i) => i.code === 'degraded-package-facts'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue