mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +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
|
including the `MOVE_FLOW` override and `GITNEXUS_SKIP_MOVE_FLOW=1` for
|
||||||
air-gapped hosts.
|
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
|
## Release candidates
|
||||||
|
|
||||||
Stable releases publish to the default `latest` dist-tag. When a pull request
|
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 {
|
try {
|
||||||
await fs.access(getGlobalRegistryPath());
|
await fs.access(getGlobalRegistryPath());
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ import type { PipelinePhase } from './types.js';
|
||||||
*/
|
*/
|
||||||
export interface StandaloneIngestOutput {
|
export interface StandaloneIngestOutput {
|
||||||
readonly ingestedFiles: ReadonlySet<string>;
|
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. */
|
/** 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 communityResult: CommunitiesOutput['communityResult'] | undefined;
|
||||||
let processResult: ProcessesOutput['processResult'] | 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 scopeResolutionOutput = getPhaseOutput<ScopeResolutionOutput>(results, 'scopeResolution');
|
||||||
const resolutionOutcomes = scopeResolutionOutput.resolutionOutcomes;
|
const resolutionOutcomes = scopeResolutionOutput.resolutionOutcomes;
|
||||||
// Streamed PDG-emit manifest (#2202): present only when streaming was on.
|
// Streamed PDG-emit manifest (#2202): present only when streaming was on.
|
||||||
|
|
@ -354,5 +364,6 @@ export const runPipelineFromRepo = async (
|
||||||
resolutionOutcomes,
|
resolutionOutcomes,
|
||||||
usedWorkerPool,
|
usedWorkerPool,
|
||||||
pdgEmitManifest,
|
pdgEmitManifest,
|
||||||
|
ingestWarnings,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,15 @@ export interface MoveConsistencyIssue {
|
||||||
| 'unresolved-resource-target'
|
| 'unresolved-resource-target'
|
||||||
/** Package with .move sources returned facts `{}` - severity policy in
|
/** Package with .move sources returned facts `{}` - severity policy in
|
||||||
* `emptyFactsIssue` below. */
|
* `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;
|
severity: MoveConsistencySeverity;
|
||||||
message: string;
|
message: string;
|
||||||
details?: Record<string, unknown>;
|
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(
|
export function validateMoveIngestOutput(
|
||||||
graph: KnowledgeGraph,
|
graph: KnowledgeGraph,
|
||||||
moveIngest: MoveIngestOutput,
|
moveIngest: MoveIngestOutput,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
import type {
|
import type {
|
||||||
PipelinePhase,
|
PipelinePhase,
|
||||||
PipelineContext,
|
PipelineContext,
|
||||||
|
|
@ -51,7 +52,11 @@ import {
|
||||||
import type { CallGraphMap, MoveFactsMap } from './compiler-facts.js';
|
import type { CallGraphMap, MoveFactsMap } from './compiler-facts.js';
|
||||||
import { moveModuleNodeId, moveModuleQualifiedName, moveRelId } from './symbol-id.js';
|
import { moveModuleNodeId, moveModuleQualifiedName, moveRelId } from './symbol-id.js';
|
||||||
import {
|
import {
|
||||||
|
buildFailedIssue,
|
||||||
|
cliWarningsFromIssues,
|
||||||
|
degradedFactsIssue,
|
||||||
emptyFactsIssue,
|
emptyFactsIssue,
|
||||||
|
unresolvedAddressIssue,
|
||||||
validateMoveIngestOutput,
|
validateMoveIngestOutput,
|
||||||
type EmptyFactsPackage,
|
type EmptyFactsPackage,
|
||||||
type MoveConsistencyIssue,
|
type MoveConsistencyIssue,
|
||||||
|
|
@ -79,6 +84,9 @@ export interface MoveIngestOutput extends StandaloneIngestOutput {
|
||||||
droppedResourceRefs?: { fnNodeId: string; target: string }[];
|
droppedResourceRefs?: { fnNodeId: string; target: string }[];
|
||||||
/** Non-fatal consistency issues found after Move ingestion. */
|
/** Non-fatal consistency issues found after Move ingestion. */
|
||||||
consistencyIssues: MoveConsistencyIssue[];
|
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. */
|
/** Mutable accumulator shared while ingesting every package. */
|
||||||
|
|
@ -130,9 +138,48 @@ function toOutput(
|
||||||
callGraphByPackage: state.callGraphByPackage,
|
callGraphByPackage: state.callGraphByPackage,
|
||||||
droppedResourceRefs: state.droppedResourceRefs,
|
droppedResourceRefs: state.droppedResourceRefs,
|
||||||
consistencyIssues,
|
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. */
|
/** Add a mapped package's nodes/edges to the graph and merge its identity maps. */
|
||||||
function applyMapped(
|
function applyMapped(
|
||||||
graph: KnowledgeGraph,
|
graph: KnowledgeGraph,
|
||||||
|
|
@ -215,6 +262,8 @@ export function createMoveIngestPhase(
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyFactsPackages: EmptyFactsPackage[] = [];
|
const emptyFactsPackages: EmptyFactsPackage[] = [];
|
||||||
|
const packageIssues: MoveConsistencyIssue[] = [];
|
||||||
|
const strictMove = isStrictMove();
|
||||||
|
|
||||||
// Pass 1: per-package nodes/edges (all packages first, so cross-package
|
// Pass 1: per-package nodes/edges (all packages first, so cross-package
|
||||||
// CALLS in Pass 2 can resolve callees in later packages).
|
// 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 },
|
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 callGraphData: CallGraphMap;
|
||||||
let factsMap: MoveFactsMap;
|
let factsMap: MoveFactsMap;
|
||||||
try {
|
try {
|
||||||
|
|
@ -233,18 +294,36 @@ export function createMoveIngestPhase(
|
||||||
factsMap = await client.facts(pkgRoot);
|
factsMap = await client.facts(pkgRoot);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof MoveFlowToolCallError) {
|
if (err instanceof MoveFlowToolCallError) {
|
||||||
// userActionable: rendered as a one-liner without a stack - a Move
|
// A Move package that does not build (bad manifest, missing
|
||||||
// package that does not build (bad manifest, missing dependency,
|
// dependency, unresolved address) is an operator problem, not a
|
||||||
// nonexistent path) is an operator problem, not a code bug.
|
// code bug. Default: skip the package (its files stay un-ingested,
|
||||||
throw Object.assign(
|
// like the empty-facts path) and surface a persistent warning —
|
||||||
new Error(`move-flow could not build Move package ${pkgRoot}: ${err.message}`),
|
// one broken auxiliary package must not abort the whole analyze.
|
||||||
{ userActionable: true },
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pkgMoveFiles = moveFilesByPackage.get(pkgRoot) ?? [];
|
|
||||||
if (Object.keys(factsMap).length === 0 && pkgMoveFiles.length > 0) {
|
if (Object.keys(factsMap).length === 0 && pkgMoveFiles.length > 0) {
|
||||||
// Facts `{}` is ambiguous: syntax-broken packages return it as a
|
// Facts `{}` is ambiguous: syntax-broken packages return it as a
|
||||||
// SUCCESS (the compiler diagnostic only surfaces via
|
// SUCCESS (the compiler diagnostic only surfaces via
|
||||||
|
|
@ -265,6 +344,15 @@ export function createMoveIngestPhase(
|
||||||
for (const rel of pkgMoveFiles) state.ingestedFiles.add(rel);
|
for (const rel of pkgMoveFiles) state.ingestedFiles.add(rel);
|
||||||
|
|
||||||
applyMapped(ctx.graph, mapFactsToGraph(factsMap, pkgRoot, ctx.repoPath), pkgRoot, state);
|
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.
|
// Pass 2+: link edges that need the full cross-package node index.
|
||||||
|
|
@ -277,10 +365,17 @@ export function createMoveIngestPhase(
|
||||||
const output = toOutput(state, packageRoots);
|
const output = toOutput(state, packageRoots);
|
||||||
createMoveEntryPointEdges(ctx.graph, output);
|
createMoveEntryPointEdges(ctx.graph, output);
|
||||||
|
|
||||||
const consistencyIssues: MoveConsistencyIssue[] = emptyFactsPackages.map(emptyFactsIssue);
|
const consistencyIssues: MoveConsistencyIssue[] = [
|
||||||
|
...packageIssues,
|
||||||
|
...emptyFactsPackages.map(emptyFactsIssue),
|
||||||
|
];
|
||||||
consistencyIssues.push(...validateMoveIngestOutput(ctx.graph, output));
|
consistencyIssues.push(...validateMoveIngestOutput(ctx.graph, output));
|
||||||
reportConsistencyIssues(ctx, consistencyIssues);
|
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.
|
* the persisted meta surface the degraded state instead of reporting healthy.
|
||||||
*/
|
*/
|
||||||
ftsSkipped?: boolean;
|
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
|
* True when the index this run produced/validated is the flat workspace
|
||||||
* slot (#2106 R2, inverted by #2354 to follow the checked-out branch).
|
* slot (#2106 R2, inverted by #2354 to follow the checked-out branch).
|
||||||
|
|
@ -2685,6 +2691,7 @@ export async function runFullAnalysis(
|
||||||
stats: meta.stats,
|
stats: meta.stats,
|
||||||
pipelineResult,
|
pipelineResult,
|
||||||
ftsSkipped: !ftsReady,
|
ftsSkipped: !ftsReady,
|
||||||
|
ingestWarnings: pipelineResult.ingestWarnings,
|
||||||
isPrimaryBranch: !placement.branch,
|
isPrimaryBranch: !placement.branch,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -36,4 +36,11 @@ export interface PipelineResult {
|
||||||
* layer (if any) is resident in `graph` and persists via the whole-graph emit.
|
* layer (if any) is resident in `graph` and persists via the whole-graph emit.
|
||||||
*/
|
*/
|
||||||
pdgEmitManifest?: PdgEmitManifest;
|
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?');
|
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;
|
let statusCalls = 0;
|
||||||
const output = await runPhase(
|
const output = await runPhase(
|
||||||
makeClient({
|
makeClient({
|
||||||
|
|
@ -112,8 +116,11 @@ describe('moveIngest empty-facts discrimination', () => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(statusCalls).toBe(0);
|
expect(statusCalls).toBe(1);
|
||||||
expect(emptyFactsIssues(output)).toHaveLength(0);
|
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);
|
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