refactor(move): simplify & harden the Move integration architecture

Single-commit version of the move-integration-arch work (base main-aptos).
Net effect on top of the Move ingest reliability release:

- Remove the move-facts cache + freshness/hashing layer; drop init_module as
  an entry point; general structure/deslop of the Move module.
- Split Move ingestion into a Pass-A (node/ref emission) / Pass-B
  (resolution/linking) seam with a MoveIngestAccumulator, and add bounded
  concurrency + native-fn pruning to the function_usage fan-out
  (concurrency-safe transport).
- Decompose mapFactsToGraph into per-symbol emitters over a shared EmitContext.
- Simplify the supporting pipeline deltas: explicit entry-point tier
  unification (process-processor), extracted scope-resolution progress
  formatters (run.ts), collapsed callable-value-flow switch, shared lbug
  delete-all helper.

Behavior-preserving where it matters — Move graph-equivalence snapshot
unchanged; unit + integration suites green (modulo known macOS/env-only
failures).
This commit is contained in:
zwxxb 2026-07-24 14:56:07 +02:00
parent 2a212ad605
commit 6ea5aa4484
62 changed files with 4095 additions and 1174 deletions

3
.gitignore vendored
View file

@ -133,3 +133,6 @@ gitnexus/web/
# Machine-local skill-evolution evidence (consumed by eval/workflow_bench/evolve.py)
eval/workflow_bench/learnings.jsonl
# Codex CLI local config
.codex/

View file

