diff --git a/.gitignore b/.gitignore index 2bde45016..647416935 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,6 @@ gitnexus/web/ # Machine-local skill-evolution evidence (consumed by eval/workflow_bench/evolve.py) eval/workflow_bench/learnings.jsonl + +# Codex CLI local config +.codex/ diff --git a/gitnexus-shared/src/graph/types.ts b/gitnexus-shared/src/graph/types.ts index 64c2aa692..4caf34930 100644 --- a/gitnexus-shared/src/graph/types.ts +++ b/gitnexus-shared/src/graph/types.ts @@ -143,9 +143,10 @@ export type NodeProperties = { /** * Node-location precision. `'precise'` = per-symbol file/span from the * move-flow `facts` query; `'module'` = only the containing module/type file - * is known; `'package'` = coarse package-root fallback. + * is known; `'package'` = coarse package-root fallback; `'external'` = + * declaration belongs to a dependency outside the indexed repository. */ - locationFidelity?: 'precise' | 'module' | 'package'; + locationFidelity?: 'precise' | 'module' | 'package' | 'external'; // BasicBlock (taint/PDG substrate, issue #2080) — reuses filePath/startLine/endLine. text?: string; /** BasicBlock: space-joined leaf callee names invoked in the block — the diff --git a/gitnexus-shared/src/lbug/schema-constants.ts b/gitnexus-shared/src/lbug/schema-constants.ts index d294b61e6..05918f63a 100644 --- a/gitnexus-shared/src/lbug/schema-constants.ts +++ b/gitnexus-shared/src/lbug/schema-constants.ts @@ -29,6 +29,7 @@ export const NODE_TABLES = [ 'Trait', 'Impl', 'TypeAlias', + 'Type', 'Const', 'Static', 'Variable', diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index c58c4fcab..2f6d9c0fb 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -13,6 +13,7 @@ import os from 'os'; import { spawn } from 'child_process'; import v8 from 'v8'; import cliProgress from 'cli-progress'; +import { ANALYZE_PROGRESS_ACTIVE_ENV } from '../core/logger.js'; import { isLbugReady, LbugWipeError } from '../core/lbug/lbug-adapter.js'; import { boundedCheckpointBeforeExit } from '../core/lbug/shutdown-helpers.js'; import { @@ -596,7 +597,7 @@ const ANALYZE_CLI_ENV_KEYS = [ 'GITNEXUS_EMBEDDING_BATCH_SIZE', 'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE', 'GITNEXUS_EMBEDDING_DEVICE', - 'GITNEXUS_ANALYZE_PROGRESS_ACTIVE', + ANALYZE_PROGRESS_ACTIVE_ENV, 'GITNEXUS_EMBEDDING_URL', 'GITNEXUS_EMBEDDING_MODEL', 'GITNEXUS_EMBEDDING_API_KEY', @@ -1316,7 +1317,7 @@ const analyzeCommandImpl = async ( console.warn = barLog; // eslint-disable-next-line no-console -- intentional console-routing for progress bar UX console.error = barLog; - process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE = '1'; + process.env[ANALYZE_PROGRESS_ACTIVE_ENV] = '1'; // Track elapsed time per phase let lastPhaseLabel = 'Initializing...'; diff --git a/gitnexus/src/core/incremental/subgraph-extract.ts b/gitnexus/src/core/incremental/subgraph-extract.ts index 7a5dd4f36..3ae2f2ae8 100644 --- a/gitnexus/src/core/incremental/subgraph-extract.ts +++ b/gitnexus/src/core/incremental/subgraph-extract.ts @@ -9,6 +9,9 @@ * - Every graph-wide node (Community, Process) — these are regenerated * each run by the communities/processes phases and must be fully * rewritten. + * - Every external dependency node (`locationFidelity: 'external'`, no + * filePath) — regenerated each run by the standalone ingest phase and + * delete-all'd by the orchestrator, same contract as Community/Process. * - Every relationship where AT LEAST ONE endpoint is in the writable * set above. Relationships entirely between unchanged-file nodes * are skipped — their rows are still in the DB and re-inserting @@ -54,6 +57,20 @@ import type { KnowledgeGraph } from '../graph/types.js'; const isGraphWide = (label: string): boolean => label === 'Community' || label === 'Process'; +/** + * Externally-declared dependency symbols (compiler-backed ingesters stamp + * `locationFidelity: 'external'`) carry no filePath, so the file-keyed + * delete/write cycle can never refresh them — an external symbol first + * referenced during an incremental run would be skipped here while its edges + * are extracted, and the dangling endpoints would fail the rel COPY into the + * per-row fallback (which silently drops them). They are regenerated + * whole-program by every ingest run, so they get the Community/Process + * treatment: the orchestrator delete-alls them first + * (`deleteAllExternalNodes`) and this extractor re-includes them from the + * fresh graph. + */ +const isExternalNode = (n: GraphNode): boolean => n.properties?.locationFidelity === 'external'; + /** * Relationship types whose VALIDITY is a whole-program property, not a * function of their endpoints' files (#2084 M4 U6). `TAINT_PATH` (cross- @@ -106,7 +123,8 @@ export const extractChangedSubgraph = ( fullGraph.forEachNode((n: GraphNode) => { const filePath = n.properties?.filePath as string | undefined; - const include = (filePath && toWriteSet.has(filePath)) || isGraphWide(n.label); + const include = + (filePath && toWriteSet.has(filePath)) || isGraphWide(n.label) || isExternalNode(n); if (include) { sub.addNode(n); writableNodeIds.add(n.id); diff --git a/gitnexus/src/core/ingestion/filesystem-walker.ts b/gitnexus/src/core/ingestion/filesystem-walker.ts index 823b3670f..6405b8edf 100644 --- a/gitnexus/src/core/ingestion/filesystem-walker.ts +++ b/gitnexus/src/core/ingestion/filesystem-walker.ts @@ -5,7 +5,7 @@ import path from 'path'; import { glob } from 'glob'; import { createIgnoreFilter } from '../../config/ignore-service.js'; -import { logger } from '../logger.js'; +import { warnRespectingProgressBar } from '../logger.js'; /** Lightweight entry — path + size from stat, no content in memory */ export interface ScannedFile { @@ -19,21 +19,8 @@ export interface FilePath { } const READ_CONCURRENCY = 32; -const ANALYZE_PROGRESS_ACTIVE_ENV = 'GITNEXUS_ANALYZE_PROGRESS_ACTIVE'; -const warnLargeFileSkip = (message: string): void => { - if (process.env[ANALYZE_PROGRESS_ACTIVE_ENV] === '1') { - // analyze.ts routes console.warn through the progress bar logger while - // the bar is active. Emitting the operator-facing large-file notice there - // avoids raw pino NDJSON corrupting the one-line progress display in the - // heap-respawn child, whose stderr is intentionally piped for crash - // classification. - // eslint-disable-next-line no-console -- intentionally routed by analyze progress UI - console.warn(message); - return; - } - logger.warn(message); -}; +const warnLargeFileSkip = (message: string): void => warnRespectingProgressBar(message); /** * Phase 1: Scan repository — stat files to get paths + sizes, no content loaded. diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index e47118be4..e7fe1177a 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -48,7 +48,9 @@ import { type ProcessesOutput, } from './pipeline-phases/index.js'; -export interface PipelineOptions { +export interface PipelineOptions< + TStandaloneIngest extends StandaloneIngestOutput = StandaloneIngestOutput, +> { /** * Skip MRO, community detection, and process extraction for faster test runs. * The `pruneLocalSymbols` phase still runs — it is graph construction (it cleans @@ -228,7 +230,7 @@ export interface PipelineOptions { */ keepLocalValueSymbols?: boolean; /** Optional compiler-backed or otherwise standalone ingester. */ - standaloneIngestPhase?: PipelinePhase; + standaloneIngestPhase?: PipelinePhase; /** * Extra fetch-wrapper function names to treat as HTTP consumers, threaded * from `.gitnexusrc` `fetchWrappers` via `AnalyzeOptions` (#1589/#1852 @@ -261,10 +263,12 @@ export interface PipelineOptions { * asserts the produced list is byte-identical to the legacy array for every * options combination. */ -export function buildPhaseList(options?: PipelineOptions): PipelinePhase[] { +export function buildPhaseList( + options?: PipelineOptions, +): PipelinePhase[] { const { standaloneIngestPhase = emptyStandaloneIngestPhase } = options ?? {}; return ( - new PhaseRegistry() + new PhaseRegistry>() .register(scanPhase) .register(structurePhase) .register(standaloneIngestPhase) @@ -295,11 +299,13 @@ export function buildPhaseList(options?: PipelineOptions): PipelinePhase[] { // ── Pipeline orchestrator ───────────────────────────────────────────────── -export const runPipelineFromRepo = async ( +export const runPipelineFromRepo = async < + TStandaloneIngest extends StandaloneIngestOutput = StandaloneIngestOutput, +>( repoPath: string, onProgress: (progress: PipelineProgress) => void, - options?: PipelineOptions, -): Promise => { + options?: PipelineOptions, +): Promise> => { const graph = createKnowledgeGraph(); const pipelineStart = Date.now(); @@ -321,16 +327,10 @@ export const runPipelineFromRepo = async ( let communityResult: CommunitiesOutput['communityResult'] | undefined; let processResult: ProcessesOutput['processResult'] | undefined; - // Standalone-ingest warnings, passed through opaquely (language-neutral). - let ingestWarnings: readonly string[] | undefined; - try { - ingestWarnings = getPhaseOutput( - results, - 'standaloneIngest', - ).ingestWarnings; - } catch { - /* phase filtered out of this run — nothing to surface */ - } + // Standalone ingest is always registered (it defaults to the empty phase), + // so its output — and the language-neutral warnings it passes through — are + // always present. + const standaloneIngest = getPhaseOutput(results, 'standaloneIngest'); const scopeResolutionOutput = getPhaseOutput(results, 'scopeResolution'); const resolutionOutcomes = scopeResolutionOutput.resolutionOutcomes; // Streamed PDG-emit manifest (#2202): present only when streaming was on. @@ -364,6 +364,6 @@ export const runPipelineFromRepo = async ( resolutionOutcomes, usedWorkerPool, pdgEmitManifest, - ingestWarnings, + standaloneIngest, }; }; diff --git a/gitnexus/src/core/ingestion/process-processor.ts b/gitnexus/src/core/ingestion/process-processor.ts index aa744e54d..9ebc867e2 100644 --- a/gitnexus/src/core/ingestion/process-processor.ts +++ b/gitnexus/src/core/ingestion/process-processor.ts @@ -2,7 +2,8 @@ * Process Detection Processor * * Detects execution flows (Processes) in the code graph by: - * 1. Finding entry points (functions with no internal callers) + * 1. Finding entry points (explicit graph roots plus functions that call + * others but have few callers) * 2. Tracing forward via CALLS edges (BFS) * 3. Grouping and deduplicating similar paths * 4. Labeling with heuristic names @@ -10,7 +11,7 @@ * Processes help agents understand how features work through the codebase. */ -import type { GraphNode, NodeLabel } from 'gitnexus-shared'; +import type { NodeLabel } from 'gitnexus-shared'; import { KnowledgeGraph } from '../graph/types.js'; import { CommunityMembership } from './community-processor.js'; import { calculateEntryPointScore, isTestFile } from './entry-point-scoring.js'; @@ -24,9 +25,9 @@ import { logger } from '../logger.js'; export interface ProcessDetectionConfig { maxTraceDepth: number; // Maximum steps to trace (default: 10) - maxBranching: number; // Max branches to follow per node (default: 3) - maxProcesses: number; // Maximum processes to detect (default: 50) - minSteps: number; // Minimum steps for a valid process (default: 2) + maxBranching: number; // Max branches to follow per node (default: 4) + maxProcesses: number; // Maximum processes to detect (default: 75) + minSteps: number; // Minimum steps for a valid process (default: 3) } const DEFAULT_CONFIG: ProcessDetectionConfig = { @@ -92,15 +93,17 @@ export const processProcesses = async ( const membershipMap = new Map(); memberships.forEach((m) => membershipMap.set(m.nodeId, m.communityId)); - const callsEdges = buildCallsGraph(knowledgeGraph); - const reverseCallsEdges = buildReverseCallsGraph(knowledgeGraph); - const nodeMap = new Map(); - for (const n of knowledgeGraph.iterNodes()) nodeMap.set(n.id, n); + const { forward: callsEdges, reverse: reverseCallsEdges } = buildCallsAdjacency(knowledgeGraph); - // Step 1: Find entry points (functions that call others but have few callers) - const entryPoints = findEntryPoints(knowledgeGraph, reverseCallsEdges, callsEdges); - - onProgress?.(`Found ${entryPoints.length} entry points, tracing flows...`, 20); + // Step 1: Find entry points — explicit ENTRY_POINT_OF graph roots plus + // heuristic ones (functions that call others but have few callers). + const explicitEntryPointIds = collectExplicitEntryPointIds(knowledgeGraph); + const entryPoints = findEntryPoints( + knowledgeGraph, + reverseCallsEdges, + callsEdges, + explicitEntryPointIds, + ); onProgress?.(`Found ${entryPoints.length} entry points, tracing flows...`, 20); @@ -125,7 +128,7 @@ export const processProcesses = async ( onProgress?.(`Found ${allTraces.length} traces, deduplicating...`, 60); // Step 3: Deduplicate similar traces (subset removal) - const uniqueTraces = deduplicateTraces(allTraces); + const uniqueTraces = deduplicateTraces(allTraces, explicitEntryPointIds); // Step 3b: Deduplicate by entry+terminal pair (keep longest path per pair) const endpointDeduped = deduplicateByEndpoints(uniqueTraces); @@ -135,9 +138,15 @@ export const processProcesses = async ( 70, ); - // Step 4: Limit to max processes (prioritize longer traces) + // Step 4: Keep explicit graph roots ahead of heuristic traces, then prefer + // longer traces within each tier. const limitedTraces = endpointDeduped - .sort((a, b) => b.length - a.length) + .sort( + explicitFirst( + (trace) => explicitEntryPointIds.has(trace[0]), + (a, b) => b.length - a.length, + ), + ) .slice(0, cfg.maxProcesses); onProgress?.(`Creating ${limitedTraces.length} process nodes...`, 80); @@ -163,8 +172,8 @@ export const processProcesses = async ( communities.length > 1 ? 'cross_community' : 'intra_community'; // Generate label - const entryNode = nodeMap.get(entryPointId); - const terminalNode = nodeMap.get(terminalId); + const entryNode = knowledgeGraph.getNode(entryPointId); + const terminalNode = knowledgeGraph.getNode(terminalId); const entryName = entryNode?.properties.name || 'Unknown'; const terminalName = terminalNode?.properties.name || 'Unknown'; const heuristicLabel = `${capitalize(entryName)} → ${capitalize(terminalName)}`; @@ -227,35 +236,51 @@ type AdjacencyList = Map; */ const MIN_TRACE_CONFIDENCE = 0.5; -const buildCallsGraph = (graph: KnowledgeGraph): AdjacencyList => { - const adj = new Map(); +/** Build forward and reverse CALLS adjacency lists in one relationship scan. */ +const buildCallsAdjacency = ( + graph: KnowledgeGraph, +): { forward: AdjacencyList; reverse: AdjacencyList } => { + const forward: AdjacencyList = new Map(); + const reverse: AdjacencyList = new Map(); + const push = (adj: AdjacencyList, key: string, value: string): void => { + const bucket = adj.get(key); + if (bucket === undefined) adj.set(key, [value]); + else bucket.push(value); + }; for (const rel of graph.iterRelationships()) { if (rel.type === 'CALLS' && rel.confidence >= MIN_TRACE_CONFIDENCE) { - if (!adj.has(rel.sourceId)) { - adj.set(rel.sourceId, []); - } - adj.get(rel.sourceId)!.push(rel.targetId); + push(forward, rel.sourceId, rel.targetId); + push(reverse, rel.targetId, rel.sourceId); } } - return adj; + return { forward, reverse }; }; -const buildReverseCallsGraph = (graph: KnowledgeGraph): AdjacencyList => { - const adj = new Map(); - - for (const rel of graph.iterRelationships()) { - if (rel.type === 'CALLS' && rel.confidence >= MIN_TRACE_CONFIDENCE) { - if (!adj.has(rel.targetId)) { - adj.set(rel.targetId, []); - } - adj.get(rel.targetId)!.push(rel.sourceId); - } +/** IDs of nodes carrying a pre-phase ENTRY_POINT_OF edge (compiler-backed + * runtime/API roots stamped by the standalone ingest phase). */ +function collectExplicitEntryPointIds(graph: KnowledgeGraph): Set { + const ids = new Set(); + for (const rel of graph.iterRelationshipsByType('ENTRY_POINT_OF')) { + // Only callable roots qualify. Route/Tool→Process ENTRY_POINT_OF edges + // are created after this phase; the label filter keeps them out even if + // phase ordering ever changes. + const label = graph.getNode(rel.sourceId)?.label; + if (label === 'Function' || label === 'Method') ids.add(rel.sourceId); } + return ids; +} - return adj; -}; +/** Global cap on heuristic (non-explicit) entry points, to prevent explosion + * on large polyglot repos. Explicit graph roots are exempt and counted first. */ +const HEURISTIC_ENTRY_POINT_BUDGET = 200; + +/** Comparator combinator: explicit items first, then a secondary order. */ +const explicitFirst = + (isExplicit: (item: T) => boolean, then: (a: T, b: T) => number) => + (a: T, b: T): number => + Number(isExplicit(b)) - Number(isExplicit(a)) || then(a, b); /** * Find functions/methods that are good entry points for tracing. @@ -271,12 +296,20 @@ const findEntryPoints = ( graph: KnowledgeGraph, reverseCallsEdges: AdjacencyList, callsEdges: AdjacencyList, + explicitEntryPointIds: ReadonlySet, ): string[] => { const symbolTypes = new Set(['Function', 'Method']); + // ENTRY_POINT_OF is the graph-wide, language-neutral signal for a known + // runtime/API root. Only edges emitted BEFORE this phase count — today + // that is compiler-backed standalone ingesters; the + // Route/Tool ENTRY_POINT_OF edges are created after process extraction + // and point Route/Tool→Process, so they never land here. These roots must + // survive the global heuristic top-200 budget on large polyglot repos. const entryPointCandidates: { id: string; score: number; reasons: string[]; + explicit: boolean; }[] = []; for (const node of graph.iterNodes()) { @@ -304,19 +337,26 @@ const findEntryPoints = ( ); let score = baseScore; + const explicit = explicitEntryPointIds.has(node.id); + if (explicit) reasons.push('graph-entry-point'); const astFrameworkMultiplier = node.properties.astFrameworkMultiplier ?? 1.0; if (astFrameworkMultiplier > 1.0) { score *= astFrameworkMultiplier; reasons.push(`framework-ast:${node.properties.astFrameworkReason || 'decorator'}`); } - if (score > 0) { - entryPointCandidates.push({ id: node.id, score, reasons }); + if (explicit || score > 0) { + entryPointCandidates.push({ id: node.id, score, reasons, explicit }); } } - // Sort by score descending and return top candidates - const sorted = entryPointCandidates.sort((a, b) => b.score - a.score); + // Known graph entry points form a priority tier ahead of heuristic scoring. + const sorted = entryPointCandidates.sort( + explicitFirst( + (candidate) => candidate.explicit, + (a, b) => b.score - a.score, + ), + ); // DEBUG: Log top candidates with new scoring details if (sorted.length > 0 && isDev) { @@ -330,9 +370,11 @@ const findEntryPoints = ( }); } - return sorted - .slice(0, 200) // Limit to prevent explosion - .map((c) => c.id); + const explicit = sorted.filter((candidate) => candidate.explicit); + const heuristic = sorted + .filter((candidate) => !candidate.explicit) + .slice(0, Math.max(0, HEURISTIC_ENTRY_POINT_BUDGET - explicit.length)); + return [...explicit, ...heuristic].map((candidate) => candidate.id); }; // ============================================================================ @@ -401,23 +443,31 @@ const traceFromEntryPoint = ( * Merge traces that are subsets of other traces. * Keep longer traces, remove redundant shorter ones. */ -const deduplicateTraces = (traces: string[][]): string[][] => { +const deduplicateTraces = ( + traces: string[][], + explicitEntryPointIds: ReadonlySet = new Set(), +): string[][] => { if (traces.length === 0) return []; // Sort by length descending const sorted = [...traces].sort((a, b) => b.length - a.length); const unique: string[][] = []; + // Cache each kept trace's join key so the redundancy scan below doesn't + // recompute `existing.join('->')` on every comparison. + const uniqueKeys: string[] = []; for (const trace of sorted) { - // Check if this trace is a subset of any already-added trace const traceKey = trace.join('->'); - const isSubset = unique.some((existing) => { - const existingKey = existing.join('->'); - return existingKey.includes(traceKey); - }); + // Explicit entry points dedupe on exact equality; heuristic traces are + // dropped when a longer kept trace already contains them as a substring. + const exactOnly = explicitEntryPointIds.has(trace[0]); + const isRedundant = uniqueKeys.some((existingKey) => + exactOnly ? existingKey === traceKey : existingKey.includes(traceKey), + ); - if (!isSubset) { + if (!isRedundant) { unique.push(trace); + uniqueKeys.push(traceKey); } } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts index b39ff8689..03b6c0d2d 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts @@ -30,6 +30,10 @@ interface Target { readonly def: SymbolDefinition; } +// Shared miss results for the fixpoint's read paths — callers only iterate. +const EMPTY_TARGETS: ReadonlyMap = new Map(); +const EMPTY_CELLS: ReadonlySet = new Set(); + interface FileFact { readonly filePath: string; readonly site: CallableFlowSite; @@ -40,13 +44,22 @@ interface FileInvoke { readonly site: CallableFlowInvokeSite; } -export interface CallableValueFlowWarning { +interface RawCallableValueFlowWarning { readonly language: string; readonly context: string; readonly candidateCount: number; readonly cap: number; } +export interface CallableValueFlowWarning extends RawCallableValueFlowWarning { + /** Number of internal cells represented by this aggregate warning. */ + readonly occurrences: number; + /** Number of source contexts represented by this aggregate warning. */ + readonly distinctContexts: number; + /** Bounded diagnostic sample; stdout receives one aggregate, not every site. */ + readonly contextSamples: readonly string[]; +} + export interface CallableValueFlowResult { readonly emitted: number; readonly resolvedInvokes: number; @@ -55,6 +68,46 @@ export interface CallableValueFlowResult { readonly iterations: number; } +/** Collapse internal-cell overflows to one bounded warning per language/cap. */ +export function aggregateCallableValueFlowWarnings( + warnings: Iterable, +): 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; + contextSamples: string[]; + } + >(); + for (const warning of warnings) { + const key = `${warning.language}\0${warning.cap}`; + const current = grouped.get(key); + if (!current) { + grouped.set(key, { + ...warning, + occurrences: 1, + allContexts: new Set([warning.context]), + contextSamples: [warning.context], + }); + continue; + } + + current.candidateCount = Math.max(current.candidateCount, warning.candidateCount); + current.occurrences++; + current.allContexts.add(warning.context); + if (current.contextSamples.length < 5 && !current.contextSamples.includes(warning.context)) { + current.contextSamples.push(warning.context); + } + } + return [...grouped.values()].map(({ allContexts, ...warning }) => ({ + ...warning, + distinctContexts: allContexts.size, + })); +} + export interface EmitCallableValueFlowInput { readonly graph: KnowledgeGraph; readonly scopes: ScopeResolutionIndexes; @@ -66,6 +119,12 @@ export interface EmitCallableValueFlowInput { readonly isCallableValueTarget?: (def: SymbolDefinition) => boolean; readonly hasFileLocalCallableLinkage?: (def: SymbolDefinition) => boolean; readonly onWarn?: (warning: CallableValueFlowWarning) => void; + /** + * Precomputed {@link collectDeferredIndirectSites} result for the same + * files/scopes. The orchestrator already needs it to build skip sets; + * threading it here avoids a second whole-repo scan. Recomputed when absent. + */ + readonly canonicalInvokeKeys?: ReadonlySet; } /** Position key shared with the existing free/reference skip-set contract. */ @@ -76,6 +135,34 @@ export function callableFlowSiteKey( return `${filePath}:${range.startLine}:${range.startCol}`; } +function warningContext(filePath: string, site: CallableFlowSite): string { + let range: { readonly startLine: number; readonly startCol: number }; + switch (site.kind) { + case 'seed': + case 'copy': + case 'alias': + case 'address': + case 'load': + range = site.destination.atRange; + break; + case 'store': + range = site.pointer.atRange; + break; + case 'formal': + range = site.ownerRange; + break; + case 'argument': + case 'invoke': + range = site.callSite; + break; + default: { + const exhaustive: never = site; + throw new Error(`Unhandled callable-flow site kind: ${String(exhaustive)}`); + } + } + return `${site.kind}:${callableFlowSiteKey(filePath, range)}`; +} + /** * Return only invoke sites that join to a canonical call ReferenceSite. * Malformed/stale facts never suppress ordinary resolution. @@ -140,7 +227,8 @@ function flowCellOperand(site: CallableFlowSite): CallableFlowOperand | undefine export function emitCallableValueFlow(input: EmitCallableValueFlowInput): CallableValueFlowResult { const facts: FileFact[] = []; const invokes: FileInvoke[] = []; - const canonicalInvokeKeys = collectDeferredIndirectSites(input.parsedFiles, input.scopes); + const canonicalInvokeKeys = + input.canonicalInvokeKeys ?? collectDeferredIndirectSites(input.parsedFiles, input.scopes); let unmatchedInvokes = 0; for (const parsed of input.parsedFiles) { for (const site of parsed.callableFlowSites ?? []) { @@ -161,7 +249,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab const addressesByBinding = new Map>(); const overflowedTargets = new Set(); const overflowedAddresses = new Set(); - const overflowWarnings = new Map(); + const overflowWarnings = new Map(); const rawGraphTargets = buildGraphTargetIndex( input.scopes, input.nodeLookup, @@ -311,7 +399,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab ): { readonly targets: ReadonlyMap; readonly overflow: boolean } => { watch('target', key); return { - targets: targetsByBinding.get(key) ?? new Map(), + targets: targetsByBinding.get(key) ?? EMPTY_TARGETS, overflow: overflowedTargets.has(key), }; }; @@ -321,7 +409,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab ): { readonly cells: ReadonlySet; readonly overflow: boolean } => { watch('address', key); return { - cells: addressesByBinding.get(key) ?? new Set(), + cells: addressesByBinding.get(key) ?? EMPTY_CELLS, overflow: overflowedAddresses.has(key), }; }; @@ -476,10 +564,10 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab for (const fact of facts) { const site = fact.site; - const context = `${fact.site.kind}:${fact.filePath}`; switch (site.kind) { case 'copy': case 'alias': { + const context = warningContext(fact.filePath, site); addWorkItem(() => { const source = bindingKey(fact.filePath, site.source); const destination = bindingKey(fact.filePath, site.destination); @@ -493,6 +581,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab break; } case 'load': { + const context = warningContext(fact.filePath, site); addWorkItem(() => { const destination = bindingKey(fact.filePath, site.destination); const reached = reachedCells(fact.filePath, site.pointer); @@ -509,6 +598,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab break; } case 'store': { + const context = warningContext(fact.filePath, site); addWorkItem(() => { const sourceTargets = operandTargets(fact.filePath, site.source); const reached = reachedCells(fact.filePath, site.pointer); @@ -585,7 +675,11 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab targetIds.add(id); } for (const id of dynamicCallees.get(callKey)?.keys() ?? []) targetIds.add(id); - let hasIndexedFormal = [...targetIds].some((id) => indexedFormals(id).length > 0); + const hasAnyIndexedFormal = (): boolean => { + for (const id of targetIds) if (indexedFormals(id).length > 0) return true; + return false; + }; + let hasIndexedFormal = hasAnyIndexedFormal(); if (!hasIndexedFormal && site.directCalleeName !== undefined) { for (const target of resolveSeedCandidates( fact.filePath, @@ -601,7 +695,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab )) { targetIds.add(target.id); } - hasIndexedFormal = [...targetIds].some((id) => indexedFormals(id).length > 0); + hasIndexedFormal = hasAnyIndexedFormal(); } const history = dynamicTargetHistory.get(callKey); const callOverflow = @@ -684,7 +778,12 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab ); } - for (const warning of overflowWarnings.values()) input.onWarn?.(warning); + // A large generated bundle can create thousands of distinct binding cells + // at the same source site. Preserve the causal evidence while emitting one + // structured warning per site instead of one line per internal cell. + for (const warning of aggregateCallableValueFlowWarnings(overflowWarnings.values())) { + input.onWarn?.(warning); + } // No partial graph output when a hostile/corrupt fact graph exhausts the // bounded work budget. The caller receives a warning; NOTE this is not diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index e47498ebb..d792aa323 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -82,6 +82,7 @@ import { callableFlowSiteKey, collectDeferredIndirectSites, emitCallableValueFlow, + type CallableValueFlowWarning, } from '../passes/callable-value-flow.js'; import type { ScopeResolver } from '../contract/scope-resolver.js'; import { findEnclosingClassDef, resolveInheritanceBaseInScope } from '../scope/walkers.js'; @@ -92,7 +93,37 @@ import { parseTruthyEnv } from '../../utils/env.js'; import { TransitionalScopeTree } from '../../../../storage/scope-index-store.js'; import { forceGc } from '../../../../storage/parsedfile-store.js'; -import { logger } from '../../../logger.js'; +import { logger, warnRespectingProgressBar } from '../../../logger.js'; + +/** Exported for the boundary test in run-progress.test.ts. */ +export const MAX_PROGRESS_WARNING_CONTEXT_CHARS = 160; + +/** Escape control bytes and bound one context shown beside the live bar. */ +export function formatScopeResolutionWarningContext(context: string): string { + const escaped = JSON.stringify(context).slice(1, -1); + if (escaped.length <= MAX_PROGRESS_WARNING_CONTEXT_CHARS) return escaped; + return `${escaped.slice(0, MAX_PROGRESS_WARNING_CONTEXT_CHARS - 3)}...`; +} + +/** One-line progress warning for property-dispatch fan-out drops. */ +function formatPropertyDispatchProgress( + language: string, + skippedKeys: number, + fanoutCap: number, + skippedKeyNames: readonly string[], +): string { + return ` Warning: property dispatch (${language}) skipped ${skippedKeys} key(s) above fan-out cap ${fanoutCap}; no CALLS were synthesized. Sample: ${skippedKeyNames + .slice(0, 5) + .join(', ')}`; +} + +/** One-line progress warning for callable-value-flow candidate-set overflows. */ +function formatCallableValueFlowProgress(warning: CallableValueFlowWarning): string { + return ` Warning: callable value flow (${warning.language}) skipped ${warning.occurrences} candidate set(s) across ${warning.distinctContexts} context(s) above cap ${warning.cap}; no partial CALLS were emitted. Sample: ${warning.contextSamples + .slice(0, 2) + .map(formatScopeResolutionWarningContext) + .join(', ')}`; +} /** * Emit one class-owned inheritance edge directly (the inheritance pre-pass is @@ -879,14 +910,23 @@ export function runScopeResolution( // Never drop dispatch coverage silently: a hook table larger than the // fan-out cap means member calls through those keys get no synthesized // CALLS — the #2437 false-safe gap reappears for exactly those keys. - logger.warn( + warnRespectingProgressBar( + formatPropertyDispatchProgress( + provider.language, + propertyDispatch.skippedKeys, + MAX_PROPERTY_DISPATCH_FANOUT, + propertyDispatch.skippedKeyNames, + ), { - lang: provider.language, - skippedKeys: propertyDispatch.skippedKeys, - skippedKeyNames: propertyDispatch.skippedKeyNames, - fanoutCap: MAX_PROPERTY_DISPATCH_FANOUT, + fields: { + lang: provider.language, + skippedKeys: propertyDispatch.skippedKeys, + skippedKeyNames: propertyDispatch.skippedKeyNames, + fanoutCap: MAX_PROPERTY_DISPATCH_FANOUT, + }, + message: + 'property-dispatch: keys over the fan-out cap were dropped (no CALLS synthesized for them)', }, - 'property-dispatch: keys over the fan-out cap were dropped (no CALLS synthesized for them)', ); } const callableValueFlow = @@ -904,15 +944,17 @@ export function runScopeResolution( parsedFiles: emitParsedFiles, nodeLookup: postHeritageNodeLookup, calleeIds: calleeIdAccumulator, + canonicalInvokeKeys: deferredIndirectSites, language: provider.language, collapseByCallerTarget: provider.collapseMemberCallsByCallerTarget === true, isCallableValueTarget: provider.isCallableValueTarget, hasFileLocalCallableLinkage: provider.hasFileLocalCallableLinkage, onWarn: (warning) => - logger.warn( - warning, - 'callable-value-flow: candidate set exceeded the cap; no partial CALLS emitted', - ), + warnRespectingProgressBar(formatCallableValueFlowProgress(warning), { + fields: warning, + message: + 'callable-value-flow: candidate set exceeded the cap; grouped occurrences emitted no partial CALLS', + }), }); const importsEmitted = callableFlowOnly ? 0 diff --git a/gitnexus/src/core/lbug/csv-generator.ts b/gitnexus/src/core/lbug/csv-generator.ts index 7dac1a79e..36a0a3d83 100644 --- a/gitnexus/src/core/lbug/csv-generator.ts +++ b/gitnexus/src/core/lbug/csv-generator.ts @@ -564,6 +564,7 @@ export const streamAllCSVsToDisk = async ( const MULTI_LANG_TYPES = [ 'Struct', 'Enum', + 'Type', 'EnumVariant', 'Macro', 'Typedef', diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 2e54abfea..006b46dc6 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -1255,6 +1255,7 @@ const BACKTICK_TABLES = new Set([ 'Trait', 'Impl', 'TypeAlias', + 'Type', 'Const', 'Static', 'Property', @@ -2281,12 +2282,7 @@ export const deleteNodesForFile = async ( /** * Chunk size for {@link deleteNodesForFiles}. 200 paths keeps each - * statement ~13KB (well inside parser limits) while a ~700-file write set - * still collapses from ~13,000 statements to 124: 31 statements per chunk - * (1 CodeEmbedding join-delete + 30 filePath-bearing node tables — the - * 32-table NODE_TABLES roster minus Community/Process) × 4 chunks. The - * original "~40" claim under-counted the per-chunk statement fan-out - * (tri-review 4669518496 accuracy sweep). + * statement ~13KB while batching one delete per file-backed table. */ export const DELETE_FILES_CHUNK_SIZE = 200; @@ -2307,9 +2303,7 @@ export const DELETE_FILES_CHUNK_SIZE = 200; * binder error on the embedding join-delete: a DB created without * EMBEDDING_SCHEMA cannot own embedding rows, so skipping that one * statement is sound, while failing would brick every incremental run on - * such a DB until `--force`. Statement count per chunk is unchanged by the - * multi-label join: 1 embedding join-delete + 30 node-table deletes = 31 - * (the rejected per-label fallback shape would have been 19 + 30 = 49). + * such a DB until `--force`. * Singleton-connection only: the analyze writeback owns the write lock, * and `queryAndDrain` routes through `withConnLock` for it (the WAL * checkpoint driver is live during this). @@ -2501,34 +2495,40 @@ export const queryImportersBatch = async ( }; /** - * Drop every Community and Process node (and their MEMBER_OF / - * STEP_IN_PROCESS edges via DETACH DELETE). Used at the start of an - * incremental run so the communities and processes phases regenerate - * them from scratch on the merged graph — required for the - * "Leiden runs on the FULL graph" correctness invariant. + * Shared mechanics for the delete-all-nodes-by-label family + * ({@link deleteAllCommunitiesAndProcesses}, {@link deleteAllExternalNodes}). + * For each label: count matching nodes, then `DETACH DELETE` them if any. + * The whole loop runs inside `withConnLock` so it cannot execute concurrently + * with the WAL-checkpoint driver's CHECKPOINT on the singleton connection + * (this runs during incremental --pdg writeback while the driver is live; + * mirrors the wrapped deleteAllInterprocTaintPaths / deleteAllCallSummaries). + * A missing table on a freshly-initialized DB is swallowed — it simply holds + * no rows to delete. + * + * `labels` are pre-formatted label tokens (the caller decides backticking). + * `filter` is an optional Cypher predicate on the bound node `n` + * (e.g. `WHERE n.locationFidelity = 'external'`); omitted → delete all rows. */ -export const deleteAllCommunitiesAndProcesses = async (): Promise<{ - nodesDeleted: number; -}> => { +const deleteAllNodesByLabels = async ( + labels: readonly string[], + filter = '', +): Promise<{ nodesDeleted: number }> => { const c = conn; if (!c) { throw new Error('LadybugDB not initialized. Call initLbug first.'); } - // count + DETACH DELETE run inside the connection lock so they cannot execute - // concurrently with the WAL-checkpoint driver's CHECKPOINT on the singleton - // connection. This runs during incremental --pdg writeback while the driver is - // live; mirrors the wrapped deleteAllInterprocTaintPaths / deleteAllCallSummaries. + const clause = filter ? ` ${filter}` : ''; return withConnLock(async () => { let nodesDeleted = 0; - for (const label of ['Community', 'Process']) { + for (const label of labels) { let countResult: lbug.QueryResult | lbug.QueryResult[] | undefined; try { - countResult = await c.query(`MATCH (n:${label}) RETURN count(n) AS cnt`); + countResult = await c.query(`MATCH (n:${label})${clause} RETURN count(n) AS cnt`); const result = Array.isArray(countResult) ? countResult[0] : countResult; const rows = await result.getAll(); const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0); if (count > 0) { - await closeQueryResults(await c.query(`MATCH (n:${label}) DETACH DELETE n`)); + await closeQueryResults(await c.query(`MATCH (n:${label})${clause} DETACH DELETE n`)); nodesDeleted += count; } } catch { @@ -2541,6 +2541,45 @@ export const deleteAllCommunitiesAndProcesses = async (): Promise<{ }); }; +/** + * Drop every Community and Process node (and their MEMBER_OF / + * STEP_IN_PROCESS edges via DETACH DELETE). Used at the start of an + * incremental run so the communities and processes phases regenerate + * them from scratch on the merged graph — required for the + * "Leiden runs on the FULL graph" correctness invariant. + */ +export const deleteAllCommunitiesAndProcesses = async (): Promise<{ + nodesDeleted: number; +}> => deleteAllNodesByLabels(['Community', 'Process']); + +/** + * Node tables that can hold externally-declared dependency symbols + * (`locationFidelity = 'external'`, filePath '') — the labels + * `ExternalMoveSymbols` in move-linker materializes. Extend alongside it. + */ +const EXTERNAL_NODE_TABLES = ['Module', 'Function', 'Type'] as const; + +/** + * Drop every externally-declared dependency node (and, via DETACH, its + * edges). Used at the start of an incremental writeback: external nodes have + * no filePath, so `deleteNodesForFiles` can never remove them and the + * file-keyed write set can never refresh them. The standalone ingest phase + * regenerates the full external surface every run and + * `extractChangedSubgraph` re-includes it (`isExternalNode`), so + * delete-all-then-rebuild keeps first-referenced externals from dangling + * their edges — same contract as Community/Process. Crash-recovery matches + * INJECTS: delete-then-COPY is not atomic, and the `incrementalInProgress` + * dirty flag forces a full rebuild if we die between them. + * + * Every external label is backticked unconditionally: `Type` is quoted at + * CREATE but absent from BACKTICK_TABLES. + */ +export const deleteAllExternalNodes = async (): Promise<{ nodesDeleted: number }> => + deleteAllNodesByLabels( + EXTERNAL_NODE_TABLES.map((t) => `\`${t}\``), + `WHERE n.locationFidelity = 'external'`, + ); + /** * Shared mechanics for the delete-all-relationships-of-one-type family * ({@link deleteAllInterprocTaintPaths}, {@link deleteAllCallSummaries}, diff --git a/gitnexus/src/core/lbug/node-table-layout.ts b/gitnexus/src/core/lbug/node-table-layout.ts index 420b4070c..d24c3f64c 100644 --- a/gitnexus/src/core/lbug/node-table-layout.ts +++ b/gitnexus/src/core/lbug/node-table-layout.ts @@ -91,6 +91,18 @@ const structLikeLayout = (table: 'Struct' | 'Enum'): NodeTableLayout => ({ ], }); +const TYPE_LAYOUT: NodeTableLayout = { + table: 'Type', + quoteTableName: true, + columns: [ + ...MULTI_LANGUAGE_BASE, + column('language', 'STRING'), + column('qualifiedName', 'STRING'), + column('moduleQualifiedName', 'STRING'), + column('locationFidelity', 'STRING'), + ], +}; + const ENUM_VARIANT_LAYOUT: NodeTableLayout = { table: 'EnumVariant', quoteTableName: true, @@ -141,6 +153,7 @@ export const NODE_TABLE_LAYOUTS = { Function: FUNCTION_LAYOUT, Struct: structLikeLayout('Struct'), Enum: structLikeLayout('Enum'), + Type: TYPE_LAYOUT, EnumVariant: ENUM_VARIANT_LAYOUT, Const: CONST_LAYOUT, Module: MODULE_LAYOUT, diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index 443dad083..a92f2fefa 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -148,6 +148,7 @@ CREATE NODE TABLE \`${name}\` ( export const STRUCT_SCHEMA = buildNodeTableSchema('Struct'); export const ENUM_SCHEMA = buildNodeTableSchema('Enum'); +export const TYPE_SCHEMA = buildNodeTableSchema('Type'); export const ENUM_VARIANT_SCHEMA = buildNodeTableSchema('EnumVariant'); export const MACRO_SCHEMA = CODE_ELEMENT_BASE('Macro'); export const TYPEDEF_SCHEMA = CODE_ELEMENT_BASE('Typedef'); @@ -280,6 +281,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM Function TO \`Enum\`, FROM Function TO \`Namespace\`, FROM Function TO \`TypeAlias\`, + FROM Function TO \`Type\`, FROM Function TO \`Module\`, FROM Function TO \`Impl\`, FROM Function TO Interface, @@ -357,6 +359,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM \`Struct\` TO \`Struct\`, FROM \`Struct\` TO Class, FROM \`Struct\` TO \`Enum\`, + FROM \`Struct\` TO \`Type\`, FROM \`Struct\` TO Function, FROM \`Struct\` TO Method, FROM \`Struct\` TO Interface, @@ -374,9 +377,11 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( // Move/Aptos: module defines structs/enums/consts; enums contain variants. FROM \`Module\` TO \`Struct\`, FROM \`Module\` TO \`Enum\`, + FROM \`Module\` TO \`Type\`, FROM \`Module\` TO \`Const\`, FROM \`Enum\` TO \`EnumVariant\`, FROM \`Module\` TO \`EnumVariant\`, + FROM \`EnumVariant\` TO \`Property\`, FROM \`Typedef\` TO Community, FROM \`Union\` TO Community, FROM \`Namespace\` TO Community, @@ -400,6 +405,9 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM \`Variable\` TO Community, FROM \`Property\` TO Community, FROM \`Property\` TO \`Property\`, + FROM \`Property\` TO \`Struct\`, + FROM \`Property\` TO \`Enum\`, + FROM \`Property\` TO \`Type\`, FROM \`Record\` TO Method, FROM \`Record\` TO \`Constructor\`, FROM \`Record\` TO \`Property\`, @@ -519,6 +527,7 @@ export const NODE_SCHEMA_QUERIES = [ // Multi-language support STRUCT_SCHEMA, ENUM_SCHEMA, + TYPE_SCHEMA, ENUM_VARIANT_SCHEMA, MACRO_SCHEMA, TYPEDEF_SCHEMA, diff --git a/gitnexus/src/core/logger.ts b/gitnexus/src/core/logger.ts index bf32f1045..95547dfb9 100644 --- a/gitnexus/src/core/logger.ts +++ b/gitnexus/src/core/logger.ts @@ -286,6 +286,34 @@ export const logger = new Proxy({} as Logger, { }, }) as Logger; +/** + * Env flag the analyze CLI sets while its live progress bar owns the + * terminal (it reroutes console.warn through the bar logger; see + * `cli/analyze.ts`). + */ +export const ANALYZE_PROGRESS_ACTIVE_ENV = 'GITNEXUS_ANALYZE_PROGRESS_ACTIVE'; + +/** + * Emit an operator-facing warning without corrupting analyze's live progress + * bar. While the bar is active, the one-line progress message goes through + * console.warn (routed into the bar by the analyze CLI) — raw pino NDJSON + * would corrupt the one-line display, including in the heap-respawn child + * whose stderr is piped for crash classification. Otherwise the structured + * Pino record is emitted, falling back to the progress message when no + * structured form is given. + */ +export function warnRespectingProgressBar( + progressMessage: string, + structured?: { readonly fields: object; readonly message: string }, +): void { + if (process.env[ANALYZE_PROGRESS_ACTIVE_ENV] === '1') { + console.warn(progressMessage); + return; + } + if (structured) logger.warn(structured.fields, structured.message); + else logger.warn(progressMessage); +} + /** * Shape of a parsed pino record. `level`, `time`, and `msg` are always * present; `name` is set when emitted from a named child logger; arbitrary diff --git a/gitnexus/src/core/move/README.md b/gitnexus/src/core/move/README.md index ae82d097e..1d70f8dd5 100644 --- a/gitnexus/src/core/move/README.md +++ b/gitnexus/src/core/move/README.md @@ -1,62 +1,37 @@ # Move compiler integration -This directory implements GitNexus's compiler-first ingestion for Move packages. -GitNexus discovers packages from `Move.toml`, communicates with `move-flow` over -MCP, and projects compiler facts into the standard GitNexus knowledge graph. -Declaration and semantic data come from the compiler-backed `facts` and -`call_graph` queries rather than raw-source parsing. - -Cold compiler builds for large packages may take several minutes. Tool calls -default to a five-minute timeout; override it in milliseconds with -`GITNEXUS_MOVE_FLOW_TIMEOUT_MS` when a repository needs a larger budget. - -## Runtime provisioning - -When `analyze` finds a `Move.toml`, it resolves `move-flow` from the authoritative -`MOVE_FLOW` path, the verified managed cache, `PATH`, or finally the pinned -release in `release.ts`. Managed releases live under -`~/.gitnexus/tools/move-flow` by default, are downloaded over HTTPS, checked -against `SHA256SUMS`, version-probed, and atomically published while a -heartbeat lease serializes concurrent installers. - -Set `GITNEXUS_SKIP_MOVE_FLOW=1` to disable automatic downloads. The umbrella -`GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` does the same while also disabling optional -grammars. Air-gapped hosts should set `MOVE_FLOW` to a preinstalled compatible -binary or pre-seed the managed cache. Advanced trusted-release overrides are -`GITNEXUS_MOVE_FLOW_DIR`, `GITNEXUS_MOVE_FLOW_VERSION`, -`GITNEXUS_MOVE_FLOW_REPO`, `GITNEXUS_MOVE_FLOW_TAG`, -`GITNEXUS_MOVE_FLOW_COMPAT`, and `GITNEXUS_MOVE_FLOW_HTTP_TIMEOUT_MS`. - -The repository metadata records the compiler identity used for Move facts. -Changing that identity forces a full rebuild. If an existing compiler-backed -graph needs updating while the compiler is unavailable, analysis fails before -mutating the graph; restore `move-flow` or set `MOVE_FLOW` and retry. -Managed identities include the verified binary hash; explicit and `PATH` -identities use their locator and reported version, so replace a same-version -local build with `gitnexus analyze --force`. -Filesystem errors while discovering `Move.toml` are also surfaced instead of -silently treating the repository as non-Move. +GitNexus's compiler-first ingestion for Move packages. Packages are discovered +from `Move.toml`, queried live over MCP via `move-flow` (`facts` + `call_graph`, +never persisted or replayed), and projected into the standard knowledge graph. ```text Move package - -> move-flow MCP - -> compiler facts and call graph + -> move-flow MCP (facts + call_graph) + -> Pass A: per-package nodes + deferred PendingRefs + -> global cross-package index + -> Pass B: resolve refs + synthesize external symbols -> GitNexus nodes and relationships -> consistency validation ``` -## Components +Ingestion runs in two passes around a global cross-package index. Pass A +(`facts-mapper.ts`) maps compiler facts for one package to deterministic nodes +(Module, Function, Struct, Enum, Const) and emits cross-package references it +cannot yet resolve as typed `PendingRef` values. Pass B (`move-linker.ts`) runs +once every package is mapped: a descriptor-driven `resolveRefs` engine looks each +ref up in the accumulated index, synthesizing stub nodes for external +(non-repo) targets and recording anything unresolved as a `DroppedRef`. The +`MoveIngestAccumulator` (in `move-ingest.ts`) holds the shared state across +passes; `consistency.ts` validates the result. -- `mcp-client.ts` owns the `move-flow mcp` process, JSON-RPC transport, and the - client contract consumed by ingestion. -- `compiler-facts.ts` defines the normalized compiler response shapes used by - downstream projections. -- `move-ingest.ts` implements the standalone ingestion phase, including package - discovery, compiler queries, and cross-package resolution. -- `facts-mapper.ts` maps compiler facts to deterministic GitNexus nodes and - relationships. -- `consistency.ts` validates the resulting graph and reports incomplete or - malformed compiler evidence. +A package move-flow cannot build is skipped with an operator-actionable warning +rather than aborting the whole analyze (`_`-placeholder addresses are caught +pre-flight; builds that compile with errors are ingested but flagged as +degraded). Compiler identity is recorded in the repository metadata; changing it +forces a full rebuild. Env knobs: `GITNEXUS_MOVE_FLOW_TIMEOUT_MS`, +`GITNEXUS_MOVE_FLOW_CONCURRENCY` (supplemental `function_usage` fan-out, default +`4`), `GITNEXUS_MOVE_STRICT=1` (restore fatal-on-build-failure), +`GITNEXUS_SKIP_MOVE_FLOW=1`, and `MOVE_FLOW` (see `provision.ts`). ## Upstream references diff --git a/gitnexus/src/core/move/compiler-facts.ts b/gitnexus/src/core/move/compiler-facts.ts index e6421da95..3789e9247 100644 --- a/gitnexus/src/core/move/compiler-facts.ts +++ b/gitnexus/src/core/move/compiler-facts.ts @@ -15,6 +15,12 @@ export interface MoveFlowConstant { export type CallGraphMap = Record; +/** 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. // diff --git a/gitnexus/src/core/move/concurrency.ts b/gitnexus/src/core/move/concurrency.ts new file mode 100644 index 000000000..14500d61f --- /dev/null +++ b/gitnexus/src/core/move/concurrency.ts @@ -0,0 +1,42 @@ +// gitnexus/src/core/move/concurrency.ts +export interface MapWithConcurrencyResult { + 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( + items: readonly T[], + limit: number, + worker: (item: T, index: number) => Promise, + opts: { failFast?: boolean } = {}, +): Promise> { + 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 { + 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 }; +} diff --git a/gitnexus/src/core/move/consistency.ts b/gitnexus/src/core/move/consistency.ts index dc9838939..4281baf3e 100644 --- a/gitnexus/src/core/move/consistency.ts +++ b/gitnexus/src/core/move/consistency.ts @@ -1,7 +1,8 @@ import type { KnowledgeGraph } from '../graph/types.js'; import type { MovePackageStatus } from './mcp-client.js'; import type { MoveIngestOutput } from './move-ingest.js'; -import { moveModuleQualifiedName } from './symbol-id.js'; +import type { DroppedRef, PendingRefKind } from './refs.js'; +import { moveModuleQualifiedName, parseMoveModuleQualifiedName } from './symbol-id.js'; export type MoveConsistencySeverity = 'warning' | 'error'; @@ -11,6 +12,16 @@ export interface MoveConsistencyIssue { | 'missing-owned-callee' | 'malformed-source-evidence' | 'unresolved-resource-target' + | 'unresolved-type-target' + | 'unresolved-friend-target' + | 'unresolved-lambda-host' + | 'function-usage-query-failed' + /** Non-empty call graph in which no caller resolves to a mapped function - + * a systematic qualified-name mismatch between `call_graph` and `facts`. */ + | 'call-graph-unlinked' + /** Externally-materialized module shares an address with repo-local + * modules - possibly local code the compiler elided from `facts`. */ + | 'external-module-address-overlap' /** Package with .move sources returned facts `{}` - severity policy in * `emptyFactsIssue` below. */ | 'empty-package-facts' @@ -35,6 +46,53 @@ export interface EmptyFactsPackage { status: MovePackageStatus | null; } +export interface MoveConsistencySummaryIssue { + code: MoveConsistencyIssue['code']; + severity: MoveConsistencySeverity; + message: string; + /** Bounded JSON preview of the original structured details. */ + details?: string; +} + +/** Compact, persistable digest of a run's Move consistency issues. */ +export interface MoveConsistencySummary { + errorCount: number; + warningCount: number; + /** Errors-first sample, capped so meta.json stays small. */ + issues: MoveConsistencySummaryIssue[]; +} + +const MAX_SUMMARY_MESSAGE_CHARS = 500; +const MAX_SUMMARY_DETAILS_CHARS = 2_000; + +function truncateSummaryText(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + return `${value.slice(0, maxChars - 3)}...`; +} + +/** `undefined` when there is nothing to record (the common clean run). */ +export function summarizeMoveConsistency( + issues: readonly MoveConsistencyIssue[], + sampleLimit = 20, +): MoveConsistencySummary | undefined { + if (issues.length === 0) return undefined; + const errors = issues.filter((i) => i.severity === 'error'); + const warnings = issues.filter((i) => i.severity === 'warning'); + return { + errorCount: errors.length, + warningCount: warnings.length, + issues: [...errors, ...warnings].slice(0, sampleLimit).map((issue) => ({ + code: issue.code, + severity: issue.severity, + message: truncateSummaryText(issue.message, MAX_SUMMARY_MESSAGE_CHARS), + details: + issue.details === undefined + ? undefined + : truncateSummaryText(JSON.stringify(issue.details), MAX_SUMMARY_DETAILS_CHARS), + })), + }; +} + /** * Severity policy for a package whose facts came back `{}` despite .move * sources: compiling -> warning (likely test-only, elided from facts), failing @@ -204,6 +262,9 @@ export function validateMoveIngestOutput( } } + validateCallGraphLinkage(moveIngest, issues); + validateExternalAddressOverlap(graph, moveIngest, issues); + for (const [packageRoot, callGraph] of moveIngest.callGraphByPackage) { for (const [callerQualified, callees] of Object.entries(callGraph)) { const callerModule = moveModuleQualifiedName(callerQualified); @@ -233,15 +294,150 @@ export function validateMoveIngestOutput( } } - const dropped = moveIngest.droppedResourceRefs; - if (dropped && dropped.length > 0) { - issues.push({ - code: 'unresolved-resource-target', - severity: 'warning', - message: `${dropped.length} resource read/write/acquires target(s) could not be resolved.`, - details: { count: dropped.length, sample: dropped.slice(0, 5) }, - }); - } + pushGroupedDrops(issues, moveIngest.droppedRefs); + pushDroppedIssue( + issues, + moveIngest.functionUsageFailures, + 'function-usage-query-failed', + 'supplemental function-usage query(s) failed', + ); return issues; } + +const DROP_ISSUE: Record = { + 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(); + 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(); + 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(); + 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) }, + }); +} diff --git a/gitnexus/src/core/move/constants.ts b/gitnexus/src/core/move/constants.ts index 9b03485df..0df44babb 100644 --- a/gitnexus/src/core/move/constants.ts +++ b/gitnexus/src/core/move/constants.ts @@ -59,6 +59,8 @@ export const MOVE_EDGE_REASON = { definesStruct: 'move-module-defines-struct', definesEnum: 'move-module-defines-enum', definesConst: 'move-module-defines-const', + externalDefinesFunction: 'move-external-module-defines-function', + externalDefinesType: 'move-external-module-defines-type', containsVariant: 'move-enum-contains-variant', friend: 'move-friend-or-package', calls: 'move-compiler-call-graph', @@ -70,12 +72,16 @@ export const MOVE_EDGE_REASON = { acquires: 'move-acquires', fnParamType: 'move-fn-param-type', fnReturnType: 'move-fn-return-type', + structFieldType: 'move-struct-field-type', + enumVariantFieldType: 'move-enum-variant-field-type', // Struct -> resource-group struct (from `#[resource_group_member(group = ...)]`) resourceGroupMember: 'move-resource-group-member', // Field edges (struct → field) hasField: 'move-struct-has-field', + hasVariantField: 'move-enum-variant-has-field', // Lambda → host edges (host fn → __lambda__N__host) lambdaHost: 'move-lambda-of-host', + closureUse: 'move-compiler-closure-use', // Entry-point edge reasons (ENTRY_POINT_OF) entryFunction: 'move-entry-function', viewFunction: 'move-view-function', diff --git a/gitnexus/src/core/move/facts-mapper.ts b/gitnexus/src/core/move/facts-mapper.ts index ee448ea3c..6c1b9fccd 100644 --- a/gitnexus/src/core/move/facts-mapper.ts +++ b/gitnexus/src/core/move/facts-mapper.ts @@ -16,7 +16,15 @@ import type { NodeProperties, RelationshipType, } 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 { moveModuleNodeId, moveFunctionNodeId, @@ -25,7 +33,6 @@ import { moveConstNodeId, moveEnumVariantNodeId, moveFieldNodeId, - moveLocalName, moveRelId, parseMoveLambdaHostName, parseMoveModuleQualifiedName, @@ -40,10 +47,19 @@ import { } from './constants.js'; import { extractTypeNames } from './type-parser.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 => 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 { nodes: GraphNode[]; edges: GraphRelationship[]; @@ -53,43 +69,8 @@ export interface MoveFactsMapResult { functionNodeMap: Map; /** Struct/enum qualified name → graph node ID. */ structNodeMap: Map; - /** Resource edges that need the full cross-package type index. */ - pendingResource: PendingResource[]; - /** 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 (`::`). */ - 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; + /** Cross-package references resolved in the linker's Pass B. */ + pendingRefs: PendingRef[]; } /** Strip generic type arguments: `CoinStore` → `CoinStore`. */ @@ -169,6 +150,465 @@ function assertFactsShape(facts: MoveFactsMap): void { } } +/** Shared mutable accumulators threaded through every emitter. */ +interface EmitContext { + nodes: GraphNode[]; + edges: GraphRelationship[]; + moduleFileMap: Map; + functionNodeMap: Map; + structNodeMap: Map; + pendingRefs: PendingRef[]; + packageRoot: string; + repoPath?: string; +} + +/** 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, + targetId: string, + type: RelationshipType, + confidence: number, + reason: string, +): void { + edges.push({ + id: moveRelId(sourceId, type, targetId, reason), + sourceId, + targetId, + type, + confidence, + reason, + }); +} + +/** Emit the Module node + friend PendingRefs; return the per-module context. */ +function emitModule( + ctx: EmitContext, + moduleQualified: string, + mod: MoveFactsModule, +): ModuleEmitContext { + const moduleFileAbs = mod.file ?? ctx.packageRoot; + const file = moveRepoRelativePath(moduleFileAbs, ctx.repoPath); + const { address, moduleName } = parseMoveModuleQualifiedName(moduleQualified); + ctx.moduleFileMap.set(moduleQualified, file); + + const moduleNodeId = moveModuleNodeId(moduleQualified, file); + ctx.nodes.push({ + id: moduleNodeId, + label: 'Module', + properties: { + name: moduleName, + filePath: file, + language: MOVE_LANGUAGE, + qualifiedName: moduleQualified, + moduleQualifiedName: moduleQualified, + moduleAddress: address, + startLine: spanLine(mod.span, 0), + endLine: spanLine(mod.span, 1), + attributes: attributeNames(mod.attributes), + attributesJson: attributesJson(mod.attributes), + locationFidelity: mod.file ? 'precise' : 'package', + }, + }); + + for (const friend of arr(mod.friends)) { + ctx.pendingRefs.push({ + kind: 'friend', + knownNodeId: moduleNodeId, + target: friend.module, + moduleQualified: '', + edgeType: 'FRIEND_OF', + reason: MOVE_EDGE_REASON.friend, + }); + } + + return { moduleQualified, moduleNodeId, moduleFileAbs, file, address, modFile: mod.file }; +} + +/** 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); + ctx.functionNodeMap.set(fnQualified, fnNodeId); + const attrs = attributeNames(fn.attributes); + const seenTypeRefs = new Set(); + // Skip the function's own type parameters (e.g. `CoinType`): they are not + // nominal types and would create spurious USES_TYPE edges. + const fnTypeParamNames = new Set(arr(fn.typeParams).map((tp) => tp.name)); + const addSignatureTypes = (typeExpr: string | null | undefined, reason: string): void => { + if (!typeExpr) return; + for (const typeName of extractTypeNames(typeExpr)) { + if (fnTypeParamNames.has(typeName)) continue; + const key = `${reason}\0${typeName}`; + if (seenTypeRefs.has(key)) continue; + seenTypeRefs.add(key); + ctx.pendingRefs.push({ + kind: 'type', + knownNodeId: fnNodeId, + moduleQualified: mctx.moduleQualified, + target: typeName, + edgeType: 'USES_TYPE', + reason, + }); + } + }; + for (const p of arr(fn.params)) { + addSignatureTypes(p.type, MOVE_EDGE_REASON.fnParamType); + } + for (const rt of arr(fn.returnTypes)) { + addSignatureTypes(rt, MOVE_EDGE_REASON.fnReturnType); + } + const fnProps: NodeProperties = { + name: fn.name, + filePath: fnFile, + language: MOVE_LANGUAGE, + qualifiedName: fnQualified, + moduleQualifiedName: mctx.moduleQualified, + // Compiler-marked entry/view functions are runtime-facing even when their + // 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, + visibilityModifier: fn.visibility, + isEntry: fn.isEntry, + isView: fn.isView, + isInline: fn.isInline, + isNative: fn.isNative, + attributes: attrs, + attributesJson: attributesJson(fn.attributes), + typeParamsJson: typeParamsJson(fn.typeParams), + acquires: arr(fn.acquiresInferred), + usedTypes: [], + returnType: returnTypeProjection(arr(fn.returnTypes)), + parameterCount: arr(fn.params).length, + locationFidelity: functionLocationFidelity(fn, mctx.modFile), + }; + // Lambda → host link queued for Pass B (host fn node may not yet exist when + // this lambda is mapped). The canonical Cypher path for "is this a lambda?" + // is the inbound CALLS edge with reason 'move-lambda-of-host'; we do not + // project an `isLambda` boolean property because the lbug Function table does + // not carry it. move-flow reports the host explicitly via `definedIn` + // (resolved through nested closures); the `__lambda__N__` name parse + // remains as fallback. + const lambdaHostLocal = + (fn.isLambdaLifted ? fn.definedIn : undefined) ?? parseMoveLambdaHostName(fn.name); + if (lambdaHostLocal) { + ctx.pendingRefs.push({ + kind: 'lambda-host', + knownNodeId: fnNodeId, + target: `${mctx.moduleQualified}::${lambdaHostLocal}`, + moduleQualified: '', + edgeType: 'CALLS', + reason: MOVE_EDGE_REASON.lambdaHost, + }); + } + ctx.nodes.push({ id: fnNodeId, label: 'Function', properties: fnProps }); + pushEdge( + ctx.edges, + mctx.moduleNodeId, + fnNodeId, + 'DEFINES', + 1.0, + MOVE_EDGE_REASON.definesFunction, + ); + + for (const r of arr(fn.resourceAccess?.reads)) { + ctx.pendingRefs.push({ + kind: 'resource', + knownNodeId: fnNodeId, + moduleQualified: mctx.moduleQualified, + target: stripTypeArgs(r), + edgeType: 'READS_RESOURCE', + reason: MOVE_EDGE_REASON.readsResource, + }); + } + for (const w of arr(fn.resourceAccess?.writes)) { + ctx.pendingRefs.push({ + kind: 'resource', + knownNodeId: fnNodeId, + moduleQualified: mctx.moduleQualified, + target: stripTypeArgs(w), + edgeType: 'WRITES_RESOURCE', + reason: MOVE_EDGE_REASON.writesResource, + }); + } + for (const a of arr(fn.acquiresInferred)) { + ctx.pendingRefs.push({ + kind: 'resource', + knownNodeId: fnNodeId, + moduleQualified: mctx.moduleQualified, + target: a, + edgeType: 'ACQUIRES', + reason: MOVE_EDGE_REASON.acquires, + }); + } +} + +/** Shared field-type ref emitter; skips the type's own type-parameter names. */ +function addFieldTypeRefs( + ctx: EmitContext, + moduleQualified: string, + typeParamNames: Set, + 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, +): void { + const tyQualified = `${mctx.moduleQualified}::${ty.name}`; + const structNodeId = moveStructNodeId(tyQualified, tyFile); + ctx.structNodeMap.set(tyQualified, structNodeId); + ctx.nodes.push({ + id: structNodeId, + label: 'Struct', + properties: { + name: ty.name, + filePath: tyFile, + language: MOVE_LANGUAGE, + qualifiedName: tyQualified, + moduleQualifiedName: mctx.moduleQualified, + moduleAddress: mctx.address, + startLine: spanLine(ty.span, 0), + endLine: spanLine(ty.span, 1), + abilities: arr(ty.abilities), + isResource: arr(ty.abilities).includes(MOVE_ABILITY.KEY), + isEvent: attrs.includes(MOVE_ATTR.EVENT), + attributes: attrs, + attributesJson: attributesJson(ty.attributes), + typeParamsJson: typeParamsJson(ty.typeParams), + fields: (ty.fields ?? []).map((f) => ({ + name: f.name, + type: f.type, + positional: f.positional, + })), + fieldList: (ty.fields ?? []).map((f) => `${f.name}: ${f.type}`), + moveDeclarationKind: 'struct', + locationFidelity: ty.file ? 'precise' : 'package', + }, + }); + pushEdge( + ctx.edges, + mctx.moduleNodeId, + 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); + if (resourceGroup) { + ctx.pendingRefs.push({ + kind: 'type', + knownNodeId: structNodeId, + moduleQualified: mctx.moduleQualified, + target: resourceGroup, + edgeType: 'USES_TYPE', + reason: MOVE_EDGE_REASON.resourceGroupMember, + }); + } + // Per-field Property nodes + HAS_PROPERTY edges so ACCESSES queries and + // field-level taint can target individual fields. The composite name + type + // live on the Struct as a `fields` array projection, but only the per-field + // nodes let Cypher join on `(:Struct)-[:HAS_PROPERTY]->(:Property)`. + for (const field of arr(ty.fields)) { + const propId = moveFieldNodeId(tyQualified, field.name, tyFile); + ctx.nodes.push({ + id: propId, + label: 'Property', + properties: { + name: field.name, + filePath: tyFile, + language: MOVE_LANGUAGE, + qualifiedName: `${tyQualified}.${field.name}`, + parentStruct: tyQualified, + moduleQualifiedName: mctx.moduleQualified, + declaredType: field.type, + positional: field.positional, + startLine: spanLine(ty.span, 0), + }, + }); + 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, + ); + } +} + +function emitEnum( + ctx: EmitContext, + mctx: ModuleEmitContext, + ty: MoveFactsType, + tyFile: string, + attrs: string[], + typeParamNames: Set, +): void { + const tyQualified = `${mctx.moduleQualified}::${ty.name}`; + const eId = moveEnumNodeId(tyQualified, tyFile); + ctx.structNodeMap.set(tyQualified, eId); + ctx.nodes.push({ + id: eId, + label: 'Enum', + properties: { + name: ty.name, + filePath: tyFile, + language: MOVE_LANGUAGE, + qualifiedName: tyQualified, + moduleQualifiedName: mctx.moduleQualified, + moduleAddress: mctx.address, + startLine: spanLine(ty.span, 0), + endLine: spanLine(ty.span, 1), + abilities: arr(ty.abilities), + isResource: arr(ty.abilities).includes(MOVE_ABILITY.KEY), + isEvent: attrs.includes(MOVE_ATTR.EVENT), + attributes: attrs, + attributesJson: attributesJson(ty.attributes), + typeParamsJson: typeParamsJson(ty.typeParams), + moveDeclarationKind: 'enum', + locationFidelity: ty.file ? 'precise' : 'package', + }, + }); + pushEdge(ctx.edges, mctx.moduleNodeId, eId, 'DEFINES', 1.0, MOVE_EDGE_REASON.definesEnum); + for (const variant of arr(ty.variants)) { + const vId = moveEnumVariantNodeId(tyQualified, variant.name, tyFile); + ctx.nodes.push({ + id: vId, + label: 'EnumVariant', + properties: { + name: variant.name, + filePath: tyFile, + language: MOVE_LANGUAGE, + qualifiedName: `${tyQualified}::${variant.name}`, + parentEnum: tyQualified, + moduleQualifiedName: mctx.moduleQualified, + variantKind: variant.kind, + fieldsJson: JSON.stringify( + arr(variant.fields).map((f) => ({ + name: f.name, + type: f.type, + positional: f.positional, + })), + ), + attributes: attributeNames(variant.attributes), + attributesJson: attributesJson(variant.attributes), + locationFidelity: ty.file ? 'module' : 'package', + }, + }); + 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, + ); + } + } +} + +/** Emit a struct or enum (move-flow groups both under the module's `structs`). */ +function emitType(ctx: EmitContext, mctx: ModuleEmitContext, ty: MoveFactsType): void { + const tyFile = moveRepoRelativePath(ty.file ?? mctx.moduleFileAbs, ctx.repoPath); + const attrs = attributeNames(ty.attributes); + 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, + label: 'Const', + properties: { + name: c.name, + filePath: mctx.file, + language: MOVE_LANGUAGE, + qualifiedName: cQualified, + moduleQualifiedName: mctx.moduleQualified, + constType: c.type, + constValue: c.value, + isErrorCode: ERROR_CODE_PATTERN.test(c.name), + locationFidelity: mctx.modFile ? 'module' : 'package', + }, + }); + pushEdge(ctx.edges, mctx.moduleNodeId, cNodeId, 'DEFINES', 1.0, MOVE_EDGE_REASON.definesConst); +} + /** * Map a full `facts` response (covering every module in a package) to graph * nodes + edges. `packageRoot` is used only as a location fallback when the @@ -182,504 +622,33 @@ export function mapFactsToGraph( repoPath?: string, ): MoveFactsMapResult { assertFactsShape(facts); - const nodes: GraphNode[] = []; - const edges: GraphRelationship[] = []; - const moduleFileMap = new Map(); - const functionNodeMap = new Map(); - const structNodeMap = new Map(); - - const edge = ( - sourceId: string, - targetId: string, - type: RelationshipType, - confidence: number, - reason: string, - ): void => { - edges.push({ - id: moveRelId(sourceId, type, targetId, reason), - sourceId, - targetId, - type, - confidence, - reason, - }); + const ctx: EmitContext = { + nodes: [], + edges: [], + moduleFileMap: new Map(), + functionNodeMap: new Map(), + structNodeMap: new Map(), + pendingRefs: [], + packageRoot, + repoPath, }; - // Deferred work that needs the full struct/module index (pass B). - const pendingResource: PendingResource[] = []; - const pendingFriends: PendingFriend[] = []; - const pendingTypeRef: PendingTypeRef[] = []; - const pendingLambdaHosts: PendingLambdaHost[] = []; - - // ── Pass A: nodes (modules, functions, types, constants) ───────────────── for (const [moduleQualified, mod] of Object.entries(facts)) { - const moduleFileAbs = mod.file ?? packageRoot; - const file = moveRepoRelativePath(moduleFileAbs, repoPath); - const { address, moduleName } = parseMoveModuleQualifiedName(moduleQualified); - moduleFileMap.set(moduleQualified, file); - - const moduleNodeId = moveModuleNodeId(moduleQualified, file); - nodes.push({ - id: moduleNodeId, - label: 'Module', - properties: { - name: moduleName, - filePath: file, - language: MOVE_LANGUAGE, - qualifiedName: moduleQualified, - moduleQualifiedName: moduleQualified, - moduleAddress: address, - startLine: spanLine(mod.span, 0), - endLine: spanLine(mod.span, 1), - attributes: attributeNames(mod.attributes), - attributesJson: attributesJson(mod.attributes), - locationFidelity: mod.file ? 'precise' : 'package', - }, - }); - - for (const friend of arr(mod.friends)) { - pendingFriends.push({ moduleNodeId, friend: friend.module }); - } - - // Functions - for (const fn of arr(mod.functions)) { - const fnQualified = `${moduleQualified}::${fn.name}`; - const fnFile = moveRepoRelativePath(fn.file ?? moduleFileAbs, repoPath); - const fnNodeId = moveFunctionNodeId(fnQualified, fnFile); - functionNodeMap.set(fnQualified, fnNodeId); - const attrs = attributeNames(fn.attributes); - const seenTypeRefs = new Set(); - // Skip the function's own type parameters (e.g. `CoinType`): they are not - // nominal types and would create spurious USES_TYPE edges. - const fnTypeParamNames = new Set(arr(fn.typeParams).map((tp) => tp.name)); - const addSignatureTypes = (typeExpr: string | null | undefined, reason: string): void => { - if (!typeExpr) return; - for (const typeName of extractTypeNames(typeExpr)) { - if (fnTypeParamNames.has(typeName)) continue; - const key = `${reason}\0${typeName}`; - if (seenTypeRefs.has(key)) continue; - seenTypeRefs.add(key); - pendingTypeRef.push({ - sourceNodeId: fnNodeId, - moduleQualified, - target: typeName, - reason, - }); - } - }; - for (const p of arr(fn.params)) { - addSignatureTypes(p.type, MOVE_EDGE_REASON.fnParamType); - } - for (const rt of arr(fn.returnTypes)) { - addSignatureTypes(rt, MOVE_EDGE_REASON.fnReturnType); - } - const fnProps: NodeProperties = { - name: fn.name, - filePath: fnFile, - language: MOVE_LANGUAGE, - qualifiedName: fnQualified, - moduleQualifiedName: moduleQualified, - startLine: spanLine(fn.span, 0), - endLine: spanLine(fn.span, 1), - visibility: fn.visibility === 'internal' ? 'private' : fn.visibility, - visibilityModifier: fn.visibility, - isEntry: fn.isEntry, - isView: fn.isView, - isInline: fn.isInline, - isNative: fn.isNative, - attributes: attrs, - attributesJson: attributesJson(fn.attributes), - typeParamsJson: typeParamsJson(fn.typeParams), - acquires: arr(fn.acquiresInferred), - usedTypes: [], - returnType: returnTypeProjection(arr(fn.returnTypes)), - parameterCount: arr(fn.params).length, - locationFidelity: fn.file ? 'precise' : 'package', - }; - // Lambda → host link queued for Pass 2 (host fn node may not yet exist - // when this lambda is mapped). The canonical Cypher path for "is this a - // lambda?" is the inbound CALLS edge with reason 'move-lambda-of-host'; - // we do not project an `isLambda` boolean property because the lbug - // Function table does not carry it. move-flow reports the host explicitly - // via `definedIn` (resolved through nested closures); the - // `__lambda__N__` name parse remains as fallback. - const lambdaHostLocal = - (fn.isLambdaLifted ? fn.definedIn : undefined) ?? parseMoveLambdaHostName(fn.name); - if (lambdaHostLocal) { - pendingLambdaHosts.push({ - lambdaFnNodeId: fnNodeId, - hostQualified: `${moduleQualified}::${lambdaHostLocal}`, - }); - } - nodes.push({ - id: fnNodeId, - label: 'Function', - properties: fnProps, - }); - edge(moduleNodeId, fnNodeId, 'DEFINES', 1.0, MOVE_EDGE_REASON.definesFunction); - - for (const r of arr(fn.resourceAccess?.reads)) { - pendingResource.push({ - fnNodeId, - moduleQualified, - type: 'READS_RESOURCE', - target: stripTypeArgs(r), - reason: MOVE_EDGE_REASON.readsResource, - }); - } - for (const w of arr(fn.resourceAccess?.writes)) { - pendingResource.push({ - fnNodeId, - moduleQualified, - type: 'WRITES_RESOURCE', - target: stripTypeArgs(w), - reason: MOVE_EDGE_REASON.writesResource, - }); - } - for (const a of arr(fn.acquiresInferred)) { - pendingResource.push({ - fnNodeId, - moduleQualified, - type: 'ACQUIRES', - target: a, - reason: MOVE_EDGE_REASON.acquires, - }); - } - } - - // Types (structs + enums) - for (const ty of arr(mod.structs)) { - const tyQualified = `${moduleQualified}::${ty.name}`; - const tyFile = moveRepoRelativePath(ty.file ?? moduleFileAbs, repoPath); - const attrs = attributeNames(ty.attributes); - if (ty.kind === 'struct') { - const structNodeId = moveStructNodeId(tyQualified, tyFile); - structNodeMap.set(tyQualified, structNodeId); - nodes.push({ - id: structNodeId, - label: 'Struct', - properties: { - name: ty.name, - filePath: tyFile, - language: MOVE_LANGUAGE, - qualifiedName: tyQualified, - moduleQualifiedName: moduleQualified, - moduleAddress: address, - startLine: spanLine(ty.span, 0), - endLine: spanLine(ty.span, 1), - abilities: arr(ty.abilities), - isResource: arr(ty.abilities).includes(MOVE_ABILITY.KEY), - isEvent: attrs.includes(MOVE_ATTR.EVENT), - attributes: attrs, - attributesJson: attributesJson(ty.attributes), - typeParamsJson: typeParamsJson(ty.typeParams), - fields: (ty.fields ?? []).map((f) => ({ - name: f.name, - type: f.type, - positional: f.positional, - })), - // STRING[] projection persisted to lbug (`fieldList` column). - fieldList: (ty.fields ?? []).map((f) => `${f.name}: ${f.type}`), - moveDeclarationKind: 'struct', - locationFidelity: ty.file ? 'precise' : 'package', - }, - }); - edge(moduleNodeId, structNodeId, 'DEFINES', 1.0, MOVE_EDGE_REASON.definesStruct); - // `#[resource_group_member(group = ...)]` carries an exact qualified - // struct ref (e.g. '0xa::vault::Group') - queue a membership USES_TYPE - // edge resolved against the cross-package type index in Pass B. - const resourceGroup = resourceGroupOf(ty.attributes); - if (resourceGroup) { - pendingTypeRef.push({ - sourceNodeId: structNodeId, - moduleQualified, - target: resourceGroup, - reason: MOVE_EDGE_REASON.resourceGroupMember, - }); - } - // Per-field Property nodes + HAS_PROPERTY edges so ACCESSES queries and - // field-level taint can target individual fields. The composite name + - // type live on the Struct as a `fields` array projection, but only the - // per-field nodes let Cypher join on `(:Struct)-[:HAS_PROPERTY]->(:Property)`. - for (const field of arr(ty.fields)) { - const propId = moveFieldNodeId(tyQualified, field.name, tyFile); - nodes.push({ - id: propId, - label: 'Property', - properties: { - name: field.name, - filePath: tyFile, - language: MOVE_LANGUAGE, - qualifiedName: `${tyQualified}.${field.name}`, - parentStruct: tyQualified, - moduleQualifiedName: moduleQualified, - declaredType: field.type, - positional: field.positional, - startLine: spanLine(ty.span, 0), - }, - }); - edge(structNodeId, propId, 'HAS_PROPERTY', 1.0, MOVE_EDGE_REASON.hasField); - } - } else { - const eId = moveEnumNodeId(tyQualified, tyFile); - structNodeMap.set(tyQualified, eId); - nodes.push({ - id: eId, - label: 'Enum', - properties: { - name: ty.name, - filePath: tyFile, - language: MOVE_LANGUAGE, - qualifiedName: tyQualified, - moduleQualifiedName: moduleQualified, - moduleAddress: address, - startLine: spanLine(ty.span, 0), - endLine: spanLine(ty.span, 1), - abilities: arr(ty.abilities), - isResource: arr(ty.abilities).includes(MOVE_ABILITY.KEY), - isEvent: attrs.includes(MOVE_ATTR.EVENT), - attributes: attrs, - attributesJson: attributesJson(ty.attributes), - typeParamsJson: typeParamsJson(ty.typeParams), - moveDeclarationKind: 'enum', - locationFidelity: ty.file ? 'precise' : 'package', - }, - }); - edge(moduleNodeId, eId, 'DEFINES', 1.0, MOVE_EDGE_REASON.definesEnum); - for (const variant of arr(ty.variants)) { - const vId = moveEnumVariantNodeId(tyQualified, variant.name, tyFile); - nodes.push({ - id: vId, - label: 'EnumVariant', - properties: { - name: variant.name, - filePath: tyFile, - language: MOVE_LANGUAGE, - qualifiedName: `${tyQualified}::${variant.name}`, - parentEnum: tyQualified, - moduleQualifiedName: moduleQualified, - variantKind: variant.kind, - fieldsJson: JSON.stringify( - arr(variant.fields).map((f) => ({ - name: f.name, - type: f.type, - positional: f.positional, - })), - ), - attributes: attributeNames(variant.attributes), - attributesJson: attributesJson(variant.attributes), - locationFidelity: ty.file ? 'module' : 'package', - }, - }); - edge(eId, vId, 'CONTAINS', 1.0, MOVE_EDGE_REASON.containsVariant); - } - } - } - - // Constants - for (const c of arr(mod.constants)) { - const cQualified = `${moduleQualified}::${c.name}`; - const cNodeId = moveConstNodeId(cQualified, file); - nodes.push({ - id: cNodeId, - label: 'Const', - properties: { - name: c.name, - filePath: file, - language: MOVE_LANGUAGE, - qualifiedName: cQualified, - moduleQualifiedName: moduleQualified, - constType: c.type, - constValue: c.value, - isErrorCode: ERROR_CODE_PATTERN.test(c.name), - locationFidelity: mod.file ? 'module' : 'package', - }, - }); - edge(moduleNodeId, cNodeId, 'DEFINES', 1.0, MOVE_EDGE_REASON.definesConst); - } + const mctx = emitModule(ctx, moduleQualified, mod); + for (const fn of arr(mod.functions)) emitFunction(ctx, mctx, fn); + for (const ty of arr(mod.structs)) emitType(ctx, mctx, ty); + for (const c of arr(mod.constants)) emitConst(ctx, mctx, c); } return { - nodes, - edges, - moduleFileMap, - functionNodeMap, - structNodeMap, - pendingResource, - pendingFriends, - pendingTypeRef, - pendingLambdaHosts, + nodes: ctx.nodes, + edges: ctx.edges, + moduleFileMap: ctx.moduleFileMap, + functionNodeMap: ctx.functionNodeMap, + structNodeMap: ctx.structNodeMap, + pendingRefs: ctx.pendingRefs, }; } -/** - * Resolve queued lambda→host links into CALLS edges. Run after every package's - * facts have been mapped so the host's Function node is guaranteed to exist - * (the host may be defined in a different module within the package, or — for - * cross-package lambda capture — in another package altogether). - */ -export function resolveLambdaHostEdges( - pendingLambdaHosts: readonly PendingLambdaHost[], - functionNodeMap: ReadonlyMap, - edgeSink: (rel: GraphRelationship) => void, -): void { - const seen = new Set(); - for (const p of pendingLambdaHosts) { - const hostId = functionNodeMap.get(p.hostQualified); - if (!hostId) continue; - const key = `${hostId}\0${p.lambdaFnNodeId}`; - if (seen.has(key)) continue; - seen.add(key); - edgeSink({ - id: moveRelId(hostId, 'CALLS', p.lambdaFnNodeId, MOVE_EDGE_REASON.lambdaHost), - sourceId: hostId, - targetId: p.lambdaFnNodeId, - type: 'CALLS', - confidence: 0.9, - reason: MOVE_EDGE_REASON.lambdaHost, - }); - } -} - -export function buildLocalNameIndex( - structNodeMap: ReadonlyMap, -): Map { - const structIdsByLocalName = new Map(); - 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; -} - -function resolveStructRef( - localOrQualified: string, - callerModule: string, - structNodeMap: ReadonlyMap, - structIdsByLocalName: ReadonlyMap, -): { 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, - structIdsByLocalName: ReadonlyMap, - edgeSink: (rel: GraphRelationship) => void, - onUnresolved?: (pending: PendingResource) => void, - onAmbiguous?: (pending: PendingResource) => void, -): void { - const seenResourceEdges = new Set(); - 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}`; - if (seenResourceEdges.has(key)) continue; - seenResourceEdges.add(key); - edgeSink({ - id: moveRelId(pr.fnNodeId, pr.type, resolved.targetId, pr.reason), - sourceId: pr.fnNodeId, - targetId: resolved.targetId, - type: pr.type, - confidence: 1.0, - reason: pr.reason, - }); - } -} - -export function resolveFriendEdges( - pendingFriends: readonly PendingFriend[], - moduleFileMap: ReadonlyMap, - 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, - structIdsByLocalName: ReadonlyMap, - edgeSink: (rel: GraphRelationship) => void, - onUnresolved?: (pending: PendingTypeRef) => void, - onAmbiguous?: (pending: PendingTypeRef) => void, -): void { - const seenTypeEdges = new Set(); - 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. export type { NodeLabel }; diff --git a/gitnexus/src/core/move/function-usage.ts b/gitnexus/src/core/move/function-usage.ts new file mode 100644 index 000000000..468879481 --- /dev/null +++ b/gitnexus/src/core/move/function-usage.ts @@ -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 { + 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 }; +} diff --git a/gitnexus/src/core/move/install.ts b/gitnexus/src/core/move/install.ts index 23d12d3c5..337b7da2c 100644 --- a/gitnexus/src/core/move/install.ts +++ b/gitnexus/src/core/move/install.ts @@ -518,6 +518,14 @@ export async function installMoveFlow( stagedDir = undefined; return { status: 'installed', binary: verifiedBinary(config, metadata) }; } catch (error) { + // A concurrent installer may have published a valid cache while this + // attempt was waiting on the lock or downloading (e.g. a stolen lease + // after host sleep makes our publish rename collide) - its install is as + // good as ours, so prefer it over reporting a failure. + const published = await validCachedInstall(config); + if (published) { + return { status: 'available', binary: published }; + } return { status: 'failed', message: `installation failed: ${error instanceof Error ? error.message : String(error)}`, diff --git a/gitnexus/src/core/move/mcp-client.ts b/gitnexus/src/core/move/mcp-client.ts index 8e6bb0cc4..f95466f9d 100644 --- a/gitnexus/src/core/move/mcp-client.ts +++ b/gitnexus/src/core/move/mcp-client.ts @@ -8,7 +8,7 @@ import { spawn, execFileSync, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { createInterface } from 'node:readline'; -import type { MoveFactsMap, CallGraphMap } from './compiler-facts.js'; +import type { MoveFactsMap, CallGraphMap, MoveFunctionUsage } from './compiler-facts.js'; import { isCompatibleMoveFlowVersion } from './release.js'; interface JsonRpcResponse { @@ -41,6 +41,15 @@ function isMcpListToolResult(v: unknown): v is McpListToolResult { return typeof v === 'object' && v !== null; } +const isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every((item) => typeof item === 'string'); + +function isMoveFunctionUsage(value: unknown): value is MoveFunctionUsage { + if (typeof value !== 'object' || value === null) return false; + const usage = value as Record; + return isStringArray(usage.called) && isStringArray(usage.used); +} + /** JSON-RPC `invalid_params` - move-flow uses it for caller/input errors * (and some builds route package build failures through it). */ const JSON_RPC_INVALID_PARAMS = -32602; @@ -74,6 +83,32 @@ export class MoveFlowToolCallError extends Error { } } +/** + * A move-flow tool call that exceeded its time budget. The client kills and + * respawns the server either way, but callers must not blindly retry: the + * budget itself is the problem (raise GITNEXUS_MOVE_FLOW_TIMEOUT_MS), unlike a + * transport fault where a fresh process can succeed. + */ +export class MoveFlowTimeoutError extends Error { + constructor(message: string) { + super(message); + this.name = 'MoveFlowTimeoutError'; + } +} + +/** + * The move-flow process died or its pipe broke while requests were in flight. + * The request layer retries these once — every query GitNexus issues is an + * idempotent read, and the process respawns on the next attempt. Everything + * else (build errors, timeouts, spawn failures, shutdown) is final. + */ +export class MoveFlowTransportError extends Error { + constructor(message: string) { + super(message); + this.name = 'MoveFlowTransportError'; + } +} + /** * The move-flow surface GitNexus consumes. Defined here (not in the ingest * phase) so the client owns its own contract and the ingest phase depends on @@ -84,6 +119,8 @@ export interface MoveFlowClient { facts(packagePath: string): Promise; /** Function-level call graph (caller qualified name → callee qualified names). */ callGraph(packagePath: string): Promise; + /** Direct/transitive calls and closure captures for one function. */ + functionUsage(packagePath: string, functionName: string): Promise; /** * Build status probe (move_package_status): does the package compile, and * what did the compiler say. Older move-flow builds do not expose the tool - @@ -110,6 +147,8 @@ export interface MovePackageStatus { export interface MoveFlowCapabilities { /** `facts` query available (rich, compiler-sourced per-module facts). */ hasFactsQuery: boolean; + /** `function_usage` query available (closure captures included in `used`). */ + hasFunctionUsageQuery: boolean; /** `move_package_status` tool available (build status + diagnostics). */ hasStatusTool: boolean; } @@ -143,22 +182,28 @@ export function detectMoveFlowCapabilities( if (t.name === 'move_package_query') querySchema = t.inputSchema; } } - const hasFactsQuery = names.has('move_package_facts') || schemaMentionsFactsQuery(querySchema); - return { hasFactsQuery, hasStatusTool: names.has('move_package_status') }; + const hasFactsQuery = + names.has('move_package_facts') || schemaMentionsQuery(querySchema, 'facts'); + const hasFunctionUsageQuery = schemaMentionsQuery(querySchema, 'function_usage'); + return { + hasFactsQuery, + hasFunctionUsageQuery, + hasStatusTool: names.has('move_package_status'), + }; } -/** True if the `move_package_query` inputSchema declares a `"facts"` query const. */ -function schemaMentionsFactsQuery(schema: unknown): boolean { +/** True if `move_package_query` declares the requested query const/enum item. */ +function schemaMentionsQuery(schema: unknown, query: string): boolean { if (!schema || typeof schema !== 'object') return false; - // Walk the JSON-schema object looking for a `const: "facts"` or - // `enum: [... "facts" ...]` anywhere under the QueryType definition. + // Walk the JSON-schema object looking for the query in either a `const` or + // an `enum` anywhere under the QueryType definition. const stack: unknown[] = [schema]; while (stack.length) { const node = stack.pop(); if (!node || typeof node !== 'object') continue; const obj = node as Record; - if (obj.const === 'facts') return true; - if (Array.isArray(obj.enum) && obj.enum.includes('facts')) return true; + if (obj.const === query) return true; + if (Array.isArray(obj.enum) && obj.enum.includes(query)) return true; for (const v of Object.values(obj)) { if (v && typeof v === 'object') stack.push(v); } @@ -180,6 +225,9 @@ export class MoveFlowMcpClient implements MoveFlowClient { private initialized = false; private initPromise: Promise | null = null; private capsPromise: Promise | null = null; + /** Set by shutdown(); an in-flight transport retry must not respawn a child + * after the owner has released the client (it would leak the process). */ + private closed = false; private binaryPath: string; private stderrLines: string[] = []; private static readonly MAX_STDERR = 20; @@ -218,6 +266,7 @@ export class MoveFlowMcpClient implements MoveFlowClient { } private async ensureStarted(): Promise { + if (this.closed) throw new Error('move-flow client is shut down'); if (this.initialized) return; if (this.initPromise) return this.initPromise; const start = this._start().catch((err) => { @@ -285,19 +334,19 @@ export class MoveFlowMcpClient implements MoveFlowClient { if (this.stderrLines.length > MoveFlowMcpClient.MAX_STDERR) { this.stderrLines.shift(); } - const wrapped = new Error(`move-flow stdin failed: ${err.message}${this.stderrContext()}`); - if (!initSettled) failInitialization(wrapped); - else this.failProcess(proc, wrapped); + const message = `move-flow stdin failed: ${err.message}${this.stderrContext()}`; + // Transport-typed only after init: a broken pipe during startup is a + // launch failure, not a retryable mid-flight fault. + if (!initSettled) failInitialization(new Error(message)); + else this.failProcess(proc, new MoveFlowTransportError(message)); }); proc.on('error', (err) => { - const wrapped = new Error( - `Failed to spawn move-flow: ${err.message}${this.stderrContext()}`, - ); + const message = `Failed to spawn move-flow: ${err.message}${this.stderrContext()}`; if (!initSettled) { - failInitialization(wrapped); + failInitialization(new Error(message)); } else { - this.failProcess(proc, wrapped); + this.failProcess(proc, new MoveFlowTransportError(message)); } }); @@ -312,7 +361,9 @@ export class MoveFlowMcpClient implements MoveFlowClient { } this.failProcess( proc, - new Error(`move-flow exited unexpectedly (code ${code})${this.stderrContext()}`), + new MoveFlowTransportError( + `move-flow exited unexpectedly (code ${code})${this.stderrContext()}`, + ), false, ); }); @@ -391,11 +442,36 @@ export class MoveFlowMcpClient implements MoveFlowClient { this.proc.stdin.write(JSON.stringify(msg) + '\n'); } + /** + * Issue a request with one optional retry after a mid-flight transport fault: + * failProcess has already reset the client state when the fault surfaces, + * so the second attempt respawns the server. Deterministic failures (spawn + * errors, timeouts, tool errors) propagate on the first attempt. + * + * Pass `{ retryOnTransport: false }` to suppress the retry — callers that + * run under bounded concurrency (e.g. functionUsage) must not trigger a + * retry/respawn storm when the transport faults. + */ private async request( method: string, params: unknown, timeoutMs: number, - timeoutMessage: string, + timeoutError: Error, + { retryOnTransport = true }: { retryOnTransport?: boolean } = {}, + ): Promise { + 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 { await this.ensureStarted(); const proc = this.proc; @@ -404,7 +480,16 @@ export class MoveFlowMcpClient implements MoveFlowClient { return new Promise((resolve, reject) => { const id = ++this.requestId; const timeout = setTimeout(() => { - this.failProcess(proc, new Error(timeoutMessage)); + const timedOut = this.pending.get(id); + if (!timedOut) return; + this.pending.delete(id); + timedOut.reject(timeoutError); + this.failProcess( + proc, + new MoveFlowTransportError( + `move-flow process restarted after another request timed out: ${timeoutError.message}`, + ), + ); }, timeoutMs); this.pending.set(id, { resolve: (result) => { @@ -426,14 +511,21 @@ export class MoveFlowMcpClient implements MoveFlowClient { }); } - private async callTool(toolName: string, args: Record): Promise { + private async callTool( + toolName: string, + args: Record, + opts?: { retryOnTransport?: boolean }, + ): Promise { const timeoutMs = resolveMoveFlowToolTimeoutMs(); const result = await this.request( 'tools/call', { name: toolName, arguments: args }, timeoutMs, - `move-flow '${toolName}' timed out after ${timeoutMs}ms ` + - '(raise GITNEXUS_MOVE_FLOW_TIMEOUT_MS for large packages)', + new MoveFlowTimeoutError( + `move-flow '${toolName}' timed out after ${timeoutMs}ms ` + + '(raise GITNEXUS_MOVE_FLOW_TIMEOUT_MS for large packages)', + ), + opts, ); if (!isMcpCallToolResult(result)) return result; if (result.isError === true) { @@ -465,6 +557,24 @@ export class MoveFlowMcpClient implements MoveFlowClient { })) as MoveFactsMap; } + async functionUsage(packagePath: string, functionName: string): Promise { + 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 { try { const result = await this.callTool('move_package_status', { package_path: packagePath }); @@ -481,7 +591,12 @@ export class MoveFlowMcpClient implements MoveFlowClient { /** Raw JSON-RPC request (non-`tools/call`), e.g. `tools/list`. */ private async rpcRequest(method: string, params?: unknown): Promise { - 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 { @@ -498,6 +613,7 @@ export class MoveFlowMcpClient implements MoveFlowClient { } async shutdown(): Promise { + this.closed = true; const proc = this.proc; if (proc) { proc.stdin?.end(); diff --git a/gitnexus/src/core/move/move-ingest.ts b/gitnexus/src/core/move/move-ingest.ts index ec8f35c85..cbd49dc40 100644 --- a/gitnexus/src/core/move/move-ingest.ts +++ b/gitnexus/src/core/move/move-ingest.ts @@ -29,28 +29,18 @@ import type { import { getPhaseOutput } from '../ingestion/pipeline-phases/types.js'; import type { StructureOutput } from '../ingestion/pipeline-phases/structure.js'; import type { StandaloneIngestOutput } from '../ingestion/pipeline-phases/standalone-ingest.js'; -import type { KnowledgeGraph } from '../graph/types.js'; -import { MOVE_EDGE_REASON, moveRepoRelativePath } from './constants.js'; +import { moveRepoRelativePath } from './constants.js'; import { + MoveFlowTimeoutError, MoveFlowToolCallError, type MoveFlowClient, type MovePackageStatus, } from './mcp-client.js'; -import { - buildLocalNameIndex, - mapFactsToGraph, - resolveFriendEdges, - resolveLambdaHostEdges, - resolveResourceEdges, - resolveTypeRefEdges, - type MoveFactsMapResult, - type PendingFriend, - type PendingLambdaHost, - type PendingResource, - type PendingTypeRef, -} from './facts-mapper.js'; +import { mapFactsToGraph, type MoveFactsMapResult } from './facts-mapper.js'; import type { CallGraphMap, MoveFactsMap } from './compiler-facts.js'; -import { moveModuleNodeId, moveModuleQualifiedName, moveRelId } from './symbol-id.js'; +import { collectClosureCaptureCalls, type FunctionUsageFailure } from './function-usage.js'; +import { linkMoveIngestGraph, type MoveLinkView } from './move-linker.js'; +import type { DroppedRef, PendingRef } from './refs.js'; import { buildFailedIssue, cliWarningsFromIssues, @@ -63,6 +53,11 @@ import { } from './consistency.js'; import { createMoveEntryPointEdges } from './entry-points.js'; +function resolveMoveConcurrency(): number { + const n = Number(process.env.GITNEXUS_MOVE_FLOW_CONCURRENCY); + return Number.isSafeInteger(n) && n > 0 ? n : 4; +} + // ── Phase output ─────────────────────────────────────────────────────────── export interface MoveIngestOutput extends StandaloneIngestOutput { @@ -80,8 +75,10 @@ export interface MoveIngestOutput extends StandaloneIngestOutput { filePackageMap: ReadonlyMap; /** Absolute package root → compiler call graph for that package. */ callGraphByPackage: ReadonlyMap; - /** Resource references dropped during global resolution. */ - droppedResourceRefs?: { fnNodeId: string; target: string }[]; + /** All refs dropped during global resolution (resource, type, friend, lambda-host). */ + droppedRefs: DroppedRef[]; + /** Supplemental function-usage queries that could not be completed. */ + functionUsageFailures: FunctionUsageFailure[]; /** Non-fatal consistency issues found after Move ingestion. */ consistencyIssues: MoveConsistencyIssue[]; /** Operator-actionable warnings for the persistent CLI summary (skipped or @@ -90,56 +87,49 @@ export interface MoveIngestOutput extends StandaloneIngestOutput { } /** Mutable accumulator shared while ingesting every package. */ -interface MoveIngestState { - ingestedFiles: Set; - moduleFileMap: Map; - functionNodeMap: Map; - structNodeMap: Map; - modulePackageMap: Map; - filePackageMap: Map; - callGraphByPackage: Map; - pendingResource: PendingResource[]; - pendingFriends: PendingFriend[]; - pendingTypeRef: PendingTypeRef[]; - pendingLambdaHosts: PendingLambdaHost[]; - droppedResourceRefs: { fnNodeId: string; target: string }[]; -} +class MoveIngestAccumulator implements MoveLinkView { + readonly moduleFileMap = new Map(); + readonly functionNodeMap = new Map(); + readonly structNodeMap = new Map(); + readonly modulePackageMap = new Map(); + readonly filePackageMap = new Map(); + readonly callGraphByPackage = new Map(); + readonly closureCallsByPackage = new Map(); + readonly pendingRefs: PendingRef[] = []; + readonly droppedRefs: DroppedRef[] = []; + readonly ingestedFiles = new Set(); + readonly functionUsageFailures: FunctionUsageFailure[] = []; -function createState(): MoveIngestState { - return { - ingestedFiles: new Set(), - moduleFileMap: new Map(), - functionNodeMap: new Map(), - structNodeMap: new Map(), - modulePackageMap: new Map(), - filePackageMap: new Map(), - callGraphByPackage: new Map(), - pendingResource: [], - pendingFriends: [], - pendingTypeRef: [], - pendingLambdaHosts: [], - droppedResourceRefs: [], - }; -} + mergePackage(mapped: MoveFactsMapResult, pkgRoot: string): void { + for (const [qn, file] of mapped.moduleFileMap) { + this.moduleFileMap.set(qn, file); + this.modulePackageMap.set(qn, pkgRoot); + this.filePackageMap.set(file, pkgRoot); + } + for (const [qn, id] of mapped.functionNodeMap) this.functionNodeMap.set(qn, id); + for (const [qn, id] of mapped.structNodeMap) this.structNodeMap.set(qn, id); + this.pendingRefs.push(...mapped.pendingRefs); + } -function toOutput( - state: MoveIngestState, - packageRoots: string[], - consistencyIssues: MoveConsistencyIssue[] = [], -): MoveIngestOutput { - return { - ingestedFiles: state.ingestedFiles, - packageRoots, - moduleFileMap: state.moduleFileMap, - functionNodeMap: state.functionNodeMap, - structNodeMap: state.structNodeMap, - modulePackageMap: state.modulePackageMap, - filePackageMap: state.filePackageMap, - callGraphByPackage: state.callGraphByPackage, - droppedResourceRefs: state.droppedResourceRefs, - consistencyIssues, - ingestWarnings: cliWarningsFromIssues(consistencyIssues), - }; + toOutput( + packageRoots: string[], + consistencyIssues: MoveConsistencyIssue[] = [], + ): MoveIngestOutput { + return { + ingestedFiles: this.ingestedFiles, + packageRoots, + moduleFileMap: this.moduleFileMap, + functionNodeMap: this.functionNodeMap, + structNodeMap: this.structNodeMap, + modulePackageMap: this.modulePackageMap, + filePackageMap: this.filePackageMap, + callGraphByPackage: this.callGraphByPackage, + droppedRefs: this.droppedRefs, + functionUsageFailures: this.functionUsageFailures, + consistencyIssues, + ingestWarnings: cliWarningsFromIssues(consistencyIssues), + }; + } } /** GITNEXUS_MOVE_STRICT=1|true restores the historical fatal-on-build-failure @@ -180,28 +170,6 @@ async function findPlaceholderAddresses(pkgRoot: string): Promise { return placeholders; } -/** Add a mapped package's nodes/edges to the graph and merge its identity maps. */ -function applyMapped( - graph: KnowledgeGraph, - mapped: MoveFactsMapResult, - pkgRoot: string, - state: MoveIngestState, -): void { - for (const node of mapped.nodes) graph.addNode(node); - for (const rel of mapped.edges) graph.addRelationship(rel); - for (const [qn, file] of mapped.moduleFileMap) { - state.moduleFileMap.set(qn, file); - state.modulePackageMap.set(qn, pkgRoot); - state.filePackageMap.set(file, pkgRoot); - } - for (const [qn, id] of mapped.functionNodeMap) state.functionNodeMap.set(qn, id); - for (const [qn, id] of mapped.structNodeMap) state.structNodeMap.set(qn, id); - state.pendingResource.push(...mapped.pendingResource); - state.pendingFriends.push(...mapped.pendingFriends); - state.pendingTypeRef.push(...mapped.pendingTypeRef); - state.pendingLambdaHosts.push(...mapped.pendingLambdaHosts); -} - export function createMoveIngestPhase( client: MoveFlowClient | null, ): PipelinePhase { @@ -224,10 +192,10 @@ export function createMoveIngestPhase( ), ].sort(); if (!client || packageRoots.length === 0) { - return toOutput(createState(), packageRoots); + return new MoveIngestAccumulator().toOutput(packageRoots); } - const { hasFactsQuery, hasStatusTool } = await client.capabilities(); + const { hasFactsQuery, hasFunctionUsageQuery, hasStatusTool } = await client.capabilities(); if (!hasFactsQuery) { // userActionable: rendered as a one-liner without a stack — the fix is // an operator action (upgrade move-flow), not a code bug. @@ -238,7 +206,8 @@ export function createMoveIngestPhase( { userActionable: true }, ); } - const state = createState(); + const acc = new MoveIngestAccumulator(); + let functionUsageEnabled = hasFunctionUsageQuery; // Group scanned .move files by their (innermost) owning package. Files // are marked as ingested per package only AFTER its facts arrive, so a @@ -261,12 +230,11 @@ export function createMoveIngestPhase( moveFilesByPackage.set(owner, files); } + // Pass 1: per-package nodes/edges (all packages first, so cross-package + // CALLS in Pass 2 can resolve callees in later packages). const emptyFactsPackages: EmptyFactsPackage[] = []; const packageIssues: MoveConsistencyIssue[] = []; const strictMove = isStrictMove(); - - // Pass 1: per-package nodes/edges (all packages first, so cross-package - // CALLS in Pass 2 can resolve callees in later packages). for (const pkgRoot of packageRoots) { ctx.onProgress({ phase: 'moveIngest', @@ -290,22 +258,17 @@ export function createMoveIngestPhase( let callGraphData: CallGraphMap; let factsMap: MoveFactsMap; try { - callGraphData = await client.callGraph(pkgRoot); - factsMap = await client.facts(pkgRoot); + [callGraphData, factsMap] = await Promise.all([ + client.callGraph(pkgRoot), + client.facts(pkgRoot), + ]); } catch (err) { - if (err instanceof MoveFlowToolCallError) { - // A Move package that does not build (bad manifest, missing - // dependency, unresolved address) is an operator problem, not a - // code bug. Default: skip the package (its files stay un-ingested, - // like the empty-facts path) and surface a persistent warning — - // one broken auxiliary package must not abort the whole analyze. - if (strictMove) { - // userActionable: rendered as a one-liner without a stack. - throw Object.assign( - new Error(`move-flow could not build Move package ${pkgRoot}: ${err.message}`), - { userActionable: true }, - ); - } + // A Move package that does not build (bad manifest, missing + // dependency, unresolved address) is an operator problem, not a code + // bug. Default: skip the package (its files stay un-ingested, like the + // empty-facts path) and surface a persistent warning — one broken + // auxiliary package must not abort the whole analyze. + if (err instanceof MoveFlowToolCallError && !strictMove) { packageIssues.push( buildFailedIssue({ pkgRoot, @@ -321,7 +284,10 @@ export function createMoveIngestPhase( }); continue; } - throw err; + // Strict-mode build failure, timeout (wedged build), or a transport + // fault that already used its retry: render as the operator one-liner + // (build/timeout) or propagate as the crash it is. + throw toPackageQueryError(err, pkgRoot); } if (Object.keys(factsMap).length === 0 && pkgMoveFiles.length > 0) { @@ -340,10 +306,24 @@ export function createMoveIngestPhase( } // Only past the gate: a skipped package must have zero footprint in // Pass 2 linking and consistency validation. - state.callGraphByPackage.set(pkgRoot, callGraphData); - for (const rel of pkgMoveFiles) state.ingestedFiles.add(rel); - - applyMapped(ctx.graph, mapFactsToGraph(factsMap, pkgRoot, ctx.repoPath), pkgRoot, state); + acc.callGraphByPackage.set(pkgRoot, callGraphData); + if (functionUsageEnabled) { + const closureCapture = await collectClosureCaptureCalls( + client, + pkgRoot, + factsMap, + callGraphData, + resolveMoveConcurrency(), + ); + acc.closureCallsByPackage.set(pkgRoot, closureCapture.calls); + acc.functionUsageFailures.push(...closureCapture.failures); + if (closureCapture.failures.length > 0) functionUsageEnabled = false; + } + for (const rel of pkgMoveFiles) acc.ingestedFiles.add(rel); + const mapped = mapFactsToGraph(factsMap, pkgRoot, ctx.repoPath); + for (const node of mapped.nodes) ctx.graph.addNode(node); + for (const rel of mapped.edges) ctx.graph.addRelationship(rel); + acc.mergePackage(mapped, pkgRoot); // Facts arrived, but move-flow serves structurally complete facts even // for builds with compiler errors — and such builds silently lose the @@ -356,13 +336,9 @@ export function createMoveIngestPhase( } // Pass 2+: link edges that need the full cross-package node index. - linkCallEdges(ctx.graph, state); - linkLambdaHostEdges(ctx.graph, state); - linkResourceAndFriendEdges(ctx.graph, state); - linkFileImports(ctx.graph, state); - linkFileModuleContains(ctx.graph, state); + linkMoveIngestGraph(ctx.graph, acc); - const output = toOutput(state, packageRoots); + const output = acc.toOutput(packageRoots); createMoveEntryPointEdges(ctx.graph, output); const consistencyIssues: MoveConsistencyIssue[] = [ @@ -380,6 +356,32 @@ export function createMoveIngestPhase( }; } +/** + * Map a package-query failure to its user-facing form. Build/input errors and + * timeouts are operator problems rendered as one-liners; transport faults + * have already used up the client's single retry, so anything else propagates + * as the crash it is. + */ +function toPackageQueryError(err: unknown, pkgRoot: string): Error { + if (err instanceof MoveFlowToolCallError) { + // userActionable: rendered as a one-liner without a stack - a Move + // package that does not build (bad manifest, missing dependency, + // nonexistent path) is an operator problem, not a code bug. + return Object.assign( + new Error(`move-flow could not build Move package ${pkgRoot}: ${err.message}`), + { userActionable: true }, + ); + } + if (err instanceof MoveFlowTimeoutError) { + // Fresh copy: the client owns this instance (it is reused across the + // transport retry), so stamping the original would mutate state we do + // not own. The message already names the fix (raise + // GITNEXUS_MOVE_FLOW_TIMEOUT_MS). + return Object.assign(new Error(err.message), { userActionable: true }); + } + return err instanceof Error ? err : new Error(String(err)); +} + // --- Empty-facts discrimination --- /** Cross-check the build status of a sources-but-empty-facts package. @@ -398,133 +400,6 @@ async function probePackageStatus( } } -/** CALLS edges from each package's call graph (resolved across all packages). */ -function linkCallEdges(graph: KnowledgeGraph, state: MoveIngestState): void { - for (const callGraph of state.callGraphByPackage.values()) { - for (const [callerQualified, callees] of Object.entries(callGraph)) { - const callerId = state.functionNodeMap.get(callerQualified); - if (!callerId) continue; - for (const calleeQualified of callees) { - const calleeId = state.functionNodeMap.get(calleeQualified); - if (!calleeId) continue; - graph.addRelationship({ - id: moveRelId(callerId, 'CALLS', calleeId, MOVE_EDGE_REASON.calls), - sourceId: callerId, - targetId: calleeId, - type: 'CALLS', - confidence: 1.0, - reason: MOVE_EDGE_REASON.calls, - }); - } - } - } -} - -/** - * CALLS edges from each `__lambda__N__host` function back to its host. move-flow - * synthesises lambdas as standalone functions but does NOT include the host-to- - * lambda link in `call_graph` — without it, upstream traversal from the lambda - * dead-ends and processes that route through a callback (e.g. market_callbacks - * → settle_trade) lose the bridge. - */ -function linkLambdaHostEdges(graph: KnowledgeGraph, state: MoveIngestState): void { - resolveLambdaHostEdges(state.pendingLambdaHosts, state.functionNodeMap, (rel) => - graph.addRelationship(rel), - ); -} - -/** Resource/friend edges from facts, resolved after all package nodes exist. */ -function linkResourceAndFriendEdges(graph: KnowledgeGraph, state: MoveIngestState): void { - const structIdsByLocalName = buildLocalNameIndex(state.structNodeMap); - resolveResourceEdges( - state.pendingResource, - state.structNodeMap, - structIdsByLocalName, - (rel) => graph.addRelationship(rel), - (pending) => - state.droppedResourceRefs.push({ fnNodeId: pending.fnNodeId, target: pending.target }), - (pending) => - state.droppedResourceRefs.push({ fnNodeId: pending.fnNodeId, target: pending.target }), - ); - resolveFriendEdges(state.pendingFriends, state.moduleFileMap, (rel) => - graph.addRelationship(rel), - ); - resolveTypeRefEdges(state.pendingTypeRef, state.structNodeMap, structIdsByLocalName, (rel) => { - graph.addRelationship(rel); - addUsedType(graph, rel.sourceId, rel.targetId); - }); -} - -function addUsedType(graph: KnowledgeGraph, functionNodeId: string, typeNodeId: string): void { - const fnNode = graph.getNode(functionNodeId); - const typeNode = graph.getNode(typeNodeId); - const qualifiedName = typeNode?.properties.qualifiedName; - // Only Function nodes carry `usedTypes` - resource-group membership USES_TYPE - // edges have a Struct source and must not grow the property there. - if (!fnNode || fnNode.label !== 'Function' || typeof qualifiedName !== 'string') return; - - const current = Array.isArray(fnNode.properties.usedTypes) ? fnNode.properties.usedTypes : []; - if (current.includes(qualifiedName)) return; - fnNode.properties.usedTypes = [...current, qualifiedName]; -} - -/** File→File IMPORTS derived from cross-module CALLS (deduped against existing). */ -function linkFileImports(graph: KnowledgeGraph, state: MoveIngestState): void { - const seen = new Set(); - for (const r of graph.iterRelationshipsByType('IMPORTS')) { - if (!r.sourceId.startsWith('File:') || !r.targetId.startsWith('File:')) continue; - seen.add(`${r.sourceId.slice(5)}\0${r.targetId.slice(5)}`); - } - for (const callGraph of state.callGraphByPackage.values()) { - for (const [callerQualified, callees] of Object.entries(callGraph)) { - const callerFile = state.moduleFileMap.get(moveModuleQualifiedName(callerQualified)); - if (!callerFile) continue; - for (const calleeQualified of callees) { - const calleeFile = state.moduleFileMap.get(moveModuleQualifiedName(calleeQualified)); - if (!calleeFile || calleeFile === callerFile) continue; - const key = `${callerFile}\0${calleeFile}`; - if (seen.has(key)) continue; - seen.add(key); - const sourceFileId = `File:${callerFile}`; - const targetFileId = `File:${calleeFile}`; - if (graph.getNode(sourceFileId) && graph.getNode(targetFileId)) { - graph.addRelationship({ - id: moveRelId( - sourceFileId, - 'IMPORTS', - targetFileId, - MOVE_EDGE_REASON.crossModuleDependency, - ), - sourceId: sourceFileId, - targetId: targetFileId, - type: 'IMPORTS', - confidence: 0.9, - reason: MOVE_EDGE_REASON.crossModuleDependency, - }); - } - } - } - } -} - -/** File→Module CONTAINS where the File node exists (from the structure phase). */ -function linkFileModuleContains(graph: KnowledgeGraph, state: MoveIngestState): void { - for (const [qn, file] of state.moduleFileMap) { - const fileNodeId = `File:${file}`; - const moduleNodeId = moveModuleNodeId(qn, file); - if (graph.getNode(fileNodeId) && graph.getNode(moduleNodeId)) { - graph.addRelationship({ - id: moveRelId(fileNodeId, 'CONTAINS', moduleNodeId, MOVE_EDGE_REASON.moduleInFile), - sourceId: fileNodeId, - targetId: moduleNodeId, - type: 'CONTAINS', - confidence: 1.0, - reason: MOVE_EDGE_REASON.moduleInFile, - }); - } - } -} - /** Surface consistency errors so they reach a human/log (not just the output). */ function reportConsistencyIssues(ctx: PipelineContext, issues: MoveConsistencyIssue[]): void { const errors = issues.filter((i) => i.severity === 'error'); diff --git a/gitnexus/src/core/move/move-linker.ts b/gitnexus/src/core/move/move-linker.ts new file mode 100644 index 000000000..79f152934 --- /dev/null +++ b/gitnexus/src/core/move/move-linker.ts @@ -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; + functionNodeMap: ReadonlyMap; + structNodeMap: ReadonlyMap; + callGraphByPackage: ReadonlyMap; + closureCallsByPackage: ReadonlyMap; + pendingRefs: readonly PendingRef[]; + droppedRefs: DroppedRef[]; +} + +export class ExternalMoveSymbols { + private readonly modules = new Map(); + private readonly functions = new Map(); + private readonly types = new Map(); + + constructor( + private readonly graph: KnowledgeGraph, + private readonly localModules: ReadonlyMap, + ) {} + + 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` → `CoinStore`. */ +function stripTypeArgs(typeName: string): string { + const idx = typeName.indexOf('<'); + return (idx === -1 ? typeName : typeName.slice(0, idx)).trim(); +} + +export function buildLocalNameIndex( + structNodeMap: ReadonlyMap, +): Map { + const structIdsByLocalName = new Map(); + 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, + structIdsByLocalName: ReadonlyMap, +): { 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; + structIdsByLocalName: ReadonlyMap; + moduleFileMap: ReadonlyMap; + functionNodeMap: ReadonlyMap; +} + +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 = { + 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(); + 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, + 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(); + 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, + }); + } +} diff --git a/gitnexus/src/core/move/provision.ts b/gitnexus/src/core/move/provision.ts index 03c63a5e0..99b2b7501 100644 --- a/gitnexus/src/core/move/provision.ts +++ b/gitnexus/src/core/move/provision.ts @@ -1,4 +1,8 @@ -import { createHash } from 'node:crypto'; +import { createHash, randomBytes } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { access, realpath } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import * as path from 'node:path'; import type { MoveCompilerIdentity } from './constants.js'; import { createMoveFlowClient, @@ -43,15 +47,70 @@ const defaultDependencies: MoveFlowProvisionDependencies = { install: installMoveFlow, }; -const localIdentity = ( +async function resolveExecutablePath(locator: string): Promise { + 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 { + const digest = createHash('sha256'); + for await (const chunk of createReadStream(absolutePath)) digest.update(chunk as Buffer); + return digest.digest('hex'); +} + +const localIdentity = async ( source: 'explicit' | 'path', locator: string, version: string, -): MoveCompilerIdentity => ({ - version, - source, - fingerprint: createHash('sha256').update(`${source}\0${locator}\0${version}`).digest('hex'), -}); + onLog?: (message: string) => void, +): Promise => { + const resolvedPath = await resolveExecutablePath(locator); + // If the binary changes between the successful version probe and hashing, + // make this identity unique rather than certifying an unverifiable runtime. + const binaryDigest = resolvedPath + ? await hashExecutable(resolvedPath).catch( + () => `unverifiable:${randomBytes(16).toString('hex')}`, + ) + : `unverifiable:${randomBytes(16).toString('hex')}`; + if (binaryDigest.startsWith('unverifiable:')) { + // The random digest deliberately churns the fingerprint, and a fingerprint + // change forces a full Move re-index — every run, until the binary is + // readable again. Say so instead of rebuilding silently. + onLog?.( + `move-flow binary at '${resolvedPath ?? locator}' could not be read for ` + + `fingerprinting; Move facts will be fully rebuilt on every run until it is readable.`, + ); + } + return { + version, + source, + fingerprint: createHash('sha256') + .update(`${source}\0${resolvedPath ?? locator}\0${version}\0${binaryDigest}`) + .digest('hex'), + }; +}; const verifiedRuntime = ( binary: VerifiedMoveFlowBinary, @@ -98,7 +157,7 @@ export async function ensureMoveFlowRuntime( if (resolved) { return { client: resolved.client, - identity: localIdentity('explicit', explicitPath, resolved.version), + identity: await localIdentity('explicit', explicitPath, resolved.version, options.onLog), }; } options.onLog?.( @@ -119,7 +178,7 @@ export async function ensureMoveFlowRuntime( // terminals/CI steps, and a fingerprint churn forces a full re-index. return { client: existing.client, - identity: localIdentity('path', 'move-flow', existing.version), + identity: await localIdentity('path', 'move-flow', existing.version, options.onLog), }; } diff --git a/gitnexus/src/core/move/refs.ts b/gitnexus/src/core/move/refs.ts new file mode 100644 index 000000000..71388c5d7 --- /dev/null +++ b/gitnexus/src/core/move/refs.ts @@ -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; +} diff --git a/gitnexus/src/core/move/symbol-id.ts b/gitnexus/src/core/move/symbol-id.ts index 79f19d3ba..09d76bf87 100644 --- a/gitnexus/src/core/move/symbol-id.ts +++ b/gitnexus/src/core/move/symbol-id.ts @@ -67,6 +67,11 @@ export function moveConstNodeId(constQualifiedName: string, filePath: string): s return `Const:${filePath}:${constQualifiedName}`; } +/** Synthetic dependency type whose declaration is outside the indexed repo. */ +export function moveExternalTypeNodeId(typeQualifiedName: string): string { + return `Type:::${typeQualifiedName}`; +} + export function moveEnumVariantNodeId( enumQualifiedName: string, variantName: string, diff --git a/gitnexus/src/core/move/type-parser.ts b/gitnexus/src/core/move/type-parser.ts index 8b48c2cf8..6e603f5d5 100644 --- a/gitnexus/src/core/move/type-parser.ts +++ b/gitnexus/src/core/move/type-parser.ts @@ -17,6 +17,12 @@ const MOVE_PRIMITIVES = new Set([ 'u64', 'u128', 'u256', + 'i8', + 'i16', + 'i32', + 'i64', + 'i128', + 'i256', 'address', 'signer', '&signer', @@ -25,11 +31,23 @@ const MOVE_PRIMITIVES = new Set([ export function extractTypeNames(typeExpr: string): string[] { if (!typeExpr) return []; - const expr = typeExpr.trim().replace(/^&(mut\s+)?/, ''); + // Move 2 function types may carry an ability constraint suffix: + // `|u64|bool has copy + drop`. The function type can itself be nested in a + // generic, so the suffix may end immediately before `>` rather than at the + // end of the complete expression. Abilities are not nominal types. + const expr = typeExpr + .trim() + .replace( + /\s+has\s+(?:copy|drop|store|key)(?:\s*\+\s*(?:copy|drop|store|key))*(?=\s*(?:[>,)|]|$))/gi, + '', + ) + .replace(/^&(mut\s+)?/, ''); if (MOVE_PRIMITIVES.has(expr)) return []; const tokens: string[] = []; - for (const raw of expr.split(/[<>,]/)) { + // Delimiters cover generics (`<`, `>`, `,`), tuples (`(`, `)`), and Move 2 + // function types (`|u64|bool`) - without `()|` those leak into type names. + for (const raw of expr.split(/[<>,()|]/)) { const name = raw.trim().replace(/^&(mut\s+)?/, ''); if (!name || MOVE_PRIMITIVES.has(name)) continue; tokens.push(name); diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 28ced6afa..a30455eeb 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -19,6 +19,7 @@ import { persistedMoveGraphRequiresCompiler, shouldProvisionMoveFlowBeforeFastPath, } from './move/constants.js'; +import { summarizeMoveConsistency } from './move/consistency.js'; import { createMoveIngestPhase } from './move/move-ingest.js'; import { repoHasMove } from './move/discovery.js'; import { getMoveFlowReleaseSelection } from './move/install.js'; @@ -40,6 +41,7 @@ import { deleteAllInterprocTaintPaths, deleteAllCallSummaries, deleteAllInjects, + deleteAllExternalNodes, queryImportersBatch, loadFTSExtension, wipeLbugDbFiles, @@ -1330,44 +1332,52 @@ export async function runFullAnalysis( // ── Phase 1: Full Pipeline (0–60%) ──────────────────────────────── // `finally` guarantees the spawned move-flow child is released even if the // pipeline throws — important for long-running hosts (MCP daemon, eval-server). - let pipelineResult: Awaited>; - try { - pipelineResult = await runPipelineFromRepo( - repoPath, - (p) => { - const phaseLabel = PHASE_LABELS[p.phase] || p.phase; - const scaled = Math.round(p.percent * 0.6); - const message = p.detail - ? `${p.message || phaseLabel} (${p.detail})` - : p.message || phaseLabel; - progress(p.phase, scaled, message); - }, - { - parseCache, - workerPoolSize: options.workerPoolSize, - standaloneIngestPhase: createMoveIngestPhase(moveFlowClient), - // CFG/PDG opt-in (#2081 M1). PipelineOptions.pdg fans out to the worker - // build gate (workerData.pdg) and the scope-resolution emit gate. - pdg: options.pdg === true, - pdgMaxFunctionLines: options.pdgMaxFunctionLines, - pdgMaxEdgesPerFunction: options.pdgMaxEdgesPerFunction, - pdgMaxReachingDefEdgesPerFunction: options.pdgMaxReachingDefEdgesPerFunction, - pdgMaxCdgEdgesPerFunction: options.pdgMaxCdgEdgesPerFunction, - pdgMaxTaintFindingsPerFunction: options.pdgMaxTaintFindingsPerFunction, - pdgMaxTaintHops: options.pdgMaxTaintHops, - pdgMaxInterprocFindings: options.pdgMaxInterprocFindings, - pdgMaxInterprocHops: options.pdgMaxInterprocHops, - pdgMaxInterprocEdges: options.pdgMaxInterprocEdges, - // Streaming/chunked PDG emit (#2202) — gated to full-rebuild runs - // (force === true) so the incremental writeback never reads back an - // offloaded BasicBlock layer. Memory-only; byte-identical output. - streamPdgEmit: resolveStreamPdgEmit(options), - pdgEmitChunkSize: resolvePdgEmitChunkSize(options), - fetchWrappers: options.fetchWrappers, - }, + const pipelineResult = await runPipelineFromRepo( + repoPath, + (p) => { + const phaseLabel = PHASE_LABELS[p.phase] || p.phase; + const scaled = Math.round(p.percent * 0.6); + const message = p.detail + ? `${p.message || phaseLabel} (${p.detail})` + : p.message || phaseLabel; + progress(p.phase, scaled, message); + }, + { + parseCache, + workerPoolSize: options.workerPoolSize, + standaloneIngestPhase: createMoveIngestPhase(moveFlowClient), + // CFG/PDG opt-in (#2081 M1). PipelineOptions.pdg fans out to the worker + // build gate (workerData.pdg) and the scope-resolution emit gate. + pdg: options.pdg === true, + pdgMaxFunctionLines: options.pdgMaxFunctionLines, + pdgMaxEdgesPerFunction: options.pdgMaxEdgesPerFunction, + pdgMaxReachingDefEdgesPerFunction: options.pdgMaxReachingDefEdgesPerFunction, + pdgMaxCdgEdgesPerFunction: options.pdgMaxCdgEdgesPerFunction, + pdgMaxTaintFindingsPerFunction: options.pdgMaxTaintFindingsPerFunction, + pdgMaxTaintHops: options.pdgMaxTaintHops, + pdgMaxInterprocFindings: options.pdgMaxInterprocFindings, + pdgMaxInterprocHops: options.pdgMaxInterprocHops, + pdgMaxInterprocEdges: options.pdgMaxInterprocEdges, + // Streaming/chunked PDG emit (#2202) — gated to full-rebuild runs + // (force === true) so the incremental writeback never reads back an + // offloaded BasicBlock layer. Memory-only; byte-identical output. + streamPdgEmit: resolveStreamPdgEmit(options), + pdgEmitChunkSize: resolvePdgEmitChunkSize(options), + fetchWrappers: options.fetchWrappers, + }, + ).finally(() => moveFlowClient?.shutdown()); + + // Move ingest consistency digest — persisted to meta.json below so warnings + // (dropped refs, test-only packages) survive the run instead of dying with + // the transient progress stream. + const moveConsistency = summarizeMoveConsistency( + pipelineResult.standaloneIngest.consistencyIssues, + ); + if (moveConsistency) { + log( + `Move ingest consistency: ${moveConsistency.errorCount} error(s), ` + + `${moveConsistency.warningCount} warning(s) — details in meta.json (moveConsistency).`, ); - } finally { - await moveFlowClient?.shutdown(); } // ── Phase 2: LadybugDB (60–85%) ────────────────────────────────── @@ -1927,6 +1937,15 @@ export async function runFullAnalysis( // deleting on every non-pdg incremental run (N runs = N copies of // every INJECTS row; CodeRelation has no PK and no read-side dedup). await deleteAllInjects(); + // 2a'. Drop externally-declared dependency nodes (locationFidelity + // 'external', filePath '') — the file-keyed delete/write cycle can + // never refresh them, so an external symbol first referenced by + // this run would otherwise be extracted edge-first and silently + // dropped at the rel-COPY fallback. The standalone ingest phase + // regenerates the full external surface every run and + // extractChangedSubgraph re-includes it (isExternalNode), same + // delete-all-then-rebuild contract as Community/Process/INJECTS. + await deleteAllExternalNodes(); // 2b. Drop interprocedural TAINT_PATH edges (#2084 M4 U6) when pdg is on // — their validity is a whole-program property (an A→C flow can be // invalidated by a change to an intermediate function on a third @@ -2392,8 +2411,23 @@ export async function runFullAnalysis( // force a full rebuild), so when the compiler is transiently unavailable // the previous record still describes the persisted facts. Stamping // false/undefined here would force a needless full rebuild the moment the - // compiler returns. + // compiler returns. All Move meta travels together: preservation is one + // structural decision, not per-field ternaries a future field can forget. const preserveMoveMeta = hasMovePackages && !moveFlow && isIncremental; + const moveMeta = preserveMoveMeta + ? { + moveIngestAvailable: existingMeta?.moveIngestAvailable, + moveCompilerIdentity: existingMeta?.moveCompilerIdentity, + moveFlowReleaseSelection: existingMeta?.moveFlowReleaseSelection, + moveConsistency: existingMeta?.moveConsistency, + } + : { + moveIngestAvailable, + moveCompilerIdentity: moveFlow?.identity, + moveFlowReleaseSelection: + moveFlow?.identity.source === 'release' ? desiredMoveFlowRelease : undefined, + moveConsistency, + }; // Annotated so the capabilities stamp below is compile-checked against // RepoMeta's status unions (tri-review 4669518496 P1/U3) — an unannotated @@ -2455,17 +2489,7 @@ export async function runFullAnalysis( // absence, so this is never conditionally omitted. cjkSegmentation: getSearchFTSCjkSegmentation(), fileHashes: hasGitDir(repoPath) ? newFileHashesRecord : undefined, - moveIngestAvailable: preserveMoveMeta - ? existingMeta?.moveIngestAvailable - : moveIngestAvailable, - moveCompilerIdentity: preserveMoveMeta - ? existingMeta?.moveCompilerIdentity - : moveFlow?.identity, - moveFlowReleaseSelection: preserveMoveMeta - ? existingMeta?.moveFlowReleaseSelection - : moveFlow?.identity.source === 'release' - ? desiredMoveFlowRelease - : undefined, + ...moveMeta, // This branch's full live chunk-key set (#2106 R6). `usedKeys` is every // chunk hash touched in this scan — cache HITS included (see parse-impl // usedKeys.add) — so it's complete even on an incremental run. Persisted @@ -2691,7 +2715,7 @@ export async function runFullAnalysis( stats: meta.stats, pipelineResult, ftsSkipped: !ftsReady, - ingestWarnings: pipelineResult.ingestWarnings, + ingestWarnings: pipelineResult.standaloneIngest.ingestWarnings, isPrimaryBranch: !placement.branch, }; } catch (err) { diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 308a109e5..6bf66656d 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -22,6 +22,7 @@ import { randomBytes } from 'crypto'; import { getInferredRepoName, resolveRepoIdentityRoot } from './git.js'; import { retryRename } from './fs-atomic.js'; import { logger } from '../core/logger.js'; +import type { MoveConsistencySummary } from '../core/move/consistency.js'; import type { MoveCompilerIdentity, MoveFlowReleaseSelection } from '../core/move/constants.js'; import { branchSlug, @@ -228,6 +229,12 @@ export interface RepoMeta { moveCompilerIdentity?: MoveCompilerIdentity; /** Managed release coordinates, kept separate from the binary fingerprint. */ moveFlowReleaseSelection?: MoveFlowReleaseSelection; + /** + * Move ingest consistency digest from the run that produced the persisted + * facts (full counts + a capped errors-first sample). Absent when the run + * was clean or the repo has no Move packages. + */ + moveConsistency?: MoveConsistencySummary; /** * Crash-recovery dirty flag — a generic marker written to the metadata * file (gitnexus.json + its meta.json mirror) BEFORE any destructive DB @@ -460,8 +467,12 @@ export interface RepoMeta { * stamp of 9 is ambiguous between the two lineages, and a pre-merge Move * index also predates main's v9–v11 rebuild reasons. Any pre-v12 stamp fails * the strict-equality reuse gate; force a full re-analyze (same contract as v3). + * v13: generic Type nodes and the Move 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 { repoPath: string; diff --git a/gitnexus/src/types/pipeline.ts b/gitnexus/src/types/pipeline.ts index 343dcb3da..3cf8fa1dd 100644 --- a/gitnexus/src/types/pipeline.ts +++ b/gitnexus/src/types/pipeline.ts @@ -1,11 +1,14 @@ import type { KnowledgeGraph } from '../core/graph/types.js'; import { CommunityDetectionResult } from '../core/ingestion/community-processor.js'; import { ProcessDetectionResult } from '../core/ingestion/process-processor.js'; +import type { StandaloneIngestOutput } from '../core/ingestion/pipeline-phases/standalone-ingest.js'; import type { ResolutionOutcome } from '../core/ingestion/scope-resolution/resolution-outcome.js'; import type { PdgEmitManifest } from '../core/lbug/pdg-emit-sink.js'; // CLI-specific: in-memory result with graph + detection results -export interface PipelineResult { +export interface PipelineResult< + TStandaloneIngest extends StandaloneIngestOutput = StandaloneIngestOutput, +> { graph: KnowledgeGraph; /** Absolute path to the repo root — used for lazy file reads during LadybugDB loading */ repoPath: string; @@ -37,10 +40,9 @@ export interface PipelineResult { */ pdgEmitManifest?: PdgEmitManifest; /** - * Operator-actionable warnings from the standalone ingest phase (skipped or - * degraded-fidelity packages). Passed through opaquely — the pipeline does - * not know which language produced them — so the CLI summary can render them - * persistently (same rationale as the FTS warning). + * Output of the standalone-ingest phase (the caller-supplied compiler-backed + * ingester, e.g. Move). The pipeline stays language-agnostic; callers that + * register a richer phase may narrow this output to their phase contract. */ - ingestWarnings?: readonly string[]; + standaloneIngest: TStandaloneIngest; } diff --git a/gitnexus/test/helpers/move-ingest-harness.ts b/gitnexus/test/helpers/move-ingest-harness.ts index 047c6fd6f..ef43a30a7 100644 --- a/gitnexus/test/helpers/move-ingest-harness.ts +++ b/gitnexus/test/helpers/move-ingest-harness.ts @@ -4,16 +4,105 @@ * Shared by the unit (move-ingest-*) and integration (move-live) Move tests. */ import { createMoveIngestPhase, type MoveIngestOutput } from '../../src/core/move/move-ingest.js'; +import type { MoveFactsFunction } from '../../src/core/move/compiler-facts.js'; import type { MoveFlowClient } from '../../src/core/move/mcp-client.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; +import type { KnowledgeGraph } from '../../src/core/graph/types.js'; import type { PhaseResult } from '../../src/core/ingestion/pipeline-phases/types.js'; +export interface MoveFlowClientCallCounts { + facts: number; + callGraph: number; + functionUsage: number; + capabilities: number; + packageStatus: number; +} + +export const moveFunctionFact = ( + name: string, + overrides: Partial = {}, +): MoveFactsFunction => ({ + 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 & { counts: MoveFlowClientCallCounts } { + const counts: MoveFlowClientCallCounts = { + facts: 0, + callGraph: 0, + functionUsage: 0, + capabilities: 0, + packageStatus: 0, + }; + const impl: MoveFlowClient = { + facts: async () => ({}), + callGraph: async () => ({}), + functionUsage: async () => ({ + called: [], + used: [], + }), + packageStatus: async () => ({ ok: true, diagnostics: '' }), + capabilities: async () => ({ + hasFactsQuery: true, + hasFunctionUsageQuery: true, + hasStatusTool: true, + }), + shutdown: async () => {}, + ...overrides, + }; + return { + counts, + facts: (pkg) => { + counts.facts += 1; + return impl.facts(pkg); + }, + callGraph: (pkg) => { + counts.callGraph += 1; + return impl.callGraph(pkg); + }, + functionUsage: (pkg, fn) => { + counts.functionUsage += 1; + return impl.functionUsage(pkg, fn); + }, + packageStatus: (pkg) => { + counts.packageStatus += 1; + return impl.packageStatus(pkg); + }, + capabilities: () => { + counts.capabilities += 1; + return impl.capabilities(); + }, + shutdown: () => impl.shutdown(), + }; +} + /** Run the moveIngest phase over `filePaths` (repo-relative) under `repoPath`. */ export async function runMoveIngestPhase( client: MoveFlowClient | null, repoPath: string, filePaths: readonly string[], ): Promise { + 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>([ [ 'structure', @@ -29,13 +118,15 @@ export async function runMoveIngestPhase( }, ], ]); - return createMoveIngestPhase(client).execute( + const graph = createKnowledgeGraph(); + const output = await createMoveIngestPhase(client).execute( { repoPath, - graph: createKnowledgeGraph(), + graph, onProgress: () => {}, pipelineStart: Date.now(), }, deps, ); + return { output, graph }; } diff --git a/gitnexus/test/integration/lbug-core-adapter.test.ts b/gitnexus/test/integration/lbug-core-adapter.test.ts index eb420e046..5a5d8b15e 100644 --- a/gitnexus/test/integration/lbug-core-adapter.test.ts +++ b/gitnexus/test/integration/lbug-core-adapter.test.ts @@ -170,6 +170,43 @@ withTestLbugDB( expect(Number((queriesLeft[0] as { cnt: number }).cnt)).toBe(1); }); + it('deleteAllExternalNodes: removes only external dependency nodes (and their edges)', async () => { + // Same contract family as the delete-alls above — external nodes have + // no filePath, so the incremental writeback delete-alls them and + // re-COPYs the fresh external surface (extractChangedSubgraph). + const { executeQuery: coreExecuteQuery, deleteAllExternalNodes } = + await import('../../src/core/lbug/lbug-adapter.js'); + + // Benign: nothing external yet → 0, does NOT throw. + await expect(deleteAllExternalNodes()).resolves.toEqual({ nodesDeleted: 0 }); + + // Seed one external Function plus an edge into it from a local one. + await coreExecuteQuery( + `CREATE (:Function {id: 'Function::0x1::object::object_address', ` + + `name: 'object_address', filePath: '', locationFidelity: 'external'})`, + ); + const fns = (await coreExecuteQuery( + `MATCH (n:Function) WHERE n.filePath <> '' RETURN n.id AS id`, + )) as { id: string }[]; + expect(fns.length).toBe(2); + await coreExecuteQuery( + `MATCH (a:Function {id: '${fns[0].id}'}), ` + + `(b:Function {id: 'Function::0x1::object::object_address'}) ` + + `CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 1.0, reason: 'move', step: 0}]->(b)`, + ); + + const r = await deleteAllExternalNodes(); + expect(r.nodesDeleted).toBe(1); + const externalLeft = await coreExecuteQuery( + `MATCH (n:Function) WHERE n.locationFidelity = 'external' RETURN count(n) AS cnt`, + ); + expect(Number((externalLeft[0] as { cnt: number }).cnt)).toBe(0); + const localLeft = await coreExecuteQuery( + `MATCH (n:Function) WHERE n.filePath <> '' RETURN count(n) AS cnt`, + ); + expect(Number((localLeft[0] as { cnt: number }).cnt)).toBe(2); + }); + describe('unhappy path', () => { it('throws on malformed Cypher query', async () => { const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js'); diff --git a/gitnexus/test/integration/move-live.test.ts b/gitnexus/test/integration/move-live.test.ts index ad8375ee2..2ff159017 100644 --- a/gitnexus/test/integration/move-live.test.ts +++ b/gitnexus/test/integration/move-live.test.ts @@ -54,6 +54,7 @@ describe.skipIf(!client)('live move-flow ingestion (coin fixture)', () => { it('detects the facts query capability', async () => { const caps = await client!.capabilities(); expect(caps.hasFactsQuery).toBe(true); + expect(caps.hasFunctionUsageQuery).toBe(true); }); it('builds a full-fidelity Move graph from compiler facts', async () => { diff --git a/gitnexus/test/integration/move-mixed-language-roundtrip.test.ts b/gitnexus/test/integration/move-mixed-language-roundtrip.test.ts index 0df3df497..087c9c834 100644 --- a/gitnexus/test/integration/move-mixed-language-roundtrip.test.ts +++ b/gitnexus/test/integration/move-mixed-language-roundtrip.test.ts @@ -14,6 +14,12 @@ const sharedTables = [ }, { table: 'Struct', otherLanguage: 'rust', detailProperty: 'isResource', moveDetail: true }, { table: 'Enum', otherLanguage: 'rust', detailProperty: 'isEvent', moveDetail: true }, + { + table: 'Type', + otherLanguage: 'typescript', + detailProperty: 'locationFidelity', + moveDetail: 'external', + }, { table: 'EnumVariant', otherLanguage: 'rust', @@ -53,6 +59,28 @@ withTestLbugDB( ]); } }); + + it('stores Move enum fields and local/external type-reference relationships', async () => { + const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js'); + const rows = await executeQuery( + "MATCH (a)-[r:CodeRelation]->(b) WHERE r.reason STARTS WITH 'move-roundtrip-' " + + 'RETURN a.id AS source, r.type AS type, b.id AS target ORDER BY r.reason', + ); + + expect(rows).toEqual([ + { + source: 'EnumVariant:move', + type: 'HAS_PROPERTY', + target: 'Property:move-field', + }, + { source: 'Function:move', type: 'USES_TYPE', target: 'Type:move' }, + { source: 'Module:move', type: 'DEFINES', target: 'Type:move' }, + { source: 'Property:move-field', type: 'USES_TYPE', target: 'Enum:move' }, + { source: 'Property:move-field', type: 'USES_TYPE', target: 'Struct:move' }, + { source: 'Property:move-field', type: 'USES_TYPE', target: 'Type:move' }, + { source: 'Struct:move', type: 'USES_TYPE', target: 'Type:move' }, + ]); + }); }); }, { @@ -62,32 +90,88 @@ withTestLbugDB( await fs.mkdir(repoPath, { recursive: true }); const graph = buildTestGraph( - sharedTables.flatMap((testCase) => [ + [ + ...sharedTables.flatMap((testCase) => [ + { + id: `${testCase.table}:move`, + label: testCase.table as NodeLabel, + name: testCase.table, + filePath: testCase.table === 'Type' ? '' : 'sources/sample.move', + startLine: 1, + endLine: 2, + isExported: testCase.table === 'Function', + extra: { + language: 'move', + qualifiedName: `0x1::sample::${testCase.table}`, + moduleQualifiedName: '0x1::sample', + [testCase.detailProperty]: testCase.moveDetail, + }, + }, + { + id: `${testCase.table}:${testCase.otherLanguage}`, + label: testCase.table as NodeLabel, + name: testCase.table, + filePath: `src/sample.${testCase.otherLanguage}`, + startLine: 1, + endLine: 2, + isExported: testCase.table === 'Function', + extra: { language: testCase.otherLanguage }, + }, + ]), { - id: `${testCase.table}:move`, - label: testCase.table as NodeLabel, - name: testCase.table, + id: 'Property:move-field', + label: 'Property', + name: 'field', filePath: 'sources/sample.move', startLine: 1, endLine: 2, - isExported: testCase.table === 'Function', - extra: { - language: 'move', - qualifiedName: `0x1::sample::${testCase.table}`, - [testCase.detailProperty]: testCase.moveDetail, - }, + extra: { declaredType: '0x1::sample::Type' }, + }, + ], + [ + { + sourceId: 'EnumVariant:move', + targetId: 'Property:move-field', + type: 'HAS_PROPERTY', + reason: 'move-roundtrip-1', }, { - id: `${testCase.table}:${testCase.otherLanguage}`, - label: testCase.table as NodeLabel, - name: testCase.table, - filePath: `src/sample.${testCase.otherLanguage}`, - startLine: 1, - endLine: 2, - isExported: testCase.table === 'Function', - extra: { language: testCase.otherLanguage }, + sourceId: 'Function:move', + targetId: 'Type:move', + type: 'USES_TYPE', + reason: 'move-roundtrip-2', }, - ]), + { + sourceId: 'Module:move', + targetId: 'Type:move', + type: 'DEFINES', + reason: 'move-roundtrip-3', + }, + { + sourceId: 'Property:move-field', + targetId: 'Enum:move', + type: 'USES_TYPE', + reason: 'move-roundtrip-4', + }, + { + sourceId: 'Property:move-field', + targetId: 'Struct:move', + type: 'USES_TYPE', + reason: 'move-roundtrip-5', + }, + { + sourceId: 'Property:move-field', + targetId: 'Type:move', + type: 'USES_TYPE', + reason: 'move-roundtrip-6', + }, + { + sourceId: 'Struct:move', + targetId: 'Type:move', + type: 'USES_TYPE', + reason: 'move-roundtrip-7', + }, + ], ); const { loadGraphToLbug } = await import('../../src/core/lbug/lbug-adapter.js'); diff --git a/gitnexus/test/integration/move/__snapshots__/graph-equivalence.test.ts.snap b/gitnexus/test/integration/move/__snapshots__/graph-equivalence.test.ts.snap new file mode 100644 index 000000000..6e4241cce --- /dev/null +++ b/gitnexus/test/integration/move/__snapshots__/graph-equivalence.test.ts.snap @@ -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", + ], +} +`; diff --git a/gitnexus/test/integration/move/graph-equivalence.test.ts b/gitnexus/test/integration/move/graph-equivalence.test.ts new file mode 100644 index 000000000..48b24aa42 --- /dev/null +++ b/gitnexus/test/integration/move/graph-equivalence.test.ts @@ -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); +}); diff --git a/gitnexus/test/unit/call-summary-schema-version.test.ts b/gitnexus/test/unit/call-summary-schema-version.test.ts index 039b0b121..e00bbb73d 100644 --- a/gitnexus/test/unit/call-summary-schema-version.test.ts +++ b/gitnexus/test/unit/call-summary-schema-version.test.ts @@ -72,15 +72,14 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => { }); }); -describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { - it('INCREMENTAL_SCHEMA_VERSION is bumped to 12 (main-aptos merge: Move attributesJson past main v9–v11)', () => { - expect(INCREMENTAL_SCHEMA_VERSION).toBe(12); +describe('incremental schema reuse gate', () => { + it('INCREMENTAL_SCHEMA_VERSION is bumped to 13 (persisted Move Type/field graph)', () => { + expect(INCREMENTAL_SCHEMA_VERSION).toBe(13); }); it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => { - // The reuse gate at run-analyze.ts:920 is exactly this strict equality on - // the persisted `existingMeta.schemaVersion` (a plain number, possibly - // absent on a legacy stamp). Replicate it as a typed predicate. + // The production gate compares the persisted numeric stamp by strict + // equality; legacy metadata may omit it. const passesReuseGate = (stampedSchemaVersion: number | undefined): boolean => stampedSchemaVersion === INCREMENTAL_SCHEMA_VERSION; // A pre-v4 (v3) index has no CALL_SUMMARY edges → must NOT reuse. @@ -121,7 +120,10 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { // `attributesJson` node-table column, so the bulk COPY referencing it // would fail on a top-up → must NOT reuse. expect(passesReuseGate(11)).toBe(false); + // A pre-v13 (v12) index silently dropped generic Type nodes and Move + // EnumVariant→Property / field USES_TYPE relationships during CSV routing. + expect(passesReuseGate(12)).toBe(false); // A current-version stamp passes the gate (incremental top-up eligible). - expect(passesReuseGate(12)).toBe(true); + expect(passesReuseGate(13)).toBe(true); }); }); diff --git a/gitnexus/test/unit/incremental-subgraph-extract.test.ts b/gitnexus/test/unit/incremental-subgraph-extract.test.ts index 667ef3399..43d750e2f 100644 --- a/gitnexus/test/unit/incremental-subgraph-extract.test.ts +++ b/gitnexus/test/unit/incremental-subgraph-extract.test.ts @@ -68,6 +68,31 @@ describe('extractChangedSubgraph', () => { expect(sub.nodes.map((n) => n.id).sort()).toEqual(['comm-1', 'proc-1']); }); + it('always includes external dependency nodes and their edges (locationFidelity external)', () => { + // A Move file changed and now calls a dependency function that was never + // persisted. The external node has no filePath, so file-keyed extraction + // would skip the node while including the edge — the dangling endpoint + // then fails the rel COPY into the silent per-row fallback. External + // nodes get the Community/Process treatment instead (orchestrator + // delete-alls, extractor re-includes). + const g = createKnowledgeGraph(); + g.addNode(makeFileNode('caller:fn', 'sources/vault.move')); + g.addNode({ + id: 'Function::0x1::object::object_address', + label: 'Function', + properties: { filePath: '', name: 'object_address', locationFidelity: 'external' }, + } as unknown as GraphNode); + g.addRelationship(makeRel('e1', 'caller:fn', 'Function::0x1::object::object_address')); + + const sub = extractChangedSubgraph(g, new Set(['sources/vault.move'])); + + expect(sub.nodes.map((n) => n.id).sort()).toEqual([ + 'Function::0x1::object::object_address', + 'caller:fn', + ]); + expect(sub.relationships.map((r) => r.id)).toEqual(['e1']); + }); + it('includes a relationship when at least one endpoint is writable', () => { const g = createKnowledgeGraph(); g.addNode(makeFileNode('a:fn', '/repo/a.ts')); diff --git a/gitnexus/test/unit/move/concurrency.test.ts b/gitnexus/test/unit/move/concurrency.test.ts new file mode 100644 index 000000000..d18eeec08 --- /dev/null +++ b/gitnexus/test/unit/move/concurrency.test.ts @@ -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((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]); + }); +}); diff --git a/gitnexus/test/unit/move/consistency.test.ts b/gitnexus/test/unit/move/consistency.test.ts index 1fa4821cb..aa34b0f5f 100644 --- a/gitnexus/test/unit/move/consistency.test.ts +++ b/gitnexus/test/unit/move/consistency.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from 'vitest'; -import { validateMoveIngestOutput } from '../../../src/core/move/consistency.js'; +import { + summarizeMoveConsistency, + validateMoveIngestOutput, +} from '../../../src/core/move/consistency.js'; import type { MoveIngestOutput } from '../../../src/core/move/move-ingest.js'; import { createKnowledgeGraph } from '../../../src/core/graph/graph.js'; @@ -21,6 +24,8 @@ function makeOutput(overrides: Partial = {}): MoveIngestOutput modulePackageMap: new Map(), filePackageMap: new Map(), callGraphByPackage: new Map(), + droppedRefs: [], + functionUsageFailures: [], consistencyIssues: [], ...overrides, }; @@ -199,11 +204,180 @@ describe('validateMoveIngestOutput', () => { const output = makeOutput({ ingestedFiles: new Set([file]), moduleFileMap: new Map([['0xa::m', file]]), - droppedResourceRefs: [{ fnNodeId: 'Function:f', target: 'Config' }], + droppedRefs: [{ kind: 'resource', sourceId: 'Function:f', target: 'Config' }], }); const issues = validateMoveIngestOutput(graph, output); expect( issues.some((i) => i.code === 'unresolved-resource-target' && i.severity === 'warning'), ).toBe(true); }); + + it('warns when type, friend, or lambda-host targets were dropped', () => { + const graph = createKnowledgeGraph(); + const output = makeOutput({ + droppedRefs: [ + { kind: 'type', sourceId: 'Function:f', target: '(u64' }, + { kind: 'friend', sourceId: 'Module:m', target: '0xb::gone' }, + { kind: 'lambda-host', sourceId: 'Function:l', target: '0xa::m::host' }, + ], + }); + const issues = validateMoveIngestOutput(graph, output); + for (const code of [ + 'unresolved-type-target', + 'unresolved-friend-target', + 'unresolved-lambda-host', + ] as const) { + const issue = issues.find((i) => i.code === code); + expect(issue?.severity).toBe('warning'); + expect(issue?.details?.count).toBe(1); + expect( + (issue?.details?.sample as Array<{ kind: string; sourceId: string; target: string }>)?.[0], + ).toMatchObject({ + kind: expect.any(String), + sourceId: expect.any(String), + target: expect.any(String), + }); + } + }); + + it('errors call-graph-unlinked when no caller resolves despite mapped functions', () => { + const graph = createKnowledgeGraph(); + const file = 'sources/coin.move'; + addFileNode(graph, file); + const output = makeOutput({ + ingestedFiles: new Set([file]), + moduleFileMap: new Map([['0xa::coin', file]]), + modulePackageMap: new Map([['0xa::coin', '/pkg']]), + functionNodeMap: new Map([['0xa::coin::register', `Function:${file}:0xa::coin::register`]]), + // Simulated normalization drift: call_graph came back zero-padded, so + // NO caller matches facts-derived names - and the ownership checks are + // also blind to it (caller modules miss modulePackageMap the same way). + callGraphByPackage: new Map([['/pkg', { '0x000a::coin::register': ['0x000a::coin::mint'] }]]), + }); + const issues = validateMoveIngestOutput(graph, output); + const issue = issues.find((i) => i.code === 'call-graph-unlinked'); + expect(issue?.severity).toBe('error'); + expect(issue?.details?.packageRoot).toBe('/pkg'); + // The pre-existing per-caller checks stay silent here - that blindness is + // exactly why the package-level check exists. + expect(issues.some((i) => i.code === 'missing-owned-caller')).toBe(false); + }); + + it('does not flag call-graph-unlinked when caller modules resolve (facts-elided callers)', () => { + const graph = createKnowledgeGraph(); + const file = 'sources/coin.move'; + addFileNode(graph, file); + const output = makeOutput({ + ingestedFiles: new Set([file]), + moduleFileMap: new Map([['0xa::coin', file]]), + modulePackageMap: new Map([['0xa::coin', '/pkg']]), + functionNodeMap: new Map([['0xa::coin::register', `Function:${file}:0xa::coin::register`]]), + // The only caller is a #[test] function elided from facts: its MODULE + // resolves, so this is per-caller warning territory, not systematic + // qualified-name drift. + callGraphByPackage: new Map([['/pkg', { '0xa::coin::test_flow': ['0xa::coin::register'] }]]), + }); + const issues = validateMoveIngestOutput(graph, output); + expect(issues.some((i) => i.code === 'call-graph-unlinked')).toBe(false); + expect(issues.some((i) => i.code === 'missing-owned-caller')).toBe(true); + }); + + it('does not flag call-graph-unlinked for a package with no mapped functions', () => { + const graph = createKnowledgeGraph(); + const file = 'sources/coin.move'; + addFileNode(graph, file); + const output = makeOutput({ + ingestedFiles: new Set([file]), + moduleFileMap: new Map([['0xa::coin', file]]), + modulePackageMap: new Map([['0xa::coin', '/pkg']]), + // Structs-only package: call graph may list elided test-only functions. + callGraphByPackage: new Map([['/pkg', { '0xa::coin::test_helper': [] }]]), + }); + const issues = validateMoveIngestOutput(graph, output); + expect(issues.some((i) => i.code === 'call-graph-unlinked')).toBe(false); + }); + + it('does not flag call-graph-unlinked when callees resolve (elided test-only caller module)', () => { + const graph = createKnowledgeGraph(); + const file = 'sources/coin.move'; + addFileNode(graph, file); + const output = makeOutput({ + ingestedFiles: new Set([file]), + moduleFileMap: new Map([['0xa::coin', file]]), + modulePackageMap: new Map([['0xa::coin', '/pkg']]), + functionNodeMap: new Map([['0xa::coin::register', `Function:${file}:0xa::coin::register`]]), + // Every caller lives in a test-only MODULE elided from facts, so neither + // the function nor the module condition can clear the check - but the + // CALLEES still join facts names, which real drift would break too. + callGraphByPackage: new Map([['/pkg', { '0xa::coin_tests::flow': ['0xa::coin::register'] }]]), + }); + const issues = validateMoveIngestOutput(graph, output); + expect(issues.some((i) => i.code === 'call-graph-unlinked')).toBe(false); + }); + + it('warns when an external module shares an address with repo-local modules', () => { + const graph = createKnowledgeGraph(); + const file = 'sources/coin.move'; + addFileNode(graph, file); + graph.addNode({ + id: 'Module::0xa::vanished', + label: 'Module', + properties: { + name: 'vanished', + filePath: '', + qualifiedName: '0xa::vanished', + locationFidelity: 'external', + }, + }); + const output = makeOutput({ + ingestedFiles: new Set([file]), + moduleFileMap: new Map([['0xa::coin', file]]), + modulePackageMap: new Map([['0xa::coin', '/pkg']]), + callGraphByPackage: new Map([['/pkg', {}]]), + }); + const issue = validateMoveIngestOutput(graph, output).find( + (i) => i.code === 'external-module-address-overlap', + ); + expect(issue?.severity).toBe('warning'); + expect(issue?.details?.sample).toEqual(['0xa::vanished']); + }); + + it('does not warn for external modules at genuinely foreign addresses', () => { + const graph = createKnowledgeGraph(); + const file = 'sources/coin.move'; + addFileNode(graph, file); + graph.addNode({ + id: 'Module::0x1::object', + label: 'Module', + properties: { + name: 'object', + filePath: '', + qualifiedName: '0x1::object', + locationFidelity: 'external', + }, + }); + const output = makeOutput({ + ingestedFiles: new Set([file]), + moduleFileMap: new Map([['0xa::coin', file]]), + modulePackageMap: new Map([['0xa::coin', '/pkg']]), + callGraphByPackage: new Map([['/pkg', {}]]), + }); + const issues = validateMoveIngestOutput(graph, output); + expect(issues.some((i) => i.code === 'external-module-address-overlap')).toBe(false); + }); +}); + +describe('summarizeMoveConsistency', () => { + it('bounds persisted messages and details', () => { + const summary = summarizeMoveConsistency([ + { + code: 'empty-package-facts', + severity: 'error', + message: 'm'.repeat(10_000), + details: { diagnostics: 'd'.repeat(1_000_000) }, + }, + ]); + + expect(JSON.stringify(summary).length).toBeLessThan(5_000); + }); }); diff --git a/gitnexus/test/unit/move/cross-package.test.ts b/gitnexus/test/unit/move/cross-package.test.ts index c80efd9e4..98ef44a4e 100644 --- a/gitnexus/test/unit/move/cross-package.test.ts +++ b/gitnexus/test/unit/move/cross-package.test.ts @@ -39,11 +39,14 @@ const client: MoveFlowClient = { async callGraph() { return {}; }, + async functionUsage() { + return { called: [], used: [] }; + }, async packageStatus() { return { ok: true, diagnostics: '' }; }, async capabilities() { - return { hasFactsQuery: true, hasStatusTool: false }; + return { hasFactsQuery: true, hasFunctionUsageQuery: false, hasStatusTool: false }; }, async shutdown() {}, }; diff --git a/gitnexus/test/unit/move/entry-points.test.ts b/gitnexus/test/unit/move/entry-points.test.ts new file mode 100644 index 000000000..b4bb56ca8 --- /dev/null +++ b/gitnexus/test/unit/move/entry-points.test.ts @@ -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); + }); +}); diff --git a/gitnexus/test/unit/move/facts-mapper.test.ts b/gitnexus/test/unit/move/facts-mapper.test.ts index ad3459ff8..5fde3f432 100644 --- a/gitnexus/test/unit/move/facts-mapper.test.ts +++ b/gitnexus/test/unit/move/facts-mapper.test.ts @@ -1,36 +1,32 @@ import { describe, it, expect } from 'vitest'; +import { mapFactsToGraph } from '../../../src/core/move/facts-mapper.js'; import { buildLocalNameIndex, - mapFactsToGraph, - resolveFriendEdges, - resolveResourceEdges, - resolveTypeRefEdges, -} from '../../../src/core/move/facts-mapper.js'; + resolveRefs, + ExternalMoveSymbols, +} from '../../../src/core/move/move-linker.js'; +import type { DroppedRef } from '../../../src/core/move/refs.js'; import { type MoveFactsFunction, type MoveFactsMap, } from '../../../src/core/move/compiler-facts.js'; +import { moveFunctionFact } from '../../helpers/move-ingest-harness.js'; +import { createKnowledgeGraph } from '../../../src/core/graph/graph.js'; -/** A minimal function facts entry with per-test overrides. */ +/** A fuller-shaped function facts entry (file/span/arrays) with per-test overrides. */ function fnFixture(name: string, overrides: Partial = {}): MoveFactsFunction { - return { - name, + return moveFunctionFact(name, { file: '/pkg/sources/fixture.move', span: [1, 3], visibility: 'public', - isEntry: false, - isInline: false, - isNative: false, - isView: false, attributes: [], typeParams: [], params: [], - returnTypes: [], acquiresInferred: [], resourceAccess: { reads: [], writes: [] }, isLambdaLifted: false, ...overrides, - }; + }); } // Trimmed-but-faithful slice of a real `move_package_query { query: "facts" }` @@ -112,29 +108,29 @@ const facts: MoveFactsMap = { }, }; -function mapFactsToGraphWithResolvedEdges( - factsMap: MoveFactsMap, -): ReturnType { +function mapFactsToGraphWithResolvedEdges(factsMap: MoveFactsMap) { const mapped = mapFactsToGraph(factsMap, '/pkg'); - const edges = [...mapped.edges]; + const graph = createKnowledgeGraph(); + for (const node of mapped.nodes) graph.addNode(node); + for (const rel of mapped.edges) graph.addRelationship(rel); const localNameIndex = buildLocalNameIndex(mapped.structNodeMap); - const addResolvedUsedType = (sourceId: string, targetId: string): void => { - const fnNode = mapped.nodes.find((n) => n.id === sourceId); - const typeNode = mapped.nodes.find((n) => n.id === targetId); - const qualifiedName = typeNode?.properties.qualifiedName; - if (!fnNode || typeof qualifiedName !== 'string') return; - const current = Array.isArray(fnNode.properties.usedTypes) ? fnNode.properties.usedTypes : []; - if (!current.includes(qualifiedName)) fnNode.properties.usedTypes = [...current, qualifiedName]; - }; - resolveResourceEdges(mapped.pendingResource, mapped.structNodeMap, localNameIndex, (rel) => - edges.push(rel), + const external = new ExternalMoveSymbols(graph, mapped.moduleFileMap); + const drops: DroppedRef[] = []; + resolveRefs( + graph, + mapped.pendingRefs, + { + structNodeMap: mapped.structNodeMap, + structIdsByLocalName: localNameIndex, + moduleFileMap: mapped.moduleFileMap, + functionNodeMap: mapped.functionNodeMap, + }, + external, + drops, ); - resolveFriendEdges(mapped.pendingFriends, mapped.moduleFileMap, (rel) => edges.push(rel)); - resolveTypeRefEdges(mapped.pendingTypeRef, mapped.structNodeMap, localNameIndex, (rel) => { - edges.push(rel); - addResolvedUsedType(rel.sourceId, rel.targetId); - }); - return { ...mapped, edges }; + const edges = [...graph.iterRelationships()]; + const nodes = [...graph.iterNodes()]; + return { ...mapped, nodes, edges }; } describe('mapFactsToGraph', () => { @@ -193,10 +189,36 @@ describe('mapFactsToGraph', () => { const { nodes } = mapFactsToGraph(facts, '/pkg'); const fn = nodes.find((n) => n.properties.name === 'balance_of'); expect(fn?.properties.isView).toBe(true); + expect(fn?.properties.isExported).toBe(true); expect(fn?.properties.locationFidelity).toBe('precise'); expect(fn?.properties.startLine).toBe(33); }); + it('maps public and compiler-marked entry/view functions onto isExported', () => { + const visibilityFacts: MoveFactsMap = { + '0xa::roots': { + file: '/pkg/sources/roots.move', + functions: [ + fnFixture('public_api', { visibility: 'public' }), + fnFixture('package_api', { visibility: 'package' }), + fnFixture('private_entry', { visibility: 'internal', isEntry: true }), + fnFixture('init_module', { visibility: 'internal' }), + fnFixture('helper', { visibility: 'internal' }), + ], + }, + }; + const { nodes } = mapFactsToGraph(visibilityFacts, '/pkg'); + const exported = (name: string) => + nodes.find((node) => node.label === 'Function' && node.properties.name === name)?.properties + .isExported; + + expect(exported('public_api')).toBe(true); + expect(exported('package_api')).toBe(false); + expect(exported('private_entry')).toBe(true); + expect(exported('init_module')).toBe(false); + expect(exported('helper')).toBe(false); + }); + it('tolerates functions/modules with missing optional arrays (real move-flow shape)', () => { // move-flow omits optional array fields (acquiresInferred, resourceAccess, // params, typeParams, attributes, friends, types, constants) for some symbols. @@ -348,6 +370,75 @@ describe('mapFactsToGraph', () => { expect(v?.properties.locationFidelity).toBe('module'); }); + it('materializes Move 2 enum variant fields and their local type edges', () => { + const enumFacts: MoveFactsMap = { + '0xa::orders': { + file: '/pkg/sources/orders.move', + functions: [], + structs: [ + { + kind: 'struct', + name: 'Payload', + file: '/pkg/sources/orders.move', + span: [1, 2], + abilities: ['copy'], + typeParams: [], + fields: [], + attributes: [], + }, + { + kind: 'enum', + name: 'OrderState', + file: '/pkg/sources/orders.move', + span: [4, 10], + abilities: ['drop'], + typeParams: [{ name: 'T', abilities: [], isPhantom: false }], + variants: [ + { + name: 'Open', + kind: 'named', + fields: [ + { name: 'payload', type: 'Payload', positional: false }, + { name: 'callback', type: '|Payload|T has copy + drop', positional: false }, + ], + attributes: [], + }, + ], + attributes: [], + }, + ], + }, + }; + const { nodes, edges, pendingRefs } = mapFactsToGraphWithResolvedEdges(enumFacts); + const variant = nodes.find( + (node) => node.label === 'EnumVariant' && node.properties.name === 'Open', + ); + const fieldIds = new Set( + edges + .filter((edge) => edge.type === 'HAS_PROPERTY' && edge.sourceId === variant?.id) + .map((edge) => edge.targetId), + ); + const fields = nodes.filter((node) => node.label === 'Property' && fieldIds.has(node.id)); + + expect(fields.map((field) => field.properties.name)).toEqual(['payload', 'callback']); + expect( + edges.filter((edge) => edge.type === 'HAS_PROPERTY' && edge.sourceId === variant?.id), + ).toHaveLength(2); + expect( + edges.some( + (edge) => + edge.type === 'USES_TYPE' && + edge.reason === 'move-enum-variant-field-type' && + edge.sourceId === fields.find((field) => field.properties.name === 'callback')?.id && + edge.targetId.includes('Payload'), + ), + ).toBe(true); + const typeRefs = pendingRefs.filter((r) => r.kind === 'type'); + expect(typeRefs.some((pending) => pending.target === 'copy')).toBe(false); + expect(typeRefs.some((pending) => pending.target === 'drop')).toBe(false); + expect(typeRefs.some((pending) => pending.target === 'T')).toBe(false); + }); + it('does not write moduleAddress on Function nodes (only Module/Struct/Enum carry it)', () => { const { nodes } = mapFactsToGraph(facts, '/pkg'); const fn = nodes.find((n) => n.label === 'Function' && n.properties.name === 'register'); @@ -535,8 +626,10 @@ describe('mapFactsToGraph', () => { constants: [], }, }; - const { pendingTypeRef } = mapFactsToGraph(rtFacts, '/pkg'); - const returnRefs = pendingTypeRef.filter((p) => p.reason === 'move-fn-return-type'); + const { pendingRefs } = mapFactsToGraph(rtFacts, '/pkg'); + const returnRefs = pendingRefs.filter( + (p) => p.kind === 'type' && p.reason === 'move-fn-return-type', + ); expect(returnRefs.map((p) => p.target)).toEqual(['0xbeef::price::PriceInfo']); }); @@ -588,18 +681,33 @@ describe('mapFactsToGraph', () => { }, }; const mapped = mapFactsToGraph(qualFacts, '/pkg'); + const graph = createKnowledgeGraph(); + for (const node of mapped.nodes) graph.addNode(node); + for (const rel of mapped.edges) graph.addRelationship(rel); const localNameIndex = buildLocalNameIndex(mapped.structNodeMap); - const resolvedEdges: unknown[] = []; - const unresolved: string[] = []; - resolveResourceEdges( - mapped.pendingResource, - mapped.structNodeMap, - localNameIndex, - (rel) => resolvedEdges.push(rel), - (pending) => unresolved.push(pending.target), + const external = new ExternalMoveSymbols(graph, mapped.moduleFileMap); + const drops: DroppedRef[] = []; + const resourceRefs = mapped.pendingRefs.filter((r) => r.kind === 'resource'); + resolveRefs( + graph, + resourceRefs, + { + structNodeMap: mapped.structNodeMap, + structIdsByLocalName: localNameIndex, + moduleFileMap: mapped.moduleFileMap, + functionNodeMap: mapped.functionNodeMap, + }, + external, + drops, + ); + const localCoinStoreId = mapped.structNodeMap.get('0xa::coin::CoinStore'); + const resolvedResourceEdges = [...graph.iterRelationships()].filter( + (e) => e.type === 'READS_RESOURCE' || e.type === 'WRITES_RESOURCE' || e.type === 'ACQUIRES', + ); + expect(resolvedResourceEdges.every((e) => e.targetId !== localCoinStoreId)).toBe(true); + expect(resolvedResourceEdges.every((e) => e.targetId.includes('0x1::coin::CoinStore'))).toBe( + true, ); - expect(resolvedEdges).toEqual([]); - expect(unresolved).toEqual(['0x1::coin::CoinStore', '0x1::coin::CoinStore']); }); it('projects full attribute payloads (args/values) as attributesJson alongside the name list', () => { @@ -704,10 +812,31 @@ describe('mapFactsToGraph', () => { constants: [], }, }; - const { pendingLambdaHosts } = mapFactsToGraph(lambdaFacts, '/pkg'); - expect(pendingLambdaHosts.map((p) => p.hostQualified)).toEqual([ - '0xa::vault::lifted', - '0xa::vault::outer', - ]); + const { pendingRefs } = mapFactsToGraph(lambdaFacts, '/pkg'); + const lambdaRefs = pendingRefs.filter((r) => r.kind === 'lambda-host'); + expect(lambdaRefs.map((p) => p.target)).toEqual(['0xa::vault::lifted', '0xa::vault::outer']); + }); + + it('attributes lifted lambdas to their declaring module source', () => { + const lambdaFacts: MoveFactsMap = { + '0xa::vault': { + file: '/pkg/sources/vault.move', + functions: [ + fnFixture('__lambda__1__run', { + file: '/external/aptos-framework/sources/big_ordered_map.move', + isLambdaLifted: true, + definedIn: 'run', + }), + ], + }, + }; + const { nodes } = mapFactsToGraph(lambdaFacts, '/pkg'); + const lambda = nodes.find( + (node) => node.label === 'Function' && node.properties.name === '__lambda__1__run', + ); + expect(lambda?.properties.filePath).toBe('/pkg/sources/vault.move'); + expect(lambda?.properties.startLine).toBeUndefined(); + expect(lambda?.properties.endLine).toBeUndefined(); + expect(lambda?.properties.locationFidelity).toBe('module'); }); }); diff --git a/gitnexus/test/unit/move/graph-quality.test.ts b/gitnexus/test/unit/move/graph-quality.test.ts new file mode 100644 index 000000000..9b04c5068 --- /dev/null +++ b/gitnexus/test/unit/move/graph-quality.test.ts @@ -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([]); + }); +}); diff --git a/gitnexus/test/unit/move/install-recovery.test.ts b/gitnexus/test/unit/move/install-recovery.test.ts new file mode 100644 index 000000000..6a2538cca --- /dev/null +++ b/gitnexus/test/unit/move/install-recovery.test.ts @@ -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 => {}, +})); + +vi.mock('../../../src/core/move/install-lock.js', async (importOriginal) => { + const actual = await importOriginal(); + 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'); + }); +}); diff --git a/gitnexus/test/unit/move/mcp-client.test.ts b/gitnexus/test/unit/move/mcp-client.test.ts index e3d4a35e0..d30d29edb 100644 --- a/gitnexus/test/unit/move/mcp-client.test.ts +++ b/gitnexus/test/unit/move/mcp-client.test.ts @@ -11,6 +11,7 @@ vi.mock('node:child_process', () => ({ import { MoveFlowMcpClient, MoveFlowToolCallError, + MoveFlowTransportError, tryResolveMoveFlowClient, detectMoveFlowCapabilities, } from '../../../src/core/move/mcp-client.js'; @@ -213,6 +214,43 @@ describe('MoveFlowMcpClient', () => { await client.shutdown(); }); + it('retries unrelated in-flight requests when another request times out', async () => { + vi.useFakeTimers(); + process.env.GITNEXUS_MOVE_FLOW_TIMEOUT_MS = '25'; + const timedOutProc = createMockProc(); + const retryProc = createMockProc(); + mockSpawn.mockReturnValueOnce(timedOutProc as any).mockReturnValueOnce(retryProc as any); + + timedOutProc.stdin.on('data', (chunk: Buffer) => { + for (const line of chunk.toString().split('\n')) { + if (!line.trim()) continue; + const msg = JSON.parse(line); + if (msg.method === 'initialize') { + timedOutProc.stdout.write( + JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} }) + '\n', + ); + } + } + }); + serveToolsCall(retryProc, { + result: { content: [{ type: 'text', text: '{"retried":true}' }], isError: false }, + }); + + const client = new MoveFlowMcpClient('move-flow'); + const first = client.facts('/first'); + const firstFailure = expect(first).rejects.toThrow( + "move-flow 'move_package_query' timed out after 25ms", + ); + await vi.advanceTimersByTimeAsync(5); + const secondSuccess = expect(client.facts('/second')).resolves.toEqual({ retried: true }); + + await vi.advanceTimersByTimeAsync(20); + await firstFailure; + await secondSuccess; + expect(mockSpawn).toHaveBeenCalledTimes(2); + await client.shutdown(); + }); + it('ignores a late response after a tool timeout and starts a fresh child', async () => { vi.useFakeTimers(); process.env.GITNEXUS_MOVE_FLOW_TIMEOUT_MS = '25'; @@ -371,6 +409,29 @@ describe('MoveFlowMcpClient', () => { await client.shutdown(); }); + it('rejects malformed function-usage payloads', async () => { + const proc = createMockProc(); + mockSpawn.mockReturnValue(proc as any); + const client = new MoveFlowMcpClient('move-flow'); + serveToolsCall(proc, { + result: { + content: [ + { + type: 'text', + text: JSON.stringify({ + called: 'not-an-array', + used: [], + }), + }, + ], + isError: false, + }, + }); + + await expect(client.functionUsage('/pkg', 'm::f')).rejects.toThrow(MoveFlowToolCallError); + await client.shutdown(); + }); + it('packageStatus reports ok on an isError:false status result', async () => { // move-flow returns "no errors or warnings" for a compiling package. const proc = createMockProc(); @@ -403,12 +464,72 @@ describe('MoveFlowMcpClient', () => { }); await client.shutdown(); }); + + it('retries a request once after a mid-flight transport crash', async () => { + const crashProc = createMockProc(); + const freshProc = createMockProc(); + mockSpawn.mockReturnValueOnce(crashProc as any).mockReturnValueOnce(freshProc as any); + + crashProc.stdin.on('data', (chunk: Buffer) => { + for (const line of chunk.toString().split('\n')) { + if (!line.trim()) continue; + const msg = JSON.parse(line); + if (msg.method === 'initialize') { + crashProc.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} }) + '\n'); + } else if (msg.method === 'tools/call') { + // Die mid-request instead of answering — a transport fault. + crashProc.emit('exit', 137); + } + } + }); + serveToolsCall(freshProc, { + result: { content: [{ type: 'text', text: '{"fresh":true}' }], isError: false }, + }); + + const client = new MoveFlowMcpClient('move-flow'); + await expect(client.facts('/pkg')).resolves.toEqual({ fresh: true }); + expect(mockSpawn).toHaveBeenCalledTimes(2); + await client.shutdown(); + }); + + it('functionUsage does not retry a transport error', async () => { + const crashProc = createMockProc(); + mockSpawn.mockReturnValueOnce(crashProc as any); + + crashProc.stdin.on('data', (chunk: Buffer) => { + for (const line of chunk.toString().split('\n')) { + if (!line.trim()) continue; + const msg = JSON.parse(line); + if (msg.method === 'initialize') { + crashProc.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} }) + '\n'); + } else if (msg.method === 'tools/call') { + // Die mid-request — a transport fault, no retry should happen. + crashProc.emit('exit', 137); + } + } + }); + + const client = new MoveFlowMcpClient('move-flow'); + await expect(client.functionUsage('/pkg', 'm::f')).rejects.toThrow(MoveFlowTransportError); + // Exactly ONE spawn — no respawn/retry for functionUsage transport errors. + expect(mockSpawn).toHaveBeenCalledTimes(1); + await client.shutdown(); + }); + + it('rejects new requests after shutdown instead of respawning a child', async () => { + const client = new MoveFlowMcpClient('move-flow'); + await client.shutdown(); + + await expect(client.facts('/pkg')).rejects.toThrow('move-flow client is shut down'); + expect(mockSpawn).not.toHaveBeenCalled(); + }); }); describe('detectMoveFlowCapabilities', () => { it('reports facts support from a standalone move_package_facts tool name', () => { const caps = detectMoveFlowCapabilities(['move_package_query', 'move_package_facts']); expect(caps.hasFactsQuery).toBe(true); + expect(caps.hasFunctionUsageQuery).toBe(false); }); it('detects the facts query from the move_package_query inputSchema enum', () => { @@ -420,23 +541,32 @@ describe('detectMoveFlowCapabilities', () => { inputSchema: { $defs: { QueryType: { - oneOf: [{ const: 'module_summary' }, { const: 'call_graph' }, { const: 'facts' }], + oneOf: [ + { const: 'module_summary' }, + { const: 'call_graph' }, + { const: 'function_usage' }, + { const: 'facts' }, + ], }, }, }, }, ]); expect(caps.hasFactsQuery).toBe(true); + expect(caps.hasFunctionUsageQuery).toBe(true); }); it('also detects facts from a flat enum schema', () => { const caps = detectMoveFlowCapabilities([ { name: 'move_package_query', - inputSchema: { properties: { query: { enum: ['module_summary', 'facts'] } } }, + inputSchema: { + properties: { query: { enum: ['module_summary', 'function_usage', 'facts'] } }, + }, }, ]); expect(caps.hasFactsQuery).toBe(true); + expect(caps.hasFunctionUsageQuery).toBe(true); }); it('reports facts absent when the schema omits it', () => { @@ -448,11 +578,13 @@ describe('detectMoveFlowCapabilities', () => { 'move_package_manifest', ]); expect(caps.hasFactsQuery).toBe(false); + expect(caps.hasFunctionUsageQuery).toBe(false); }); it('reports facts absent when move_package_query is missing entirely', () => { const caps = detectMoveFlowCapabilities(['move_package_status']); expect(caps.hasFactsQuery).toBe(false); + expect(caps.hasFunctionUsageQuery).toBe(false); expect(caps.hasStatusTool).toBe(true); }); diff --git a/gitnexus/test/unit/move/move-ingest-empty-facts.test.ts b/gitnexus/test/unit/move/move-ingest-empty-facts.test.ts index c705c87d4..0af751a02 100644 --- a/gitnexus/test/unit/move/move-ingest-empty-facts.test.ts +++ b/gitnexus/test/unit/move/move-ingest-empty-facts.test.ts @@ -10,19 +10,12 @@ import { describe, it, expect } from 'vitest'; import path from 'node:path'; import type { MoveFlowClient } from '../../../src/core/move/mcp-client.js'; -import { runMoveIngestPhase } from '../../helpers/move-ingest-harness.js'; +import { makeMoveFlowClientStub, runMoveIngestPhase } from '../../helpers/move-ingest-harness.js'; const REPO_ROOT = path.resolve('/repo'); function makeClient(overrides: Partial = {}): MoveFlowClient { - return { - facts: async () => ({}), - callGraph: async () => ({}), - packageStatus: async () => ({ ok: true, diagnostics: 'no errors or warnings' }), - capabilities: async () => ({ hasFactsQuery: true, hasStatusTool: true }), - shutdown: async () => {}, - ...overrides, - }; + return makeMoveFlowClientStub(overrides); } /** Run the phase against a fake repo with one package holding one .move file. */ @@ -65,7 +58,11 @@ describe('moveIngest empty-facts discrimination', () => { it('falls back to the error-level issue when move_package_status is unavailable', async () => { const output = await runPhase( makeClient({ - capabilities: async () => ({ hasFactsQuery: true, hasStatusTool: false }), + capabilities: async () => ({ + hasFactsQuery: true, + hasFunctionUsageQuery: false, + hasStatusTool: false, + }), }), ); diff --git a/gitnexus/test/unit/move/move-ingest-ownership.test.ts b/gitnexus/test/unit/move/move-ingest-ownership.test.ts index 23e5a832e..fbac9f8be 100644 --- a/gitnexus/test/unit/move/move-ingest-ownership.test.ts +++ b/gitnexus/test/unit/move/move-ingest-ownership.test.ts @@ -6,21 +6,18 @@ */ import { describe, it, expect } from 'vitest'; import path from 'node:path'; -import type { MoveFlowClient } from '../../../src/core/move/mcp-client.js'; -import { runMoveIngestPhase } from '../../helpers/move-ingest-harness.js'; +import { makeMoveFlowClientStub, runMoveIngestPhase } from '../../helpers/move-ingest-harness.js'; const REPO_ROOT = path.resolve('/repo'); -/** Client returning empty facts for every package, without a status tool. */ -function emptyFactsClient(): MoveFlowClient { - return { - facts: async () => ({}), - callGraph: async () => ({}), - packageStatus: async () => ({ ok: true, diagnostics: '' }), - capabilities: async () => ({ hasFactsQuery: true, hasStatusTool: false }), - shutdown: async () => {}, - }; -} +const emptyFactsClient = () => + makeMoveFlowClientStub({ + capabilities: async () => ({ + hasFactsQuery: true, + hasFunctionUsageQuery: false, + hasStatusTool: false, + }), + }); describe('moveIngest package-ownership attribution', () => { it('attributes each file to its own package across sibling packages pkg_a / pkg_ab', async () => { diff --git a/gitnexus/test/unit/move/move-ingest-query-errors.test.ts b/gitnexus/test/unit/move/move-ingest-query-errors.test.ts new file mode 100644 index 000000000..7f363a809 --- /dev/null +++ b/gitnexus/test/unit/move/move-ingest-query-errors.test.ts @@ -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); + }); +}); diff --git a/gitnexus/test/unit/move/provision.test.ts b/gitnexus/test/unit/move/provision.test.ts index f63fab627..7475b47d8 100644 --- a/gitnexus/test/unit/move/provision.test.ts +++ b/gitnexus/test/unit/move/provision.test.ts @@ -1,4 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import type { MoveFlowMcpClient, ResolvedMoveFlowClient, @@ -12,6 +15,8 @@ import type { VerifiedMoveFlowBinary } from '../../../src/core/move/install.js'; const originalMoveFlow = process.env.MOVE_FLOW; afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); if (originalMoveFlow === undefined) delete process.env.MOVE_FLOW; else process.env.MOVE_FLOW = originalMoveFlow; }); @@ -42,6 +47,64 @@ describe('ensureMoveFlowRuntime', () => { expect(deps.install).not.toHaveBeenCalled(); }); + it('logs when the resolved binary cannot be read for fingerprinting', async () => { + // A resolver that answers for a locator whose file does not exist: the + // digest falls back to a random 'unverifiable' value, which churns the + // fingerprint (full re-index every run) - that must not stay silent. + process.env.MOVE_FLOW = '/missing/move-flow'; + const deps = dependencies({ resolveClient: vi.fn(() => resolved) }); + const onLog = vi.fn(); + + const runtime = await ensureMoveFlowRuntime({ onLog }, deps); + + expect(runtime?.client).toBe(client); + expect(onLog).toHaveBeenCalledWith( + expect.stringContaining('could not be read for fingerprinting'), + ); + }); + + it.skipIf(process.platform === 'win32')( + 'fingerprints local compiler bytes, not only path and version', + async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'move-flow-identity-')); + const binary = path.join(directory, 'move-flow'); + try { + process.env.MOVE_FLOW = binary; + await writeFile(binary, '#!/bin/sh\necho first\n'); + await chmod(binary, 0o755); + const deps = dependencies({ resolveClient: vi.fn(() => resolved) }); + const first = await ensureMoveFlowRuntime({}, deps); + + await writeFile(binary, '#!/bin/sh\necho second\n'); + await chmod(binary, 0o755); + const second = await ensureMoveFlowRuntime({}, deps); + expect(second?.identity.fingerprint).not.toBe(first?.identity.fingerprint); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, + ); + + it('fingerprints a Windows PATH locator that already includes its executable extension', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'move-flow-windows-identity-')); + const binary = path.join(directory, 'move-flow.exe'); + try { + await writeFile(binary, 'stable move-flow bytes'); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + vi.stubEnv('MOVE_FLOW', 'move-flow.exe'); + vi.stubEnv('PATH', directory); + vi.stubEnv('PATHEXT', '.exe'); + const deps = dependencies({ resolveClient: vi.fn(() => resolved) }); + + const first = await ensureMoveFlowRuntime({}, deps); + const second = await ensureMoveFlowRuntime({}, deps); + + expect(second?.identity.fingerprint).toBe(first?.identity.fingerprint); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + it('keeps an invalid explicit MOVE_FLOW authoritative', async () => { process.env.MOVE_FLOW = '/missing/move-flow'; const deps = dependencies(); diff --git a/gitnexus/test/unit/move/ref-resolver.test.ts b/gitnexus/test/unit/move/ref-resolver.test.ts new file mode 100644 index 000000000..1d8753cb9 --- /dev/null +++ b/gitnexus/test/unit/move/ref-resolver.test.ts @@ -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, id: string) { + graph.addNode({ + id, + label: 'Function', + properties: { + name: id, + filePath: 'a.move', + language: 'move', + qualifiedName: id, + usedTypes: [], + }, + }); +} +function structNode(graph: ReturnType, 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:::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); + }); +}); diff --git a/gitnexus/test/unit/move/type-parser.test.ts b/gitnexus/test/unit/move/type-parser.test.ts index 0b282af95..5ee0d22cd 100644 --- a/gitnexus/test/unit/move/type-parser.test.ts +++ b/gitnexus/test/unit/move/type-parser.test.ts @@ -6,6 +6,8 @@ describe('extractTypeNames', () => { expect(extractTypeNames('u64')).toEqual([]); expect(extractTypeNames('address')).toEqual([]); expect(extractTypeNames('bool')).toEqual([]); + expect(extractTypeNames('i64')).toEqual([]); + expect(extractTypeNames('i256')).toEqual([]); expect(extractTypeNames('&signer')).toEqual([]); }); @@ -31,4 +33,24 @@ describe('extractTypeNames', () => { expect(extractTypeNames('vector')).toEqual([]); expect(extractTypeNames('Option')).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'), + ).toEqual(['0x1::table::Table']); + expect(extractTypeNames('vector<|Coin|Vault has store + key>')).toEqual(['Coin', 'Vault']); + }); }); diff --git a/gitnexus/test/unit/node-table-layout.test.ts b/gitnexus/test/unit/node-table-layout.test.ts index 1e5294d5e..4bf2dda56 100644 --- a/gitnexus/test/unit/node-table-layout.test.ts +++ b/gitnexus/test/unit/node-table-layout.test.ts @@ -7,6 +7,7 @@ import { FUNCTION_SCHEMA, MODULE_SCHEMA, STRUCT_SCHEMA, + TYPE_SCHEMA, } from '../../src/core/lbug/schema.js'; import { getNodeTableCsvHeader, @@ -20,6 +21,7 @@ const schemas: Record = { Function: FUNCTION_SCHEMA, Struct: STRUCT_SCHEMA, Enum: ENUM_SCHEMA, + Type: TYPE_SCHEMA, EnumVariant: ENUM_VARIANT_SCHEMA, Const: CONST_SCHEMA, Module: MODULE_SCHEMA, diff --git a/gitnexus/test/unit/process-processor.test.ts b/gitnexus/test/unit/process-processor.test.ts index 09600c6de..33eee8554 100644 --- a/gitnexus/test/unit/process-processor.test.ts +++ b/gitnexus/test/unit/process-processor.test.ts @@ -524,6 +524,73 @@ describe('processProcesses', () => { expect(result.stats.totalProcesses).toBeLessThanOrEqual(3); }); + it('prioritizes explicit graph entry points ahead of the heuristic top-200 budget', async () => { + const graph = createKnowledgeGraph(); + const addFunction = (id: string, name: string) => + graph.addNode({ + id, + label: 'Function', + properties: { name, filePath: `src/${name}.move`, isExported: true }, + }); + + addFunction('func:explicit', 'entry_api'); + addFunction('func:middle', 'middle'); + addFunction('func:end', 'end'); + graph.addNode({ + id: 'module:root', + label: 'Module', + properties: { name: 'root', filePath: 'src/root.move' }, + }); + graph.addRelationship({ + id: 'entry:explicit', + sourceId: 'func:explicit', + targetId: 'module:root', + type: 'ENTRY_POINT_OF', + confidence: 1, + reason: 'compiler-entry-point', + }); + graph.addRelationship({ + id: 'call:explicit-middle', + sourceId: 'func:explicit', + targetId: 'func:middle', + type: 'CALLS', + confidence: 1, + reason: 'compiler-call', + }); + graph.addRelationship({ + id: 'call:middle-end', + sourceId: 'func:middle', + targetId: 'func:end', + type: 'CALLS', + confidence: 1, + reason: 'compiler-call', + }); + + // More than 200 caller-free heuristic candidates make the explicit root's + // ordinary score too low to survive the legacy global slice. + for (let i = 0; i < 205; i++) { + const id = `func:noise-${i}`; + addFunction(id, `noise_${i}`); + graph.addRelationship({ + id: `call:noise-${i}`, + sourceId: id, + targetId: 'func:explicit', + type: 'CALLS', + confidence: 1, + reason: 'compiler-call', + }); + } + + const result = await processProcesses(graph, [], undefined, { + maxProcesses: 1, + maxTraceDepth: 4, + }); + + expect(result.processes).toHaveLength(1); + expect(result.processes[0].entryPointId).toBe('func:explicit'); + expect(result.processes[0].trace).toEqual(['func:explicit', 'func:middle', 'func:end']); + }); + // Regression for #2198: the processesPhase dynamic sizing used to cap at // Math.min(300, symbolCount/10). On large repos (>3000 symbols) that silently // truncated the process index. The cap was removed by extracting diff --git a/gitnexus/test/unit/repo-manager-reconcile.test.ts b/gitnexus/test/unit/repo-manager-reconcile.test.ts index ca07b663f..f5153d60d 100644 --- a/gitnexus/test/unit/repo-manager-reconcile.test.ts +++ b/gitnexus/test/unit/repo-manager-reconcile.test.ts @@ -266,6 +266,10 @@ describe('runFullAnalysis metadata reconciliation (mocked pipeline)', () => { repoPath, totalFileCount: 1, graph: { forEachNode: () => undefined }, + standaloneIngest: { + ingestedFiles: new Set(), + consistencyIssues: [], + }, })), })); // Avoid touching the global registry / repo .gitnexusignore from a unit test. diff --git a/gitnexus/test/unit/run-analyze-fts-repair.test.ts b/gitnexus/test/unit/run-analyze-fts-repair.test.ts index eb524305f..2a4d9c86a 100644 --- a/gitnexus/test/unit/run-analyze-fts-repair.test.ts +++ b/gitnexus/test/unit/run-analyze-fts-repair.test.ts @@ -6,6 +6,10 @@ import { createTempDir } from '../helpers/test-db.js'; const SIMULATED_MISSING_FTS_INDEX_NAME = 'File.file_fts'; const PLACEHOLDER_GRAPH_STORE_CONTENT = 'fixture'; +const emptyMoveIngestOutput = () => ({ + ingestedFiles: new Set(), + consistencyIssues: [], +}); const createPlaceholderGraphStore = async (lbugPath: string): Promise => { // Repair mode gates on existence before `initLbug` takes over open/validate. @@ -84,6 +88,7 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => { const runPipelineFromRepo = vi.fn(async (repoPath: string) => ({ repoPath, graph: { forEachNode: () => undefined }, + standaloneIngest: emptyMoveIngestOutput(), })); vi.doMock('../../src/core/ingestion/pipeline.js', () => ({ runPipelineFromRepo, @@ -113,6 +118,7 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => { const runPipelineFromRepo = vi.fn(async (repoPath: string) => ({ repoPath, graph: { forEachNode: () => undefined }, + standaloneIngest: emptyMoveIngestOutput(), })); vi.doMock('../../src/core/ingestion/pipeline.js', () => ({ runPipelineFromRepo, @@ -499,8 +505,15 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => { repoPath, // Full-analyze path only needs `forEachNode` before the FTS phase. graph: { forEachNode: () => undefined }, + standaloneIngest: emptyMoveIngestOutput(), })), })); + // Avoid touching the global registry / repo .gitnexusignore from a unit test. + vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({ + ...(await importActual()), + registerRepo: vi.fn(async () => 'verify-fail-repo'), + ensureGitNexusIgnored: vi.fn(async () => undefined), + })); const tmpRepo = await createTempDir('gitnexus-run-analyze-full-verify-fail-'); try { @@ -564,6 +577,7 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => { repoPath, totalFileCount: 1, graph: { forEachNode: () => undefined }, + standaloneIngest: emptyMoveIngestOutput(), })), })); // Avoid touching the global registry / repo .gitnexusignore from a unit test. @@ -635,6 +649,7 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => { repoPath, totalFileCount: 1, graph: { forEachNode: () => undefined }, + standaloneIngest: emptyMoveIngestOutput(), })), })); vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({ @@ -769,6 +784,7 @@ describe('runFullAnalysis wipe-and-restore vector-index stamp (tri-review 466951 runPipelineFromRepo: vi.fn(async (repoPath: string) => ({ repoPath, totalFileCount: 1, + standaloneIngest: emptyMoveIngestOutput(), graph: { forEachNode: (fn: (node: typeof stubNode) => void) => fn(stubNode), getNode: (id: string) => (id === RESTORED_NODE_ID ? stubNode : undefined), @@ -871,6 +887,7 @@ describe('runFullAnalysis dirty-recovery parking failure fails fast (this shippi repoPath, totalFileCount: 1, graph: { forEachNode: () => undefined }, + standaloneIngest: emptyMoveIngestOutput(), })); // Wholesale factory EXCEPT LbugWipeError: run-analyze throws the class it // imports from this module, and the test asserts on that very type — so diff --git a/gitnexus/test/unit/schema.test.ts b/gitnexus/test/unit/schema.test.ts index 0d503c0b7..3ad6ce837 100644 --- a/gitnexus/test/unit/schema.test.ts +++ b/gitnexus/test/unit/schema.test.ts @@ -14,6 +14,7 @@ import { INTERFACE_SCHEMA, METHOD_SCHEMA, PROPERTY_SCHEMA, + TYPE_SCHEMA, CODE_ELEMENT_SCHEMA, COMMUNITY_SCHEMA, PROCESS_SCHEMA, @@ -54,6 +55,7 @@ describe('LadybugDB Schema', () => { 'Trait', 'Impl', 'TypeAlias', + 'Type', 'Const', 'Static', 'Variable', @@ -75,8 +77,8 @@ describe('LadybugDB Schema', () => { }); it('has expected total count', () => { - // 9 core + 20 multi-language/Move + Route + Tool + BasicBlock = 33 - expect(NODE_TABLES).toHaveLength(33); + // 10 core (including Section) + 21 multi-language/Move + Route + Tool + BasicBlock = 34 + expect(NODE_TABLES).toHaveLength(34); }); }); @@ -151,6 +153,13 @@ describe('LadybugDB Schema', () => { expect(PROPERTY_SCHEMA).toContain('declaredType STRING'); }); + it('Type schema preserves Move dependency identity and location fidelity', () => { + expect(SCHEMA_QUERIES).toContain(TYPE_SCHEMA); + expect(TYPE_SCHEMA).toContain('qualifiedName STRING'); + expect(TYPE_SCHEMA).toContain('moduleQualifiedName STRING'); + expect(TYPE_SCHEMA).toContain('locationFidelity STRING'); + }); + it('BasicBlock schema is wired into SCHEMA_QUERIES (issue #2080, F1 guard)', () => { // Defining BASICBLOCK_SCHEMA is not enough — it must be appended to // NODE_SCHEMA_QUERIES (→ SCHEMA_QUERIES) or initLbug never creates the @@ -205,6 +214,16 @@ describe('LadybugDB Schema', () => { expect(RELATION_SCHEMA).toContain('FROM Method TO Process'); }); + it('persists Move enum fields and signature/field type targets', () => { + expect(RELATION_SCHEMA).toContain('FROM `EnumVariant` TO `Property`'); + expect(RELATION_SCHEMA).toContain('FROM Function TO `Type`'); + expect(RELATION_SCHEMA).toContain('FROM `Module` TO `Type`'); + expect(RELATION_SCHEMA).toContain('FROM `Struct` TO `Type`'); + expect(RELATION_SCHEMA).toContain('FROM `Property` TO `Struct`'); + expect(RELATION_SCHEMA).toContain('FROM `Property` TO `Enum`'); + expect(RELATION_SCHEMA).toContain('FROM `Property` TO `Type`'); + }); + it('connects BasicBlock to BasicBlock (taint/PDG substrate edges, #2080)', () => { expect(RELATION_SCHEMA).toContain('FROM BasicBlock TO BasicBlock'); }); @@ -253,8 +272,8 @@ describe('LadybugDB Schema', () => { describe('schema query ordering', () => { it('NODE_SCHEMA_QUERIES has correct count', () => { - // 31 + EnumVariant + BasicBlock = 33 - expect(NODE_SCHEMA_QUERIES).toHaveLength(33); + // 31 + EnumVariant + BasicBlock + Type = 34 + expect(NODE_SCHEMA_QUERIES).toHaveLength(34); }); it('REL_SCHEMA_QUERIES has one relation table', () => { @@ -262,8 +281,8 @@ describe('LadybugDB Schema', () => { }); it('SCHEMA_QUERIES includes all node + rel + embedding schemas', () => { - // 33 node + 1 rel + 1 embedding = 35 - expect(SCHEMA_QUERIES).toHaveLength(35); + // 34 node + 1 rel + 1 embedding = 36 + expect(SCHEMA_QUERIES).toHaveLength(36); }); it('node schemas come before relation schemas in SCHEMA_QUERIES', () => { diff --git a/gitnexus/test/unit/scope-resolution/callable-value-flow-worklist.test.ts b/gitnexus/test/unit/scope-resolution/callable-value-flow-worklist.test.ts index 3a22dac63..74e4369c9 100644 --- a/gitnexus/test/unit/scope-resolution/callable-value-flow-worklist.test.ts +++ b/gitnexus/test/unit/scope-resolution/callable-value-flow-worklist.test.ts @@ -17,7 +17,10 @@ import { createKnowledgeGraph } from '../../../src/core/graph/graph.js'; import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js'; import { buildGraphNodeLookup } from '../../../src/core/ingestion/scope-resolution/graph-bridge/node-lookup.js'; import { createCalleeIdAccumulator } from '../../../src/core/ingestion/scope-resolution/graph-bridge/callee-id-sink.js'; -import { emitCallableValueFlow } from '../../../src/core/ingestion/scope-resolution/passes/callable-value-flow.js'; +import { + aggregateCallableValueFlowWarnings, + emitCallableValueFlow, +} from '../../../src/core/ingestion/scope-resolution/passes/callable-value-flow.js'; const FILE = 'chain.ts'; const MODULE = 'scope:module' as ScopeId; @@ -199,4 +202,39 @@ describe('callable-value-flow dependency worklist', () => { ), ).toEqual(['target']); }); + + it('groups repeated internal overflows by causal source context', () => { + expect( + aggregateCallableValueFlowWarnings([ + { + language: 'javascript', + context: 'site:bundle.js:4:1933', + candidateCount: 33, + cap: 32, + }, + { + language: 'javascript', + context: 'site:bundle.js:4:1933', + candidateCount: 40, + cap: 32, + }, + { + language: 'javascript', + context: 'copy:bundle.js', + candidateCount: 33, + cap: 32, + }, + ]), + ).toEqual([ + { + language: 'javascript', + context: 'site:bundle.js:4:1933', + candidateCount: 40, + cap: 32, + occurrences: 3, + distinctContexts: 2, + contextSamples: ['site:bundle.js:4:1933', 'copy:bundle.js'], + }, + ]); + }); }); diff --git a/gitnexus/test/unit/scope-resolution/run-progress.test.ts b/gitnexus/test/unit/scope-resolution/run-progress.test.ts index 45bbfe9d7..17f8a79a8 100644 --- a/gitnexus/test/unit/scope-resolution/run-progress.test.ts +++ b/gitnexus/test/unit/scope-resolution/run-progress.test.ts @@ -1,12 +1,15 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import type { ParsedFile, ScopeId, Scope } from 'gitnexus-shared'; import { + formatScopeResolutionWarningContext, + MAX_PROGRESS_WARNING_CONTEXT_CHARS, runScopeResolution, type ScopeResolutionSubPhase, } from '../../../src/core/ingestion/scope-resolution/pipeline/run.js'; import { createKnowledgeGraph } from '../../../src/core/graph/graph.js'; import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js'; import type { ScopeResolver } from '../../../src/core/ingestion/scope-resolution/contract/scope-resolver.js'; +import { _captureLogger, warnRespectingProgressBar } from '../../../src/core/logger.js'; const mkScope = (id: ScopeId, filePath: string): Scope => ({ id, @@ -41,6 +44,37 @@ const stubProvider = { } as unknown as ScopeResolver; describe('runScopeResolution onProgress', () => { + it('escapes control bytes and bounds progress warning contexts', () => { + const formatted = formatScopeResolutionWarningContext(`binding\0${'x'.repeat(200)}`); + + expect(formatted).not.toContain('\0'); + expect(formatted).toContain('\\u0000'); + expect(formatted).toHaveLength(MAX_PROGRESS_WARNING_CONTEXT_CHARS); + expect(formatted.endsWith('...')).toBe(true); + }); + + it('routes warnings through the analyze progress logger instead of Pino', () => { + const previous = process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE; + const capture = _captureLogger(); + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE = '1'; + warnRespectingProgressBar('progress-safe warning', { + fields: { language: 'move', occurrences: 2 }, + message: 'structured warning', + }); + + expect(consoleWarn).toHaveBeenCalledOnce(); + expect(consoleWarn).toHaveBeenCalledWith('progress-safe warning'); + expect(capture.records()).toEqual([]); + } finally { + consoleWarn.mockRestore(); + capture.restore(); + if (previous === undefined) delete process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE; + else process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE = previous; + } + }); + it('emits sub-phases in order for a 3-file input', () => { const files = [ { path: 'a.py', content: '' },