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:
abhigyantrumio 2026-07-24 03:39:23 +05:30
parent 5868b10d37
commit 3483aeb6f8
10 changed files with 537 additions and 12 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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. */

View file

@ -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,
};
};

View file

@ -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,

View file

@ -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),
};
},
};
}

View file

@ -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) {

View file

@ -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[];
}

View file

@ -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);
});
});

View 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);
});
});