mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
refactor(move): simplify & harden the Move integration architecture
Single-commit version of the move-integration-arch work (base main-aptos). Net effect on top of the Move ingest reliability release: - Remove the move-facts cache + freshness/hashing layer; drop init_module as an entry point; general structure/deslop of the Move module. - Split Move ingestion into a Pass-A (node/ref emission) / Pass-B (resolution/linking) seam with a MoveIngestAccumulator, and add bounded concurrency + native-fn pruning to the function_usage fan-out (concurrency-safe transport). - Decompose mapFactsToGraph into per-symbol emitters over a shared EmitContext. - Simplify the supporting pipeline deltas: explicit entry-point tier unification (process-processor), extracted scope-resolution progress formatters (run.ts), collapsed callable-value-flow switch, shared lbug delete-all helper. Behavior-preserving where it matters — Move graph-equivalence snapshot unchanged; unit + integration suites green (modulo known macOS/env-only failures).
This commit is contained in:
parent
2a212ad605
commit
6ea5aa4484
62 changed files with 4095 additions and 1174 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export const NODE_TABLES = [
|
|||
'Trait',
|
||||
'Impl',
|
||||
'TypeAlias',
|
||||
'Type',
|
||||
'Const',
|
||||
'Static',
|
||||
'Variable',
|
||||
|
|
|
|||
|
|
@ -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...';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -48,7 +48,9 @@ import {
|
|||
type ProcessesOutput,
|
||||
} from './pipeline-phases/index.js';
|
||||
|
||||
export interface PipelineOptions {
|
||||
export interface PipelineOptions<
|
||||
TStandaloneIngest extends StandaloneIngestOutput = StandaloneIngestOutput,
|
||||
> {
|
||||
/**
|
||||
* Skip MRO, community detection, and process extraction for faster test runs.
|
||||
* The `pruneLocalSymbols` phase still runs — it is graph construction (it cleans
|
||||
|
|
@ -228,7 +230,7 @@ export interface PipelineOptions {
|
|||
*/
|
||||
keepLocalValueSymbols?: boolean;
|
||||
/** Optional compiler-backed or otherwise standalone ingester. */
|
||||
standaloneIngestPhase?: PipelinePhase<StandaloneIngestOutput>;
|
||||
standaloneIngestPhase?: PipelinePhase<TStandaloneIngest>;
|
||||
/**
|
||||
* Extra fetch-wrapper function names to treat as HTTP consumers, threaded
|
||||
* from `.gitnexusrc` `fetchWrappers` via `AnalyzeOptions` (#1589/#1852
|
||||
|
|
@ -261,10 +263,12 @@ export interface PipelineOptions {
|
|||
* asserts the produced list is byte-identical to the legacy array for every
|
||||
* options combination.
|
||||
*/
|
||||
export function buildPhaseList(options?: PipelineOptions): PipelinePhase[] {
|
||||
export function buildPhaseList<TStandaloneIngest extends StandaloneIngestOutput>(
|
||||
options?: PipelineOptions<TStandaloneIngest>,
|
||||
): PipelinePhase[] {
|
||||
const { standaloneIngestPhase = emptyStandaloneIngestPhase } = options ?? {};
|
||||
return (
|
||||
new PhaseRegistry<PipelineOptions>()
|
||||
new PhaseRegistry<PipelineOptions<TStandaloneIngest>>()
|
||||
.register(scanPhase)
|
||||
.register(structurePhase)
|
||||
.register(standaloneIngestPhase)
|
||||
|
|
@ -295,11 +299,13 @@ export function buildPhaseList(options?: PipelineOptions): PipelinePhase[] {
|
|||
|
||||
// ── Pipeline orchestrator ─────────────────────────────────────────────────
|
||||
|
||||
export const runPipelineFromRepo = async (
|
||||
export const runPipelineFromRepo = async <
|
||||
TStandaloneIngest extends StandaloneIngestOutput = StandaloneIngestOutput,
|
||||
>(
|
||||
repoPath: string,
|
||||
onProgress: (progress: PipelineProgress) => void,
|
||||
options?: PipelineOptions,
|
||||
): Promise<PipelineResult> => {
|
||||
options?: PipelineOptions<TStandaloneIngest>,
|
||||
): Promise<PipelineResult<TStandaloneIngest>> => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const pipelineStart = Date.now();
|
||||
|
||||
|
|
@ -321,16 +327,10 @@ export const runPipelineFromRepo = async (
|
|||
|
||||
let communityResult: CommunitiesOutput['communityResult'] | undefined;
|
||||
let processResult: ProcessesOutput['processResult'] | undefined;
|
||||
// Standalone-ingest warnings, passed through opaquely (language-neutral).
|
||||
let ingestWarnings: readonly string[] | undefined;
|
||||
try {
|
||||
ingestWarnings = getPhaseOutput<StandaloneIngestOutput>(
|
||||
results,
|
||||
'standaloneIngest',
|
||||
).ingestWarnings;
|
||||
} catch {
|
||||
/* phase filtered out of this run — nothing to surface */
|
||||
}
|
||||
// Standalone ingest is always registered (it defaults to the empty phase),
|
||||
// so its output — and the language-neutral warnings it passes through — are
|
||||
// always present.
|
||||
const standaloneIngest = getPhaseOutput<TStandaloneIngest>(results, 'standaloneIngest');
|
||||
const scopeResolutionOutput = getPhaseOutput<ScopeResolutionOutput>(results, 'scopeResolution');
|
||||
const resolutionOutcomes = scopeResolutionOutput.resolutionOutcomes;
|
||||
// Streamed PDG-emit manifest (#2202): present only when streaming was on.
|
||||
|
|
@ -364,6 +364,6 @@ export const runPipelineFromRepo = async (
|
|||
resolutionOutcomes,
|
||||
usedWorkerPool,
|
||||
pdgEmitManifest,
|
||||
ingestWarnings,
|
||||
standaloneIngest,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
* Process Detection Processor
|
||||
*
|
||||
* Detects execution flows (Processes) in the code graph by:
|
||||
* 1. Finding entry points (functions with no internal callers)
|
||||
* 1. Finding entry points (explicit graph roots plus functions that call
|
||||
* others but have few callers)
|
||||
* 2. Tracing forward via CALLS edges (BFS)
|
||||
* 3. Grouping and deduplicating similar paths
|
||||
* 4. Labeling with heuristic names
|
||||
|
|
@ -10,7 +11,7 @@
|
|||
* Processes help agents understand how features work through the codebase.
|
||||
*/
|
||||
|
||||
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import { KnowledgeGraph } from '../graph/types.js';
|
||||
import { CommunityMembership } from './community-processor.js';
|
||||
import { calculateEntryPointScore, isTestFile } from './entry-point-scoring.js';
|
||||
|
|
@ -24,9 +25,9 @@ import { logger } from '../logger.js';
|
|||
|
||||
export interface ProcessDetectionConfig {
|
||||
maxTraceDepth: number; // Maximum steps to trace (default: 10)
|
||||
maxBranching: number; // Max branches to follow per node (default: 3)
|
||||
maxProcesses: number; // Maximum processes to detect (default: 50)
|
||||
minSteps: number; // Minimum steps for a valid process (default: 2)
|
||||
maxBranching: number; // Max branches to follow per node (default: 4)
|
||||
maxProcesses: number; // Maximum processes to detect (default: 75)
|
||||
minSteps: number; // Minimum steps for a valid process (default: 3)
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: ProcessDetectionConfig = {
|
||||
|
|
@ -92,15 +93,17 @@ export const processProcesses = async (
|
|||
const membershipMap = new Map<string, string>();
|
||||
memberships.forEach((m) => membershipMap.set(m.nodeId, m.communityId));
|
||||
|
||||
const callsEdges = buildCallsGraph(knowledgeGraph);
|
||||
const reverseCallsEdges = buildReverseCallsGraph(knowledgeGraph);
|
||||
const nodeMap = new Map<string, GraphNode>();
|
||||
for (const n of knowledgeGraph.iterNodes()) nodeMap.set(n.id, n);
|
||||
const { forward: callsEdges, reverse: reverseCallsEdges } = buildCallsAdjacency(knowledgeGraph);
|
||||
|
||||
// Step 1: Find entry points (functions that call others but have few callers)
|
||||
const entryPoints = findEntryPoints(knowledgeGraph, reverseCallsEdges, callsEdges);
|
||||
|
||||
onProgress?.(`Found ${entryPoints.length} entry points, tracing flows...`, 20);
|
||||
// Step 1: Find entry points — explicit ENTRY_POINT_OF graph roots plus
|
||||
// heuristic ones (functions that call others but have few callers).
|
||||
const explicitEntryPointIds = collectExplicitEntryPointIds(knowledgeGraph);
|
||||
const entryPoints = findEntryPoints(
|
||||
knowledgeGraph,
|
||||
reverseCallsEdges,
|
||||
callsEdges,
|
||||
explicitEntryPointIds,
|
||||
);
|
||||
|
||||
onProgress?.(`Found ${entryPoints.length} entry points, tracing flows...`, 20);
|
||||
|
||||
|
|
@ -125,7 +128,7 @@ export const processProcesses = async (
|
|||
onProgress?.(`Found ${allTraces.length} traces, deduplicating...`, 60);
|
||||
|
||||
// Step 3: Deduplicate similar traces (subset removal)
|
||||
const uniqueTraces = deduplicateTraces(allTraces);
|
||||
const uniqueTraces = deduplicateTraces(allTraces, explicitEntryPointIds);
|
||||
|
||||
// Step 3b: Deduplicate by entry+terminal pair (keep longest path per pair)
|
||||
const endpointDeduped = deduplicateByEndpoints(uniqueTraces);
|
||||
|
|
@ -135,9 +138,15 @@ export const processProcesses = async (
|
|||
70,
|
||||
);
|
||||
|
||||
// Step 4: Limit to max processes (prioritize longer traces)
|
||||
// Step 4: Keep explicit graph roots ahead of heuristic traces, then prefer
|
||||
// longer traces within each tier.
|
||||
const limitedTraces = endpointDeduped
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.sort(
|
||||
explicitFirst(
|
||||
(trace) => explicitEntryPointIds.has(trace[0]),
|
||||
(a, b) => b.length - a.length,
|
||||
),
|
||||
)
|
||||
.slice(0, cfg.maxProcesses);
|
||||
|
||||
onProgress?.(`Creating ${limitedTraces.length} process nodes...`, 80);
|
||||
|
|
@ -163,8 +172,8 @@ export const processProcesses = async (
|
|||
communities.length > 1 ? 'cross_community' : 'intra_community';
|
||||
|
||||
// Generate label
|
||||
const entryNode = nodeMap.get(entryPointId);
|
||||
const terminalNode = nodeMap.get(terminalId);
|
||||
const entryNode = knowledgeGraph.getNode(entryPointId);
|
||||
const terminalNode = knowledgeGraph.getNode(terminalId);
|
||||
const entryName = entryNode?.properties.name || 'Unknown';
|
||||
const terminalName = terminalNode?.properties.name || 'Unknown';
|
||||
const heuristicLabel = `${capitalize(entryName)} → ${capitalize(terminalName)}`;
|
||||
|
|
@ -227,35 +236,51 @@ type AdjacencyList = Map<string, string[]>;
|
|||
*/
|
||||
const MIN_TRACE_CONFIDENCE = 0.5;
|
||||
|
||||
const buildCallsGraph = (graph: KnowledgeGraph): AdjacencyList => {
|
||||
const adj = new Map<string, string[]>();
|
||||
/** Build forward and reverse CALLS adjacency lists in one relationship scan. */
|
||||
const buildCallsAdjacency = (
|
||||
graph: KnowledgeGraph,
|
||||
): { forward: AdjacencyList; reverse: AdjacencyList } => {
|
||||
const forward: AdjacencyList = new Map();
|
||||
const reverse: AdjacencyList = new Map();
|
||||
const push = (adj: AdjacencyList, key: string, value: string): void => {
|
||||
const bucket = adj.get(key);
|
||||
if (bucket === undefined) adj.set(key, [value]);
|
||||
else bucket.push(value);
|
||||
};
|
||||
|
||||
for (const rel of graph.iterRelationships()) {
|
||||
if (rel.type === 'CALLS' && rel.confidence >= MIN_TRACE_CONFIDENCE) {
|
||||
if (!adj.has(rel.sourceId)) {
|
||||
adj.set(rel.sourceId, []);
|
||||
}
|
||||
adj.get(rel.sourceId)!.push(rel.targetId);
|
||||
push(forward, rel.sourceId, rel.targetId);
|
||||
push(reverse, rel.targetId, rel.sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
return adj;
|
||||
return { forward, reverse };
|
||||
};
|
||||
|
||||
const buildReverseCallsGraph = (graph: KnowledgeGraph): AdjacencyList => {
|
||||
const adj = new Map<string, string[]>();
|
||||
|
||||
for (const rel of graph.iterRelationships()) {
|
||||
if (rel.type === 'CALLS' && rel.confidence >= MIN_TRACE_CONFIDENCE) {
|
||||
if (!adj.has(rel.targetId)) {
|
||||
adj.set(rel.targetId, []);
|
||||
}
|
||||
adj.get(rel.targetId)!.push(rel.sourceId);
|
||||
}
|
||||
/** IDs of nodes carrying a pre-phase ENTRY_POINT_OF edge (compiler-backed
|
||||
* runtime/API roots stamped by the standalone ingest phase). */
|
||||
function collectExplicitEntryPointIds(graph: KnowledgeGraph): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const rel of graph.iterRelationshipsByType('ENTRY_POINT_OF')) {
|
||||
// Only callable roots qualify. Route/Tool→Process ENTRY_POINT_OF edges
|
||||
// are created after this phase; the label filter keeps them out even if
|
||||
// phase ordering ever changes.
|
||||
const label = graph.getNode(rel.sourceId)?.label;
|
||||
if (label === 'Function' || label === 'Method') ids.add(rel.sourceId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
return adj;
|
||||
};
|
||||
/** Global cap on heuristic (non-explicit) entry points, to prevent explosion
|
||||
* on large polyglot repos. Explicit graph roots are exempt and counted first. */
|
||||
const HEURISTIC_ENTRY_POINT_BUDGET = 200;
|
||||
|
||||
/** Comparator combinator: explicit items first, then a secondary order. */
|
||||
const explicitFirst =
|
||||
<T>(isExplicit: (item: T) => boolean, then: (a: T, b: T) => number) =>
|
||||
(a: T, b: T): number =>
|
||||
Number(isExplicit(b)) - Number(isExplicit(a)) || then(a, b);
|
||||
|
||||
/**
|
||||
* Find functions/methods that are good entry points for tracing.
|
||||
|
|
@ -271,12 +296,20 @@ const findEntryPoints = (
|
|||
graph: KnowledgeGraph,
|
||||
reverseCallsEdges: AdjacencyList,
|
||||
callsEdges: AdjacencyList,
|
||||
explicitEntryPointIds: ReadonlySet<string>,
|
||||
): string[] => {
|
||||
const symbolTypes = new Set<NodeLabel>(['Function', 'Method']);
|
||||
// ENTRY_POINT_OF is the graph-wide, language-neutral signal for a known
|
||||
// runtime/API root. Only edges emitted BEFORE this phase count — today
|
||||
// that is compiler-backed standalone ingesters; the
|
||||
// Route/Tool ENTRY_POINT_OF edges are created after process extraction
|
||||
// and point Route/Tool→Process, so they never land here. These roots must
|
||||
// survive the global heuristic top-200 budget on large polyglot repos.
|
||||
const entryPointCandidates: {
|
||||
id: string;
|
||||
score: number;
|
||||
reasons: string[];
|
||||
explicit: boolean;
|
||||
}[] = [];
|
||||
|
||||
for (const node of graph.iterNodes()) {
|
||||
|
|
@ -304,19 +337,26 @@ const findEntryPoints = (
|
|||
);
|
||||
|
||||
let score = baseScore;
|
||||
const explicit = explicitEntryPointIds.has(node.id);
|
||||
if (explicit) reasons.push('graph-entry-point');
|
||||
const astFrameworkMultiplier = node.properties.astFrameworkMultiplier ?? 1.0;
|
||||
if (astFrameworkMultiplier > 1.0) {
|
||||
score *= astFrameworkMultiplier;
|
||||
reasons.push(`framework-ast:${node.properties.astFrameworkReason || 'decorator'}`);
|
||||
}
|
||||
|
||||
if (score > 0) {
|
||||
entryPointCandidates.push({ id: node.id, score, reasons });
|
||||
if (explicit || score > 0) {
|
||||
entryPointCandidates.push({ id: node.id, score, reasons, explicit });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending and return top candidates
|
||||
const sorted = entryPointCandidates.sort((a, b) => b.score - a.score);
|
||||
// Known graph entry points form a priority tier ahead of heuristic scoring.
|
||||
const sorted = entryPointCandidates.sort(
|
||||
explicitFirst(
|
||||
(candidate) => candidate.explicit,
|
||||
(a, b) => b.score - a.score,
|
||||
),
|
||||
);
|
||||
|
||||
// DEBUG: Log top candidates with new scoring details
|
||||
if (sorted.length > 0 && isDev) {
|
||||
|
|
@ -330,9 +370,11 @@ const findEntryPoints = (
|
|||
});
|
||||
}
|
||||
|
||||
return sorted
|
||||
.slice(0, 200) // Limit to prevent explosion
|
||||
.map((c) => c.id);
|
||||
const explicit = sorted.filter((candidate) => candidate.explicit);
|
||||
const heuristic = sorted
|
||||
.filter((candidate) => !candidate.explicit)
|
||||
.slice(0, Math.max(0, HEURISTIC_ENTRY_POINT_BUDGET - explicit.length));
|
||||
return [...explicit, ...heuristic].map((candidate) => candidate.id);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -401,23 +443,31 @@ const traceFromEntryPoint = (
|
|||
* Merge traces that are subsets of other traces.
|
||||
* Keep longer traces, remove redundant shorter ones.
|
||||
*/
|
||||
const deduplicateTraces = (traces: string[][]): string[][] => {
|
||||
const deduplicateTraces = (
|
||||
traces: string[][],
|
||||
explicitEntryPointIds: ReadonlySet<string> = new Set(),
|
||||
): string[][] => {
|
||||
if (traces.length === 0) return [];
|
||||
|
||||
// Sort by length descending
|
||||
const sorted = [...traces].sort((a, b) => b.length - a.length);
|
||||
const unique: string[][] = [];
|
||||
// Cache each kept trace's join key so the redundancy scan below doesn't
|
||||
// recompute `existing.join('->')` on every comparison.
|
||||
const uniqueKeys: string[] = [];
|
||||
|
||||
for (const trace of sorted) {
|
||||
// Check if this trace is a subset of any already-added trace
|
||||
const traceKey = trace.join('->');
|
||||
const isSubset = unique.some((existing) => {
|
||||
const existingKey = existing.join('->');
|
||||
return existingKey.includes(traceKey);
|
||||
});
|
||||
// Explicit entry points dedupe on exact equality; heuristic traces are
|
||||
// dropped when a longer kept trace already contains them as a substring.
|
||||
const exactOnly = explicitEntryPointIds.has(trace[0]);
|
||||
const isRedundant = uniqueKeys.some((existingKey) =>
|
||||
exactOnly ? existingKey === traceKey : existingKey.includes(traceKey),
|
||||
);
|
||||
|
||||
if (!isSubset) {
|
||||
if (!isRedundant) {
|
||||
unique.push(trace);
|
||||
uniqueKeys.push(traceKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ interface Target {
|
|||
readonly def: SymbolDefinition;
|
||||
}
|
||||
|
||||
// Shared miss results for the fixpoint's read paths — callers only iterate.
|
||||
const EMPTY_TARGETS: ReadonlyMap<string, Target> = new Map();
|
||||
const EMPTY_CELLS: ReadonlySet<string> = new Set();
|
||||
|
||||
interface FileFact {
|
||||
readonly filePath: string;
|
||||
readonly site: CallableFlowSite;
|
||||
|
|
@ -40,13 +44,22 @@ interface FileInvoke {
|
|||
readonly site: CallableFlowInvokeSite;
|
||||
}
|
||||
|
||||
export interface CallableValueFlowWarning {
|
||||
interface RawCallableValueFlowWarning {
|
||||
readonly language: string;
|
||||
readonly context: string;
|
||||
readonly candidateCount: number;
|
||||
readonly cap: number;
|
||||
}
|
||||
|
||||
export interface CallableValueFlowWarning extends RawCallableValueFlowWarning {
|
||||
/** Number of internal cells represented by this aggregate warning. */
|
||||
readonly occurrences: number;
|
||||
/** Number of source contexts represented by this aggregate warning. */
|
||||
readonly distinctContexts: number;
|
||||
/** Bounded diagnostic sample; stdout receives one aggregate, not every site. */
|
||||
readonly contextSamples: readonly string[];
|
||||
}
|
||||
|
||||
export interface CallableValueFlowResult {
|
||||
readonly emitted: number;
|
||||
readonly resolvedInvokes: number;
|
||||
|
|
@ -55,6 +68,46 @@ export interface CallableValueFlowResult {
|
|||
readonly iterations: number;
|
||||
}
|
||||
|
||||
/** Collapse internal-cell overflows to one bounded warning per language/cap. */
|
||||
export function aggregateCallableValueFlowWarnings(
|
||||
warnings: Iterable<RawCallableValueFlowWarning>,
|
||||
): CallableValueFlowWarning[] {
|
||||
const grouped = new Map<
|
||||
string,
|
||||
RawCallableValueFlowWarning & {
|
||||
// Re-declared mutable so occurrences can raise it (the raw field is readonly).
|
||||
candidateCount: number;
|
||||
occurrences: number;
|
||||
allContexts: Set<string>;
|
||||
contextSamples: string[];
|
||||
}
|
||||
>();
|
||||
for (const warning of warnings) {
|
||||
const key = `${warning.language}\0${warning.cap}`;
|
||||
const current = grouped.get(key);
|
||||
if (!current) {
|
||||
grouped.set(key, {
|
||||
...warning,
|
||||
occurrences: 1,
|
||||
allContexts: new Set([warning.context]),
|
||||
contextSamples: [warning.context],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
current.candidateCount = Math.max(current.candidateCount, warning.candidateCount);
|
||||
current.occurrences++;
|
||||
current.allContexts.add(warning.context);
|
||||
if (current.contextSamples.length < 5 && !current.contextSamples.includes(warning.context)) {
|
||||
current.contextSamples.push(warning.context);
|
||||
}
|
||||
}
|
||||
return [...grouped.values()].map(({ allContexts, ...warning }) => ({
|
||||
...warning,
|
||||
distinctContexts: allContexts.size,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface EmitCallableValueFlowInput {
|
||||
readonly graph: KnowledgeGraph;
|
||||
readonly scopes: ScopeResolutionIndexes;
|
||||
|
|
@ -66,6 +119,12 @@ export interface EmitCallableValueFlowInput {
|
|||
readonly isCallableValueTarget?: (def: SymbolDefinition) => boolean;
|
||||
readonly hasFileLocalCallableLinkage?: (def: SymbolDefinition) => boolean;
|
||||
readonly onWarn?: (warning: CallableValueFlowWarning) => void;
|
||||
/**
|
||||
* Precomputed {@link collectDeferredIndirectSites} result for the same
|
||||
* files/scopes. The orchestrator already needs it to build skip sets;
|
||||
* threading it here avoids a second whole-repo scan. Recomputed when absent.
|
||||
*/
|
||||
readonly canonicalInvokeKeys?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/** Position key shared with the existing free/reference skip-set contract. */
|
||||
|
|
@ -76,6 +135,34 @@ export function callableFlowSiteKey(
|
|||
return `${filePath}:${range.startLine}:${range.startCol}`;
|
||||
}
|
||||
|
||||
function warningContext(filePath: string, site: CallableFlowSite): string {
|
||||
let range: { readonly startLine: number; readonly startCol: number };
|
||||
switch (site.kind) {
|
||||
case 'seed':
|
||||
case 'copy':
|
||||
case 'alias':
|
||||
case 'address':
|
||||
case 'load':
|
||||
range = site.destination.atRange;
|
||||
break;
|
||||
case 'store':
|
||||
range = site.pointer.atRange;
|
||||
break;
|
||||
case 'formal':
|
||||
range = site.ownerRange;
|
||||
break;
|
||||
case 'argument':
|
||||
case 'invoke':
|
||||
range = site.callSite;
|
||||
break;
|
||||
default: {
|
||||
const exhaustive: never = site;
|
||||
throw new Error(`Unhandled callable-flow site kind: ${String(exhaustive)}`);
|
||||
}
|
||||
}
|
||||
return `${site.kind}:${callableFlowSiteKey(filePath, range)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return only invoke sites that join to a canonical call ReferenceSite.
|
||||
* Malformed/stale facts never suppress ordinary resolution.
|
||||
|
|
@ -140,7 +227,8 @@ function flowCellOperand(site: CallableFlowSite): CallableFlowOperand | undefine
|
|||
export function emitCallableValueFlow(input: EmitCallableValueFlowInput): CallableValueFlowResult {
|
||||
const facts: FileFact[] = [];
|
||||
const invokes: FileInvoke[] = [];
|
||||
const canonicalInvokeKeys = collectDeferredIndirectSites(input.parsedFiles, input.scopes);
|
||||
const canonicalInvokeKeys =
|
||||
input.canonicalInvokeKeys ?? collectDeferredIndirectSites(input.parsedFiles, input.scopes);
|
||||
let unmatchedInvokes = 0;
|
||||
for (const parsed of input.parsedFiles) {
|
||||
for (const site of parsed.callableFlowSites ?? []) {
|
||||
|
|
@ -161,7 +249,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
const addressesByBinding = new Map<string, Set<string>>();
|
||||
const overflowedTargets = new Set<string>();
|
||||
const overflowedAddresses = new Set<string>();
|
||||
const overflowWarnings = new Map<string, CallableValueFlowWarning>();
|
||||
const overflowWarnings = new Map<string, RawCallableValueFlowWarning>();
|
||||
const rawGraphTargets = buildGraphTargetIndex(
|
||||
input.scopes,
|
||||
input.nodeLookup,
|
||||
|
|
@ -311,7 +399,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
): { readonly targets: ReadonlyMap<string, Target>; readonly overflow: boolean } => {
|
||||
watch('target', key);
|
||||
return {
|
||||
targets: targetsByBinding.get(key) ?? new Map(),
|
||||
targets: targetsByBinding.get(key) ?? EMPTY_TARGETS,
|
||||
overflow: overflowedTargets.has(key),
|
||||
};
|
||||
};
|
||||
|
|
@ -321,7 +409,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
): { readonly cells: ReadonlySet<string>; readonly overflow: boolean } => {
|
||||
watch('address', key);
|
||||
return {
|
||||
cells: addressesByBinding.get(key) ?? new Set(),
|
||||
cells: addressesByBinding.get(key) ?? EMPTY_CELLS,
|
||||
overflow: overflowedAddresses.has(key),
|
||||
};
|
||||
};
|
||||
|
|
@ -476,10 +564,10 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
|
||||
for (const fact of facts) {
|
||||
const site = fact.site;
|
||||
const context = `${fact.site.kind}:${fact.filePath}`;
|
||||
switch (site.kind) {
|
||||
case 'copy':
|
||||
case 'alias': {
|
||||
const context = warningContext(fact.filePath, site);
|
||||
addWorkItem(() => {
|
||||
const source = bindingKey(fact.filePath, site.source);
|
||||
const destination = bindingKey(fact.filePath, site.destination);
|
||||
|
|
@ -493,6 +581,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
break;
|
||||
}
|
||||
case 'load': {
|
||||
const context = warningContext(fact.filePath, site);
|
||||
addWorkItem(() => {
|
||||
const destination = bindingKey(fact.filePath, site.destination);
|
||||
const reached = reachedCells(fact.filePath, site.pointer);
|
||||
|
|
@ -509,6 +598,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
break;
|
||||
}
|
||||
case 'store': {
|
||||
const context = warningContext(fact.filePath, site);
|
||||
addWorkItem(() => {
|
||||
const sourceTargets = operandTargets(fact.filePath, site.source);
|
||||
const reached = reachedCells(fact.filePath, site.pointer);
|
||||
|
|
@ -585,7 +675,11 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
targetIds.add(id);
|
||||
}
|
||||
for (const id of dynamicCallees.get(callKey)?.keys() ?? []) targetIds.add(id);
|
||||
let hasIndexedFormal = [...targetIds].some((id) => indexedFormals(id).length > 0);
|
||||
const hasAnyIndexedFormal = (): boolean => {
|
||||
for (const id of targetIds) if (indexedFormals(id).length > 0) return true;
|
||||
return false;
|
||||
};
|
||||
let hasIndexedFormal = hasAnyIndexedFormal();
|
||||
if (!hasIndexedFormal && site.directCalleeName !== undefined) {
|
||||
for (const target of resolveSeedCandidates(
|
||||
fact.filePath,
|
||||
|
|
@ -601,7 +695,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
)) {
|
||||
targetIds.add(target.id);
|
||||
}
|
||||
hasIndexedFormal = [...targetIds].some((id) => indexedFormals(id).length > 0);
|
||||
hasIndexedFormal = hasAnyIndexedFormal();
|
||||
}
|
||||
const history = dynamicTargetHistory.get(callKey);
|
||||
const callOverflow =
|
||||
|
|
@ -684,7 +778,12 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
);
|
||||
}
|
||||
|
||||
for (const warning of overflowWarnings.values()) input.onWarn?.(warning);
|
||||
// A large generated bundle can create thousands of distinct binding cells
|
||||
// at the same source site. Preserve the causal evidence while emitting one
|
||||
// structured warning per site instead of one line per internal cell.
|
||||
for (const warning of aggregateCallableValueFlowWarnings(overflowWarnings.values())) {
|
||||
input.onWarn?.(warning);
|
||||
}
|
||||
|
||||
// No partial graph output when a hostile/corrupt fact graph exhausts the
|
||||
// bounded work budget. The caller receives a warning; NOTE this is not
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -564,6 +564,7 @@ export const streamAllCSVsToDisk = async (
|
|||
const MULTI_LANG_TYPES = [
|
||||
'Struct',
|
||||
'Enum',
|
||||
'Type',
|
||||
'EnumVariant',
|
||||
'Macro',
|
||||
'Typedef',
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ export interface MoveFlowConstant {
|
|||
|
||||
export type CallGraphMap = Record<string, string[]>;
|
||||
|
||||
/** Compiler usage for one function. `used - called` is closure capture. */
|
||||
export interface MoveFunctionUsage {
|
||||
called: string[];
|
||||
used: string[];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// move-flow `facts` query — full-fidelity, compiler-sourced per-module facts.
|
||||
//
|
||||
|
|
|
|||
42
gitnexus/src/core/move/concurrency.ts
Normal file
42
gitnexus/src/core/move/concurrency.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// gitnexus/src/core/move/concurrency.ts
|
||||
export interface MapWithConcurrencyResult<T, R> {
|
||||
results: R[];
|
||||
failure?: { item: T; error: unknown };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `worker` over `items` with at most `limit` in flight. With `failFast`,
|
||||
* the first rejection stops scheduling new work and is returned as `failure`
|
||||
* (already-running workers are awaited). Without it, the first rejection
|
||||
* rejects the returned promise.
|
||||
*/
|
||||
export async function mapWithConcurrency<T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
worker: (item: T, index: number) => Promise<R>,
|
||||
opts: { failFast?: boolean } = {},
|
||||
): Promise<MapWithConcurrencyResult<T, R>> {
|
||||
const results: R[] = new Array(items.length);
|
||||
const effectiveLimit = Math.max(1, Math.floor(limit));
|
||||
let next = 0;
|
||||
let failure: { item: T; error: unknown } | undefined;
|
||||
|
||||
async function run(): Promise<void> {
|
||||
while (next < items.length && !(opts.failFast && failure)) {
|
||||
const i = next++;
|
||||
try {
|
||||
results[i] = await worker(items[i], i);
|
||||
} catch (error) {
|
||||
if (opts.failFast) {
|
||||
if (!failure) failure = { item: items[i], error };
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(effectiveLimit, items.length) }, run);
|
||||
await Promise.all(workers);
|
||||
return failure ? { results, failure } : { results };
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import type { KnowledgeGraph } from '../graph/types.js';
|
||||
import type { MovePackageStatus } from './mcp-client.js';
|
||||
import type { MoveIngestOutput } from './move-ingest.js';
|
||||
import { moveModuleQualifiedName } from './symbol-id.js';
|
||||
import type { DroppedRef, PendingRefKind } from './refs.js';
|
||||
import { moveModuleQualifiedName, parseMoveModuleQualifiedName } from './symbol-id.js';
|
||||
|
||||
export type MoveConsistencySeverity = 'warning' | 'error';
|
||||
|
||||
|
|
@ -11,6 +12,16 @@ export interface MoveConsistencyIssue {
|
|||
| 'missing-owned-callee'
|
||||
| 'malformed-source-evidence'
|
||||
| 'unresolved-resource-target'
|
||||
| 'unresolved-type-target'
|
||||
| 'unresolved-friend-target'
|
||||
| 'unresolved-lambda-host'
|
||||
| 'function-usage-query-failed'
|
||||
/** Non-empty call graph in which no caller resolves to a mapped function -
|
||||
* a systematic qualified-name mismatch between `call_graph` and `facts`. */
|
||||
| 'call-graph-unlinked'
|
||||
/** Externally-materialized module shares an address with repo-local
|
||||
* modules - possibly local code the compiler elided from `facts`. */
|
||||
| 'external-module-address-overlap'
|
||||
/** Package with .move sources returned facts `{}` - severity policy in
|
||||
* `emptyFactsIssue` below. */
|
||||
| 'empty-package-facts'
|
||||
|
|
@ -35,6 +46,53 @@ export interface EmptyFactsPackage {
|
|||
status: MovePackageStatus | null;
|
||||
}
|
||||
|
||||
export interface MoveConsistencySummaryIssue {
|
||||
code: MoveConsistencyIssue['code'];
|
||||
severity: MoveConsistencySeverity;
|
||||
message: string;
|
||||
/** Bounded JSON preview of the original structured details. */
|
||||
details?: string;
|
||||
}
|
||||
|
||||
/** Compact, persistable digest of a run's Move consistency issues. */
|
||||
export interface MoveConsistencySummary {
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
/** Errors-first sample, capped so meta.json stays small. */
|
||||
issues: MoveConsistencySummaryIssue[];
|
||||
}
|
||||
|
||||
const MAX_SUMMARY_MESSAGE_CHARS = 500;
|
||||
const MAX_SUMMARY_DETAILS_CHARS = 2_000;
|
||||
|
||||
function truncateSummaryText(value: string, maxChars: number): string {
|
||||
if (value.length <= maxChars) return value;
|
||||
return `${value.slice(0, maxChars - 3)}...`;
|
||||
}
|
||||
|
||||
/** `undefined` when there is nothing to record (the common clean run). */
|
||||
export function summarizeMoveConsistency(
|
||||
issues: readonly MoveConsistencyIssue[],
|
||||
sampleLimit = 20,
|
||||
): MoveConsistencySummary | undefined {
|
||||
if (issues.length === 0) return undefined;
|
||||
const errors = issues.filter((i) => i.severity === 'error');
|
||||
const warnings = issues.filter((i) => i.severity === 'warning');
|
||||
return {
|
||||
errorCount: errors.length,
|
||||
warningCount: warnings.length,
|
||||
issues: [...errors, ...warnings].slice(0, sampleLimit).map((issue) => ({
|
||||
code: issue.code,
|
||||
severity: issue.severity,
|
||||
message: truncateSummaryText(issue.message, MAX_SUMMARY_MESSAGE_CHARS),
|
||||
details:
|
||||
issue.details === undefined
|
||||
? undefined
|
||||
: truncateSummaryText(JSON.stringify(issue.details), MAX_SUMMARY_DETAILS_CHARS),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Severity policy for a package whose facts came back `{}` despite .move
|
||||
* sources: compiling -> warning (likely test-only, elided from facts), failing
|
||||
|
|
@ -204,6 +262,9 @@ export function validateMoveIngestOutput(
|
|||
}
|
||||
}
|
||||
|
||||
validateCallGraphLinkage(moveIngest, issues);
|
||||
validateExternalAddressOverlap(graph, moveIngest, issues);
|
||||
|
||||
for (const [packageRoot, callGraph] of moveIngest.callGraphByPackage) {
|
||||
for (const [callerQualified, callees] of Object.entries(callGraph)) {
|
||||
const callerModule = moveModuleQualifiedName(callerQualified);
|
||||
|
|
@ -233,15 +294,150 @@ export function validateMoveIngestOutput(
|
|||
}
|
||||
}
|
||||
|
||||
const dropped = moveIngest.droppedResourceRefs;
|
||||
if (dropped && dropped.length > 0) {
|
||||
issues.push({
|
||||
code: 'unresolved-resource-target',
|
||||
severity: 'warning',
|
||||
message: `${dropped.length} resource read/write/acquires target(s) could not be resolved.`,
|
||||
details: { count: dropped.length, sample: dropped.slice(0, 5) },
|
||||
});
|
||||
}
|
||||
pushGroupedDrops(issues, moveIngest.droppedRefs);
|
||||
pushDroppedIssue(
|
||||
issues,
|
||||
moveIngest.functionUsageFailures,
|
||||
'function-usage-query-failed',
|
||||
'supplemental function-usage query(s) failed',
|
||||
);
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
const DROP_ISSUE: Record<PendingRefKind, { code: MoveConsistencyIssue['code']; label: string }> = {
|
||||
resource: {
|
||||
code: 'unresolved-resource-target',
|
||||
label: 'resource read/write/acquires target(s) could not be resolved',
|
||||
},
|
||||
type: {
|
||||
code: 'unresolved-type-target',
|
||||
label: 'signature/type reference target(s) could not be resolved',
|
||||
},
|
||||
friend: {
|
||||
code: 'unresolved-friend-target',
|
||||
label: 'friend declaration target(s) have no Module node',
|
||||
},
|
||||
'lambda-host': {
|
||||
code: 'unresolved-lambda-host',
|
||||
label: 'lambda function(s) have no resolvable host function',
|
||||
},
|
||||
};
|
||||
|
||||
function pushGroupedDrops(issues: MoveConsistencyIssue[], drops: readonly DroppedRef[]): void {
|
||||
const byKind = new Map<PendingRefKind, DroppedRef[]>();
|
||||
for (const d of drops) {
|
||||
const list = byKind.get(d.kind);
|
||||
if (list) list.push(d);
|
||||
else byKind.set(d.kind, [d]);
|
||||
}
|
||||
for (const [kind, list] of byKind) {
|
||||
const { code, label } = DROP_ISSUE[kind];
|
||||
issues.push({
|
||||
code,
|
||||
severity: 'warning',
|
||||
message: `${list.length} ${label}.`,
|
||||
details: { count: list.length, sample: list.slice(0, 5) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** One warning per non-empty dropped-reference list: full count, 5-item sample. */
|
||||
function pushDroppedIssue(
|
||||
issues: MoveConsistencyIssue[],
|
||||
dropped: readonly unknown[],
|
||||
code: MoveConsistencyIssue['code'],
|
||||
label: string,
|
||||
): void {
|
||||
if (dropped.length === 0) return;
|
||||
issues.push({
|
||||
code,
|
||||
severity: 'warning',
|
||||
message: `${dropped.length} ${label}.`,
|
||||
details: { count: dropped.length, sample: dropped.slice(0, 5) },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-empty call graph in which NO caller resolves to a mapped function OR
|
||||
* mapped module is a systematic qualified-name mismatch between `call_graph`
|
||||
* and `facts` (e.g. address-normalization drift across move-flow versions):
|
||||
* every CALLS edge drops, and the per-caller ownership checks are also blind
|
||||
* because modulePackageMap is keyed from the same facts names. The module
|
||||
* condition keeps call graphs that merely include facts-elided functions
|
||||
* (e.g. tests) out of scope — their caller MODULES still resolve, and the
|
||||
* per-caller missing-owned-caller check already covers them. The callee
|
||||
* condition covers callers living entirely in facts-elided modules (a
|
||||
* test-only module exercising a mapped library): their CALLEES still join
|
||||
* facts names, which real normalization drift would break on both sides.
|
||||
* Scoped to packages that DID map functions, so a structs-only package
|
||||
* cannot false-positive.
|
||||
*/
|
||||
function validateCallGraphLinkage(
|
||||
moveIngest: MoveIngestOutput,
|
||||
issues: MoveConsistencyIssue[],
|
||||
): void {
|
||||
const mappedFnPackages = new Set<string>();
|
||||
for (const fnQualified of moveIngest.functionNodeMap.keys()) {
|
||||
const pkg = moveIngest.modulePackageMap.get(moveModuleQualifiedName(fnQualified));
|
||||
if (pkg) mappedFnPackages.add(pkg);
|
||||
}
|
||||
for (const [packageRoot, callGraph] of moveIngest.callGraphByPackage) {
|
||||
const callers = Object.keys(callGraph);
|
||||
if (callers.length === 0 || !mappedFnPackages.has(packageRoot)) continue;
|
||||
if (callers.some((caller) => moveIngest.functionNodeMap.has(caller))) continue;
|
||||
if (
|
||||
callers.some((caller) => moveIngest.modulePackageMap.has(moveModuleQualifiedName(caller)))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const callees = Object.values(callGraph).flat();
|
||||
if (callees.some((callee) => moveIngest.functionNodeMap.has(callee))) continue;
|
||||
issues.push({
|
||||
code: 'call-graph-unlinked',
|
||||
severity: 'error',
|
||||
message:
|
||||
`Move call graph for ${packageRoot} resolved zero of ${callers.length} caller(s) to ` +
|
||||
`mapped functions - qualified-name mismatch between call_graph and facts?`,
|
||||
details: { packageRoot, callerCount: callers.length, sample: callers.slice(0, 5) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The linker materializes unresolved cross-module targets as external
|
||||
* dependency nodes (`locationFidelity: 'external'`). An external module whose
|
||||
* ADDRESS also hosts repo-local modules is suspicious: it may be local code
|
||||
* that `facts` elided while `call_graph`/type refs still name it, which would
|
||||
* otherwise mislabel the user's own symbols as foreign with no trace.
|
||||
*/
|
||||
function validateExternalAddressOverlap(
|
||||
graph: KnowledgeGraph,
|
||||
moveIngest: MoveIngestOutput,
|
||||
issues: MoveConsistencyIssue[],
|
||||
): void {
|
||||
const localAddresses = new Set<string>();
|
||||
for (const moduleQualified of moveIngest.moduleFileMap.keys()) {
|
||||
const { address } = parseMoveModuleQualifiedName(moduleQualified);
|
||||
if (address) localAddresses.add(address);
|
||||
}
|
||||
if (localAddresses.size === 0) return;
|
||||
|
||||
const overlapping: string[] = [];
|
||||
for (const node of graph.iterNodes()) {
|
||||
if (node.label !== 'Module' || node.properties.locationFidelity !== 'external') continue;
|
||||
const qualifiedName = node.properties.qualifiedName;
|
||||
if (typeof qualifiedName !== 'string') continue;
|
||||
const { address } = parseMoveModuleQualifiedName(qualifiedName);
|
||||
if (address && localAddresses.has(address)) overlapping.push(qualifiedName);
|
||||
}
|
||||
if (overlapping.length === 0) return;
|
||||
issues.push({
|
||||
code: 'external-module-address-overlap',
|
||||
severity: 'warning',
|
||||
message:
|
||||
`${overlapping.length} external-dependency module(s) share an address with repo-local ` +
|
||||
`modules - local code may have been materialized as external (facts/call_graph drift).`,
|
||||
details: { count: overlapping.length, sample: overlapping.slice(0, 5) },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ export const MOVE_EDGE_REASON = {
|
|||
definesStruct: 'move-module-defines-struct',
|
||||
definesEnum: 'move-module-defines-enum',
|
||||
definesConst: 'move-module-defines-const',
|
||||
externalDefinesFunction: 'move-external-module-defines-function',
|
||||
externalDefinesType: 'move-external-module-defines-type',
|
||||
containsVariant: 'move-enum-contains-variant',
|
||||
friend: 'move-friend-or-package',
|
||||
calls: 'move-compiler-call-graph',
|
||||
|
|
@ -70,12 +72,16 @@ export const MOVE_EDGE_REASON = {
|
|||
acquires: 'move-acquires',
|
||||
fnParamType: 'move-fn-param-type',
|
||||
fnReturnType: 'move-fn-return-type',
|
||||
structFieldType: 'move-struct-field-type',
|
||||
enumVariantFieldType: 'move-enum-variant-field-type',
|
||||
// Struct -> resource-group struct (from `#[resource_group_member(group = ...)]`)
|
||||
resourceGroupMember: 'move-resource-group-member',
|
||||
// Field edges (struct → field)
|
||||
hasField: 'move-struct-has-field',
|
||||
hasVariantField: 'move-enum-variant-has-field',
|
||||
// Lambda → host edges (host fn → __lambda__N__host)
|
||||
lambdaHost: 'move-lambda-of-host',
|
||||
closureUse: 'move-compiler-closure-use',
|
||||
// Entry-point edge reasons (ENTRY_POINT_OF)
|
||||
entryFunction: 'move-entry-function',
|
||||
viewFunction: 'move-view-function',
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
65
gitnexus/src/core/move/function-usage.ts
Normal file
65
gitnexus/src/core/move/function-usage.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import type { CallGraphMap, MoveFactsMap } from './compiler-facts.js';
|
||||
import { mapWithConcurrency } from './concurrency.js';
|
||||
import type { MoveFlowClient } from './mcp-client.js';
|
||||
import { moveShortSymbol } from './symbol-id.js';
|
||||
|
||||
export interface FunctionUsageFailure {
|
||||
functionQualified: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ClosureCaptureResult {
|
||||
calls: CallGraphMap;
|
||||
failures: FunctionUsageFailure[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover compiler-known calls through function values. `used` contains direct
|
||||
* calls and closure captures; subtracting `called` leaves the missing callable
|
||||
* edges. `call_graph` callees are subtracted too: the two queries may classify
|
||||
* the same edge differently (invoked AND passed as a value), and an edge the
|
||||
* package call graph already carries must not be emitted a second time under
|
||||
* the closure-use reason. Failures are supplemental diagnostics and do not
|
||||
* discard facts or the ordinary call graph. The first failure stops the
|
||||
* supplemental scan so a broken or slow query cannot multiply the package
|
||||
* timeout by every function.
|
||||
*/
|
||||
export async function collectClosureCaptureCalls(
|
||||
client: MoveFlowClient,
|
||||
packageRoot: string,
|
||||
facts: MoveFactsMap,
|
||||
callGraph: CallGraphMap,
|
||||
limit: number,
|
||||
): Promise<ClosureCaptureResult> {
|
||||
const candidates: string[] = [];
|
||||
for (const [moduleQualified, moduleFacts] of Object.entries(facts)) {
|
||||
for (const fn of moduleFacts.functions ?? []) {
|
||||
if (fn.isLambdaLifted || fn.isNative) continue;
|
||||
candidates.push(`${moduleQualified}::${fn.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const calls: CallGraphMap = {};
|
||||
const { failure } = await mapWithConcurrency(
|
||||
candidates,
|
||||
limit,
|
||||
async (functionQualified) => {
|
||||
const usage = await client.functionUsage(packageRoot, moveShortSymbol(functionQualified));
|
||||
const known = new Set([...usage.called, ...(callGraph[functionQualified] ?? [])]);
|
||||
const captured = usage.used.filter((t) => !known.has(t));
|
||||
if (captured.length > 0) calls[functionQualified] = [...new Set(captured)];
|
||||
},
|
||||
{ failFast: true },
|
||||
);
|
||||
|
||||
const failures: FunctionUsageFailure[] = failure
|
||||
? [
|
||||
{
|
||||
functionQualified: String(failure.item),
|
||||
message: failure.error instanceof Error ? failure.error.message : String(failure.error),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return { calls, failures };
|
||||
}
|
||||
|
|
@ -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)}`,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import { spawn, execFileSync, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { createInterface } from 'node:readline';
|
||||
import type { MoveFactsMap, CallGraphMap } from './compiler-facts.js';
|
||||
import type { MoveFactsMap, CallGraphMap, MoveFunctionUsage } from './compiler-facts.js';
|
||||
import { isCompatibleMoveFlowVersion } from './release.js';
|
||||
|
||||
interface JsonRpcResponse {
|
||||
|
|
@ -41,6 +41,15 @@ function isMcpListToolResult(v: unknown): v is McpListToolResult {
|
|||
return typeof v === 'object' && v !== null;
|
||||
}
|
||||
|
||||
const isStringArray = (value: unknown): value is string[] =>
|
||||
Array.isArray(value) && value.every((item) => typeof item === 'string');
|
||||
|
||||
function isMoveFunctionUsage(value: unknown): value is MoveFunctionUsage {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const usage = value as Record<string, unknown>;
|
||||
return isStringArray(usage.called) && isStringArray(usage.used);
|
||||
}
|
||||
|
||||
/** JSON-RPC `invalid_params` - move-flow uses it for caller/input errors
|
||||
* (and some builds route package build failures through it). */
|
||||
const JSON_RPC_INVALID_PARAMS = -32602;
|
||||
|
|
@ -74,6 +83,32 @@ export class MoveFlowToolCallError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A move-flow tool call that exceeded its time budget. The client kills and
|
||||
* respawns the server either way, but callers must not blindly retry: the
|
||||
* budget itself is the problem (raise GITNEXUS_MOVE_FLOW_TIMEOUT_MS), unlike a
|
||||
* transport fault where a fresh process can succeed.
|
||||
*/
|
||||
export class MoveFlowTimeoutError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'MoveFlowTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The move-flow process died or its pipe broke while requests were in flight.
|
||||
* The request layer retries these once — every query GitNexus issues is an
|
||||
* idempotent read, and the process respawns on the next attempt. Everything
|
||||
* else (build errors, timeouts, spawn failures, shutdown) is final.
|
||||
*/
|
||||
export class MoveFlowTransportError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'MoveFlowTransportError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The move-flow surface GitNexus consumes. Defined here (not in the ingest
|
||||
* phase) so the client owns its own contract and the ingest phase depends on
|
||||
|
|
@ -84,6 +119,8 @@ export interface MoveFlowClient {
|
|||
facts(packagePath: string): Promise<MoveFactsMap>;
|
||||
/** Function-level call graph (caller qualified name → callee qualified names). */
|
||||
callGraph(packagePath: string): Promise<CallGraphMap>;
|
||||
/** Direct/transitive calls and closure captures for one function. */
|
||||
functionUsage(packagePath: string, functionName: string): Promise<MoveFunctionUsage>;
|
||||
/**
|
||||
* Build status probe (move_package_status): does the package compile, and
|
||||
* what did the compiler say. Older move-flow builds do not expose the tool -
|
||||
|
|
@ -110,6 +147,8 @@ export interface MovePackageStatus {
|
|||
export interface MoveFlowCapabilities {
|
||||
/** `facts` query available (rich, compiler-sourced per-module facts). */
|
||||
hasFactsQuery: boolean;
|
||||
/** `function_usage` query available (closure captures included in `used`). */
|
||||
hasFunctionUsageQuery: boolean;
|
||||
/** `move_package_status` tool available (build status + diagnostics). */
|
||||
hasStatusTool: boolean;
|
||||
}
|
||||
|
|
@ -143,22 +182,28 @@ export function detectMoveFlowCapabilities(
|
|||
if (t.name === 'move_package_query') querySchema = t.inputSchema;
|
||||
}
|
||||
}
|
||||
const hasFactsQuery = names.has('move_package_facts') || schemaMentionsFactsQuery(querySchema);
|
||||
return { hasFactsQuery, hasStatusTool: names.has('move_package_status') };
|
||||
const hasFactsQuery =
|
||||
names.has('move_package_facts') || schemaMentionsQuery(querySchema, 'facts');
|
||||
const hasFunctionUsageQuery = schemaMentionsQuery(querySchema, 'function_usage');
|
||||
return {
|
||||
hasFactsQuery,
|
||||
hasFunctionUsageQuery,
|
||||
hasStatusTool: names.has('move_package_status'),
|
||||
};
|
||||
}
|
||||
|
||||
/** True if the `move_package_query` inputSchema declares a `"facts"` query const. */
|
||||
function schemaMentionsFactsQuery(schema: unknown): boolean {
|
||||
/** True if `move_package_query` declares the requested query const/enum item. */
|
||||
function schemaMentionsQuery(schema: unknown, query: string): boolean {
|
||||
if (!schema || typeof schema !== 'object') return false;
|
||||
// Walk the JSON-schema object looking for a `const: "facts"` or
|
||||
// `enum: [... "facts" ...]` anywhere under the QueryType definition.
|
||||
// Walk the JSON-schema object looking for the query in either a `const` or
|
||||
// an `enum` anywhere under the QueryType definition.
|
||||
const stack: unknown[] = [schema];
|
||||
while (stack.length) {
|
||||
const node = stack.pop();
|
||||
if (!node || typeof node !== 'object') continue;
|
||||
const obj = node as Record<string, unknown>;
|
||||
if (obj.const === 'facts') return true;
|
||||
if (Array.isArray(obj.enum) && obj.enum.includes('facts')) return true;
|
||||
if (obj.const === query) return true;
|
||||
if (Array.isArray(obj.enum) && obj.enum.includes(query)) return true;
|
||||
for (const v of Object.values(obj)) {
|
||||
if (v && typeof v === 'object') stack.push(v);
|
||||
}
|
||||
|
|
@ -180,6 +225,9 @@ export class MoveFlowMcpClient implements MoveFlowClient {
|
|||
private initialized = false;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
private capsPromise: Promise<MoveFlowCapabilities> | null = null;
|
||||
/** Set by shutdown(); an in-flight transport retry must not respawn a child
|
||||
* after the owner has released the client (it would leak the process). */
|
||||
private closed = false;
|
||||
private binaryPath: string;
|
||||
private stderrLines: string[] = [];
|
||||
private static readonly MAX_STDERR = 20;
|
||||
|
|
@ -218,6 +266,7 @@ export class MoveFlowMcpClient implements MoveFlowClient {
|
|||
}
|
||||
|
||||
private async ensureStarted(): Promise<void> {
|
||||
if (this.closed) throw new Error('move-flow client is shut down');
|
||||
if (this.initialized) return;
|
||||
if (this.initPromise) return this.initPromise;
|
||||
const start = this._start().catch((err) => {
|
||||
|
|
@ -285,19 +334,19 @@ export class MoveFlowMcpClient implements MoveFlowClient {
|
|||
if (this.stderrLines.length > MoveFlowMcpClient.MAX_STDERR) {
|
||||
this.stderrLines.shift();
|
||||
}
|
||||
const wrapped = new Error(`move-flow stdin failed: ${err.message}${this.stderrContext()}`);
|
||||
if (!initSettled) failInitialization(wrapped);
|
||||
else this.failProcess(proc, wrapped);
|
||||
const message = `move-flow stdin failed: ${err.message}${this.stderrContext()}`;
|
||||
// Transport-typed only after init: a broken pipe during startup is a
|
||||
// launch failure, not a retryable mid-flight fault.
|
||||
if (!initSettled) failInitialization(new Error(message));
|
||||
else this.failProcess(proc, new MoveFlowTransportError(message));
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
const wrapped = new Error(
|
||||
`Failed to spawn move-flow: ${err.message}${this.stderrContext()}`,
|
||||
);
|
||||
const message = `Failed to spawn move-flow: ${err.message}${this.stderrContext()}`;
|
||||
if (!initSettled) {
|
||||
failInitialization(wrapped);
|
||||
failInitialization(new Error(message));
|
||||
} else {
|
||||
this.failProcess(proc, wrapped);
|
||||
this.failProcess(proc, new MoveFlowTransportError(message));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -312,7 +361,9 @@ export class MoveFlowMcpClient implements MoveFlowClient {
|
|||
}
|
||||
this.failProcess(
|
||||
proc,
|
||||
new Error(`move-flow exited unexpectedly (code ${code})${this.stderrContext()}`),
|
||||
new MoveFlowTransportError(
|
||||
`move-flow exited unexpectedly (code ${code})${this.stderrContext()}`,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
|
@ -391,11 +442,36 @@ export class MoveFlowMcpClient implements MoveFlowClient {
|
|||
this.proc.stdin.write(JSON.stringify(msg) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a request with one optional retry after a mid-flight transport fault:
|
||||
* failProcess has already reset the client state when the fault surfaces,
|
||||
* so the second attempt respawns the server. Deterministic failures (spawn
|
||||
* errors, timeouts, tool errors) propagate on the first attempt.
|
||||
*
|
||||
* Pass `{ retryOnTransport: false }` to suppress the retry — callers that
|
||||
* run under bounded concurrency (e.g. functionUsage) must not trigger a
|
||||
* retry/respawn storm when the transport faults.
|
||||
*/
|
||||
private async request(
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs: number,
|
||||
timeoutMessage: string,
|
||||
timeoutError: Error,
|
||||
{ retryOnTransport = true }: { retryOnTransport?: boolean } = {},
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
return await this.requestOnce(method, params, timeoutMs, timeoutError);
|
||||
} catch (err) {
|
||||
if (!retryOnTransport || !(err instanceof MoveFlowTransportError)) throw err;
|
||||
return this.requestOnce(method, params, timeoutMs, timeoutError);
|
||||
}
|
||||
}
|
||||
|
||||
private async requestOnce(
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs: number,
|
||||
timeoutError: Error,
|
||||
): Promise<unknown> {
|
||||
await this.ensureStarted();
|
||||
const proc = this.proc;
|
||||
|
|
@ -404,7 +480,16 @@ export class MoveFlowMcpClient implements MoveFlowClient {
|
|||
return new Promise<unknown>((resolve, reject) => {
|
||||
const id = ++this.requestId;
|
||||
const timeout = setTimeout(() => {
|
||||
this.failProcess(proc, new Error(timeoutMessage));
|
||||
const timedOut = this.pending.get(id);
|
||||
if (!timedOut) return;
|
||||
this.pending.delete(id);
|
||||
timedOut.reject(timeoutError);
|
||||
this.failProcess(
|
||||
proc,
|
||||
new MoveFlowTransportError(
|
||||
`move-flow process restarted after another request timed out: ${timeoutError.message}`,
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, {
|
||||
resolve: (result) => {
|
||||
|
|
@ -426,14 +511,21 @@ export class MoveFlowMcpClient implements MoveFlowClient {
|
|||
});
|
||||
}
|
||||
|
||||
private async callTool(toolName: string, args: Record<string, unknown>): Promise<unknown> {
|
||||
private async callTool(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
opts?: { retryOnTransport?: boolean },
|
||||
): Promise<unknown> {
|
||||
const timeoutMs = resolveMoveFlowToolTimeoutMs();
|
||||
const result = await this.request(
|
||||
'tools/call',
|
||||
{ name: toolName, arguments: args },
|
||||
timeoutMs,
|
||||
`move-flow '${toolName}' timed out after ${timeoutMs}ms ` +
|
||||
'(raise GITNEXUS_MOVE_FLOW_TIMEOUT_MS for large packages)',
|
||||
new MoveFlowTimeoutError(
|
||||
`move-flow '${toolName}' timed out after ${timeoutMs}ms ` +
|
||||
'(raise GITNEXUS_MOVE_FLOW_TIMEOUT_MS for large packages)',
|
||||
),
|
||||
opts,
|
||||
);
|
||||
if (!isMcpCallToolResult(result)) return result;
|
||||
if (result.isError === true) {
|
||||
|
|
@ -465,6 +557,24 @@ export class MoveFlowMcpClient implements MoveFlowClient {
|
|||
})) as MoveFactsMap;
|
||||
}
|
||||
|
||||
async functionUsage(packagePath: string, functionName: string): Promise<MoveFunctionUsage> {
|
||||
const usage = await this.callTool(
|
||||
'move_package_query',
|
||||
{
|
||||
package_path: packagePath,
|
||||
query: 'function_usage',
|
||||
function: functionName,
|
||||
},
|
||||
{ retryOnTransport: false },
|
||||
);
|
||||
if (!isMoveFunctionUsage(usage)) {
|
||||
throw new MoveFlowToolCallError(
|
||||
`move-flow returned malformed function_usage for '${functionName}'`,
|
||||
);
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
async packageStatus(packagePath: string): Promise<MovePackageStatus> {
|
||||
try {
|
||||
const result = await this.callTool('move_package_status', { package_path: packagePath });
|
||||
|
|
@ -481,7 +591,12 @@ export class MoveFlowMcpClient implements MoveFlowClient {
|
|||
|
||||
/** Raw JSON-RPC request (non-`tools/call`), e.g. `tools/list`. */
|
||||
private async rpcRequest(method: string, params?: unknown): Promise<unknown> {
|
||||
return this.request(method, params, 30_000, `move-flow '${method}' timed out after 30s`);
|
||||
return this.request(
|
||||
method,
|
||||
params,
|
||||
30_000,
|
||||
new MoveFlowTimeoutError(`move-flow '${method}' timed out after 30s`),
|
||||
);
|
||||
}
|
||||
|
||||
async capabilities(): Promise<MoveFlowCapabilities> {
|
||||
|
|
@ -498,6 +613,7 @@ export class MoveFlowMcpClient implements MoveFlowClient {
|
|||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.closed = true;
|
||||
const proc = this.proc;
|
||||
if (proc) {
|
||||
proc.stdin?.end();
|
||||
|
|
|
|||
|
|
@ -29,28 +29,18 @@ import type {
|
|||
import { getPhaseOutput } from '../ingestion/pipeline-phases/types.js';
|
||||
import type { StructureOutput } from '../ingestion/pipeline-phases/structure.js';
|
||||
import type { StandaloneIngestOutput } from '../ingestion/pipeline-phases/standalone-ingest.js';
|
||||
import type { KnowledgeGraph } from '../graph/types.js';
|
||||
import { MOVE_EDGE_REASON, moveRepoRelativePath } from './constants.js';
|
||||
import { moveRepoRelativePath } from './constants.js';
|
||||
import {
|
||||
MoveFlowTimeoutError,
|
||||
MoveFlowToolCallError,
|
||||
type MoveFlowClient,
|
||||
type MovePackageStatus,
|
||||
} from './mcp-client.js';
|
||||
import {
|
||||
buildLocalNameIndex,
|
||||
mapFactsToGraph,
|
||||
resolveFriendEdges,
|
||||
resolveLambdaHostEdges,
|
||||
resolveResourceEdges,
|
||||
resolveTypeRefEdges,
|
||||
type MoveFactsMapResult,
|
||||
type PendingFriend,
|
||||
type PendingLambdaHost,
|
||||
type PendingResource,
|
||||
type PendingTypeRef,
|
||||
} from './facts-mapper.js';
|
||||
import { mapFactsToGraph, type MoveFactsMapResult } from './facts-mapper.js';
|
||||
import type { CallGraphMap, MoveFactsMap } from './compiler-facts.js';
|
||||
import { moveModuleNodeId, moveModuleQualifiedName, moveRelId } from './symbol-id.js';
|
||||
import { collectClosureCaptureCalls, type FunctionUsageFailure } from './function-usage.js';
|
||||
import { linkMoveIngestGraph, type MoveLinkView } from './move-linker.js';
|
||||
import type { DroppedRef, PendingRef } from './refs.js';
|
||||
import {
|
||||
buildFailedIssue,
|
||||
cliWarningsFromIssues,
|
||||
|
|
@ -63,6 +53,11 @@ import {
|
|||
} from './consistency.js';
|
||||
import { createMoveEntryPointEdges } from './entry-points.js';
|
||||
|
||||
function resolveMoveConcurrency(): number {
|
||||
const n = Number(process.env.GITNEXUS_MOVE_FLOW_CONCURRENCY);
|
||||
return Number.isSafeInteger(n) && n > 0 ? n : 4;
|
||||
}
|
||||
|
||||
// ── Phase output ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface MoveIngestOutput extends StandaloneIngestOutput {
|
||||
|
|
@ -80,8 +75,10 @@ export interface MoveIngestOutput extends StandaloneIngestOutput {
|
|||
filePackageMap: ReadonlyMap<string, string>;
|
||||
/** Absolute package root → compiler call graph for that package. */
|
||||
callGraphByPackage: ReadonlyMap<string, CallGraphMap>;
|
||||
/** Resource references dropped during global resolution. */
|
||||
droppedResourceRefs?: { fnNodeId: string; target: string }[];
|
||||
/** All refs dropped during global resolution (resource, type, friend, lambda-host). */
|
||||
droppedRefs: DroppedRef[];
|
||||
/** Supplemental function-usage queries that could not be completed. */
|
||||
functionUsageFailures: FunctionUsageFailure[];
|
||||
/** Non-fatal consistency issues found after Move ingestion. */
|
||||
consistencyIssues: MoveConsistencyIssue[];
|
||||
/** Operator-actionable warnings for the persistent CLI summary (skipped or
|
||||
|
|
@ -90,56 +87,49 @@ export interface MoveIngestOutput extends StandaloneIngestOutput {
|
|||
}
|
||||
|
||||
/** Mutable accumulator shared while ingesting every package. */
|
||||
interface MoveIngestState {
|
||||
ingestedFiles: Set<string>;
|
||||
moduleFileMap: Map<string, string>;
|
||||
functionNodeMap: Map<string, string>;
|
||||
structNodeMap: Map<string, string>;
|
||||
modulePackageMap: Map<string, string>;
|
||||
filePackageMap: Map<string, string>;
|
||||
callGraphByPackage: Map<string, CallGraphMap>;
|
||||
pendingResource: PendingResource[];
|
||||
pendingFriends: PendingFriend[];
|
||||
pendingTypeRef: PendingTypeRef[];
|
||||
pendingLambdaHosts: PendingLambdaHost[];
|
||||
droppedResourceRefs: { fnNodeId: string; target: string }[];
|
||||
}
|
||||
class MoveIngestAccumulator implements MoveLinkView {
|
||||
readonly moduleFileMap = new Map<string, string>();
|
||||
readonly functionNodeMap = new Map<string, string>();
|
||||
readonly structNodeMap = new Map<string, string>();
|
||||
readonly modulePackageMap = new Map<string, string>();
|
||||
readonly filePackageMap = new Map<string, string>();
|
||||
readonly callGraphByPackage = new Map<string, CallGraphMap>();
|
||||
readonly closureCallsByPackage = new Map<string, CallGraphMap>();
|
||||
readonly pendingRefs: PendingRef[] = [];
|
||||
readonly droppedRefs: DroppedRef[] = [];
|
||||
readonly ingestedFiles = new Set<string>();
|
||||
readonly functionUsageFailures: FunctionUsageFailure[] = [];
|
||||
|
||||
function createState(): MoveIngestState {
|
||||
return {
|
||||
ingestedFiles: new Set(),
|
||||
moduleFileMap: new Map(),
|
||||
functionNodeMap: new Map(),
|
||||
structNodeMap: new Map(),
|
||||
modulePackageMap: new Map(),
|
||||
filePackageMap: new Map(),
|
||||
callGraphByPackage: new Map(),
|
||||
pendingResource: [],
|
||||
pendingFriends: [],
|
||||
pendingTypeRef: [],
|
||||
pendingLambdaHosts: [],
|
||||
droppedResourceRefs: [],
|
||||
};
|
||||
}
|
||||
mergePackage(mapped: MoveFactsMapResult, pkgRoot: string): void {
|
||||
for (const [qn, file] of mapped.moduleFileMap) {
|
||||
this.moduleFileMap.set(qn, file);
|
||||
this.modulePackageMap.set(qn, pkgRoot);
|
||||
this.filePackageMap.set(file, pkgRoot);
|
||||
}
|
||||
for (const [qn, id] of mapped.functionNodeMap) this.functionNodeMap.set(qn, id);
|
||||
for (const [qn, id] of mapped.structNodeMap) this.structNodeMap.set(qn, id);
|
||||
this.pendingRefs.push(...mapped.pendingRefs);
|
||||
}
|
||||
|
||||
function toOutput(
|
||||
state: MoveIngestState,
|
||||
packageRoots: string[],
|
||||
consistencyIssues: MoveConsistencyIssue[] = [],
|
||||
): MoveIngestOutput {
|
||||
return {
|
||||
ingestedFiles: state.ingestedFiles,
|
||||
packageRoots,
|
||||
moduleFileMap: state.moduleFileMap,
|
||||
functionNodeMap: state.functionNodeMap,
|
||||
structNodeMap: state.structNodeMap,
|
||||
modulePackageMap: state.modulePackageMap,
|
||||
filePackageMap: state.filePackageMap,
|
||||
callGraphByPackage: state.callGraphByPackage,
|
||||
droppedResourceRefs: state.droppedResourceRefs,
|
||||
consistencyIssues,
|
||||
ingestWarnings: cliWarningsFromIssues(consistencyIssues),
|
||||
};
|
||||
toOutput(
|
||||
packageRoots: string[],
|
||||
consistencyIssues: MoveConsistencyIssue[] = [],
|
||||
): MoveIngestOutput {
|
||||
return {
|
||||
ingestedFiles: this.ingestedFiles,
|
||||
packageRoots,
|
||||
moduleFileMap: this.moduleFileMap,
|
||||
functionNodeMap: this.functionNodeMap,
|
||||
structNodeMap: this.structNodeMap,
|
||||
modulePackageMap: this.modulePackageMap,
|
||||
filePackageMap: this.filePackageMap,
|
||||
callGraphByPackage: this.callGraphByPackage,
|
||||
droppedRefs: this.droppedRefs,
|
||||
functionUsageFailures: this.functionUsageFailures,
|
||||
consistencyIssues,
|
||||
ingestWarnings: cliWarningsFromIssues(consistencyIssues),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** GITNEXUS_MOVE_STRICT=1|true restores the historical fatal-on-build-failure
|
||||
|
|
@ -180,28 +170,6 @@ async function findPlaceholderAddresses(pkgRoot: string): Promise<string[]> {
|
|||
return placeholders;
|
||||
}
|
||||
|
||||
/** Add a mapped package's nodes/edges to the graph and merge its identity maps. */
|
||||
function applyMapped(
|
||||
graph: KnowledgeGraph,
|
||||
mapped: MoveFactsMapResult,
|
||||
pkgRoot: string,
|
||||
state: MoveIngestState,
|
||||
): void {
|
||||
for (const node of mapped.nodes) graph.addNode(node);
|
||||
for (const rel of mapped.edges) graph.addRelationship(rel);
|
||||
for (const [qn, file] of mapped.moduleFileMap) {
|
||||
state.moduleFileMap.set(qn, file);
|
||||
state.modulePackageMap.set(qn, pkgRoot);
|
||||
state.filePackageMap.set(file, pkgRoot);
|
||||
}
|
||||
for (const [qn, id] of mapped.functionNodeMap) state.functionNodeMap.set(qn, id);
|
||||
for (const [qn, id] of mapped.structNodeMap) state.structNodeMap.set(qn, id);
|
||||
state.pendingResource.push(...mapped.pendingResource);
|
||||
state.pendingFriends.push(...mapped.pendingFriends);
|
||||
state.pendingTypeRef.push(...mapped.pendingTypeRef);
|
||||
state.pendingLambdaHosts.push(...mapped.pendingLambdaHosts);
|
||||
}
|
||||
|
||||
export function createMoveIngestPhase(
|
||||
client: MoveFlowClient | null,
|
||||
): PipelinePhase<MoveIngestOutput> {
|
||||
|
|
@ -224,10 +192,10 @@ export function createMoveIngestPhase(
|
|||
),
|
||||
].sort();
|
||||
if (!client || packageRoots.length === 0) {
|
||||
return toOutput(createState(), packageRoots);
|
||||
return new MoveIngestAccumulator().toOutput(packageRoots);
|
||||
}
|
||||
|
||||
const { hasFactsQuery, hasStatusTool } = await client.capabilities();
|
||||
const { hasFactsQuery, hasFunctionUsageQuery, hasStatusTool } = await client.capabilities();
|
||||
if (!hasFactsQuery) {
|
||||
// userActionable: rendered as a one-liner without a stack — the fix is
|
||||
// an operator action (upgrade move-flow), not a code bug.
|
||||
|
|
@ -238,7 +206,8 @@ export function createMoveIngestPhase(
|
|||
{ userActionable: true },
|
||||
);
|
||||
}
|
||||
const state = createState();
|
||||
const acc = new MoveIngestAccumulator();
|
||||
let functionUsageEnabled = hasFunctionUsageQuery;
|
||||
|
||||
// Group scanned .move files by their (innermost) owning package. Files
|
||||
// are marked as ingested per package only AFTER its facts arrive, so a
|
||||
|
|
@ -261,12 +230,11 @@ export function createMoveIngestPhase(
|
|||
moveFilesByPackage.set(owner, files);
|
||||
}
|
||||
|
||||
// Pass 1: per-package nodes/edges (all packages first, so cross-package
|
||||
// CALLS in Pass 2 can resolve callees in later packages).
|
||||
const emptyFactsPackages: EmptyFactsPackage[] = [];
|
||||
const packageIssues: MoveConsistencyIssue[] = [];
|
||||
const strictMove = isStrictMove();
|
||||
|
||||
// Pass 1: per-package nodes/edges (all packages first, so cross-package
|
||||
// CALLS in Pass 2 can resolve callees in later packages).
|
||||
for (const pkgRoot of packageRoots) {
|
||||
ctx.onProgress({
|
||||
phase: 'moveIngest',
|
||||
|
|
@ -290,22 +258,17 @@ export function createMoveIngestPhase(
|
|||
let callGraphData: CallGraphMap;
|
||||
let factsMap: MoveFactsMap;
|
||||
try {
|
||||
callGraphData = await client.callGraph(pkgRoot);
|
||||
factsMap = await client.facts(pkgRoot);
|
||||
[callGraphData, factsMap] = await Promise.all([
|
||||
client.callGraph(pkgRoot),
|
||||
client.facts(pkgRoot),
|
||||
]);
|
||||
} catch (err) {
|
||||
if (err instanceof MoveFlowToolCallError) {
|
||||
// A Move package that does not build (bad manifest, missing
|
||||
// dependency, unresolved address) is an operator problem, not a
|
||||
// code bug. Default: skip the package (its files stay un-ingested,
|
||||
// like the empty-facts path) and surface a persistent warning —
|
||||
// one broken auxiliary package must not abort the whole analyze.
|
||||
if (strictMove) {
|
||||
// userActionable: rendered as a one-liner without a stack.
|
||||
throw Object.assign(
|
||||
new Error(`move-flow could not build Move package ${pkgRoot}: ${err.message}`),
|
||||
{ userActionable: true },
|
||||
);
|
||||
}
|
||||
// A Move package that does not build (bad manifest, missing
|
||||
// dependency, unresolved address) is an operator problem, not a code
|
||||
// bug. Default: skip the package (its files stay un-ingested, like the
|
||||
// empty-facts path) and surface a persistent warning — one broken
|
||||
// auxiliary package must not abort the whole analyze.
|
||||
if (err instanceof MoveFlowToolCallError && !strictMove) {
|
||||
packageIssues.push(
|
||||
buildFailedIssue({
|
||||
pkgRoot,
|
||||
|
|
@ -321,7 +284,10 @@ export function createMoveIngestPhase(
|
|||
});
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
// Strict-mode build failure, timeout (wedged build), or a transport
|
||||
// fault that already used its retry: render as the operator one-liner
|
||||
// (build/timeout) or propagate as the crash it is.
|
||||
throw toPackageQueryError(err, pkgRoot);
|
||||
}
|
||||
|
||||
if (Object.keys(factsMap).length === 0 && pkgMoveFiles.length > 0) {
|
||||
|
|
@ -340,10 +306,24 @@ export function createMoveIngestPhase(
|
|||
}
|
||||
// Only past the gate: a skipped package must have zero footprint in
|
||||
// Pass 2 linking and consistency validation.
|
||||
state.callGraphByPackage.set(pkgRoot, callGraphData);
|
||||
for (const rel of pkgMoveFiles) state.ingestedFiles.add(rel);
|
||||
|
||||
applyMapped(ctx.graph, mapFactsToGraph(factsMap, pkgRoot, ctx.repoPath), pkgRoot, state);
|
||||
acc.callGraphByPackage.set(pkgRoot, callGraphData);
|
||||
if (functionUsageEnabled) {
|
||||
const closureCapture = await collectClosureCaptureCalls(
|
||||
client,
|
||||
pkgRoot,
|
||||
factsMap,
|
||||
callGraphData,
|
||||
resolveMoveConcurrency(),
|
||||
);
|
||||
acc.closureCallsByPackage.set(pkgRoot, closureCapture.calls);
|
||||
acc.functionUsageFailures.push(...closureCapture.failures);
|
||||
if (closureCapture.failures.length > 0) functionUsageEnabled = false;
|
||||
}
|
||||
for (const rel of pkgMoveFiles) acc.ingestedFiles.add(rel);
|
||||
const mapped = mapFactsToGraph(factsMap, pkgRoot, ctx.repoPath);
|
||||
for (const node of mapped.nodes) ctx.graph.addNode(node);
|
||||
for (const rel of mapped.edges) ctx.graph.addRelationship(rel);
|
||||
acc.mergePackage(mapped, pkgRoot);
|
||||
|
||||
// Facts arrived, but move-flow serves structurally complete facts even
|
||||
// for builds with compiler errors — and such builds silently lose the
|
||||
|
|
@ -356,13 +336,9 @@ export function createMoveIngestPhase(
|
|||
}
|
||||
|
||||
// Pass 2+: link edges that need the full cross-package node index.
|
||||
linkCallEdges(ctx.graph, state);
|
||||
linkLambdaHostEdges(ctx.graph, state);
|
||||
linkResourceAndFriendEdges(ctx.graph, state);
|
||||
linkFileImports(ctx.graph, state);
|
||||
linkFileModuleContains(ctx.graph, state);
|
||||
linkMoveIngestGraph(ctx.graph, acc);
|
||||
|
||||
const output = toOutput(state, packageRoots);
|
||||
const output = acc.toOutput(packageRoots);
|
||||
createMoveEntryPointEdges(ctx.graph, output);
|
||||
|
||||
const consistencyIssues: MoveConsistencyIssue[] = [
|
||||
|
|
@ -380,6 +356,32 @@ export function createMoveIngestPhase(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a package-query failure to its user-facing form. Build/input errors and
|
||||
* timeouts are operator problems rendered as one-liners; transport faults
|
||||
* have already used up the client's single retry, so anything else propagates
|
||||
* as the crash it is.
|
||||
*/
|
||||
function toPackageQueryError(err: unknown, pkgRoot: string): Error {
|
||||
if (err instanceof MoveFlowToolCallError) {
|
||||
// userActionable: rendered as a one-liner without a stack - a Move
|
||||
// package that does not build (bad manifest, missing dependency,
|
||||
// nonexistent path) is an operator problem, not a code bug.
|
||||
return Object.assign(
|
||||
new Error(`move-flow could not build Move package ${pkgRoot}: ${err.message}`),
|
||||
{ userActionable: true },
|
||||
);
|
||||
}
|
||||
if (err instanceof MoveFlowTimeoutError) {
|
||||
// Fresh copy: the client owns this instance (it is reused across the
|
||||
// transport retry), so stamping the original would mutate state we do
|
||||
// not own. The message already names the fix (raise
|
||||
// GITNEXUS_MOVE_FLOW_TIMEOUT_MS).
|
||||
return Object.assign(new Error(err.message), { userActionable: true });
|
||||
}
|
||||
return err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
|
||||
// --- Empty-facts discrimination ---
|
||||
|
||||
/** Cross-check the build status of a sources-but-empty-facts package.
|
||||
|
|
@ -398,133 +400,6 @@ async function probePackageStatus(
|
|||
}
|
||||
}
|
||||
|
||||
/** CALLS edges from each package's call graph (resolved across all packages). */
|
||||
function linkCallEdges(graph: KnowledgeGraph, state: MoveIngestState): void {
|
||||
for (const callGraph of state.callGraphByPackage.values()) {
|
||||
for (const [callerQualified, callees] of Object.entries(callGraph)) {
|
||||
const callerId = state.functionNodeMap.get(callerQualified);
|
||||
if (!callerId) continue;
|
||||
for (const calleeQualified of callees) {
|
||||
const calleeId = state.functionNodeMap.get(calleeQualified);
|
||||
if (!calleeId) continue;
|
||||
graph.addRelationship({
|
||||
id: moveRelId(callerId, 'CALLS', calleeId, MOVE_EDGE_REASON.calls),
|
||||
sourceId: callerId,
|
||||
targetId: calleeId,
|
||||
type: 'CALLS',
|
||||
confidence: 1.0,
|
||||
reason: MOVE_EDGE_REASON.calls,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CALLS edges from each `__lambda__N__host` function back to its host. move-flow
|
||||
* synthesises lambdas as standalone functions but does NOT include the host-to-
|
||||
* lambda link in `call_graph` — without it, upstream traversal from the lambda
|
||||
* dead-ends and processes that route through a callback (e.g. market_callbacks
|
||||
* → settle_trade) lose the bridge.
|
||||
*/
|
||||
function linkLambdaHostEdges(graph: KnowledgeGraph, state: MoveIngestState): void {
|
||||
resolveLambdaHostEdges(state.pendingLambdaHosts, state.functionNodeMap, (rel) =>
|
||||
graph.addRelationship(rel),
|
||||
);
|
||||
}
|
||||
|
||||
/** Resource/friend edges from facts, resolved after all package nodes exist. */
|
||||
function linkResourceAndFriendEdges(graph: KnowledgeGraph, state: MoveIngestState): void {
|
||||
const structIdsByLocalName = buildLocalNameIndex(state.structNodeMap);
|
||||
resolveResourceEdges(
|
||||
state.pendingResource,
|
||||
state.structNodeMap,
|
||||
structIdsByLocalName,
|
||||
(rel) => graph.addRelationship(rel),
|
||||
(pending) =>
|
||||
state.droppedResourceRefs.push({ fnNodeId: pending.fnNodeId, target: pending.target }),
|
||||
(pending) =>
|
||||
state.droppedResourceRefs.push({ fnNodeId: pending.fnNodeId, target: pending.target }),
|
||||
);
|
||||
resolveFriendEdges(state.pendingFriends, state.moduleFileMap, (rel) =>
|
||||
graph.addRelationship(rel),
|
||||
);
|
||||
resolveTypeRefEdges(state.pendingTypeRef, state.structNodeMap, structIdsByLocalName, (rel) => {
|
||||
graph.addRelationship(rel);
|
||||
addUsedType(graph, rel.sourceId, rel.targetId);
|
||||
});
|
||||
}
|
||||
|
||||
function addUsedType(graph: KnowledgeGraph, functionNodeId: string, typeNodeId: string): void {
|
||||
const fnNode = graph.getNode(functionNodeId);
|
||||
const typeNode = graph.getNode(typeNodeId);
|
||||
const qualifiedName = typeNode?.properties.qualifiedName;
|
||||
// Only Function nodes carry `usedTypes` - resource-group membership USES_TYPE
|
||||
// edges have a Struct source and must not grow the property there.
|
||||
if (!fnNode || fnNode.label !== 'Function' || typeof qualifiedName !== 'string') return;
|
||||
|
||||
const current = Array.isArray(fnNode.properties.usedTypes) ? fnNode.properties.usedTypes : [];
|
||||
if (current.includes(qualifiedName)) return;
|
||||
fnNode.properties.usedTypes = [...current, qualifiedName];
|
||||
}
|
||||
|
||||
/** File→File IMPORTS derived from cross-module CALLS (deduped against existing). */
|
||||
function linkFileImports(graph: KnowledgeGraph, state: MoveIngestState): void {
|
||||
const seen = new Set<string>();
|
||||
for (const r of graph.iterRelationshipsByType('IMPORTS')) {
|
||||
if (!r.sourceId.startsWith('File:') || !r.targetId.startsWith('File:')) continue;
|
||||
seen.add(`${r.sourceId.slice(5)}\0${r.targetId.slice(5)}`);
|
||||
}
|
||||
for (const callGraph of state.callGraphByPackage.values()) {
|
||||
for (const [callerQualified, callees] of Object.entries(callGraph)) {
|
||||
const callerFile = state.moduleFileMap.get(moveModuleQualifiedName(callerQualified));
|
||||
if (!callerFile) continue;
|
||||
for (const calleeQualified of callees) {
|
||||
const calleeFile = state.moduleFileMap.get(moveModuleQualifiedName(calleeQualified));
|
||||
if (!calleeFile || calleeFile === callerFile) continue;
|
||||
const key = `${callerFile}\0${calleeFile}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const sourceFileId = `File:${callerFile}`;
|
||||
const targetFileId = `File:${calleeFile}`;
|
||||
if (graph.getNode(sourceFileId) && graph.getNode(targetFileId)) {
|
||||
graph.addRelationship({
|
||||
id: moveRelId(
|
||||
sourceFileId,
|
||||
'IMPORTS',
|
||||
targetFileId,
|
||||
MOVE_EDGE_REASON.crossModuleDependency,
|
||||
),
|
||||
sourceId: sourceFileId,
|
||||
targetId: targetFileId,
|
||||
type: 'IMPORTS',
|
||||
confidence: 0.9,
|
||||
reason: MOVE_EDGE_REASON.crossModuleDependency,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** File→Module CONTAINS where the File node exists (from the structure phase). */
|
||||
function linkFileModuleContains(graph: KnowledgeGraph, state: MoveIngestState): void {
|
||||
for (const [qn, file] of state.moduleFileMap) {
|
||||
const fileNodeId = `File:${file}`;
|
||||
const moduleNodeId = moveModuleNodeId(qn, file);
|
||||
if (graph.getNode(fileNodeId) && graph.getNode(moduleNodeId)) {
|
||||
graph.addRelationship({
|
||||
id: moveRelId(fileNodeId, 'CONTAINS', moduleNodeId, MOVE_EDGE_REASON.moduleInFile),
|
||||
sourceId: fileNodeId,
|
||||
targetId: moduleNodeId,
|
||||
type: 'CONTAINS',
|
||||
confidence: 1.0,
|
||||
reason: MOVE_EDGE_REASON.moduleInFile,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Surface consistency errors so they reach a human/log (not just the output). */
|
||||
function reportConsistencyIssues(ctx: PipelineContext, issues: MoveConsistencyIssue[]): void {
|
||||
const errors = issues.filter((i) => i.severity === 'error');
|
||||
|
|
|
|||
404
gitnexus/src/core/move/move-linker.ts
Normal file
404
gitnexus/src/core/move/move-linker.ts
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
import type { GraphRelationship } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../graph/types.js';
|
||||
import type { CallGraphMap } from './compiler-facts.js';
|
||||
import { MOVE_EDGE_REASON, MOVE_LANGUAGE } from './constants.js';
|
||||
import type { DroppedRef, PendingRef, PendingRefKind } from './refs.js';
|
||||
import {
|
||||
moveExternalTypeNodeId,
|
||||
moveFunctionNodeId,
|
||||
moveLocalName,
|
||||
moveModuleNodeId,
|
||||
moveModuleQualifiedName,
|
||||
moveRelId,
|
||||
parseMoveModuleQualifiedName,
|
||||
} from './symbol-id.js';
|
||||
|
||||
export interface MoveLinkView {
|
||||
moduleFileMap: ReadonlyMap<string, string>;
|
||||
functionNodeMap: ReadonlyMap<string, string>;
|
||||
structNodeMap: ReadonlyMap<string, string>;
|
||||
callGraphByPackage: ReadonlyMap<string, CallGraphMap>;
|
||||
closureCallsByPackage: ReadonlyMap<string, CallGraphMap>;
|
||||
pendingRefs: readonly PendingRef[];
|
||||
droppedRefs: DroppedRef[];
|
||||
}
|
||||
|
||||
export class ExternalMoveSymbols {
|
||||
private readonly modules = new Map<string, string>();
|
||||
private readonly functions = new Map<string, string>();
|
||||
private readonly types = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
private readonly graph: KnowledgeGraph,
|
||||
private readonly localModules: ReadonlyMap<string, string>,
|
||||
) {}
|
||||
|
||||
ensureModule(moduleQualified: string): string | undefined {
|
||||
if (this.localModules.has(moduleQualified)) return undefined;
|
||||
const existing = this.modules.get(moduleQualified);
|
||||
if (existing) return existing;
|
||||
|
||||
const { address, moduleName } = parseMoveModuleQualifiedName(moduleQualified);
|
||||
if (!address || !moduleName) return undefined;
|
||||
const moduleId = moveModuleNodeId(moduleQualified, '');
|
||||
this.graph.addNode({
|
||||
id: moduleId,
|
||||
label: 'Module',
|
||||
properties: {
|
||||
name: moduleName,
|
||||
filePath: '',
|
||||
language: MOVE_LANGUAGE,
|
||||
qualifiedName: moduleQualified,
|
||||
moduleQualifiedName: moduleQualified,
|
||||
moduleAddress: address,
|
||||
description: `External Move dependency module ${moduleQualified}`,
|
||||
locationFidelity: 'external',
|
||||
},
|
||||
});
|
||||
this.modules.set(moduleQualified, moduleId);
|
||||
return moduleId;
|
||||
}
|
||||
|
||||
ensureFunction(functionQualified: string): string | undefined {
|
||||
const existing = this.functions.get(functionQualified);
|
||||
if (existing) return existing;
|
||||
const moduleQualified = moveModuleQualifiedName(functionQualified);
|
||||
const moduleId = this.ensureModule(moduleQualified);
|
||||
if (!moduleId) return undefined;
|
||||
|
||||
const functionId = moveFunctionNodeId(functionQualified, '');
|
||||
this.graph.addNode({
|
||||
id: functionId,
|
||||
label: 'Function',
|
||||
properties: {
|
||||
name: moveLocalName(functionQualified),
|
||||
filePath: '',
|
||||
language: MOVE_LANGUAGE,
|
||||
qualifiedName: functionQualified,
|
||||
moduleQualifiedName: moduleQualified,
|
||||
visibility: 'external',
|
||||
visibilityModifier: 'external',
|
||||
isExported: true,
|
||||
description: `External Move dependency function ${functionQualified}`,
|
||||
locationFidelity: 'external',
|
||||
},
|
||||
});
|
||||
this.addDefinition(moduleId, functionId, MOVE_EDGE_REASON.externalDefinesFunction);
|
||||
this.functions.set(functionQualified, functionId);
|
||||
return functionId;
|
||||
}
|
||||
|
||||
ensureType(typeQualified: string): string | undefined {
|
||||
const existing = this.types.get(typeQualified);
|
||||
if (existing) return existing;
|
||||
const moduleQualified = moveModuleQualifiedName(typeQualified);
|
||||
const moduleId = this.ensureModule(moduleQualified);
|
||||
if (!moduleId) return undefined;
|
||||
|
||||
const typeId = moveExternalTypeNodeId(typeQualified);
|
||||
this.graph.addNode({
|
||||
id: typeId,
|
||||
label: 'Type',
|
||||
properties: {
|
||||
name: moveLocalName(typeQualified),
|
||||
filePath: '',
|
||||
language: MOVE_LANGUAGE,
|
||||
qualifiedName: typeQualified,
|
||||
moduleQualifiedName: moduleQualified,
|
||||
description: `External Move dependency type ${typeQualified}`,
|
||||
locationFidelity: 'external',
|
||||
},
|
||||
});
|
||||
this.addDefinition(moduleId, typeId, MOVE_EDGE_REASON.externalDefinesType);
|
||||
this.types.set(typeQualified, typeId);
|
||||
return typeId;
|
||||
}
|
||||
|
||||
private addDefinition(moduleId: string, targetId: string, reason: string): void {
|
||||
this.graph.addRelationship({
|
||||
id: moveRelId(moduleId, 'DEFINES', targetId, reason),
|
||||
sourceId: moduleId,
|
||||
targetId,
|
||||
type: 'DEFINES',
|
||||
confidence: 1.0,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip generic type arguments: `CoinStore<CoinType>` → `CoinStore`. */
|
||||
function stripTypeArgs(typeName: string): string {
|
||||
const idx = typeName.indexOf('<');
|
||||
return (idx === -1 ? typeName : typeName.slice(0, idx)).trim();
|
||||
}
|
||||
|
||||
export function buildLocalNameIndex(
|
||||
structNodeMap: ReadonlyMap<string, string>,
|
||||
): Map<string, string[]> {
|
||||
const structIdsByLocalName = new Map<string, string[]>();
|
||||
for (const [qn, id] of structNodeMap) {
|
||||
const key = moveLocalName(qn);
|
||||
const list = structIdsByLocalName.get(key);
|
||||
if (list) list.push(id);
|
||||
else structIdsByLocalName.set(key, [id]);
|
||||
}
|
||||
return structIdsByLocalName;
|
||||
}
|
||||
|
||||
export function resolveStructRef(
|
||||
localOrQualified: string,
|
||||
callerModule: string,
|
||||
structNodeMap: ReadonlyMap<string, string>,
|
||||
structIdsByLocalName: ReadonlyMap<string, readonly string[]>,
|
||||
): { targetId: string } | { unresolved: true } | { ambiguous: true } {
|
||||
const exact = structNodeMap.get(localOrQualified);
|
||||
if (exact) return { targetId: exact };
|
||||
|
||||
const base = stripTypeArgs(localOrQualified);
|
||||
const baseExact = structNodeMap.get(base);
|
||||
if (baseExact) return { targetId: baseExact };
|
||||
|
||||
// move-flow emits every resourceAccess/param/return ref fully qualified, so a
|
||||
// qualified ref that misses the exact lookup is a type outside the indexed
|
||||
// graph (e.g. a dependency-only 0x1::coin::CoinStore). Falling through to the
|
||||
// bare-name heuristic would silently mis-bind it to a same-named repo struct
|
||||
// under a different address - report unresolved instead. The heuristics below
|
||||
// remain only for unqualified inputs.
|
||||
if (base.includes('::')) return { unresolved: true };
|
||||
|
||||
const sameModule = structNodeMap.get(`${callerModule}::${base}`);
|
||||
if (sameModule) return { targetId: sameModule };
|
||||
|
||||
const matches = structIdsByLocalName.get(moveLocalName(base)) ?? [];
|
||||
if (matches.length === 1) return { targetId: matches[0] };
|
||||
return matches.length > 1 ? { ambiguous: true } : { unresolved: true };
|
||||
}
|
||||
|
||||
export interface RefIndexes {
|
||||
structNodeMap: ReadonlyMap<string, string>;
|
||||
structIdsByLocalName: ReadonlyMap<string, readonly string[]>;
|
||||
moduleFileMap: ReadonlyMap<string, string>;
|
||||
functionNodeMap: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
type Resolution = { id: string } | 'unresolved' | 'ambiguous';
|
||||
|
||||
interface RefDescriptor {
|
||||
resolve(target: string, moduleQualified: string, idx: RefIndexes): Resolution;
|
||||
externalize?(target: string, external: ExternalMoveSymbols): string | undefined;
|
||||
direction: 'known-source' | 'known-target';
|
||||
confidence?: number;
|
||||
postEdge?(graph: KnowledgeGraph, rel: GraphRelationship): void;
|
||||
}
|
||||
|
||||
const REF_DESCRIPTORS: Record<PendingRefKind, RefDescriptor> = {
|
||||
resource: {
|
||||
resolve: (t, m, idx) => {
|
||||
const r = resolveStructRef(t, m, idx.structNodeMap, idx.structIdsByLocalName);
|
||||
if ('targetId' in r) return { id: r.targetId };
|
||||
if ('ambiguous' in r) return 'ambiguous';
|
||||
return 'unresolved';
|
||||
},
|
||||
externalize: (t, ext) => ext.ensureType(t),
|
||||
direction: 'known-source',
|
||||
},
|
||||
type: {
|
||||
resolve: (t, m, idx) => {
|
||||
const r = resolveStructRef(t, m, idx.structNodeMap, idx.structIdsByLocalName);
|
||||
if ('targetId' in r) return { id: r.targetId };
|
||||
if ('ambiguous' in r) return 'ambiguous';
|
||||
return 'unresolved';
|
||||
},
|
||||
externalize: (t, ext) => ext.ensureType(t),
|
||||
direction: 'known-source',
|
||||
postEdge: recordUsedType,
|
||||
},
|
||||
friend: {
|
||||
resolve: (t, _m, idx) => {
|
||||
const file = idx.moduleFileMap.get(t);
|
||||
return file ? { id: moveModuleNodeId(t, file) } : 'unresolved';
|
||||
},
|
||||
externalize: (t, ext) => ext.ensureModule(t),
|
||||
direction: 'known-source',
|
||||
},
|
||||
'lambda-host': {
|
||||
resolve: (t, _m, idx) => {
|
||||
const id = idx.functionNodeMap.get(t);
|
||||
return id ? { id } : 'unresolved';
|
||||
},
|
||||
direction: 'known-target',
|
||||
confidence: 0.9,
|
||||
},
|
||||
};
|
||||
|
||||
/** `usedTypes` bookkeeping for USES_TYPE edges (was addTypeRelationship). */
|
||||
function recordUsedType(graph: KnowledgeGraph, rel: GraphRelationship): void {
|
||||
const source = graph.getNode(rel.sourceId);
|
||||
const target = graph.getNode(rel.targetId);
|
||||
const qualifiedName = target?.properties.qualifiedName;
|
||||
if (!source || source.label !== 'Function' || typeof qualifiedName !== 'string') return;
|
||||
const usedTypes = Array.isArray(source.properties.usedTypes) ? source.properties.usedTypes : [];
|
||||
if (!usedTypes.includes(qualifiedName))
|
||||
source.properties.usedTypes = [...usedTypes, qualifiedName];
|
||||
}
|
||||
|
||||
export function resolveRefs(
|
||||
graph: KnowledgeGraph,
|
||||
refs: readonly PendingRef[],
|
||||
indexes: RefIndexes,
|
||||
external: ExternalMoveSymbols,
|
||||
drops: DroppedRef[],
|
||||
): void {
|
||||
const seen = new Set<string>();
|
||||
for (const ref of refs) {
|
||||
const desc = REF_DESCRIPTORS[ref.kind];
|
||||
const resolved = desc.resolve(ref.target, ref.moduleQualified, indexes);
|
||||
let targetId: string | undefined;
|
||||
if (resolved === 'ambiguous') {
|
||||
drops.push({ kind: ref.kind, sourceId: ref.knownNodeId, target: ref.target });
|
||||
continue;
|
||||
}
|
||||
if (resolved === 'unresolved') {
|
||||
targetId = desc.externalize?.(ref.target, external);
|
||||
if (!targetId) {
|
||||
drops.push({ kind: ref.kind, sourceId: ref.knownNodeId, target: ref.target });
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
targetId = resolved.id;
|
||||
}
|
||||
const sourceId = desc.direction === 'known-target' ? targetId : ref.knownNodeId;
|
||||
const dstId = desc.direction === 'known-target' ? ref.knownNodeId : targetId;
|
||||
const key = `${sourceId}\0${ref.edgeType}\0${dstId}\0${ref.reason}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const rel: GraphRelationship = {
|
||||
id: moveRelId(sourceId, ref.edgeType, dstId, ref.reason),
|
||||
sourceId,
|
||||
targetId: dstId,
|
||||
type: ref.edgeType,
|
||||
confidence: desc.confidence ?? 1.0,
|
||||
reason: ref.reason,
|
||||
};
|
||||
graph.addRelationship(rel);
|
||||
desc.postEdge?.(graph, rel);
|
||||
}
|
||||
}
|
||||
|
||||
export function linkMoveIngestGraph(graph: KnowledgeGraph, state: MoveLinkView): void {
|
||||
const external = new ExternalMoveSymbols(graph, state.moduleFileMap);
|
||||
linkCallGraph(
|
||||
graph,
|
||||
state,
|
||||
external,
|
||||
state.callGraphByPackage.values(),
|
||||
MOVE_EDGE_REASON.calls,
|
||||
1.0,
|
||||
);
|
||||
linkCallGraph(
|
||||
graph,
|
||||
state,
|
||||
external,
|
||||
state.closureCallsByPackage.values(),
|
||||
MOVE_EDGE_REASON.closureUse,
|
||||
0.95,
|
||||
);
|
||||
const structIdsByLocalName = buildLocalNameIndex(state.structNodeMap);
|
||||
const indexes: RefIndexes = {
|
||||
structNodeMap: state.structNodeMap,
|
||||
structIdsByLocalName,
|
||||
moduleFileMap: state.moduleFileMap,
|
||||
functionNodeMap: state.functionNodeMap,
|
||||
};
|
||||
resolveRefs(graph, state.pendingRefs, indexes, external, state.droppedRefs);
|
||||
linkFileImports(graph, state);
|
||||
linkFileModuleContains(graph, state);
|
||||
}
|
||||
|
||||
function linkCallGraph(
|
||||
graph: KnowledgeGraph,
|
||||
state: MoveLinkView,
|
||||
external: ExternalMoveSymbols,
|
||||
callGraphs: Iterable<CallGraphMap>,
|
||||
reason: string,
|
||||
confidence: number,
|
||||
): void {
|
||||
for (const callGraph of callGraphs) {
|
||||
for (const [callerQualified, callees] of Object.entries(callGraph)) {
|
||||
const callerId = state.functionNodeMap.get(callerQualified);
|
||||
if (!callerId) continue;
|
||||
for (const calleeQualified of callees) {
|
||||
const calleeId =
|
||||
state.functionNodeMap.get(calleeQualified) ?? external.ensureFunction(calleeQualified);
|
||||
if (!calleeId) continue;
|
||||
graph.addRelationship({
|
||||
id: moveRelId(callerId, 'CALLS', calleeId, reason),
|
||||
sourceId: callerId,
|
||||
targetId: calleeId,
|
||||
type: 'CALLS',
|
||||
confidence,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function linkFileImports(graph: KnowledgeGraph, state: MoveLinkView): void {
|
||||
const seen = new Set<string>();
|
||||
for (const relationship of graph.iterRelationshipsByType('IMPORTS')) {
|
||||
if (!relationship.sourceId.startsWith('File:') || !relationship.targetId.startsWith('File:')) {
|
||||
continue;
|
||||
}
|
||||
seen.add(`${relationship.sourceId.slice(5)}\0${relationship.targetId.slice(5)}`);
|
||||
}
|
||||
|
||||
for (const callGraph of [
|
||||
...state.callGraphByPackage.values(),
|
||||
...state.closureCallsByPackage.values(),
|
||||
]) {
|
||||
for (const [callerQualified, callees] of Object.entries(callGraph)) {
|
||||
const callerFile = state.moduleFileMap.get(moveModuleQualifiedName(callerQualified));
|
||||
if (!callerFile) continue;
|
||||
for (const calleeQualified of callees) {
|
||||
const calleeFile = state.moduleFileMap.get(moveModuleQualifiedName(calleeQualified));
|
||||
if (!calleeFile || calleeFile === callerFile) continue;
|
||||
const key = `${callerFile}\0${calleeFile}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const sourceFileId = `File:${callerFile}`;
|
||||
const targetFileId = `File:${calleeFile}`;
|
||||
if (!graph.getNode(sourceFileId) || !graph.getNode(targetFileId)) continue;
|
||||
graph.addRelationship({
|
||||
id: moveRelId(
|
||||
sourceFileId,
|
||||
'IMPORTS',
|
||||
targetFileId,
|
||||
MOVE_EDGE_REASON.crossModuleDependency,
|
||||
),
|
||||
sourceId: sourceFileId,
|
||||
targetId: targetFileId,
|
||||
type: 'IMPORTS',
|
||||
confidence: 0.9,
|
||||
reason: MOVE_EDGE_REASON.crossModuleDependency,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function linkFileModuleContains(graph: KnowledgeGraph, state: MoveLinkView): void {
|
||||
for (const [moduleQualified, file] of state.moduleFileMap) {
|
||||
const fileNodeId = `File:${file}`;
|
||||
const moduleNodeId = moveModuleNodeId(moduleQualified, file);
|
||||
if (!graph.getNode(fileNodeId) || !graph.getNode(moduleNodeId)) continue;
|
||||
graph.addRelationship({
|
||||
id: moveRelId(fileNodeId, 'CONTAINS', moduleNodeId, MOVE_EDGE_REASON.moduleInFile),
|
||||
sourceId: fileNodeId,
|
||||
targetId: moduleNodeId,
|
||||
type: 'CONTAINS',
|
||||
confidence: 1.0,
|
||||
reason: MOVE_EDGE_REASON.moduleInFile,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { access, realpath } from 'node:fs/promises';
|
||||
import { constants } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { MoveCompilerIdentity } from './constants.js';
|
||||
import {
|
||||
createMoveFlowClient,
|
||||
|
|
@ -43,15 +47,70 @@ const defaultDependencies: MoveFlowProvisionDependencies = {
|
|||
install: installMoveFlow,
|
||||
};
|
||||
|
||||
const localIdentity = (
|
||||
async function resolveExecutablePath(locator: string): Promise<string | null> {
|
||||
const candidates =
|
||||
path.isAbsolute(locator) || locator.includes(path.sep)
|
||||
? [locator]
|
||||
: (process.env.PATH ?? '')
|
||||
.split(path.delimiter)
|
||||
.filter(Boolean)
|
||||
.flatMap((directory) => {
|
||||
const exact = path.join(directory, locator);
|
||||
if (process.platform !== 'win32' || path.extname(locator)) return [exact];
|
||||
const extensions = (process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';');
|
||||
return [
|
||||
exact,
|
||||
...extensions.map((extension) => path.join(directory, `${locator}${extension}`)),
|
||||
];
|
||||
});
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await access(candidate, constants.R_OK | (process.platform === 'win32' ? 0 : constants.X_OK));
|
||||
return await realpath(candidate);
|
||||
} catch {
|
||||
// Continue through PATH. A resolver mock may intentionally have no file.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function hashExecutable(absolutePath: string): Promise<string> {
|
||||
const digest = createHash('sha256');
|
||||
for await (const chunk of createReadStream(absolutePath)) digest.update(chunk as Buffer);
|
||||
return digest.digest('hex');
|
||||
}
|
||||
|
||||
const localIdentity = async (
|
||||
source: 'explicit' | 'path',
|
||||
locator: string,
|
||||
version: string,
|
||||
): MoveCompilerIdentity => ({
|
||||
version,
|
||||
source,
|
||||
fingerprint: createHash('sha256').update(`${source}\0${locator}\0${version}`).digest('hex'),
|
||||
});
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<MoveCompilerIdentity> => {
|
||||
const resolvedPath = await resolveExecutablePath(locator);
|
||||
// If the binary changes between the successful version probe and hashing,
|
||||
// make this identity unique rather than certifying an unverifiable runtime.
|
||||
const binaryDigest = resolvedPath
|
||||
? await hashExecutable(resolvedPath).catch(
|
||||
() => `unverifiable:${randomBytes(16).toString('hex')}`,
|
||||
)
|
||||
: `unverifiable:${randomBytes(16).toString('hex')}`;
|
||||
if (binaryDigest.startsWith('unverifiable:')) {
|
||||
// The random digest deliberately churns the fingerprint, and a fingerprint
|
||||
// change forces a full Move re-index — every run, until the binary is
|
||||
// readable again. Say so instead of rebuilding silently.
|
||||
onLog?.(
|
||||
`move-flow binary at '${resolvedPath ?? locator}' could not be read for ` +
|
||||
`fingerprinting; Move facts will be fully rebuilt on every run until it is readable.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
version,
|
||||
source,
|
||||
fingerprint: createHash('sha256')
|
||||
.update(`${source}\0${resolvedPath ?? locator}\0${version}\0${binaryDigest}`)
|
||||
.digest('hex'),
|
||||
};
|
||||
};
|
||||
|
||||
const verifiedRuntime = (
|
||||
binary: VerifiedMoveFlowBinary,
|
||||
|
|
@ -98,7 +157,7 @@ export async function ensureMoveFlowRuntime(
|
|||
if (resolved) {
|
||||
return {
|
||||
client: resolved.client,
|
||||
identity: localIdentity('explicit', explicitPath, resolved.version),
|
||||
identity: await localIdentity('explicit', explicitPath, resolved.version, options.onLog),
|
||||
};
|
||||
}
|
||||
options.onLog?.(
|
||||
|
|
@ -119,7 +178,7 @@ export async function ensureMoveFlowRuntime(
|
|||
// terminals/CI steps, and a fingerprint churn forces a full re-index.
|
||||
return {
|
||||
client: existing.client,
|
||||
identity: localIdentity('path', 'move-flow', existing.version),
|
||||
identity: await localIdentity('path', 'move-flow', existing.version, options.onLog),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
28
gitnexus/src/core/move/refs.ts
Normal file
28
gitnexus/src/core/move/refs.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// gitnexus/src/core/move/refs.ts
|
||||
import type { RelationshipType } from 'gitnexus-shared';
|
||||
|
||||
/** A deferred cross-package reference resolved after every package is mapped. */
|
||||
export type PendingRefKind = 'resource' | 'type' | 'friend' | 'lambda-host';
|
||||
|
||||
/**
|
||||
* A reference emitted in Pass-A whose target node is only known once the full
|
||||
* cross-package index exists. `knownNodeId` is the node we already have; for
|
||||
* every kind except `lambda-host` it is the edge SOURCE and `target` resolves to
|
||||
* the edge target. For `lambda-host` the resolved host is the source and
|
||||
* `knownNodeId` (the lambda function) is the target.
|
||||
*/
|
||||
export interface PendingRef {
|
||||
kind: PendingRefKind;
|
||||
knownNodeId: string;
|
||||
target: string;
|
||||
moduleQualified: string;
|
||||
edgeType: RelationshipType;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** A `PendingRef` that could not be resolved or externalized. */
|
||||
export interface DroppedRef {
|
||||
kind: PendingRefKind;
|
||||
sourceId: string;
|
||||
target: string;
|
||||
}
|
||||
|
|
@ -67,6 +67,11 @@ export function moveConstNodeId(constQualifiedName: string, filePath: string): s
|
|||
return `Const:${filePath}:${constQualifiedName}`;
|
||||
}
|
||||
|
||||
/** Synthetic dependency type whose declaration is outside the indexed repo. */
|
||||
export function moveExternalTypeNodeId(typeQualifiedName: string): string {
|
||||
return `Type::<external>:${typeQualifiedName}`;
|
||||
}
|
||||
|
||||
export function moveEnumVariantNodeId(
|
||||
enumQualifiedName: string,
|
||||
variantName: string,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<typeof runPipelineFromRepo>>;
|
||||
try {
|
||||
pipelineResult = await runPipelineFromRepo(
|
||||
repoPath,
|
||||
(p) => {
|
||||
const phaseLabel = PHASE_LABELS[p.phase] || p.phase;
|
||||
const scaled = Math.round(p.percent * 0.6);
|
||||
const message = p.detail
|
||||
? `${p.message || phaseLabel} (${p.detail})`
|
||||
: p.message || phaseLabel;
|
||||
progress(p.phase, scaled, message);
|
||||
},
|
||||
{
|
||||
parseCache,
|
||||
workerPoolSize: options.workerPoolSize,
|
||||
standaloneIngestPhase: createMoveIngestPhase(moveFlowClient),
|
||||
// CFG/PDG opt-in (#2081 M1). PipelineOptions.pdg fans out to the worker
|
||||
// build gate (workerData.pdg) and the scope-resolution emit gate.
|
||||
pdg: options.pdg === true,
|
||||
pdgMaxFunctionLines: options.pdgMaxFunctionLines,
|
||||
pdgMaxEdgesPerFunction: options.pdgMaxEdgesPerFunction,
|
||||
pdgMaxReachingDefEdgesPerFunction: options.pdgMaxReachingDefEdgesPerFunction,
|
||||
pdgMaxCdgEdgesPerFunction: options.pdgMaxCdgEdgesPerFunction,
|
||||
pdgMaxTaintFindingsPerFunction: options.pdgMaxTaintFindingsPerFunction,
|
||||
pdgMaxTaintHops: options.pdgMaxTaintHops,
|
||||
pdgMaxInterprocFindings: options.pdgMaxInterprocFindings,
|
||||
pdgMaxInterprocHops: options.pdgMaxInterprocHops,
|
||||
pdgMaxInterprocEdges: options.pdgMaxInterprocEdges,
|
||||
// Streaming/chunked PDG emit (#2202) — gated to full-rebuild runs
|
||||
// (force === true) so the incremental writeback never reads back an
|
||||
// offloaded BasicBlock layer. Memory-only; byte-identical output.
|
||||
streamPdgEmit: resolveStreamPdgEmit(options),
|
||||
pdgEmitChunkSize: resolvePdgEmitChunkSize(options),
|
||||
fetchWrappers: options.fetchWrappers,
|
||||
},
|
||||
const pipelineResult = await runPipelineFromRepo(
|
||||
repoPath,
|
||||
(p) => {
|
||||
const phaseLabel = PHASE_LABELS[p.phase] || p.phase;
|
||||
const scaled = Math.round(p.percent * 0.6);
|
||||
const message = p.detail
|
||||
? `${p.message || phaseLabel} (${p.detail})`
|
||||
: p.message || phaseLabel;
|
||||
progress(p.phase, scaled, message);
|
||||
},
|
||||
{
|
||||
parseCache,
|
||||
workerPoolSize: options.workerPoolSize,
|
||||
standaloneIngestPhase: createMoveIngestPhase(moveFlowClient),
|
||||
// CFG/PDG opt-in (#2081 M1). PipelineOptions.pdg fans out to the worker
|
||||
// build gate (workerData.pdg) and the scope-resolution emit gate.
|
||||
pdg: options.pdg === true,
|
||||
pdgMaxFunctionLines: options.pdgMaxFunctionLines,
|
||||
pdgMaxEdgesPerFunction: options.pdgMaxEdgesPerFunction,
|
||||
pdgMaxReachingDefEdgesPerFunction: options.pdgMaxReachingDefEdgesPerFunction,
|
||||
pdgMaxCdgEdgesPerFunction: options.pdgMaxCdgEdgesPerFunction,
|
||||
pdgMaxTaintFindingsPerFunction: options.pdgMaxTaintFindingsPerFunction,
|
||||
pdgMaxTaintHops: options.pdgMaxTaintHops,
|
||||
pdgMaxInterprocFindings: options.pdgMaxInterprocFindings,
|
||||
pdgMaxInterprocHops: options.pdgMaxInterprocHops,
|
||||
pdgMaxInterprocEdges: options.pdgMaxInterprocEdges,
|
||||
// Streaming/chunked PDG emit (#2202) — gated to full-rebuild runs
|
||||
// (force === true) so the incremental writeback never reads back an
|
||||
// offloaded BasicBlock layer. Memory-only; byte-identical output.
|
||||
streamPdgEmit: resolveStreamPdgEmit(options),
|
||||
pdgEmitChunkSize: resolvePdgEmitChunkSize(options),
|
||||
fetchWrappers: options.fetchWrappers,
|
||||
},
|
||||
).finally(() => moveFlowClient?.shutdown());
|
||||
|
||||
// Move ingest consistency digest — persisted to meta.json below so warnings
|
||||
// (dropped refs, test-only packages) survive the run instead of dying with
|
||||
// the transient progress stream.
|
||||
const moveConsistency = summarizeMoveConsistency(
|
||||
pipelineResult.standaloneIngest.consistencyIssues,
|
||||
);
|
||||
if (moveConsistency) {
|
||||
log(
|
||||
`Move ingest consistency: ${moveConsistency.errorCount} error(s), ` +
|
||||
`${moveConsistency.warningCount} warning(s) — details in meta.json (moveConsistency).`,
|
||||
);
|
||||
} finally {
|
||||
await moveFlowClient?.shutdown();
|
||||
}
|
||||
|
||||
// ── Phase 2: LadybugDB (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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,16 +4,105 @@
|
|||
* Shared by the unit (move-ingest-*) and integration (move-live) Move tests.
|
||||
*/
|
||||
import { createMoveIngestPhase, type MoveIngestOutput } from '../../src/core/move/move-ingest.js';
|
||||
import type { MoveFactsFunction } from '../../src/core/move/compiler-facts.js';
|
||||
import type { MoveFlowClient } from '../../src/core/move/mcp-client.js';
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||||
import type { KnowledgeGraph } from '../../src/core/graph/types.js';
|
||||
import type { PhaseResult } from '../../src/core/ingestion/pipeline-phases/types.js';
|
||||
|
||||
export interface MoveFlowClientCallCounts {
|
||||
facts: number;
|
||||
callGraph: number;
|
||||
functionUsage: number;
|
||||
capabilities: number;
|
||||
packageStatus: number;
|
||||
}
|
||||
|
||||
export const moveFunctionFact = (
|
||||
name: string,
|
||||
overrides: Partial<MoveFactsFunction> = {},
|
||||
): MoveFactsFunction => ({
|
||||
name,
|
||||
visibility: 'internal',
|
||||
isEntry: false,
|
||||
isInline: false,
|
||||
isNative: false,
|
||||
isView: false,
|
||||
returnTypes: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* Counting MoveFlowClient stub shared by the Move unit tests: benign defaults,
|
||||
* overridable per member. The wrapper counts every call — including calls that
|
||||
* reach an override — so tests can assert zero/at-most-once client interaction.
|
||||
*/
|
||||
export function makeMoveFlowClientStub(
|
||||
overrides: Partial<MoveFlowClient> = {},
|
||||
): MoveFlowClient & { counts: MoveFlowClientCallCounts } {
|
||||
const counts: MoveFlowClientCallCounts = {
|
||||
facts: 0,
|
||||
callGraph: 0,
|
||||
functionUsage: 0,
|
||||
capabilities: 0,
|
||||
packageStatus: 0,
|
||||
};
|
||||
const impl: MoveFlowClient = {
|
||||
facts: async () => ({}),
|
||||
callGraph: async () => ({}),
|
||||
functionUsage: async () => ({
|
||||
called: [],
|
||||
used: [],
|
||||
}),
|
||||
packageStatus: async () => ({ ok: true, diagnostics: '' }),
|
||||
capabilities: async () => ({
|
||||
hasFactsQuery: true,
|
||||
hasFunctionUsageQuery: true,
|
||||
hasStatusTool: true,
|
||||
}),
|
||||
shutdown: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
counts,
|
||||
facts: (pkg) => {
|
||||
counts.facts += 1;
|
||||
return impl.facts(pkg);
|
||||
},
|
||||
callGraph: (pkg) => {
|
||||
counts.callGraph += 1;
|
||||
return impl.callGraph(pkg);
|
||||
},
|
||||
functionUsage: (pkg, fn) => {
|
||||
counts.functionUsage += 1;
|
||||
return impl.functionUsage(pkg, fn);
|
||||
},
|
||||
packageStatus: (pkg) => {
|
||||
counts.packageStatus += 1;
|
||||
return impl.packageStatus(pkg);
|
||||
},
|
||||
capabilities: () => {
|
||||
counts.capabilities += 1;
|
||||
return impl.capabilities();
|
||||
},
|
||||
shutdown: () => impl.shutdown(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Run the moveIngest phase over `filePaths` (repo-relative) under `repoPath`. */
|
||||
export async function runMoveIngestPhase(
|
||||
client: MoveFlowClient | null,
|
||||
repoPath: string,
|
||||
filePaths: readonly string[],
|
||||
): Promise<MoveIngestOutput> {
|
||||
return (await runMoveIngestPhaseWithGraph(client, repoPath, filePaths)).output;
|
||||
}
|
||||
|
||||
export async function runMoveIngestPhaseWithGraph(
|
||||
client: MoveFlowClient | null,
|
||||
repoPath: string,
|
||||
filePaths: readonly string[],
|
||||
): Promise<{ output: MoveIngestOutput; graph: KnowledgeGraph }> {
|
||||
const deps = new Map<string, PhaseResult<unknown>>([
|
||||
[
|
||||
'structure',
|
||||
|
|
@ -29,13 +118,15 @@ export async function runMoveIngestPhase(
|
|||
},
|
||||
],
|
||||
]);
|
||||
return createMoveIngestPhase(client).execute(
|
||||
const graph = createKnowledgeGraph();
|
||||
const output = await createMoveIngestPhase(client).execute(
|
||||
{
|
||||
repoPath,
|
||||
graph: createKnowledgeGraph(),
|
||||
graph,
|
||||
onProgress: () => {},
|
||||
pipelineStart: Date.now(),
|
||||
},
|
||||
deps,
|
||||
);
|
||||
return { output, graph };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
}
|
||||
`;
|
||||
60
gitnexus/test/integration/move/graph-equivalence.test.ts
Normal file
60
gitnexus/test/integration/move/graph-equivalence.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* Graph-equivalence regression guard for the Move ingestion refactor.
|
||||
*
|
||||
* This test locks the post-refactor graph structure (sorted node and edge sets)
|
||||
* for the coin fixture as a vitest snapshot. Any future change that alters the
|
||||
* Move graph shape will cause this test to fail, surfacing unintended regressions
|
||||
* before merge.
|
||||
*
|
||||
* Per-refactor behavioral correctness (Pass-A/Pass-B seam, PendingRef confidence
|
||||
* and reason, usedTypes assertions) is covered by the Move unit suite:
|
||||
* test/unit/move/ref-resolver.test.ts
|
||||
* test/unit/move/facts-mapper.test.ts
|
||||
*
|
||||
* Guard scope: Move-graph structural stability (node labels, edge types, edge
|
||||
* reasons). The snapshot is the content — it must not be empty. If move-flow is
|
||||
* unavailable in the current environment, the test suite skips (expected locally).
|
||||
* CI sets GITNEXUS_REQUIRE_MOVE_FLOW=1 to make unavailability a hard failure.
|
||||
*/
|
||||
import { describe, it, expect, afterAll } from 'vitest';
|
||||
import * as path from 'node:path';
|
||||
import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js';
|
||||
import { tryResolveMoveFlowClient } from '../../../src/core/move/mcp-client.js';
|
||||
import { createMoveIngestPhase } from '../../../src/core/move/move-ingest.js';
|
||||
import { ensureMoveFlowRuntime } from '../../../src/core/move/provision.js';
|
||||
|
||||
const requireMoveFlow = process.env.GITNEXUS_REQUIRE_MOVE_FLOW === '1';
|
||||
const client = requireMoveFlow
|
||||
? (
|
||||
await ensureMoveFlowRuntime({
|
||||
onLog: (message) => console.info(`[move-graph-equiv] ${message}`),
|
||||
})
|
||||
)?.client
|
||||
: tryResolveMoveFlowClient()?.client;
|
||||
|
||||
if (requireMoveFlow && !client) {
|
||||
throw new Error('GITNEXUS_REQUIRE_MOVE_FLOW=1 but MoveFlow provisioning failed');
|
||||
}
|
||||
|
||||
const coinFixture = path.resolve(process.cwd(), 'test/fixtures/move/aptos-framework/coin');
|
||||
|
||||
describe.skipIf(!client)('Move graph-equivalence regression guard (coin fixture)', () => {
|
||||
afterAll(async () => {
|
||||
await client?.shutdown();
|
||||
});
|
||||
|
||||
it('matches the committed graph snapshot (nodes + edges)', async () => {
|
||||
const result = await runPipelineFromRepo(coinFixture, () => {}, {
|
||||
standaloneIngestPhase: createMoveIngestPhase(client),
|
||||
skipGraphPhases: true,
|
||||
});
|
||||
|
||||
const nodes = [...result.graph.iterNodes()].map((n) => `${n.id}\t${n.label}`).sort();
|
||||
|
||||
const edges = [...result.graph.iterRelationships()]
|
||||
.map((r) => `${r.sourceId}\t${r.type}\t${r.targetId}\t${r.reason ?? ''}`)
|
||||
.sort();
|
||||
|
||||
expect({ nodes, edges }).toMatchSnapshot();
|
||||
}, 60000);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
97
gitnexus/test/unit/move/concurrency.test.ts
Normal file
97
gitnexus/test/unit/move/concurrency.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// gitnexus/test/unit/move/concurrency.test.ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mapWithConcurrency } from '../../../src/core/move/concurrency.js';
|
||||
|
||||
describe('mapWithConcurrency', () => {
|
||||
it('never exceeds the concurrency limit and reaches it', async () => {
|
||||
let inFlight = 0,
|
||||
peak = 0;
|
||||
await mapWithConcurrency([1, 2, 3, 4, 5, 6], 2, async () => {
|
||||
inFlight++;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
inFlight--;
|
||||
});
|
||||
expect(peak).toBe(2);
|
||||
});
|
||||
|
||||
it('fail-fast returns first error and stops scheduling new work', async () => {
|
||||
const started: number[] = [];
|
||||
const { failure } = await mapWithConcurrency(
|
||||
[1, 2, 3, 4, 5, 6],
|
||||
1,
|
||||
async (n) => {
|
||||
started.push(n);
|
||||
if (n === 2) throw new Error('boom');
|
||||
},
|
||||
{ failFast: true },
|
||||
);
|
||||
expect(failure?.error).toBeInstanceOf(Error);
|
||||
expect(failure?.item).toBe(2);
|
||||
expect(started).toEqual([1, 2]); // 3..6 never scheduled
|
||||
});
|
||||
|
||||
it('fail-fast with limit=2: awaits in-flight workers, stops new scheduling, captures first error', async () => {
|
||||
let resolveA!: () => void;
|
||||
const latchA = new Promise<void>((r) => {
|
||||
resolveA = r;
|
||||
});
|
||||
const aCompleted: boolean[] = [];
|
||||
const started: number[] = [];
|
||||
|
||||
const resultPromise = mapWithConcurrency(
|
||||
[1, 2, 3, 4, 5],
|
||||
2,
|
||||
async (n) => {
|
||||
started.push(n);
|
||||
if (n === 1) {
|
||||
await latchA;
|
||||
aCompleted.push(true);
|
||||
return;
|
||||
}
|
||||
if (n === 2) throw new Error('worker-B-fail');
|
||||
},
|
||||
{ failFast: true },
|
||||
);
|
||||
|
||||
// Allow microtasks to run so both workers start and worker B's rejection settles.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
// Worker B has rejected; worker A is still blocked on latchA.
|
||||
// Release A so mapWithConcurrency can resolve.
|
||||
resolveA();
|
||||
|
||||
const { failure } = await resultPromise;
|
||||
|
||||
// (a) Worker A completed before the result resolved.
|
||||
expect(aCompleted).toHaveLength(1);
|
||||
// (b) Items after the failing index were never scheduled.
|
||||
expect(started).not.toContain(3);
|
||||
expect(started).not.toContain(4);
|
||||
expect(started).not.toContain(5);
|
||||
// (c) The failure captures the first (and only) error.
|
||||
expect(failure?.error).toBeInstanceOf(Error);
|
||||
expect((failure?.error as Error).message).toBe('worker-B-fail');
|
||||
expect(failure?.item).toBe(2);
|
||||
});
|
||||
|
||||
it('without failFast, first worker rejection rejects the returned promise', async () => {
|
||||
await expect(
|
||||
mapWithConcurrency([1, 2, 3], 2, async (n) => {
|
||||
if (n === 2) throw new Error('non-failfast-rejection');
|
||||
}),
|
||||
).rejects.toThrow('non-failfast-rejection');
|
||||
});
|
||||
|
||||
it('limit=1 runs sequentially in order', async () => {
|
||||
const order: number[] = [];
|
||||
const { results } = await mapWithConcurrency([1, 2, 3], 1, async (n) => {
|
||||
order.push(n);
|
||||
return n * 2;
|
||||
});
|
||||
expect(order).toEqual([1, 2, 3]);
|
||||
expect(results).toEqual([2, 4, 6]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { validateMoveIngestOutput } from '../../../src/core/move/consistency.js';
|
||||
import {
|
||||
summarizeMoveConsistency,
|
||||
validateMoveIngestOutput,
|
||||
} from '../../../src/core/move/consistency.js';
|
||||
import type { MoveIngestOutput } from '../../../src/core/move/move-ingest.js';
|
||||
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
|
||||
|
||||
|
|
@ -21,6 +24,8 @@ function makeOutput(overrides: Partial<MoveIngestOutput> = {}): MoveIngestOutput
|
|||
modulePackageMap: new Map(),
|
||||
filePackageMap: new Map(),
|
||||
callGraphByPackage: new Map(),
|
||||
droppedRefs: [],
|
||||
functionUsageFailures: [],
|
||||
consistencyIssues: [],
|
||||
...overrides,
|
||||
};
|
||||
|
|
@ -199,11 +204,180 @@ describe('validateMoveIngestOutput', () => {
|
|||
const output = makeOutput({
|
||||
ingestedFiles: new Set([file]),
|
||||
moduleFileMap: new Map([['0xa::m', file]]),
|
||||
droppedResourceRefs: [{ fnNodeId: 'Function:f', target: 'Config' }],
|
||||
droppedRefs: [{ kind: 'resource', sourceId: 'Function:f', target: 'Config' }],
|
||||
});
|
||||
const issues = validateMoveIngestOutput(graph, output);
|
||||
expect(
|
||||
issues.some((i) => i.code === 'unresolved-resource-target' && i.severity === 'warning'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when type, friend, or lambda-host targets were dropped', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const output = makeOutput({
|
||||
droppedRefs: [
|
||||
{ kind: 'type', sourceId: 'Function:f', target: '(u64' },
|
||||
{ kind: 'friend', sourceId: 'Module:m', target: '0xb::gone' },
|
||||
{ kind: 'lambda-host', sourceId: 'Function:l', target: '0xa::m::host' },
|
||||
],
|
||||
});
|
||||
const issues = validateMoveIngestOutput(graph, output);
|
||||
for (const code of [
|
||||
'unresolved-type-target',
|
||||
'unresolved-friend-target',
|
||||
'unresolved-lambda-host',
|
||||
] as const) {
|
||||
const issue = issues.find((i) => i.code === code);
|
||||
expect(issue?.severity).toBe('warning');
|
||||
expect(issue?.details?.count).toBe(1);
|
||||
expect(
|
||||
(issue?.details?.sample as Array<{ kind: string; sourceId: string; target: string }>)?.[0],
|
||||
).toMatchObject({
|
||||
kind: expect.any(String),
|
||||
sourceId: expect.any(String),
|
||||
target: expect.any(String),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('errors call-graph-unlinked when no caller resolves despite mapped functions', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const file = 'sources/coin.move';
|
||||
addFileNode(graph, file);
|
||||
const output = makeOutput({
|
||||
ingestedFiles: new Set([file]),
|
||||
moduleFileMap: new Map([['0xa::coin', file]]),
|
||||
modulePackageMap: new Map([['0xa::coin', '/pkg']]),
|
||||
functionNodeMap: new Map([['0xa::coin::register', `Function:${file}:0xa::coin::register`]]),
|
||||
// Simulated normalization drift: call_graph came back zero-padded, so
|
||||
// NO caller matches facts-derived names - and the ownership checks are
|
||||
// also blind to it (caller modules miss modulePackageMap the same way).
|
||||
callGraphByPackage: new Map([['/pkg', { '0x000a::coin::register': ['0x000a::coin::mint'] }]]),
|
||||
});
|
||||
const issues = validateMoveIngestOutput(graph, output);
|
||||
const issue = issues.find((i) => i.code === 'call-graph-unlinked');
|
||||
expect(issue?.severity).toBe('error');
|
||||
expect(issue?.details?.packageRoot).toBe('/pkg');
|
||||
// The pre-existing per-caller checks stay silent here - that blindness is
|
||||
// exactly why the package-level check exists.
|
||||
expect(issues.some((i) => i.code === 'missing-owned-caller')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not flag call-graph-unlinked when caller modules resolve (facts-elided callers)', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const file = 'sources/coin.move';
|
||||
addFileNode(graph, file);
|
||||
const output = makeOutput({
|
||||
ingestedFiles: new Set([file]),
|
||||
moduleFileMap: new Map([['0xa::coin', file]]),
|
||||
modulePackageMap: new Map([['0xa::coin', '/pkg']]),
|
||||
functionNodeMap: new Map([['0xa::coin::register', `Function:${file}:0xa::coin::register`]]),
|
||||
// The only caller is a #[test] function elided from facts: its MODULE
|
||||
// resolves, so this is per-caller warning territory, not systematic
|
||||
// qualified-name drift.
|
||||
callGraphByPackage: new Map([['/pkg', { '0xa::coin::test_flow': ['0xa::coin::register'] }]]),
|
||||
});
|
||||
const issues = validateMoveIngestOutput(graph, output);
|
||||
expect(issues.some((i) => i.code === 'call-graph-unlinked')).toBe(false);
|
||||
expect(issues.some((i) => i.code === 'missing-owned-caller')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag call-graph-unlinked for a package with no mapped functions', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const file = 'sources/coin.move';
|
||||
addFileNode(graph, file);
|
||||
const output = makeOutput({
|
||||
ingestedFiles: new Set([file]),
|
||||
moduleFileMap: new Map([['0xa::coin', file]]),
|
||||
modulePackageMap: new Map([['0xa::coin', '/pkg']]),
|
||||
// Structs-only package: call graph may list elided test-only functions.
|
||||
callGraphByPackage: new Map([['/pkg', { '0xa::coin::test_helper': [] }]]),
|
||||
});
|
||||
const issues = validateMoveIngestOutput(graph, output);
|
||||
expect(issues.some((i) => i.code === 'call-graph-unlinked')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not flag call-graph-unlinked when callees resolve (elided test-only caller module)', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const file = 'sources/coin.move';
|
||||
addFileNode(graph, file);
|
||||
const output = makeOutput({
|
||||
ingestedFiles: new Set([file]),
|
||||
moduleFileMap: new Map([['0xa::coin', file]]),
|
||||
modulePackageMap: new Map([['0xa::coin', '/pkg']]),
|
||||
functionNodeMap: new Map([['0xa::coin::register', `Function:${file}:0xa::coin::register`]]),
|
||||
// Every caller lives in a test-only MODULE elided from facts, so neither
|
||||
// the function nor the module condition can clear the check - but the
|
||||
// CALLEES still join facts names, which real drift would break too.
|
||||
callGraphByPackage: new Map([['/pkg', { '0xa::coin_tests::flow': ['0xa::coin::register'] }]]),
|
||||
});
|
||||
const issues = validateMoveIngestOutput(graph, output);
|
||||
expect(issues.some((i) => i.code === 'call-graph-unlinked')).toBe(false);
|
||||
});
|
||||
|
||||
it('warns when an external module shares an address with repo-local modules', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const file = 'sources/coin.move';
|
||||
addFileNode(graph, file);
|
||||
graph.addNode({
|
||||
id: 'Module::0xa::vanished',
|
||||
label: 'Module',
|
||||
properties: {
|
||||
name: 'vanished',
|
||||
filePath: '',
|
||||
qualifiedName: '0xa::vanished',
|
||||
locationFidelity: 'external',
|
||||
},
|
||||
});
|
||||
const output = makeOutput({
|
||||
ingestedFiles: new Set([file]),
|
||||
moduleFileMap: new Map([['0xa::coin', file]]),
|
||||
modulePackageMap: new Map([['0xa::coin', '/pkg']]),
|
||||
callGraphByPackage: new Map([['/pkg', {}]]),
|
||||
});
|
||||
const issue = validateMoveIngestOutput(graph, output).find(
|
||||
(i) => i.code === 'external-module-address-overlap',
|
||||
);
|
||||
expect(issue?.severity).toBe('warning');
|
||||
expect(issue?.details?.sample).toEqual(['0xa::vanished']);
|
||||
});
|
||||
|
||||
it('does not warn for external modules at genuinely foreign addresses', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const file = 'sources/coin.move';
|
||||
addFileNode(graph, file);
|
||||
graph.addNode({
|
||||
id: 'Module::0x1::object',
|
||||
label: 'Module',
|
||||
properties: {
|
||||
name: 'object',
|
||||
filePath: '',
|
||||
qualifiedName: '0x1::object',
|
||||
locationFidelity: 'external',
|
||||
},
|
||||
});
|
||||
const output = makeOutput({
|
||||
ingestedFiles: new Set([file]),
|
||||
moduleFileMap: new Map([['0xa::coin', file]]),
|
||||
modulePackageMap: new Map([['0xa::coin', '/pkg']]),
|
||||
callGraphByPackage: new Map([['/pkg', {}]]),
|
||||
});
|
||||
const issues = validateMoveIngestOutput(graph, output);
|
||||
expect(issues.some((i) => i.code === 'external-module-address-overlap')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeMoveConsistency', () => {
|
||||
it('bounds persisted messages and details', () => {
|
||||
const summary = summarizeMoveConsistency([
|
||||
{
|
||||
code: 'empty-package-facts',
|
||||
severity: 'error',
|
||||
message: 'm'.repeat(10_000),
|
||||
details: { diagnostics: 'd'.repeat(1_000_000) },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(JSON.stringify(summary).length).toBeLessThan(5_000);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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() {},
|
||||
};
|
||||
|
|
|
|||
42
gitnexus/test/unit/move/entry-points.test.ts
Normal file
42
gitnexus/test/unit/move/entry-points.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MoveFactsMap } from '../../../src/core/move/compiler-facts.js';
|
||||
import {
|
||||
makeMoveFlowClientStub,
|
||||
moveFunctionFact,
|
||||
runMoveIngestPhaseWithGraph,
|
||||
} from '../../helpers/move-ingest-harness.js';
|
||||
|
||||
describe('Move entry points', () => {
|
||||
it('emits roots for entry and view functions, but not init_module', async () => {
|
||||
const repoRoot = path.resolve('/repo');
|
||||
const facts: MoveFactsMap = {
|
||||
'0xa::roots': {
|
||||
file: path.join(repoRoot, 'pkg/sources/roots.move'),
|
||||
functions: [
|
||||
moveFunctionFact('entry_api', { isEntry: true }),
|
||||
moveFunctionFact('view_api', { isView: true }),
|
||||
moveFunctionFact('init_module'),
|
||||
],
|
||||
},
|
||||
};
|
||||
const client = makeMoveFlowClientStub({
|
||||
facts: async () => facts,
|
||||
callGraph: async () => ({}),
|
||||
capabilities: async () => ({
|
||||
hasFactsQuery: true,
|
||||
hasFunctionUsageQuery: false,
|
||||
hasStatusTool: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const { graph } = await runMoveIngestPhaseWithGraph(client, repoRoot, [
|
||||
'pkg/Move.toml',
|
||||
'pkg/sources/roots.move',
|
||||
]);
|
||||
const roots = [...graph.iterRelationshipsByType('ENTRY_POINT_OF')].map((edge) => edge.reason);
|
||||
|
||||
expect(roots).toEqual(expect.arrayContaining(['move-entry-function', 'move-view-function']));
|
||||
expect(roots).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,36 +1,32 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { mapFactsToGraph } from '../../../src/core/move/facts-mapper.js';
|
||||
import {
|
||||
buildLocalNameIndex,
|
||||
mapFactsToGraph,
|
||||
resolveFriendEdges,
|
||||
resolveResourceEdges,
|
||||
resolveTypeRefEdges,
|
||||
} from '../../../src/core/move/facts-mapper.js';
|
||||
resolveRefs,
|
||||
ExternalMoveSymbols,
|
||||
} from '../../../src/core/move/move-linker.js';
|
||||
import type { DroppedRef } from '../../../src/core/move/refs.js';
|
||||
import {
|
||||
type MoveFactsFunction,
|
||||
type MoveFactsMap,
|
||||
} from '../../../src/core/move/compiler-facts.js';
|
||||
import { moveFunctionFact } from '../../helpers/move-ingest-harness.js';
|
||||
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
|
||||
|
||||
/** A minimal function facts entry with per-test overrides. */
|
||||
/** A fuller-shaped function facts entry (file/span/arrays) with per-test overrides. */
|
||||
function fnFixture(name: string, overrides: Partial<MoveFactsFunction> = {}): MoveFactsFunction {
|
||||
return {
|
||||
name,
|
||||
return moveFunctionFact(name, {
|
||||
file: '/pkg/sources/fixture.move',
|
||||
span: [1, 3],
|
||||
visibility: 'public',
|
||||
isEntry: false,
|
||||
isInline: false,
|
||||
isNative: false,
|
||||
isView: false,
|
||||
attributes: [],
|
||||
typeParams: [],
|
||||
params: [],
|
||||
returnTypes: [],
|
||||
acquiresInferred: [],
|
||||
resourceAccess: { reads: [], writes: [] },
|
||||
isLambdaLifted: false,
|
||||
...overrides,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Trimmed-but-faithful slice of a real `move_package_query { query: "facts" }`
|
||||
|
|
@ -112,29 +108,29 @@ const facts: MoveFactsMap = {
|
|||
},
|
||||
};
|
||||
|
||||
function mapFactsToGraphWithResolvedEdges(
|
||||
factsMap: MoveFactsMap,
|
||||
): ReturnType<typeof mapFactsToGraph> {
|
||||
function mapFactsToGraphWithResolvedEdges(factsMap: MoveFactsMap) {
|
||||
const mapped = mapFactsToGraph(factsMap, '/pkg');
|
||||
const edges = [...mapped.edges];
|
||||
const graph = createKnowledgeGraph();
|
||||
for (const node of mapped.nodes) graph.addNode(node);
|
||||
for (const rel of mapped.edges) graph.addRelationship(rel);
|
||||
const localNameIndex = buildLocalNameIndex(mapped.structNodeMap);
|
||||
const addResolvedUsedType = (sourceId: string, targetId: string): void => {
|
||||
const fnNode = mapped.nodes.find((n) => n.id === sourceId);
|
||||
const typeNode = mapped.nodes.find((n) => n.id === targetId);
|
||||
const qualifiedName = typeNode?.properties.qualifiedName;
|
||||
if (!fnNode || typeof qualifiedName !== 'string') return;
|
||||
const current = Array.isArray(fnNode.properties.usedTypes) ? fnNode.properties.usedTypes : [];
|
||||
if (!current.includes(qualifiedName)) fnNode.properties.usedTypes = [...current, qualifiedName];
|
||||
};
|
||||
resolveResourceEdges(mapped.pendingResource, mapped.structNodeMap, localNameIndex, (rel) =>
|
||||
edges.push(rel),
|
||||
const external = new ExternalMoveSymbols(graph, mapped.moduleFileMap);
|
||||
const drops: DroppedRef[] = [];
|
||||
resolveRefs(
|
||||
graph,
|
||||
mapped.pendingRefs,
|
||||
{
|
||||
structNodeMap: mapped.structNodeMap,
|
||||
structIdsByLocalName: localNameIndex,
|
||||
moduleFileMap: mapped.moduleFileMap,
|
||||
functionNodeMap: mapped.functionNodeMap,
|
||||
},
|
||||
external,
|
||||
drops,
|
||||
);
|
||||
resolveFriendEdges(mapped.pendingFriends, mapped.moduleFileMap, (rel) => edges.push(rel));
|
||||
resolveTypeRefEdges(mapped.pendingTypeRef, mapped.structNodeMap, localNameIndex, (rel) => {
|
||||
edges.push(rel);
|
||||
addResolvedUsedType(rel.sourceId, rel.targetId);
|
||||
});
|
||||
return { ...mapped, edges };
|
||||
const edges = [...graph.iterRelationships()];
|
||||
const nodes = [...graph.iterNodes()];
|
||||
return { ...mapped, nodes, edges };
|
||||
}
|
||||
|
||||
describe('mapFactsToGraph', () => {
|
||||
|
|
@ -193,10 +189,36 @@ describe('mapFactsToGraph', () => {
|
|||
const { nodes } = mapFactsToGraph(facts, '/pkg');
|
||||
const fn = nodes.find((n) => n.properties.name === 'balance_of');
|
||||
expect(fn?.properties.isView).toBe(true);
|
||||
expect(fn?.properties.isExported).toBe(true);
|
||||
expect(fn?.properties.locationFidelity).toBe('precise');
|
||||
expect(fn?.properties.startLine).toBe(33);
|
||||
});
|
||||
|
||||
it('maps public and compiler-marked entry/view functions onto isExported', () => {
|
||||
const visibilityFacts: MoveFactsMap = {
|
||||
'0xa::roots': {
|
||||
file: '/pkg/sources/roots.move',
|
||||
functions: [
|
||||
fnFixture('public_api', { visibility: 'public' }),
|
||||
fnFixture('package_api', { visibility: 'package' }),
|
||||
fnFixture('private_entry', { visibility: 'internal', isEntry: true }),
|
||||
fnFixture('init_module', { visibility: 'internal' }),
|
||||
fnFixture('helper', { visibility: 'internal' }),
|
||||
],
|
||||
},
|
||||
};
|
||||
const { nodes } = mapFactsToGraph(visibilityFacts, '/pkg');
|
||||
const exported = (name: string) =>
|
||||
nodes.find((node) => node.label === 'Function' && node.properties.name === name)?.properties
|
||||
.isExported;
|
||||
|
||||
expect(exported('public_api')).toBe(true);
|
||||
expect(exported('package_api')).toBe(false);
|
||||
expect(exported('private_entry')).toBe(true);
|
||||
expect(exported('init_module')).toBe(false);
|
||||
expect(exported('helper')).toBe(false);
|
||||
});
|
||||
|
||||
it('tolerates functions/modules with missing optional arrays (real move-flow shape)', () => {
|
||||
// move-flow omits optional array fields (acquiresInferred, resourceAccess,
|
||||
// params, typeParams, attributes, friends, types, constants) for some symbols.
|
||||
|
|
@ -348,6 +370,75 @@ describe('mapFactsToGraph', () => {
|
|||
expect(v?.properties.locationFidelity).toBe('module');
|
||||
});
|
||||
|
||||
it('materializes Move 2 enum variant fields and their local type edges', () => {
|
||||
const enumFacts: MoveFactsMap = {
|
||||
'0xa::orders': {
|
||||
file: '/pkg/sources/orders.move',
|
||||
functions: [],
|
||||
structs: [
|
||||
{
|
||||
kind: 'struct',
|
||||
name: 'Payload',
|
||||
file: '/pkg/sources/orders.move',
|
||||
span: [1, 2],
|
||||
abilities: ['copy'],
|
||||
typeParams: [],
|
||||
fields: [],
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
kind: 'enum',
|
||||
name: 'OrderState',
|
||||
file: '/pkg/sources/orders.move',
|
||||
span: [4, 10],
|
||||
abilities: ['drop'],
|
||||
typeParams: [{ name: 'T', abilities: [], isPhantom: false }],
|
||||
variants: [
|
||||
{
|
||||
name: 'Open',
|
||||
kind: 'named',
|
||||
fields: [
|
||||
{ name: 'payload', type: 'Payload', positional: false },
|
||||
{ name: 'callback', type: '|Payload|T has copy + drop', positional: false },
|
||||
],
|
||||
attributes: [],
|
||||
},
|
||||
],
|
||||
attributes: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const { nodes, edges, pendingRefs } = mapFactsToGraphWithResolvedEdges(enumFacts);
|
||||
const variant = nodes.find(
|
||||
(node) => node.label === 'EnumVariant' && node.properties.name === 'Open',
|
||||
);
|
||||
const fieldIds = new Set(
|
||||
edges
|
||||
.filter((edge) => edge.type === 'HAS_PROPERTY' && edge.sourceId === variant?.id)
|
||||
.map((edge) => edge.targetId),
|
||||
);
|
||||
const fields = nodes.filter((node) => node.label === 'Property' && fieldIds.has(node.id));
|
||||
|
||||
expect(fields.map((field) => field.properties.name)).toEqual(['payload', 'callback']);
|
||||
expect(
|
||||
edges.filter((edge) => edge.type === 'HAS_PROPERTY' && edge.sourceId === variant?.id),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
edges.some(
|
||||
(edge) =>
|
||||
edge.type === 'USES_TYPE' &&
|
||||
edge.reason === 'move-enum-variant-field-type' &&
|
||||
edge.sourceId === fields.find((field) => field.properties.name === 'callback')?.id &&
|
||||
edge.targetId.includes('Payload'),
|
||||
),
|
||||
).toBe(true);
|
||||
const typeRefs = pendingRefs.filter((r) => r.kind === 'type');
|
||||
expect(typeRefs.some((pending) => pending.target === 'copy')).toBe(false);
|
||||
expect(typeRefs.some((pending) => pending.target === 'drop')).toBe(false);
|
||||
expect(typeRefs.some((pending) => pending.target === 'T')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not write moduleAddress on Function nodes (only Module/Struct/Enum carry it)', () => {
|
||||
const { nodes } = mapFactsToGraph(facts, '/pkg');
|
||||
const fn = nodes.find((n) => n.label === 'Function' && n.properties.name === 'register');
|
||||
|
|
@ -535,8 +626,10 @@ describe('mapFactsToGraph', () => {
|
|||
constants: [],
|
||||
},
|
||||
};
|
||||
const { pendingTypeRef } = mapFactsToGraph(rtFacts, '/pkg');
|
||||
const returnRefs = pendingTypeRef.filter((p) => p.reason === 'move-fn-return-type');
|
||||
const { pendingRefs } = mapFactsToGraph(rtFacts, '/pkg');
|
||||
const returnRefs = pendingRefs.filter(
|
||||
(p) => p.kind === 'type' && p.reason === 'move-fn-return-type',
|
||||
);
|
||||
expect(returnRefs.map((p) => p.target)).toEqual(['0xbeef::price::PriceInfo']);
|
||||
});
|
||||
|
||||
|
|
@ -588,18 +681,33 @@ describe('mapFactsToGraph', () => {
|
|||
},
|
||||
};
|
||||
const mapped = mapFactsToGraph(qualFacts, '/pkg');
|
||||
const graph = createKnowledgeGraph();
|
||||
for (const node of mapped.nodes) graph.addNode(node);
|
||||
for (const rel of mapped.edges) graph.addRelationship(rel);
|
||||
const localNameIndex = buildLocalNameIndex(mapped.structNodeMap);
|
||||
const resolvedEdges: unknown[] = [];
|
||||
const unresolved: string[] = [];
|
||||
resolveResourceEdges(
|
||||
mapped.pendingResource,
|
||||
mapped.structNodeMap,
|
||||
localNameIndex,
|
||||
(rel) => resolvedEdges.push(rel),
|
||||
(pending) => unresolved.push(pending.target),
|
||||
const external = new ExternalMoveSymbols(graph, mapped.moduleFileMap);
|
||||
const drops: DroppedRef[] = [];
|
||||
const resourceRefs = mapped.pendingRefs.filter((r) => r.kind === 'resource');
|
||||
resolveRefs(
|
||||
graph,
|
||||
resourceRefs,
|
||||
{
|
||||
structNodeMap: mapped.structNodeMap,
|
||||
structIdsByLocalName: localNameIndex,
|
||||
moduleFileMap: mapped.moduleFileMap,
|
||||
functionNodeMap: mapped.functionNodeMap,
|
||||
},
|
||||
external,
|
||||
drops,
|
||||
);
|
||||
const localCoinStoreId = mapped.structNodeMap.get('0xa::coin::CoinStore');
|
||||
const resolvedResourceEdges = [...graph.iterRelationships()].filter(
|
||||
(e) => e.type === 'READS_RESOURCE' || e.type === 'WRITES_RESOURCE' || e.type === 'ACQUIRES',
|
||||
);
|
||||
expect(resolvedResourceEdges.every((e) => e.targetId !== localCoinStoreId)).toBe(true);
|
||||
expect(resolvedResourceEdges.every((e) => e.targetId.includes('0x1::coin::CoinStore'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(resolvedEdges).toEqual([]);
|
||||
expect(unresolved).toEqual(['0x1::coin::CoinStore', '0x1::coin::CoinStore']);
|
||||
});
|
||||
|
||||
it('projects full attribute payloads (args/values) as attributesJson alongside the name list', () => {
|
||||
|
|
@ -704,10 +812,31 @@ describe('mapFactsToGraph', () => {
|
|||
constants: [],
|
||||
},
|
||||
};
|
||||
const { pendingLambdaHosts } = mapFactsToGraph(lambdaFacts, '/pkg');
|
||||
expect(pendingLambdaHosts.map((p) => p.hostQualified)).toEqual([
|
||||
'0xa::vault::lifted',
|
||||
'0xa::vault::outer',
|
||||
]);
|
||||
const { pendingRefs } = mapFactsToGraph(lambdaFacts, '/pkg');
|
||||
const lambdaRefs = pendingRefs.filter((r) => r.kind === 'lambda-host');
|
||||
expect(lambdaRefs.map((p) => p.target)).toEqual(['0xa::vault::lifted', '0xa::vault::outer']);
|
||||
});
|
||||
|
||||
it('attributes lifted lambdas to their declaring module source', () => {
|
||||
const lambdaFacts: MoveFactsMap = {
|
||||
'0xa::vault': {
|
||||
file: '/pkg/sources/vault.move',
|
||||
functions: [
|
||||
fnFixture('__lambda__1__run', {
|
||||
file: '/external/aptos-framework/sources/big_ordered_map.move',
|
||||
isLambdaLifted: true,
|
||||
definedIn: 'run',
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
const { nodes } = mapFactsToGraph(lambdaFacts, '/pkg');
|
||||
const lambda = nodes.find(
|
||||
(node) => node.label === 'Function' && node.properties.name === '__lambda__1__run',
|
||||
);
|
||||
expect(lambda?.properties.filePath).toBe('/pkg/sources/vault.move');
|
||||
expect(lambda?.properties.startLine).toBeUndefined();
|
||||
expect(lambda?.properties.endLine).toBeUndefined();
|
||||
expect(lambda?.properties.locationFidelity).toBe('module');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
190
gitnexus/test/unit/move/graph-quality.test.ts
Normal file
190
gitnexus/test/unit/move/graph-quality.test.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MoveFactsMap } from '../../../src/core/move/compiler-facts.js';
|
||||
import {
|
||||
makeMoveFlowClientStub,
|
||||
moveFunctionFact,
|
||||
runMoveIngestPhaseWithGraph,
|
||||
} from '../../helpers/move-ingest-harness.js';
|
||||
|
||||
const REPO_ROOT = path.resolve('/repo');
|
||||
const SOURCE_FILE = path.join(REPO_ROOT, 'pkg/sources/spot.move');
|
||||
|
||||
const facts: MoveFactsMap = {
|
||||
'0xa::spot_callbacks': {
|
||||
file: SOURCE_FILE,
|
||||
functions: [
|
||||
moveFunctionFact('make_callbacks', {
|
||||
file: SOURCE_FILE,
|
||||
span: [1, 4],
|
||||
visibility: 'public',
|
||||
params: [],
|
||||
returnTypes: ['|address|bool has copy + drop', '|address|bool has copy + drop'],
|
||||
}),
|
||||
moveFunctionFact('dispatch_funds', {
|
||||
file: SOURCE_FILE,
|
||||
span: [6, 8],
|
||||
params: [],
|
||||
}),
|
||||
moveFunctionFact('local_callback_user', {
|
||||
file: SOURCE_FILE,
|
||||
span: [9, 9],
|
||||
params: [],
|
||||
}),
|
||||
moveFunctionFact('withdrawal_done', {
|
||||
file: SOURCE_FILE,
|
||||
span: [10, 12],
|
||||
params: [],
|
||||
}),
|
||||
moveFunctionFact('complete', {
|
||||
file: SOURCE_FILE,
|
||||
span: [14, 16],
|
||||
params: [],
|
||||
}),
|
||||
moveFunctionFact('public_api', {
|
||||
file: SOURCE_FILE,
|
||||
span: [22, 25],
|
||||
visibility: 'public',
|
||||
params: [
|
||||
{
|
||||
name: 'object',
|
||||
type: '0x1::object::Object<0x1::fungible_asset::Metadata>',
|
||||
},
|
||||
],
|
||||
}),
|
||||
moveFunctionFact('native_helper', {
|
||||
file: SOURCE_FILE,
|
||||
span: [27, 27],
|
||||
isNative: true,
|
||||
}),
|
||||
],
|
||||
structs: [],
|
||||
constants: [],
|
||||
},
|
||||
};
|
||||
|
||||
async function ingestFixture() {
|
||||
const client = makeMoveFlowClientStub({
|
||||
facts: async () => facts,
|
||||
callGraph: async () => ({
|
||||
'0xa::spot_callbacks::make_callbacks': [],
|
||||
'0xa::spot_callbacks::withdrawal_done': ['0xa::spot_callbacks::complete'],
|
||||
'0xa::spot_callbacks::public_api': ['0x1::object::object_address'],
|
||||
}),
|
||||
functionUsage: async (_pkg, functionName) => {
|
||||
if (functionName === 'spot_callbacks::local_callback_user') {
|
||||
return {
|
||||
called: [],
|
||||
used: ['0xa::spot_callbacks::complete'],
|
||||
};
|
||||
}
|
||||
if (functionName === 'spot_callbacks::withdrawal_done') {
|
||||
// call_graph already carries withdrawal_done → complete; function_usage
|
||||
// reporting it as used-not-called must not mint a second CALLS edge.
|
||||
return { called: [], used: ['0xa::spot_callbacks::complete'] };
|
||||
}
|
||||
if (functionName !== 'spot_callbacks::make_callbacks') {
|
||||
return { called: [], used: [] };
|
||||
}
|
||||
return {
|
||||
called: [],
|
||||
used: ['0xa::spot_callbacks::dispatch_funds', '0xa::spot_callbacks::withdrawal_done'],
|
||||
};
|
||||
},
|
||||
});
|
||||
const { output, graph } = await runMoveIngestPhaseWithGraph(client, REPO_ROOT, [
|
||||
'pkg/Move.toml',
|
||||
'pkg/sources/spot.move',
|
||||
]);
|
||||
const nodes = [...graph.iterNodes()];
|
||||
const edges = [...graph.iterRelationships()];
|
||||
const functionId = (qualifiedName: string) =>
|
||||
nodes.find(
|
||||
(node) => node.label === 'Function' && node.properties.qualifiedName === qualifiedName,
|
||||
)?.id;
|
||||
return { client, output, nodes, edges, functionId };
|
||||
}
|
||||
|
||||
describe('Move graph quality regressions', () => {
|
||||
it('links compiler-reported closure captures for all ordinary functions', async () => {
|
||||
const { client, edges, functionId } = await ingestFixture();
|
||||
const makeCallbacks = functionId('0xa::spot_callbacks::make_callbacks');
|
||||
expect(
|
||||
edges.filter(
|
||||
(edge) =>
|
||||
edge.sourceId === makeCallbacks &&
|
||||
edge.type === 'CALLS' &&
|
||||
edge.reason === 'move-compiler-closure-use',
|
||||
),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
targetId: functionId('0xa::spot_callbacks::dispatch_funds'),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
targetId: functionId('0xa::spot_callbacks::withdrawal_done'),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
edges.some(
|
||||
(edge) =>
|
||||
edge.sourceId === functionId('0xa::spot_callbacks::local_callback_user') &&
|
||||
edge.targetId === functionId('0xa::spot_callbacks::complete') &&
|
||||
edge.type === 'CALLS' &&
|
||||
edge.reason === 'move-compiler-closure-use',
|
||||
),
|
||||
).toBe(true);
|
||||
const queryable = (facts['0xa::spot_callbacks'].functions ?? []).filter(
|
||||
(f) => !f.isNative && !f.isLambdaLifted,
|
||||
);
|
||||
expect(client.counts.functionUsage).toBe(queryable.length);
|
||||
});
|
||||
|
||||
it('does not duplicate a call-graph edge the usage query classifies as a capture', async () => {
|
||||
const { edges, functionId } = await ingestFixture();
|
||||
const callEdges = edges.filter(
|
||||
(edge) =>
|
||||
edge.type === 'CALLS' &&
|
||||
edge.sourceId === functionId('0xa::spot_callbacks::withdrawal_done') &&
|
||||
edge.targetId === functionId('0xa::spot_callbacks::complete'),
|
||||
);
|
||||
|
||||
expect(callEdges).toHaveLength(1);
|
||||
expect(callEdges[0].reason).toBe('move-compiler-call-graph');
|
||||
});
|
||||
|
||||
it('materializes external function and type targets', async () => {
|
||||
const { output, nodes, edges, functionId } = await ingestFixture();
|
||||
const externalFunction = nodes.find(
|
||||
(node) =>
|
||||
node.label === 'Function' &&
|
||||
node.properties.qualifiedName === '0x1::object::object_address',
|
||||
);
|
||||
expect(externalFunction?.properties.locationFidelity).toBe('external');
|
||||
expect(
|
||||
edges.some(
|
||||
(edge) =>
|
||||
edge.type === 'CALLS' &&
|
||||
edge.sourceId === functionId('0xa::spot_callbacks::public_api') &&
|
||||
edge.targetId === externalFunction?.id,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const externalTypes = nodes.filter(
|
||||
(node) => node.label === 'Type' && node.properties.locationFidelity === 'external',
|
||||
);
|
||||
expect(externalTypes.map((node) => node.properties.qualifiedName).sort()).toEqual([
|
||||
'0x1::fungible_asset::Metadata',
|
||||
'0x1::object::Object',
|
||||
]);
|
||||
expect(
|
||||
edges.filter(
|
||||
(edge) =>
|
||||
edge.type === 'USES_TYPE' &&
|
||||
edge.sourceId === functionId('0xa::spot_callbacks::public_api'),
|
||||
),
|
||||
).toHaveLength(2);
|
||||
expect(output.droppedRefs.filter((d) => d.kind === 'type')).toEqual([]);
|
||||
});
|
||||
});
|
||||
76
gitnexus/test/unit/move/install-recovery.test.ts
Normal file
76
gitnexus/test/unit/move/install-recovery.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const raceLoss = vi.hoisted(() => ({
|
||||
publish: async (): Promise<void> => {},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/core/move/install-lock.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../src/core/move/install-lock.js')>();
|
||||
return {
|
||||
...actual,
|
||||
acquireInstallLock: async () => {
|
||||
await raceLoss.publish();
|
||||
throw new Error('timed out waiting for another move-flow installation');
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { getMoveFlowInstallConfig, installMoveFlow } =
|
||||
await import('../../../src/core/move/install.js');
|
||||
|
||||
describe('move-flow installer concurrent-publish recovery', () => {
|
||||
let cacheRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
cacheRoot = await mkdtemp(path.join(os.tmpdir(), 'move-flow-recovery-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(cacheRoot, { recursive: true, force: true });
|
||||
raceLoss.publish = async () => {};
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'returns the concurrently published cache instead of failed',
|
||||
async () => {
|
||||
const env: NodeJS.ProcessEnv = { GITNEXUS_MOVE_FLOW_DIR: cacheRoot };
|
||||
const config = await getMoveFlowInstallConfig(env);
|
||||
if (!config) throw new Error('expected a supported move-flow install target');
|
||||
|
||||
raceLoss.publish = async () => {
|
||||
await mkdir(config.installDir, { recursive: true });
|
||||
const script = `#!/bin/sh\necho "move-flow ${config.version}"\n`;
|
||||
await writeFile(config.binaryPath, script);
|
||||
await chmod(config.binaryPath, 0o755);
|
||||
const binarySha256 = createHash('sha256').update(script).digest('hex');
|
||||
await writeFile(
|
||||
config.metadataPath,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
version: config.version,
|
||||
repository: config.repository,
|
||||
tag: config.tag,
|
||||
assetName: config.assetName,
|
||||
archiveSha256: 'unused',
|
||||
binarySha256,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const result = await installMoveFlow(env);
|
||||
expect(result.status).toBe('available');
|
||||
expect(result.binary?.binaryPath).toBe(config.binaryPath);
|
||||
expect(result.binary?.version).toBe(config.version);
|
||||
},
|
||||
);
|
||||
|
||||
it('still reports failed when no valid cache exists', async () => {
|
||||
const result = await installMoveFlow({ GITNEXUS_MOVE_FLOW_DIR: cacheRoot });
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.message).toContain('timed out waiting');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,19 +10,12 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import type { MoveFlowClient } from '../../../src/core/move/mcp-client.js';
|
||||
import { runMoveIngestPhase } from '../../helpers/move-ingest-harness.js';
|
||||
import { makeMoveFlowClientStub, runMoveIngestPhase } from '../../helpers/move-ingest-harness.js';
|
||||
|
||||
const REPO_ROOT = path.resolve('/repo');
|
||||
|
||||
function makeClient(overrides: Partial<MoveFlowClient> = {}): MoveFlowClient {
|
||||
return {
|
||||
facts: async () => ({}),
|
||||
callGraph: async () => ({}),
|
||||
packageStatus: async () => ({ ok: true, diagnostics: 'no errors or warnings' }),
|
||||
capabilities: async () => ({ hasFactsQuery: true, hasStatusTool: true }),
|
||||
shutdown: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
return makeMoveFlowClientStub(overrides);
|
||||
}
|
||||
|
||||
/** Run the phase against a fake repo with one package holding one .move file. */
|
||||
|
|
@ -65,7 +58,11 @@ describe('moveIngest empty-facts discrimination', () => {
|
|||
it('falls back to the error-level issue when move_package_status is unavailable', async () => {
|
||||
const output = await runPhase(
|
||||
makeClient({
|
||||
capabilities: async () => ({ hasFactsQuery: true, hasStatusTool: false }),
|
||||
capabilities: async () => ({
|
||||
hasFactsQuery: true,
|
||||
hasFunctionUsageQuery: false,
|
||||
hasStatusTool: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
179
gitnexus/test/unit/move/move-ingest-query-errors.test.ts
Normal file
179
gitnexus/test/unit/move/move-ingest-query-errors.test.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* Package-query failure classification in the moveIngest phase.
|
||||
*
|
||||
* A package build failure is skip-and-warn by default (GITNEXUS_MOVE_STRICT=1
|
||||
* restores the fatal path — covered in move-ingest-skip-and-warn.test.ts).
|
||||
* Timeouts and transport crashes still abort after the client's transport
|
||||
* retry. Supplemental function-usage failures are reported without discarding
|
||||
* the compiler graph.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
MoveFlowTimeoutError,
|
||||
MoveFlowToolCallError,
|
||||
type MoveFlowClient,
|
||||
} from '../../../src/core/move/mcp-client.js';
|
||||
import {
|
||||
makeMoveFlowClientStub,
|
||||
moveFunctionFact,
|
||||
runMoveIngestPhase,
|
||||
} from '../../helpers/move-ingest-harness.js';
|
||||
|
||||
const REPO_ROOT = path.resolve('/repo');
|
||||
|
||||
const runPhase = (client: MoveFlowClient) =>
|
||||
runMoveIngestPhase(client, REPO_ROOT, ['pkg/Move.toml', 'pkg/sources/t.move']);
|
||||
|
||||
describe('moveIngest package-query failure classification', () => {
|
||||
it('queries move-flow again on every ingest run', async () => {
|
||||
let factsCall = 0;
|
||||
const client = makeMoveFlowClientStub({
|
||||
callGraph: async () => ({}),
|
||||
facts: async () => {
|
||||
factsCall += 1;
|
||||
return {
|
||||
'0xa::t': {
|
||||
file: path.join(REPO_ROOT, 'pkg/sources/t.move'),
|
||||
functions: [
|
||||
moveFunctionFact(factsCall === 1 ? 'first' : 'second', { visibility: 'public' }),
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const first = await runPhase(client);
|
||||
const second = await runPhase(client);
|
||||
|
||||
expect(client.counts).toMatchObject({ capabilities: 2, callGraph: 2, facts: 2 });
|
||||
expect(first.functionNodeMap.has('0xa::t::first')).toBe(true);
|
||||
expect(second.functionNodeMap.has('0xa::t::second')).toBe(true);
|
||||
});
|
||||
|
||||
it('reports supplemental function-usage failures without aborting ingestion', async () => {
|
||||
const client = makeMoveFlowClientStub({
|
||||
facts: async () => ({
|
||||
'0xa::t': {
|
||||
file: path.join(REPO_ROOT, 'pkg/sources/t.move'),
|
||||
functions: [
|
||||
moveFunctionFact('with_callback', {
|
||||
visibility: 'public',
|
||||
params: [],
|
||||
returnTypes: ['|u64|u64'],
|
||||
}),
|
||||
moveFunctionFact('after_failure', { visibility: 'public', params: [] }),
|
||||
],
|
||||
},
|
||||
}),
|
||||
functionUsage: async () => {
|
||||
throw new MoveFlowToolCallError('function usage unavailable');
|
||||
},
|
||||
});
|
||||
|
||||
const output = await runPhase(client);
|
||||
|
||||
expect(output.functionNodeMap.has('0xa::t::with_callback')).toBe(true);
|
||||
expect(client.counts.functionUsage).toBeLessThanOrEqual(2);
|
||||
expect(output.functionUsageFailures).toHaveLength(1);
|
||||
expect(output.consistencyIssues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: 'function-usage-query-failed',
|
||||
severity: 'warning',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('disables supplemental queries for later packages after the first failure', async () => {
|
||||
const client = makeMoveFlowClientStub({
|
||||
facts: async (packageRoot) => {
|
||||
const moduleName = path.basename(packageRoot);
|
||||
return {
|
||||
[`0xa::${moduleName}`]: {
|
||||
file: path.join(packageRoot, 'sources/t.move'),
|
||||
functions: [moveFunctionFact('run', { visibility: 'public', params: [] })],
|
||||
},
|
||||
};
|
||||
},
|
||||
functionUsage: async () => {
|
||||
throw new MoveFlowToolCallError('function usage unavailable');
|
||||
},
|
||||
});
|
||||
|
||||
await runMoveIngestPhase(client, REPO_ROOT, [
|
||||
'pkg_a/Move.toml',
|
||||
'pkg_a/sources/t.move',
|
||||
'pkg_b/Move.toml',
|
||||
'pkg_b/sources/t.move',
|
||||
]);
|
||||
|
||||
expect(client.counts.functionUsage).toBe(1);
|
||||
});
|
||||
|
||||
it('marks a timeout operator-actionable without a phase-level retry', async () => {
|
||||
const client = makeMoveFlowClientStub({
|
||||
callGraph: async () => {
|
||||
throw new MoveFlowTimeoutError(
|
||||
"move-flow 'move_package_query' timed out after 300000ms " +
|
||||
'(raise GITNEXUS_MOVE_FLOW_TIMEOUT_MS for large packages)',
|
||||
);
|
||||
},
|
||||
});
|
||||
await expect(runPhase(client)).rejects.toMatchObject({
|
||||
userActionable: true,
|
||||
message: expect.stringContaining('timed out'),
|
||||
});
|
||||
expect(client.counts.callGraph).toBe(1);
|
||||
});
|
||||
|
||||
it('skips a package that fails to build (skip-and-warn) instead of aborting', async () => {
|
||||
const client = makeMoveFlowClientStub({
|
||||
callGraph: async () => {
|
||||
throw new MoveFlowToolCallError('failed to build package `pkg`: missing dependency');
|
||||
},
|
||||
});
|
||||
// Default is skip-and-warn: the analyze continues, the package is left
|
||||
// un-ingested, and a persistent operator warning is surfaced instead of a
|
||||
// throw (the fatal GITNEXUS_MOVE_STRICT path lives in the skip-and-warn suite).
|
||||
const output = await runPhase(client);
|
||||
expect(output.functionNodeMap.size).toBe(0);
|
||||
expect(output.consistencyIssues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'package-build-failed', severity: 'warning' }),
|
||||
]),
|
||||
);
|
||||
expect(output.ingestWarnings ?? []).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('could not build it')]),
|
||||
);
|
||||
expect(client.counts.callGraph).toBe(1);
|
||||
});
|
||||
|
||||
it('skips isNative functions in the function_usage scan', async () => {
|
||||
const client = makeMoveFlowClientStub({
|
||||
facts: async () => ({
|
||||
'0xa::m': {
|
||||
file: 'a.move',
|
||||
functions: [moveFunctionFact('nat', { isNative: true }), moveFunctionFact('reg')],
|
||||
structs: [],
|
||||
constants: [],
|
||||
},
|
||||
}),
|
||||
callGraph: async () => ({}),
|
||||
});
|
||||
await runMoveIngestPhase(client, '/repo', ['a.move', 'Move.toml']);
|
||||
expect(client.counts.functionUsage).toBe(1);
|
||||
});
|
||||
|
||||
it('propagates a transport crash unchanged (the client already retried)', async () => {
|
||||
const crash = new Error('move-flow exited unexpectedly (code 137)');
|
||||
const client = makeMoveFlowClientStub({
|
||||
callGraph: async () => {
|
||||
throw crash;
|
||||
},
|
||||
});
|
||||
await expect(runPhase(client)).rejects.toBe(crash);
|
||||
expect(client.counts.callGraph).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
174
gitnexus/test/unit/move/ref-resolver.test.ts
Normal file
174
gitnexus/test/unit/move/ref-resolver.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// gitnexus/test/unit/move/ref-resolver.test.ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
|
||||
import { resolveRefs, buildLocalNameIndex } from '../../../src/core/move/move-linker.js';
|
||||
import { ExternalMoveSymbols } from '../../../src/core/move/move-linker.js';
|
||||
import type { PendingRef, DroppedRef } from '../../../src/core/move/refs.js';
|
||||
import { MOVE_EDGE_REASON } from '../../../src/core/move/constants.js';
|
||||
|
||||
function fnNode(graph: ReturnType<typeof createKnowledgeGraph>, id: string) {
|
||||
graph.addNode({
|
||||
id,
|
||||
label: 'Function',
|
||||
properties: {
|
||||
name: id,
|
||||
filePath: 'a.move',
|
||||
language: 'move',
|
||||
qualifiedName: id,
|
||||
usedTypes: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
function structNode(graph: ReturnType<typeof createKnowledgeGraph>, id: string, qn: string) {
|
||||
graph.addNode({
|
||||
id,
|
||||
label: 'Struct',
|
||||
properties: { name: qn, filePath: 'a.move', language: 'move', qualifiedName: qn },
|
||||
});
|
||||
}
|
||||
|
||||
describe('resolveRefs', () => {
|
||||
it('resolves a type ref to a mapped struct and records usedTypes', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
fnNode(graph, 'Function:a.move:0xa::m::f');
|
||||
structNode(graph, 'Struct:a.move:0xa::m::S', '0xa::m::S');
|
||||
const structNodeMap = new Map([['0xa::m::S', 'Struct:a.move:0xa::m::S']]);
|
||||
const drops: DroppedRef[] = [];
|
||||
const external = new ExternalMoveSymbols(graph, new Map([['0xa::m', 'Module:a.move:0xa::m']]));
|
||||
const refs: PendingRef[] = [
|
||||
{
|
||||
kind: 'type',
|
||||
knownNodeId: 'Function:a.move:0xa::m::f',
|
||||
target: '0xa::m::S',
|
||||
moduleQualified: '0xa::m',
|
||||
edgeType: 'USES_TYPE',
|
||||
reason: MOVE_EDGE_REASON.fnParamType,
|
||||
},
|
||||
];
|
||||
resolveRefs(
|
||||
graph,
|
||||
refs,
|
||||
{
|
||||
structNodeMap,
|
||||
structIdsByLocalName: buildLocalNameIndex(structNodeMap),
|
||||
moduleFileMap: new Map(),
|
||||
functionNodeMap: new Map(),
|
||||
},
|
||||
external,
|
||||
drops,
|
||||
);
|
||||
const edges = [...graph.iterRelationshipsByType('USES_TYPE')];
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(graph.getNode('Function:a.move:0xa::m::f')!.properties.usedTypes).toContain('0xa::m::S');
|
||||
expect(drops).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('drops an ambiguous local-name struct ref without externalizing', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
fnNode(graph, 'Function:a.move:0xa::m::f');
|
||||
structNode(graph, 'Struct:a.move:0xa::m::S', '0xa::m::S');
|
||||
structNode(graph, 'Struct:b.move:0xb::n::S', '0xb::n::S');
|
||||
const structNodeMap = new Map([
|
||||
['0xa::m::S', 'Struct:a.move:0xa::m::S'],
|
||||
['0xb::n::S', 'Struct:b.move:0xb::n::S'],
|
||||
]);
|
||||
const drops: DroppedRef[] = [];
|
||||
const external = new ExternalMoveSymbols(graph, new Map());
|
||||
const refs: PendingRef[] = [
|
||||
{
|
||||
kind: 'resource',
|
||||
knownNodeId: 'Function:a.move:0xa::m::f',
|
||||
target: 'S',
|
||||
moduleQualified: '0xc::other',
|
||||
edgeType: 'READS_RESOURCE',
|
||||
reason: MOVE_EDGE_REASON.readsResource,
|
||||
},
|
||||
];
|
||||
resolveRefs(
|
||||
graph,
|
||||
refs,
|
||||
{
|
||||
structNodeMap,
|
||||
structIdsByLocalName: buildLocalNameIndex(structNodeMap),
|
||||
moduleFileMap: new Map(),
|
||||
functionNodeMap: new Map(),
|
||||
},
|
||||
external,
|
||||
drops,
|
||||
);
|
||||
expect([...graph.iterRelationshipsByType('READS_RESOURCE')]).toHaveLength(0);
|
||||
expect(drops).toEqual([
|
||||
{ kind: 'resource', sourceId: 'Function:a.move:0xa::m::f', target: 'S' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('externalizes an unresolved qualified type ref', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
fnNode(graph, 'Function:a.move:0xa::m::f');
|
||||
const drops: DroppedRef[] = [];
|
||||
const external = new ExternalMoveSymbols(graph, new Map([['0xa::m', 'Module:a.move:0xa::m']]));
|
||||
const refs: PendingRef[] = [
|
||||
{
|
||||
kind: 'type',
|
||||
knownNodeId: 'Function:a.move:0xa::m::f',
|
||||
target: '0x1::coin::Coin',
|
||||
moduleQualified: '0xa::m',
|
||||
edgeType: 'USES_TYPE',
|
||||
reason: MOVE_EDGE_REASON.fnParamType,
|
||||
},
|
||||
];
|
||||
resolveRefs(
|
||||
graph,
|
||||
refs,
|
||||
{
|
||||
structNodeMap: new Map(),
|
||||
structIdsByLocalName: new Map(),
|
||||
moduleFileMap: new Map(),
|
||||
functionNodeMap: new Map(),
|
||||
},
|
||||
external,
|
||||
drops,
|
||||
);
|
||||
expect([...graph.iterRelationshipsByType('USES_TYPE')]).toHaveLength(1);
|
||||
expect(drops).toHaveLength(0);
|
||||
expect(graph.getNode('Type::<external>:0x1::coin::Coin')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('resolves lambda-host with the resolved host as the edge source (known-target)', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
fnNode(graph, 'Function:a.move:0xa::m::host');
|
||||
fnNode(graph, 'Function:a.move:0xa::m::__lambda__0__host');
|
||||
const functionNodeMap = new Map([['0xa::m::host', 'Function:a.move:0xa::m::host']]);
|
||||
const drops: DroppedRef[] = [];
|
||||
const external = new ExternalMoveSymbols(graph, new Map());
|
||||
const refs: PendingRef[] = [
|
||||
{
|
||||
kind: 'lambda-host',
|
||||
knownNodeId: 'Function:a.move:0xa::m::__lambda__0__host',
|
||||
target: '0xa::m::host',
|
||||
moduleQualified: '',
|
||||
edgeType: 'CALLS',
|
||||
reason: MOVE_EDGE_REASON.lambdaHost,
|
||||
},
|
||||
];
|
||||
resolveRefs(
|
||||
graph,
|
||||
refs,
|
||||
{
|
||||
structNodeMap: new Map(),
|
||||
structIdsByLocalName: new Map(),
|
||||
moduleFileMap: new Map(),
|
||||
functionNodeMap,
|
||||
},
|
||||
external,
|
||||
drops,
|
||||
);
|
||||
const calls = [...graph.iterRelationshipsByType('CALLS')];
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].sourceId).toBe('Function:a.move:0xa::m::host');
|
||||
expect(calls[0].targetId).toBe('Function:a.move:0xa::m::__lambda__0__host');
|
||||
// Behavior-preservation: lambda-host CALLS keeps the legacy 0.9 confidence
|
||||
// (resource/type/friend use 1.0).
|
||||
expect(calls[0].confidence).toBe(0.9);
|
||||
});
|
||||
});
|
||||
|
|
@ -6,6 +6,8 @@ describe('extractTypeNames', () => {
|
|||
expect(extractTypeNames('u64')).toEqual([]);
|
||||
expect(extractTypeNames('address')).toEqual([]);
|
||||
expect(extractTypeNames('bool')).toEqual([]);
|
||||
expect(extractTypeNames('i64')).toEqual([]);
|
||||
expect(extractTypeNames('i256')).toEqual([]);
|
||||
expect(extractTypeNames('&signer')).toEqual([]);
|
||||
});
|
||||
|
||||
|
|
@ -31,4 +33,24 @@ describe('extractTypeNames', () => {
|
|||
expect(extractTypeNames('vector<u8>')).toEqual([]);
|
||||
expect(extractTypeNames('Option<Vault>')).toEqual(['Option', 'Vault']);
|
||||
});
|
||||
|
||||
it('handles tuple types without leaking parentheses', () => {
|
||||
expect(extractTypeNames('(u64, bool)')).toEqual([]);
|
||||
expect(extractTypeNames('(Coin, u64)')).toEqual(['Coin']);
|
||||
});
|
||||
|
||||
it('handles Move 2 function types without leaking pipes', () => {
|
||||
expect(extractTypeNames('|u64|u64')).toEqual([]);
|
||||
expect(extractTypeNames('|Coin|bool')).toEqual(['Coin']);
|
||||
expect(extractTypeNames('|&mut Vault|u64')).toEqual(['Vault']);
|
||||
});
|
||||
|
||||
it('does not treat function-type abilities as nominal types', () => {
|
||||
expect(extractTypeNames('|u64|bool has copy + drop')).toEqual([]);
|
||||
expect(extractTypeNames('|Coin|Vault has store + key')).toEqual(['Coin', 'Vault']);
|
||||
expect(
|
||||
extractTypeNames('0x1::table::Table<address, |address, u64|bool has copy + drop + store>'),
|
||||
).toEqual(['0x1::table::Table']);
|
||||
expect(extractTypeNames('vector<|Coin|Vault has store + key>')).toEqual(['Coin', 'Vault']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
FUNCTION_SCHEMA,
|
||||
MODULE_SCHEMA,
|
||||
STRUCT_SCHEMA,
|
||||
TYPE_SCHEMA,
|
||||
} from '../../src/core/lbug/schema.js';
|
||||
import {
|
||||
getNodeTableCsvHeader,
|
||||
|
|
@ -20,6 +21,7 @@ const schemas: Record<LayoutTableName, string> = {
|
|||
Function: FUNCTION_SCHEMA,
|
||||
Struct: STRUCT_SCHEMA,
|
||||
Enum: ENUM_SCHEMA,
|
||||
Type: TYPE_SCHEMA,
|
||||
EnumVariant: ENUM_VARIANT_SCHEMA,
|
||||
Const: CONST_SCHEMA,
|
||||
Module: MODULE_SCHEMA,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -266,6 +266,10 @@ describe('runFullAnalysis metadata reconciliation (mocked pipeline)', () => {
|
|||
repoPath,
|
||||
totalFileCount: 1,
|
||||
graph: { forEachNode: () => undefined },
|
||||
standaloneIngest: {
|
||||
ingestedFiles: new Set<string>(),
|
||||
consistencyIssues: [],
|
||||
},
|
||||
})),
|
||||
}));
|
||||
// Avoid touching the global registry / repo .gitnexusignore from a unit test.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ import { createTempDir } from '../helpers/test-db.js';
|
|||
|
||||
const SIMULATED_MISSING_FTS_INDEX_NAME = 'File.file_fts';
|
||||
const PLACEHOLDER_GRAPH_STORE_CONTENT = 'fixture';
|
||||
const emptyMoveIngestOutput = () => ({
|
||||
ingestedFiles: new Set<string>(),
|
||||
consistencyIssues: [],
|
||||
});
|
||||
|
||||
const createPlaceholderGraphStore = async (lbugPath: string): Promise<void> => {
|
||||
// Repair mode gates on existence before `initLbug` takes over open/validate.
|
||||
|
|
@ -84,6 +88,7 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => {
|
|||
const runPipelineFromRepo = vi.fn(async (repoPath: string) => ({
|
||||
repoPath,
|
||||
graph: { forEachNode: () => undefined },
|
||||
standaloneIngest: emptyMoveIngestOutput(),
|
||||
}));
|
||||
vi.doMock('../../src/core/ingestion/pipeline.js', () => ({
|
||||
runPipelineFromRepo,
|
||||
|
|
@ -113,6 +118,7 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => {
|
|||
const runPipelineFromRepo = vi.fn(async (repoPath: string) => ({
|
||||
repoPath,
|
||||
graph: { forEachNode: () => undefined },
|
||||
standaloneIngest: emptyMoveIngestOutput(),
|
||||
}));
|
||||
vi.doMock('../../src/core/ingestion/pipeline.js', () => ({
|
||||
runPipelineFromRepo,
|
||||
|
|
@ -499,8 +505,15 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => {
|
|||
repoPath,
|
||||
// Full-analyze path only needs `forEachNode` before the FTS phase.
|
||||
graph: { forEachNode: () => undefined },
|
||||
standaloneIngest: emptyMoveIngestOutput(),
|
||||
})),
|
||||
}));
|
||||
// Avoid touching the global registry / repo .gitnexusignore from a unit test.
|
||||
vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({
|
||||
...(await importActual<typeof import('../../src/storage/repo-manager.js')>()),
|
||||
registerRepo: vi.fn(async () => 'verify-fail-repo'),
|
||||
ensureGitNexusIgnored: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const tmpRepo = await createTempDir('gitnexus-run-analyze-full-verify-fail-');
|
||||
try {
|
||||
|
|
@ -564,6 +577,7 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => {
|
|||
repoPath,
|
||||
totalFileCount: 1,
|
||||
graph: { forEachNode: () => undefined },
|
||||
standaloneIngest: emptyMoveIngestOutput(),
|
||||
})),
|
||||
}));
|
||||
// Avoid touching the global registry / repo .gitnexusignore from a unit test.
|
||||
|
|
@ -635,6 +649,7 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => {
|
|||
repoPath,
|
||||
totalFileCount: 1,
|
||||
graph: { forEachNode: () => undefined },
|
||||
standaloneIngest: emptyMoveIngestOutput(),
|
||||
})),
|
||||
}));
|
||||
vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({
|
||||
|
|
@ -769,6 +784,7 @@ describe('runFullAnalysis wipe-and-restore vector-index stamp (tri-review 466951
|
|||
runPipelineFromRepo: vi.fn(async (repoPath: string) => ({
|
||||
repoPath,
|
||||
totalFileCount: 1,
|
||||
standaloneIngest: emptyMoveIngestOutput(),
|
||||
graph: {
|
||||
forEachNode: (fn: (node: typeof stubNode) => void) => fn(stubNode),
|
||||
getNode: (id: string) => (id === RESTORED_NODE_ID ? stubNode : undefined),
|
||||
|
|
@ -871,6 +887,7 @@ describe('runFullAnalysis dirty-recovery parking failure fails fast (this shippi
|
|||
repoPath,
|
||||
totalFileCount: 1,
|
||||
graph: { forEachNode: () => undefined },
|
||||
standaloneIngest: emptyMoveIngestOutput(),
|
||||
}));
|
||||
// Wholesale factory EXCEPT LbugWipeError: run-analyze throws the class it
|
||||
// imports from this module, and the test asserts on that very type — so
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: '' },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue