diff --git a/gitnexus/README.md b/gitnexus/README.md index 804526c48..bc7aa5fb5 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -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 diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 2e6993736..c58c4fcab 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -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 { diff --git a/gitnexus/src/core/ingestion/pipeline-phases/standalone-ingest.ts b/gitnexus/src/core/ingestion/pipeline-phases/standalone-ingest.ts index c86d50904..3cdda3167 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/standalone-ingest.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/standalone-ingest.ts @@ -6,6 +6,12 @@ import type { PipelinePhase } from './types.js'; */ export interface StandaloneIngestOutput { readonly ingestedFiles: ReadonlySet; + /** + * 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. */ diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 82db89027..e47118be4 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -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( + results, + 'standaloneIngest', + ).ingestWarnings; + } catch { + /* phase filtered out of this run — nothing to surface */ + } const scopeResolutionOutput = getPhaseOutput(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, }; }; diff --git a/gitnexus/src/core/move/consistency.ts b/gitnexus/src/core/move/consistency.ts index d42f6f1ee..dc9838939 100644 --- a/gitnexus/src/core/move/consistency.ts +++ b/gitnexus/src/core/move/consistency.ts @@ -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; @@ -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, diff --git a/gitnexus/src/core/move/move-ingest.ts b/gitnexus/src/core/move/move-ingest.ts index 218902530..ec8f35c85 100644 --- a/gitnexus/src/core/move/move-ingest.ts +++ b/gitnexus/src/core/move/move-ingest.ts @@ -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 { + 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), + }; }, }; } diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 8ed2214ff..28ced6afa 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -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) { diff --git a/gitnexus/src/types/pipeline.ts b/gitnexus/src/types/pipeline.ts index 4cbb28886..343dcb3da 100644 --- a/gitnexus/src/types/pipeline.ts +++ b/gitnexus/src/types/pipeline.ts @@ -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[]; } diff --git a/gitnexus/test/unit/move/move-ingest-empty-facts.test.ts b/gitnexus/test/unit/move/move-ingest-empty-facts.test.ts index f1fd0e760..c705c87d4 100644 --- a/gitnexus/test/unit/move/move-ingest-empty-facts.test.ts +++ b/gitnexus/test/unit/move/move-ingest-empty-facts.test.ts @@ -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); }); }); diff --git a/gitnexus/test/unit/move/move-ingest-skip-and-warn.test.ts b/gitnexus/test/unit/move/move-ingest-skip-and-warn.test.ts new file mode 100644 index 000000000..10b4b6ad1 --- /dev/null +++ b/gitnexus/test/unit/move/move-ingest-skip-and-warn.test.ts @@ -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 { + 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 { + 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); + }); +});