@ -143,9 +143,10 @@ export type NodeProperties = {
/**
* Node-location precision. `'precise'` = per-symbol file/span from the
* move-flow `facts` query; `'module'` = only the containing module/type file
* is known; `'package'` = coarse package-root fallback.
* is known; `'package'` = coarse package-root fallback; `'external'` =
* declaration belongs to a dependency outside the indexed repository.
*/
locationFidelity?: 'precise' | 'module' | 'package';
locationFidelity?: 'precise' | 'module' | 'package' | 'external';
// BasicBlock (taint/PDG substrate, issue #2080) — reuses filePath/startLine/endLine.
text?: string;
/** BasicBlock: space-joined leaf callee names invoked in the block the

View file

@ -29,6 +29,7 @@ export const NODE_TABLES = [
'Trait',
'Impl',
'TypeAlias',
'Type',
'Const',
'Static',
'Variable',

View file

@ -13,6 +13,7 @@ import os from 'os';
import { spawn } from 'child_process';
import v8 from 'v8';
import cliProgress from 'cli-progress';
import { ANALYZE_PROGRESS_ACTIVE_ENV } from '../core/logger.js';
import { isLbugReady, LbugWipeError } from '../core/lbug/lbug-adapter.js';
import { boundedCheckpointBeforeExit } from '../core/lbug/shutdown-helpers.js';
import {
@ -596,7 +597,7 @@ const ANALYZE_CLI_ENV_KEYS = [
'GITNEXUS_EMBEDDING_BATCH_SIZE',
'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE',
'GITNEXUS_EMBEDDING_DEVICE',
'GITNEXUS_ANALYZE_PROGRESS_ACTIVE',
ANALYZE_PROGRESS_ACTIVE_ENV,
'GITNEXUS_EMBEDDING_URL',
'GITNEXUS_EMBEDDING_MODEL',
'GITNEXUS_EMBEDDING_API_KEY',
@ -1316,7 +1317,7 @@ const analyzeCommandImpl = async (
console.warn = barLog;
// eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
console.error = barLog;
process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE = '1';
process.env[ANALYZE_PROGRESS_ACTIVE_ENV] = '1';
// Track elapsed time per phase
let lastPhaseLabel = 'Initializing...';

View file

@ -9,6 +9,9 @@
* - Every graph-wide node (Community, Process) these are regenerated
* each run by the communities/processes phases and must be fully
* rewritten.
* - Every external dependency node (`locationFidelity: 'external'`, no
* filePath) regenerated each run by the standalone ingest phase and
* delete-all'd by the orchestrator, same contract as Community/Process.
* - Every relationship where AT LEAST ONE endpoint is in the writable
* set above. Relationships entirely between unchanged-file nodes
* are skipped their rows are still in the DB and re-inserting
@ -54,6 +57,20 @@ import type { KnowledgeGraph } from '../graph/types.js';
const isGraphWide = (label: string): boolean => label === 'Community' || label === 'Process';
/**
* Externally-declared dependency symbols (compiler-backed ingesters stamp
* `locationFidelity: 'external'`) carry no filePath, so the file-keyed
* delete/write cycle can never refresh them an external symbol first
* referenced during an incremental run would be skipped here while its edges
* are extracted, and the dangling endpoints would fail the rel COPY into the
* per-row fallback (which silently drops them). They are regenerated
* whole-program by every ingest run, so they get the Community/Process
* treatment: the orchestrator delete-alls them first
* (`deleteAllExternalNodes`) and this extractor re-includes them from the
* fresh graph.
*/
const isExternalNode = (n: GraphNode): boolean => n.properties?.locationFidelity === 'external';
/**
* Relationship types whose VALIDITY is a whole-program property, not a
* function of their endpoints' files (#2084 M4 U6). `TAINT_PATH` (cross-
@ -106,7 +123,8 @@ export const extractChangedSubgraph = (
fullGraph.forEachNode((n: GraphNode) => {
const filePath = n.properties?.filePath as string | undefined;
const include = (filePath && toWriteSet.has(filePath)) || isGraphWide(n.label);
const include =
(filePath && toWriteSet.has(filePath)) || isGraphWide(n.label) || isExternalNode(n);
if (include) {
sub.addNode(n);
writableNodeIds.add(n.id);

View file

@ -5,7 +5,7 @@ import path from 'path';
import { glob } from 'glob';
import { createIgnoreFilter } from '../../config/ignore-service.js';
import { logger } from '../logger.js';
import { warnRespectingProgressBar } from '../logger.js';
/** Lightweight entry — path + size from stat, no content in memory */
export interface ScannedFile {
@ -19,21 +19,8 @@ export interface FilePath {
}
const READ_CONCURRENCY = 32;
const ANALYZE_PROGRESS_ACTIVE_ENV = 'GITNEXUS_ANALYZE_PROGRESS_ACTIVE';
const warnLargeFileSkip = (message: string): void => {
if (process.env[ANALYZE_PROGRESS_ACTIVE_ENV] === '1') {
// analyze.ts routes console.warn through the progress bar logger while
// the bar is active. Emitting the operator-facing large-file notice there
// avoids raw pino NDJSON corrupting the one-line progress display in the
// heap-respawn child, whose stderr is intentionally piped for crash
// classification.
// eslint-disable-next-line no-console -- intentionally routed by analyze progress UI
console.warn(message);
return;
}
logger.warn(message);
};
const warnLargeFileSkip = (message: string): void => warnRespectingProgressBar(message);
/**
* Phase 1: Scan repository stat files to get paths + sizes, no content loaded.

View file

@ -48,7 +48,9 @@ import {
type ProcessesOutput,
} from './pipeline-phases/index.js';
export interface PipelineOptions {
export interface PipelineOptions<
TStandaloneIngest extends StandaloneIngestOutput = StandaloneIngestOutput,
> {
/**
* Skip MRO, community detection, and process extraction for faster test runs.
* The `pruneLocalSymbols` phase still runs it is graph construction (it cleans
@ -228,7 +230,7 @@ export interface PipelineOptions {
*/
keepLocalValueSymbols?: boolean;
/** Optional compiler-backed or otherwise standalone ingester. */
standaloneIngestPhase?: PipelinePhase<StandaloneIngestOutput>;
standaloneIngestPhase?: PipelinePhase<TStandaloneIngest>;
/**
* Extra fetch-wrapper function names to treat as HTTP consumers, threaded
* from `.gitnexusrc` `fetchWrappers` via `AnalyzeOptions` (#1589/#1852
@ -261,10 +263,12 @@ export interface PipelineOptions {
* asserts the produced list is byte-identical to the legacy array for every
* options combination.
*/
export function buildPhaseList(options?: PipelineOptions): PipelinePhase[] {
export function buildPhaseList<TStandaloneIngest extends StandaloneIngestOutput>(
options?: PipelineOptions<TStandaloneIngest>,
): PipelinePhase[] {
const { standaloneIngestPhase = emptyStandaloneIngestPhase } = options ?? {};
return (
new PhaseRegistry<PipelineOptions>()
new PhaseRegistry<PipelineOptions<TStandaloneIngest>>()
.register(scanPhase)
.register(structurePhase)
.register(standaloneIngestPhase)
@ -295,11 +299,13 @@ export function buildPhaseList(options?: PipelineOptions): PipelinePhase[] {
// ── Pipeline orchestrator ─────────────────────────────────────────────────
export const runPipelineFromRepo = async (
export const runPipelineFromRepo = async <
TStandaloneIngest extends StandaloneIngestOutput = StandaloneIngestOutput,
>(
repoPath: string,
onProgress: (progress: PipelineProgress) => void,
options?: PipelineOptions,
): Promise<PipelineResult> => {
options?: PipelineOptions<TStandaloneIngest>,
): Promise<PipelineResult<TStandaloneIngest>> => {
const graph = createKnowledgeGraph();
const pipelineStart = Date.now();
@ -321,16 +327,10 @@ 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 */
}
// Standalone ingest is always registered (it defaults to the empty phase),
// so its output — and the language-neutral warnings it passes through — are
// always present.
const standaloneIngest = getPhaseOutput<TStandaloneIngest>(results, 'standaloneIngest');
const scopeResolutionOutput = getPhaseOutput<ScopeResolutionOutput>(results, 'scopeResolution');
const resolutionOutcomes = scopeResolutionOutput.resolutionOutcomes;
// Streamed PDG-emit manifest (#2202): present only when streaming was on.
@ -364,6 +364,6 @@ export const runPipelineFromRepo = async (
resolutionOutcomes,
usedWorkerPool,
pdgEmitManifest,
ingestWarnings,
standaloneIngest,
};
};

View file

@ -2,7 +2,8 @@
* Process Detection Processor
*
* Detects execution flows (Processes) in the code graph by:
* 1. Finding entry points (functions with no internal callers)
* 1. Finding entry points (explicit graph roots plus functions that call
* others but have few callers)
* 2. Tracing forward via CALLS edges (BFS)
* 3. Grouping and deduplicating similar paths
* 4. Labeling with heuristic names
@ -10,7 +11,7 @@
* Processes help agents understand how features work through the codebase.
*/
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
import type { NodeLabel } from 'gitnexus-shared';
import { KnowledgeGraph } from '../graph/types.js';
import { CommunityMembership } from './community-processor.js';
import { calculateEntryPointScore, isTestFile } from './entry-point-scoring.js';
@ -24,9 +25,9 @@ import { logger } from '../logger.js';
export interface ProcessDetectionConfig {
maxTraceDepth: number; // Maximum steps to trace (default: 10)
maxBranching: number; // Max branches to follow per node (default: 3)
maxProcesses: number; // Maximum processes to detect (default: 50)
minSteps: number; // Minimum steps for a valid process (default: 2)
maxBranching: number; // Max branches to follow per node (default: 4)
maxProcesses: number; // Maximum processes to detect (default: 75)
minSteps: number; // Minimum steps for a valid process (default: 3)
}
const DEFAULT_CONFIG: ProcessDetectionConfig = {
@ -92,15 +93,17 @@ export const processProcesses = async (
const membershipMap = new Map<string, string>();
memberships.forEach((m) => membershipMap.set(m.nodeId, m.communityId));
const callsEdges = buildCallsGraph(knowledgeGraph);
const reverseCallsEdges = buildReverseCallsGraph(knowledgeGraph);
const nodeMap = new Map<string, GraphNode>();
for (const n of knowledgeGraph.iterNodes()) nodeMap.set(n.id, n);
const { forward: callsEdges, reverse: reverseCallsEdges } = buildCallsAdjacency(knowledgeGraph);
// Step 1: Find entry points (functions that call others but have few callers)
const entryPoints = findEntryPoints(knowledgeGraph, reverseCallsEdges, callsEdges);
onProgress?.(`Found ${entryPoints.length} entry points, tracing flows...`, 20);
// Step 1: Find entry points — explicit ENTRY_POINT_OF graph roots plus
// heuristic ones (functions that call others but have few callers).
const explicitEntryPointIds = collectExplicitEntryPointIds(knowledgeGraph);
const entryPoints = findEntryPoints(
knowledgeGraph,
reverseCallsEdges,
callsEdges,
explicitEntryPointIds,
);
onProgress?.(`Found ${entryPoints.length} entry points, tracing flows...`, 20);
@ -125,7 +128,7 @@ export const processProcesses = async (
onProgress?.(`Found ${allTraces.length} traces, deduplicating...`, 60);
// Step 3: Deduplicate similar traces (subset removal)
const uniqueTraces = deduplicateTraces(allTraces);
const uniqueTraces = deduplicateTraces(allTraces, explicitEntryPointIds);
// Step 3b: Deduplicate by entry+terminal pair (keep longest path per pair)
const endpointDeduped = deduplicateByEndpoints(uniqueTraces);
@ -135,9 +138,15 @@ export const processProcesses = async (
70,
);
// Step 4: Limit to max processes (prioritize longer traces)
// Step 4: Keep explicit graph roots ahead of heuristic traces, then prefer
// longer traces within each tier.
const limitedTraces = endpointDeduped
.sort((a, b) => b.length - a.length)
.sort(
explicitFirst(
(trace) => explicitEntryPointIds.has(trace[0]),
(a, b) => b.length - a.length,
),
)
.slice(0, cfg.maxProcesses);
onProgress?.(`Creating ${limitedTraces.length} process nodes...`, 80);
@ -163,8 +172,8 @@ export const processProcesses = async (
communities.length > 1 ? 'cross_community' : 'intra_community';
// Generate label
const entryNode = nodeMap.get(entryPointId);
const terminalNode = nodeMap.get(terminalId);
const entryNode = knowledgeGraph.getNode(entryPointId);
const terminalNode = knowledgeGraph.getNode(terminalId);
const entryName = entryNode?.properties.name || 'Unknown';
const terminalName = terminalNode?.properties.name || 'Unknown';
const heuristicLabel = `${capitalize(entryName)}${capitalize(terminalName)}`;
@ -227,35 +236,51 @@ type AdjacencyList = Map<string, string[]>;
*/
const MIN_TRACE_CONFIDENCE = 0.5;
const buildCallsGraph = (graph: KnowledgeGraph): AdjacencyList => {
const adj = new Map<string, string[]>();
/** Build forward and reverse CALLS adjacency lists in one relationship scan. */
const buildCallsAdjacency = (
graph: KnowledgeGraph,
): { forward: AdjacencyList; reverse: AdjacencyList } => {
const forward: AdjacencyList = new Map();
const reverse: AdjacencyList = new Map();
const push = (adj: AdjacencyList, key: string, value: string): void => {
const bucket = adj.get(key);
if (bucket === undefined) adj.set(key, [value]);
else bucket.push(value);
};
for (const rel of graph.iterRelationships()) {
if (rel.type === 'CALLS' && rel.confidence >= MIN_TRACE_CONFIDENCE) {
if (!adj.has(rel.sourceId)) {
adj.set(rel.sourceId, []);
}
adj.get(rel.sourceId)!.push(rel.targetId);
push(forward, rel.sourceId, rel.targetId);
push(reverse, rel.targetId, rel.sourceId);
}
}
return adj;
return { forward, reverse };
};
const buildReverseCallsGraph = (graph: KnowledgeGraph): AdjacencyList => {
const adj = new Map<string, string[]>();
for (const rel of graph.iterRelationships()) {
if (rel.type === 'CALLS' && rel.confidence >= MIN_TRACE_CONFIDENCE) {
if (!adj.has(rel.targetId)) {
adj.set(rel.targetId, []);
}
adj.get(rel.targetId)!.push(rel.sourceId);
}
/** IDs of nodes carrying a pre-phase ENTRY_POINT_OF edge (compiler-backed
* runtime/API roots stamped by the standalone ingest phase). */
function collectExplicitEntryPointIds(graph: KnowledgeGraph): Set<string> {
const ids = new Set<string>();
for (const rel of graph.iterRelationshipsByType('ENTRY_POINT_OF')) {
// Only callable roots qualify. Route/Tool→Process ENTRY_POINT_OF edges
// are created after this phase; the label filter keeps them out even if
// phase ordering ever changes.
const label = graph.getNode(rel.sourceId)?.label;
if (label === 'Function' || label === 'Method') ids.add(rel.sourceId);
}
return ids;
}
return adj;
};
/** Global cap on heuristic (non-explicit) entry points, to prevent explosion
* on large polyglot repos. Explicit graph roots are exempt and counted first. */
const HEURISTIC_ENTRY_POINT_BUDGET = 200;
/** Comparator combinator: explicit items first, then a secondary order. */
const explicitFirst =
<T>(isExplicit: (item: T) => boolean, then: (a: T, b: T) => number) =>
(a: T, b: T): number =>
Number(isExplicit(b)) - Number(isExplicit(a)) || then(a, b);
/**
* Find functions/methods that are good entry points for tracing.
@ -271,12 +296,20 @@ const findEntryPoints = (
graph: KnowledgeGraph,
reverseCallsEdges: AdjacencyList,
callsEdges: AdjacencyList,
explicitEntryPointIds: ReadonlySet<string>,
): string[] => {
const symbolTypes = new Set<NodeLabel>(['Function', 'Method']);
// ENTRY_POINT_OF is the graph-wide, language-neutral signal for a known
// runtime/API root. Only edges emitted BEFORE this phase count — today
// that is compiler-backed standalone ingesters; the
// Route/Tool ENTRY_POINT_OF edges are created after process extraction
// and point Route/Tool→Process, so they never land here. These roots must
// survive the global heuristic top-200 budget on large polyglot repos.
const entryPointCandidates: {
id: string;
score: number;
reasons: string[];
explicit: boolean;
}[] = [];
for (const node of graph.iterNodes()) {
@ -304,19 +337,26 @@ const findEntryPoints = (
);
let score = baseScore;
const explicit = explicitEntryPointIds.has(node.id);
if (explicit) reasons.push('graph-entry-point');
const astFrameworkMultiplier = node.properties.astFrameworkMultiplier ?? 1.0;
if (astFrameworkMultiplier > 1.0) {
score *= astFrameworkMultiplier;
reasons.push(`framework-ast:${node.properties.astFrameworkReason || 'decorator'}`);
}
if (score > 0) {
entryPointCandidates.push({ id: node.id, score, reasons });
if (explicit || score > 0) {
entryPointCandidates.push({ id: node.id, score, reasons, explicit });
}
}
// Sort by score descending and return top candidates
const sorted = entryPointCandidates.sort((a, b) => b.score - a.score);
// Known graph entry points form a priority tier ahead of heuristic scoring.
const sorted = entryPointCandidates.sort(
explicitFirst(
(candidate) => candidate.explicit,
(a, b) => b.score - a.score,
),
);
// DEBUG: Log top candidates with new scoring details
if (sorted.length > 0 && isDev) {
@ -330,9 +370,11 @@ const findEntryPoints = (
});
}
return sorted
.slice(0, 200) // Limit to prevent explosion
.map((c) => c.id);
const explicit = sorted.filter((candidate) => candidate.explicit);
const heuristic = sorted
.filter((candidate) => !candidate.explicit)
.slice(0, Math.max(0, HEURISTIC_ENTRY_POINT_BUDGET - explicit.length));
return [...explicit, ...heuristic].map((candidate) => candidate.id);
};
// ============================================================================
@ -401,23 +443,31 @@ const traceFromEntryPoint = (
* Merge traces that are subsets of other traces.
* Keep longer traces, remove redundant shorter ones.
*/
const deduplicateTraces = (traces: string[][]): string[][] => {
const deduplicateTraces = (
traces: string[][],
explicitEntryPointIds: ReadonlySet<string> = new Set(),
): string[][] => {
if (traces.length === 0) return [];
// Sort by length descending
const sorted = [...traces].sort((a, b) => b.length - a.length);
const unique: string[][] = [];
// Cache each kept trace's join key so the redundancy scan below doesn't
// recompute `existing.join('->')` on every comparison.
const uniqueKeys: string[] = [];
for (const trace of sorted) {
// Check if this trace is a subset of any already-added trace
const traceKey = trace.join('->');
const isSubset = unique.some((existing) => {
const existingKey = existing.join('->');
return existingKey.includes(traceKey);
});
// Explicit entry points dedupe on exact equality; heuristic traces are
// dropped when a longer kept trace already contains them as a substring.
const exactOnly = explicitEntryPointIds.has(trace[0]);
const isRedundant = uniqueKeys.some((existingKey) =>
exactOnly ? existingKey === traceKey : existingKey.includes(traceKey),
);
if (!isSubset) {
if (!isRedundant) {
unique.push(trace);
uniqueKeys.push(traceKey);
}
}

View file

@ -30,6 +30,10 @@ interface Target {
readonly def: SymbolDefinition;
}
// Shared miss results for the fixpoint's read paths — callers only iterate.
const EMPTY_TARGETS: ReadonlyMap<string, Target> = new Map();
const EMPTY_CELLS: ReadonlySet<string> = new Set();
interface FileFact {
readonly filePath: string;
readonly site: CallableFlowSite;
@ -40,13 +44,22 @@ interface FileInvoke {
readonly site: CallableFlowInvokeSite;
}
export interface CallableValueFlowWarning {
interface RawCallableValueFlowWarning {
readonly language: string;
readonly context: string;
readonly candidateCount: number;
readonly cap: number;
}
export interface CallableValueFlowWarning extends RawCallableValueFlowWarning {
/** Number of internal cells represented by this aggregate warning. */
readonly occurrences: number;
/** Number of source contexts represented by this aggregate warning. */
readonly distinctContexts: number;
/** Bounded diagnostic sample; stdout receives one aggregate, not every site. */
readonly contextSamples: readonly string[];
}
export interface CallableValueFlowResult {
readonly emitted: number;
readonly resolvedInvokes: number;
@ -55,6 +68,46 @@ export interface CallableValueFlowResult {
readonly iterations: number;
}
/** Collapse internal-cell overflows to one bounded warning per language/cap. */
export function aggregateCallableValueFlowWarnings(
warnings: Iterable<RawCallableValueFlowWarning>,
): CallableValueFlowWarning[] {
const grouped = new Map<
string,
RawCallableValueFlowWarning & {
// Re-declared mutable so occurrences can raise it (the raw field is readonly).
candidateCount: number;
occurrences: number;
allContexts: Set<string>;
contextSamples: string[];
}
>();
for (const warning of warnings) {
const key = `${warning.language}\0${warning.cap}`;
const current = grouped.get(key);
if (!current) {
grouped.set(key, {
...warning,
occurrences: 1,
allContexts: new Set([warning.context]),
contextSamples: [warning.context],
});
continue;
}
current.candidateCount = Math.max(current.candidateCount, warning.candidateCount);
current.occurrences++;
current.allContexts.add(warning.context);
if (current.contextSamples.length < 5 && !current.contextSamples.includes(warning.context)) {
current.contextSamples.push(warning.context);
}
}
return [...grouped.values()].map(({ allContexts, ...warning }) => ({
...warning,
distinctContexts: allContexts.size,
}));
}
export interface EmitCallableValueFlowInput {
readonly graph: KnowledgeGraph;
readonly scopes: ScopeResolutionIndexes;
@ -66,6 +119,12 @@ export interface EmitCallableValueFlowInput {
readonly isCallableValueTarget?: (def: SymbolDefinition) => boolean;
readonly hasFileLocalCallableLinkage?: (def: SymbolDefinition) => boolean;
readonly onWarn?: (warning: CallableValueFlowWarning) => void;
/**
* Precomputed {@link collectDeferredIndirectSites} result for the same
* files/scopes. The orchestrator already needs it to build skip sets;
* threading it here avoids a second whole-repo scan. Recomputed when absent.
*/
readonly canonicalInvokeKeys?: ReadonlySet<string>;
}
/** Position key shared with the existing free/reference skip-set contract. */
@ -76,6 +135,34 @@ export function callableFlowSiteKey(
return `${filePath}:${range.startLine}:${range.startCol}`;
}
function warningContext(filePath: string, site: CallableFlowSite): string {
let range: { readonly startLine: number; readonly startCol: number };
switch (site.kind) {
case 'seed':
case 'copy':
case 'alias':
case 'address':
case 'load':
range = site.destination.atRange;
break;
case 'store':
range = site.pointer.atRange;
break;
case 'formal':
range = site.ownerRange;
break;
case 'argument':
case 'invoke':
range = site.callSite;
break;
default: {
const exhaustive: never = site;
throw new Error(`Unhandled callable-flow site kind: ${String(exhaustive)}`);
}
}
return `${site.kind}:${callableFlowSiteKey(filePath, range)}`;
}
/**
* Return only invoke sites that join to a canonical call ReferenceSite.
* Malformed/stale facts never suppress ordinary resolution.
@ -140,7 +227,8 @@ function flowCellOperand(site: CallableFlowSite): CallableFlowOperand | undefine
export function emitCallableValueFlow(input: EmitCallableValueFlowInput): CallableValueFlowResult {
const facts: FileFact[] = [];
const invokes: FileInvoke[] = [];
const canonicalInvokeKeys = collectDeferredIndirectSites(input.parsedFiles, input.scopes);
const canonicalInvokeKeys =
input.canonicalInvokeKeys ?? collectDeferredIndirectSites(input.parsedFiles, input.scopes);
let unmatchedInvokes = 0;
for (const parsed of input.parsedFiles) {
for (const site of parsed.callableFlowSites ?? []) {
@ -161,7 +249,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
const addressesByBinding = new Map<string, Set<string>>();
const overflowedTargets = new Set<string>();
const overflowedAddresses = new Set<string>();
const overflowWarnings = new Map<string, CallableValueFlowWarning>();
const overflowWarnings = new Map<string, RawCallableValueFlowWarning>();
const rawGraphTargets = buildGraphTargetIndex(
input.scopes,
input.nodeLookup,
@ -311,7 +399,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
): { readonly targets: ReadonlyMap<string, Target>; readonly overflow: boolean } => {
watch('target', key);
return {
targets: targetsByBinding.get(key) ?? new Map(),
targets: targetsByBinding.get(key) ?? EMPTY_TARGETS,
overflow: overflowedTargets.has(key),
};
};
@ -321,7 +409,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
): { readonly cells: ReadonlySet<string>; readonly overflow: boolean } => {
watch('address', key);
return {
cells: addressesByBinding.get(key) ?? new Set(),
cells: addressesByBinding.get(key) ?? EMPTY_CELLS,
overflow: overflowedAddresses.has(key),
};
};
@ -476,10 +564,10 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
for (const fact of facts) {
const site = fact.site;
const context = `${fact.site.kind}:${fact.filePath}`;
switch (site.kind) {
case 'copy':
case 'alias': {
const context = warningContext(fact.filePath, site);
addWorkItem(() => {
const source = bindingKey(fact.filePath, site.source);
const destination = bindingKey(fact.filePath, site.destination);
@ -493,6 +581,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
break;
}
case 'load': {
const context = warningContext(fact.filePath, site);
addWorkItem(() => {
const destination = bindingKey(fact.filePath, site.destination);
const reached = reachedCells(fact.filePath, site.pointer);
@ -509,6 +598,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
break;
}
case 'store': {
const context = warningContext(fact.filePath, site);
addWorkItem(() => {
const sourceTargets = operandTargets(fact.filePath, site.source);
const reached = reachedCells(fact.filePath, site.pointer);
@ -585,7 +675,11 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
targetIds.add(id);
}
for (const id of dynamicCallees.get(callKey)?.keys() ?? []) targetIds.add(id);
let hasIndexedFormal = [...targetIds].some((id) => indexedFormals(id).length > 0);
const hasAnyIndexedFormal = (): boolean => {
for (const id of targetIds) if (indexedFormals(id).length > 0) return true;
return false;
};
let hasIndexedFormal = hasAnyIndexedFormal();
if (!hasIndexedFormal && site.directCalleeName !== undefined) {
for (const target of resolveSeedCandidates(
fact.filePath,
@ -601,7 +695,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
)) {
targetIds.add(target.id);
}
hasIndexedFormal = [...targetIds].some((id) => indexedFormals(id).length > 0);
hasIndexedFormal = hasAnyIndexedFormal();
}
const history = dynamicTargetHistory.get(callKey);
const callOverflow =
@ -684,7 +778,12 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
);
}
for (const warning of overflowWarnings.values()) input.onWarn?.(warning);
// A large generated bundle can create thousands of distinct binding cells
// at the same source site. Preserve the causal evidence while emitting one
// structured warning per site instead of one line per internal cell.
for (const warning of aggregateCallableValueFlowWarnings(overflowWarnings.values())) {
input.onWarn?.(warning);
}
// No partial graph output when a hostile/corrupt fact graph exhausts the
// bounded work budget. The caller receives a warning; NOTE this is not

View file

@ -82,6 +82,7 @@ import {
callableFlowSiteKey,
collectDeferredIndirectSites,
emitCallableValueFlow,
type CallableValueFlowWarning,
} from '../passes/callable-value-flow.js';
import type { ScopeResolver } from '../contract/scope-resolver.js';
import { findEnclosingClassDef, resolveInheritanceBaseInScope } from '../scope/walkers.js';
@ -92,7 +93,37 @@ import { parseTruthyEnv } from '../../utils/env.js';
import { TransitionalScopeTree } from '../../../../storage/scope-index-store.js';
import { forceGc } from '../../../../storage/parsedfile-store.js';
import { logger } from '../../../logger.js';
import { logger, warnRespectingProgressBar } from '../../../logger.js';
/** Exported for the boundary test in run-progress.test.ts. */
export const MAX_PROGRESS_WARNING_CONTEXT_CHARS = 160;
/** Escape control bytes and bound one context shown beside the live bar. */
export function formatScopeResolutionWarningContext(context: string): string {
const escaped = JSON.stringify(context).slice(1, -1);
if (escaped.length <= MAX_PROGRESS_WARNING_CONTEXT_CHARS) return escaped;
return `${escaped.slice(0, MAX_PROGRESS_WARNING_CONTEXT_CHARS - 3)}...`;
}
/** One-line progress warning for property-dispatch fan-out drops. */
function formatPropertyDispatchProgress(
language: string,
skippedKeys: number,
fanoutCap: number,
skippedKeyNames: readonly string[],
): string {
return ` Warning: property dispatch (${language}) skipped ${skippedKeys} key(s) above fan-out cap ${fanoutCap}; no CALLS were synthesized. Sample: ${skippedKeyNames
.slice(0, 5)
.join(', ')}`;
}
/** One-line progress warning for callable-value-flow candidate-set overflows. */
function formatCallableValueFlowProgress(warning: CallableValueFlowWarning): string {
return ` Warning: callable value flow (${warning.language}) skipped ${warning.occurrences} candidate set(s) across ${warning.distinctContexts} context(s) above cap ${warning.cap}; no partial CALLS were emitted. Sample: ${warning.contextSamples
.slice(0, 2)
.map(formatScopeResolutionWarningContext)
.join(', ')}`;
}
/**
* Emit one class-owned inheritance edge directly (the inheritance pre-pass is
@ -879,14 +910,23 @@ export function runScopeResolution(
// Never drop dispatch coverage silently: a hook table larger than the
// fan-out cap means member calls through those keys get no synthesized
// CALLS — the #2437 false-safe gap reappears for exactly those keys.
logger.warn(
warnRespectingProgressBar(
formatPropertyDispatchProgress(
provider.language,
propertyDispatch.skippedKeys,
MAX_PROPERTY_DISPATCH_FANOUT,
propertyDispatch.skippedKeyNames,
),
{
lang: provider.language,
skippedKeys: propertyDispatch.skippedKeys,
skippedKeyNames: propertyDispatch.skippedKeyNames,
fanoutCap: MAX_PROPERTY_DISPATCH_FANOUT,
fields: {
lang: provider.language,
skippedKeys: propertyDispatch.skippedKeys,
skippedKeyNames: propertyDispatch.skippedKeyNames,
fanoutCap: MAX_PROPERTY_DISPATCH_FANOUT,
},
message:
'property-dispatch: keys over the fan-out cap were dropped (no CALLS synthesized for them)',
},
'property-dispatch: keys over the fan-out cap were dropped (no CALLS synthesized for them)',
);
}
const callableValueFlow =
@ -904,15 +944,17 @@ export function runScopeResolution(
parsedFiles: emitParsedFiles,
nodeLookup: postHeritageNodeLookup,
calleeIds: calleeIdAccumulator,
canonicalInvokeKeys: deferredIndirectSites,
language: provider.language,
collapseByCallerTarget: provider.collapseMemberCallsByCallerTarget === true,
isCallableValueTarget: provider.isCallableValueTarget,
hasFileLocalCallableLinkage: provider.hasFileLocalCallableLinkage,
onWarn: (warning) =>
logger.warn(
warning,
'callable-value-flow: candidate set exceeded the cap; no partial CALLS emitted',
),
warnRespectingProgressBar(formatCallableValueFlowProgress(warning), {
fields: warning,
message:
'callable-value-flow: candidate set exceeded the cap; grouped occurrences emitted no partial CALLS',
}),
});
const importsEmitted = callableFlowOnly
? 0

View file

@ -564,6 +564,7 @@ export const streamAllCSVsToDisk = async (
const MULTI_LANG_TYPES = [
'Struct',
'Enum',
'Type',
'EnumVariant',
'Macro',
'Typedef',

View file

@ -1255,6 +1255,7 @@ const BACKTICK_TABLES = new Set([
'Trait',
'Impl',
'TypeAlias',
'Type',
'Const',
'Static',
'Property',
@ -2281,12 +2282,7 @@ export const deleteNodesForFile = async (
/**
* Chunk size for {@link deleteNodesForFiles}. 200 paths keeps each
* statement ~13KB (well inside parser limits) while a ~700-file write set
* still collapses from ~13,000 statements to 124: 31 statements per chunk
* (1 CodeEmbedding join-delete + 30 filePath-bearing node tables the
* 32-table NODE_TABLES roster minus Community/Process) × 4 chunks. The
* original "~40" claim under-counted the per-chunk statement fan-out
* (tri-review 4669518496 accuracy sweep).
* statement ~13KB while batching one delete per file-backed table.
*/
export const DELETE_FILES_CHUNK_SIZE = 200;
@ -2307,9 +2303,7 @@ export const DELETE_FILES_CHUNK_SIZE = 200;
* binder error on the embedding join-delete: a DB created without
* EMBEDDING_SCHEMA cannot own embedding rows, so skipping that one
* statement is sound, while failing would brick every incremental run on
* such a DB until `--force`. Statement count per chunk is unchanged by the
* multi-label join: 1 embedding join-delete + 30 node-table deletes = 31
* (the rejected per-label fallback shape would have been 19 + 30 = 49).
* such a DB until `--force`.
* Singleton-connection only: the analyze writeback owns the write lock,
* and `queryAndDrain` routes through `withConnLock` for it (the WAL
* checkpoint driver is live during this).
@ -2501,34 +2495,40 @@ export const queryImportersBatch = async (
};
/**
* Drop every Community and Process node (and their MEMBER_OF /
* STEP_IN_PROCESS edges via DETACH DELETE). Used at the start of an
* incremental run so the communities and processes phases regenerate
* them from scratch on the merged graph required for the
* "Leiden runs on the FULL graph" correctness invariant.
* Shared mechanics for the delete-all-nodes-by-label family
* ({@link deleteAllCommunitiesAndProcesses}, {@link deleteAllExternalNodes}).
* For each label: count matching nodes, then `DETACH DELETE` them if any.
* The whole loop runs inside `withConnLock` so it cannot execute concurrently
* with the WAL-checkpoint driver's CHECKPOINT on the singleton connection
* (this runs during incremental --pdg writeback while the driver is live;
* mirrors the wrapped deleteAllInterprocTaintPaths / deleteAllCallSummaries).
* A missing table on a freshly-initialized DB is swallowed it simply holds
* no rows to delete.
*
* `labels` are pre-formatted label tokens (the caller decides backticking).
* `filter` is an optional Cypher predicate on the bound node `n`
* (e.g. `WHERE n.locationFidelity = 'external'`); omitted delete all rows.
*/
export const deleteAllCommunitiesAndProcesses = async (): Promise<{
nodesDeleted: number;
}> => {
const deleteAllNodesByLabels = async (
labels: readonly string[],
filter = '',
): Promise<{ nodesDeleted: number }> => {
const c = conn;
if (!c) {
throw new Error('LadybugDB not initialized. Call initLbug first.');
}
// count + DETACH DELETE run inside the connection lock so they cannot execute
// concurrently with the WAL-checkpoint driver's CHECKPOINT on the singleton
// connection. This runs during incremental --pdg writeback while the driver is
// live; mirrors the wrapped deleteAllInterprocTaintPaths / deleteAllCallSummaries.
const clause = filter ? ` ${filter}` : '';
return withConnLock(async () => {
let nodesDeleted = 0;
for (const label of ['Community', 'Process']) {
for (const label of labels) {
let countResult: lbug.QueryResult | lbug.QueryResult[] | undefined;
try {
countResult = await c.query(`MATCH (n:${label}) RETURN count(n) AS cnt`);
countResult = await c.query(`MATCH (n:${label})${clause} RETURN count(n) AS cnt`);
const result = Array.isArray(countResult) ? countResult[0] : countResult;
const rows = await result.getAll();
const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0);
if (count > 0) {
await closeQueryResults(await c.query(`MATCH (n:${label}) DETACH DELETE n`));
await closeQueryResults(await c.query(`MATCH (n:${label})${clause} DETACH DELETE n`));
nodesDeleted += count;
}
} catch {
@ -2541,6 +2541,45 @@ export const deleteAllCommunitiesAndProcesses = async (): Promise<{
});
};
/**
* Drop every Community and Process node (and their MEMBER_OF /
* STEP_IN_PROCESS edges via DETACH DELETE). Used at the start of an
* incremental run so the communities and processes phases regenerate
* them from scratch on the merged graph required for the
* "Leiden runs on the FULL graph" correctness invariant.
*/
export const deleteAllCommunitiesAndProcesses = async (): Promise<{
nodesDeleted: number;
}> => deleteAllNodesByLabels(['Community', 'Process']);
/**
* Node tables that can hold externally-declared dependency symbols
* (`locationFidelity = 'external'`, filePath '') the labels
* `ExternalMoveSymbols` in move-linker materializes. Extend alongside it.
*/
const EXTERNAL_NODE_TABLES = ['Module', 'Function', 'Type'] as const;
/**
* Drop every externally-declared dependency node (and, via DETACH, its
* edges). Used at the start of an incremental writeback: external nodes have
* no filePath, so `deleteNodesForFiles` can never remove them and the
* file-keyed write set can never refresh them. The standalone ingest phase
* regenerates the full external surface every run and
* `extractChangedSubgraph` re-includes it (`isExternalNode`), so
* delete-all-then-rebuild keeps first-referenced externals from dangling
* their edges same contract as Community/Process. Crash-recovery matches
* INJECTS: delete-then-COPY is not atomic, and the `incrementalInProgress`
* dirty flag forces a full rebuild if we die between them.
*
* Every external label is backticked unconditionally: `Type` is quoted at
* CREATE but absent from BACKTICK_TABLES.
*/
export const deleteAllExternalNodes = async (): Promise<{ nodesDeleted: number }> =>
deleteAllNodesByLabels(
EXTERNAL_NODE_TABLES.map((t) => `\`${t}\``),
`WHERE n.locationFidelity = 'external'`,
);
/**
* Shared mechanics for the delete-all-relationships-of-one-type family
* ({@link deleteAllInterprocTaintPaths}, {@link deleteAllCallSummaries},

View file

@ -91,6 +91,18 @@ const structLikeLayout = (table: 'Struct' | 'Enum'): NodeTableLayout => ({
],
});
const TYPE_LAYOUT: NodeTableLayout = {
table: 'Type',
quoteTableName: true,
columns: [
...MULTI_LANGUAGE_BASE,
column('language', 'STRING'),
column('qualifiedName', 'STRING'),
column('moduleQualifiedName', 'STRING'),
column('locationFidelity', 'STRING'),
],
};
const ENUM_VARIANT_LAYOUT: NodeTableLayout = {
table: 'EnumVariant',
quoteTableName: true,
@ -141,6 +153,7 @@ export const NODE_TABLE_LAYOUTS = {
Function: FUNCTION_LAYOUT,
Struct: structLikeLayout('Struct'),
Enum: structLikeLayout('Enum'),
Type: TYPE_LAYOUT,
EnumVariant: ENUM_VARIANT_LAYOUT,
Const: CONST_LAYOUT,
Module: MODULE_LAYOUT,

View file

@ -148,6 +148,7 @@ CREATE NODE TABLE \`${name}\` (
export const STRUCT_SCHEMA = buildNodeTableSchema('Struct');
export const ENUM_SCHEMA = buildNodeTableSchema('Enum');
export const TYPE_SCHEMA = buildNodeTableSchema('Type');
export const ENUM_VARIANT_SCHEMA = buildNodeTableSchema('EnumVariant');
export const MACRO_SCHEMA = CODE_ELEMENT_BASE('Macro');
export const TYPEDEF_SCHEMA = CODE_ELEMENT_BASE('Typedef');
@ -280,6 +281,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM Function TO \`Enum\`,
FROM Function TO \`Namespace\`,
FROM Function TO \`TypeAlias\`,
FROM Function TO \`Type\`,
FROM Function TO \`Module\`,
FROM Function TO \`Impl\`,
FROM Function TO Interface,
@ -357,6 +359,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM \`Struct\` TO \`Struct\`,
FROM \`Struct\` TO Class,
FROM \`Struct\` TO \`Enum\`,
FROM \`Struct\` TO \`Type\`,
FROM \`Struct\` TO Function,
FROM \`Struct\` TO Method,
FROM \`Struct\` TO Interface,
@ -374,9 +377,11 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
// Move/Aptos: module defines structs/enums/consts; enums contain variants.
FROM \`Module\` TO \`Struct\`,
FROM \`Module\` TO \`Enum\`,
FROM \`Module\` TO \`Type\`,
FROM \`Module\` TO \`Const\`,
FROM \`Enum\` TO \`EnumVariant\`,
FROM \`Module\` TO \`EnumVariant\`,
FROM \`EnumVariant\` TO \`Property\`,
FROM \`Typedef\` TO Community,
FROM \`Union\` TO Community,
FROM \`Namespace\` TO Community,
@ -400,6 +405,9 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM \`Variable\` TO Community,
FROM \`Property\` TO Community,
FROM \`Property\` TO \`Property\`,
FROM \`Property\` TO \`Struct\`,
FROM \`Property\` TO \`Enum\`,
FROM \`Property\` TO \`Type\`,
FROM \`Record\` TO Method,
FROM \`Record\` TO \`Constructor\`,
FROM \`Record\` TO \`Property\`,
@ -519,6 +527,7 @@ export const NODE_SCHEMA_QUERIES = [
// Multi-language support
STRUCT_SCHEMA,
ENUM_SCHEMA,
TYPE_SCHEMA,
ENUM_VARIANT_SCHEMA,
MACRO_SCHEMA,
TYPEDEF_SCHEMA,

View file

@ -286,6 +286,34 @@ export const logger = new Proxy({} as Logger, {
},
}) as Logger;
/**
* Env flag the analyze CLI sets while its live progress bar owns the
* terminal (it reroutes console.warn through the bar logger; see
* `cli/analyze.ts`).
*/
export const ANALYZE_PROGRESS_ACTIVE_ENV = 'GITNEXUS_ANALYZE_PROGRESS_ACTIVE';
/**
* Emit an operator-facing warning without corrupting analyze's live progress
* bar. While the bar is active, the one-line progress message goes through
* console.warn (routed into the bar by the analyze CLI) raw pino NDJSON
* would corrupt the one-line display, including in the heap-respawn child
* whose stderr is piped for crash classification. Otherwise the structured
* Pino record is emitted, falling back to the progress message when no
* structured form is given.
*/
export function warnRespectingProgressBar(
progressMessage: string,
structured?: { readonly fields: object; readonly message: string },
): void {
if (process.env[ANALYZE_PROGRESS_ACTIVE_ENV] === '1') {
console.warn(progressMessage);
return;
}
if (structured) logger.warn(structured.fields, structured.message);
else logger.warn(progressMessage);
}
/**
* Shape of a parsed pino record. `level`, `time`, and `msg` are always
* present; `name` is set when emitted from a named child logger; arbitrary

View file

@ -1,62 +1,37 @@
# Move compiler integration
This directory implements GitNexus's compiler-first ingestion for Move packages.
GitNexus discovers packages from `Move.toml`, communicates with `move-flow` over
MCP, and projects compiler facts into the standard GitNexus knowledge graph.
Declaration and semantic data come from the compiler-backed `facts` and
`call_graph` queries rather than raw-source parsing.
Cold compiler builds for large packages may take several minutes. Tool calls
default to a five-minute timeout; override it in milliseconds with
`GITNEXUS_MOVE_FLOW_TIMEOUT_MS` when a repository needs a larger budget.
## Runtime provisioning
When `analyze` finds a `Move.toml`, it resolves `move-flow` from the authoritative
`MOVE_FLOW` path, the verified managed cache, `PATH`, or finally the pinned
release in `release.ts`. Managed releases live under
`~/.gitnexus/tools/move-flow` by default, are downloaded over HTTPS, checked
against `SHA256SUMS`, version-probed, and atomically published while a
heartbeat lease serializes concurrent installers.
Set `GITNEXUS_SKIP_MOVE_FLOW=1` to disable automatic downloads. The umbrella
`GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` does the same while also disabling optional
grammars. Air-gapped hosts should set `MOVE_FLOW` to a preinstalled compatible
binary or pre-seed the managed cache. Advanced trusted-release overrides are
`GITNEXUS_MOVE_FLOW_DIR`, `GITNEXUS_MOVE_FLOW_VERSION`,
`GITNEXUS_MOVE_FLOW_REPO`, `GITNEXUS_MOVE_FLOW_TAG`,
`GITNEXUS_MOVE_FLOW_COMPAT`, and `GITNEXUS_MOVE_FLOW_HTTP_TIMEOUT_MS`.
The repository metadata records the compiler identity used for Move facts.
Changing that identity forces a full rebuild. If an existing compiler-backed
graph needs updating while the compiler is unavailable, analysis fails before
mutating the graph; restore `move-flow` or set `MOVE_FLOW` and retry.
Managed identities include the verified binary hash; explicit and `PATH`
identities use their locator and reported version, so replace a same-version
local build with `gitnexus analyze --force`.
Filesystem errors while discovering `Move.toml` are also surfaced instead of
silently treating the repository as non-Move.
GitNexus's compiler-first ingestion for Move packages. Packages are discovered
from `Move.toml`, queried live over MCP via `move-flow` (`facts` + `call_graph`,
never persisted or replayed), and projected into the standard knowledge graph.
```text
Move package
-> move-flow MCP
-> compiler facts and call graph
-> move-flow MCP (facts + call_graph)
-> Pass A: per-package nodes + deferred PendingRefs
-> global cross-package index
-> Pass B: resolve refs + synthesize external symbols
-> GitNexus nodes and relationships
-> consistency validation
```
## Components
Ingestion runs in two passes around a global cross-package index. Pass A
(`facts-mapper.ts`) maps compiler facts for one package to deterministic nodes
(Module, Function, Struct, Enum, Const) and emits cross-package references it
cannot yet resolve as typed `PendingRef` values. Pass B (`move-linker.ts`) runs
once every package is mapped: a descriptor-driven `resolveRefs` engine looks each
ref up in the accumulated index, synthesizing stub nodes for external
(non-repo) targets and recording anything unresolved as a `DroppedRef`. The
`MoveIngestAccumulator` (in `move-ingest.ts`) holds the shared state across
passes; `consistency.ts` validates the result.
- `mcp-client.ts` owns the `move-flow mcp` process, JSON-RPC transport, and the
client contract consumed by ingestion.
- `compiler-facts.ts` defines the normalized compiler response shapes used by
downstream projections.
- `move-ingest.ts` implements the standalone ingestion phase, including package
discovery, compiler queries, and cross-package resolution.
- `facts-mapper.ts` maps compiler facts to deterministic GitNexus nodes and
relationships.
- `consistency.ts` validates the resulting graph and reports incomplete or
malformed compiler evidence.
A package move-flow cannot build is skipped with an operator-actionable warning
rather than aborting the whole analyze (`_`-placeholder addresses are caught
pre-flight; builds that compile with errors are ingested but flagged as
degraded). Compiler identity is recorded in the repository metadata; changing it
forces a full rebuild. Env knobs: `GITNEXUS_MOVE_FLOW_TIMEOUT_MS`,
`GITNEXUS_MOVE_FLOW_CONCURRENCY` (supplemental `function_usage` fan-out, default
`4`), `GITNEXUS_MOVE_STRICT=1` (restore fatal-on-build-failure),
`GITNEXUS_SKIP_MOVE_FLOW=1`, and `MOVE_FLOW` (see `provision.ts`).
## Upstream references

View file

@ -15,6 +15,12 @@ export interface MoveFlowConstant {
export type CallGraphMap = Record<string, string[]>;
/** Compiler usage for one function. `used - called` is closure capture. */
export interface MoveFunctionUsage {
called: string[];
used: string[];
}
// ─────────────────────────────────────────────────────────────────────────
// move-flow `facts` query — full-fidelity, compiler-sourced per-module facts.
//

View file

@ -0,0 +1,42 @@
// gitnexus/src/core/move/concurrency.ts
export interface MapWithConcurrencyResult<T, R> {
results: R[];
failure?: { item: T; error: unknown };
}
/**
* Run `worker` over `items` with at most `limit` in flight. With `failFast`,
* the first rejection stops scheduling new work and is returned as `failure`
* (already-running workers are awaited). Without it, the first rejection
* rejects the returned promise.
*/
export async function mapWithConcurrency<T, R>(
items: readonly T[],
limit: number,
worker: (item: T, index: number) => Promise<R>,
opts: { failFast?: boolean } = {},
): Promise<MapWithConcurrencyResult<T, R>> {
const results: R[] = new Array(items.length);
const effectiveLimit = Math.max(1, Math.floor(limit));
let next = 0;
let failure: { item: T; error: unknown } | undefined;
async function run(): Promise<void> {
while (next < items.length && !(opts.failFast && failure)) {
const i = next++;
try {
results[i] = await worker(items[i], i);
} catch (error) {
if (opts.failFast) {
if (!failure) failure = { item: items[i], error };
return;
}
throw error;
}
}
}
const workers = Array.from({ length: Math.min(effectiveLimit, items.length) }, run);
await Promise.all(workers);
return failure ? { results, failure } : { results };
}

View file

@ -1,7 +1,8 @@
import type { KnowledgeGraph } from '../graph/types.js';
import type { MovePackageStatus } from './mcp-client.js';
import type { MoveIngestOutput } from './move-ingest.js';
import { moveModuleQualifiedName } from './symbol-id.js';
import type { DroppedRef, PendingRefKind } from './refs.js';
import { moveModuleQualifiedName, parseMoveModuleQualifiedName } from './symbol-id.js';
export type MoveConsistencySeverity = 'warning' | 'error';
@ -11,6 +12,16 @@ export interface MoveConsistencyIssue {
| 'missing-owned-callee'
| 'malformed-source-evidence'
| 'unresolved-resource-target'
| 'unresolved-type-target'
| 'unresolved-friend-target'
| 'unresolved-lambda-host'
| 'function-usage-query-failed'
/** Non-empty call graph in which no caller resolves to a mapped function -
* a systematic qualified-name mismatch between `call_graph` and `facts`. */
| 'call-graph-unlinked'
/** Externally-materialized module shares an address with repo-local
* modules - possibly local code the compiler elided from `facts`. */
| 'external-module-address-overlap'
/** Package with .move sources returned facts `{}` - severity policy in
* `emptyFactsIssue` below. */
| 'empty-package-facts'
@ -35,6 +46,53 @@ export interface EmptyFactsPackage {
status: MovePackageStatus | null;
}
export interface MoveConsistencySummaryIssue {
code: MoveConsistencyIssue['code'];
severity: MoveConsistencySeverity;
message: string;
/** Bounded JSON preview of the original structured details. */
details?: string;
}
/** Compact, persistable digest of a run's Move consistency issues. */
export interface MoveConsistencySummary {
errorCount: number;
warningCount: number;
/** Errors-first sample, capped so meta.json stays small. */
issues: MoveConsistencySummaryIssue[];
}
const MAX_SUMMARY_MESSAGE_CHARS = 500;
const MAX_SUMMARY_DETAILS_CHARS = 2_000;
function truncateSummaryText(value: string, maxChars: number): string {
if (value.length <= maxChars) return value;
return `${value.slice(0, maxChars - 3)}...`;
}
/** `undefined` when there is nothing to record (the common clean run). */
export function summarizeMoveConsistency(
issues: readonly MoveConsistencyIssue[],
sampleLimit = 20,
): MoveConsistencySummary | undefined {
if (issues.length === 0) return undefined;
const errors = issues.filter((i) => i.severity === 'error');
const warnings = issues.filter((i) => i.severity === 'warning');
return {
errorCount: errors.length,
warningCount: warnings.length,
issues: [...errors, ...warnings].slice(0, sampleLimit).map((issue) => ({
code: issue.code,
severity: issue.severity,
message: truncateSummaryText(issue.message, MAX_SUMMARY_MESSAGE_CHARS),
details:
issue.details === undefined
? undefined
: truncateSummaryText(JSON.stringify(issue.details), MAX_SUMMARY_DETAILS_CHARS),
})),
};
}
/**
* Severity policy for a package whose facts came back `{}` despite .move
* sources: compiling -> warning (likely test-only, elided from facts), failing
@ -204,6 +262,9 @@ export function validateMoveIngestOutput(
}
}
validateCallGraphLinkage(moveIngest, issues);
validateExternalAddressOverlap(graph, moveIngest, issues);
for (const [packageRoot, callGraph] of moveIngest.callGraphByPackage) {
for (const [callerQualified, callees] of Object.entries(callGraph)) {
const callerModule = moveModuleQualifiedName(callerQualified);
@ -233,15 +294,150 @@ export function validateMoveIngestOutput(
}
}
const dropped = moveIngest.droppedResourceRefs;
if (dropped && dropped.length > 0) {
issues.push({
code: 'unresolved-resource-target',
severity: 'warning',
message: `${dropped.length} resource read/write/acquires target(s) could not be resolved.`,
details: { count: dropped.length, sample: dropped.slice(0, 5) },
});
}
pushGroupedDrops(issues, moveIngest.droppedRefs);
pushDroppedIssue(
issues,
moveIngest.functionUsageFailures,
'function-usage-query-failed',
'supplemental function-usage query(s) failed',
);
return issues;
}
const DROP_ISSUE: Record<PendingRefKind, { code: MoveConsistencyIssue['code']; label: string }> = {
resource: {
code: 'unresolved-resource-target',
label: 'resource read/write/acquires target(s) could not be resolved',
},
type: {
code: 'unresolved-type-target',
label: 'signature/type reference target(s) could not be resolved',
},
friend: {
code: 'unresolved-friend-target',
label: 'friend declaration target(s) have no Module node',
},
'lambda-host': {
code: 'unresolved-lambda-host',
label: 'lambda function(s) have no resolvable host function',
},
};
function pushGroupedDrops(issues: MoveConsistencyIssue[], drops: readonly DroppedRef[]): void {
const byKind = new Map<PendingRefKind, DroppedRef[]>();
for (const d of drops) {
const list = byKind.get(d.kind);
if (list) list.push(d);
else byKind.set(d.kind, [d]);
}
for (const [kind, list] of byKind) {
const { code, label } = DROP_ISSUE[kind];
issues.push({
code,
severity: 'warning',
message: `${list.length} ${label}.`,
details: { count: list.length, sample: list.slice(0, 5) },
});
}
}
/** One warning per non-empty dropped-reference list: full count, 5-item sample. */
function pushDroppedIssue(
issues: MoveConsistencyIssue[],
dropped: readonly unknown[],
code: MoveConsistencyIssue['code'],
label: string,
): void {
if (dropped.length === 0) return;
issues.push({
code,
severity: 'warning',
message: `${dropped.length} ${label}.`,
details: { count: dropped.length, sample: dropped.slice(0, 5) },
});
}
/**
* A non-empty call graph in which NO caller resolves to a mapped function OR
* mapped module is a systematic qualified-name mismatch between `call_graph`
* and `facts` (e.g. address-normalization drift across move-flow versions):
* every CALLS edge drops, and the per-caller ownership checks are also blind
* because modulePackageMap is keyed from the same facts names. The module
* condition keeps call graphs that merely include facts-elided functions
* (e.g. tests) out of scope their caller MODULES still resolve, and the
* per-caller missing-owned-caller check already covers them. The callee
* condition covers callers living entirely in facts-elided modules (a
* test-only module exercising a mapped library): their CALLEES still join
* facts names, which real normalization drift would break on both sides.
* Scoped to packages that DID map functions, so a structs-only package
* cannot false-positive.
*/
function validateCallGraphLinkage(
moveIngest: MoveIngestOutput,
issues: MoveConsistencyIssue[],
): void {
const mappedFnPackages = new Set<string>();
for (const fnQualified of moveIngest.functionNodeMap.keys()) {
const pkg = moveIngest.modulePackageMap.get(moveModuleQualifiedName(fnQualified));
if (pkg) mappedFnPackages.add(pkg);
}
for (const [packageRoot, callGraph] of moveIngest.callGraphByPackage) {
const callers = Object.keys(callGraph);
if (callers.length === 0 || !mappedFnPackages.has(packageRoot)) continue;
if (callers.some((caller) => moveIngest.functionNodeMap.has(caller))) continue;
if (
callers.some((caller) => moveIngest.modulePackageMap.has(moveModuleQualifiedName(caller)))
) {
continue;
}
const callees = Object.values(callGraph).flat();
if (callees.some((callee) => moveIngest.functionNodeMap.has(callee))) continue;
issues.push({
code: 'call-graph-unlinked',
severity: 'error',
message:
`Move call graph for ${packageRoot} resolved zero of ${callers.length} caller(s) to ` +
`mapped functions - qualified-name mismatch between call_graph and facts?`,
details: { packageRoot, callerCount: callers.length, sample: callers.slice(0, 5) },
});
}
}
/**
* The linker materializes unresolved cross-module targets as external
* dependency nodes (`locationFidelity: 'external'`). An external module whose
* ADDRESS also hosts repo-local modules is suspicious: it may be local code
* that `facts` elided while `call_graph`/type refs still name it, which would
* otherwise mislabel the user's own symbols as foreign with no trace.
*/
function validateExternalAddressOverlap(
graph: KnowledgeGraph,
moveIngest: MoveIngestOutput,
issues: MoveConsistencyIssue[],
): void {
const localAddresses = new Set<string>();
for (const moduleQualified of moveIngest.moduleFileMap.keys()) {
const { address } = parseMoveModuleQualifiedName(moduleQualified);
if (address) localAddresses.add(address);
}
if (localAddresses.size === 0) return;
const overlapping: string[] = [];
for (const node of graph.iterNodes()) {
if (node.label !== 'Module' || node.properties.locationFidelity !== 'external') continue;
const qualifiedName = node.properties.qualifiedName;
if (typeof qualifiedName !== 'string') continue;
const { address } = parseMoveModuleQualifiedName(qualifiedName);
if (address && localAddresses.has(address)) overlapping.push(qualifiedName);
}
if (overlapping.length === 0) return;
issues.push({
code: 'external-module-address-overlap',
severity: 'warning',
message:
`${overlapping.length} external-dependency module(s) share an address with repo-local ` +
`modules - local code may have been materialized as external (facts/call_graph drift).`,
details: { count: overlapping.length, sample: overlapping.slice(0, 5) },
});
}

View file

@ -59,6 +59,8 @@ export const MOVE_EDGE_REASON = {
definesStruct: 'move-module-defines-struct',
definesEnum: 'move-module-defines-enum',
definesConst: 'move-module-defines-const',
externalDefinesFunction: 'move-external-module-defines-function',
externalDefinesType: 'move-external-module-defines-type',
containsVariant: 'move-enum-contains-variant',
friend: 'move-friend-or-package',
calls: 'move-compiler-call-graph',
@ -70,12 +72,16 @@ export const MOVE_EDGE_REASON = {
acquires: 'move-acquires',
fnParamType: 'move-fn-param-type',
fnReturnType: 'move-fn-return-type',
structFieldType: 'move-struct-field-type',
enumVariantFieldType: 'move-enum-variant-field-type',
// Struct -> resource-group struct (from `#[resource_group_member(group = ...)]`)
resourceGroupMember: 'move-resource-group-member',
// Field edges (struct → field)
hasField: 'move-struct-has-field',
hasVariantField: 'move-enum-variant-has-field',
// Lambda → host edges (host fn → __lambda__N__host)
lambdaHost: 'move-lambda-of-host',
closureUse: 'move-compiler-closure-use',
// Entry-point edge reasons (ENTRY_POINT_OF)
entryFunction: 'move-entry-function',
viewFunction: 'move-view-function',

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,65 @@
import type { CallGraphMap, MoveFactsMap } from './compiler-facts.js';
import { mapWithConcurrency } from './concurrency.js';
import type { MoveFlowClient } from './mcp-client.js';
import { moveShortSymbol } from './symbol-id.js';
export interface FunctionUsageFailure {
functionQualified: string;
message: string;
}
export interface ClosureCaptureResult {
calls: CallGraphMap;
failures: FunctionUsageFailure[];
}
/**
* Recover compiler-known calls through function values. `used` contains direct
* calls and closure captures; subtracting `called` leaves the missing callable
* edges. `call_graph` callees are subtracted too: the two queries may classify
* the same edge differently (invoked AND passed as a value), and an edge the
* package call graph already carries must not be emitted a second time under
* the closure-use reason. Failures are supplemental diagnostics and do not
* discard facts or the ordinary call graph. The first failure stops the
* supplemental scan so a broken or slow query cannot multiply the package
* timeout by every function.
*/
export async function collectClosureCaptureCalls(
client: MoveFlowClient,
packageRoot: string,
facts: MoveFactsMap,
callGraph: CallGraphMap,
limit: number,
): Promise<ClosureCaptureResult> {
const candidates: string[] = [];
for (const [moduleQualified, moduleFacts] of Object.entries(facts)) {
for (const fn of moduleFacts.functions ?? []) {
if (fn.isLambdaLifted || fn.isNative) continue;
candidates.push(`${moduleQualified}::${fn.name}`);
}
}
const calls: CallGraphMap = {};
const { failure } = await mapWithConcurrency(
candidates,
limit,
async (functionQualified) => {
const usage = await client.functionUsage(packageRoot, moveShortSymbol(functionQualified));
const known = new Set([...usage.called, ...(callGraph[functionQualified] ?? [])]);
const captured = usage.used.filter((t) => !known.has(t));
if (captured.length > 0) calls[functionQualified] = [...new Set(captured)];
},
{ failFast: true },
);
const failures: FunctionUsageFailure[] = failure
? [
{
functionQualified: String(failure.item),
message: failure.error instanceof Error ? failure.error.message : String(failure.error),
},
]
: [];
return { calls, failures };
}

View file

@ -518,6 +518,14 @@ export async function installMoveFlow(
stagedDir = undefined;
return { status: 'installed', binary: verifiedBinary(config, metadata) };
} catch (error) {
// A concurrent installer may have published a valid cache while this
// attempt was waiting on the lock or downloading (e.g. a stolen lease
// after host sleep makes our publish rename collide) - its install is as
// good as ours, so prefer it over reporting a failure.
const published = await validCachedInstall(config);
if (published) {
return { status: 'available', binary: published };
}
return {
status: 'failed',
message: `installation failed: ${error instanceof Error ? error.message : String(error)}`,

View file

@ -8,7 +8,7 @@
import { spawn, execFileSync, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { createInterface } from 'node:readline';
import type { MoveFactsMap, CallGraphMap } from './compiler-facts.js';
import type { MoveFactsMap, CallGraphMap, MoveFunctionUsage } from './compiler-facts.js';
import { isCompatibleMoveFlowVersion } from './release.js';
interface JsonRpcResponse {
@ -41,6 +41,15 @@ function isMcpListToolResult(v: unknown): v is McpListToolResult {
return typeof v === 'object' && v !== null;
}
const isStringArray = (value: unknown): value is string[] =>
Array.isArray(value) && value.every((item) => typeof item === 'string');
function isMoveFunctionUsage(value: unknown): value is MoveFunctionUsage {
if (typeof value !== 'object' || value === null) return false;
const usage = value as Record<string, unknown>;
return isStringArray(usage.called) && isStringArray(usage.used);
}
/** JSON-RPC `invalid_params` - move-flow uses it for caller/input errors
* (and some builds route package build failures through it). */
const JSON_RPC_INVALID_PARAMS = -32602;
@ -74,6 +83,32 @@ export class MoveFlowToolCallError extends Error {
}
}
/**
* A move-flow tool call that exceeded its time budget. The client kills and
* respawns the server either way, but callers must not blindly retry: the
* budget itself is the problem (raise GITNEXUS_MOVE_FLOW_TIMEOUT_MS), unlike a
* transport fault where a fresh process can succeed.
*/
export class MoveFlowTimeoutError extends Error {
constructor(message: string) {
super(message);
this.name = 'MoveFlowTimeoutError';
}
}
/**
* The move-flow process died or its pipe broke while requests were in flight.
* The request layer retries these once every query GitNexus issues is an
* idempotent read, and the process respawns on the next attempt. Everything
* else (build errors, timeouts, spawn failures, shutdown) is final.
*/
export class MoveFlowTransportError extends Error {
constructor(message: string) {
super(message);
this.name = 'MoveFlowTransportError';
}
}
/**
* The move-flow surface GitNexus consumes. Defined here (not in the ingest
* phase) so the client owns its own contract and the ingest phase depends on
@ -84,6 +119,8 @@ export interface MoveFlowClient {
facts(packagePath: string): Promise<MoveFactsMap>;
/** Function-level call graph (caller qualified name → callee qualified names). */
callGraph(packagePath: string): Promise<CallGraphMap>;
/** Direct/transitive calls and closure captures for one function. */
functionUsage(packagePath: string, functionName: string): Promise<MoveFunctionUsage>;
/**
* Build status probe (move_package_status): does the package compile, and
* what did the compiler say. Older move-flow builds do not expose the tool -
@ -110,6 +147,8 @@ export interface MovePackageStatus {
export interface MoveFlowCapabilities {
/** `facts` query available (rich, compiler-sourced per-module facts). */
hasFactsQuery: boolean;
/** `function_usage` query available (closure captures included in `used`). */
hasFunctionUsageQuery: boolean;
/** `move_package_status` tool available (build status + diagnostics). */
hasStatusTool: boolean;
}
@ -143,22 +182,28 @@ export function detectMoveFlowCapabilities(
if (t.name === 'move_package_query') querySchema = t.inputSchema;
}
}
const hasFactsQuery = names.has('move_package_facts') || schemaMentionsFactsQuery(querySchema);
return { hasFactsQuery, hasStatusTool: names.has('move_package_status') };
const hasFactsQuery =
names.has('move_package_facts') || schemaMentionsQuery(querySchema, 'facts');
const hasFunctionUsageQuery = schemaMentionsQuery(querySchema, 'function_usage');
return {
hasFactsQuery,
hasFunctionUsageQuery,
hasStatusTool: names.has('move_package_status'),
};
}
/** True if the `move_package_query` inputSchema declares a `"facts"` query const. */
function schemaMentionsFactsQuery(schema: unknown): boolean {
/** True if `move_package_query` declares the requested query const/enum item. */
function schemaMentionsQuery(schema: unknown, query: string): boolean {
if (!schema || typeof schema !== 'object') return false;
// Walk the JSON-schema object looking for a `const: "facts"` or
// `enum: [... "facts" ...]` anywhere under the QueryType definition.
// Walk the JSON-schema object looking for the query in either a `const` or
// an `enum` anywhere under the QueryType definition.
const stack: unknown[] = [schema];
while (stack.length) {
const node = stack.pop();
if (!node || typeof node !== 'object') continue;
const obj = node as Record<string, unknown>;
if (obj.const === 'facts') return true;
if (Array.isArray(obj.enum) && obj.enum.includes('facts')) return true;
if (obj.const === query) return true;
if (Array.isArray(obj.enum) && obj.enum.includes(query)) return true;
for (const v of Object.values(obj)) {
if (v && typeof v === 'object') stack.push(v);
}
@ -180,6 +225,9 @@ export class MoveFlowMcpClient implements MoveFlowClient {
private initialized = false;
private initPromise: Promise<void> | null = null;
private capsPromise: Promise<MoveFlowCapabilities> | null = null;
/** Set by shutdown(); an in-flight transport retry must not respawn a child
* after the owner has released the client (it would leak the process). */
private closed = false;
private binaryPath: string;
private stderrLines: string[] = [];
private static readonly MAX_STDERR = 20;
@ -218,6 +266,7 @@ export class MoveFlowMcpClient implements MoveFlowClient {
}
private async ensureStarted(): Promise<void> {
if (this.closed) throw new Error('move-flow client is shut down');
if (this.initialized) return;
if (this.initPromise) return this.initPromise;
const start = this._start().catch((err) => {
@ -285,19 +334,19 @@ export class MoveFlowMcpClient implements MoveFlowClient {
if (this.stderrLines.length > MoveFlowMcpClient.MAX_STDERR) {
this.stderrLines.shift();
}
const wrapped = new Error(`move-flow stdin failed: ${err.message}${this.stderrContext()}`);
if (!initSettled) failInitialization(wrapped);
else this.failProcess(proc, wrapped);
const message = `move-flow stdin failed: ${err.message}${this.stderrContext()}`;
// Transport-typed only after init: a broken pipe during startup is a
// launch failure, not a retryable mid-flight fault.
if (!initSettled) failInitialization(new Error(message));
else this.failProcess(proc, new MoveFlowTransportError(message));
});
proc.on('error', (err) => {
const wrapped = new Error(
`Failed to spawn move-flow: ${err.message}${this.stderrContext()}`,
);
const message = `Failed to spawn move-flow: ${err.message}${this.stderrContext()}`;
if (!initSettled) {
failInitialization(wrapped);
failInitialization(new Error(message));
} else {
this.failProcess(proc, wrapped);
this.failProcess(proc, new MoveFlowTransportError(message));
}
});
@ -312,7 +361,9 @@ export class MoveFlowMcpClient implements MoveFlowClient {
}
this.failProcess(
proc,
new Error(`move-flow exited unexpectedly (code ${code})${this.stderrContext()}`),
new MoveFlowTransportError(
`move-flow exited unexpectedly (code ${code})${this.stderrContext()}`,
),
false,
);
});
@ -391,11 +442,36 @@ export class MoveFlowMcpClient implements MoveFlowClient {
this.proc.stdin.write(JSON.stringify(msg) + '\n');
}
/**
* Issue a request with one optional retry after a mid-flight transport fault:
* failProcess has already reset the client state when the fault surfaces,
* so the second attempt respawns the server. Deterministic failures (spawn
* errors, timeouts, tool errors) propagate on the first attempt.
*
* Pass `{ retryOnTransport: false }` to suppress the retry callers that
* run under bounded concurrency (e.g. functionUsage) must not trigger a
* retry/respawn storm when the transport faults.
*/
private async request(
method: string,
params: unknown,
timeoutMs: number,
timeoutMessage: string,
timeoutError: Error,
{ retryOnTransport = true }: { retryOnTransport?: boolean } = {},
): Promise<unknown> {
try {
return await this.requestOnce(method, params, timeoutMs, timeoutError);
} catch (err) {
if (!retryOnTransport || !(err instanceof MoveFlowTransportError)) throw err;
return this.requestOnce(method, params, timeoutMs, timeoutError);
}
}
private async requestOnce(
method: string,
params: unknown,
timeoutMs: number,
timeoutError: Error,
): Promise<unknown> {
await this.ensureStarted();
const proc = this.proc;
@ -404,7 +480,16 @@ export class MoveFlowMcpClient implements MoveFlowClient {
return new Promise<unknown>((resolve, reject) => {
const id = ++this.requestId;
const timeout = setTimeout(() => {
this.failProcess(proc, new Error(timeoutMessage));
const timedOut = this.pending.get(id);
if (!timedOut) return;
this.pending.delete(id);
timedOut.reject(timeoutError);
this.failProcess(
proc,
new MoveFlowTransportError(
`move-flow process restarted after another request timed out: ${timeoutError.message}`,
),
);
}, timeoutMs);
this.pending.set(id, {
resolve: (result) => {
@ -426,14 +511,21 @@ export class MoveFlowMcpClient implements MoveFlowClient {
});
}
private async callTool(toolName: string, args: Record<string, unknown>): Promise<unknown> {
private async callTool(
toolName: string,
args: Record<string, unknown>,
opts?: { retryOnTransport?: boolean },
): Promise<unknown> {
const timeoutMs = resolveMoveFlowToolTimeoutMs();
const result = await this.request(
'tools/call',
{ name: toolName, arguments: args },
timeoutMs,
`move-flow '${toolName}' timed out after ${timeoutMs}ms ` +
'(raise GITNEXUS_MOVE_FLOW_TIMEOUT_MS for large packages)',
new MoveFlowTimeoutError(
`move-flow '${toolName}' timed out after ${timeoutMs}ms ` +
'(raise GITNEXUS_MOVE_FLOW_TIMEOUT_MS for large packages)',
),
opts,
);
if (!isMcpCallToolResult(result)) return result;
if (result.isError === true) {
@ -465,6 +557,24 @@ export class MoveFlowMcpClient implements MoveFlowClient {
})) as MoveFactsMap;
}
async functionUsage(packagePath: string, functionName: string): Promise<MoveFunctionUsage> {
const usage = await this.callTool(
'move_package_query',
{
package_path: packagePath,
query: 'function_usage',
function: functionName,
},
{ retryOnTransport: false },
);
if (!isMoveFunctionUsage(usage)) {
throw new MoveFlowToolCallError(
`move-flow returned malformed function_usage for '${functionName}'`,
);
}
return usage;
}
async packageStatus(packagePath: string): Promise<MovePackageStatus> {
try {
const result = await this.callTool('move_package_status', { package_path: packagePath });
@ -481,7 +591,12 @@ export class MoveFlowMcpClient implements MoveFlowClient {
/** Raw JSON-RPC request (non-`tools/call`), e.g. `tools/list`. */
private async rpcRequest(method: string, params?: unknown): Promise<unknown> {
return this.request(method, params, 30_000, `move-flow '${method}' timed out after 30s`);
return this.request(
method,
params,
30_000,
new MoveFlowTimeoutError(`move-flow '${method}' timed out after 30s`),
);
}
async capabilities(): Promise<MoveFlowCapabilities> {
@ -498,6 +613,7 @@ export class MoveFlowMcpClient implements MoveFlowClient {
}
async shutdown(): Promise<void> {
this.closed = true;
const proc = this.proc;
if (proc) {
proc.stdin?.end();

View file

@ -29,28 +29,18 @@ import type {
import { getPhaseOutput } from '../ingestion/pipeline-phases/types.js';
import type { StructureOutput } from '../ingestion/pipeline-phases/structure.js';
import type { StandaloneIngestOutput } from '../ingestion/pipeline-phases/standalone-ingest.js';
import type { KnowledgeGraph } from '../graph/types.js';
import { MOVE_EDGE_REASON, moveRepoRelativePath } from './constants.js';
import { moveRepoRelativePath } from './constants.js';
import {
MoveFlowTimeoutError,
MoveFlowToolCallError,
type MoveFlowClient,
type MovePackageStatus,
} from './mcp-client.js';
import {
buildLocalNameIndex,
mapFactsToGraph,
resolveFriendEdges,
resolveLambdaHostEdges,
resolveResourceEdges,
resolveTypeRefEdges,
type MoveFactsMapResult,
type PendingFriend,
type PendingLambdaHost,
type PendingResource,
type PendingTypeRef,
} from './facts-mapper.js';
import { mapFactsToGraph, type MoveFactsMapResult } from './facts-mapper.js';
import type { CallGraphMap, MoveFactsMap } from './compiler-facts.js';
import { moveModuleNodeId, moveModuleQualifiedName, moveRelId } from './symbol-id.js';
import { collectClosureCaptureCalls, type FunctionUsageFailure } from './function-usage.js';
import { linkMoveIngestGraph, type MoveLinkView } from './move-linker.js';
import type { DroppedRef, PendingRef } from './refs.js';
import {
buildFailedIssue,
cliWarningsFromIssues,
@ -63,6 +53,11 @@ import {
} from './consistency.js';
import { createMoveEntryPointEdges } from './entry-points.js';
function resolveMoveConcurrency(): number {
const n = Number(process.env.GITNEXUS_MOVE_FLOW_CONCURRENCY);
return Number.isSafeInteger(n) && n > 0 ? n : 4;
}
// ── Phase output ───────────────────────────────────────────────────────────
export interface MoveIngestOutput extends StandaloneIngestOutput {
@ -80,8 +75,10 @@ export interface MoveIngestOutput extends StandaloneIngestOutput {
filePackageMap: ReadonlyMap<string, string>;
/** Absolute package root → compiler call graph for that package. */
callGraphByPackage: ReadonlyMap<string, CallGraphMap>;
/** Resource references dropped during global resolution. */
droppedResourceRefs?: { fnNodeId: string; target: string }[];
/** All refs dropped during global resolution (resource, type, friend, lambda-host). */
droppedRefs: DroppedRef[];
/** Supplemental function-usage queries that could not be completed. */
functionUsageFailures: FunctionUsageFailure[];
/** Non-fatal consistency issues found after Move ingestion. */
consistencyIssues: MoveConsistencyIssue[];
/** Operator-actionable warnings for the persistent CLI summary (skipped or
@ -90,56 +87,49 @@ export interface MoveIngestOutput extends StandaloneIngestOutput {
}
/** Mutable accumulator shared while ingesting every package. */
interface MoveIngestState {
ingestedFiles: Set<string>;
moduleFileMap: Map<string, string>;
functionNodeMap: Map<string, string>;
structNodeMap: Map<string, string>;
modulePackageMap: Map<string, string>;
filePackageMap: Map<string, string>;
callGraphByPackage: Map<string, CallGraphMap>;
pendingResource: PendingResource[];
pendingFriends: PendingFriend[];
pendingTypeRef: PendingTypeRef[];
pendingLambdaHosts: PendingLambdaHost[];
droppedResourceRefs: { fnNodeId: string; target: string }[];
}
class MoveIngestAccumulator implements MoveLinkView {
readonly moduleFileMap = new Map<string, string>();
readonly functionNodeMap = new Map<string, string>();
readonly structNodeMap = new Map<string, string>();
readonly modulePackageMap = new Map<string, string>();
readonly filePackageMap = new Map<string, string>();
readonly callGraphByPackage = new Map<string, CallGraphMap>();
readonly closureCallsByPackage = new Map<string, CallGraphMap>();
readonly pendingRefs: PendingRef[] = [];
readonly droppedRefs: DroppedRef[] = [];
readonly ingestedFiles = new Set<string>();
readonly functionUsageFailures: FunctionUsageFailure[] = [];
function createState(): MoveIngestState {
return {
ingestedFiles: new Set(),
moduleFileMap: new Map(),
functionNodeMap: new Map(),
structNodeMap: new Map(),
modulePackageMap: new Map(),
filePackageMap: new Map(),
callGraphByPackage: new Map(),
pendingResource: [],
pendingFriends: [],
pendingTypeRef: [],
pendingLambdaHosts: [],
droppedResourceRefs: [],
};
}
mergePackage(mapped: MoveFactsMapResult, pkgRoot: string): void {
for (const [qn, file] of mapped.moduleFileMap) {
this.moduleFileMap.set(qn, file);
this.modulePackageMap.set(qn, pkgRoot);
this.filePackageMap.set(file, pkgRoot);
}
for (const [qn, id] of mapped.functionNodeMap) this.functionNodeMap.set(qn, id);
for (const [qn, id] of mapped.structNodeMap) this.structNodeMap.set(qn, id);
this.pendingRefs.push(...mapped.pendingRefs);
}
function toOutput(
state: MoveIngestState,
packageRoots: string[],
consistencyIssues: MoveConsistencyIssue[] = [],
): MoveIngestOutput {
return {
ingestedFiles: state.ingestedFiles,
packageRoots,
moduleFileMap: state.moduleFileMap,
functionNodeMap: state.functionNodeMap,
structNodeMap: state.structNodeMap,
modulePackageMap: state.modulePackageMap,
filePackageMap: state.filePackageMap,
callGraphByPackage: state.callGraphByPackage,
droppedResourceRefs: state.droppedResourceRefs,
consistencyIssues,
ingestWarnings: cliWarningsFromIssues(consistencyIssues),
};
toOutput(
packageRoots: string[],
consistencyIssues: MoveConsistencyIssue[] = [],
): MoveIngestOutput {
return {
ingestedFiles: this.ingestedFiles,
packageRoots,
moduleFileMap: this.moduleFileMap,
functionNodeMap: this.functionNodeMap,
structNodeMap: this.structNodeMap,
modulePackageMap: this.modulePackageMap,
filePackageMap: this.filePackageMap,
callGraphByPackage: this.callGraphByPackage,
droppedRefs: this.droppedRefs,
functionUsageFailures: this.functionUsageFailures,
consistencyIssues,
ingestWarnings: cliWarningsFromIssues(consistencyIssues),
};
}
}
/** GITNEXUS_MOVE_STRICT=1|true restores the historical fatal-on-build-failure
@ -180,28 +170,6 @@ async function findPlaceholderAddresses(pkgRoot: string): Promise<string[]> {
return placeholders;
}
/** Add a mapped package's nodes/edges to the graph and merge its identity maps. */
function applyMapped(
graph: KnowledgeGraph,
mapped: MoveFactsMapResult,
pkgRoot: string,
state: MoveIngestState,
): void {
for (const node of mapped.nodes) graph.addNode(node);
for (const rel of mapped.edges) graph.addRelationship(rel);
for (const [qn, file] of mapped.moduleFileMap) {
state.moduleFileMap.set(qn, file);
state.modulePackageMap.set(qn, pkgRoot);
state.filePackageMap.set(file, pkgRoot);
}
for (const [qn, id] of mapped.functionNodeMap) state.functionNodeMap.set(qn, id);
for (const [qn, id] of mapped.structNodeMap) state.structNodeMap.set(qn, id);
state.pendingResource.push(...mapped.pendingResource);
state.pendingFriends.push(...mapped.pendingFriends);
state.pendingTypeRef.push(...mapped.pendingTypeRef);
state.pendingLambdaHosts.push(...mapped.pendingLambdaHosts);
}
export function createMoveIngestPhase(
client: MoveFlowClient | null,
): PipelinePhase<MoveIngestOutput> {
@ -224,10 +192,10 @@ export function createMoveIngestPhase(
),
].sort();
if (!client || packageRoots.length === 0) {
return toOutput(createState(), packageRoots);
return new MoveIngestAccumulator().toOutput(packageRoots);
}
const { hasFactsQuery, hasStatusTool } = await client.capabilities();
const { hasFactsQuery, hasFunctionUsageQuery, hasStatusTool } = await client.capabilities();
if (!hasFactsQuery) {
// userActionable: rendered as a one-liner without a stack — the fix is
// an operator action (upgrade move-flow), not a code bug.
@ -238,7 +206,8 @@ export function createMoveIngestPhase(
{ userActionable: true },
);
}
const state = createState();
const acc = new MoveIngestAccumulator();
let functionUsageEnabled = hasFunctionUsageQuery;
// Group scanned .move files by their (innermost) owning package. Files
// are marked as ingested per package only AFTER its facts arrive, so a
@ -261,12 +230,11 @@ export function createMoveIngestPhase(
moveFilesByPackage.set(owner, files);
}
// Pass 1: per-package nodes/edges (all packages first, so cross-package
// CALLS in Pass 2 can resolve callees in later packages).
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).
for (const pkgRoot of packageRoots) {
ctx.onProgress({
phase: 'moveIngest',
@ -290,22 +258,17 @@ export function createMoveIngestPhase(
let callGraphData: CallGraphMap;
let factsMap: MoveFactsMap;
try {
callGraphData = await client.callGraph(pkgRoot);
factsMap = await client.facts(pkgRoot);
[callGraphData, factsMap] = await Promise.all([
client.callGraph(pkgRoot),
client.facts(pkgRoot),
]);
} catch (err) {
if (err instanceof MoveFlowToolCallError) {
// 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 },
);
}
// 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 (err instanceof MoveFlowToolCallError && !strictMove) {
packageIssues.push(
buildFailedIssue({
pkgRoot,
@ -321,7 +284,10 @@ export function createMoveIngestPhase(
});
continue;
}
throw err;
// Strict-mode build failure, timeout (wedged build), or a transport
// fault that already used its retry: render as the operator one-liner
// (build/timeout) or propagate as the crash it is.
throw toPackageQueryError(err, pkgRoot);
}
if (Object.keys(factsMap).length === 0 && pkgMoveFiles.length > 0) {
@ -340,10 +306,24 @@ export function createMoveIngestPhase(
}
// Only past the gate: a skipped package must have zero footprint in
// Pass 2 linking and consistency validation.
state.callGraphByPackage.set(pkgRoot, callGraphData);
for (const rel of pkgMoveFiles) state.ingestedFiles.add(rel);
applyMapped(ctx.graph, mapFactsToGraph(factsMap, pkgRoot, ctx.repoPath), pkgRoot, state);
acc.callGraphByPackage.set(pkgRoot, callGraphData);
if (functionUsageEnabled) {
const closureCapture = await collectClosureCaptureCalls(
client,
pkgRoot,
factsMap,
callGraphData,
resolveMoveConcurrency(),
);
acc.closureCallsByPackage.set(pkgRoot, closureCapture.calls);
acc.functionUsageFailures.push(...closureCapture.failures);
if (closureCapture.failures.length > 0) functionUsageEnabled = false;
}
for (const rel of pkgMoveFiles) acc.ingestedFiles.add(rel);
const mapped = mapFactsToGraph(factsMap, pkgRoot, ctx.repoPath);
for (const node of mapped.nodes) ctx.graph.addNode(node);
for (const rel of mapped.edges) ctx.graph.addRelationship(rel);
acc.mergePackage(mapped, pkgRoot);
// Facts arrived, but move-flow serves structurally complete facts even
// for builds with compiler errors — and such builds silently lose the
@ -356,13 +336,9 @@ export function createMoveIngestPhase(
}
// Pass 2+: link edges that need the full cross-package node index.
linkCallEdges(ctx.graph, state);
linkLambdaHostEdges(ctx.graph, state);
linkResourceAndFriendEdges(ctx.graph, state);
linkFileImports(ctx.graph, state);
linkFileModuleContains(ctx.graph, state);
linkMoveIngestGraph(ctx.graph, acc);
const output = toOutput(state, packageRoots);
const output = acc.toOutput(packageRoots);
createMoveEntryPointEdges(ctx.graph, output);
const consistencyIssues: MoveConsistencyIssue[] = [
@ -380,6 +356,32 @@ export function createMoveIngestPhase(
};
}
/**
* Map a package-query failure to its user-facing form. Build/input errors and
* timeouts are operator problems rendered as one-liners; transport faults
* have already used up the client's single retry, so anything else propagates
* as the crash it is.
*/
function toPackageQueryError(err: unknown, pkgRoot: string): Error {
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.
return Object.assign(
new Error(`move-flow could not build Move package ${pkgRoot}: ${err.message}`),
{ userActionable: true },
);
}
if (err instanceof MoveFlowTimeoutError) {
// Fresh copy: the client owns this instance (it is reused across the
// transport retry), so stamping the original would mutate state we do
// not own. The message already names the fix (raise
// GITNEXUS_MOVE_FLOW_TIMEOUT_MS).
return Object.assign(new Error(err.message), { userActionable: true });
}
return err instanceof Error ? err : new Error(String(err));
}
// --- Empty-facts discrimination ---
/** Cross-check the build status of a sources-but-empty-facts package.
@ -398,133 +400,6 @@ async function probePackageStatus(
}
}
/** CALLS edges from each package's call graph (resolved across all packages). */
function linkCallEdges(graph: KnowledgeGraph, state: MoveIngestState): void {
for (const callGraph of state.callGraphByPackage.values()) {
for (const [callerQualified, callees] of Object.entries(callGraph)) {
const callerId = state.functionNodeMap.get(callerQualified);
if (!callerId) continue;
for (const calleeQualified of callees) {
const calleeId = state.functionNodeMap.get(calleeQualified);
if (!calleeId) continue;
graph.addRelationship({
id: moveRelId(callerId, 'CALLS', calleeId, MOVE_EDGE_REASON.calls),
sourceId: callerId,
targetId: calleeId,
type: 'CALLS',
confidence: 1.0,
reason: MOVE_EDGE_REASON.calls,
});
}
}
}
}
/**
* CALLS edges from each `__lambda__N__host` function back to its host. move-flow
* synthesises lambdas as standalone functions but does NOT include the host-to-
* lambda link in `call_graph` without it, upstream traversal from the lambda
* dead-ends and processes that route through a callback (e.g. market_callbacks
* settle_trade) lose the bridge.
*/
function linkLambdaHostEdges(graph: KnowledgeGraph, state: MoveIngestState): void {
resolveLambdaHostEdges(state.pendingLambdaHosts, state.functionNodeMap, (rel) =>
graph.addRelationship(rel),
);
}
/** Resource/friend edges from facts, resolved after all package nodes exist. */
function linkResourceAndFriendEdges(graph: KnowledgeGraph, state: MoveIngestState): void {
const structIdsByLocalName = buildLocalNameIndex(state.structNodeMap);
resolveResourceEdges(
state.pendingResource,
state.structNodeMap,
structIdsByLocalName,
(rel) => graph.addRelationship(rel),
(pending) =>
state.droppedResourceRefs.push({ fnNodeId: pending.fnNodeId, target: pending.target }),
(pending) =>
state.droppedResourceRefs.push({ fnNodeId: pending.fnNodeId, target: pending.target }),
);
resolveFriendEdges(state.pendingFriends, state.moduleFileMap, (rel) =>
graph.addRelationship(rel),
);
resolveTypeRefEdges(state.pendingTypeRef, state.structNodeMap, structIdsByLocalName, (rel) => {
graph.addRelationship(rel);
addUsedType(graph, rel.sourceId, rel.targetId);
});
}
function addUsedType(graph: KnowledgeGraph, functionNodeId: string, typeNodeId: string): void {
const fnNode = graph.getNode(functionNodeId);
const typeNode = graph.getNode(typeNodeId);
const qualifiedName = typeNode?.properties.qualifiedName;
// Only Function nodes carry `usedTypes` - resource-group membership USES_TYPE
// edges have a Struct source and must not grow the property there.
if (!fnNode || fnNode.label !== 'Function' || typeof qualifiedName !== 'string') return;
const current = Array.isArray(fnNode.properties.usedTypes) ? fnNode.properties.usedTypes : [];
if (current.includes(qualifiedName)) return;
fnNode.properties.usedTypes = [...current, qualifiedName];
}
/** File→File IMPORTS derived from cross-module CALLS (deduped against existing). */
function linkFileImports(graph: KnowledgeGraph, state: MoveIngestState): void {
const seen = new Set<string>();
for (const r of graph.iterRelationshipsByType('IMPORTS')) {
if (!r.sourceId.startsWith('File:') || !r.targetId.startsWith('File:')) continue;
seen.add(`${r.sourceId.slice(5)}\0${r.targetId.slice(5)}`);
}
for (const callGraph of state.callGraphByPackage.values()) {
for (const [callerQualified, callees] of Object.entries(callGraph)) {
const callerFile = state.moduleFileMap.get(moveModuleQualifiedName(callerQualified));
if (!callerFile) continue;
for (const calleeQualified of callees) {
const calleeFile = state.moduleFileMap.get(moveModuleQualifiedName(calleeQualified));
if (!calleeFile || calleeFile === callerFile) continue;
const key = `${callerFile}\0${calleeFile}`;
if (seen.has(key)) continue;
seen.add(key);
const sourceFileId = `File:${callerFile}`;
const targetFileId = `File:${calleeFile}`;
if (graph.getNode(sourceFileId) && graph.getNode(targetFileId)) {
graph.addRelationship({
id: moveRelId(
sourceFileId,
'IMPORTS',
targetFileId,
MOVE_EDGE_REASON.crossModuleDependency,
),
sourceId: sourceFileId,
targetId: targetFileId,
type: 'IMPORTS',
confidence: 0.9,
reason: MOVE_EDGE_REASON.crossModuleDependency,
});
}
}
}
}
}
/** File→Module CONTAINS where the File node exists (from the structure phase). */
function linkFileModuleContains(graph: KnowledgeGraph, state: MoveIngestState): void {
for (const [qn, file] of state.moduleFileMap) {
const fileNodeId = `File:${file}`;
const moduleNodeId = moveModuleNodeId(qn, file);
if (graph.getNode(fileNodeId) && graph.getNode(moduleNodeId)) {
graph.addRelationship({
id: moveRelId(fileNodeId, 'CONTAINS', moduleNodeId, MOVE_EDGE_REASON.moduleInFile),
sourceId: fileNodeId,
targetId: moduleNodeId,
type: 'CONTAINS',
confidence: 1.0,
reason: MOVE_EDGE_REASON.moduleInFile,
});
}
}
}
/** Surface consistency errors so they reach a human/log (not just the output). */
function reportConsistencyIssues(ctx: PipelineContext, issues: MoveConsistencyIssue[]): void {
const errors = issues.filter((i) => i.severity === 'error');

View file

@ -0,0 +1,404 @@
import type { GraphRelationship } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../graph/types.js';
import type { CallGraphMap } from './compiler-facts.js';
import { MOVE_EDGE_REASON, MOVE_LANGUAGE } from './constants.js';
import type { DroppedRef, PendingRef, PendingRefKind } from './refs.js';
import {
moveExternalTypeNodeId,
moveFunctionNodeId,
moveLocalName,
moveModuleNodeId,
moveModuleQualifiedName,
moveRelId,
parseMoveModuleQualifiedName,
} from './symbol-id.js';
export interface MoveLinkView {
moduleFileMap: ReadonlyMap<string, string>;
functionNodeMap: ReadonlyMap<string, string>;
structNodeMap: ReadonlyMap<string, string>;
callGraphByPackage: ReadonlyMap<string, CallGraphMap>;
closureCallsByPackage: ReadonlyMap<string, CallGraphMap>;
pendingRefs: readonly PendingRef[];
droppedRefs: DroppedRef[];
}
export class ExternalMoveSymbols {
private readonly modules = new Map<string, string>();
private readonly functions = new Map<string, string>();
private readonly types = new Map<string, string>();
constructor(
private readonly graph: KnowledgeGraph,
private readonly localModules: ReadonlyMap<string, string>,
) {}
ensureModule(moduleQualified: string): string | undefined {
if (this.localModules.has(moduleQualified)) return undefined;
const existing = this.modules.get(moduleQualified);
if (existing) return existing;
const { address, moduleName } = parseMoveModuleQualifiedName(moduleQualified);
if (!address || !moduleName) return undefined;
const moduleId = moveModuleNodeId(moduleQualified, '');
this.graph.addNode({
id: moduleId,
label: 'Module',
properties: {
name: moduleName,
filePath: '',
language: MOVE_LANGUAGE,
qualifiedName: moduleQualified,
moduleQualifiedName: moduleQualified,
moduleAddress: address,
description: `External Move dependency module ${moduleQualified}`,
locationFidelity: 'external',
},
});
this.modules.set(moduleQualified, moduleId);
return moduleId;
}
ensureFunction(functionQualified: string): string | undefined {
const existing = this.functions.get(functionQualified);
if (existing) return existing;
const moduleQualified = moveModuleQualifiedName(functionQualified);
const moduleId = this.ensureModule(moduleQualified);
if (!moduleId) return undefined;
const functionId = moveFunctionNodeId(functionQualified, '');
this.graph.addNode({
id: functionId,
label: 'Function',
properties: {
name: moveLocalName(functionQualified),
filePath: '',
language: MOVE_LANGUAGE,
qualifiedName: functionQualified,
moduleQualifiedName: moduleQualified,
visibility: 'external',
visibilityModifier: 'external',
isExported: true,
description: `External Move dependency function ${functionQualified}`,
locationFidelity: 'external',
},
});
this.addDefinition(moduleId, functionId, MOVE_EDGE_REASON.externalDefinesFunction);
this.functions.set(functionQualified, functionId);
return functionId;
}
ensureType(typeQualified: string): string | undefined {
const existing = this.types.get(typeQualified);
if (existing) return existing;
const moduleQualified = moveModuleQualifiedName(typeQualified);
const moduleId = this.ensureModule(moduleQualified);
if (!moduleId) return undefined;
const typeId = moveExternalTypeNodeId(typeQualified);
this.graph.addNode({
id: typeId,
label: 'Type',
properties: {
name: moveLocalName(typeQualified),
filePath: '',
language: MOVE_LANGUAGE,
qualifiedName: typeQualified,
moduleQualifiedName: moduleQualified,
description: `External Move dependency type ${typeQualified}`,
locationFidelity: 'external',
},
});
this.addDefinition(moduleId, typeId, MOVE_EDGE_REASON.externalDefinesType);
this.types.set(typeQualified, typeId);
return typeId;
}
private addDefinition(moduleId: string, targetId: string, reason: string): void {
this.graph.addRelationship({
id: moveRelId(moduleId, 'DEFINES', targetId, reason),
sourceId: moduleId,
targetId,
type: 'DEFINES',
confidence: 1.0,
reason,
});
}
}
/** Strip generic type arguments: `CoinStore<CoinType>` → `CoinStore`. */
function stripTypeArgs(typeName: string): string {
const idx = typeName.indexOf('<');
return (idx === -1 ? typeName : typeName.slice(0, idx)).trim();
}
export function buildLocalNameIndex(
structNodeMap: ReadonlyMap<string, string>,
): Map<string, string[]> {
const structIdsByLocalName = new Map<string, string[]>();
for (const [qn, id] of structNodeMap) {
const key = moveLocalName(qn);
const list = structIdsByLocalName.get(key);
if (list) list.push(id);
else structIdsByLocalName.set(key, [id]);
}
return structIdsByLocalName;
}
export function resolveStructRef(
localOrQualified: string,
callerModule: string,
structNodeMap: ReadonlyMap<string, string>,
structIdsByLocalName: ReadonlyMap<string, readonly string[]>,
): { targetId: string } | { unresolved: true } | { ambiguous: true } {
const exact = structNodeMap.get(localOrQualified);
if (exact) return { targetId: exact };
const base = stripTypeArgs(localOrQualified);
const baseExact = structNodeMap.get(base);
if (baseExact) return { targetId: baseExact };
// move-flow emits every resourceAccess/param/return ref fully qualified, so a
// qualified ref that misses the exact lookup is a type outside the indexed
// graph (e.g. a dependency-only 0x1::coin::CoinStore). Falling through to the
// bare-name heuristic would silently mis-bind it to a same-named repo struct
// under a different address - report unresolved instead. The heuristics below
// remain only for unqualified inputs.
if (base.includes('::')) return { unresolved: true };
const sameModule = structNodeMap.get(`${callerModule}::${base}`);
if (sameModule) return { targetId: sameModule };
const matches = structIdsByLocalName.get(moveLocalName(base)) ?? [];
if (matches.length === 1) return { targetId: matches[0] };
return matches.length > 1 ? { ambiguous: true } : { unresolved: true };
}
export interface RefIndexes {
structNodeMap: ReadonlyMap<string, string>;
structIdsByLocalName: ReadonlyMap<string, readonly string[]>;
moduleFileMap: ReadonlyMap<string, string>;
functionNodeMap: ReadonlyMap<string, string>;
}
type Resolution = { id: string } | 'unresolved' | 'ambiguous';
interface RefDescriptor {
resolve(target: string, moduleQualified: string, idx: RefIndexes): Resolution;
externalize?(target: string, external: ExternalMoveSymbols): string | undefined;
direction: 'known-source' | 'known-target';
confidence?: number;
postEdge?(graph: KnowledgeGraph, rel: GraphRelationship): void;
}
const REF_DESCRIPTORS: Record<PendingRefKind, RefDescriptor> = {
resource: {
resolve: (t, m, idx) => {
const r = resolveStructRef(t, m, idx.structNodeMap, idx.structIdsByLocalName);
if ('targetId' in r) return { id: r.targetId };
if ('ambiguous' in r) return 'ambiguous';
return 'unresolved';
},
externalize: (t, ext) => ext.ensureType(t),
direction: 'known-source',
},
type: {
resolve: (t, m, idx) => {
const r = resolveStructRef(t, m, idx.structNodeMap, idx.structIdsByLocalName);
if ('targetId' in r) return { id: r.targetId };
if ('ambiguous' in r) return 'ambiguous';
return 'unresolved';
},
externalize: (t, ext) => ext.ensureType(t),
direction: 'known-source',
postEdge: recordUsedType,
},
friend: {
resolve: (t, _m, idx) => {
const file = idx.moduleFileMap.get(t);
return file ? { id: moveModuleNodeId(t, file) } : 'unresolved';
},
externalize: (t, ext) => ext.ensureModule(t),
direction: 'known-source',
},
'lambda-host': {
resolve: (t, _m, idx) => {
const id = idx.functionNodeMap.get(t);
return id ? { id } : 'unresolved';
},
direction: 'known-target',
confidence: 0.9,
},
};
/** `usedTypes` bookkeeping for USES_TYPE edges (was addTypeRelationship). */
function recordUsedType(graph: KnowledgeGraph, rel: GraphRelationship): void {
const source = graph.getNode(rel.sourceId);
const target = graph.getNode(rel.targetId);
const qualifiedName = target?.properties.qualifiedName;
if (!source || source.label !== 'Function' || typeof qualifiedName !== 'string') return;
const usedTypes = Array.isArray(source.properties.usedTypes) ? source.properties.usedTypes : [];
if (!usedTypes.includes(qualifiedName))
source.properties.usedTypes = [...usedTypes, qualifiedName];
}
export function resolveRefs(
graph: KnowledgeGraph,
refs: readonly PendingRef[],
indexes: RefIndexes,
external: ExternalMoveSymbols,
drops: DroppedRef[],
): void {
const seen = new Set<string>();
for (const ref of refs) {
const desc = REF_DESCRIPTORS[ref.kind];
const resolved = desc.resolve(ref.target, ref.moduleQualified, indexes);
let targetId: string | undefined;
if (resolved === 'ambiguous') {
drops.push({ kind: ref.kind, sourceId: ref.knownNodeId, target: ref.target });
continue;
}
if (resolved === 'unresolved') {
targetId = desc.externalize?.(ref.target, external);
if (!targetId) {
drops.push({ kind: ref.kind, sourceId: ref.knownNodeId, target: ref.target });
continue;
}
} else {
targetId = resolved.id;
}
const sourceId = desc.direction === 'known-target' ? targetId : ref.knownNodeId;
const dstId = desc.direction === 'known-target' ? ref.knownNodeId : targetId;
const key = `${sourceId}\0${ref.edgeType}\0${dstId}\0${ref.reason}`;
if (seen.has(key)) continue;
seen.add(key);
const rel: GraphRelationship = {
id: moveRelId(sourceId, ref.edgeType, dstId, ref.reason),
sourceId,
targetId: dstId,
type: ref.edgeType,
confidence: desc.confidence ?? 1.0,
reason: ref.reason,
};
graph.addRelationship(rel);
desc.postEdge?.(graph, rel);
}
}
export function linkMoveIngestGraph(graph: KnowledgeGraph, state: MoveLinkView): void {
const external = new ExternalMoveSymbols(graph, state.moduleFileMap);
linkCallGraph(
graph,
state,
external,
state.callGraphByPackage.values(),
MOVE_EDGE_REASON.calls,
1.0,
);
linkCallGraph(
graph,
state,
external,
state.closureCallsByPackage.values(),
MOVE_EDGE_REASON.closureUse,
0.95,
);
const structIdsByLocalName = buildLocalNameIndex(state.structNodeMap);
const indexes: RefIndexes = {
structNodeMap: state.structNodeMap,
structIdsByLocalName,
moduleFileMap: state.moduleFileMap,
functionNodeMap: state.functionNodeMap,
};
resolveRefs(graph, state.pendingRefs, indexes, external, state.droppedRefs);
linkFileImports(graph, state);
linkFileModuleContains(graph, state);
}
function linkCallGraph(
graph: KnowledgeGraph,
state: MoveLinkView,
external: ExternalMoveSymbols,
callGraphs: Iterable<CallGraphMap>,
reason: string,
confidence: number,
): void {
for (const callGraph of callGraphs) {
for (const [callerQualified, callees] of Object.entries(callGraph)) {
const callerId = state.functionNodeMap.get(callerQualified);
if (!callerId) continue;
for (const calleeQualified of callees) {
const calleeId =
state.functionNodeMap.get(calleeQualified) ?? external.ensureFunction(calleeQualified);
if (!calleeId) continue;
graph.addRelationship({
id: moveRelId(callerId, 'CALLS', calleeId, reason),
sourceId: callerId,
targetId: calleeId,
type: 'CALLS',
confidence,
reason,
});
}
}
}
}
function linkFileImports(graph: KnowledgeGraph, state: MoveLinkView): void {
const seen = new Set<string>();
for (const relationship of graph.iterRelationshipsByType('IMPORTS')) {
if (!relationship.sourceId.startsWith('File:') || !relationship.targetId.startsWith('File:')) {
continue;
}
seen.add(`${relationship.sourceId.slice(5)}\0${relationship.targetId.slice(5)}`);
}
for (const callGraph of [
...state.callGraphByPackage.values(),
...state.closureCallsByPackage.values(),
]) {
for (const [callerQualified, callees] of Object.entries(callGraph)) {
const callerFile = state.moduleFileMap.get(moveModuleQualifiedName(callerQualified));
if (!callerFile) continue;
for (const calleeQualified of callees) {
const calleeFile = state.moduleFileMap.get(moveModuleQualifiedName(calleeQualified));
if (!calleeFile || calleeFile === callerFile) continue;
const key = `${callerFile}\0${calleeFile}`;
if (seen.has(key)) continue;
seen.add(key);
const sourceFileId = `File:${callerFile}`;
const targetFileId = `File:${calleeFile}`;
if (!graph.getNode(sourceFileId) || !graph.getNode(targetFileId)) continue;
graph.addRelationship({
id: moveRelId(
sourceFileId,
'IMPORTS',
targetFileId,
MOVE_EDGE_REASON.crossModuleDependency,
),
sourceId: sourceFileId,
targetId: targetFileId,
type: 'IMPORTS',
confidence: 0.9,
reason: MOVE_EDGE_REASON.crossModuleDependency,
});
}
}
}
}
function linkFileModuleContains(graph: KnowledgeGraph, state: MoveLinkView): void {
for (const [moduleQualified, file] of state.moduleFileMap) {
const fileNodeId = `File:${file}`;
const moduleNodeId = moveModuleNodeId(moduleQualified, file);
if (!graph.getNode(fileNodeId) || !graph.getNode(moduleNodeId)) continue;
graph.addRelationship({
id: moveRelId(fileNodeId, 'CONTAINS', moduleNodeId, MOVE_EDGE_REASON.moduleInFile),
sourceId: fileNodeId,
targetId: moduleNodeId,
type: 'CONTAINS',
confidence: 1.0,
reason: MOVE_EDGE_REASON.moduleInFile,
});
}
}

View file

@ -1,4 +1,8 @@
import { createHash } from 'node:crypto';
import { createHash, randomBytes } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { access, realpath } from 'node:fs/promises';
import { constants } from 'node:fs';
import * as path from 'node:path';
import type { MoveCompilerIdentity } from './constants.js';
import {
createMoveFlowClient,
@ -43,15 +47,70 @@ const defaultDependencies: MoveFlowProvisionDependencies = {
install: installMoveFlow,
};
const localIdentity = (
async function resolveExecutablePath(locator: string): Promise<string | null> {
const candidates =
path.isAbsolute(locator) || locator.includes(path.sep)
? [locator]
: (process.env.PATH ?? '')
.split(path.delimiter)
.filter(Boolean)
.flatMap((directory) => {
const exact = path.join(directory, locator);
if (process.platform !== 'win32' || path.extname(locator)) return [exact];
const extensions = (process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';');
return [
exact,
...extensions.map((extension) => path.join(directory, `${locator}${extension}`)),
];
});
for (const candidate of candidates) {
try {
await access(candidate, constants.R_OK | (process.platform === 'win32' ? 0 : constants.X_OK));
return await realpath(candidate);
} catch {
// Continue through PATH. A resolver mock may intentionally have no file.
}
}
return null;
}
async function hashExecutable(absolutePath: string): Promise<string> {
const digest = createHash('sha256');
for await (const chunk of createReadStream(absolutePath)) digest.update(chunk as Buffer);
return digest.digest('hex');
}
const localIdentity = async (
source: 'explicit' | 'path',
locator: string,
version: string,
): MoveCompilerIdentity => ({
version,
source,
fingerprint: createHash('sha256').update(`${source}\0${locator}\0${version}`).digest('hex'),
});
onLog?: (message: string) => void,
): Promise<MoveCompilerIdentity> => {
const resolvedPath = await resolveExecutablePath(locator);
// If the binary changes between the successful version probe and hashing,
// make this identity unique rather than certifying an unverifiable runtime.
const binaryDigest = resolvedPath
? await hashExecutable(resolvedPath).catch(
() => `unverifiable:${randomBytes(16).toString('hex')}`,
)
: `unverifiable:${randomBytes(16).toString('hex')}`;
if (binaryDigest.startsWith('unverifiable:')) {
// The random digest deliberately churns the fingerprint, and a fingerprint
// change forces a full Move re-index — every run, until the binary is
// readable again. Say so instead of rebuilding silently.
onLog?.(
`move-flow binary at '${resolvedPath ?? locator}' could not be read for ` +
`fingerprinting; Move facts will be fully rebuilt on every run until it is readable.`,
);
}
return {
version,
source,
fingerprint: createHash('sha256')
.update(`${source}\0${resolvedPath ?? locator}\0${version}\0${binaryDigest}`)
.digest('hex'),
};
};
const verifiedRuntime = (
binary: VerifiedMoveFlowBinary,
@ -98,7 +157,7 @@ export async function ensureMoveFlowRuntime(
if (resolved) {
return {
client: resolved.client,
identity: localIdentity('explicit', explicitPath, resolved.version),
identity: await localIdentity('explicit', explicitPath, resolved.version, options.onLog),
};
}
options.onLog?.(
@ -119,7 +178,7 @@ export async function ensureMoveFlowRuntime(
// terminals/CI steps, and a fingerprint churn forces a full re-index.
return {
client: existing.client,
identity: localIdentity('path', 'move-flow', existing.version),
identity: await localIdentity('path', 'move-flow', existing.version, options.onLog),
};
}

View file

@ -0,0 +1,28 @@
// gitnexus/src/core/move/refs.ts
import type { RelationshipType } from 'gitnexus-shared';
/** A deferred cross-package reference resolved after every package is mapped. */
export type PendingRefKind = 'resource' | 'type' | 'friend' | 'lambda-host';
/**
* A reference emitted in Pass-A whose target node is only known once the full
* cross-package index exists. `knownNodeId` is the node we already have; for
* every kind except `lambda-host` it is the edge SOURCE and `target` resolves to
* the edge target. For `lambda-host` the resolved host is the source and
* `knownNodeId` (the lambda function) is the target.
*/
export interface PendingRef {
kind: PendingRefKind;
knownNodeId: string;
target: string;
moduleQualified: string;
edgeType: RelationshipType;
reason: string;
}
/** A `PendingRef` that could not be resolved or externalized. */
export interface DroppedRef {
kind: PendingRefKind;
sourceId: string;
target: string;
}

View file

@ -67,6 +67,11 @@ export function moveConstNodeId(constQualifiedName: string, filePath: string): s
return `Const:${filePath}:${constQualifiedName}`;
}
/** Synthetic dependency type whose declaration is outside the indexed repo. */
export function moveExternalTypeNodeId(typeQualifiedName: string): string {
return `Type::<external>:${typeQualifiedName}`;
}
export function moveEnumVariantNodeId(
enumQualifiedName: string,
variantName: string,

View file

@ -17,6 +17,12 @@ const MOVE_PRIMITIVES = new Set([
'u64',
'u128',
'u256',
'i8',
'i16',
'i32',
'i64',
'i128',
'i256',
'address',
'signer',
'&signer',
@ -25,11 +31,23 @@ const MOVE_PRIMITIVES = new Set([
export function extractTypeNames(typeExpr: string): string[] {
if (!typeExpr) return [];
const expr = typeExpr.trim().replace(/^&(mut\s+)?/, '');
// Move 2 function types may carry an ability constraint suffix:
// `|u64|bool has copy + drop`. The function type can itself be nested in a
// generic, so the suffix may end immediately before `>` rather than at the
// end of the complete expression. Abilities are not nominal types.
const expr = typeExpr
.trim()
.replace(
/\s+has\s+(?:copy|drop|store|key)(?:\s*\+\s*(?:copy|drop|store|key))*(?=\s*(?:[>,)|]|$))/gi,
'',
)
.replace(/^&(mut\s+)?/, '');
if (MOVE_PRIMITIVES.has(expr)) return [];
const tokens: string[] = [];
for (const raw of expr.split(/[<>,]/)) {
// Delimiters cover generics (`<`, `>`, `,`), tuples (`(`, `)`), and Move 2
// function types (`|u64|bool`) - without `()|` those leak into type names.
for (const raw of expr.split(/[<>,()|]/)) {
const name = raw.trim().replace(/^&(mut\s+)?/, '');
if (!name || MOVE_PRIMITIVES.has(name)) continue;
tokens.push(name);

View file

@ -19,6 +19,7 @@ import {
persistedMoveGraphRequiresCompiler,
shouldProvisionMoveFlowBeforeFastPath,
} from './move/constants.js';
import { summarizeMoveConsistency } from './move/consistency.js';
import { createMoveIngestPhase } from './move/move-ingest.js';
import { repoHasMove } from './move/discovery.js';
import { getMoveFlowReleaseSelection } from './move/install.js';
@ -40,6 +41,7 @@ import {
deleteAllInterprocTaintPaths,
deleteAllCallSummaries,
deleteAllInjects,
deleteAllExternalNodes,
queryImportersBatch,
loadFTSExtension,
wipeLbugDbFiles,
@ -1330,44 +1332,52 @@ export async function runFullAnalysis(
// ── Phase 1: Full Pipeline (060%) ────────────────────────────────
// `finally` guarantees the spawned move-flow child is released even if the
// pipeline throws — important for long-running hosts (MCP daemon, eval-server).
let pipelineResult: Awaited<ReturnType<typeof runPipelineFromRepo>>;
try {
pipelineResult = await runPipelineFromRepo(
repoPath,
(p) => {
const phaseLabel = PHASE_LABELS[p.phase] || p.phase;
const scaled = Math.round(p.percent * 0.6);
const message = p.detail
? `${p.message || phaseLabel} (${p.detail})`
: p.message || phaseLabel;
progress(p.phase, scaled, message);
},
{
parseCache,
workerPoolSize: options.workerPoolSize,
standaloneIngestPhase: createMoveIngestPhase(moveFlowClient),
// CFG/PDG opt-in (#2081 M1). PipelineOptions.pdg fans out to the worker
// build gate (workerData.pdg) and the scope-resolution emit gate.
pdg: options.pdg === true,
pdgMaxFunctionLines: options.pdgMaxFunctionLines,
pdgMaxEdgesPerFunction: options.pdgMaxEdgesPerFunction,
pdgMaxReachingDefEdgesPerFunction: options.pdgMaxReachingDefEdgesPerFunction,
pdgMaxCdgEdgesPerFunction: options.pdgMaxCdgEdgesPerFunction,
pdgMaxTaintFindingsPerFunction: options.pdgMaxTaintFindingsPerFunction,
pdgMaxTaintHops: options.pdgMaxTaintHops,
pdgMaxInterprocFindings: options.pdgMaxInterprocFindings,
pdgMaxInterprocHops: options.pdgMaxInterprocHops,
pdgMaxInterprocEdges: options.pdgMaxInterprocEdges,
// Streaming/chunked PDG emit (#2202) — gated to full-rebuild runs
// (force === true) so the incremental writeback never reads back an
// offloaded BasicBlock layer. Memory-only; byte-identical output.
streamPdgEmit: resolveStreamPdgEmit(options),
pdgEmitChunkSize: resolvePdgEmitChunkSize(options),
fetchWrappers: options.fetchWrappers,
},
const pipelineResult = await runPipelineFromRepo(
repoPath,
(p) => {
const phaseLabel = PHASE_LABELS[p.phase] || p.phase;
const scaled = Math.round(p.percent * 0.6);
const message = p.detail
? `${p.message || phaseLabel} (${p.detail})`
: p.message || phaseLabel;
progress(p.phase, scaled, message);
},
{
parseCache,
workerPoolSize: options.workerPoolSize,
standaloneIngestPhase: createMoveIngestPhase(moveFlowClient),
// CFG/PDG opt-in (#2081 M1). PipelineOptions.pdg fans out to the worker
// build gate (workerData.pdg) and the scope-resolution emit gate.
pdg: options.pdg === true,
pdgMaxFunctionLines: options.pdgMaxFunctionLines,
pdgMaxEdgesPerFunction: options.pdgMaxEdgesPerFunction,
pdgMaxReachingDefEdgesPerFunction: options.pdgMaxReachingDefEdgesPerFunction,
pdgMaxCdgEdgesPerFunction: options.pdgMaxCdgEdgesPerFunction,
pdgMaxTaintFindingsPerFunction: options.pdgMaxTaintFindingsPerFunction,
pdgMaxTaintHops: options.pdgMaxTaintHops,
pdgMaxInterprocFindings: options.pdgMaxInterprocFindings,
pdgMaxInterprocHops: options.pdgMaxInterprocHops,
pdgMaxInterprocEdges: options.pdgMaxInterprocEdges,
// Streaming/chunked PDG emit (#2202) — gated to full-rebuild runs
// (force === true) so the incremental writeback never reads back an
// offloaded BasicBlock layer. Memory-only; byte-identical output.
streamPdgEmit: resolveStreamPdgEmit(options),
pdgEmitChunkSize: resolvePdgEmitChunkSize(options),
fetchWrappers: options.fetchWrappers,
},
).finally(() => moveFlowClient?.shutdown());
// Move ingest consistency digest — persisted to meta.json below so warnings
// (dropped refs, test-only packages) survive the run instead of dying with
// the transient progress stream.
const moveConsistency = summarizeMoveConsistency(
pipelineResult.standaloneIngest.consistencyIssues,
);
if (moveConsistency) {
log(
`Move ingest consistency: ${moveConsistency.errorCount} error(s), ` +
`${moveConsistency.warningCount} warning(s) — details in meta.json (moveConsistency).`,
);
} finally {
await moveFlowClient?.shutdown();
}
// ── Phase 2: LadybugDB (6085%) ──────────────────────────────────
@ -1927,6 +1937,15 @@ export async function runFullAnalysis(
// deleting on every non-pdg incremental run (N runs = N copies of
// every INJECTS row; CodeRelation has no PK and no read-side dedup).
await deleteAllInjects();
// 2a'. Drop externally-declared dependency nodes (locationFidelity
// 'external', filePath '') — the file-keyed delete/write cycle can
// never refresh them, so an external symbol first referenced by
// this run would otherwise be extracted edge-first and silently
// dropped at the rel-COPY fallback. The standalone ingest phase
// regenerates the full external surface every run and
// extractChangedSubgraph re-includes it (isExternalNode), same
// delete-all-then-rebuild contract as Community/Process/INJECTS.
await deleteAllExternalNodes();
// 2b. Drop interprocedural TAINT_PATH edges (#2084 M4 U6) when pdg is on
// — their validity is a whole-program property (an A→C flow can be
// invalidated by a change to an intermediate function on a third
@ -2392,8 +2411,23 @@ export async function runFullAnalysis(
// force a full rebuild), so when the compiler is transiently unavailable
// the previous record still describes the persisted facts. Stamping
// false/undefined here would force a needless full rebuild the moment the
// compiler returns.
// compiler returns. All Move meta travels together: preservation is one
// structural decision, not per-field ternaries a future field can forget.
const preserveMoveMeta = hasMovePackages && !moveFlow && isIncremental;
const moveMeta = preserveMoveMeta
? {
moveIngestAvailable: existingMeta?.moveIngestAvailable,
moveCompilerIdentity: existingMeta?.moveCompilerIdentity,
moveFlowReleaseSelection: existingMeta?.moveFlowReleaseSelection,
moveConsistency: existingMeta?.moveConsistency,
}
: {
moveIngestAvailable,
moveCompilerIdentity: moveFlow?.identity,
moveFlowReleaseSelection:
moveFlow?.identity.source === 'release' ? desiredMoveFlowRelease : undefined,
moveConsistency,
};
// Annotated so the capabilities stamp below is compile-checked against
// RepoMeta's status unions (tri-review 4669518496 P1/U3) — an unannotated
@ -2455,17 +2489,7 @@ export async function runFullAnalysis(
// absence, so this is never conditionally omitted.
cjkSegmentation: getSearchFTSCjkSegmentation(),
fileHashes: hasGitDir(repoPath) ? newFileHashesRecord : undefined,
moveIngestAvailable: preserveMoveMeta
? existingMeta?.moveIngestAvailable
: moveIngestAvailable,
moveCompilerIdentity: preserveMoveMeta
? existingMeta?.moveCompilerIdentity
: moveFlow?.identity,
moveFlowReleaseSelection: preserveMoveMeta
? existingMeta?.moveFlowReleaseSelection
: moveFlow?.identity.source === 'release'
? desiredMoveFlowRelease
: undefined,
...moveMeta,
// This branch's full live chunk-key set (#2106 R6). `usedKeys` is every
// chunk hash touched in this scan — cache HITS included (see parse-impl
// usedKeys.add) — so it's complete even on an incremental run. Persisted
@ -2691,7 +2715,7 @@ export async function runFullAnalysis(
stats: meta.stats,
pipelineResult,
ftsSkipped: !ftsReady,
ingestWarnings: pipelineResult.ingestWarnings,
ingestWarnings: pipelineResult.standaloneIngest.ingestWarnings,
isPrimaryBranch: !placement.branch,
};
} catch (err) {

View file

@ -22,6 +22,7 @@ import { randomBytes } from 'crypto';
import { getInferredRepoName, resolveRepoIdentityRoot } from './git.js';
import { retryRename } from './fs-atomic.js';
import { logger } from '../core/logger.js';
import type { MoveConsistencySummary } from '../core/move/consistency.js';
import type { MoveCompilerIdentity, MoveFlowReleaseSelection } from '../core/move/constants.js';
import {
branchSlug,
@ -228,6 +229,12 @@ export interface RepoMeta {
moveCompilerIdentity?: MoveCompilerIdentity;
/** Managed release coordinates, kept separate from the binary fingerprint. */
moveFlowReleaseSelection?: MoveFlowReleaseSelection;
/**
* Move ingest consistency digest from the run that produced the persisted
* facts (full counts + a capped errors-first sample). Absent when the run
* was clean or the repo has no Move packages.
*/
moveConsistency?: MoveConsistencySummary;
/**
* Crash-recovery dirty flag a generic marker written to the metadata
* file (gitnexus.json + its meta.json mirror) BEFORE any destructive DB
@ -460,8 +467,12 @@ export interface RepoMeta {
* stamp of 9 is ambiguous between the two lineages, and a pre-merge Move
* index also predates main's v9v11 rebuild reasons. Any pre-v12 stamp fails
* the strict-equality reuse gate; force a full re-analyze (same contract as v3).
* v13: generic Type nodes and the Move EnumVariantProperty / field
* USES_TYPE endpoint pairs became persistable. Older indexes silently dropped
* those nodes and relationships during CSV routing, so a full rebuild is
* required to backfill them.
*/
export const INCREMENTAL_SCHEMA_VERSION = 12;
export const INCREMENTAL_SCHEMA_VERSION = 13;
export interface IndexedRepo {
repoPath: string;

View file

@ -1,11 +1,14 @@
import type { KnowledgeGraph } from '../core/graph/types.js';
import { CommunityDetectionResult } from '../core/ingestion/community-processor.js';
import { ProcessDetectionResult } from '../core/ingestion/process-processor.js';
import type { StandaloneIngestOutput } from '../core/ingestion/pipeline-phases/standalone-ingest.js';
import type { ResolutionOutcome } from '../core/ingestion/scope-resolution/resolution-outcome.js';
import type { PdgEmitManifest } from '../core/lbug/pdg-emit-sink.js';
// CLI-specific: in-memory result with graph + detection results
export interface PipelineResult {
export interface PipelineResult<
TStandaloneIngest extends StandaloneIngestOutput = StandaloneIngestOutput,
> {
graph: KnowledgeGraph;
/** Absolute path to the repo root — used for lazy file reads during LadybugDB loading */
repoPath: string;
@ -37,10 +40,9 @@ export interface PipelineResult {
*/
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).
* Output of the standalone-ingest phase (the caller-supplied compiler-backed
* ingester, e.g. Move). The pipeline stays language-agnostic; callers that
* register a richer phase may narrow this output to their phase contract.
*/
ingestWarnings?: readonly string[];
standaloneIngest: TStandaloneIngest;
}

View file

@ -4,16 +4,105 @@
* Shared by the unit (move-ingest-*) and integration (move-live) Move tests.
*/
import { createMoveIngestPhase, type MoveIngestOutput } from '../../src/core/move/move-ingest.js';
import type { MoveFactsFunction } from '../../src/core/move/compiler-facts.js';
import type { MoveFlowClient } from '../../src/core/move/mcp-client.js';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import type { KnowledgeGraph } from '../../src/core/graph/types.js';
import type { PhaseResult } from '../../src/core/ingestion/pipeline-phases/types.js';
export interface MoveFlowClientCallCounts {
facts: number;
callGraph: number;
functionUsage: number;
capabilities: number;
packageStatus: number;
}
export const moveFunctionFact = (
name: string,
overrides: Partial<MoveFactsFunction> = {},
): MoveFactsFunction => ({
name,
visibility: 'internal',
isEntry: false,
isInline: false,
isNative: false,
isView: false,
returnTypes: [],
...overrides,
});
/**
* Counting MoveFlowClient stub shared by the Move unit tests: benign defaults,
* overridable per member. The wrapper counts every call including calls that
* reach an override so tests can assert zero/at-most-once client interaction.
*/
export function makeMoveFlowClientStub(
overrides: Partial<MoveFlowClient> = {},
): MoveFlowClient & { counts: MoveFlowClientCallCounts } {
const counts: MoveFlowClientCallCounts = {
facts: 0,
callGraph: 0,
functionUsage: 0,
capabilities: 0,
packageStatus: 0,
};
const impl: MoveFlowClient = {
facts: async () => ({}),
callGraph: async () => ({}),
functionUsage: async () => ({
called: [],
used: [],
}),
packageStatus: async () => ({ ok: true, diagnostics: '' }),
capabilities: async () => ({
hasFactsQuery: true,
hasFunctionUsageQuery: true,
hasStatusTool: true,
}),
shutdown: async () => {},
...overrides,
};
return {
counts,
facts: (pkg) => {
counts.facts += 1;
return impl.facts(pkg);
},
callGraph: (pkg) => {
counts.callGraph += 1;
return impl.callGraph(pkg);
},
functionUsage: (pkg, fn) => {
counts.functionUsage += 1;
return impl.functionUsage(pkg, fn);
},
packageStatus: (pkg) => {
counts.packageStatus += 1;
return impl.packageStatus(pkg);
},
capabilities: () => {
counts.capabilities += 1;
return impl.capabilities();
},
shutdown: () => impl.shutdown(),
};
}
/** Run the moveIngest phase over `filePaths` (repo-relative) under `repoPath`. */
export async function runMoveIngestPhase(
client: MoveFlowClient | null,
repoPath: string,
filePaths: readonly string[],
): Promise<MoveIngestOutput> {
return (await runMoveIngestPhaseWithGraph(client, repoPath, filePaths)).output;
}
export async function runMoveIngestPhaseWithGraph(
client: MoveFlowClient | null,
repoPath: string,
filePaths: readonly string[],
): Promise<{ output: MoveIngestOutput; graph: KnowledgeGraph }> {
const deps = new Map<string, PhaseResult<unknown>>([
[
'structure',
@ -29,13 +118,15 @@ export async function runMoveIngestPhase(
},
],
]);
return createMoveIngestPhase(client).execute(
const graph = createKnowledgeGraph();
const output = await createMoveIngestPhase(client).execute(
{
repoPath,
graph: createKnowledgeGraph(),
graph,
onProgress: () => {},
pipelineStart: Date.now(),
},
deps,
);
return { output, graph };
}

View file

@ -170,6 +170,43 @@ withTestLbugDB(
expect(Number((queriesLeft[0] as { cnt: number }).cnt)).toBe(1);
});
it('deleteAllExternalNodes: removes only external dependency nodes (and their edges)', async () => {
// Same contract family as the delete-alls above — external nodes have
// no filePath, so the incremental writeback delete-alls them and
// re-COPYs the fresh external surface (extractChangedSubgraph).
const { executeQuery: coreExecuteQuery, deleteAllExternalNodes } =
await import('../../src/core/lbug/lbug-adapter.js');
// Benign: nothing external yet → 0, does NOT throw.
await expect(deleteAllExternalNodes()).resolves.toEqual({ nodesDeleted: 0 });
// Seed one external Function plus an edge into it from a local one.
await coreExecuteQuery(
`CREATE (:Function {id: 'Function::0x1::object::object_address', ` +
`name: 'object_address', filePath: '', locationFidelity: 'external'})`,
);
const fns = (await coreExecuteQuery(
`MATCH (n:Function) WHERE n.filePath <> '' RETURN n.id AS id`,
)) as { id: string }[];
expect(fns.length).toBe(2);
await coreExecuteQuery(
`MATCH (a:Function {id: '${fns[0].id}'}), ` +
`(b:Function {id: 'Function::0x1::object::object_address'}) ` +
`CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 1.0, reason: 'move', step: 0}]->(b)`,
);
const r = await deleteAllExternalNodes();
expect(r.nodesDeleted).toBe(1);
const externalLeft = await coreExecuteQuery(
`MATCH (n:Function) WHERE n.locationFidelity = 'external' RETURN count(n) AS cnt`,
);
expect(Number((externalLeft[0] as { cnt: number }).cnt)).toBe(0);
const localLeft = await coreExecuteQuery(
`MATCH (n:Function) WHERE n.filePath <> '' RETURN count(n) AS cnt`,
);
expect(Number((localLeft[0] as { cnt: number }).cnt)).toBe(2);
});
describe('unhappy path', () => {
it('throws on malformed Cypher query', async () => {
const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');

View file

@ -54,6 +54,7 @@ describe.skipIf(!client)('live move-flow ingestion (coin fixture)', () => {
it('detects the facts query capability', async () => {
const caps = await client!.capabilities();
expect(caps.hasFactsQuery).toBe(true);
expect(caps.hasFunctionUsageQuery).toBe(true);
});
it('builds a full-fidelity Move graph from compiler facts', async () => {

View file

@ -14,6 +14,12 @@ const sharedTables = [
},
{ table: 'Struct', otherLanguage: 'rust', detailProperty: 'isResource', moveDetail: true },
{ table: 'Enum', otherLanguage: 'rust', detailProperty: 'isEvent', moveDetail: true },
{
table: 'Type',
otherLanguage: 'typescript',
detailProperty: 'locationFidelity',
moveDetail: 'external',
},
{
table: 'EnumVariant',
otherLanguage: 'rust',
@ -53,6 +59,28 @@ withTestLbugDB(
]);
}
});
it('stores Move enum fields and local/external type-reference relationships', async () => {
const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');
const rows = await executeQuery(
"MATCH (a)-[r:CodeRelation]->(b) WHERE r.reason STARTS WITH 'move-roundtrip-' " +
'RETURN a.id AS source, r.type AS type, b.id AS target ORDER BY r.reason',
);
expect(rows).toEqual([
{
source: 'EnumVariant:move',
type: 'HAS_PROPERTY',
target: 'Property:move-field',
},
{ source: 'Function:move', type: 'USES_TYPE', target: 'Type:move' },
{ source: 'Module:move', type: 'DEFINES', target: 'Type:move' },
{ source: 'Property:move-field', type: 'USES_TYPE', target: 'Enum:move' },
{ source: 'Property:move-field', type: 'USES_TYPE', target: 'Struct:move' },
{ source: 'Property:move-field', type: 'USES_TYPE', target: 'Type:move' },
{ source: 'Struct:move', type: 'USES_TYPE', target: 'Type:move' },
]);
});
});
},
{
@ -62,32 +90,88 @@ withTestLbugDB(
await fs.mkdir(repoPath, { recursive: true });
const graph = buildTestGraph(
sharedTables.flatMap((testCase) => [
[
...sharedTables.flatMap((testCase) => [
{
id: `${testCase.table}:move`,
label: testCase.table as NodeLabel,
name: testCase.table,
filePath: testCase.table === 'Type' ? '' : 'sources/sample.move',
startLine: 1,
endLine: 2,
isExported: testCase.table === 'Function',
extra: {
language: 'move',
qualifiedName: `0x1::sample::${testCase.table}`,
moduleQualifiedName: '0x1::sample',
[testCase.detailProperty]: testCase.moveDetail,
},
},
{
id: `${testCase.table}:${testCase.otherLanguage}`,
label: testCase.table as NodeLabel,
name: testCase.table,
filePath: `src/sample.${testCase.otherLanguage}`,
startLine: 1,
endLine: 2,
isExported: testCase.table === 'Function',
extra: { language: testCase.otherLanguage },
},
]),
{
id: `${testCase.table}:move`,
label: testCase.table as NodeLabel,
name: testCase.table,
id: 'Property:move-field',
label: 'Property',
name: 'field',
filePath: 'sources/sample.move',
startLine: 1,
endLine: 2,
isExported: testCase.table === 'Function',
extra: {
language: 'move',
qualifiedName: `0x1::sample::${testCase.table}`,
[testCase.detailProperty]: testCase.moveDetail,
},
extra: { declaredType: '0x1::sample::Type' },
},
],
[
{
sourceId: 'EnumVariant:move',
targetId: 'Property:move-field',
type: 'HAS_PROPERTY',
reason: 'move-roundtrip-1',
},
{
id: `${testCase.table}:${testCase.otherLanguage}`,
label: testCase.table as NodeLabel,
name: testCase.table,
filePath: `src/sample.${testCase.otherLanguage}`,
startLine: 1,
endLine: 2,
isExported: testCase.table === 'Function',
extra: { language: testCase.otherLanguage },
sourceId: 'Function:move',
targetId: 'Type:move',
type: 'USES_TYPE',
reason: 'move-roundtrip-2',
},
]),
{
sourceId: 'Module:move',
targetId: 'Type:move',
type: 'DEFINES',
reason: 'move-roundtrip-3',
},
{
sourceId: 'Property:move-field',
targetId: 'Enum:move',
type: 'USES_TYPE',
reason: 'move-roundtrip-4',
},
{
sourceId: 'Property:move-field',
targetId: 'Struct:move',
type: 'USES_TYPE',
reason: 'move-roundtrip-5',
},
{
sourceId: 'Property:move-field',
targetId: 'Type:move',
type: 'USES_TYPE',
reason: 'move-roundtrip-6',
},
{
sourceId: 'Struct:move',
targetId: 'Type:move',
type: 'USES_TYPE',
reason: 'move-roundtrip-7',
},
],
);
const { loadGraphToLbug } = await import('../../src/core/lbug/lbug-adapter.js');

View file

@ -0,0 +1,64 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Move graph-equivalence regression guard (coin fixture) > matches the committed graph snapshot (nodes + edges) 1`] = `
{
"edges": [
"File:sources/coin.move CONTAINS Module:sources/coin.move:0xa::coin move-module-in-file",
"File:sources/coin.move CONTAINS Module:sources/coin.move:0xa::coin_admin move-module-in-file",
"Folder:sources CONTAINS File:sources/coin.move ",
"Function:sources/coin.move:0xa::coin::balance_of ACQUIRES Struct:sources/coin.move:0xa::coin::CoinStore move-acquires",
"Function:sources/coin.move:0xa::coin::balance_of ENTRY_POINT_OF Module:sources/coin.move:0xa::coin move-view-function",
"Function:sources/coin.move:0xa::coin::balance_of READS_RESOURCE Struct:sources/coin.move:0xa::coin::CoinStore move-reads_resource",
"Function:sources/coin.move:0xa::coin::burn_internal ACQUIRES Struct:sources/coin.move:0xa::coin::CoinStore move-acquires",
"Function:sources/coin.move:0xa::coin::burn_internal READS_RESOURCE Struct:sources/coin.move:0xa::coin::CoinStore move-reads_resource",
"Function:sources/coin.move:0xa::coin::burn_internal USES_TYPE Struct:sources/coin.move:0xa::coin::CoinStore move-fn-return-type",
"Function:sources/coin.move:0xa::coin::burn_internal WRITES_RESOURCE Struct:sources/coin.move:0xa::coin::CoinStore move-writes_resource",
"Function:sources/coin.move:0xa::coin::mint_internal ACQUIRES Struct:sources/coin.move:0xa::coin::CoinStore move-acquires",
"Function:sources/coin.move:0xa::coin::mint_internal READS_RESOURCE Struct:sources/coin.move:0xa::coin::CoinStore move-reads_resource",
"Function:sources/coin.move:0xa::coin::mint_internal WRITES_RESOURCE Struct:sources/coin.move:0xa::coin::CoinStore move-writes_resource",
"Function:sources/coin.move:0xa::coin::register ENTRY_POINT_OF Module:sources/coin.move:0xa::coin move-entry-function",
"Function:sources/coin.move:0xa::coin::register WRITES_RESOURCE Struct:sources/coin.move:0xa::coin::CoinStore move-writes_resource",
"Function:sources/coin.move:0xa::coin::transfer ACQUIRES Struct:sources/coin.move:0xa::coin::CoinStore move-acquires",
"Function:sources/coin.move:0xa::coin::transfer ENTRY_POINT_OF Module:sources/coin.move:0xa::coin move-entry-function",
"Function:sources/coin.move:0xa::coin::transfer READS_RESOURCE Struct:sources/coin.move:0xa::coin::CoinStore move-reads_resource",
"Function:sources/coin.move:0xa::coin::transfer WRITES_RESOURCE Struct:sources/coin.move:0xa::coin::CoinStore move-writes_resource",
"Function:sources/coin.move:0xa::coin_admin::mint CALLS Function:sources/coin.move:0xa::coin::mint_internal move-compiler-call-graph",
"Module:sources/coin.move:0xa::coin DEFINES Const:sources/coin.move:0xa::coin::E_INSUFFICIENT_BALANCE move-module-defines-const",
"Module:sources/coin.move:0xa::coin DEFINES Const:sources/coin.move:0xa::coin::E_NOT_REGISTERED move-module-defines-const",
"Module:sources/coin.move:0xa::coin DEFINES Function:sources/coin.move:0xa::coin::balance_of move-module-defines-function",
"Module:sources/coin.move:0xa::coin DEFINES Function:sources/coin.move:0xa::coin::burn_internal move-module-defines-function",
"Module:sources/coin.move:0xa::coin DEFINES Function:sources/coin.move:0xa::coin::mint_internal move-module-defines-function",
"Module:sources/coin.move:0xa::coin DEFINES Function:sources/coin.move:0xa::coin::register move-module-defines-function",
"Module:sources/coin.move:0xa::coin DEFINES Function:sources/coin.move:0xa::coin::transfer move-module-defines-function",
"Module:sources/coin.move:0xa::coin DEFINES Struct:sources/coin.move:0xa::coin::CoinStore move-module-defines-struct",
"Module:sources/coin.move:0xa::coin DEFINES Struct:sources/coin.move:0xa::coin::TransferEvent move-module-defines-struct",
"Module:sources/coin.move:0xa::coin FRIEND_OF Module:sources/coin.move:0xa::coin_admin move-friend-or-package",
"Module:sources/coin.move:0xa::coin_admin DEFINES Function:sources/coin.move:0xa::coin_admin::mint move-module-defines-function",
"Struct:sources/coin.move:0xa::coin::CoinStore HAS_PROPERTY Property:sources/coin.move:0xa::coin::CoinStore.balance move-struct-has-field",
"Struct:sources/coin.move:0xa::coin::TransferEvent HAS_PROPERTY Property:sources/coin.move:0xa::coin::TransferEvent.amount move-struct-has-field",
"Struct:sources/coin.move:0xa::coin::TransferEvent HAS_PROPERTY Property:sources/coin.move:0xa::coin::TransferEvent.from move-struct-has-field",
"Struct:sources/coin.move:0xa::coin::TransferEvent HAS_PROPERTY Property:sources/coin.move:0xa::coin::TransferEvent.to move-struct-has-field",
],
"nodes": [
"Const:sources/coin.move:0xa::coin::E_INSUFFICIENT_BALANCE Const",
"Const:sources/coin.move:0xa::coin::E_NOT_REGISTERED Const",
"File:Move.toml File",
"File:sources/coin.move File",
"Folder:sources Folder",
"Function:sources/coin.move:0xa::coin::balance_of Function",
"Function:sources/coin.move:0xa::coin::burn_internal Function",
"Function:sources/coin.move:0xa::coin::mint_internal Function",
"Function:sources/coin.move:0xa::coin::register Function",
"Function:sources/coin.move:0xa::coin::transfer Function",
"Function:sources/coin.move:0xa::coin_admin::mint Function",
"Module:sources/coin.move:0xa::coin Module",
"Module:sources/coin.move:0xa::coin_admin Module",
"Property:sources/coin.move:0xa::coin::CoinStore.balance Property",
"Property:sources/coin.move:0xa::coin::TransferEvent.amount Property",
"Property:sources/coin.move:0xa::coin::TransferEvent.from Property",
"Property:sources/coin.move:0xa::coin::TransferEvent.to Property",
"Struct:sources/coin.move:0xa::coin::CoinStore Struct",
"Struct:sources/coin.move:0xa::coin::TransferEvent Struct",
],
}
`;

View file

@ -0,0 +1,60 @@
/**
* Graph-equivalence regression guard for the Move ingestion refactor.
*
* This test locks the post-refactor graph structure (sorted node and edge sets)
* for the coin fixture as a vitest snapshot. Any future change that alters the
* Move graph shape will cause this test to fail, surfacing unintended regressions
* before merge.
*
* Per-refactor behavioral correctness (Pass-A/Pass-B seam, PendingRef confidence
* and reason, usedTypes assertions) is covered by the Move unit suite:
* test/unit/move/ref-resolver.test.ts
* test/unit/move/facts-mapper.test.ts
*
* Guard scope: Move-graph structural stability (node labels, edge types, edge
* reasons). The snapshot is the content it must not be empty. If move-flow is
* unavailable in the current environment, the test suite skips (expected locally).
* CI sets GITNEXUS_REQUIRE_MOVE_FLOW=1 to make unavailability a hard failure.
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as path from 'node:path';
import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js';
import { tryResolveMoveFlowClient } from '../../../src/core/move/mcp-client.js';
import { createMoveIngestPhase } from '../../../src/core/move/move-ingest.js';
import { ensureMoveFlowRuntime } from '../../../src/core/move/provision.js';
const requireMoveFlow = process.env.GITNEXUS_REQUIRE_MOVE_FLOW === '1';
const client = requireMoveFlow
? (
await ensureMoveFlowRuntime({
onLog: (message) => console.info(`[move-graph-equiv] ${message}`),
})
)?.client
: tryResolveMoveFlowClient()?.client;
if (requireMoveFlow && !client) {
throw new Error('GITNEXUS_REQUIRE_MOVE_FLOW=1 but MoveFlow provisioning failed');
}
const coinFixture = path.resolve(process.cwd(), 'test/fixtures/move/aptos-framework/coin');
describe.skipIf(!client)('Move graph-equivalence regression guard (coin fixture)', () => {
afterAll(async () => {
await client?.shutdown();
});
it('matches the committed graph snapshot (nodes + edges)', async () => {
const result = await runPipelineFromRepo(coinFixture, () => {}, {
standaloneIngestPhase: createMoveIngestPhase(client),
skipGraphPhases: true,
});
const nodes = [...result.graph.iterNodes()].map((n) => `${n.id}\t${n.label}`).sort();
const edges = [...result.graph.iterRelationships()]
.map((r) => `${r.sourceId}\t${r.type}\t${r.targetId}\t${r.reason ?? ''}`)
.sort();
expect({ nodes, edges }).toMatchSnapshot();
}, 60000);
});

View file

@ -72,15 +72,14 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => {
});
});
describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
it('INCREMENTAL_SCHEMA_VERSION is bumped to 12 (main-aptos merge: Move attributesJson past main v9v11)', () => {
expect(INCREMENTAL_SCHEMA_VERSION).toBe(12);
describe('incremental schema reuse gate', () => {
it('INCREMENTAL_SCHEMA_VERSION is bumped to 13 (persisted Move Type/field graph)', () => {
expect(INCREMENTAL_SCHEMA_VERSION).toBe(13);
});
it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => {
// The reuse gate at run-analyze.ts:920 is exactly this strict equality on
// the persisted `existingMeta.schemaVersion` (a plain number, possibly
// absent on a legacy stamp). Replicate it as a typed predicate.
// The production gate compares the persisted numeric stamp by strict
// equality; legacy metadata may omit it.
const passesReuseGate = (stampedSchemaVersion: number | undefined): boolean =>
stampedSchemaVersion === INCREMENTAL_SCHEMA_VERSION;
// A pre-v4 (v3) index has no CALL_SUMMARY edges → must NOT reuse.
@ -121,7 +120,10 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
// `attributesJson` node-table column, so the bulk COPY referencing it
// would fail on a top-up → must NOT reuse.
expect(passesReuseGate(11)).toBe(false);
// A pre-v13 (v12) index silently dropped generic Type nodes and Move
// EnumVariant→Property / field USES_TYPE relationships during CSV routing.
expect(passesReuseGate(12)).toBe(false);
// A current-version stamp passes the gate (incremental top-up eligible).
expect(passesReuseGate(12)).toBe(true);
expect(passesReuseGate(13)).toBe(true);
});
});

View file

@ -68,6 +68,31 @@ describe('extractChangedSubgraph', () => {
expect(sub.nodes.map((n) => n.id).sort()).toEqual(['comm-1', 'proc-1']);
});
it('always includes external dependency nodes and their edges (locationFidelity external)', () => {
// A Move file changed and now calls a dependency function that was never
// persisted. The external node has no filePath, so file-keyed extraction
// would skip the node while including the edge — the dangling endpoint
// then fails the rel COPY into the silent per-row fallback. External
// nodes get the Community/Process treatment instead (orchestrator
// delete-alls, extractor re-includes).
const g = createKnowledgeGraph();
g.addNode(makeFileNode('caller:fn', 'sources/vault.move'));
g.addNode({
id: 'Function::0x1::object::object_address',
label: 'Function',
properties: { filePath: '', name: 'object_address', locationFidelity: 'external' },
} as unknown as GraphNode);
g.addRelationship(makeRel('e1', 'caller:fn', 'Function::0x1::object::object_address'));
const sub = extractChangedSubgraph(g, new Set(['sources/vault.move']));
expect(sub.nodes.map((n) => n.id).sort()).toEqual([
'Function::0x1::object::object_address',
'caller:fn',
]);
expect(sub.relationships.map((r) => r.id)).toEqual(['e1']);
});
it('includes a relationship when at least one endpoint is writable', () => {
const g = createKnowledgeGraph();
g.addNode(makeFileNode('a:fn', '/repo/a.ts'));

View file

@ -0,0 +1,97 @@
// gitnexus/test/unit/move/concurrency.test.ts
import { describe, it, expect } from 'vitest';
import { mapWithConcurrency } from '../../../src/core/move/concurrency.js';
describe('mapWithConcurrency', () => {
it('never exceeds the concurrency limit and reaches it', async () => {
let inFlight = 0,
peak = 0;
await mapWithConcurrency([1, 2, 3, 4, 5, 6], 2, async () => {
inFlight++;
peak = Math.max(peak, inFlight);
await new Promise((r) => setTimeout(r, 5));
inFlight--;
});
expect(peak).toBe(2);
});
it('fail-fast returns first error and stops scheduling new work', async () => {
const started: number[] = [];
const { failure } = await mapWithConcurrency(
[1, 2, 3, 4, 5, 6],
1,
async (n) => {
started.push(n);
if (n === 2) throw new Error('boom');
},
{ failFast: true },
);
expect(failure?.error).toBeInstanceOf(Error);
expect(failure?.item).toBe(2);
expect(started).toEqual([1, 2]); // 3..6 never scheduled
});
it('fail-fast with limit=2: awaits in-flight workers, stops new scheduling, captures first error', async () => {
let resolveA!: () => void;
const latchA = new Promise<void>((r) => {
resolveA = r;
});
const aCompleted: boolean[] = [];
const started: number[] = [];
const resultPromise = mapWithConcurrency(
[1, 2, 3, 4, 5],
2,
async (n) => {
started.push(n);
if (n === 1) {
await latchA;
aCompleted.push(true);
return;
}
if (n === 2) throw new Error('worker-B-fail');
},
{ failFast: true },
);
// Allow microtasks to run so both workers start and worker B's rejection settles.
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
// Worker B has rejected; worker A is still blocked on latchA.
// Release A so mapWithConcurrency can resolve.
resolveA();
const { failure } = await resultPromise;
// (a) Worker A completed before the result resolved.
expect(aCompleted).toHaveLength(1);
// (b) Items after the failing index were never scheduled.
expect(started).not.toContain(3);
expect(started).not.toContain(4);
expect(started).not.toContain(5);
// (c) The failure captures the first (and only) error.
expect(failure?.error).toBeInstanceOf(Error);
expect((failure?.error as Error).message).toBe('worker-B-fail');
expect(failure?.item).toBe(2);
});
it('without failFast, first worker rejection rejects the returned promise', async () => {
await expect(
mapWithConcurrency([1, 2, 3], 2, async (n) => {
if (n === 2) throw new Error('non-failfast-rejection');
}),
).rejects.toThrow('non-failfast-rejection');
});
it('limit=1 runs sequentially in order', async () => {
const order: number[] = [];
const { results } = await mapWithConcurrency([1, 2, 3], 1, async (n) => {
order.push(n);
return n * 2;
});
expect(order).toEqual([1, 2, 3]);
expect(results).toEqual([2, 4, 6]);
});
});

View file

@ -1,5 +1,8 @@
import { describe, it, expect } from 'vitest';
import { validateMoveIngestOutput } from '../../../src/core/move/consistency.js';
import {
summarizeMoveConsistency,
validateMoveIngestOutput,
} from '../../../src/core/move/consistency.js';
import type { MoveIngestOutput } from '../../../src/core/move/move-ingest.js';
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
@ -21,6 +24,8 @@ function makeOutput(overrides: Partial<MoveIngestOutput> = {}): MoveIngestOutput
modulePackageMap: new Map(),
filePackageMap: new Map(),
callGraphByPackage: new Map(),
droppedRefs: [],
functionUsageFailures: [],
consistencyIssues: [],
...overrides,
};
@ -199,11 +204,180 @@ describe('validateMoveIngestOutput', () => {
const output = makeOutput({
ingestedFiles: new Set([file]),
moduleFileMap: new Map([['0xa::m', file]]),
droppedResourceRefs: [{ fnNodeId: 'Function:f', target: 'Config' }],
droppedRefs: [{ kind: 'resource', sourceId: 'Function:f', target: 'Config' }],
});
const issues = validateMoveIngestOutput(graph, output);
expect(
issues.some((i) => i.code === 'unresolved-resource-target' && i.severity === 'warning'),
).toBe(true);
});
it('warns when type, friend, or lambda-host targets were dropped', () => {
const graph = createKnowledgeGraph();
const output = makeOutput({
droppedRefs: [
{ kind: 'type', sourceId: 'Function:f', target: '(u64' },
{ kind: 'friend', sourceId: 'Module:m', target: '0xb::gone' },
{ kind: 'lambda-host', sourceId: 'Function:l', target: '0xa::m::host' },
],
});
const issues = validateMoveIngestOutput(graph, output);
for (const code of [
'unresolved-type-target',
'unresolved-friend-target',
'unresolved-lambda-host',
] as const) {
const issue = issues.find((i) => i.code === code);
expect(issue?.severity).toBe('warning');
expect(issue?.details?.count).toBe(1);
expect(
(issue?.details?.sample as Array<{ kind: string; sourceId: string; target: string }>)?.[0],
).toMatchObject({
kind: expect.any(String),
sourceId: expect.any(String),
target: expect.any(String),
});
}
});
it('errors call-graph-unlinked when no caller resolves despite mapped functions', () => {
const graph = createKnowledgeGraph();
const file = 'sources/coin.move';
addFileNode(graph, file);
const output = makeOutput({
ingestedFiles: new Set([file]),
moduleFileMap: new Map([['0xa::coin', file]]),
modulePackageMap: new Map([['0xa::coin', '/pkg']]),
functionNodeMap: new Map([['0xa::coin::register', `Function:${file}:0xa::coin::register`]]),
// Simulated normalization drift: call_graph came back zero-padded, so
// NO caller matches facts-derived names - and the ownership checks are
// also blind to it (caller modules miss modulePackageMap the same way).
callGraphByPackage: new Map([['/pkg', { '0x000a::coin::register': ['0x000a::coin::mint'] }]]),
});
const issues = validateMoveIngestOutput(graph, output);
const issue = issues.find((i) => i.code === 'call-graph-unlinked');
expect(issue?.severity).toBe('error');
expect(issue?.details?.packageRoot).toBe('/pkg');
// The pre-existing per-caller checks stay silent here - that blindness is
// exactly why the package-level check exists.
expect(issues.some((i) => i.code === 'missing-owned-caller')).toBe(false);
});
it('does not flag call-graph-unlinked when caller modules resolve (facts-elided callers)', () => {
const graph = createKnowledgeGraph();
const file = 'sources/coin.move';
addFileNode(graph, file);
const output = makeOutput({
ingestedFiles: new Set([file]),
moduleFileMap: new Map([['0xa::coin', file]]),
modulePackageMap: new Map([['0xa::coin', '/pkg']]),
functionNodeMap: new Map([['0xa::coin::register', `Function:${file}:0xa::coin::register`]]),
// The only caller is a #[test] function elided from facts: its MODULE
// resolves, so this is per-caller warning territory, not systematic
// qualified-name drift.
callGraphByPackage: new Map([['/pkg', { '0xa::coin::test_flow': ['0xa::coin::register'] }]]),
});
const issues = validateMoveIngestOutput(graph, output);
expect(issues.some((i) => i.code === 'call-graph-unlinked')).toBe(false);
expect(issues.some((i) => i.code === 'missing-owned-caller')).toBe(true);
});
it('does not flag call-graph-unlinked for a package with no mapped functions', () => {
const graph = createKnowledgeGraph();
const file = 'sources/coin.move';
addFileNode(graph, file);
const output = makeOutput({
ingestedFiles: new Set([file]),
moduleFileMap: new Map([['0xa::coin', file]]),
modulePackageMap: new Map([['0xa::coin', '/pkg']]),
// Structs-only package: call graph may list elided test-only functions.
callGraphByPackage: new Map([['/pkg', { '0xa::coin::test_helper': [] }]]),
});
const issues = validateMoveIngestOutput(graph, output);
expect(issues.some((i) => i.code === 'call-graph-unlinked')).toBe(false);
});
it('does not flag call-graph-unlinked when callees resolve (elided test-only caller module)', () => {
const graph = createKnowledgeGraph();
const file = 'sources/coin.move';
addFileNode(graph, file);
const output = makeOutput({
ingestedFiles: new Set([file]),
moduleFileMap: new Map([['0xa::coin', file]]),
modulePackageMap: new Map([['0xa::coin', '/pkg']]),
functionNodeMap: new Map([['0xa::coin::register', `Function:${file}:0xa::coin::register`]]),
// Every caller lives in a test-only MODULE elided from facts, so neither
// the function nor the module condition can clear the check - but the
// CALLEES still join facts names, which real drift would break too.
callGraphByPackage: new Map([['/pkg', { '0xa::coin_tests::flow': ['0xa::coin::register'] }]]),
});
const issues = validateMoveIngestOutput(graph, output);
expect(issues.some((i) => i.code === 'call-graph-unlinked')).toBe(false);
});
it('warns when an external module shares an address with repo-local modules', () => {
const graph = createKnowledgeGraph();
const file = 'sources/coin.move';
addFileNode(graph, file);
graph.addNode({
id: 'Module::0xa::vanished',
label: 'Module',
properties: {
name: 'vanished',
filePath: '',
qualifiedName: '0xa::vanished',
locationFidelity: 'external',
},
});
const output = makeOutput({
ingestedFiles: new Set([file]),
moduleFileMap: new Map([['0xa::coin', file]]),
modulePackageMap: new Map([['0xa::coin', '/pkg']]),
callGraphByPackage: new Map([['/pkg', {}]]),
});
const issue = validateMoveIngestOutput(graph, output).find(
(i) => i.code === 'external-module-address-overlap',
);
expect(issue?.severity).toBe('warning');
expect(issue?.details?.sample).toEqual(['0xa::vanished']);
});
it('does not warn for external modules at genuinely foreign addresses', () => {
const graph = createKnowledgeGraph();
const file = 'sources/coin.move';
addFileNode(graph, file);
graph.addNode({
id: 'Module::0x1::object',
label: 'Module',
properties: {
name: 'object',
filePath: '',
qualifiedName: '0x1::object',
locationFidelity: 'external',
},
});
const output = makeOutput({
ingestedFiles: new Set([file]),
moduleFileMap: new Map([['0xa::coin', file]]),
modulePackageMap: new Map([['0xa::coin', '/pkg']]),
callGraphByPackage: new Map([['/pkg', {}]]),
});
const issues = validateMoveIngestOutput(graph, output);
expect(issues.some((i) => i.code === 'external-module-address-overlap')).toBe(false);
});
});
describe('summarizeMoveConsistency', () => {
it('bounds persisted messages and details', () => {
const summary = summarizeMoveConsistency([
{
code: 'empty-package-facts',
severity: 'error',
message: 'm'.repeat(10_000),
details: { diagnostics: 'd'.repeat(1_000_000) },
},
]);
expect(JSON.stringify(summary).length).toBeLessThan(5_000);
});
});

View file

@ -39,11 +39,14 @@ const client: MoveFlowClient = {
async callGraph() {
return {};
},
async functionUsage() {
return { called: [], used: [] };
},
async packageStatus() {
return { ok: true, diagnostics: '' };
},
async capabilities() {
return { hasFactsQuery: true, hasStatusTool: false };
return { hasFactsQuery: true, hasFunctionUsageQuery: false, hasStatusTool: false };
},
async shutdown() {},
};

View file

@ -0,0 +1,42 @@
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import type { MoveFactsMap } from '../../../src/core/move/compiler-facts.js';
import {
makeMoveFlowClientStub,
moveFunctionFact,
runMoveIngestPhaseWithGraph,
} from '../../helpers/move-ingest-harness.js';
describe('Move entry points', () => {
it('emits roots for entry and view functions, but not init_module', async () => {
const repoRoot = path.resolve('/repo');
const facts: MoveFactsMap = {
'0xa::roots': {
file: path.join(repoRoot, 'pkg/sources/roots.move'),
functions: [
moveFunctionFact('entry_api', { isEntry: true }),
moveFunctionFact('view_api', { isView: true }),
moveFunctionFact('init_module'),
],
},
};
const client = makeMoveFlowClientStub({
facts: async () => facts,
callGraph: async () => ({}),
capabilities: async () => ({
hasFactsQuery: true,
hasFunctionUsageQuery: false,
hasStatusTool: false,
}),
});
const { graph } = await runMoveIngestPhaseWithGraph(client, repoRoot, [
'pkg/Move.toml',
'pkg/sources/roots.move',
]);
const roots = [...graph.iterRelationshipsByType('ENTRY_POINT_OF')].map((edge) => edge.reason);
expect(roots).toEqual(expect.arrayContaining(['move-entry-function', 'move-view-function']));
expect(roots).toHaveLength(2);
});
});

View file

@ -1,36 +1,32 @@
import { describe, it, expect } from 'vitest';
import { mapFactsToGraph } from '../../../src/core/move/facts-mapper.js';
import {
buildLocalNameIndex,
mapFactsToGraph,
resolveFriendEdges,
resolveResourceEdges,
resolveTypeRefEdges,
} from '../../../src/core/move/facts-mapper.js';
resolveRefs,
ExternalMoveSymbols,
} from '../../../src/core/move/move-linker.js';
import type { DroppedRef } from '../../../src/core/move/refs.js';
import {
type MoveFactsFunction,
type MoveFactsMap,
} from '../../../src/core/move/compiler-facts.js';
import { moveFunctionFact } from '../../helpers/move-ingest-harness.js';
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
/** A minimal function facts entry with per-test overrides. */
/** A fuller-shaped function facts entry (file/span/arrays) with per-test overrides. */
function fnFixture(name: string, overrides: Partial<MoveFactsFunction> = {}): MoveFactsFunction {
return {
name,
return moveFunctionFact(name, {
file: '/pkg/sources/fixture.move',
span: [1, 3],
visibility: 'public',
isEntry: false,
isInline: false,
isNative: false,
isView: false,
attributes: [],
typeParams: [],
params: [],
returnTypes: [],
acquiresInferred: [],
resourceAccess: { reads: [], writes: [] },
isLambdaLifted: false,
...overrides,
};
});
}
// Trimmed-but-faithful slice of a real `move_package_query { query: "facts" }`
@ -112,29 +108,29 @@ const facts: MoveFactsMap = {
},
};
function mapFactsToGraphWithResolvedEdges(
factsMap: MoveFactsMap,
): ReturnType<typeof mapFactsToGraph> {
function mapFactsToGraphWithResolvedEdges(factsMap: MoveFactsMap) {
const mapped = mapFactsToGraph(factsMap, '/pkg');
const edges = [...mapped.edges];
const graph = createKnowledgeGraph();
for (const node of mapped.nodes) graph.addNode(node);
for (const rel of mapped.edges) graph.addRelationship(rel);
const localNameIndex = buildLocalNameIndex(mapped.structNodeMap);
const addResolvedUsedType = (sourceId: string, targetId: string): void => {
const fnNode = mapped.nodes.find((n) => n.id === sourceId);
const typeNode = mapped.nodes.find((n) => n.id === targetId);
const qualifiedName = typeNode?.properties.qualifiedName;
if (!fnNode || typeof qualifiedName !== 'string') return;
const current = Array.isArray(fnNode.properties.usedTypes) ? fnNode.properties.usedTypes : [];
if (!current.includes(qualifiedName)) fnNode.properties.usedTypes = [...current, qualifiedName];
};
resolveResourceEdges(mapped.pendingResource, mapped.structNodeMap, localNameIndex, (rel) =>
edges.push(rel),
const external = new ExternalMoveSymbols(graph, mapped.moduleFileMap);
const drops: DroppedRef[] = [];
resolveRefs(
graph,
mapped.pendingRefs,
{
structNodeMap: mapped.structNodeMap,
structIdsByLocalName: localNameIndex,
moduleFileMap: mapped.moduleFileMap,
functionNodeMap: mapped.functionNodeMap,
},
external,
drops,
);
resolveFriendEdges(mapped.pendingFriends, mapped.moduleFileMap, (rel) => edges.push(rel));
resolveTypeRefEdges(mapped.pendingTypeRef, mapped.structNodeMap, localNameIndex, (rel) => {
edges.push(rel);
addResolvedUsedType(rel.sourceId, rel.targetId);
});
return { ...mapped, edges };
const edges = [...graph.iterRelationships()];
const nodes = [...graph.iterNodes()];
return { ...mapped, nodes, edges };
}
describe('mapFactsToGraph', () => {
@ -193,10 +189,36 @@ describe('mapFactsToGraph', () => {
const { nodes } = mapFactsToGraph(facts, '/pkg');
const fn = nodes.find((n) => n.properties.name === 'balance_of');
expect(fn?.properties.isView).toBe(true);
expect(fn?.properties.isExported).toBe(true);
expect(fn?.properties.locationFidelity).toBe('precise');
expect(fn?.properties.startLine).toBe(33);
});
it('maps public and compiler-marked entry/view functions onto isExported', () => {
const visibilityFacts: MoveFactsMap = {
'0xa::roots': {
file: '/pkg/sources/roots.move',
functions: [
fnFixture('public_api', { visibility: 'public' }),
fnFixture('package_api', { visibility: 'package' }),
fnFixture('private_entry', { visibility: 'internal', isEntry: true }),
fnFixture('init_module', { visibility: 'internal' }),
fnFixture('helper', { visibility: 'internal' }),
],
},
};
const { nodes } = mapFactsToGraph(visibilityFacts, '/pkg');
const exported = (name: string) =>
nodes.find((node) => node.label === 'Function' && node.properties.name === name)?.properties
.isExported;
expect(exported('public_api')).toBe(true);
expect(exported('package_api')).toBe(false);
expect(exported('private_entry')).toBe(true);
expect(exported('init_module')).toBe(false);
expect(exported('helper')).toBe(false);
});
it('tolerates functions/modules with missing optional arrays (real move-flow shape)', () => {
// move-flow omits optional array fields (acquiresInferred, resourceAccess,
// params, typeParams, attributes, friends, types, constants) for some symbols.
@ -348,6 +370,75 @@ describe('mapFactsToGraph', () => {
expect(v?.properties.locationFidelity).toBe('module');
});
it('materializes Move 2 enum variant fields and their local type edges', () => {
const enumFacts: MoveFactsMap = {
'0xa::orders': {
file: '/pkg/sources/orders.move',
functions: [],
structs: [
{
kind: 'struct',
name: 'Payload',
file: '/pkg/sources/orders.move',
span: [1, 2],
abilities: ['copy'],
typeParams: [],
fields: [],
attributes: [],
},
{
kind: 'enum',
name: 'OrderState',
file: '/pkg/sources/orders.move',
span: [4, 10],
abilities: ['drop'],
typeParams: [{ name: 'T', abilities: [], isPhantom: false }],
variants: [
{
name: 'Open',
kind: 'named',
fields: [
{ name: 'payload', type: 'Payload', positional: false },
{ name: 'callback', type: '|Payload|T has copy + drop', positional: false },
],
attributes: [],
},
],
attributes: [],
},
],
},
};
const { nodes, edges, pendingRefs } = mapFactsToGraphWithResolvedEdges(enumFacts);
const variant = nodes.find(
(node) => node.label === 'EnumVariant' && node.properties.name === 'Open',
);
const fieldIds = new Set(
edges
.filter((edge) => edge.type === 'HAS_PROPERTY' && edge.sourceId === variant?.id)
.map((edge) => edge.targetId),
);
const fields = nodes.filter((node) => node.label === 'Property' && fieldIds.has(node.id));
expect(fields.map((field) => field.properties.name)).toEqual(['payload', 'callback']);
expect(
edges.filter((edge) => edge.type === 'HAS_PROPERTY' && edge.sourceId === variant?.id),
).toHaveLength(2);
expect(
edges.some(
(edge) =>
edge.type === 'USES_TYPE' &&
edge.reason === 'move-enum-variant-field-type' &&
edge.sourceId === fields.find((field) => field.properties.name === 'callback')?.id &&
edge.targetId.includes('Payload'),
),
).toBe(true);
const typeRefs = pendingRefs.filter((r) => r.kind === 'type');
expect(typeRefs.some((pending) => pending.target === 'copy')).toBe(false);
expect(typeRefs.some((pending) => pending.target === 'drop')).toBe(false);
expect(typeRefs.some((pending) => pending.target === 'T')).toBe(false);
});
it('does not write moduleAddress on Function nodes (only Module/Struct/Enum carry it)', () => {
const { nodes } = mapFactsToGraph(facts, '/pkg');
const fn = nodes.find((n) => n.label === 'Function' && n.properties.name === 'register');
@ -535,8 +626,10 @@ describe('mapFactsToGraph', () => {
constants: [],
},
};
const { pendingTypeRef } = mapFactsToGraph(rtFacts, '/pkg');
const returnRefs = pendingTypeRef.filter((p) => p.reason === 'move-fn-return-type');
const { pendingRefs } = mapFactsToGraph(rtFacts, '/pkg');
const returnRefs = pendingRefs.filter(
(p) => p.kind === 'type' && p.reason === 'move-fn-return-type',
);
expect(returnRefs.map((p) => p.target)).toEqual(['0xbeef::price::PriceInfo']);
});
@ -588,18 +681,33 @@ describe('mapFactsToGraph', () => {
},
};
const mapped = mapFactsToGraph(qualFacts, '/pkg');
const graph = createKnowledgeGraph();
for (const node of mapped.nodes) graph.addNode(node);
for (const rel of mapped.edges) graph.addRelationship(rel);
const localNameIndex = buildLocalNameIndex(mapped.structNodeMap);
const resolvedEdges: unknown[] = [];
const unresolved: string[] = [];
resolveResourceEdges(
mapped.pendingResource,
mapped.structNodeMap,
localNameIndex,
(rel) => resolvedEdges.push(rel),
(pending) => unresolved.push(pending.target),
const external = new ExternalMoveSymbols(graph, mapped.moduleFileMap);
const drops: DroppedRef[] = [];
const resourceRefs = mapped.pendingRefs.filter((r) => r.kind === 'resource');
resolveRefs(
graph,
resourceRefs,
{
structNodeMap: mapped.structNodeMap,
structIdsByLocalName: localNameIndex,
moduleFileMap: mapped.moduleFileMap,
functionNodeMap: mapped.functionNodeMap,
},
external,
drops,
);
const localCoinStoreId = mapped.structNodeMap.get('0xa::coin::CoinStore');
const resolvedResourceEdges = [...graph.iterRelationships()].filter(
(e) => e.type === 'READS_RESOURCE' || e.type === 'WRITES_RESOURCE' || e.type === 'ACQUIRES',
);
expect(resolvedResourceEdges.every((e) => e.targetId !== localCoinStoreId)).toBe(true);
expect(resolvedResourceEdges.every((e) => e.targetId.includes('0x1::coin::CoinStore'))).toBe(
true,
);
expect(resolvedEdges).toEqual([]);
expect(unresolved).toEqual(['0x1::coin::CoinStore', '0x1::coin::CoinStore']);
});
it('projects full attribute payloads (args/values) as attributesJson alongside the name list', () => {
@ -704,10 +812,31 @@ describe('mapFactsToGraph', () => {
constants: [],
},
};
const { pendingLambdaHosts } = mapFactsToGraph(lambdaFacts, '/pkg');
expect(pendingLambdaHosts.map((p) => p.hostQualified)).toEqual([
'0xa::vault::lifted',
'0xa::vault::outer',
]);
const { pendingRefs } = mapFactsToGraph(lambdaFacts, '/pkg');
const lambdaRefs = pendingRefs.filter((r) => r.kind === 'lambda-host');
expect(lambdaRefs.map((p) => p.target)).toEqual(['0xa::vault::lifted', '0xa::vault::outer']);
});
it('attributes lifted lambdas to their declaring module source', () => {
const lambdaFacts: MoveFactsMap = {
'0xa::vault': {
file: '/pkg/sources/vault.move',
functions: [
fnFixture('__lambda__1__run', {
file: '/external/aptos-framework/sources/big_ordered_map.move',
isLambdaLifted: true,
definedIn: 'run',
}),
],
},
};
const { nodes } = mapFactsToGraph(lambdaFacts, '/pkg');
const lambda = nodes.find(
(node) => node.label === 'Function' && node.properties.name === '__lambda__1__run',
);
expect(lambda?.properties.filePath).toBe('/pkg/sources/vault.move');
expect(lambda?.properties.startLine).toBeUndefined();
expect(lambda?.properties.endLine).toBeUndefined();
expect(lambda?.properties.locationFidelity).toBe('module');
});
});

View file

@ -0,0 +1,190 @@
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import type { MoveFactsMap } from '../../../src/core/move/compiler-facts.js';
import {
makeMoveFlowClientStub,
moveFunctionFact,
runMoveIngestPhaseWithGraph,
} from '../../helpers/move-ingest-harness.js';
const REPO_ROOT = path.resolve('/repo');
const SOURCE_FILE = path.join(REPO_ROOT, 'pkg/sources/spot.move');
const facts: MoveFactsMap = {
'0xa::spot_callbacks': {
file: SOURCE_FILE,
functions: [
moveFunctionFact('make_callbacks', {
file: SOURCE_FILE,
span: [1, 4],
visibility: 'public',
params: [],
returnTypes: ['|address|bool has copy + drop', '|address|bool has copy + drop'],
}),
moveFunctionFact('dispatch_funds', {
file: SOURCE_FILE,
span: [6, 8],
params: [],
}),
moveFunctionFact('local_callback_user', {
file: SOURCE_FILE,
span: [9, 9],
params: [],
}),
moveFunctionFact('withdrawal_done', {
file: SOURCE_FILE,
span: [10, 12],
params: [],
}),
moveFunctionFact('complete', {
file: SOURCE_FILE,
span: [14, 16],
params: [],
}),
moveFunctionFact('public_api', {
file: SOURCE_FILE,
span: [22, 25],
visibility: 'public',
params: [
{
name: 'object',
type: '0x1::object::Object<0x1::fungible_asset::Metadata>',
},
],
}),
moveFunctionFact('native_helper', {
file: SOURCE_FILE,
span: [27, 27],
isNative: true,
}),
],
structs: [],
constants: [],
},
};
async function ingestFixture() {
const client = makeMoveFlowClientStub({
facts: async () => facts,
callGraph: async () => ({
'0xa::spot_callbacks::make_callbacks': [],
'0xa::spot_callbacks::withdrawal_done': ['0xa::spot_callbacks::complete'],
'0xa::spot_callbacks::public_api': ['0x1::object::object_address'],
}),
functionUsage: async (_pkg, functionName) => {
if (functionName === 'spot_callbacks::local_callback_user') {
return {
called: [],
used: ['0xa::spot_callbacks::complete'],
};
}
if (functionName === 'spot_callbacks::withdrawal_done') {
// call_graph already carries withdrawal_done → complete; function_usage
// reporting it as used-not-called must not mint a second CALLS edge.
return { called: [], used: ['0xa::spot_callbacks::complete'] };
}
if (functionName !== 'spot_callbacks::make_callbacks') {
return { called: [], used: [] };
}
return {
called: [],
used: ['0xa::spot_callbacks::dispatch_funds', '0xa::spot_callbacks::withdrawal_done'],
};
},
});
const { output, graph } = await runMoveIngestPhaseWithGraph(client, REPO_ROOT, [
'pkg/Move.toml',
'pkg/sources/spot.move',
]);
const nodes = [...graph.iterNodes()];
const edges = [...graph.iterRelationships()];
const functionId = (qualifiedName: string) =>
nodes.find(
(node) => node.label === 'Function' && node.properties.qualifiedName === qualifiedName,
)?.id;
return { client, output, nodes, edges, functionId };
}
describe('Move graph quality regressions', () => {
it('links compiler-reported closure captures for all ordinary functions', async () => {
const { client, edges, functionId } = await ingestFixture();
const makeCallbacks = functionId('0xa::spot_callbacks::make_callbacks');
expect(
edges.filter(
(edge) =>
edge.sourceId === makeCallbacks &&
edge.type === 'CALLS' &&
edge.reason === 'move-compiler-closure-use',
),
).toEqual(
expect.arrayContaining([
expect.objectContaining({
targetId: functionId('0xa::spot_callbacks::dispatch_funds'),
}),
expect.objectContaining({
targetId: functionId('0xa::spot_callbacks::withdrawal_done'),
}),
]),
);
expect(
edges.some(
(edge) =>
edge.sourceId === functionId('0xa::spot_callbacks::local_callback_user') &&
edge.targetId === functionId('0xa::spot_callbacks::complete') &&
edge.type === 'CALLS' &&
edge.reason === 'move-compiler-closure-use',
),
).toBe(true);
const queryable = (facts['0xa::spot_callbacks'].functions ?? []).filter(
(f) => !f.isNative && !f.isLambdaLifted,
);
expect(client.counts.functionUsage).toBe(queryable.length);
});
it('does not duplicate a call-graph edge the usage query classifies as a capture', async () => {
const { edges, functionId } = await ingestFixture();
const callEdges = edges.filter(
(edge) =>
edge.type === 'CALLS' &&
edge.sourceId === functionId('0xa::spot_callbacks::withdrawal_done') &&
edge.targetId === functionId('0xa::spot_callbacks::complete'),
);
expect(callEdges).toHaveLength(1);
expect(callEdges[0].reason).toBe('move-compiler-call-graph');
});
it('materializes external function and type targets', async () => {
const { output, nodes, edges, functionId } = await ingestFixture();
const externalFunction = nodes.find(
(node) =>
node.label === 'Function' &&
node.properties.qualifiedName === '0x1::object::object_address',
);
expect(externalFunction?.properties.locationFidelity).toBe('external');
expect(
edges.some(
(edge) =>
edge.type === 'CALLS' &&
edge.sourceId === functionId('0xa::spot_callbacks::public_api') &&
edge.targetId === externalFunction?.id,
),
).toBe(true);
const externalTypes = nodes.filter(
(node) => node.label === 'Type' && node.properties.locationFidelity === 'external',
);
expect(externalTypes.map((node) => node.properties.qualifiedName).sort()).toEqual([
'0x1::fungible_asset::Metadata',
'0x1::object::Object',
]);
expect(
edges.filter(
(edge) =>
edge.type === 'USES_TYPE' &&
edge.sourceId === functionId('0xa::spot_callbacks::public_api'),
),
).toHaveLength(2);
expect(output.droppedRefs.filter((d) => d.kind === 'type')).toEqual([]);
});
});

View file

@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createHash } from 'node:crypto';
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
const raceLoss = vi.hoisted(() => ({
publish: async (): Promise<void> => {},
}));
vi.mock('../../../src/core/move/install-lock.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/core/move/install-lock.js')>();
return {
...actual,
acquireInstallLock: async () => {
await raceLoss.publish();
throw new Error('timed out waiting for another move-flow installation');
},
};
});
const { getMoveFlowInstallConfig, installMoveFlow } =
await import('../../../src/core/move/install.js');
describe('move-flow installer concurrent-publish recovery', () => {
let cacheRoot: string;
beforeEach(async () => {
cacheRoot = await mkdtemp(path.join(os.tmpdir(), 'move-flow-recovery-'));
});
afterEach(async () => {
await rm(cacheRoot, { recursive: true, force: true });
raceLoss.publish = async () => {};
});
it.skipIf(process.platform === 'win32')(
'returns the concurrently published cache instead of failed',
async () => {
const env: NodeJS.ProcessEnv = { GITNEXUS_MOVE_FLOW_DIR: cacheRoot };
const config = await getMoveFlowInstallConfig(env);
if (!config) throw new Error('expected a supported move-flow install target');
raceLoss.publish = async () => {
await mkdir(config.installDir, { recursive: true });
const script = `#!/bin/sh\necho "move-flow ${config.version}"\n`;
await writeFile(config.binaryPath, script);
await chmod(config.binaryPath, 0o755);
const binarySha256 = createHash('sha256').update(script).digest('hex');
await writeFile(
config.metadataPath,
JSON.stringify({
schemaVersion: 1,
version: config.version,
repository: config.repository,
tag: config.tag,
assetName: config.assetName,
archiveSha256: 'unused',
binarySha256,
}),
);
};
const result = await installMoveFlow(env);
expect(result.status).toBe('available');
expect(result.binary?.binaryPath).toBe(config.binaryPath);
expect(result.binary?.version).toBe(config.version);
},
);
it('still reports failed when no valid cache exists', async () => {
const result = await installMoveFlow({ GITNEXUS_MOVE_FLOW_DIR: cacheRoot });
expect(result.status).toBe('failed');
expect(result.message).toContain('timed out waiting');
});
});

View file

@ -11,6 +11,7 @@ vi.mock('node:child_process', () => ({
import {
MoveFlowMcpClient,
MoveFlowToolCallError,
MoveFlowTransportError,
tryResolveMoveFlowClient,
detectMoveFlowCapabilities,
} from '../../../src/core/move/mcp-client.js';
@ -213,6 +214,43 @@ describe('MoveFlowMcpClient', () => {
await client.shutdown();
});
it('retries unrelated in-flight requests when another request times out', async () => {
vi.useFakeTimers();
process.env.GITNEXUS_MOVE_FLOW_TIMEOUT_MS = '25';
const timedOutProc = createMockProc();
const retryProc = createMockProc();
mockSpawn.mockReturnValueOnce(timedOutProc as any).mockReturnValueOnce(retryProc as any);
timedOutProc.stdin.on('data', (chunk: Buffer) => {
for (const line of chunk.toString().split('\n')) {
if (!line.trim()) continue;
const msg = JSON.parse(line);
if (msg.method === 'initialize') {
timedOutProc.stdout.write(
JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} }) + '\n',
);
}
}
});
serveToolsCall(retryProc, {
result: { content: [{ type: 'text', text: '{"retried":true}' }], isError: false },
});
const client = new MoveFlowMcpClient('move-flow');
const first = client.facts('/first');
const firstFailure = expect(first).rejects.toThrow(
"move-flow 'move_package_query' timed out after 25ms",
);
await vi.advanceTimersByTimeAsync(5);
const secondSuccess = expect(client.facts('/second')).resolves.toEqual({ retried: true });
await vi.advanceTimersByTimeAsync(20);
await firstFailure;
await secondSuccess;
expect(mockSpawn).toHaveBeenCalledTimes(2);
await client.shutdown();
});
it('ignores a late response after a tool timeout and starts a fresh child', async () => {
vi.useFakeTimers();
process.env.GITNEXUS_MOVE_FLOW_TIMEOUT_MS = '25';
@ -371,6 +409,29 @@ describe('MoveFlowMcpClient', () => {
await client.shutdown();
});
it('rejects malformed function-usage payloads', async () => {
const proc = createMockProc();
mockSpawn.mockReturnValue(proc as any);
const client = new MoveFlowMcpClient('move-flow');
serveToolsCall(proc, {
result: {
content: [
{
type: 'text',
text: JSON.stringify({
called: 'not-an-array',
used: [],
}),
},
],
isError: false,
},
});
await expect(client.functionUsage('/pkg', 'm::f')).rejects.toThrow(MoveFlowToolCallError);
await client.shutdown();
});
it('packageStatus reports ok on an isError:false status result', async () => {
// move-flow returns "no errors or warnings" for a compiling package.
const proc = createMockProc();
@ -403,12 +464,72 @@ describe('MoveFlowMcpClient', () => {
});
await client.shutdown();
});
it('retries a request once after a mid-flight transport crash', async () => {
const crashProc = createMockProc();
const freshProc = createMockProc();
mockSpawn.mockReturnValueOnce(crashProc as any).mockReturnValueOnce(freshProc as any);
crashProc.stdin.on('data', (chunk: Buffer) => {
for (const line of chunk.toString().split('\n')) {
if (!line.trim()) continue;
const msg = JSON.parse(line);
if (msg.method === 'initialize') {
crashProc.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} }) + '\n');
} else if (msg.method === 'tools/call') {
// Die mid-request instead of answering — a transport fault.
crashProc.emit('exit', 137);
}
}
});
serveToolsCall(freshProc, {
result: { content: [{ type: 'text', text: '{"fresh":true}' }], isError: false },
});
const client = new MoveFlowMcpClient('move-flow');
await expect(client.facts('/pkg')).resolves.toEqual({ fresh: true });
expect(mockSpawn).toHaveBeenCalledTimes(2);
await client.shutdown();
});
it('functionUsage does not retry a transport error', async () => {
const crashProc = createMockProc();
mockSpawn.mockReturnValueOnce(crashProc as any);
crashProc.stdin.on('data', (chunk: Buffer) => {
for (const line of chunk.toString().split('\n')) {
if (!line.trim()) continue;
const msg = JSON.parse(line);
if (msg.method === 'initialize') {
crashProc.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} }) + '\n');
} else if (msg.method === 'tools/call') {
// Die mid-request — a transport fault, no retry should happen.
crashProc.emit('exit', 137);
}
}
});
const client = new MoveFlowMcpClient('move-flow');
await expect(client.functionUsage('/pkg', 'm::f')).rejects.toThrow(MoveFlowTransportError);
// Exactly ONE spawn — no respawn/retry for functionUsage transport errors.
expect(mockSpawn).toHaveBeenCalledTimes(1);
await client.shutdown();
});
it('rejects new requests after shutdown instead of respawning a child', async () => {
const client = new MoveFlowMcpClient('move-flow');
await client.shutdown();
await expect(client.facts('/pkg')).rejects.toThrow('move-flow client is shut down');
expect(mockSpawn).not.toHaveBeenCalled();
});
});
describe('detectMoveFlowCapabilities', () => {
it('reports facts support from a standalone move_package_facts tool name', () => {
const caps = detectMoveFlowCapabilities(['move_package_query', 'move_package_facts']);
expect(caps.hasFactsQuery).toBe(true);
expect(caps.hasFunctionUsageQuery).toBe(false);
});
it('detects the facts query from the move_package_query inputSchema enum', () => {
@ -420,23 +541,32 @@ describe('detectMoveFlowCapabilities', () => {
inputSchema: {
$defs: {
QueryType: {
oneOf: [{ const: 'module_summary' }, { const: 'call_graph' }, { const: 'facts' }],
oneOf: [
{ const: 'module_summary' },
{ const: 'call_graph' },
{ const: 'function_usage' },
{ const: 'facts' },
],
},
},
},
},
]);
expect(caps.hasFactsQuery).toBe(true);
expect(caps.hasFunctionUsageQuery).toBe(true);
});
it('also detects facts from a flat enum schema', () => {
const caps = detectMoveFlowCapabilities([
{
name: 'move_package_query',
inputSchema: { properties: { query: { enum: ['module_summary', 'facts'] } } },
inputSchema: {
properties: { query: { enum: ['module_summary', 'function_usage', 'facts'] } },
},
},
]);
expect(caps.hasFactsQuery).toBe(true);
expect(caps.hasFunctionUsageQuery).toBe(true);
});
it('reports facts absent when the schema omits it', () => {
@ -448,11 +578,13 @@ describe('detectMoveFlowCapabilities', () => {
'move_package_manifest',
]);
expect(caps.hasFactsQuery).toBe(false);
expect(caps.hasFunctionUsageQuery).toBe(false);
});
it('reports facts absent when move_package_query is missing entirely', () => {
const caps = detectMoveFlowCapabilities(['move_package_status']);
expect(caps.hasFactsQuery).toBe(false);
expect(caps.hasFunctionUsageQuery).toBe(false);
expect(caps.hasStatusTool).toBe(true);
});

View file

@ -10,19 +10,12 @@
import { describe, it, expect } from 'vitest';
import path from 'node:path';
import type { MoveFlowClient } from '../../../src/core/move/mcp-client.js';
import { runMoveIngestPhase } from '../../helpers/move-ingest-harness.js';
import { makeMoveFlowClientStub, 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,
};
return makeMoveFlowClientStub(overrides);
}
/** Run the phase against a fake repo with one package holding one .move file. */
@ -65,7 +58,11 @@ describe('moveIngest empty-facts discrimination', () => {
it('falls back to the error-level issue when move_package_status is unavailable', async () => {
const output = await runPhase(
makeClient({
capabilities: async () => ({ hasFactsQuery: true, hasStatusTool: false }),
capabilities: async () => ({
hasFactsQuery: true,
hasFunctionUsageQuery: false,
hasStatusTool: false,
}),
}),
);

View file

@ -6,21 +6,18 @@
*/
import { describe, it, expect } from 'vitest';
import path from 'node:path';
import type { MoveFlowClient } from '../../../src/core/move/mcp-client.js';
import { runMoveIngestPhase } from '../../helpers/move-ingest-harness.js';
import { makeMoveFlowClientStub, runMoveIngestPhase } from '../../helpers/move-ingest-harness.js';
const REPO_ROOT = path.resolve('/repo');
/** Client returning empty facts for every package, without a status tool. */
function emptyFactsClient(): MoveFlowClient {
return {
facts: async () => ({}),
callGraph: async () => ({}),
packageStatus: async () => ({ ok: true, diagnostics: '' }),
capabilities: async () => ({ hasFactsQuery: true, hasStatusTool: false }),
shutdown: async () => {},
};
}
const emptyFactsClient = () =>
makeMoveFlowClientStub({
capabilities: async () => ({
hasFactsQuery: true,
hasFunctionUsageQuery: false,
hasStatusTool: false,
}),
});
describe('moveIngest package-ownership attribution', () => {
it('attributes each file to its own package across sibling packages pkg_a / pkg_ab', async () => {

View file

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

View file

@ -1,4 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type {
MoveFlowMcpClient,
ResolvedMoveFlowClient,
@ -12,6 +15,8 @@ import type { VerifiedMoveFlowBinary } from '../../../src/core/move/install.js';
const originalMoveFlow = process.env.MOVE_FLOW;
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
if (originalMoveFlow === undefined) delete process.env.MOVE_FLOW;
else process.env.MOVE_FLOW = originalMoveFlow;
});
@ -42,6 +47,64 @@ describe('ensureMoveFlowRuntime', () => {
expect(deps.install).not.toHaveBeenCalled();
});
it('logs when the resolved binary cannot be read for fingerprinting', async () => {
// A resolver that answers for a locator whose file does not exist: the
// digest falls back to a random 'unverifiable' value, which churns the
// fingerprint (full re-index every run) - that must not stay silent.
process.env.MOVE_FLOW = '/missing/move-flow';
const deps = dependencies({ resolveClient: vi.fn(() => resolved) });
const onLog = vi.fn();
const runtime = await ensureMoveFlowRuntime({ onLog }, deps);
expect(runtime?.client).toBe(client);
expect(onLog).toHaveBeenCalledWith(
expect.stringContaining('could not be read for fingerprinting'),
);
});
it.skipIf(process.platform === 'win32')(
'fingerprints local compiler bytes, not only path and version',
async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'move-flow-identity-'));
const binary = path.join(directory, 'move-flow');
try {
process.env.MOVE_FLOW = binary;
await writeFile(binary, '#!/bin/sh\necho first\n');
await chmod(binary, 0o755);
const deps = dependencies({ resolveClient: vi.fn(() => resolved) });
const first = await ensureMoveFlowRuntime({}, deps);
await writeFile(binary, '#!/bin/sh\necho second\n');
await chmod(binary, 0o755);
const second = await ensureMoveFlowRuntime({}, deps);
expect(second?.identity.fingerprint).not.toBe(first?.identity.fingerprint);
} finally {
await rm(directory, { recursive: true, force: true });
}
},
);
it('fingerprints a Windows PATH locator that already includes its executable extension', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'move-flow-windows-identity-'));
const binary = path.join(directory, 'move-flow.exe');
try {
await writeFile(binary, 'stable move-flow bytes');
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
vi.stubEnv('MOVE_FLOW', 'move-flow.exe');
vi.stubEnv('PATH', directory);
vi.stubEnv('PATHEXT', '.exe');
const deps = dependencies({ resolveClient: vi.fn(() => resolved) });
const first = await ensureMoveFlowRuntime({}, deps);
const second = await ensureMoveFlowRuntime({}, deps);
expect(second?.identity.fingerprint).toBe(first?.identity.fingerprint);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it('keeps an invalid explicit MOVE_FLOW authoritative', async () => {
process.env.MOVE_FLOW = '/missing/move-flow';
const deps = dependencies();

View file

@ -0,0 +1,174 @@
// gitnexus/test/unit/move/ref-resolver.test.ts
import { describe, it, expect } from 'vitest';
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
import { resolveRefs, buildLocalNameIndex } from '../../../src/core/move/move-linker.js';
import { ExternalMoveSymbols } from '../../../src/core/move/move-linker.js';
import type { PendingRef, DroppedRef } from '../../../src/core/move/refs.js';
import { MOVE_EDGE_REASON } from '../../../src/core/move/constants.js';
function fnNode(graph: ReturnType<typeof createKnowledgeGraph>, id: string) {
graph.addNode({
id,
label: 'Function',
properties: {
name: id,
filePath: 'a.move',
language: 'move',
qualifiedName: id,
usedTypes: [],
},
});
}
function structNode(graph: ReturnType<typeof createKnowledgeGraph>, id: string, qn: string) {
graph.addNode({
id,
label: 'Struct',
properties: { name: qn, filePath: 'a.move', language: 'move', qualifiedName: qn },
});
}
describe('resolveRefs', () => {
it('resolves a type ref to a mapped struct and records usedTypes', () => {
const graph = createKnowledgeGraph();
fnNode(graph, 'Function:a.move:0xa::m::f');
structNode(graph, 'Struct:a.move:0xa::m::S', '0xa::m::S');
const structNodeMap = new Map([['0xa::m::S', 'Struct:a.move:0xa::m::S']]);
const drops: DroppedRef[] = [];
const external = new ExternalMoveSymbols(graph, new Map([['0xa::m', 'Module:a.move:0xa::m']]));
const refs: PendingRef[] = [
{
kind: 'type',
knownNodeId: 'Function:a.move:0xa::m::f',
target: '0xa::m::S',
moduleQualified: '0xa::m',
edgeType: 'USES_TYPE',
reason: MOVE_EDGE_REASON.fnParamType,
},
];
resolveRefs(
graph,
refs,
{
structNodeMap,
structIdsByLocalName: buildLocalNameIndex(structNodeMap),
moduleFileMap: new Map(),
functionNodeMap: new Map(),
},
external,
drops,
);
const edges = [...graph.iterRelationshipsByType('USES_TYPE')];
expect(edges).toHaveLength(1);
expect(graph.getNode('Function:a.move:0xa::m::f')!.properties.usedTypes).toContain('0xa::m::S');
expect(drops).toHaveLength(0);
});
it('drops an ambiguous local-name struct ref without externalizing', () => {
const graph = createKnowledgeGraph();
fnNode(graph, 'Function:a.move:0xa::m::f');
structNode(graph, 'Struct:a.move:0xa::m::S', '0xa::m::S');
structNode(graph, 'Struct:b.move:0xb::n::S', '0xb::n::S');
const structNodeMap = new Map([
['0xa::m::S', 'Struct:a.move:0xa::m::S'],
['0xb::n::S', 'Struct:b.move:0xb::n::S'],
]);
const drops: DroppedRef[] = [];
const external = new ExternalMoveSymbols(graph, new Map());
const refs: PendingRef[] = [
{
kind: 'resource',
knownNodeId: 'Function:a.move:0xa::m::f',
target: 'S',
moduleQualified: '0xc::other',
edgeType: 'READS_RESOURCE',
reason: MOVE_EDGE_REASON.readsResource,
},
];
resolveRefs(
graph,
refs,
{
structNodeMap,
structIdsByLocalName: buildLocalNameIndex(structNodeMap),
moduleFileMap: new Map(),
functionNodeMap: new Map(),
},
external,
drops,
);
expect([...graph.iterRelationshipsByType('READS_RESOURCE')]).toHaveLength(0);
expect(drops).toEqual([
{ kind: 'resource', sourceId: 'Function:a.move:0xa::m::f', target: 'S' },
]);
});
it('externalizes an unresolved qualified type ref', () => {
const graph = createKnowledgeGraph();
fnNode(graph, 'Function:a.move:0xa::m::f');
const drops: DroppedRef[] = [];
const external = new ExternalMoveSymbols(graph, new Map([['0xa::m', 'Module:a.move:0xa::m']]));
const refs: PendingRef[] = [
{
kind: 'type',
knownNodeId: 'Function:a.move:0xa::m::f',
target: '0x1::coin::Coin',
moduleQualified: '0xa::m',
edgeType: 'USES_TYPE',
reason: MOVE_EDGE_REASON.fnParamType,
},
];
resolveRefs(
graph,
refs,
{
structNodeMap: new Map(),
structIdsByLocalName: new Map(),
moduleFileMap: new Map(),
functionNodeMap: new Map(),
},
external,
drops,
);
expect([...graph.iterRelationshipsByType('USES_TYPE')]).toHaveLength(1);
expect(drops).toHaveLength(0);
expect(graph.getNode('Type::<external>:0x1::coin::Coin')).toBeTruthy();
});
it('resolves lambda-host with the resolved host as the edge source (known-target)', () => {
const graph = createKnowledgeGraph();
fnNode(graph, 'Function:a.move:0xa::m::host');
fnNode(graph, 'Function:a.move:0xa::m::__lambda__0__host');
const functionNodeMap = new Map([['0xa::m::host', 'Function:a.move:0xa::m::host']]);
const drops: DroppedRef[] = [];
const external = new ExternalMoveSymbols(graph, new Map());
const refs: PendingRef[] = [
{
kind: 'lambda-host',
knownNodeId: 'Function:a.move:0xa::m::__lambda__0__host',
target: '0xa::m::host',
moduleQualified: '',
edgeType: 'CALLS',
reason: MOVE_EDGE_REASON.lambdaHost,
},
];
resolveRefs(
graph,
refs,
{
structNodeMap: new Map(),
structIdsByLocalName: new Map(),
moduleFileMap: new Map(),
functionNodeMap,
},
external,
drops,
);
const calls = [...graph.iterRelationshipsByType('CALLS')];
expect(calls).toHaveLength(1);
expect(calls[0].sourceId).toBe('Function:a.move:0xa::m::host');
expect(calls[0].targetId).toBe('Function:a.move:0xa::m::__lambda__0__host');
// Behavior-preservation: lambda-host CALLS keeps the legacy 0.9 confidence
// (resource/type/friend use 1.0).
expect(calls[0].confidence).toBe(0.9);
});
});

View file

@ -6,6 +6,8 @@ describe('extractTypeNames', () => {
expect(extractTypeNames('u64')).toEqual([]);
expect(extractTypeNames('address')).toEqual([]);
expect(extractTypeNames('bool')).toEqual([]);
expect(extractTypeNames('i64')).toEqual([]);
expect(extractTypeNames('i256')).toEqual([]);
expect(extractTypeNames('&signer')).toEqual([]);
});
@ -31,4 +33,24 @@ describe('extractTypeNames', () => {
expect(extractTypeNames('vector<u8>')).toEqual([]);
expect(extractTypeNames('Option<Vault>')).toEqual(['Option', 'Vault']);
});
it('handles tuple types without leaking parentheses', () => {
expect(extractTypeNames('(u64, bool)')).toEqual([]);
expect(extractTypeNames('(Coin, u64)')).toEqual(['Coin']);
});
it('handles Move 2 function types without leaking pipes', () => {
expect(extractTypeNames('|u64|u64')).toEqual([]);
expect(extractTypeNames('|Coin|bool')).toEqual(['Coin']);
expect(extractTypeNames('|&mut Vault|u64')).toEqual(['Vault']);
});
it('does not treat function-type abilities as nominal types', () => {
expect(extractTypeNames('|u64|bool has copy + drop')).toEqual([]);
expect(extractTypeNames('|Coin|Vault has store + key')).toEqual(['Coin', 'Vault']);
expect(
extractTypeNames('0x1::table::Table<address, |address, u64|bool has copy + drop + store>'),
).toEqual(['0x1::table::Table']);
expect(extractTypeNames('vector<|Coin|Vault has store + key>')).toEqual(['Coin', 'Vault']);
});
});

View file

@ -7,6 +7,7 @@ import {
FUNCTION_SCHEMA,
MODULE_SCHEMA,
STRUCT_SCHEMA,
TYPE_SCHEMA,
} from '../../src/core/lbug/schema.js';
import {
getNodeTableCsvHeader,
@ -20,6 +21,7 @@ const schemas: Record<LayoutTableName, string> = {
Function: FUNCTION_SCHEMA,
Struct: STRUCT_SCHEMA,
Enum: ENUM_SCHEMA,
Type: TYPE_SCHEMA,
EnumVariant: ENUM_VARIANT_SCHEMA,
Const: CONST_SCHEMA,
Module: MODULE_SCHEMA,

View file

@ -524,6 +524,73 @@ describe('processProcesses', () => {
expect(result.stats.totalProcesses).toBeLessThanOrEqual(3);
});
it('prioritizes explicit graph entry points ahead of the heuristic top-200 budget', async () => {
const graph = createKnowledgeGraph();
const addFunction = (id: string, name: string) =>
graph.addNode({
id,
label: 'Function',
properties: { name, filePath: `src/${name}.move`, isExported: true },
});
addFunction('func:explicit', 'entry_api');
addFunction('func:middle', 'middle');
addFunction('func:end', 'end');
graph.addNode({
id: 'module:root',
label: 'Module',
properties: { name: 'root', filePath: 'src/root.move' },
});
graph.addRelationship({
id: 'entry:explicit',
sourceId: 'func:explicit',
targetId: 'module:root',
type: 'ENTRY_POINT_OF',
confidence: 1,
reason: 'compiler-entry-point',
});
graph.addRelationship({
id: 'call:explicit-middle',
sourceId: 'func:explicit',
targetId: 'func:middle',
type: 'CALLS',
confidence: 1,
reason: 'compiler-call',
});
graph.addRelationship({
id: 'call:middle-end',
sourceId: 'func:middle',
targetId: 'func:end',
type: 'CALLS',
confidence: 1,
reason: 'compiler-call',
});
// More than 200 caller-free heuristic candidates make the explicit root's
// ordinary score too low to survive the legacy global slice.
for (let i = 0; i < 205; i++) {
const id = `func:noise-${i}`;
addFunction(id, `noise_${i}`);
graph.addRelationship({
id: `call:noise-${i}`,
sourceId: id,
targetId: 'func:explicit',
type: 'CALLS',
confidence: 1,
reason: 'compiler-call',
});
}
const result = await processProcesses(graph, [], undefined, {
maxProcesses: 1,
maxTraceDepth: 4,
});
expect(result.processes).toHaveLength(1);
expect(result.processes[0].entryPointId).toBe('func:explicit');
expect(result.processes[0].trace).toEqual(['func:explicit', 'func:middle', 'func:end']);
});
// Regression for #2198: the processesPhase dynamic sizing used to cap at
// Math.min(300, symbolCount/10). On large repos (>3000 symbols) that silently
// truncated the process index. The cap was removed by extracting

View file

@ -266,6 +266,10 @@ describe('runFullAnalysis metadata reconciliation (mocked pipeline)', () => {
repoPath,
totalFileCount: 1,
graph: { forEachNode: () => undefined },
standaloneIngest: {
ingestedFiles: new Set<string>(),
consistencyIssues: [],
},
})),
}));
// Avoid touching the global registry / repo .gitnexusignore from a unit test.

View file

@ -6,6 +6,10 @@ import { createTempDir } from '../helpers/test-db.js';
const SIMULATED_MISSING_FTS_INDEX_NAME = 'File.file_fts';
const PLACEHOLDER_GRAPH_STORE_CONTENT = 'fixture';
const emptyMoveIngestOutput = () => ({
ingestedFiles: new Set<string>(),
consistencyIssues: [],
});
const createPlaceholderGraphStore = async (lbugPath: string): Promise<void> => {
// Repair mode gates on existence before `initLbug` takes over open/validate.
@ -84,6 +88,7 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => {
const runPipelineFromRepo = vi.fn(async (repoPath: string) => ({
repoPath,
graph: { forEachNode: () => undefined },
standaloneIngest: emptyMoveIngestOutput(),
}));
vi.doMock('../../src/core/ingestion/pipeline.js', () => ({
runPipelineFromRepo,
@ -113,6 +118,7 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => {
const runPipelineFromRepo = vi.fn(async (repoPath: string) => ({
repoPath,
graph: { forEachNode: () => undefined },
standaloneIngest: emptyMoveIngestOutput(),
}));
vi.doMock('../../src/core/ingestion/pipeline.js', () => ({
runPipelineFromRepo,
@ -499,8 +505,15 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => {
repoPath,
// Full-analyze path only needs `forEachNode` before the FTS phase.
graph: { forEachNode: () => undefined },
standaloneIngest: emptyMoveIngestOutput(),
})),
}));
// Avoid touching the global registry / repo .gitnexusignore from a unit test.
vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({
...(await importActual<typeof import('../../src/storage/repo-manager.js')>()),
registerRepo: vi.fn(async () => 'verify-fail-repo'),
ensureGitNexusIgnored: vi.fn(async () => undefined),
}));
const tmpRepo = await createTempDir('gitnexus-run-analyze-full-verify-fail-');
try {
@ -564,6 +577,7 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => {
repoPath,
totalFileCount: 1,
graph: { forEachNode: () => undefined },
standaloneIngest: emptyMoveIngestOutput(),
})),
}));
// Avoid touching the global registry / repo .gitnexusignore from a unit test.
@ -635,6 +649,7 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => {
repoPath,
totalFileCount: 1,
graph: { forEachNode: () => undefined },
standaloneIngest: emptyMoveIngestOutput(),
})),
}));
vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({
@ -769,6 +784,7 @@ describe('runFullAnalysis wipe-and-restore vector-index stamp (tri-review 466951
runPipelineFromRepo: vi.fn(async (repoPath: string) => ({
repoPath,
totalFileCount: 1,
standaloneIngest: emptyMoveIngestOutput(),
graph: {
forEachNode: (fn: (node: typeof stubNode) => void) => fn(stubNode),
getNode: (id: string) => (id === RESTORED_NODE_ID ? stubNode : undefined),
@ -871,6 +887,7 @@ describe('runFullAnalysis dirty-recovery parking failure fails fast (this shippi
repoPath,
totalFileCount: 1,
graph: { forEachNode: () => undefined },
standaloneIngest: emptyMoveIngestOutput(),
}));
// Wholesale factory EXCEPT LbugWipeError: run-analyze throws the class it
// imports from this module, and the test asserts on that very type — so

View file

@ -14,6 +14,7 @@ import {
INTERFACE_SCHEMA,
METHOD_SCHEMA,
PROPERTY_SCHEMA,
TYPE_SCHEMA,
CODE_ELEMENT_SCHEMA,
COMMUNITY_SCHEMA,
PROCESS_SCHEMA,
@ -54,6 +55,7 @@ describe('LadybugDB Schema', () => {
'Trait',
'Impl',
'TypeAlias',
'Type',
'Const',
'Static',
'Variable',
@ -75,8 +77,8 @@ describe('LadybugDB Schema', () => {
});
it('has expected total count', () => {
// 9 core + 20 multi-language/Move + Route + Tool + BasicBlock = 33
expect(NODE_TABLES).toHaveLength(33);
// 10 core (including Section) + 21 multi-language/Move + Route + Tool + BasicBlock = 34
expect(NODE_TABLES).toHaveLength(34);
});
});
@ -151,6 +153,13 @@ describe('LadybugDB Schema', () => {
expect(PROPERTY_SCHEMA).toContain('declaredType STRING');
});
it('Type schema preserves Move dependency identity and location fidelity', () => {
expect(SCHEMA_QUERIES).toContain(TYPE_SCHEMA);
expect(TYPE_SCHEMA).toContain('qualifiedName STRING');
expect(TYPE_SCHEMA).toContain('moduleQualifiedName STRING');
expect(TYPE_SCHEMA).toContain('locationFidelity STRING');
});
it('BasicBlock schema is wired into SCHEMA_QUERIES (issue #2080, F1 guard)', () => {
// Defining BASICBLOCK_SCHEMA is not enough — it must be appended to
// NODE_SCHEMA_QUERIES (→ SCHEMA_QUERIES) or initLbug never creates the
@ -205,6 +214,16 @@ describe('LadybugDB Schema', () => {
expect(RELATION_SCHEMA).toContain('FROM Method TO Process');
});
it('persists Move enum fields and signature/field type targets', () => {
expect(RELATION_SCHEMA).toContain('FROM `EnumVariant` TO `Property`');
expect(RELATION_SCHEMA).toContain('FROM Function TO `Type`');
expect(RELATION_SCHEMA).toContain('FROM `Module` TO `Type`');
expect(RELATION_SCHEMA).toContain('FROM `Struct` TO `Type`');
expect(RELATION_SCHEMA).toContain('FROM `Property` TO `Struct`');
expect(RELATION_SCHEMA).toContain('FROM `Property` TO `Enum`');
expect(RELATION_SCHEMA).toContain('FROM `Property` TO `Type`');
});
it('connects BasicBlock to BasicBlock (taint/PDG substrate edges, #2080)', () => {
expect(RELATION_SCHEMA).toContain('FROM BasicBlock TO BasicBlock');
});
@ -253,8 +272,8 @@ describe('LadybugDB Schema', () => {
describe('schema query ordering', () => {
it('NODE_SCHEMA_QUERIES has correct count', () => {
// 31 + EnumVariant + BasicBlock = 33
expect(NODE_SCHEMA_QUERIES).toHaveLength(33);
// 31 + EnumVariant + BasicBlock + Type = 34
expect(NODE_SCHEMA_QUERIES).toHaveLength(34);
});
it('REL_SCHEMA_QUERIES has one relation table', () => {
@ -262,8 +281,8 @@ describe('LadybugDB Schema', () => {
});
it('SCHEMA_QUERIES includes all node + rel + embedding schemas', () => {
// 33 node + 1 rel + 1 embedding = 35
expect(SCHEMA_QUERIES).toHaveLength(35);
// 34 node + 1 rel + 1 embedding = 36
expect(SCHEMA_QUERIES).toHaveLength(36);
});
it('node schemas come before relation schemas in SCHEMA_QUERIES', () => {

View file

@ -17,7 +17,10 @@ import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js';
import { buildGraphNodeLookup } from '../../../src/core/ingestion/scope-resolution/graph-bridge/node-lookup.js';
import { createCalleeIdAccumulator } from '../../../src/core/ingestion/scope-resolution/graph-bridge/callee-id-sink.js';
import { emitCallableValueFlow } from '../../../src/core/ingestion/scope-resolution/passes/callable-value-flow.js';
import {
aggregateCallableValueFlowWarnings,
emitCallableValueFlow,
} from '../../../src/core/ingestion/scope-resolution/passes/callable-value-flow.js';
const FILE = 'chain.ts';
const MODULE = 'scope:module' as ScopeId;
@ -199,4 +202,39 @@ describe('callable-value-flow dependency worklist', () => {
),
).toEqual(['target']);
});
it('groups repeated internal overflows by causal source context', () => {
expect(
aggregateCallableValueFlowWarnings([
{
language: 'javascript',
context: 'site:bundle.js:4:1933',
candidateCount: 33,
cap: 32,
},
{
language: 'javascript',
context: 'site:bundle.js:4:1933',
candidateCount: 40,
cap: 32,
},
{
language: 'javascript',
context: 'copy:bundle.js',
candidateCount: 33,
cap: 32,
},
]),
).toEqual([
{
language: 'javascript',
context: 'site:bundle.js:4:1933',
candidateCount: 40,
cap: 32,
occurrences: 3,
distinctContexts: 2,
contextSamples: ['site:bundle.js:4:1933', 'copy:bundle.js'],
},
]);
});
});

View file

@ -1,12 +1,15 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import type { ParsedFile, ScopeId, Scope } from 'gitnexus-shared';
import {
formatScopeResolutionWarningContext,
MAX_PROGRESS_WARNING_CONTEXT_CHARS,
runScopeResolution,
type ScopeResolutionSubPhase,
} from '../../../src/core/ingestion/scope-resolution/pipeline/run.js';
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js';
import type { ScopeResolver } from '../../../src/core/ingestion/scope-resolution/contract/scope-resolver.js';
import { _captureLogger, warnRespectingProgressBar } from '../../../src/core/logger.js';
const mkScope = (id: ScopeId, filePath: string): Scope => ({
id,
@ -41,6 +44,37 @@ const stubProvider = {
} as unknown as ScopeResolver;
describe('runScopeResolution onProgress', () => {
it('escapes control bytes and bounds progress warning contexts', () => {
const formatted = formatScopeResolutionWarningContext(`binding\0${'x'.repeat(200)}`);
expect(formatted).not.toContain('\0');
expect(formatted).toContain('\\u0000');
expect(formatted).toHaveLength(MAX_PROGRESS_WARNING_CONTEXT_CHARS);
expect(formatted.endsWith('...')).toBe(true);
});
it('routes warnings through the analyze progress logger instead of Pino', () => {
const previous = process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE;
const capture = _captureLogger();
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE = '1';
warnRespectingProgressBar('progress-safe warning', {
fields: { language: 'move', occurrences: 2 },
message: 'structured warning',
});
expect(consoleWarn).toHaveBeenCalledOnce();
expect(consoleWarn).toHaveBeenCalledWith('progress-safe warning');
expect(capture.records()).toEqual([]);
} finally {
consoleWarn.mockRestore();
capture.restore();
if (previous === undefined) delete process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE;
else process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE = previous;
}
});
it('emits sub-phases in order for a 3-file input', () => {
const files = [
{ path: 'a.py', content: '' },