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) # Machine-local skill-evolution evidence (consumed by eval/workflow_bench/evolve.py)
eval/workflow_bench/learnings.jsonl 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 * Node-location precision. `'precise'` = per-symbol file/span from the
* move-flow `facts` query; `'module'` = only the containing module/type file * 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. // BasicBlock (taint/PDG substrate, issue #2080) — reuses filePath/startLine/endLine.
text?: string; text?: string;
/** BasicBlock: space-joined leaf callee names invoked in the block — the /** BasicBlock: space-joined leaf callee names invoked in the block — the

View file

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

View file

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

View file

@ -9,6 +9,9 @@
* - Every graph-wide node (Community, Process) — these are regenerated * - Every graph-wide node (Community, Process) — these are regenerated
* each run by the communities/processes phases and must be fully * each run by the communities/processes phases and must be fully
* rewritten. * 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 * - Every relationship where AT LEAST ONE endpoint is in the writable
* set above. Relationships entirely between unchanged-file nodes * set above. Relationships entirely between unchanged-file nodes
* are skipped — their rows are still in the DB and re-inserting * 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'; 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 * Relationship types whose VALIDITY is a whole-program property, not a
* function of their endpoints' files (#2084 M4 U6). `TAINT_PATH` (cross- * function of their endpoints' files (#2084 M4 U6). `TAINT_PATH` (cross-
@ -106,7 +123,8 @@ export const extractChangedSubgraph = (
fullGraph.forEachNode((n: GraphNode) => { fullGraph.forEachNode((n: GraphNode) => {
const filePath = n.properties?.filePath as string | undefined; 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) { if (include) {
sub.addNode(n); sub.addNode(n);
writableNodeIds.add(n.id); writableNodeIds.add(n.id);

View file

@ -5,7 +5,7 @@ import path from 'path';
import { glob } from 'glob'; import { glob } from 'glob';
import { createIgnoreFilter } from '../../config/ignore-service.js'; 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 */ /** Lightweight entry — path + size from stat, no content in memory */
export interface ScannedFile { export interface ScannedFile {
@ -19,21 +19,8 @@ export interface FilePath {
} }
const READ_CONCURRENCY = 32; const READ_CONCURRENCY = 32;
const ANALYZE_PROGRESS_ACTIVE_ENV = 'GITNEXUS_ANALYZE_PROGRESS_ACTIVE';
const warnLargeFileSkip = (message: string): void => { const warnLargeFileSkip = (message: string): void => warnRespectingProgressBar(message);
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);
};
/** /**
* Phase 1: Scan repository — stat files to get paths + sizes, no content loaded. * Phase 1: Scan repository — stat files to get paths + sizes, no content loaded.

View file

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

View file

@ -2,7 +2,8 @@
* Process Detection Processor * Process Detection Processor
* *
* Detects execution flows (Processes) in the code graph by: * 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) * 2. Tracing forward via CALLS edges (BFS)
* 3. Grouping and deduplicating similar paths * 3. Grouping and deduplicating similar paths
* 4. Labeling with heuristic names * 4. Labeling with heuristic names
@ -10,7 +11,7 @@
* Processes help agents understand how features work through the codebase. * 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 { KnowledgeGraph } from '../graph/types.js';
import { CommunityMembership } from './community-processor.js'; import { CommunityMembership } from './community-processor.js';
import { calculateEntryPointScore, isTestFile } from './entry-point-scoring.js'; import { calculateEntryPointScore, isTestFile } from './entry-point-scoring.js';
@ -24,9 +25,9 @@ import { logger } from '../logger.js';
export interface ProcessDetectionConfig { export interface ProcessDetectionConfig {
maxTraceDepth: number; // Maximum steps to trace (default: 10) maxTraceDepth: number; // Maximum steps to trace (default: 10)
maxBranching: number; // Max branches to follow per node (default: 3) maxBranching: number; // Max branches to follow per node (default: 4)
maxProcesses: number; // Maximum processes to detect (default: 50) maxProcesses: number; // Maximum processes to detect (default: 75)
minSteps: number; // Minimum steps for a valid process (default: 2) minSteps: number; // Minimum steps for a valid process (default: 3)
} }
const DEFAULT_CONFIG: ProcessDetectionConfig = { const DEFAULT_CONFIG: ProcessDetectionConfig = {
@ -92,15 +93,17 @@ export const processProcesses = async (
const membershipMap = new Map<string, string>(); const membershipMap = new Map<string, string>();
memberships.forEach((m) => membershipMap.set(m.nodeId, m.communityId)); memberships.forEach((m) => membershipMap.set(m.nodeId, m.communityId));
const callsEdges = buildCallsGraph(knowledgeGraph); const { forward: callsEdges, reverse: reverseCallsEdges } = buildCallsAdjacency(knowledgeGraph);
const reverseCallsEdges = buildReverseCallsGraph(knowledgeGraph);
const nodeMap = new Map<string, GraphNode>();
for (const n of knowledgeGraph.iterNodes()) nodeMap.set(n.id, n);
// Step 1: Find entry points (functions that call others but have few callers) // Step 1: Find entry points — explicit ENTRY_POINT_OF graph roots plus
const entryPoints = findEntryPoints(knowledgeGraph, reverseCallsEdges, callsEdges); // heuristic ones (functions that call others but have few callers).
const explicitEntryPointIds = collectExplicitEntryPointIds(knowledgeGraph);
onProgress?.(`Found ${entryPoints.length} entry points, tracing flows...`, 20); const entryPoints = findEntryPoints(
knowledgeGraph,
reverseCallsEdges,
callsEdges,
explicitEntryPointIds,
);
onProgress?.(`Found ${entryPoints.length} entry points, tracing flows...`, 20); onProgress?.(`Found ${entryPoints.length} entry points, tracing flows...`, 20);
@ -125,7 +128,7 @@ export const processProcesses = async (
onProgress?.(`Found ${allTraces.length} traces, deduplicating...`, 60); onProgress?.(`Found ${allTraces.length} traces, deduplicating...`, 60);
// Step 3: Deduplicate similar traces (subset removal) // 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) // Step 3b: Deduplicate by entry+terminal pair (keep longest path per pair)
const endpointDeduped = deduplicateByEndpoints(uniqueTraces); const endpointDeduped = deduplicateByEndpoints(uniqueTraces);
@ -135,9 +138,15 @@ export const processProcesses = async (
70, 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 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); .slice(0, cfg.maxProcesses);
onProgress?.(`Creating ${limitedTraces.length} process nodes...`, 80); onProgress?.(`Creating ${limitedTraces.length} process nodes...`, 80);
@ -163,8 +172,8 @@ export const processProcesses = async (
communities.length > 1 ? 'cross_community' : 'intra_community'; communities.length > 1 ? 'cross_community' : 'intra_community';
// Generate label // Generate label
const entryNode = nodeMap.get(entryPointId); const entryNode = knowledgeGraph.getNode(entryPointId);
const terminalNode = nodeMap.get(terminalId); const terminalNode = knowledgeGraph.getNode(terminalId);
const entryName = entryNode?.properties.name || 'Unknown'; const entryName = entryNode?.properties.name || 'Unknown';
const terminalName = terminalNode?.properties.name || 'Unknown'; const terminalName = terminalNode?.properties.name || 'Unknown';
const heuristicLabel = `${capitalize(entryName)} → ${capitalize(terminalName)}`; const heuristicLabel = `${capitalize(entryName)} → ${capitalize(terminalName)}`;
@ -227,35 +236,51 @@ type AdjacencyList = Map<string, string[]>;
*/ */
const MIN_TRACE_CONFIDENCE = 0.5; const MIN_TRACE_CONFIDENCE = 0.5;
const buildCallsGraph = (graph: KnowledgeGraph): AdjacencyList => { /** Build forward and reverse CALLS adjacency lists in one relationship scan. */
const adj = new Map<string, string[]>(); 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()) { for (const rel of graph.iterRelationships()) {
if (rel.type === 'CALLS' && rel.confidence >= MIN_TRACE_CONFIDENCE) { if (rel.type === 'CALLS' && rel.confidence >= MIN_TRACE_CONFIDENCE) {
if (!adj.has(rel.sourceId)) { push(forward, rel.sourceId, rel.targetId);
adj.set(rel.sourceId, []); push(reverse, rel.targetId, rel.sourceId);
}
adj.get(rel.sourceId)!.push(rel.targetId);
} }
} }
return adj; return { forward, reverse };
}; };
const buildReverseCallsGraph = (graph: KnowledgeGraph): AdjacencyList => { /** IDs of nodes carrying a pre-phase ENTRY_POINT_OF edge (compiler-backed
const adj = new Map<string, string[]>(); * 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;
}
for (const rel of graph.iterRelationships()) { /** Global cap on heuristic (non-explicit) entry points, to prevent explosion
if (rel.type === 'CALLS' && rel.confidence >= MIN_TRACE_CONFIDENCE) { * on large polyglot repos. Explicit graph roots are exempt and counted first. */
if (!adj.has(rel.targetId)) { const HEURISTIC_ENTRY_POINT_BUDGET = 200;
adj.set(rel.targetId, []);
}
adj.get(rel.targetId)!.push(rel.sourceId);
}
}
return adj; /** 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. * Find functions/methods that are good entry points for tracing.
@ -271,12 +296,20 @@ const findEntryPoints = (
graph: KnowledgeGraph, graph: KnowledgeGraph,
reverseCallsEdges: AdjacencyList, reverseCallsEdges: AdjacencyList,
callsEdges: AdjacencyList, callsEdges: AdjacencyList,
explicitEntryPointIds: ReadonlySet<string>,
): string[] => { ): string[] => {
const symbolTypes = new Set<NodeLabel>(['Function', 'Method']); 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: { const entryPointCandidates: {
id: string; id: string;
score: number; score: number;
reasons: string[]; reasons: string[];
explicit: boolean;
}[] = []; }[] = [];
for (const node of graph.iterNodes()) { for (const node of graph.iterNodes()) {
@ -304,19 +337,26 @@ const findEntryPoints = (
); );
let score = baseScore; let score = baseScore;
const explicit = explicitEntryPointIds.has(node.id);
if (explicit) reasons.push('graph-entry-point');
const astFrameworkMultiplier = node.properties.astFrameworkMultiplier ?? 1.0; const astFrameworkMultiplier = node.properties.astFrameworkMultiplier ?? 1.0;
if (astFrameworkMultiplier > 1.0) { if (astFrameworkMultiplier > 1.0) {
score *= astFrameworkMultiplier; score *= astFrameworkMultiplier;
reasons.push(`framework-ast:${node.properties.astFrameworkReason || 'decorator'}`); reasons.push(`framework-ast:${node.properties.astFrameworkReason || 'decorator'}`);
} }
if (score > 0) { if (explicit || score > 0) {
entryPointCandidates.push({ id: node.id, score, reasons }); entryPointCandidates.push({ id: node.id, score, reasons, explicit });
} }
} }
// Sort by score descending and return top candidates // Known graph entry points form a priority tier ahead of heuristic scoring.
const sorted = entryPointCandidates.sort((a, b) => b.score - a.score); const sorted = entryPointCandidates.sort(
explicitFirst(
(candidate) => candidate.explicit,
(a, b) => b.score - a.score,
),
);
// DEBUG: Log top candidates with new scoring details // DEBUG: Log top candidates with new scoring details
if (sorted.length > 0 && isDev) { if (sorted.length > 0 && isDev) {
@ -330,9 +370,11 @@ const findEntryPoints = (
}); });
} }
return sorted const explicit = sorted.filter((candidate) => candidate.explicit);
.slice(0, 200) // Limit to prevent explosion const heuristic = sorted
.map((c) => c.id); .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. * Merge traces that are subsets of other traces.
* Keep longer traces, remove redundant shorter ones. * 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 []; if (traces.length === 0) return [];
// Sort by length descending // Sort by length descending
const sorted = [...traces].sort((a, b) => b.length - a.length); const sorted = [...traces].sort((a, b) => b.length - a.length);
const unique: string[][] = []; 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) { for (const trace of sorted) {
// Check if this trace is a subset of any already-added trace
const traceKey = trace.join('->'); const traceKey = trace.join('->');
const isSubset = unique.some((existing) => { // Explicit entry points dedupe on exact equality; heuristic traces are
const existingKey = existing.join('->'); // dropped when a longer kept trace already contains them as a substring.
return existingKey.includes(traceKey); const exactOnly = explicitEntryPointIds.has(trace[0]);
}); const isRedundant = uniqueKeys.some((existingKey) =>
exactOnly ? existingKey === traceKey : existingKey.includes(traceKey),
);
if (!isSubset) { if (!isRedundant) {
unique.push(trace); unique.push(trace);
uniqueKeys.push(traceKey);
} }
} }

View file

@ -30,6 +30,10 @@ interface Target {
readonly def: SymbolDefinition; 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 { interface FileFact {
readonly filePath: string; readonly filePath: string;
readonly site: CallableFlowSite; readonly site: CallableFlowSite;
@ -40,13 +44,22 @@ interface FileInvoke {
readonly site: CallableFlowInvokeSite; readonly site: CallableFlowInvokeSite;
} }
export interface CallableValueFlowWarning { interface RawCallableValueFlowWarning {
readonly language: string; readonly language: string;
readonly context: string; readonly context: string;
readonly candidateCount: number; readonly candidateCount: number;
readonly cap: 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 { export interface CallableValueFlowResult {
readonly emitted: number; readonly emitted: number;
readonly resolvedInvokes: number; readonly resolvedInvokes: number;
@ -55,6 +68,46 @@ export interface CallableValueFlowResult {
readonly iterations: number; 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 { export interface EmitCallableValueFlowInput {
readonly graph: KnowledgeGraph; readonly graph: KnowledgeGraph;
readonly scopes: ScopeResolutionIndexes; readonly scopes: ScopeResolutionIndexes;
@ -66,6 +119,12 @@ export interface EmitCallableValueFlowInput {
readonly isCallableValueTarget?: (def: SymbolDefinition) => boolean; readonly isCallableValueTarget?: (def: SymbolDefinition) => boolean;
readonly hasFileLocalCallableLinkage?: (def: SymbolDefinition) => boolean; readonly hasFileLocalCallableLinkage?: (def: SymbolDefinition) => boolean;
readonly onWarn?: (warning: CallableValueFlowWarning) => void; 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. */ /** Position key shared with the existing free/reference skip-set contract. */
@ -76,6 +135,34 @@ export function callableFlowSiteKey(
return `${filePath}:${range.startLine}:${range.startCol}`; 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. * Return only invoke sites that join to a canonical call ReferenceSite.
* Malformed/stale facts never suppress ordinary resolution. * Malformed/stale facts never suppress ordinary resolution.
@ -140,7 +227,8 @@ function flowCellOperand(site: CallableFlowSite): CallableFlowOperand | undefine
export function emitCallableValueFlow(input: EmitCallableValueFlowInput): CallableValueFlowResult { export function emitCallableValueFlow(input: EmitCallableValueFlowInput): CallableValueFlowResult {
const facts: FileFact[] = []; const facts: FileFact[] = [];
const invokes: FileInvoke[] = []; const invokes: FileInvoke[] = [];
const canonicalInvokeKeys = collectDeferredIndirectSites(input.parsedFiles, input.scopes); const canonicalInvokeKeys =
input.canonicalInvokeKeys ?? collectDeferredIndirectSites(input.parsedFiles, input.scopes);
let unmatchedInvokes = 0; let unmatchedInvokes = 0;
for (const parsed of input.parsedFiles) { for (const parsed of input.parsedFiles) {
for (const site of parsed.callableFlowSites ?? []) { for (const site of parsed.callableFlowSites ?? []) {
@ -161,7 +249,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
const addressesByBinding = new Map<string, Set<string>>(); const addressesByBinding = new Map<string, Set<string>>();
const overflowedTargets = new Set<string>(); const overflowedTargets = new Set<string>();
const overflowedAddresses = new Set<string>(); const overflowedAddresses = new Set<string>();
const overflowWarnings = new Map<string, CallableValueFlowWarning>(); const overflowWarnings = new Map<string, RawCallableValueFlowWarning>();
const rawGraphTargets = buildGraphTargetIndex( const rawGraphTargets = buildGraphTargetIndex(
input.scopes, input.scopes,
input.nodeLookup, input.nodeLookup,
@ -311,7 +399,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
): { readonly targets: ReadonlyMap<string, Target>; readonly overflow: boolean } => { ): { readonly targets: ReadonlyMap<string, Target>; readonly overflow: boolean } => {
watch('target', key); watch('target', key);
return { return {
targets: targetsByBinding.get(key) ?? new Map(), targets: targetsByBinding.get(key) ?? EMPTY_TARGETS,
overflow: overflowedTargets.has(key), overflow: overflowedTargets.has(key),
}; };
}; };
@ -321,7 +409,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
): { readonly cells: ReadonlySet<string>; readonly overflow: boolean } => { ): { readonly cells: ReadonlySet<string>; readonly overflow: boolean } => {
watch('address', key); watch('address', key);
return { return {
cells: addressesByBinding.get(key) ?? new Set(), cells: addressesByBinding.get(key) ?? EMPTY_CELLS,
overflow: overflowedAddresses.has(key), overflow: overflowedAddresses.has(key),
}; };
}; };
@ -476,10 +564,10 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
for (const fact of facts) { for (const fact of facts) {
const site = fact.site; const site = fact.site;
const context = `${fact.site.kind}:${fact.filePath}`;
switch (site.kind) { switch (site.kind) {
case 'copy': case 'copy':
case 'alias': { case 'alias': {
const context = warningContext(fact.filePath, site);
addWorkItem(() => { addWorkItem(() => {
const source = bindingKey(fact.filePath, site.source); const source = bindingKey(fact.filePath, site.source);
const destination = bindingKey(fact.filePath, site.destination); const destination = bindingKey(fact.filePath, site.destination);
@ -493,6 +581,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
break; break;
} }
case 'load': { case 'load': {
const context = warningContext(fact.filePath, site);
addWorkItem(() => { addWorkItem(() => {
const destination = bindingKey(fact.filePath, site.destination); const destination = bindingKey(fact.filePath, site.destination);
const reached = reachedCells(fact.filePath, site.pointer); const reached = reachedCells(fact.filePath, site.pointer);
@ -509,6 +598,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
break; break;
} }
case 'store': { case 'store': {
const context = warningContext(fact.filePath, site);
addWorkItem(() => { addWorkItem(() => {
const sourceTargets = operandTargets(fact.filePath, site.source); const sourceTargets = operandTargets(fact.filePath, site.source);
const reached = reachedCells(fact.filePath, site.pointer); const reached = reachedCells(fact.filePath, site.pointer);
@ -585,7 +675,11 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
targetIds.add(id); targetIds.add(id);
} }
for (const id of dynamicCallees.get(callKey)?.keys() ?? []) 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) { if (!hasIndexedFormal && site.directCalleeName !== undefined) {
for (const target of resolveSeedCandidates( for (const target of resolveSeedCandidates(
fact.filePath, fact.filePath,
@ -601,7 +695,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
)) { )) {
targetIds.add(target.id); targetIds.add(target.id);
} }
hasIndexedFormal = [...targetIds].some((id) => indexedFormals(id).length > 0); hasIndexedFormal = hasAnyIndexedFormal();
} }
const history = dynamicTargetHistory.get(callKey); const history = dynamicTargetHistory.get(callKey);
const callOverflow = 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 // No partial graph output when a hostile/corrupt fact graph exhausts the
// bounded work budget. The caller receives a warning; NOTE this is not // bounded work budget. The caller receives a warning; NOTE this is not

View file

@ -82,6 +82,7 @@ import {
callableFlowSiteKey, callableFlowSiteKey,
collectDeferredIndirectSites, collectDeferredIndirectSites,
emitCallableValueFlow, emitCallableValueFlow,
type CallableValueFlowWarning,
} from '../passes/callable-value-flow.js'; } from '../passes/callable-value-flow.js';
import type { ScopeResolver } from '../contract/scope-resolver.js'; import type { ScopeResolver } from '../contract/scope-resolver.js';
import { findEnclosingClassDef, resolveInheritanceBaseInScope } from '../scope/walkers.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 { TransitionalScopeTree } from '../../../../storage/scope-index-store.js';
import { forceGc } from '../../../../storage/parsedfile-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 * 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 // Never drop dispatch coverage silently: a hook table larger than the
// fan-out cap means member calls through those keys get no synthesized // fan-out cap means member calls through those keys get no synthesized
// CALLS — the #2437 false-safe gap reappears for exactly those keys. // 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,
),
{ {
fields: {
lang: provider.language, lang: provider.language,
skippedKeys: propertyDispatch.skippedKeys, skippedKeys: propertyDispatch.skippedKeys,
skippedKeyNames: propertyDispatch.skippedKeyNames, skippedKeyNames: propertyDispatch.skippedKeyNames,
fanoutCap: MAX_PROPERTY_DISPATCH_FANOUT, 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 = const callableValueFlow =
@ -904,15 +944,17 @@ export function runScopeResolution(
parsedFiles: emitParsedFiles, parsedFiles: emitParsedFiles,
nodeLookup: postHeritageNodeLookup, nodeLookup: postHeritageNodeLookup,
calleeIds: calleeIdAccumulator, calleeIds: calleeIdAccumulator,
canonicalInvokeKeys: deferredIndirectSites,
language: provider.language, language: provider.language,
collapseByCallerTarget: provider.collapseMemberCallsByCallerTarget === true, collapseByCallerTarget: provider.collapseMemberCallsByCallerTarget === true,
isCallableValueTarget: provider.isCallableValueTarget, isCallableValueTarget: provider.isCallableValueTarget,
hasFileLocalCallableLinkage: provider.hasFileLocalCallableLinkage, hasFileLocalCallableLinkage: provider.hasFileLocalCallableLinkage,
onWarn: (warning) => onWarn: (warning) =>
logger.warn( warnRespectingProgressBar(formatCallableValueFlowProgress(warning), {
warning, fields: warning,
'callable-value-flow: candidate set exceeded the cap; no partial CALLS emitted', message:
), 'callable-value-flow: candidate set exceeded the cap; grouped occurrences emitted no partial CALLS',
}),
}); });
const importsEmitted = callableFlowOnly const importsEmitted = callableFlowOnly
? 0 ? 0

View file

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

View file

@ -1255,6 +1255,7 @@ const BACKTICK_TABLES = new Set([
'Trait', 'Trait',
'Impl', 'Impl',
'TypeAlias', 'TypeAlias',
'Type',
'Const', 'Const',
'Static', 'Static',
'Property', 'Property',
@ -2281,12 +2282,7 @@ export const deleteNodesForFile = async (
/** /**
* Chunk size for {@link deleteNodesForFiles}. 200 paths keeps each * Chunk size for {@link deleteNodesForFiles}. 200 paths keeps each
* statement ~13KB (well inside parser limits) while a ~700-file write set * statement ~13KB while batching one delete per file-backed table.
* 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).
*/ */
export const DELETE_FILES_CHUNK_SIZE = 200; 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 * binder error on the embedding join-delete: a DB created without
* EMBEDDING_SCHEMA cannot own embedding rows, so skipping that one * EMBEDDING_SCHEMA cannot own embedding rows, so skipping that one
* statement is sound, while failing would brick every incremental run on * statement is sound, while failing would brick every incremental run on
* such a DB until `--force`. Statement count per chunk is unchanged by the * such a DB until `--force`.
* multi-label join: 1 embedding join-delete + 30 node-table deletes = 31
* (the rejected per-label fallback shape would have been 19 + 30 = 49).
* Singleton-connection only: the analyze writeback owns the write lock, * Singleton-connection only: the analyze writeback owns the write lock,
* and `queryAndDrain` routes through `withConnLock` for it (the WAL * and `queryAndDrain` routes through `withConnLock` for it (the WAL
* checkpoint driver is live during this). * checkpoint driver is live during this).
@ -2501,34 +2495,40 @@ export const queryImportersBatch = async (
}; };
/** /**
* Drop every Community and Process node (and their MEMBER_OF / * Shared mechanics for the delete-all-nodes-by-label family
* STEP_IN_PROCESS edges via DETACH DELETE). Used at the start of an * ({@link deleteAllCommunitiesAndProcesses}, {@link deleteAllExternalNodes}).
* incremental run so the communities and processes phases regenerate * For each label: count matching nodes, then `DETACH DELETE` them if any.
* them from scratch on the merged graph — required for the * The whole loop runs inside `withConnLock` so it cannot execute concurrently
* "Leiden runs on the FULL graph" correctness invariant. * 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<{ const deleteAllNodesByLabels = async (
nodesDeleted: number; labels: readonly string[],
}> => { filter = '',
): Promise<{ nodesDeleted: number }> => {
const c = conn; const c = conn;
if (!c) { if (!c) {
throw new Error('LadybugDB not initialized. Call initLbug first.'); throw new Error('LadybugDB not initialized. Call initLbug first.');
} }
// count + DETACH DELETE run inside the connection lock so they cannot execute const clause = filter ? ` ${filter}` : '';
// 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.
return withConnLock(async () => { return withConnLock(async () => {
let nodesDeleted = 0; let nodesDeleted = 0;
for (const label of ['Community', 'Process']) { for (const label of labels) {
let countResult: lbug.QueryResult | lbug.QueryResult[] | undefined; let countResult: lbug.QueryResult | lbug.QueryResult[] | undefined;
try { 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 result = Array.isArray(countResult) ? countResult[0] : countResult;
const rows = await result.getAll(); const rows = await result.getAll();
const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0); const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0);
if (count > 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; nodesDeleted += count;
} }
} catch { } 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 * Shared mechanics for the delete-all-relationships-of-one-type family
* ({@link deleteAllInterprocTaintPaths}, {@link deleteAllCallSummaries}, * ({@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 = { const ENUM_VARIANT_LAYOUT: NodeTableLayout = {
table: 'EnumVariant', table: 'EnumVariant',
quoteTableName: true, quoteTableName: true,
@ -141,6 +153,7 @@ export const NODE_TABLE_LAYOUTS = {
Function: FUNCTION_LAYOUT, Function: FUNCTION_LAYOUT,
Struct: structLikeLayout('Struct'), Struct: structLikeLayout('Struct'),
Enum: structLikeLayout('Enum'), Enum: structLikeLayout('Enum'),
Type: TYPE_LAYOUT,
EnumVariant: ENUM_VARIANT_LAYOUT, EnumVariant: ENUM_VARIANT_LAYOUT,
Const: CONST_LAYOUT, Const: CONST_LAYOUT,
Module: MODULE_LAYOUT, Module: MODULE_LAYOUT,

View file

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

View file

@ -286,6 +286,34 @@ export const logger = new Proxy({} as Logger, {
}, },
}) 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 * Shape of a parsed pino record. `level`, `time`, and `msg` are always
* present; `name` is set when emitted from a named child logger; arbitrary * present; `name` is set when emitted from a named child logger; arbitrary

View file

@ -1,62 +1,37 @@
# Move compiler integration # Move compiler integration
This directory implements GitNexus's compiler-first ingestion for Move packages. GitNexus's compiler-first ingestion for Move packages. Packages are discovered
GitNexus discovers packages from `Move.toml`, communicates with `move-flow` over from `Move.toml`, queried live over MCP via `move-flow` (`facts` + `call_graph`,
MCP, and projects compiler facts into the standard GitNexus knowledge graph. never persisted or replayed), and projected into the standard 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.
```text ```text
Move package Move package
-> move-flow MCP -> move-flow MCP (facts + call_graph)
-> compiler facts and call graph -> Pass A: per-package nodes + deferred PendingRefs
-> global cross-package index
-> Pass B: resolve refs + synthesize external symbols
-> GitNexus nodes and relationships -> GitNexus nodes and relationships
-> consistency validation -> 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 A package move-flow cannot build is skipped with an operator-actionable warning
client contract consumed by ingestion. rather than aborting the whole analyze (`_`-placeholder addresses are caught
- `compiler-facts.ts` defines the normalized compiler response shapes used by pre-flight; builds that compile with errors are ingested but flagged as
downstream projections. degraded). Compiler identity is recorded in the repository metadata; changing it
- `move-ingest.ts` implements the standalone ingestion phase, including package forces a full rebuild. Env knobs: `GITNEXUS_MOVE_FLOW_TIMEOUT_MS`,
discovery, compiler queries, and cross-package resolution. `GITNEXUS_MOVE_FLOW_CONCURRENCY` (supplemental `function_usage` fan-out, default
- `facts-mapper.ts` maps compiler facts to deterministic GitNexus nodes and `4`), `GITNEXUS_MOVE_STRICT=1` (restore fatal-on-build-failure),
relationships. `GITNEXUS_SKIP_MOVE_FLOW=1`, and `MOVE_FLOW` (see `provision.ts`).
- `consistency.ts` validates the resulting graph and reports incomplete or
malformed compiler evidence.
## Upstream references ## Upstream references

View file

@ -15,6 +15,12 @@ export interface MoveFlowConstant {
export type CallGraphMap = Record<string, string[]>; 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. // 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 { KnowledgeGraph } from '../graph/types.js';
import type { MovePackageStatus } from './mcp-client.js'; import type { MovePackageStatus } from './mcp-client.js';
import type { MoveIngestOutput } from './move-ingest.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'; export type MoveConsistencySeverity = 'warning' | 'error';
@ -11,6 +12,16 @@ export interface MoveConsistencyIssue {
| 'missing-owned-callee' | 'missing-owned-callee'
| 'malformed-source-evidence' | 'malformed-source-evidence'
| 'unresolved-resource-target' | '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 /** Package with .move sources returned facts `{}` - severity policy in
* `emptyFactsIssue` below. */ * `emptyFactsIssue` below. */
| 'empty-package-facts' | 'empty-package-facts'
@ -35,6 +46,53 @@ export interface EmptyFactsPackage {
status: MovePackageStatus | null; 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 * Severity policy for a package whose facts came back `{}` despite .move
* sources: compiling -> warning (likely test-only, elided from facts), failing * 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 [packageRoot, callGraph] of moveIngest.callGraphByPackage) {
for (const [callerQualified, callees] of Object.entries(callGraph)) { for (const [callerQualified, callees] of Object.entries(callGraph)) {
const callerModule = moveModuleQualifiedName(callerQualified); const callerModule = moveModuleQualifiedName(callerQualified);
@ -233,15 +294,150 @@ export function validateMoveIngestOutput(
} }
} }
const dropped = moveIngest.droppedResourceRefs; pushGroupedDrops(issues, moveIngest.droppedRefs);
if (dropped && dropped.length > 0) { pushDroppedIssue(
issues.push({ issues,
code: 'unresolved-resource-target', moveIngest.functionUsageFailures,
severity: 'warning', 'function-usage-query-failed',
message: `${dropped.length} resource read/write/acquires target(s) could not be resolved.`, 'supplemental function-usage query(s) failed',
details: { count: dropped.length, sample: dropped.slice(0, 5) }, );
});
}
return issues; 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', definesStruct: 'move-module-defines-struct',
definesEnum: 'move-module-defines-enum', definesEnum: 'move-module-defines-enum',
definesConst: 'move-module-defines-const', definesConst: 'move-module-defines-const',
externalDefinesFunction: 'move-external-module-defines-function',
externalDefinesType: 'move-external-module-defines-type',
containsVariant: 'move-enum-contains-variant', containsVariant: 'move-enum-contains-variant',
friend: 'move-friend-or-package', friend: 'move-friend-or-package',
calls: 'move-compiler-call-graph', calls: 'move-compiler-call-graph',
@ -70,12 +72,16 @@ export const MOVE_EDGE_REASON = {
acquires: 'move-acquires', acquires: 'move-acquires',
fnParamType: 'move-fn-param-type', fnParamType: 'move-fn-param-type',
fnReturnType: 'move-fn-return-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 = ...)]`) // Struct -> resource-group struct (from `#[resource_group_member(group = ...)]`)
resourceGroupMember: 'move-resource-group-member', resourceGroupMember: 'move-resource-group-member',
// Field edges (struct → field) // Field edges (struct → field)
hasField: 'move-struct-has-field', hasField: 'move-struct-has-field',
hasVariantField: 'move-enum-variant-has-field',
// Lambda → host edges (host fn → __lambda__N__host) // Lambda → host edges (host fn → __lambda__N__host)
lambdaHost: 'move-lambda-of-host', lambdaHost: 'move-lambda-of-host',
closureUse: 'move-compiler-closure-use',
// Entry-point edge reasons (ENTRY_POINT_OF) // Entry-point edge reasons (ENTRY_POINT_OF)
entryFunction: 'move-entry-function', entryFunction: 'move-entry-function',
viewFunction: 'move-view-function', viewFunction: 'move-view-function',

View file

@ -16,7 +16,15 @@ import type {
NodeProperties, NodeProperties,
RelationshipType, RelationshipType,
} from 'gitnexus-shared'; } from 'gitnexus-shared';
import type { MoveFactsAttribute, MoveFactsMap, MoveFactsTypeParam } from './compiler-facts.js'; import type {
MoveFactsAttribute,
MoveFactsFunction,
MoveFactsMap,
MoveFactsModule,
MoveFactsType,
MoveFactsTypeParam,
MoveFlowConstant,
} from './compiler-facts.js';
import { import {
moveModuleNodeId, moveModuleNodeId,
moveFunctionNodeId, moveFunctionNodeId,
@ -25,7 +33,6 @@ import {
moveConstNodeId, moveConstNodeId,
moveEnumVariantNodeId, moveEnumVariantNodeId,
moveFieldNodeId, moveFieldNodeId,
moveLocalName,
moveRelId, moveRelId,
parseMoveLambdaHostName, parseMoveLambdaHostName,
parseMoveModuleQualifiedName, parseMoveModuleQualifiedName,
@ -40,10 +47,19 @@ import {
} from './constants.js'; } from './constants.js';
import { extractTypeNames } from './type-parser.js'; import { extractTypeNames } from './type-parser.js';
import { toZeroBasedLine } from '../ingestion/utils/line-base.js'; import { toZeroBasedLine } from '../ingestion/utils/line-base.js';
import type { PendingRef } from './refs.js';
const spanLine = (span: [number, number] | undefined, idx: 0 | 1): number | undefined => const spanLine = (span: [number, number] | undefined, idx: 0 | 1): number | undefined =>
span ? toZeroBasedLine(span[idx]) : undefined; span ? toZeroBasedLine(span[idx]) : undefined;
function functionLocationFidelity(
fn: MoveFactsFunction,
moduleFile: string | undefined,
): NodeProperties['locationFidelity'] {
if (fn.isLambdaLifted) return moduleFile ? 'module' : 'package';
return fn.file ? 'precise' : 'package';
}
export interface MoveFactsMapResult { export interface MoveFactsMapResult {
nodes: GraphNode[]; nodes: GraphNode[];
edges: GraphRelationship[]; edges: GraphRelationship[];
@ -53,43 +69,8 @@ export interface MoveFactsMapResult {
functionNodeMap: Map<string, string>; functionNodeMap: Map<string, string>;
/** Struct/enum qualified name → graph node ID. */ /** Struct/enum qualified name → graph node ID. */
structNodeMap: Map<string, string>; structNodeMap: Map<string, string>;
/** Resource edges that need the full cross-package type index. */ /** Cross-package references resolved in the linker's Pass B. */
pendingResource: PendingResource[]; pendingRefs: PendingRef[];
/** Friend edges that need the full cross-package module index. */
pendingFriends: PendingFriend[];
/** Signature type refs that need the full cross-package type index. */
pendingTypeRef: PendingTypeRef[];
/** Lambda → host links that need the host's Function node id (resolved post-pass). */
pendingLambdaHosts: PendingLambdaHost[];
}
export interface PendingLambdaHost {
/** The lambda's Function node id (target of the CALLS edge). */
lambdaFnNodeId: string;
/** Host function's fully-qualified name (`<module>::<localHost>`). */
hostQualified: string;
}
export interface PendingResource {
fnNodeId: string;
moduleQualified: string;
type: RelationshipType;
target: string;
reason: string;
}
export interface PendingFriend {
moduleNodeId: string;
friend: string;
}
export interface PendingTypeRef {
/** Source node of the USES_TYPE edge - a Function, or a Struct for
* `resource_group_member` membership refs. */
sourceNodeId: string;
moduleQualified: string;
target: string;
reason: string;
} }
/** Strip generic type arguments: `CoinStore<CoinType>` → `CoinStore`. */ /** Strip generic type arguments: `CoinStore<CoinType>` → `CoinStore`. */
@ -169,32 +150,39 @@ function assertFactsShape(facts: MoveFactsMap): void {
} }
} }
/** /** Shared mutable accumulators threaded through every emitter. */
* Map a full `facts` response (covering every module in a package) to graph interface EmitContext {
* nodes + edges. `packageRoot` is used only as a location fallback when the nodes: GraphNode[];
* compiler omits a per-symbol file (it never does today, but the field is edges: GraphRelationship[];
* optional in the schema). When `repoPath` is given, absolute file paths from moduleFileMap: Map<string, string>;
* the compiler are made repo-relative so node IDs align with `File:` nodes. functionNodeMap: Map<string, string>;
*/ structNodeMap: Map<string, string>;
export function mapFactsToGraph( pendingRefs: PendingRef[];
facts: MoveFactsMap, packageRoot: string;
packageRoot: string, repoPath?: string;
repoPath?: string, }
): MoveFactsMapResult {
assertFactsShape(facts);
const nodes: GraphNode[] = [];
const edges: GraphRelationship[] = [];
const moduleFileMap = new Map<string, string>();
const functionNodeMap = new Map<string, string>();
const structNodeMap = new Map<string, string>();
const edge = ( /** Values derived once per module and read by the function/type/const emitters. */
interface ModuleEmitContext {
moduleQualified: string;
moduleNodeId: string;
/** Absolute module file (or packageRoot fallback) — the per-symbol file fallback. */
moduleFileAbs: string;
/** Repo-relative module file — used for module-level and const nodes. */
file: string;
address: string;
/** Raw `mod.file`; drives locationFidelity ('precise'/'module' vs 'package'). */
modFile?: string;
}
function pushEdge(
edges: GraphRelationship[],
sourceId: string, sourceId: string,
targetId: string, targetId: string,
type: RelationshipType, type: RelationshipType,
confidence: number, confidence: number,
reason: string, reason: string,
): void => { ): void {
edges.push({ edges.push({
id: moveRelId(sourceId, type, targetId, reason), id: moveRelId(sourceId, type, targetId, reason),
sourceId, sourceId,
@ -203,23 +191,21 @@ export function mapFactsToGraph(
confidence, confidence,
reason, reason,
}); });
}; }
// Deferred work that needs the full struct/module index (pass B). /** Emit the Module node + friend PendingRefs; return the per-module context. */
const pendingResource: PendingResource[] = []; function emitModule(
const pendingFriends: PendingFriend[] = []; ctx: EmitContext,
const pendingTypeRef: PendingTypeRef[] = []; moduleQualified: string,
const pendingLambdaHosts: PendingLambdaHost[] = []; mod: MoveFactsModule,
): ModuleEmitContext {
// ── Pass A: nodes (modules, functions, types, constants) ───────────────── const moduleFileAbs = mod.file ?? ctx.packageRoot;
for (const [moduleQualified, mod] of Object.entries(facts)) { const file = moveRepoRelativePath(moduleFileAbs, ctx.repoPath);
const moduleFileAbs = mod.file ?? packageRoot;
const file = moveRepoRelativePath(moduleFileAbs, repoPath);
const { address, moduleName } = parseMoveModuleQualifiedName(moduleQualified); const { address, moduleName } = parseMoveModuleQualifiedName(moduleQualified);
moduleFileMap.set(moduleQualified, file); ctx.moduleFileMap.set(moduleQualified, file);
const moduleNodeId = moveModuleNodeId(moduleQualified, file); const moduleNodeId = moveModuleNodeId(moduleQualified, file);
nodes.push({ ctx.nodes.push({
id: moduleNodeId, id: moduleNodeId,
label: 'Module', label: 'Module',
properties: { properties: {
@ -238,15 +224,31 @@ export function mapFactsToGraph(
}); });
for (const friend of arr(mod.friends)) { for (const friend of arr(mod.friends)) {
pendingFriends.push({ moduleNodeId, friend: friend.module }); ctx.pendingRefs.push({
kind: 'friend',
knownNodeId: moduleNodeId,
target: friend.module,
moduleQualified: '',
edgeType: 'FRIEND_OF',
reason: MOVE_EDGE_REASON.friend,
});
} }
// Functions return { moduleQualified, moduleNodeId, moduleFileAbs, file, address, modFile: mod.file };
for (const fn of arr(mod.functions)) { }
const fnQualified = `${moduleQualified}::${fn.name}`;
const fnFile = moveRepoRelativePath(fn.file ?? moduleFileAbs, repoPath); /** Emit a Function node + DEFINES and its signature/resource/lambda PendingRefs. */
function emitFunction(ctx: EmitContext, mctx: ModuleEmitContext, fn: MoveFactsFunction): void {
const fnQualified = `${mctx.moduleQualified}::${fn.name}`;
// move-flow may report the callee/dependency span that caused a lifted lambda
// to be instantiated. The synthesized function still belongs to its declaring
// module, so anchoring it to that external dependency creates phantom source
// ownership (observed with Aptos big_ordered_map).
const fnSourceFile = fn.isLambdaLifted ? mctx.moduleFileAbs : (fn.file ?? mctx.moduleFileAbs);
const fnFile = moveRepoRelativePath(fnSourceFile, ctx.repoPath);
const fnSpan = fn.isLambdaLifted ? undefined : fn.span;
const fnNodeId = moveFunctionNodeId(fnQualified, fnFile); const fnNodeId = moveFunctionNodeId(fnQualified, fnFile);
functionNodeMap.set(fnQualified, fnNodeId); ctx.functionNodeMap.set(fnQualified, fnNodeId);
const attrs = attributeNames(fn.attributes); const attrs = attributeNames(fn.attributes);
const seenTypeRefs = new Set<string>(); const seenTypeRefs = new Set<string>();
// Skip the function's own type parameters (e.g. `CoinType`): they are not // Skip the function's own type parameters (e.g. `CoinType`): they are not
@ -259,10 +261,12 @@ export function mapFactsToGraph(
const key = `${reason}\0${typeName}`; const key = `${reason}\0${typeName}`;
if (seenTypeRefs.has(key)) continue; if (seenTypeRefs.has(key)) continue;
seenTypeRefs.add(key); seenTypeRefs.add(key);
pendingTypeRef.push({ ctx.pendingRefs.push({
sourceNodeId: fnNodeId, kind: 'type',
moduleQualified, knownNodeId: fnNodeId,
moduleQualified: mctx.moduleQualified,
target: typeName, target: typeName,
edgeType: 'USES_TYPE',
reason, reason,
}); });
} }
@ -278,9 +282,12 @@ export function mapFactsToGraph(
filePath: fnFile, filePath: fnFile,
language: MOVE_LANGUAGE, language: MOVE_LANGUAGE,
qualifiedName: fnQualified, qualifiedName: fnQualified,
moduleQualifiedName: moduleQualified, moduleQualifiedName: mctx.moduleQualified,
startLine: spanLine(fn.span, 0), // Compiler-marked entry/view functions are runtime-facing even when their
endLine: spanLine(fn.span, 1), // source visibility is internal.
isExported: fn.visibility === 'public' || fn.isEntry === true || fn.isView === true,
startLine: spanLine(fnSpan, 0),
endLine: spanLine(fnSpan, 1),
visibility: fn.visibility === 'internal' ? 'private' : fn.visibility, visibility: fn.visibility === 'internal' ? 'private' : fn.visibility,
visibilityModifier: fn.visibility, visibilityModifier: fn.visibility,
isEntry: fn.isEntry, isEntry: fn.isEntry,
@ -294,68 +301,103 @@ export function mapFactsToGraph(
usedTypes: [], usedTypes: [],
returnType: returnTypeProjection(arr(fn.returnTypes)), returnType: returnTypeProjection(arr(fn.returnTypes)),
parameterCount: arr(fn.params).length, parameterCount: arr(fn.params).length,
locationFidelity: fn.file ? 'precise' : 'package', locationFidelity: functionLocationFidelity(fn, mctx.modFile),
}; };
// Lambda → host link queued for Pass 2 (host fn node may not yet exist // Lambda → host link queued for Pass B (host fn node may not yet exist when
// when this lambda is mapped). The canonical Cypher path for "is this a // this lambda is mapped). The canonical Cypher path for "is this a lambda?"
// lambda?" is the inbound CALLS edge with reason 'move-lambda-of-host'; // is the inbound CALLS edge with reason 'move-lambda-of-host'; we do not
// we do not project an `isLambda` boolean property because the lbug // project an `isLambda` boolean property because the lbug Function table does
// Function table does not carry it. move-flow reports the host explicitly // not carry it. move-flow reports the host explicitly via `definedIn`
// via `definedIn` (resolved through nested closures); the // (resolved through nested closures); the `__lambda__N__<host>` name parse
// `__lambda__N__<host>` name parse remains as fallback. // remains as fallback.
const lambdaHostLocal = const lambdaHostLocal =
(fn.isLambdaLifted ? fn.definedIn : undefined) ?? parseMoveLambdaHostName(fn.name); (fn.isLambdaLifted ? fn.definedIn : undefined) ?? parseMoveLambdaHostName(fn.name);
if (lambdaHostLocal) { if (lambdaHostLocal) {
pendingLambdaHosts.push({ ctx.pendingRefs.push({
lambdaFnNodeId: fnNodeId, kind: 'lambda-host',
hostQualified: `${moduleQualified}::${lambdaHostLocal}`, knownNodeId: fnNodeId,
target: `${mctx.moduleQualified}::${lambdaHostLocal}`,
moduleQualified: '',
edgeType: 'CALLS',
reason: MOVE_EDGE_REASON.lambdaHost,
}); });
} }
nodes.push({ ctx.nodes.push({ id: fnNodeId, label: 'Function', properties: fnProps });
id: fnNodeId, pushEdge(
label: 'Function', ctx.edges,
properties: fnProps, mctx.moduleNodeId,
}); fnNodeId,
edge(moduleNodeId, fnNodeId, 'DEFINES', 1.0, MOVE_EDGE_REASON.definesFunction); 'DEFINES',
1.0,
MOVE_EDGE_REASON.definesFunction,
);
for (const r of arr(fn.resourceAccess?.reads)) { for (const r of arr(fn.resourceAccess?.reads)) {
pendingResource.push({ ctx.pendingRefs.push({
fnNodeId, kind: 'resource',
moduleQualified, knownNodeId: fnNodeId,
type: 'READS_RESOURCE', moduleQualified: mctx.moduleQualified,
target: stripTypeArgs(r), target: stripTypeArgs(r),
edgeType: 'READS_RESOURCE',
reason: MOVE_EDGE_REASON.readsResource, reason: MOVE_EDGE_REASON.readsResource,
}); });
} }
for (const w of arr(fn.resourceAccess?.writes)) { for (const w of arr(fn.resourceAccess?.writes)) {
pendingResource.push({ ctx.pendingRefs.push({
fnNodeId, kind: 'resource',
moduleQualified, knownNodeId: fnNodeId,
type: 'WRITES_RESOURCE', moduleQualified: mctx.moduleQualified,
target: stripTypeArgs(w), target: stripTypeArgs(w),
edgeType: 'WRITES_RESOURCE',
reason: MOVE_EDGE_REASON.writesResource, reason: MOVE_EDGE_REASON.writesResource,
}); });
} }
for (const a of arr(fn.acquiresInferred)) { for (const a of arr(fn.acquiresInferred)) {
pendingResource.push({ ctx.pendingRefs.push({
fnNodeId, kind: 'resource',
moduleQualified, knownNodeId: fnNodeId,
type: 'ACQUIRES', moduleQualified: mctx.moduleQualified,
target: a, target: a,
edgeType: 'ACQUIRES',
reason: MOVE_EDGE_REASON.acquires, reason: MOVE_EDGE_REASON.acquires,
}); });
} }
} }
// Types (structs + enums) /** Shared field-type ref emitter; skips the type's own type-parameter names. */
for (const ty of arr(mod.structs)) { function addFieldTypeRefs(
const tyQualified = `${moduleQualified}::${ty.name}`; ctx: EmitContext,
const tyFile = moveRepoRelativePath(ty.file ?? moduleFileAbs, repoPath); moduleQualified: string,
const attrs = attributeNames(ty.attributes); typeParamNames: Set<string>,
if (ty.kind === 'struct') { sourceNodeId: string,
typeExpr: string,
reason: string,
): void {
for (const target of new Set(extractTypeNames(typeExpr))) {
if (typeParamNames.has(target)) continue;
ctx.pendingRefs.push({
kind: 'type',
knownNodeId: sourceNodeId,
moduleQualified,
target,
edgeType: 'USES_TYPE',
reason,
});
}
}
function emitStruct(
ctx: EmitContext,
mctx: ModuleEmitContext,
ty: MoveFactsType,
tyFile: string,
attrs: string[],
typeParamNames: Set<string>,
): void {
const tyQualified = `${mctx.moduleQualified}::${ty.name}`;
const structNodeId = moveStructNodeId(tyQualified, tyFile); const structNodeId = moveStructNodeId(tyQualified, tyFile);
structNodeMap.set(tyQualified, structNodeId); ctx.structNodeMap.set(tyQualified, structNodeId);
nodes.push({ ctx.nodes.push({
id: structNodeId, id: structNodeId,
label: 'Struct', label: 'Struct',
properties: { properties: {
@ -363,8 +405,8 @@ export function mapFactsToGraph(
filePath: tyFile, filePath: tyFile,
language: MOVE_LANGUAGE, language: MOVE_LANGUAGE,
qualifiedName: tyQualified, qualifiedName: tyQualified,
moduleQualifiedName: moduleQualified, moduleQualifiedName: mctx.moduleQualified,
moduleAddress: address, moduleAddress: mctx.address,
startLine: spanLine(ty.span, 0), startLine: spanLine(ty.span, 0),
endLine: spanLine(ty.span, 1), endLine: spanLine(ty.span, 1),
abilities: arr(ty.abilities), abilities: arr(ty.abilities),
@ -378,32 +420,39 @@ export function mapFactsToGraph(
type: f.type, type: f.type,
positional: f.positional, positional: f.positional,
})), })),
// STRING[] projection persisted to lbug (`fieldList` column).
fieldList: (ty.fields ?? []).map((f) => `${f.name}: ${f.type}`), fieldList: (ty.fields ?? []).map((f) => `${f.name}: ${f.type}`),
moveDeclarationKind: 'struct', moveDeclarationKind: 'struct',
locationFidelity: ty.file ? 'precise' : 'package', locationFidelity: ty.file ? 'precise' : 'package',
}, },
}); });
edge(moduleNodeId, structNodeId, 'DEFINES', 1.0, MOVE_EDGE_REASON.definesStruct); pushEdge(
// `#[resource_group_member(group = ...)]` carries an exact qualified ctx.edges,
// struct ref (e.g. '0xa::vault::Group') - queue a membership USES_TYPE mctx.moduleNodeId,
// edge resolved against the cross-package type index in Pass B. structNodeId,
'DEFINES',
1.0,
MOVE_EDGE_REASON.definesStruct,
);
// `#[resource_group_member(group = ...)]` carries an exact qualified struct ref;
// queue a membership USES_TYPE edge resolved in Pass B.
const resourceGroup = resourceGroupOf(ty.attributes); const resourceGroup = resourceGroupOf(ty.attributes);
if (resourceGroup) { if (resourceGroup) {
pendingTypeRef.push({ ctx.pendingRefs.push({
sourceNodeId: structNodeId, kind: 'type',
moduleQualified, knownNodeId: structNodeId,
moduleQualified: mctx.moduleQualified,
target: resourceGroup, target: resourceGroup,
edgeType: 'USES_TYPE',
reason: MOVE_EDGE_REASON.resourceGroupMember, reason: MOVE_EDGE_REASON.resourceGroupMember,
}); });
} }
// Per-field Property nodes + HAS_PROPERTY edges so ACCESSES queries and // Per-field Property nodes + HAS_PROPERTY edges so ACCESSES queries and
// field-level taint can target individual fields. The composite name + // field-level taint can target individual fields. The composite name + type
// type live on the Struct as a `fields` array projection, but only the // live on the Struct as a `fields` array projection, but only the per-field
// per-field nodes let Cypher join on `(:Struct)-[:HAS_PROPERTY]->(:Property)`. // nodes let Cypher join on `(:Struct)-[:HAS_PROPERTY]->(:Property)`.
for (const field of arr(ty.fields)) { for (const field of arr(ty.fields)) {
const propId = moveFieldNodeId(tyQualified, field.name, tyFile); const propId = moveFieldNodeId(tyQualified, field.name, tyFile);
nodes.push({ ctx.nodes.push({
id: propId, id: propId,
label: 'Property', label: 'Property',
properties: { properties: {
@ -412,18 +461,36 @@ export function mapFactsToGraph(
language: MOVE_LANGUAGE, language: MOVE_LANGUAGE,
qualifiedName: `${tyQualified}.${field.name}`, qualifiedName: `${tyQualified}.${field.name}`,
parentStruct: tyQualified, parentStruct: tyQualified,
moduleQualifiedName: moduleQualified, moduleQualifiedName: mctx.moduleQualified,
declaredType: field.type, declaredType: field.type,
positional: field.positional, positional: field.positional,
startLine: spanLine(ty.span, 0), startLine: spanLine(ty.span, 0),
}, },
}); });
edge(structNodeId, propId, 'HAS_PROPERTY', 1.0, MOVE_EDGE_REASON.hasField); pushEdge(ctx.edges, structNodeId, propId, 'HAS_PROPERTY', 1.0, MOVE_EDGE_REASON.hasField);
addFieldTypeRefs(
ctx,
mctx.moduleQualified,
typeParamNames,
propId,
field.type,
MOVE_EDGE_REASON.structFieldType,
);
} }
} else { }
function emitEnum(
ctx: EmitContext,
mctx: ModuleEmitContext,
ty: MoveFactsType,
tyFile: string,
attrs: string[],
typeParamNames: Set<string>,
): void {
const tyQualified = `${mctx.moduleQualified}::${ty.name}`;
const eId = moveEnumNodeId(tyQualified, tyFile); const eId = moveEnumNodeId(tyQualified, tyFile);
structNodeMap.set(tyQualified, eId); ctx.structNodeMap.set(tyQualified, eId);
nodes.push({ ctx.nodes.push({
id: eId, id: eId,
label: 'Enum', label: 'Enum',
properties: { properties: {
@ -431,8 +498,8 @@ export function mapFactsToGraph(
filePath: tyFile, filePath: tyFile,
language: MOVE_LANGUAGE, language: MOVE_LANGUAGE,
qualifiedName: tyQualified, qualifiedName: tyQualified,
moduleQualifiedName: moduleQualified, moduleQualifiedName: mctx.moduleQualified,
moduleAddress: address, moduleAddress: mctx.address,
startLine: spanLine(ty.span, 0), startLine: spanLine(ty.span, 0),
endLine: spanLine(ty.span, 1), endLine: spanLine(ty.span, 1),
abilities: arr(ty.abilities), abilities: arr(ty.abilities),
@ -445,10 +512,10 @@ export function mapFactsToGraph(
locationFidelity: ty.file ? 'precise' : 'package', locationFidelity: ty.file ? 'precise' : 'package',
}, },
}); });
edge(moduleNodeId, eId, 'DEFINES', 1.0, MOVE_EDGE_REASON.definesEnum); pushEdge(ctx.edges, mctx.moduleNodeId, eId, 'DEFINES', 1.0, MOVE_EDGE_REASON.definesEnum);
for (const variant of arr(ty.variants)) { for (const variant of arr(ty.variants)) {
const vId = moveEnumVariantNodeId(tyQualified, variant.name, tyFile); const vId = moveEnumVariantNodeId(tyQualified, variant.name, tyFile);
nodes.push({ ctx.nodes.push({
id: vId, id: vId,
label: 'EnumVariant', label: 'EnumVariant',
properties: { properties: {
@ -457,7 +524,7 @@ export function mapFactsToGraph(
language: MOVE_LANGUAGE, language: MOVE_LANGUAGE,
qualifiedName: `${tyQualified}::${variant.name}`, qualifiedName: `${tyQualified}::${variant.name}`,
parentEnum: tyQualified, parentEnum: tyQualified,
moduleQualifiedName: moduleQualified, moduleQualifiedName: mctx.moduleQualified,
variantKind: variant.kind, variantKind: variant.kind,
fieldsJson: JSON.stringify( fieldsJson: JSON.stringify(
arr(variant.fields).map((f) => ({ arr(variant.fields).map((f) => ({
@ -471,214 +538,116 @@ export function mapFactsToGraph(
locationFidelity: ty.file ? 'module' : 'package', locationFidelity: ty.file ? 'module' : 'package',
}, },
}); });
edge(eId, vId, 'CONTAINS', 1.0, MOVE_EDGE_REASON.containsVariant); pushEdge(ctx.edges, eId, vId, 'CONTAINS', 1.0, MOVE_EDGE_REASON.containsVariant);
} for (const field of arr(variant.fields)) {
const variantQualified = `${tyQualified}::${variant.name}`;
const propId = moveFieldNodeId(variantQualified, field.name, tyFile);
ctx.nodes.push({
id: propId,
label: 'Property',
properties: {
name: field.name,
filePath: tyFile,
language: MOVE_LANGUAGE,
qualifiedName: `${variantQualified}.${field.name}`,
moduleQualifiedName: mctx.moduleQualified,
declaredType: field.type,
startLine: spanLine(ty.span, 0),
},
});
pushEdge(ctx.edges, vId, propId, 'HAS_PROPERTY', 1.0, MOVE_EDGE_REASON.hasVariantField);
addFieldTypeRefs(
ctx,
mctx.moduleQualified,
typeParamNames,
propId,
field.type,
MOVE_EDGE_REASON.enumVariantFieldType,
);
} }
} }
}
// Constants /** Emit a struct or enum (move-flow groups both under the module's `structs`). */
for (const c of arr(mod.constants)) { function emitType(ctx: EmitContext, mctx: ModuleEmitContext, ty: MoveFactsType): void {
const cQualified = `${moduleQualified}::${c.name}`; const tyFile = moveRepoRelativePath(ty.file ?? mctx.moduleFileAbs, ctx.repoPath);
const cNodeId = moveConstNodeId(cQualified, file); const attrs = attributeNames(ty.attributes);
nodes.push({ const typeParamNames = new Set(arr(ty.typeParams).map((tp) => tp.name));
switch (ty.kind) {
case 'struct':
emitStruct(ctx, mctx, ty, tyFile, attrs, typeParamNames);
break;
case 'enum':
emitEnum(ctx, mctx, ty, tyFile, attrs, typeParamNames);
break;
default: {
const _exhaustive: never = ty.kind;
throw new Error(`Unhandled Move type kind: ${String(_exhaustive)}`);
}
}
}
/** Emit a Const node + DEFINES. */
function emitConst(ctx: EmitContext, mctx: ModuleEmitContext, c: MoveFlowConstant): void {
const cQualified = `${mctx.moduleQualified}::${c.name}`;
const cNodeId = moveConstNodeId(cQualified, mctx.file);
ctx.nodes.push({
id: cNodeId, id: cNodeId,
label: 'Const', label: 'Const',
properties: { properties: {
name: c.name, name: c.name,
filePath: file, filePath: mctx.file,
language: MOVE_LANGUAGE, language: MOVE_LANGUAGE,
qualifiedName: cQualified, qualifiedName: cQualified,
moduleQualifiedName: moduleQualified, moduleQualifiedName: mctx.moduleQualified,
constType: c.type, constType: c.type,
constValue: c.value, constValue: c.value,
isErrorCode: ERROR_CODE_PATTERN.test(c.name), isErrorCode: ERROR_CODE_PATTERN.test(c.name),
locationFidelity: mod.file ? 'module' : 'package', locationFidelity: mctx.modFile ? 'module' : 'package',
}, },
}); });
edge(moduleNodeId, cNodeId, 'DEFINES', 1.0, MOVE_EDGE_REASON.definesConst); pushEdge(ctx.edges, mctx.moduleNodeId, cNodeId, 'DEFINES', 1.0, MOVE_EDGE_REASON.definesConst);
}
}
return {
nodes,
edges,
moduleFileMap,
functionNodeMap,
structNodeMap,
pendingResource,
pendingFriends,
pendingTypeRef,
pendingLambdaHosts,
};
} }
/** /**
* Resolve queued lambda→host links into CALLS edges. Run after every package's * Map a full `facts` response (covering every module in a package) to graph
* facts have been mapped so the host's Function node is guaranteed to exist * nodes + edges. `packageRoot` is used only as a location fallback when the
* (the host may be defined in a different module within the package, or — for * compiler omits a per-symbol file (it never does today, but the field is
* cross-package lambda capture — in another package altogether). * optional in the schema). When `repoPath` is given, absolute file paths from
* the compiler are made repo-relative so node IDs align with `File:` nodes.
*/ */
export function resolveLambdaHostEdges( export function mapFactsToGraph(
pendingLambdaHosts: readonly PendingLambdaHost[], facts: MoveFactsMap,
functionNodeMap: ReadonlyMap<string, string>, packageRoot: string,
edgeSink: (rel: GraphRelationship) => void, repoPath?: string,
): void { ): MoveFactsMapResult {
const seen = new Set<string>(); assertFactsShape(facts);
for (const p of pendingLambdaHosts) { const ctx: EmitContext = {
const hostId = functionNodeMap.get(p.hostQualified); nodes: [],
if (!hostId) continue; edges: [],
const key = `${hostId}\0${p.lambdaFnNodeId}`; moduleFileMap: new Map(),
if (seen.has(key)) continue; functionNodeMap: new Map(),
seen.add(key); structNodeMap: new Map(),
edgeSink({ pendingRefs: [],
id: moveRelId(hostId, 'CALLS', p.lambdaFnNodeId, MOVE_EDGE_REASON.lambdaHost), packageRoot,
sourceId: hostId, repoPath,
targetId: p.lambdaFnNodeId, };
type: 'CALLS',
confidence: 0.9,
reason: MOVE_EDGE_REASON.lambdaHost,
});
}
}
export function buildLocalNameIndex( for (const [moduleQualified, mod] of Object.entries(facts)) {
structNodeMap: ReadonlyMap<string, string>, const mctx = emitModule(ctx, moduleQualified, mod);
): Map<string, string[]> { for (const fn of arr(mod.functions)) emitFunction(ctx, mctx, fn);
const structIdsByLocalName = new Map<string, string[]>(); for (const ty of arr(mod.structs)) emitType(ctx, mctx, ty);
for (const [qn, id] of structNodeMap) { for (const c of arr(mod.constants)) emitConst(ctx, mctx, c);
const key = moveLocalName(qn);
const list = structIdsByLocalName.get(key);
if (list) list.push(id);
else structIdsByLocalName.set(key, [id]);
}
return structIdsByLocalName;
}
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 function resolveResourceEdges(
pendingResource: readonly PendingResource[],
structNodeMap: ReadonlyMap<string, string>,
structIdsByLocalName: ReadonlyMap<string, readonly string[]>,
edgeSink: (rel: GraphRelationship) => void,
onUnresolved?: (pending: PendingResource) => void,
onAmbiguous?: (pending: PendingResource) => void,
): void {
const seenResourceEdges = new Set<string>();
for (const pr of pendingResource) {
const resolved = resolveStructRef(
pr.target,
pr.moduleQualified,
structNodeMap,
structIdsByLocalName,
);
if ('unresolved' in resolved) {
onUnresolved?.(pr);
continue;
}
if ('ambiguous' in resolved) {
onAmbiguous?.(pr);
continue;
} }
const key = `${pr.fnNodeId}\0${pr.type}\0${resolved.targetId}`; return {
if (seenResourceEdges.has(key)) continue; nodes: ctx.nodes,
seenResourceEdges.add(key); edges: ctx.edges,
edgeSink({ moduleFileMap: ctx.moduleFileMap,
id: moveRelId(pr.fnNodeId, pr.type, resolved.targetId, pr.reason), functionNodeMap: ctx.functionNodeMap,
sourceId: pr.fnNodeId, structNodeMap: ctx.structNodeMap,
targetId: resolved.targetId, pendingRefs: ctx.pendingRefs,
type: pr.type, };
confidence: 1.0,
reason: pr.reason,
});
}
}
export function resolveFriendEdges(
pendingFriends: readonly PendingFriend[],
moduleFileMap: ReadonlyMap<string, string>,
edgeSink: (rel: GraphRelationship) => void,
): void {
for (const pf of pendingFriends) {
const friendFile = moduleFileMap.get(pf.friend);
if (!friendFile) continue;
const targetId = moveModuleNodeId(pf.friend, friendFile);
edgeSink({
id: moveRelId(pf.moduleNodeId, 'FRIEND_OF', targetId, MOVE_EDGE_REASON.friend),
sourceId: pf.moduleNodeId,
targetId,
type: 'FRIEND_OF',
confidence: 1.0,
reason: MOVE_EDGE_REASON.friend,
});
}
}
export function resolveTypeRefEdges(
pendingTypeRef: readonly PendingTypeRef[],
structNodeMap: ReadonlyMap<string, string>,
structIdsByLocalName: ReadonlyMap<string, readonly string[]>,
edgeSink: (rel: GraphRelationship) => void,
onUnresolved?: (pending: PendingTypeRef) => void,
onAmbiguous?: (pending: PendingTypeRef) => void,
): void {
const seenTypeEdges = new Set<string>();
for (const pr of pendingTypeRef) {
const resolved = resolveStructRef(
pr.target,
pr.moduleQualified,
structNodeMap,
structIdsByLocalName,
);
if ('unresolved' in resolved) {
onUnresolved?.(pr);
continue;
}
if ('ambiguous' in resolved) {
onAmbiguous?.(pr);
continue;
}
const key = `${pr.sourceNodeId}\0${pr.reason}\0${resolved.targetId}`;
if (seenTypeEdges.has(key)) continue;
seenTypeEdges.add(key);
edgeSink({
id: moveRelId(pr.sourceNodeId, 'USES_TYPE', resolved.targetId, pr.reason),
sourceId: pr.sourceNodeId,
targetId: resolved.targetId,
type: 'USES_TYPE',
confidence: 1.0,
reason: pr.reason,
});
}
} }
// Re-export for callers that prefer the label type. // Re-export for callers that prefer the label type.

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; stagedDir = undefined;
return { status: 'installed', binary: verifiedBinary(config, metadata) }; return { status: 'installed', binary: verifiedBinary(config, metadata) };
} catch (error) { } 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 { return {
status: 'failed', status: 'failed',
message: `installation failed: ${error instanceof Error ? error.message : String(error)}`, 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 { spawn, execFileSync, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { createInterface } from 'node:readline'; 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'; import { isCompatibleMoveFlowVersion } from './release.js';
interface JsonRpcResponse { interface JsonRpcResponse {
@ -41,6 +41,15 @@ function isMcpListToolResult(v: unknown): v is McpListToolResult {
return typeof v === 'object' && v !== null; 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 /** JSON-RPC `invalid_params` - move-flow uses it for caller/input errors
* (and some builds route package build failures through it). */ * (and some builds route package build failures through it). */
const JSON_RPC_INVALID_PARAMS = -32602; 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 * 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 * 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>; facts(packagePath: string): Promise<MoveFactsMap>;
/** Function-level call graph (caller qualified name → callee qualified names). */ /** Function-level call graph (caller qualified name → callee qualified names). */
callGraph(packagePath: string): Promise<CallGraphMap>; 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 * 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 - * what did the compiler say. Older move-flow builds do not expose the tool -
@ -110,6 +147,8 @@ export interface MovePackageStatus {
export interface MoveFlowCapabilities { export interface MoveFlowCapabilities {
/** `facts` query available (rich, compiler-sourced per-module facts). */ /** `facts` query available (rich, compiler-sourced per-module facts). */
hasFactsQuery: boolean; hasFactsQuery: boolean;
/** `function_usage` query available (closure captures included in `used`). */
hasFunctionUsageQuery: boolean;
/** `move_package_status` tool available (build status + diagnostics). */ /** `move_package_status` tool available (build status + diagnostics). */
hasStatusTool: boolean; hasStatusTool: boolean;
} }
@ -143,22 +182,28 @@ export function detectMoveFlowCapabilities(
if (t.name === 'move_package_query') querySchema = t.inputSchema; if (t.name === 'move_package_query') querySchema = t.inputSchema;
} }
} }
const hasFactsQuery = names.has('move_package_facts') || schemaMentionsFactsQuery(querySchema); const hasFactsQuery =
return { hasFactsQuery, hasStatusTool: names.has('move_package_status') }; 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. */ /** True if `move_package_query` declares the requested query const/enum item. */
function schemaMentionsFactsQuery(schema: unknown): boolean { function schemaMentionsQuery(schema: unknown, query: string): boolean {
if (!schema || typeof schema !== 'object') return false; if (!schema || typeof schema !== 'object') return false;
// Walk the JSON-schema object looking for a `const: "facts"` or // Walk the JSON-schema object looking for the query in either a `const` or
// `enum: [... "facts" ...]` anywhere under the QueryType definition. // an `enum` anywhere under the QueryType definition.
const stack: unknown[] = [schema]; const stack: unknown[] = [schema];
while (stack.length) { while (stack.length) {
const node = stack.pop(); const node = stack.pop();
if (!node || typeof node !== 'object') continue; if (!node || typeof node !== 'object') continue;
const obj = node as Record<string, unknown>; const obj = node as Record<string, unknown>;
if (obj.const === 'facts') return true; if (obj.const === query) return true;
if (Array.isArray(obj.enum) && obj.enum.includes('facts')) return true; if (Array.isArray(obj.enum) && obj.enum.includes(query)) return true;
for (const v of Object.values(obj)) { for (const v of Object.values(obj)) {
if (v && typeof v === 'object') stack.push(v); if (v && typeof v === 'object') stack.push(v);
} }
@ -180,6 +225,9 @@ export class MoveFlowMcpClient implements MoveFlowClient {
private initialized = false; private initialized = false;
private initPromise: Promise<void> | null = null; private initPromise: Promise<void> | null = null;
private capsPromise: Promise<MoveFlowCapabilities> | 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 binaryPath: string;
private stderrLines: string[] = []; private stderrLines: string[] = [];
private static readonly MAX_STDERR = 20; private static readonly MAX_STDERR = 20;
@ -218,6 +266,7 @@ export class MoveFlowMcpClient implements MoveFlowClient {
} }
private async ensureStarted(): Promise<void> { private async ensureStarted(): Promise<void> {
if (this.closed) throw new Error('move-flow client is shut down');
if (this.initialized) return; if (this.initialized) return;
if (this.initPromise) return this.initPromise; if (this.initPromise) return this.initPromise;
const start = this._start().catch((err) => { const start = this._start().catch((err) => {
@ -285,19 +334,19 @@ export class MoveFlowMcpClient implements MoveFlowClient {
if (this.stderrLines.length > MoveFlowMcpClient.MAX_STDERR) { if (this.stderrLines.length > MoveFlowMcpClient.MAX_STDERR) {
this.stderrLines.shift(); this.stderrLines.shift();
} }
const wrapped = new Error(`move-flow stdin failed: ${err.message}${this.stderrContext()}`); const message = `move-flow stdin failed: ${err.message}${this.stderrContext()}`;
if (!initSettled) failInitialization(wrapped); // Transport-typed only after init: a broken pipe during startup is a
else this.failProcess(proc, wrapped); // 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) => { proc.on('error', (err) => {
const wrapped = new Error( const message = `Failed to spawn move-flow: ${err.message}${this.stderrContext()}`;
`Failed to spawn move-flow: ${err.message}${this.stderrContext()}`,
);
if (!initSettled) { if (!initSettled) {
failInitialization(wrapped); failInitialization(new Error(message));
} else { } else {
this.failProcess(proc, wrapped); this.failProcess(proc, new MoveFlowTransportError(message));
} }
}); });
@ -312,7 +361,9 @@ export class MoveFlowMcpClient implements MoveFlowClient {
} }
this.failProcess( this.failProcess(
proc, proc,
new Error(`move-flow exited unexpectedly (code ${code})${this.stderrContext()}`), new MoveFlowTransportError(
`move-flow exited unexpectedly (code ${code})${this.stderrContext()}`,
),
false, false,
); );
}); });
@ -391,11 +442,36 @@ export class MoveFlowMcpClient implements MoveFlowClient {
this.proc.stdin.write(JSON.stringify(msg) + '\n'); 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( private async request(
method: string, method: string,
params: unknown, params: unknown,
timeoutMs: number, 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> { ): Promise<unknown> {
await this.ensureStarted(); await this.ensureStarted();
const proc = this.proc; const proc = this.proc;
@ -404,7 +480,16 @@ export class MoveFlowMcpClient implements MoveFlowClient {
return new Promise<unknown>((resolve, reject) => { return new Promise<unknown>((resolve, reject) => {
const id = ++this.requestId; const id = ++this.requestId;
const timeout = setTimeout(() => { 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); }, timeoutMs);
this.pending.set(id, { this.pending.set(id, {
resolve: (result) => { 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 timeoutMs = resolveMoveFlowToolTimeoutMs();
const result = await this.request( const result = await this.request(
'tools/call', 'tools/call',
{ name: toolName, arguments: args }, { name: toolName, arguments: args },
timeoutMs, timeoutMs,
new MoveFlowTimeoutError(
`move-flow '${toolName}' timed out after ${timeoutMs}ms ` + `move-flow '${toolName}' timed out after ${timeoutMs}ms ` +
'(raise GITNEXUS_MOVE_FLOW_TIMEOUT_MS for large packages)', '(raise GITNEXUS_MOVE_FLOW_TIMEOUT_MS for large packages)',
),
opts,
); );
if (!isMcpCallToolResult(result)) return result; if (!isMcpCallToolResult(result)) return result;
if (result.isError === true) { if (result.isError === true) {
@ -465,6 +557,24 @@ export class MoveFlowMcpClient implements MoveFlowClient {
})) as MoveFactsMap; })) 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> { async packageStatus(packagePath: string): Promise<MovePackageStatus> {
try { try {
const result = await this.callTool('move_package_status', { package_path: packagePath }); 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`. */ /** Raw JSON-RPC request (non-`tools/call`), e.g. `tools/list`. */
private async rpcRequest(method: string, params?: unknown): Promise<unknown> { 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> { async capabilities(): Promise<MoveFlowCapabilities> {
@ -498,6 +613,7 @@ export class MoveFlowMcpClient implements MoveFlowClient {
} }
async shutdown(): Promise<void> { async shutdown(): Promise<void> {
this.closed = true;
const proc = this.proc; const proc = this.proc;
if (proc) { if (proc) {
proc.stdin?.end(); proc.stdin?.end();

View file

@ -29,28 +29,18 @@ import type {
import { getPhaseOutput } from '../ingestion/pipeline-phases/types.js'; import { getPhaseOutput } from '../ingestion/pipeline-phases/types.js';
import type { StructureOutput } from '../ingestion/pipeline-phases/structure.js'; import type { StructureOutput } from '../ingestion/pipeline-phases/structure.js';
import type { StandaloneIngestOutput } from '../ingestion/pipeline-phases/standalone-ingest.js'; import type { StandaloneIngestOutput } from '../ingestion/pipeline-phases/standalone-ingest.js';
import type { KnowledgeGraph } from '../graph/types.js'; import { moveRepoRelativePath } from './constants.js';
import { MOVE_EDGE_REASON, moveRepoRelativePath } from './constants.js';
import { import {
MoveFlowTimeoutError,
MoveFlowToolCallError, MoveFlowToolCallError,
type MoveFlowClient, type MoveFlowClient,
type MovePackageStatus, type MovePackageStatus,
} from './mcp-client.js'; } from './mcp-client.js';
import { import { mapFactsToGraph, type MoveFactsMapResult } from './facts-mapper.js';
buildLocalNameIndex,
mapFactsToGraph,
resolveFriendEdges,
resolveLambdaHostEdges,
resolveResourceEdges,
resolveTypeRefEdges,
type MoveFactsMapResult,
type PendingFriend,
type PendingLambdaHost,
type PendingResource,
type PendingTypeRef,
} from './facts-mapper.js';
import type { CallGraphMap, MoveFactsMap } from './compiler-facts.js'; import type { CallGraphMap, MoveFactsMap } from './compiler-facts.js';
import { moveModuleNodeId, moveModuleQualifiedName, moveRelId } from './symbol-id.js'; import { collectClosureCaptureCalls, type FunctionUsageFailure } from './function-usage.js';
import { linkMoveIngestGraph, type MoveLinkView } from './move-linker.js';
import type { DroppedRef, PendingRef } from './refs.js';
import { import {
buildFailedIssue, buildFailedIssue,
cliWarningsFromIssues, cliWarningsFromIssues,
@ -63,6 +53,11 @@ import {
} from './consistency.js'; } from './consistency.js';
import { createMoveEntryPointEdges } from './entry-points.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 ─────────────────────────────────────────────────────────── // ── Phase output ───────────────────────────────────────────────────────────
export interface MoveIngestOutput extends StandaloneIngestOutput { export interface MoveIngestOutput extends StandaloneIngestOutput {
@ -80,8 +75,10 @@ export interface MoveIngestOutput extends StandaloneIngestOutput {
filePackageMap: ReadonlyMap<string, string>; filePackageMap: ReadonlyMap<string, string>;
/** Absolute package root → compiler call graph for that package. */ /** Absolute package root → compiler call graph for that package. */
callGraphByPackage: ReadonlyMap<string, CallGraphMap>; callGraphByPackage: ReadonlyMap<string, CallGraphMap>;
/** Resource references dropped during global resolution. */ /** All refs dropped during global resolution (resource, type, friend, lambda-host). */
droppedResourceRefs?: { fnNodeId: string; target: string }[]; droppedRefs: DroppedRef[];
/** Supplemental function-usage queries that could not be completed. */
functionUsageFailures: FunctionUsageFailure[];
/** Non-fatal consistency issues found after Move ingestion. */ /** Non-fatal consistency issues found after Move ingestion. */
consistencyIssues: MoveConsistencyIssue[]; consistencyIssues: MoveConsistencyIssue[];
/** Operator-actionable warnings for the persistent CLI summary (skipped or /** 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. */ /** Mutable accumulator shared while ingesting every package. */
interface MoveIngestState { class MoveIngestAccumulator implements MoveLinkView {
ingestedFiles: Set<string>; readonly moduleFileMap = new Map<string, string>();
moduleFileMap: Map<string, string>; readonly functionNodeMap = new Map<string, string>();
functionNodeMap: Map<string, string>; readonly structNodeMap = new Map<string, string>();
structNodeMap: Map<string, string>; readonly modulePackageMap = new Map<string, string>();
modulePackageMap: Map<string, string>; readonly filePackageMap = new Map<string, string>();
filePackageMap: Map<string, string>; readonly callGraphByPackage = new Map<string, CallGraphMap>();
callGraphByPackage: Map<string, CallGraphMap>; readonly closureCallsByPackage = new Map<string, CallGraphMap>();
pendingResource: PendingResource[]; readonly pendingRefs: PendingRef[] = [];
pendingFriends: PendingFriend[]; readonly droppedRefs: DroppedRef[] = [];
pendingTypeRef: PendingTypeRef[]; readonly ingestedFiles = new Set<string>();
pendingLambdaHosts: PendingLambdaHost[]; readonly functionUsageFailures: FunctionUsageFailure[] = [];
droppedResourceRefs: { fnNodeId: string; target: string }[];
}
function createState(): MoveIngestState { mergePackage(mapped: MoveFactsMapResult, pkgRoot: string): void {
return { for (const [qn, file] of mapped.moduleFileMap) {
ingestedFiles: new Set(), this.moduleFileMap.set(qn, file);
moduleFileMap: new Map(), this.modulePackageMap.set(qn, pkgRoot);
functionNodeMap: new Map(), this.filePackageMap.set(file, pkgRoot);
structNodeMap: new Map(), }
modulePackageMap: new Map(), for (const [qn, id] of mapped.functionNodeMap) this.functionNodeMap.set(qn, id);
filePackageMap: new Map(), for (const [qn, id] of mapped.structNodeMap) this.structNodeMap.set(qn, id);
callGraphByPackage: new Map(), this.pendingRefs.push(...mapped.pendingRefs);
pendingResource: [], }
pendingFriends: [],
pendingTypeRef: [],
pendingLambdaHosts: [],
droppedResourceRefs: [],
};
}
function toOutput( toOutput(
state: MoveIngestState,
packageRoots: string[], packageRoots: string[],
consistencyIssues: MoveConsistencyIssue[] = [], consistencyIssues: MoveConsistencyIssue[] = [],
): MoveIngestOutput { ): MoveIngestOutput {
return { return {
ingestedFiles: state.ingestedFiles, ingestedFiles: this.ingestedFiles,
packageRoots, packageRoots,
moduleFileMap: state.moduleFileMap, moduleFileMap: this.moduleFileMap,
functionNodeMap: state.functionNodeMap, functionNodeMap: this.functionNodeMap,
structNodeMap: state.structNodeMap, structNodeMap: this.structNodeMap,
modulePackageMap: state.modulePackageMap, modulePackageMap: this.modulePackageMap,
filePackageMap: state.filePackageMap, filePackageMap: this.filePackageMap,
callGraphByPackage: state.callGraphByPackage, callGraphByPackage: this.callGraphByPackage,
droppedResourceRefs: state.droppedResourceRefs, droppedRefs: this.droppedRefs,
functionUsageFailures: this.functionUsageFailures,
consistencyIssues, consistencyIssues,
ingestWarnings: cliWarningsFromIssues(consistencyIssues), ingestWarnings: cliWarningsFromIssues(consistencyIssues),
}; };
}
} }
/** GITNEXUS_MOVE_STRICT=1|true restores the historical fatal-on-build-failure /** 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; 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( export function createMoveIngestPhase(
client: MoveFlowClient | null, client: MoveFlowClient | null,
): PipelinePhase<MoveIngestOutput> { ): PipelinePhase<MoveIngestOutput> {
@ -224,10 +192,10 @@ export function createMoveIngestPhase(
), ),
].sort(); ].sort();
if (!client || packageRoots.length === 0) { 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) { if (!hasFactsQuery) {
// userActionable: rendered as a one-liner without a stack — the fix is // userActionable: rendered as a one-liner without a stack — the fix is
// an operator action (upgrade move-flow), not a code bug. // an operator action (upgrade move-flow), not a code bug.
@ -238,7 +206,8 @@ export function createMoveIngestPhase(
{ userActionable: true }, { userActionable: true },
); );
} }
const state = createState(); const acc = new MoveIngestAccumulator();
let functionUsageEnabled = hasFunctionUsageQuery;
// Group scanned .move files by their (innermost) owning package. Files // Group scanned .move files by their (innermost) owning package. Files
// are marked as ingested per package only AFTER its facts arrive, so a // are marked as ingested per package only AFTER its facts arrive, so a
@ -261,12 +230,11 @@ export function createMoveIngestPhase(
moveFilesByPackage.set(owner, files); 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 emptyFactsPackages: EmptyFactsPackage[] = [];
const packageIssues: MoveConsistencyIssue[] = []; const packageIssues: MoveConsistencyIssue[] = [];
const strictMove = isStrictMove(); 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) { for (const pkgRoot of packageRoots) {
ctx.onProgress({ ctx.onProgress({
phase: 'moveIngest', phase: 'moveIngest',
@ -290,22 +258,17 @@ export function createMoveIngestPhase(
let callGraphData: CallGraphMap; let callGraphData: CallGraphMap;
let factsMap: MoveFactsMap; let factsMap: MoveFactsMap;
try { try {
callGraphData = await client.callGraph(pkgRoot); [callGraphData, factsMap] = await Promise.all([
factsMap = await client.facts(pkgRoot); client.callGraph(pkgRoot),
client.facts(pkgRoot),
]);
} catch (err) { } catch (err) {
if (err instanceof MoveFlowToolCallError) {
// A Move package that does not build (bad manifest, missing // A Move package that does not build (bad manifest, missing
// dependency, unresolved address) is an operator problem, not a // dependency, unresolved address) is an operator problem, not a code
// code bug. Default: skip the package (its files stay un-ingested, // bug. Default: skip the package (its files stay un-ingested, like the
// like the empty-facts path) and surface a persistent warning — // empty-facts path) and surface a persistent warning — one broken
// one broken auxiliary package must not abort the whole analyze. // auxiliary package must not abort the whole analyze.
if (strictMove) { if (err instanceof MoveFlowToolCallError && !strictMove) {
// userActionable: rendered as a one-liner without a stack.
throw Object.assign(
new Error(`move-flow could not build Move package ${pkgRoot}: ${err.message}`),
{ userActionable: true },
);
}
packageIssues.push( packageIssues.push(
buildFailedIssue({ buildFailedIssue({
pkgRoot, pkgRoot,
@ -321,7 +284,10 @@ export function createMoveIngestPhase(
}); });
continue; 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) { 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 // Only past the gate: a skipped package must have zero footprint in
// Pass 2 linking and consistency validation. // Pass 2 linking and consistency validation.
state.callGraphByPackage.set(pkgRoot, callGraphData); acc.callGraphByPackage.set(pkgRoot, callGraphData);
for (const rel of pkgMoveFiles) state.ingestedFiles.add(rel); if (functionUsageEnabled) {
const closureCapture = await collectClosureCaptureCalls(
applyMapped(ctx.graph, mapFactsToGraph(factsMap, pkgRoot, ctx.repoPath), pkgRoot, state); 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 // Facts arrived, but move-flow serves structurally complete facts even
// for builds with compiler errors — and such builds silently lose the // 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. // Pass 2+: link edges that need the full cross-package node index.
linkCallEdges(ctx.graph, state); linkMoveIngestGraph(ctx.graph, acc);
linkLambdaHostEdges(ctx.graph, state);
linkResourceAndFriendEdges(ctx.graph, state);
linkFileImports(ctx.graph, state);
linkFileModuleContains(ctx.graph, state);
const output = toOutput(state, packageRoots); const output = acc.toOutput(packageRoots);
createMoveEntryPointEdges(ctx.graph, output); createMoveEntryPointEdges(ctx.graph, output);
const consistencyIssues: MoveConsistencyIssue[] = [ 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 --- // --- Empty-facts discrimination ---
/** Cross-check the build status of a sources-but-empty-facts package. /** 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). */ /** Surface consistency errors so they reach a human/log (not just the output). */
function reportConsistencyIssues(ctx: PipelineContext, issues: MoveConsistencyIssue[]): void { function reportConsistencyIssues(ctx: PipelineContext, issues: MoveConsistencyIssue[]): void {
const errors = issues.filter((i) => i.severity === 'error'); 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 type { MoveCompilerIdentity } from './constants.js';
import { import {
createMoveFlowClient, createMoveFlowClient,
@ -43,15 +47,70 @@ const defaultDependencies: MoveFlowProvisionDependencies = {
install: installMoveFlow, 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', source: 'explicit' | 'path',
locator: string, locator: string,
version: string, version: string,
): MoveCompilerIdentity => ({ 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, version,
source, source,
fingerprint: createHash('sha256').update(`${source}\0${locator}\0${version}`).digest('hex'), fingerprint: createHash('sha256')
}); .update(`${source}\0${resolvedPath ?? locator}\0${version}\0${binaryDigest}`)
.digest('hex'),
};
};
const verifiedRuntime = ( const verifiedRuntime = (
binary: VerifiedMoveFlowBinary, binary: VerifiedMoveFlowBinary,
@ -98,7 +157,7 @@ export async function ensureMoveFlowRuntime(
if (resolved) { if (resolved) {
return { return {
client: resolved.client, client: resolved.client,
identity: localIdentity('explicit', explicitPath, resolved.version), identity: await localIdentity('explicit', explicitPath, resolved.version, options.onLog),
}; };
} }
options.onLog?.( options.onLog?.(
@ -119,7 +178,7 @@ export async function ensureMoveFlowRuntime(
// terminals/CI steps, and a fingerprint churn forces a full re-index. // terminals/CI steps, and a fingerprint churn forces a full re-index.
return { return {
client: existing.client, 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}`; 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( export function moveEnumVariantNodeId(
enumQualifiedName: string, enumQualifiedName: string,
variantName: string, variantName: string,

View file

@ -17,6 +17,12 @@ const MOVE_PRIMITIVES = new Set([
'u64', 'u64',
'u128', 'u128',
'u256', 'u256',
'i8',
'i16',
'i32',
'i64',
'i128',
'i256',
'address', 'address',
'signer', 'signer',
'&signer', '&signer',
@ -25,11 +31,23 @@ const MOVE_PRIMITIVES = new Set([
export function extractTypeNames(typeExpr: string): string[] { export function extractTypeNames(typeExpr: string): string[] {
if (!typeExpr) return []; 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 []; if (MOVE_PRIMITIVES.has(expr)) return [];
const tokens: string[] = []; 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+)?/, ''); const name = raw.trim().replace(/^&(mut\s+)?/, '');
if (!name || MOVE_PRIMITIVES.has(name)) continue; if (!name || MOVE_PRIMITIVES.has(name)) continue;
tokens.push(name); tokens.push(name);

View file

@ -19,6 +19,7 @@ import {
persistedMoveGraphRequiresCompiler, persistedMoveGraphRequiresCompiler,
shouldProvisionMoveFlowBeforeFastPath, shouldProvisionMoveFlowBeforeFastPath,
} from './move/constants.js'; } from './move/constants.js';
import { summarizeMoveConsistency } from './move/consistency.js';
import { createMoveIngestPhase } from './move/move-ingest.js'; import { createMoveIngestPhase } from './move/move-ingest.js';
import { repoHasMove } from './move/discovery.js'; import { repoHasMove } from './move/discovery.js';
import { getMoveFlowReleaseSelection } from './move/install.js'; import { getMoveFlowReleaseSelection } from './move/install.js';
@ -40,6 +41,7 @@ import {
deleteAllInterprocTaintPaths, deleteAllInterprocTaintPaths,
deleteAllCallSummaries, deleteAllCallSummaries,
deleteAllInjects, deleteAllInjects,
deleteAllExternalNodes,
queryImportersBatch, queryImportersBatch,
loadFTSExtension, loadFTSExtension,
wipeLbugDbFiles, wipeLbugDbFiles,
@ -1330,9 +1332,7 @@ export async function runFullAnalysis(
// ── Phase 1: Full Pipeline (0–60%) ──────────────────────────────── // ── Phase 1: Full Pipeline (0–60%) ────────────────────────────────
// `finally` guarantees the spawned move-flow child is released even if the // `finally` guarantees the spawned move-flow child is released even if the
// pipeline throws — important for long-running hosts (MCP daemon, eval-server). // pipeline throws — important for long-running hosts (MCP daemon, eval-server).
let pipelineResult: Awaited<ReturnType<typeof runPipelineFromRepo>>; const pipelineResult = await runPipelineFromRepo(
try {
pipelineResult = await runPipelineFromRepo(
repoPath, repoPath,
(p) => { (p) => {
const phaseLabel = PHASE_LABELS[p.phase] || p.phase; const phaseLabel = PHASE_LABELS[p.phase] || p.phase;
@ -1365,9 +1365,19 @@ export async function runFullAnalysis(
pdgEmitChunkSize: resolvePdgEmitChunkSize(options), pdgEmitChunkSize: resolvePdgEmitChunkSize(options),
fetchWrappers: options.fetchWrappers, 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 (60–85%) ────────────────────────────────── // ── Phase 2: LadybugDB (60–85%) ──────────────────────────────────
@ -1927,6 +1937,15 @@ export async function runFullAnalysis(
// deleting on every non-pdg incremental run (N runs = N copies of // deleting on every non-pdg incremental run (N runs = N copies of
// every INJECTS row; CodeRelation has no PK and no read-side dedup). // every INJECTS row; CodeRelation has no PK and no read-side dedup).
await deleteAllInjects(); 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 // 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 // — their validity is a whole-program property (an A→C flow can be
// invalidated by a change to an intermediate function on a third // 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 // force a full rebuild), so when the compiler is transiently unavailable
// the previous record still describes the persisted facts. Stamping // the previous record still describes the persisted facts. Stamping
// false/undefined here would force a needless full rebuild the moment the // 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 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 // Annotated so the capabilities stamp below is compile-checked against
// RepoMeta's status unions (tri-review 4669518496 P1/U3) — an unannotated // 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. // absence, so this is never conditionally omitted.
cjkSegmentation: getSearchFTSCjkSegmentation(), cjkSegmentation: getSearchFTSCjkSegmentation(),
fileHashes: hasGitDir(repoPath) ? newFileHashesRecord : undefined, fileHashes: hasGitDir(repoPath) ? newFileHashesRecord : undefined,
moveIngestAvailable: preserveMoveMeta ...moveMeta,
? existingMeta?.moveIngestAvailable
: moveIngestAvailable,
moveCompilerIdentity: preserveMoveMeta
? existingMeta?.moveCompilerIdentity
: moveFlow?.identity,
moveFlowReleaseSelection: preserveMoveMeta
? existingMeta?.moveFlowReleaseSelection
: moveFlow?.identity.source === 'release'
? desiredMoveFlowRelease
: undefined,
// This branch's full live chunk-key set (#2106 R6). `usedKeys` is every // 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 // chunk hash touched in this scan — cache HITS included (see parse-impl
// usedKeys.add) — so it's complete even on an incremental run. Persisted // usedKeys.add) — so it's complete even on an incremental run. Persisted
@ -2691,7 +2715,7 @@ export async function runFullAnalysis(
stats: meta.stats, stats: meta.stats,
pipelineResult, pipelineResult,
ftsSkipped: !ftsReady, ftsSkipped: !ftsReady,
ingestWarnings: pipelineResult.ingestWarnings, ingestWarnings: pipelineResult.standaloneIngest.ingestWarnings,
isPrimaryBranch: !placement.branch, isPrimaryBranch: !placement.branch,
}; };
} catch (err) { } catch (err) {

View file

@ -22,6 +22,7 @@ import { randomBytes } from 'crypto';
import { getInferredRepoName, resolveRepoIdentityRoot } from './git.js'; import { getInferredRepoName, resolveRepoIdentityRoot } from './git.js';
import { retryRename } from './fs-atomic.js'; import { retryRename } from './fs-atomic.js';
import { logger } from '../core/logger.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 type { MoveCompilerIdentity, MoveFlowReleaseSelection } from '../core/move/constants.js';
import { import {
branchSlug, branchSlug,
@ -228,6 +229,12 @@ export interface RepoMeta {
moveCompilerIdentity?: MoveCompilerIdentity; moveCompilerIdentity?: MoveCompilerIdentity;
/** Managed release coordinates, kept separate from the binary fingerprint. */ /** Managed release coordinates, kept separate from the binary fingerprint. */
moveFlowReleaseSelection?: MoveFlowReleaseSelection; 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 * Crash-recovery dirty flag — a generic marker written to the metadata
* file (gitnexus.json + its meta.json mirror) BEFORE any destructive DB * 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 * stamp of 9 is ambiguous between the two lineages, and a pre-merge Move
* index also predates main's v9–v11 rebuild reasons. Any pre-v12 stamp fails * index also predates main's v9–v11 rebuild reasons. Any pre-v12 stamp fails
* the strict-equality reuse gate; force a full re-analyze (same contract as v3). * the strict-equality reuse gate; force a full re-analyze (same contract as v3).
* v13: generic Type nodes and the Move EnumVariant→Property / 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 { export interface IndexedRepo {
repoPath: string; repoPath: string;

View file

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

View file

@ -4,16 +4,105 @@
* Shared by the unit (move-ingest-*) and integration (move-live) Move tests. * Shared by the unit (move-ingest-*) and integration (move-live) Move tests.
*/ */
import { createMoveIngestPhase, type MoveIngestOutput } from '../../src/core/move/move-ingest.js'; 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 type { MoveFlowClient } from '../../src/core/move/mcp-client.js';
import { createKnowledgeGraph } from '../../src/core/graph/graph.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'; 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`. */ /** Run the moveIngest phase over `filePaths` (repo-relative) under `repoPath`. */
export async function runMoveIngestPhase( export async function runMoveIngestPhase(
client: MoveFlowClient | null, client: MoveFlowClient | null,
repoPath: string, repoPath: string,
filePaths: readonly string[], filePaths: readonly string[],
): Promise<MoveIngestOutput> { ): 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>>([ const deps = new Map<string, PhaseResult<unknown>>([
[ [
'structure', 'structure',
@ -29,13 +118,15 @@ export async function runMoveIngestPhase(
}, },
], ],
]); ]);
return createMoveIngestPhase(client).execute( const graph = createKnowledgeGraph();
const output = await createMoveIngestPhase(client).execute(
{ {
repoPath, repoPath,
graph: createKnowledgeGraph(), graph,
onProgress: () => {}, onProgress: () => {},
pipelineStart: Date.now(), pipelineStart: Date.now(),
}, },
deps, deps,
); );
return { output, graph };
} }

View file

@ -170,6 +170,43 @@ withTestLbugDB(
expect(Number((queriesLeft[0] as { cnt: number }).cnt)).toBe(1); 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', () => { describe('unhappy path', () => {
it('throws on malformed Cypher query', async () => { it('throws on malformed Cypher query', async () => {
const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js'); 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 () => { it('detects the facts query capability', async () => {
const caps = await client!.capabilities(); const caps = await client!.capabilities();
expect(caps.hasFactsQuery).toBe(true); expect(caps.hasFactsQuery).toBe(true);
expect(caps.hasFunctionUsageQuery).toBe(true);
}); });
it('builds a full-fidelity Move graph from compiler facts', async () => { 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: 'Struct', otherLanguage: 'rust', detailProperty: 'isResource', moveDetail: true },
{ table: 'Enum', otherLanguage: 'rust', detailProperty: 'isEvent', moveDetail: true }, { table: 'Enum', otherLanguage: 'rust', detailProperty: 'isEvent', moveDetail: true },
{
table: 'Type',
otherLanguage: 'typescript',
detailProperty: 'locationFidelity',
moveDetail: 'external',
},
{ {
table: 'EnumVariant', table: 'EnumVariant',
otherLanguage: 'rust', 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,18 +90,20 @@ withTestLbugDB(
await fs.mkdir(repoPath, { recursive: true }); await fs.mkdir(repoPath, { recursive: true });
const graph = buildTestGraph( const graph = buildTestGraph(
sharedTables.flatMap((testCase) => [ [
...sharedTables.flatMap((testCase) => [
{ {
id: `${testCase.table}:move`, id: `${testCase.table}:move`,
label: testCase.table as NodeLabel, label: testCase.table as NodeLabel,
name: testCase.table, name: testCase.table,
filePath: 'sources/sample.move', filePath: testCase.table === 'Type' ? '' : 'sources/sample.move',
startLine: 1, startLine: 1,
endLine: 2, endLine: 2,
isExported: testCase.table === 'Function', isExported: testCase.table === 'Function',
extra: { extra: {
language: 'move', language: 'move',
qualifiedName: `0x1::sample::${testCase.table}`, qualifiedName: `0x1::sample::${testCase.table}`,
moduleQualifiedName: '0x1::sample',
[testCase.detailProperty]: testCase.moveDetail, [testCase.detailProperty]: testCase.moveDetail,
}, },
}, },
@ -88,6 +118,60 @@ withTestLbugDB(
extra: { language: testCase.otherLanguage }, extra: { language: testCase.otherLanguage },
}, },
]), ]),
{
id: 'Property:move-field',
label: 'Property',
name: 'field',
filePath: 'sources/sample.move',
startLine: 1,
endLine: 2,
extra: { declaredType: '0x1::sample::Type' },
},
],
[
{
sourceId: 'EnumVariant:move',
targetId: 'Property:move-field',
type: 'HAS_PROPERTY',
reason: 'move-roundtrip-1',
},
{
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'); 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)', () => { describe('incremental schema reuse gate', () => {
it('INCREMENTAL_SCHEMA_VERSION is bumped to 12 (main-aptos merge: Move attributesJson past main v9–v11)', () => { it('INCREMENTAL_SCHEMA_VERSION is bumped to 13 (persisted Move Type/field graph)', () => {
expect(INCREMENTAL_SCHEMA_VERSION).toBe(12); expect(INCREMENTAL_SCHEMA_VERSION).toBe(13);
}); });
it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => { 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 production gate compares the persisted numeric stamp by strict
// the persisted `existingMeta.schemaVersion` (a plain number, possibly // equality; legacy metadata may omit it.
// absent on a legacy stamp). Replicate it as a typed predicate.
const passesReuseGate = (stampedSchemaVersion: number | undefined): boolean => const passesReuseGate = (stampedSchemaVersion: number | undefined): boolean =>
stampedSchemaVersion === INCREMENTAL_SCHEMA_VERSION; stampedSchemaVersion === INCREMENTAL_SCHEMA_VERSION;
// A pre-v4 (v3) index has no CALL_SUMMARY edges → must NOT reuse. // 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 // `attributesJson` node-table column, so the bulk COPY referencing it
// would fail on a top-up → must NOT reuse. // would fail on a top-up → must NOT reuse.
expect(passesReuseGate(11)).toBe(false); 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). // 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']); 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', () => { it('includes a relationship when at least one endpoint is writable', () => {
const g = createKnowledgeGraph(); const g = createKnowledgeGraph();
g.addNode(makeFileNode('a:fn', '/repo/a.ts')); 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 { 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 type { MoveIngestOutput } from '../../../src/core/move/move-ingest.js';
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js'; import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
@ -21,6 +24,8 @@ function makeOutput(overrides: Partial<MoveIngestOutput> = {}): MoveIngestOutput
modulePackageMap: new Map(), modulePackageMap: new Map(),
filePackageMap: new Map(), filePackageMap: new Map(),
callGraphByPackage: new Map(), callGraphByPackage: new Map(),
droppedRefs: [],
functionUsageFailures: [],
consistencyIssues: [], consistencyIssues: [],
...overrides, ...overrides,
}; };
@ -199,11 +204,180 @@ describe('validateMoveIngestOutput', () => {
const output = makeOutput({ const output = makeOutput({
ingestedFiles: new Set([file]), ingestedFiles: new Set([file]),
moduleFileMap: new Map([['0xa::m', 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); const issues = validateMoveIngestOutput(graph, output);
expect( expect(
issues.some((i) => i.code === 'unresolved-resource-target' && i.severity === 'warning'), issues.some((i) => i.code === 'unresolved-resource-target' && i.severity === 'warning'),
).toBe(true); ).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() { async callGraph() {
return {}; return {};
}, },
async functionUsage() {
return { called: [], used: [] };
},
async packageStatus() { async packageStatus() {
return { ok: true, diagnostics: '' }; return { ok: true, diagnostics: '' };
}, },
async capabilities() { async capabilities() {
return { hasFactsQuery: true, hasStatusTool: false }; return { hasFactsQuery: true, hasFunctionUsageQuery: false, hasStatusTool: false };
}, },
async shutdown() {}, 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 { describe, it, expect } from 'vitest';
import { mapFactsToGraph } from '../../../src/core/move/facts-mapper.js';
import { import {
buildLocalNameIndex, buildLocalNameIndex,
mapFactsToGraph, resolveRefs,
resolveFriendEdges, ExternalMoveSymbols,
resolveResourceEdges, } from '../../../src/core/move/move-linker.js';
resolveTypeRefEdges, import type { DroppedRef } from '../../../src/core/move/refs.js';
} from '../../../src/core/move/facts-mapper.js';
import { import {
type MoveFactsFunction, type MoveFactsFunction,
type MoveFactsMap, type MoveFactsMap,
} from '../../../src/core/move/compiler-facts.js'; } 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 { function fnFixture(name: string, overrides: Partial<MoveFactsFunction> = {}): MoveFactsFunction {
return { return moveFunctionFact(name, {
name,
file: '/pkg/sources/fixture.move', file: '/pkg/sources/fixture.move',
span: [1, 3], span: [1, 3],
visibility: 'public', visibility: 'public',
isEntry: false,
isInline: false,
isNative: false,
isView: false,
attributes: [], attributes: [],
typeParams: [], typeParams: [],
params: [], params: [],
returnTypes: [],
acquiresInferred: [], acquiresInferred: [],
resourceAccess: { reads: [], writes: [] }, resourceAccess: { reads: [], writes: [] },
isLambdaLifted: false, isLambdaLifted: false,
...overrides, ...overrides,
}; });
} }
// Trimmed-but-faithful slice of a real `move_package_query { query: "facts" }` // Trimmed-but-faithful slice of a real `move_package_query { query: "facts" }`
@ -112,29 +108,29 @@ const facts: MoveFactsMap = {
}, },
}; };
function mapFactsToGraphWithResolvedEdges( function mapFactsToGraphWithResolvedEdges(factsMap: MoveFactsMap) {
factsMap: MoveFactsMap,
): ReturnType<typeof mapFactsToGraph> {
const mapped = mapFactsToGraph(factsMap, '/pkg'); 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 localNameIndex = buildLocalNameIndex(mapped.structNodeMap);
const addResolvedUsedType = (sourceId: string, targetId: string): void => { const external = new ExternalMoveSymbols(graph, mapped.moduleFileMap);
const fnNode = mapped.nodes.find((n) => n.id === sourceId); const drops: DroppedRef[] = [];
const typeNode = mapped.nodes.find((n) => n.id === targetId); resolveRefs(
const qualifiedName = typeNode?.properties.qualifiedName; graph,
if (!fnNode || typeof qualifiedName !== 'string') return; mapped.pendingRefs,
const current = Array.isArray(fnNode.properties.usedTypes) ? fnNode.properties.usedTypes : []; {
if (!current.includes(qualifiedName)) fnNode.properties.usedTypes = [...current, qualifiedName]; structNodeMap: mapped.structNodeMap,
}; structIdsByLocalName: localNameIndex,
resolveResourceEdges(mapped.pendingResource, mapped.structNodeMap, localNameIndex, (rel) => moduleFileMap: mapped.moduleFileMap,
edges.push(rel), functionNodeMap: mapped.functionNodeMap,
},
external,
drops,
); );
resolveFriendEdges(mapped.pendingFriends, mapped.moduleFileMap, (rel) => edges.push(rel)); const edges = [...graph.iterRelationships()];
resolveTypeRefEdges(mapped.pendingTypeRef, mapped.structNodeMap, localNameIndex, (rel) => { const nodes = [...graph.iterNodes()];
edges.push(rel); return { ...mapped, nodes, edges };
addResolvedUsedType(rel.sourceId, rel.targetId);
});
return { ...mapped, edges };
} }
describe('mapFactsToGraph', () => { describe('mapFactsToGraph', () => {
@ -193,10 +189,36 @@ describe('mapFactsToGraph', () => {
const { nodes } = mapFactsToGraph(facts, '/pkg'); const { nodes } = mapFactsToGraph(facts, '/pkg');
const fn = nodes.find((n) => n.properties.name === 'balance_of'); const fn = nodes.find((n) => n.properties.name === 'balance_of');
expect(fn?.properties.isView).toBe(true); expect(fn?.properties.isView).toBe(true);
expect(fn?.properties.isExported).toBe(true);
expect(fn?.properties.locationFidelity).toBe('precise'); expect(fn?.properties.locationFidelity).toBe('precise');
expect(fn?.properties.startLine).toBe(33); 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)', () => { it('tolerates functions/modules with missing optional arrays (real move-flow shape)', () => {
// move-flow omits optional array fields (acquiresInferred, resourceAccess, // move-flow omits optional array fields (acquiresInferred, resourceAccess,
// params, typeParams, attributes, friends, types, constants) for some symbols. // params, typeParams, attributes, friends, types, constants) for some symbols.
@ -348,6 +370,75 @@ describe('mapFactsToGraph', () => {
expect(v?.properties.locationFidelity).toBe('module'); 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)', () => { it('does not write moduleAddress on Function nodes (only Module/Struct/Enum carry it)', () => {
const { nodes } = mapFactsToGraph(facts, '/pkg'); const { nodes } = mapFactsToGraph(facts, '/pkg');
const fn = nodes.find((n) => n.label === 'Function' && n.properties.name === 'register'); const fn = nodes.find((n) => n.label === 'Function' && n.properties.name === 'register');
@ -535,8 +626,10 @@ describe('mapFactsToGraph', () => {
constants: [], constants: [],
}, },
}; };
const { pendingTypeRef } = mapFactsToGraph(rtFacts, '/pkg'); const { pendingRefs } = mapFactsToGraph(rtFacts, '/pkg');
const returnRefs = pendingTypeRef.filter((p) => p.reason === 'move-fn-return-type'); const returnRefs = pendingRefs.filter(
(p) => p.kind === 'type' && p.reason === 'move-fn-return-type',
);
expect(returnRefs.map((p) => p.target)).toEqual(['0xbeef::price::PriceInfo']); expect(returnRefs.map((p) => p.target)).toEqual(['0xbeef::price::PriceInfo']);
}); });
@ -588,18 +681,33 @@ describe('mapFactsToGraph', () => {
}, },
}; };
const mapped = mapFactsToGraph(qualFacts, '/pkg'); 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 localNameIndex = buildLocalNameIndex(mapped.structNodeMap);
const resolvedEdges: unknown[] = []; const external = new ExternalMoveSymbols(graph, mapped.moduleFileMap);
const unresolved: string[] = []; const drops: DroppedRef[] = [];
resolveResourceEdges( const resourceRefs = mapped.pendingRefs.filter((r) => r.kind === 'resource');
mapped.pendingResource, resolveRefs(
mapped.structNodeMap, graph,
localNameIndex, resourceRefs,
(rel) => resolvedEdges.push(rel), {
(pending) => unresolved.push(pending.target), 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', () => { it('projects full attribute payloads (args/values) as attributesJson alongside the name list', () => {
@ -704,10 +812,31 @@ describe('mapFactsToGraph', () => {
constants: [], constants: [],
}, },
}; };
const { pendingLambdaHosts } = mapFactsToGraph(lambdaFacts, '/pkg'); const { pendingRefs } = mapFactsToGraph(lambdaFacts, '/pkg');
expect(pendingLambdaHosts.map((p) => p.hostQualified)).toEqual([ const lambdaRefs = pendingRefs.filter((r) => r.kind === 'lambda-host');
'0xa::vault::lifted', expect(lambdaRefs.map((p) => p.target)).toEqual(['0xa::vault::lifted', '0xa::vault::outer']);
'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 { import {
MoveFlowMcpClient, MoveFlowMcpClient,
MoveFlowToolCallError, MoveFlowToolCallError,
MoveFlowTransportError,
tryResolveMoveFlowClient, tryResolveMoveFlowClient,
detectMoveFlowCapabilities, detectMoveFlowCapabilities,
} from '../../../src/core/move/mcp-client.js'; } from '../../../src/core/move/mcp-client.js';
@ -213,6 +214,43 @@ describe('MoveFlowMcpClient', () => {
await client.shutdown(); 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 () => { it('ignores a late response after a tool timeout and starts a fresh child', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
process.env.GITNEXUS_MOVE_FLOW_TIMEOUT_MS = '25'; process.env.GITNEXUS_MOVE_FLOW_TIMEOUT_MS = '25';
@ -371,6 +409,29 @@ describe('MoveFlowMcpClient', () => {
await client.shutdown(); 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 () => { it('packageStatus reports ok on an isError:false status result', async () => {
// move-flow returns "no errors or warnings" for a compiling package. // move-flow returns "no errors or warnings" for a compiling package.
const proc = createMockProc(); const proc = createMockProc();
@ -403,12 +464,72 @@ describe('MoveFlowMcpClient', () => {
}); });
await client.shutdown(); 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', () => { describe('detectMoveFlowCapabilities', () => {
it('reports facts support from a standalone move_package_facts tool name', () => { it('reports facts support from a standalone move_package_facts tool name', () => {
const caps = detectMoveFlowCapabilities(['move_package_query', 'move_package_facts']); const caps = detectMoveFlowCapabilities(['move_package_query', 'move_package_facts']);
expect(caps.hasFactsQuery).toBe(true); expect(caps.hasFactsQuery).toBe(true);
expect(caps.hasFunctionUsageQuery).toBe(false);
}); });
it('detects the facts query from the move_package_query inputSchema enum', () => { it('detects the facts query from the move_package_query inputSchema enum', () => {
@ -420,23 +541,32 @@ describe('detectMoveFlowCapabilities', () => {
inputSchema: { inputSchema: {
$defs: { $defs: {
QueryType: { 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.hasFactsQuery).toBe(true);
expect(caps.hasFunctionUsageQuery).toBe(true);
}); });
it('also detects facts from a flat enum schema', () => { it('also detects facts from a flat enum schema', () => {
const caps = detectMoveFlowCapabilities([ const caps = detectMoveFlowCapabilities([
{ {
name: 'move_package_query', 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.hasFactsQuery).toBe(true);
expect(caps.hasFunctionUsageQuery).toBe(true);
}); });
it('reports facts absent when the schema omits it', () => { it('reports facts absent when the schema omits it', () => {
@ -448,11 +578,13 @@ describe('detectMoveFlowCapabilities', () => {
'move_package_manifest', 'move_package_manifest',
]); ]);
expect(caps.hasFactsQuery).toBe(false); expect(caps.hasFactsQuery).toBe(false);
expect(caps.hasFunctionUsageQuery).toBe(false);
}); });
it('reports facts absent when move_package_query is missing entirely', () => { it('reports facts absent when move_package_query is missing entirely', () => {
const caps = detectMoveFlowCapabilities(['move_package_status']); const caps = detectMoveFlowCapabilities(['move_package_status']);
expect(caps.hasFactsQuery).toBe(false); expect(caps.hasFactsQuery).toBe(false);
expect(caps.hasFunctionUsageQuery).toBe(false);
expect(caps.hasStatusTool).toBe(true); expect(caps.hasStatusTool).toBe(true);
}); });

View file

@ -10,19 +10,12 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import path from 'node:path'; import path from 'node:path';
import type { MoveFlowClient } from '../../../src/core/move/mcp-client.js'; 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'); const REPO_ROOT = path.resolve('/repo');
function makeClient(overrides: Partial<MoveFlowClient> = {}): MoveFlowClient { function makeClient(overrides: Partial<MoveFlowClient> = {}): MoveFlowClient {
return { return makeMoveFlowClientStub(overrides);
facts: async () => ({}),
callGraph: async () => ({}),
packageStatus: async () => ({ ok: true, diagnostics: 'no errors or warnings' }),
capabilities: async () => ({ hasFactsQuery: true, hasStatusTool: true }),
shutdown: async () => {},
...overrides,
};
} }
/** Run the phase against a fake repo with one package holding one .move file. */ /** 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 () => { it('falls back to the error-level issue when move_package_status is unavailable', async () => {
const output = await runPhase( const output = await runPhase(
makeClient({ 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 { describe, it, expect } from 'vitest';
import path from 'node:path'; import path from 'node:path';
import type { MoveFlowClient } from '../../../src/core/move/mcp-client.js'; import { makeMoveFlowClientStub, runMoveIngestPhase } from '../../helpers/move-ingest-harness.js';
import { runMoveIngestPhase } from '../../helpers/move-ingest-harness.js';
const REPO_ROOT = path.resolve('/repo'); const REPO_ROOT = path.resolve('/repo');
/** Client returning empty facts for every package, without a status tool. */ const emptyFactsClient = () =>
function emptyFactsClient(): MoveFlowClient { makeMoveFlowClientStub({
return { capabilities: async () => ({
facts: async () => ({}), hasFactsQuery: true,
callGraph: async () => ({}), hasFunctionUsageQuery: false,
packageStatus: async () => ({ ok: true, diagnostics: '' }), hasStatusTool: false,
capabilities: async () => ({ hasFactsQuery: true, hasStatusTool: false }), }),
shutdown: async () => {}, });
};
}
describe('moveIngest package-ownership attribution', () => { describe('moveIngest package-ownership attribution', () => {
it('attributes each file to its own package across sibling packages pkg_a / pkg_ab', async () => { 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 { 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 { import type {
MoveFlowMcpClient, MoveFlowMcpClient,
ResolvedMoveFlowClient, ResolvedMoveFlowClient,
@ -12,6 +15,8 @@ import type { VerifiedMoveFlowBinary } from '../../../src/core/move/install.js';
const originalMoveFlow = process.env.MOVE_FLOW; const originalMoveFlow = process.env.MOVE_FLOW;
afterEach(() => { afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
if (originalMoveFlow === undefined) delete process.env.MOVE_FLOW; if (originalMoveFlow === undefined) delete process.env.MOVE_FLOW;
else process.env.MOVE_FLOW = originalMoveFlow; else process.env.MOVE_FLOW = originalMoveFlow;
}); });
@ -42,6 +47,64 @@ describe('ensureMoveFlowRuntime', () => {
expect(deps.install).not.toHaveBeenCalled(); 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 () => { it('keeps an invalid explicit MOVE_FLOW authoritative', async () => {
process.env.MOVE_FLOW = '/missing/move-flow'; process.env.MOVE_FLOW = '/missing/move-flow';
const deps = dependencies(); 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('u64')).toEqual([]);
expect(extractTypeNames('address')).toEqual([]); expect(extractTypeNames('address')).toEqual([]);
expect(extractTypeNames('bool')).toEqual([]); expect(extractTypeNames('bool')).toEqual([]);
expect(extractTypeNames('i64')).toEqual([]);
expect(extractTypeNames('i256')).toEqual([]);
expect(extractTypeNames('&signer')).toEqual([]); expect(extractTypeNames('&signer')).toEqual([]);
}); });
@ -31,4 +33,24 @@ describe('extractTypeNames', () => {
expect(extractTypeNames('vector<u8>')).toEqual([]); expect(extractTypeNames('vector<u8>')).toEqual([]);
expect(extractTypeNames('Option<Vault>')).toEqual(['Option', 'Vault']); 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, FUNCTION_SCHEMA,
MODULE_SCHEMA, MODULE_SCHEMA,
STRUCT_SCHEMA, STRUCT_SCHEMA,
TYPE_SCHEMA,
} from '../../src/core/lbug/schema.js'; } from '../../src/core/lbug/schema.js';
import { import {
getNodeTableCsvHeader, getNodeTableCsvHeader,
@ -20,6 +21,7 @@ const schemas: Record<LayoutTableName, string> = {
Function: FUNCTION_SCHEMA, Function: FUNCTION_SCHEMA,
Struct: STRUCT_SCHEMA, Struct: STRUCT_SCHEMA,
Enum: ENUM_SCHEMA, Enum: ENUM_SCHEMA,
Type: TYPE_SCHEMA,
EnumVariant: ENUM_VARIANT_SCHEMA, EnumVariant: ENUM_VARIANT_SCHEMA,
Const: CONST_SCHEMA, Const: CONST_SCHEMA,
Module: MODULE_SCHEMA, Module: MODULE_SCHEMA,

View file

@ -524,6 +524,73 @@ describe('processProcesses', () => {
expect(result.stats.totalProcesses).toBeLessThanOrEqual(3); 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 // Regression for #2198: the processesPhase dynamic sizing used to cap at
// Math.min(300, symbolCount/10). On large repos (>3000 symbols) that silently // Math.min(300, symbolCount/10). On large repos (>3000 symbols) that silently
// truncated the process index. The cap was removed by extracting // truncated the process index. The cap was removed by extracting

View file

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

View file

@ -14,6 +14,7 @@ import {
INTERFACE_SCHEMA, INTERFACE_SCHEMA,
METHOD_SCHEMA, METHOD_SCHEMA,
PROPERTY_SCHEMA, PROPERTY_SCHEMA,
TYPE_SCHEMA,
CODE_ELEMENT_SCHEMA, CODE_ELEMENT_SCHEMA,
COMMUNITY_SCHEMA, COMMUNITY_SCHEMA,
PROCESS_SCHEMA, PROCESS_SCHEMA,
@ -54,6 +55,7 @@ describe('LadybugDB Schema', () => {
'Trait', 'Trait',
'Impl', 'Impl',
'TypeAlias', 'TypeAlias',
'Type',
'Const', 'Const',
'Static', 'Static',
'Variable', 'Variable',
@ -75,8 +77,8 @@ describe('LadybugDB Schema', () => {
}); });
it('has expected total count', () => { it('has expected total count', () => {
// 9 core + 20 multi-language/Move + Route + Tool + BasicBlock = 33 // 10 core (including Section) + 21 multi-language/Move + Route + Tool + BasicBlock = 34
expect(NODE_TABLES).toHaveLength(33); expect(NODE_TABLES).toHaveLength(34);
}); });
}); });
@ -151,6 +153,13 @@ describe('LadybugDB Schema', () => {
expect(PROPERTY_SCHEMA).toContain('declaredType STRING'); 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)', () => { it('BasicBlock schema is wired into SCHEMA_QUERIES (issue #2080, F1 guard)', () => {
// Defining BASICBLOCK_SCHEMA is not enough — it must be appended to // Defining BASICBLOCK_SCHEMA is not enough — it must be appended to
// NODE_SCHEMA_QUERIES (→ SCHEMA_QUERIES) or initLbug never creates the // 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'); 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)', () => { it('connects BasicBlock to BasicBlock (taint/PDG substrate edges, #2080)', () => {
expect(RELATION_SCHEMA).toContain('FROM BasicBlock TO BasicBlock'); expect(RELATION_SCHEMA).toContain('FROM BasicBlock TO BasicBlock');
}); });
@ -253,8 +272,8 @@ describe('LadybugDB Schema', () => {
describe('schema query ordering', () => { describe('schema query ordering', () => {
it('NODE_SCHEMA_QUERIES has correct count', () => { it('NODE_SCHEMA_QUERIES has correct count', () => {
// 31 + EnumVariant + BasicBlock = 33 // 31 + EnumVariant + BasicBlock + Type = 34
expect(NODE_SCHEMA_QUERIES).toHaveLength(33); expect(NODE_SCHEMA_QUERIES).toHaveLength(34);
}); });
it('REL_SCHEMA_QUERIES has one relation table', () => { it('REL_SCHEMA_QUERIES has one relation table', () => {
@ -262,8 +281,8 @@ describe('LadybugDB Schema', () => {
}); });
it('SCHEMA_QUERIES includes all node + rel + embedding schemas', () => { it('SCHEMA_QUERIES includes all node + rel + embedding schemas', () => {
// 33 node + 1 rel + 1 embedding = 35 // 34 node + 1 rel + 1 embedding = 36
expect(SCHEMA_QUERIES).toHaveLength(35); expect(SCHEMA_QUERIES).toHaveLength(36);
}); });
it('node schemas come before relation schemas in SCHEMA_QUERIES', () => { 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 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 { 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 { 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 FILE = 'chain.ts';
const MODULE = 'scope:module' as ScopeId; const MODULE = 'scope:module' as ScopeId;
@ -199,4 +202,39 @@ describe('callable-value-flow dependency worklist', () => {
), ),
).toEqual(['target']); ).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 type { ParsedFile, ScopeId, Scope } from 'gitnexus-shared';
import { import {
formatScopeResolutionWarningContext,
MAX_PROGRESS_WARNING_CONTEXT_CHARS,
runScopeResolution, runScopeResolution,
type ScopeResolutionSubPhase, type ScopeResolutionSubPhase,
} from '../../../src/core/ingestion/scope-resolution/pipeline/run.js'; } from '../../../src/core/ingestion/scope-resolution/pipeline/run.js';
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js'; import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.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 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 => ({ const mkScope = (id: ScopeId, filePath: string): Scope => ({
id, id,
@ -41,6 +44,37 @@ const stubProvider = {
} as unknown as ScopeResolver; } as unknown as ScopeResolver;
describe('runScopeResolution onProgress', () => { 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', () => { it('emits sub-phases in order for a 3-file input', () => {
const files = [ const files = [
{ path: 'a.py', content: '' }, { path: 'a.py', content: '' },