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