Compare commits

...
Author SHA1 Message Date
abhigyantrumio 7abb2c9bb5 fix(move): harden real-repo Aptos ingestion 2026-07-21 16:36:01 +05:30
Abhigyan Patwari 8127b75c0f Merge pull request #2524 from zwxxb/upstream/move-compiler-ingestion
feat(move): add compiler-first graph ingestion
2026-07-21 15:49:36 +05:30
abhigyantrumio be1dad7f70 test: cover Move language provider 2026-07-21 15:25:15 +05:30
abhigyantrumio 6c8ed81783 test: update Move CSV emit fingerprint 2026-07-21 15:00:05 +05:30
Gergő Magyar e3a3d6b58c Merge branch 'main-aptos' into upstream/move-compiler-ingestion 2026-07-21 10:17:58 +01:00
abhigyantrumio 5c6f887b63 ci: gate Aptos releases with approval 2026-07-21 14:43:21 +05:30
abhigyantrumio 2582b9351c ci: bootstrap checks for main-aptos 2026-07-21 14:41:31 +05:30
zwxxb edb8afb726 fix(move): harden installer checksum matching 2026-07-21 10:55:56 +02:00
zwxxb 31933c706a fix(move): clean up move-flow startup failures 2026-07-20 10:35:23 +02:00
zwxxb d3998f4788 ci(release): add Aptos npm release channel 2026-07-18 16:03:10 +02:00
zwxxbandCursor a67ea56320 docs(move): reference upstream Move tooling
Document the compiler-first ingestion flow and link contributors to Aptos Core, MoveFlow 2.0.0, and the Move Book.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 15:37:43 +02:00
zwxxb 30fdb059fd fix(move): align 2.0 integration with runtime
Move availability and discovery into focused modules, accept literal executable paths, and split resource queries for LadybugDB compatibility.
2026-07-18 15:37:42 +02:00
zwxxbandGotenJBZ 1c58114e57 feat(move): add compiler-first graph ingestion
Integrate Move compiler facts across indexing, persistence, and MCP queries while keeping shared ingestion language-agnostic and incremental updates safe.

Co-authored-by: GotenJBZ <marco@freemaker.it>
2026-07-18 15:37:42 +02:00
100 changed files with 6171 additions and 140 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"plugins": [
{
"name": "gitnexus",
"version": "1.6.9",
"version": "1.6.9-aptos",
"source": {
"source": "local",
"path": "./gitnexus-claude-plugin"
+1 -1
View File
@@ -11,7 +11,7 @@
"plugins": [
{
"name": "gitnexus",
"version": "1.6.9",
"version": "1.6.9-aptos",
"source": "./gitnexus-claude-plugin",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase."
}
+38
View File
@@ -0,0 +1,38 @@
# Aptos release line
The `main-aptos` branch carries the compiler-backed Move integration separately
from the compilerless `main` release line. Keep it current by regularly merging
`main` into `main-aptos` through a pull request. Do not rewrite the shared branch:
published Aptos tags must remain reachable from it.
## Release an Aptos version
1. Synchronize `main-aptos` with `main` and pass the full CI suite.
2. Choose an unused npm prerelease version: `X.Y.Z-aptos` for the first release
on a base version, then `X.Y.Z-aptos.N` for later revisions.
3. From `gitnexus/`, run:
```bash
npm version X.Y.Z-aptos --no-git-tag-version
```
This synchronizes `package.json`, `package-lock.json`, and the plugin
manifests. Commit the version change to `main-aptos`.
4. Tag that versioned commit and push the tag:
```bash
git tag -a vX.Y.Z-aptos -m vX.Y.Z-aptos
git push origin main-aptos
git push origin vX.Y.Z-aptos
```
The publish workflow rejects tags that do not match the package version or are
not contained in `main-aptos`. A valid tag publishes the exact npm version under
the `aptos` dist-tag, so users can install either form:
```bash
npm install gitnexus@X.Y.Z-aptos
npm install gitnexus@aptos
```
Aptos releases are GitHub prereleases and never update npm's `latest` tag.
@@ -75,7 +75,7 @@ on:
type: boolean
default: true
pull_request:
branches: [main]
branches: [main, main-aptos]
paths:
# Any build-affecting change under a vendored grammar triggers a rebuild —
# not just a version bump — so editing the vendored source (parser.c,
+25 -1
View File
@@ -33,6 +33,20 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# Cache the move-flow binary the postinstall probe downloads into
# vendor/move-flow/<platform>/. On cache hit, the probe is idempotent
# (skips the download); on miss, it downloads + verifies a pinned
# aptos-labs/aptos-ai release and soft-fails if anything goes wrong (the suite stays green;
# the live Move test simply skips when the binary is absent). Keep the
# cache key tied to the MOVE_FLOW_VERSION constant in
# gitnexus/scripts/install-move-flow.cjs.
- name: Cache move-flow binary
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: gitnexus/vendor/move-flow
key: ${{ runner.os }}-move-flow-v2.0.0
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
@@ -194,7 +208,9 @@ jobs:
runs-on: ${{ matrix.os }}
timeout-minutes: 25
# Same guarantee on the platform-sensitive runners: FTS-dependent suites in
# the cross-platform subset must run, not silently skip.
# the cross-platform subset must run, not silently skip. Move ingestion isn't
# exercised by this subset either, so GITNEXUS_SKIP_MOVE_FLOW below drops the
# postinstall move-flow probe as pure wasted bandwidth here.
#
# GITNEXUS_E2E_CLI=dist: the e2e suites spawn the CLI ~50 times; each spawn via
# `node --import tsx src/cli/index.ts` re-transpiles the whole CLI, and Windows
@@ -204,6 +220,7 @@ jobs:
# THIS job: the Ubuntu coverage job leaves it unset, so it keeps exercising the
# tsx-on-source path in CI (both entry points stay covered).
env:
GITNEXUS_SKIP_MOVE_FLOW: '1'
GITNEXUS_REQUIRE_FTS: '1'
GITNEXUS_E2E_CLI: dist
# #2449: hosted Windows runners intermittently push the busiest shard past
@@ -260,6 +277,9 @@ jobs:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
# ABI assert only exercises tree-sitter grammars; move-flow isn't needed.
env:
GITNEXUS_SKIP_MOVE_FLOW: '1'
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
@@ -290,6 +310,10 @@ jobs:
os: [windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 15
# Packaging smoke doesn't run the Move ingestion path; skip the probe so
# the global `npm install -g` doesn't depend on a release-asset fetch.
env:
GITNEXUS_SKIP_MOVE_FLOW: '1'
steps:
# persist-credentials: false — this job runs npm pack + npm install -g
# from a tarball and never pushes back; the token in .git/config would
+1 -1
View File
@@ -2,7 +2,7 @@ name: CI
on:
pull_request:
branches: [main]
branches: [main, main-aptos]
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
workflow_call:
+1 -1
View File
@@ -8,7 +8,7 @@ name: CodeQL
on:
pull_request:
branches: [main]
branches: [main, main-aptos]
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
push:
branches: [main]
+1 -1
View File
@@ -8,7 +8,7 @@ name: Dependency Review
on:
pull_request:
branches: [main]
branches: [main, main-aptos]
permissions:
contents: read
+1 -1
View File
@@ -8,7 +8,7 @@ name: Gitleaks
on:
pull_request:
branches: [main]
branches: [main, main-aptos]
push:
branches: [main]
+1 -1
View File
@@ -3,7 +3,7 @@ name: PR Description Check
on:
pull_request:
types: [opened, edited, reopened]
branches: [main]
branches: [main, main-aptos]
permissions:
pull-requests: write
+90 -30
View File
@@ -5,7 +5,7 @@ name: Publish
# images. Replaces the former two-workflow design — see issue #1609 for the
# double-publish race this unification closes.
#
# Two release modes, both routed through this file:
# Three release modes, all routed through this file:
# • Release candidate (rc) — triggered by push to `main` or workflow_dispatch.
# The RC path computes the next rc version, applies it in-CI, pushes a
# detached release commit with v<X.Y.Z>-rc.<N> + rc/<SHA> marker
@@ -14,6 +14,10 @@ name: Publish
# • Stable — triggered by push of a v<X.Y.Z> tag (no -rc.*
# suffix). Verifies package.json matches the tag, publishes to npm with
# --tag latest, creates a stable GitHub Release. No docker (RC-only).
# • Aptos — triggered by push of a v<X.Y.Z>-aptos[.<N>]
# tag. Verifies package.json matches the tag and that the tagged commit is
# contained in main-aptos, publishes to npm with --tag aptos, and creates
# a GitHub prerelease. No docker (RC-only).
#
# ⚠️ SELF-TRIGGER INVARIANT — DO NOT WEAKEN ⚠️
# The `tags:` filter below uses a negative glob `'!v*-rc.*'` to prevent the
@@ -123,13 +127,16 @@ jobs:
;;
refs/tags/v*)
# The trigger filter already excluded v*-rc.* tags. Anything
# reaching here is either a stable semver or a malformed v*.
# reaching here is a stable semver, an Aptos release, or a
# malformed v* tag.
TAG="${GH_REF#refs/tags/}"
if [[ "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
MODE="stable"
elif [[ "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-aptos(\.[0-9]+)?$ ]]; then
MODE="aptos"
else
echo "::error::malformed v* tag rejected: ${REF_NAME_SAFE}"
echo "::error::stable tags must match ^v[0-9]+\\.[0-9]+\\.[0-9]+\$"
echo "::error::accepted tags are vX.Y.Z or vX.Y.Z-aptos[.N]"
exit 1
fi
;;
@@ -255,18 +262,33 @@ jobs:
fi
# ── Phase 3: reusable CI gate ──────────────────────────────────────────────
# Runs for both rc (when guard says go) and stable. No `secrets:` passed —
# Runs for rc (when guard says go), stable, and Aptos. No `secrets:` passed —
# ci.yml and its entire reusable-workflow chain (ci-quality, ci-tests,
# ci-e2e, ci-report) reference zero `secrets.*` values;
# passing any would be unused surface. GITHUB_TOKEN is implicit.
ci:
needs: [route, rc-guard]
if: ${{ always() && (needs.route.outputs.mode == 'stable' || needs.rc-guard.outputs.should_run == 'true') }}
if: ${{ always() && (needs.route.outputs.mode == 'stable' || needs.route.outputs.mode == 'aptos' || needs.rc-guard.outputs.should_run == 'true') }}
uses: ./.github/workflows/ci.yml
permissions:
contents: read
actions: read
# Aptos releases are maintained on a separate branch and npm dist-tag.
# Require an explicit maintainer approval after CI before publishing them.
aptos-release-gate:
name: Approve Aptos npm release
needs: [route, ci]
if: ${{ always() && needs.route.outputs.mode == 'aptos' && needs.ci.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 5
environment:
name: aptos-release
permissions: {}
steps:
- name: Approval recorded
run: echo "Aptos release approved."
# ── Phase 4: publish to npm + push refs (RC path) ──────────────────────────
# INVARIANT: `timeout-minutes` MUST stay below the App-token TTL (~60 min
# for actions/create-github-app-token installation tokens). The atomic
@@ -276,8 +298,8 @@ jobs:
# `Create and push rc tags` step instead.
publish:
name: Publish to npm
needs: [route, rc-guard, ci]
if: ${{ always() && needs.ci.result == 'success' && (needs.route.outputs.mode == 'stable' || needs.rc-guard.outputs.should_run == 'true') }}
needs: [route, rc-guard, ci, aptos-release-gate]
if: ${{ always() && needs.ci.result == 'success' && (needs.route.outputs.mode == 'stable' || needs.rc-guard.outputs.should_run == 'true' || (needs.route.outputs.mode == 'aptos' && needs.aptos-release-gate.result == 'success')) }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
@@ -322,6 +344,9 @@ jobs:
# which the action resolves correctly.
client-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
permission-contents: write
permission-workflows: write
permission-metadata: read
# ── Separate checkout steps per mode ─────────────────────────────────
# Conditional `token:` expressions are footguns: empty string passed to
@@ -347,13 +372,14 @@ jobs:
# `Create and push rc tags` step below.
persist-credentials: false
- name: Checkout (stable)
if: needs.route.outputs.mode == 'stable'
- name: Checkout (tagged release)
if: needs.route.outputs.mode == 'stable' || needs.route.outputs.mode == 'aptos'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# No `token:` — actions/checkout uses GITHUB_TOKEN by default. Stable
# path performs no git pushes; the default scope is sufficient.
# No `token:` — actions/checkout uses GITHUB_TOKEN by default. Tagged
# release paths perform no git pushes; the default scope is sufficient.
with:
# No git pushes from the stable path either. Skip credential
fetch-depth: 0
# No git pushes from tagged release paths. Skip credential
# persistence (artipacked audit).
persist-credentials: false
@@ -404,28 +430,48 @@ jobs:
run: npm ci
working-directory: gitnexus
# ── Stable-only: verify the tag and package.json agree ───────────────
- name: Verify version consistency (stable)
if: needs.route.outputs.mode == 'stable'
# ── Tagged releases: verify tag, source branch, and package version ──
- name: Verify version consistency (tagged release)
if: needs.route.outputs.mode == 'stable' || needs.route.outputs.mode == 'aptos'
shell: bash
working-directory: gitnexus
env:
RELEASE_MODE: ${{ needs.route.outputs.mode }}
run: |
set -euo pipefail
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
# Stable mode REJECTS prerelease suffixes — those are filtered at
# trigger by the negative-glob filter, but defend at the bash layer too.
if ! [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Stable tag must be ^v[0-9]+.[0-9]+.[0-9]+$ — got v$TAG_VERSION"
exit 1
fi
case "$RELEASE_MODE" in
stable)
if ! [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Stable tag must be vX.Y.Z — got v$TAG_VERSION"
exit 1
fi
;;
aptos)
if ! [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-aptos(\.[0-9]+)?$ ]]; then
echo "::error::Aptos tag must be vX.Y.Z-aptos[.N] — got v$TAG_VERSION"
exit 1
fi
# Aptos releases must come from the maintained main-aptos line,
# not from an arbitrary commit carrying a syntactically valid tag.
git fetch --no-tags origin main-aptos:refs/remotes/origin/main-aptos
if ! git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main-aptos; then
echo "::error::Aptos tag v$TAG_VERSION is not contained in origin/main-aptos."
exit 1
fi
;;
*)
echo "::error::Unexpected tagged release mode: $RELEASE_MODE"
exit 1
;;
esac
PKG_VERSION=$(node -p "require('./package.json').version")
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
echo "::error::Tag version (v$TAG_VERSION) does not match package.json version ($PKG_VERSION)"
exit 1
fi
# Stable releases carry their version bump on main via the release
# PR, so the manifest surfaces must already be in sync — refuse to
# publish a stable whose manifests drifted (#2445).
# Tagged releases carry their version in the tagged tree, so all
# manifest surfaces must already be in sync (#2445).
node scripts/sync-plugin-manifests.mjs --check
echo "Version verified: $PKG_VERSION"
@@ -608,7 +654,7 @@ jobs:
shell: bash
working-directory: gitnexus
env:
NPM_TAG: ${{ needs.route.outputs.mode == 'rc' && 'rc' || 'latest' }}
NPM_TAG: ${{ needs.route.outputs.mode == 'rc' && 'rc' || needs.route.outputs.mode == 'aptos' && 'aptos' || 'latest' }}
run: npm publish --dry-run --tag "$NPM_TAG"
# ── Acquire the "rc lock" BEFORE publishing (idempotency anchor) ─────
@@ -725,9 +771,9 @@ jobs:
echo "release_sha=$RELEASE_SHA"
} >> "$GITHUB_OUTPUT"
- name: Set vtag (stable)
- name: Set vtag (tagged release)
id: stable-vtag
if: needs.route.outputs.mode == 'stable'
if: needs.route.outputs.mode == 'stable' || needs.route.outputs.mode == 'aptos'
shell: bash
# github.ref_name flows in via env to avoid templating into the
# shell source (template-injection audit). Even though refs are
@@ -770,6 +816,12 @@ jobs:
exit 1
fi
;;
aptos)
if ! [[ "$VTAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-aptos(\.[0-9]+)?$ ]]; then
echo "::error::vtag '${VTAG}' does not match Aptos shape ^vX.Y.Z-aptos[.N]$"
exit 1
fi
;;
*)
echo "::error::unknown mode '${MODE}' at vtag integrity gate."
exit 1
@@ -778,6 +830,7 @@ jobs:
echo "vtag verified: ${VTAG} (mode=${MODE})"
echo "vtag=${VTAG}" >> "$GITHUB_OUTPUT"
echo "version=${VTAG#v}" >> "$GITHUB_OUTPUT"
# npm Trusted Publishing (GA'd 2025-07-31). OIDC authentication only
# engages when no npm credential is configured anywhere — the absence
@@ -808,7 +861,7 @@ jobs:
shell: bash
working-directory: gitnexus
env:
NPM_TAG: ${{ needs.route.outputs.mode == 'rc' && 'rc' || 'latest' }}
NPM_TAG: ${{ needs.route.outputs.mode == 'rc' && 'rc' || needs.route.outputs.mode == 'aptos' && 'aptos' || 'latest' }}
run: npm publish --access public --tag "$NPM_TAG"
# ── Stable-only: pull CHANGELOG body if present ──────────────────────
@@ -834,8 +887,10 @@ jobs:
name: >-
${{ needs.route.outputs.mode == 'rc'
&& format('Release Candidate {0}', steps.vtag-gate.outputs.vtag)
|| needs.route.outputs.mode == 'aptos'
&& format('Aptos {0}', steps.vtag-gate.outputs.vtag)
|| steps.vtag-gate.outputs.vtag }}
prerelease: ${{ needs.route.outputs.mode == 'rc' }}
prerelease: ${{ needs.route.outputs.mode == 'rc' || needs.route.outputs.mode == 'aptos' }}
make_latest: ${{ needs.route.outputs.mode == 'stable' && 'true' || 'false' }}
# Stable: prefer CHANGELOG body, fall back to auto-generated.
# RC: always auto-generated + the prerelease body block below.
@@ -844,9 +899,14 @@ jobs:
&& '/tmp/release-notes.md' || '' }}
generate_release_notes: >-
${{ needs.route.outputs.mode == 'rc'
|| needs.route.outputs.mode == 'aptos'
|| steps.changelog.outputs.fallback == 'true' }}
body: >-
${{ needs.route.outputs.mode == 'rc' && format(
${{ needs.route.outputs.mode == 'aptos' && format(
'Aptos-specific GitNexus distribution from `main-aptos`.{0}{0}**npm:** `npm install gitnexus@{1}`{0}**npm channel:** `npm install gitnexus@aptos`{0}{0}This release includes the compiler-backed Move integration and is maintained separately from the compilerless core release.',
'\n',
steps.vtag-gate.outputs.version
) || needs.route.outputs.mode == 'rc' && format(
'Automated release candidate build from `main`.{0}{0}**npm:** `npm install gitnexus@rc`{0}**Version:** `{1}`{0}**Target base:** `{2}` (rc #{3}){0}**Source commit (main):** {4}{0}**Release commit (versioned tree):** {5}{0}{0}Release candidates are pre-stable builds intended for early testing. Stable releases remain on the `latest` dist-tag.',
'\n',
steps.rc-version.outputs.rc_version,
+1 -1
View File
@@ -11,7 +11,7 @@ name: Workflow Lint
on:
pull_request:
branches: [main]
branches: [main, main-aptos]
paths:
- '.github/**'
+4
View File
@@ -87,6 +87,10 @@ GitNexus.sln
gitnexus/vendor/**/build/
gitnexus/vendor/**/node_modules/
# move-flow binary is downloaded at install time into vendor/move-flow/
# (postinstall probe), never committed. See gitnexus/scripts/install-move-flow.cjs.
gitnexus/vendor/move-flow/
/github/scripts/triage/__pycache__/
.claude-flow/
@@ -1,7 +1,7 @@
{
"name": "gitnexus",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
"version": "1.6.9",
"version": "1.6.9-aptos",
"author": {
"name": "GitNexus"
},
@@ -1,7 +1,7 @@
{
"name": "gitnexus",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
"version": "1.6.9",
"version": "1.6.9-aptos",
"skills": "./skills",
"mcpServers": "./.mcp.json",
"hooks": "./hooks/hooks.json",
+65
View File
@@ -45,6 +45,9 @@ export type NodeLabel =
| 'Section'
| 'Route'
| 'Tool'
// Move/Aptos: one EnumVariant node per Move 2 enum variant, linked to its
// Enum via CONTAINS. Sourced from the move-flow `facts` query.
| 'EnumVariant'
// Taint/PDG substrate (issue #2080). Intra-procedural control-flow node.
// Emitted by no phase yet — M1 (#2081) populates these behind an opt-in.
| 'BasicBlock';
@@ -95,6 +98,54 @@ export type NodeProperties = {
responseKeys?: string[];
errorKeys?: string[];
middleware?: string[];
// ── Move/Aptos (compiler-first, sourced from the move-flow `facts` query) ──
// All fields below are populated by the thin facts→graph mapper. They are
// optional and additive; non-Move nodes never set them.
qualifiedName?: string;
moduleQualifiedName?: string;
moduleAddress?: string;
visibilityModifier?: string;
isEntry?: boolean;
isView?: boolean;
isInline?: boolean;
isNative?: boolean;
isResource?: boolean;
isEvent?: boolean;
isTest?: boolean;
isTestOnly?: boolean;
hasSpec?: boolean;
/** Subset of {'copy','drop','store','key'} — Move struct/enum abilities. */
abilities?: string[];
/** Resource qualified names from `acquiresInferred` (move-flow facts). */
acquires?: string[];
/** Generic type parameters (facts `typeParams`: abilities → constraints). */
typeParams?: Array<{ name: string; constraints?: string[]; isPhantom?: boolean }>;
/** Resource qualified names reached transitively (move-flow function_usage). */
usedTypes?: string[];
/** Full attribute name list (`["view","event","test_only",...]`). */
attributes?: string[];
/** Full attribute payload as JSON - nested `args` and assignment `value`s,
* e.g. `[{"name":"resource_group_member","args":[{"name":"group","value":"0xa::vault::Group"}]}]`.
* Persisted without a code consumer, deliberately: preserving attribute
* args/values is the whole point, and the raw Cypher surface (MCP `cypher`
* tool) makes the column user-queryable directly - re-deriving it later
* would cost another full re-index. Unset (not `"[]"`) when the symbol has
* no attributes. */
attributesJson?: string;
/** Parsed `#[expected_failure(...)]` payload (true for the bare form). */
expectedFailure?: true | Record<string, string>;
/** Move 1 vs Move 2 distinction: `'struct'` or `'enum'`. */
moveDeclarationKind?: 'struct' | 'enum';
/** EnumVariant shape (facts `variants[].kind`). */
variantKind?: 'unit' | 'positional' | 'named';
/** Struct / variant fields (facts `fields`: name/type/positional). */
fields?: Array<{ name: string; type: string; positional?: boolean }>;
/**
* Node-location precision. `'precise'` = per-symbol file/span from the
* move-flow `facts` query; `'module'` = only the containing module/type file
* is known; `'package'` = coarse package-root fallback.
*/
locationFidelity?: 'precise' | 'module' | 'package';
// BasicBlock (taint/PDG substrate, issue #2080) — reuses filePath/startLine/endLine.
text?: string;
/** BasicBlock: space-joined leaf callee names invoked in the block — the
@@ -109,6 +160,7 @@ export type RelationshipType =
| 'CALLS'
| 'INHERITS'
| 'METHOD_OVERRIDES'
| 'OVERRIDES'
| 'METHOD_IMPLEMENTS'
| 'IMPORTS'
| 'USES'
@@ -147,6 +199,19 @@ export type RelationshipType =
* `reason` encodes the event name: `vue-event: @<eventName>`.
* Complements `EMITS_EVENT`; together they enable Cypher queries that
* trace which handlers receive which component's emitted events. */
// ── Move/Aptos edges (compiler-first via the move-flow `facts` query) ──
/** `friend` module declaration: source module → friend module. */
| 'FRIEND_OF'
/** Function reads a resource (`facts.resourceAccess.reads`). */
| 'READS_RESOURCE'
/** Function writes a resource (`facts.resourceAccess.writes`). */
| 'WRITES_RESOURCE'
/** Function acquires a resource (`facts.acquiresInferred`). */
| 'ACQUIRES'
/** Function signature mentions a Move struct/enum type. */
| 'USES_TYPE'
/** Reserved: function emits an event struct (not yet sourced from facts). */
| 'EMITS'
| 'BINDS_EVENT_HANDLER'
/** Vue component event system: a component calls `emit('eventName', ...)`
* or `this.$emit('eventName', ...)`, advertising that it can emit that event.
@@ -52,6 +52,7 @@ const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = {
[SupportedLanguages.Swift]: ['.swift'],
[SupportedLanguages.Dart]: ['.dart'],
[SupportedLanguages.Vue]: ['.vue'],
[SupportedLanguages.Move]: ['.move'],
[SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'],
} satisfies Record<SupportedLanguages, readonly string[]>; // Ensure exhaustiveness
@@ -120,6 +121,7 @@ const SYNTAX_MAP: Record<SupportedLanguages, string> = {
[SupportedLanguages.Swift]: 'swift',
[SupportedLanguages.Dart]: 'dart',
[SupportedLanguages.Vue]: 'typescript',
[SupportedLanguages.Move]: 'rust',
[SupportedLanguages.Cobol]: 'cobol',
} satisfies Record<SupportedLanguages, string>; // Ensure exhaustiveness
+2
View File
@@ -20,6 +20,8 @@ export enum SupportedLanguages {
Swift = 'swift',
Dart = 'dart',
Vue = 'vue',
/** Aptos Move — compiler-first via move-flow MCP; no tree-sitter, no raw-source scanning. */
Move = 'move',
/** Standalone regex processor — no tree-sitter, no LanguageProvider. */
Cobol = 'cobol',
}
@@ -21,6 +21,7 @@ export const NODE_TABLES = [
'Section',
'Struct',
'Enum',
'EnumVariant',
'Macro',
'Typedef',
'Union',
@@ -70,6 +71,13 @@ export const REL_TYPES = [
'WRAPS',
'QUERIES',
'INJECTS',
// Move/Aptos edges (compiler-first via move-flow `facts` query)
'FRIEND_OF',
'READS_RESOURCE',
'WRITES_RESOURCE',
'ACQUIRES',
'USES_TYPE',
'EMITS',
// Taint/PDG substrate (issue #2080) — reserved edge types, emitted by no
// phase yet (CFG → M1, REACHING_DEF → M2, TAINTED/SANITIZES/TAINT_PATH →
// M3/M4). REACHING_DEF's variable name rides the relation's `reason` column.
+1
View File
@@ -6,6 +6,7 @@ export type PipelinePhase =
| 'idle'
| 'extracting'
| 'structure'
| 'moveIngest'
| 'parsing'
| 'imports'
| 'calls'
@@ -40,6 +40,7 @@ export const LanguageClassifications: Readonly<Record<SupportedLanguages, Langua
[SupportedLanguages.Swift]: 'production',
[SupportedLanguages.Dart]: 'production',
[SupportedLanguages.Vue]: 'experimental',
[SupportedLanguages.Move]: 'experimental',
[SupportedLanguages.Cobol]: 'experimental',
};
+2
View File
@@ -38,6 +38,7 @@ export const NODE_COLORS: Record<NodeLabel, string> = {
Template: '#a78bfa', // Violet light - like Type
Route: '#f43f5e', // Rose - like Process
Tool: '#a855f7', // Purple - like Project
EnumVariant: '#fb923c', // Orange light - a Move enum's variant (child of Enum)
BasicBlock: '#475569', // Slate darker - control-flow node (muted, taint/PDG substrate)
};
@@ -80,6 +81,7 @@ export const NODE_SIZES: Record<NodeLabel, number> = {
Template: 3, // Like Type
Route: 5, // Like Enum
Tool: 5, // Like Enum
EnumVariant: 3, // Move enum variant - small leaf
BasicBlock: 2, // Tiny - control-flow node (taint/PDG substrate)
};
@@ -101,7 +101,7 @@ describe('validRelType – REL_TYPES membership', () => {
['SQL keyword', 'DROP'],
['injection attempt', 'CALLS;DELETE'],
['lowercase', 'calls'],
['nonexistent type', 'FRIEND_OF'],
['nonexistent type', 'NOT_A_RELATION'],
['padded', ' CALLS '],
])('rejects invalid relation type: %s', (_desc, relType) => {
expect(validRelType(relType)).toBe(false);
@@ -1,5 +1,5 @@
{
"fingerprint": "b169463b7d02185d757b6d8601db6215ac6e7b2a20e52fb0f1276cc153836bd4",
"fingerprint": "3adcbf99e77b95c7a7d4779f0688f10dd1c064fa9fbeabfa060885effcac37c9",
"scaling_budget": 1.8,
"max_ms_large": 1000,
"_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted — binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`."
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "gitnexus",
"version": "1.6.9",
"version": "1.6.9-aptos",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
"version": "1.6.9",
"version": "1.6.9-aptos",
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
+8 -3
View File
@@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.6.9",
"version": "1.6.9-aptos",
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
"author": "Abhigyan Patwari",
"license": "PolyForm-Noncommercial-1.0.0",
@@ -35,7 +35,12 @@
"hooks",
"scripts",
"skills",
"vendor",
"vendor/leiden",
"vendor/tree-sitter-c",
"vendor/tree-sitter-dart",
"vendor/tree-sitter-kotlin",
"vendor/tree-sitter-proto",
"vendor/tree-sitter-swift",
"web"
],
"scripts": {
@@ -49,7 +54,7 @@
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:cross-platform": "tsx scripts/run-cross-platform.ts",
"postinstall": "node scripts/build-tree-sitter-grammars.cjs",
"postinstall": "node scripts/build-tree-sitter-grammars.cjs && node scripts/install-move-flow.cjs",
"assert-publish-coverage": "node scripts/assert-publish-grammar-coverage.cjs",
"prepare": "node scripts/build.js",
"prepack": "node scripts/assert-publish-grammar-coverage.cjs && node scripts/build.js",
+371
View File
@@ -0,0 +1,371 @@
#!/usr/bin/env node
/**
* Optional native dependency probe: download a pinned `move-flow` release
* into `gitnexus/vendor/move-flow/<platform>/`.
*
* Same shape as `build-tree-sitter-grammars.cjs`: an opt-out env var, a soft-fail
* contract that always exits 0 (the gitnexus install must succeed even when
* the binary can't be provisioned), idempotency (skip when the on-disk
* version already matches), and an offline platform detect that warns and
* exits cleanly on unsupported targets.
*
* The runtime side (`tryCreateMoveFlowClient`) resolves the binary in this
* order: $MOVE_FLOW → bundled `vendor/move-flow/<platform>/move-flow[.exe]`
* → $PATH. When this probe fails, the Move ingest phase no-ops for non-Move
* repos and the analyze CLI emits a one-line stderr notice for Move repos.
*
* Supported env:
* GITNEXUS_SKIP_MOVE_FLOW=1 skip this probe
* GITNEXUS_MOVE_FLOW_VERSION=x.y.z override the pinned version
* GITNEXUS_MOVE_FLOW_REPO=owner/repo override the release repository
* GITNEXUS_MOVE_FLOW_TAG=tag override the release tag
* GITNEXUS_MOVE_FLOW_COMPAT=1 force Linux compat artifact
*/
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const https = require('node:https');
const crypto = require('node:crypto');
const { execFileSync } = require('node:child_process');
const os = require('node:os');
const { pipeline } = require('node:stream');
/** Pinned move-flow release from https://github.com/aptos-labs/aptos-ai. */
const MOVE_FLOW_VERSION = process.env.GITNEXUS_MOVE_FLOW_VERSION || '2.0.0';
const RELEASE_REPO = process.env.GITNEXUS_MOVE_FLOW_REPO || 'aptos-labs/aptos-ai';
const RELEASE_TAG = process.env.GITNEXUS_MOVE_FLOW_TAG || `move-flow-v${MOVE_FLOW_VERSION}`;
const RELEASE_BASE = `https://github.com/${RELEASE_REPO}/releases/download/${RELEASE_TAG}`;
const BIN_NAME = process.platform === 'win32' ? 'move-flow.exe' : 'move-flow';
const SKIP_FLAGS = ['GITNEXUS_SKIP_MOVE_FLOW', 'GITNEXUS_SKIP_OPTIONAL_GRAMMARS'];
const vendorRoot = path.join(__dirname, '..', 'vendor', 'move-flow');
function platformKey() {
const { platform, arch } = process;
if (platform === 'linux' && arch === 'x64') return 'linux-x64';
if (platform === 'linux' && arch === 'arm64') return 'linux-arm64';
if (platform === 'darwin' && arch === 'arm64') return 'darwin-arm64';
if (platform === 'darwin' && arch === 'x64') return 'darwin-x64';
if (platform === 'win32' && arch === 'x64') return 'win32-x64';
return null;
}
function targetBinaryPath(key) {
const dir = path.join(vendorRoot, key);
return { dir, file: path.join(dir, BIN_NAME) };
}
function versionMatches(file) {
try {
const out = execFileSync(file, ['--version'], {
encoding: 'utf8',
timeout: 5000,
stdio: ['ignore', 'pipe', 'ignore'],
});
return out.includes(MOVE_FLOW_VERSION);
} catch {
return false;
}
}
function linuxNeedsCompatBuild() {
if (process.platform !== 'linux') return false;
if (process.env.GITNEXUS_MOVE_FLOW_COMPAT === '1') return true;
if (process.arch === 'x64') {
try {
const cpuinfo = fs.readFileSync('/proc/cpuinfo', 'utf8');
if (!/(^|\s)avx2(\s|$)/m.test(cpuinfo)) return true;
} catch {
return true;
}
}
try {
const out = execFileSync('ldd', ['--version'], {
encoding: 'utf8',
timeout: 3000,
stdio: ['ignore', 'pipe', 'ignore'],
});
const match = /(\d+)\.(\d+)/.exec(out.split('\n')[0] ?? '');
if (!match) return false;
const major = Number(match[1]);
const minor = Number(match[2]);
return major < 2 || (major === 2 && minor < 34);
} catch {
return false;
}
}
function releaseTargetForPlatform(key) {
switch (key) {
case 'darwin-arm64':
return 'aarch64-apple-darwin';
case 'darwin-x64':
return 'x86_64-apple-darwin';
case 'linux-arm64':
return `aarch64-unknown-linux-gnu${linuxNeedsCompatBuild() ? '-compat' : ''}`;
case 'linux-x64':
return `x86_64-unknown-linux-gnu${linuxNeedsCompatBuild() ? '-compat' : ''}`;
case 'win32-x64':
return 'x86_64-pc-windows-msvc';
default:
return null;
}
}
function downloadToFile(url, dest, get = https.get) {
return new Promise((resolve, reject) => {
const tmp = `${dest}.partial`;
let settled = false;
const fail = (err) => {
if (settled) return;
settled = true;
try {
fs.rmSync(tmp, { force: true });
} catch {
/* the caller's temp-directory cleanup gets a second chance */
}
reject(err);
};
const followRedirect = (target, hops) => {
if (hops > 5) {
fail(new Error('too many redirects'));
return;
}
const req = get(target, (res) => {
const status = res.statusCode || 0;
if (status >= 300 && status < 400 && res.headers.location) {
res.on('error', fail);
res.resume();
followRedirect(new URL(res.headers.location, target).toString(), hops + 1);
return;
}
if (status !== 200) {
res.on('error', fail);
res.resume();
fail(new Error(`HTTP ${status} for ${target}`));
return;
}
// pipeline owns both streams and reports source (mid-download) and
// destination errors through one callback instead of allowing an
// unhandled stream 'error' event to escape the postinstall soft-fail.
pipeline(res, fs.createWriteStream(tmp), (err) => {
if (settled) return;
if (err) {
fail(err);
return;
}
try {
fs.renameSync(tmp, dest);
settled = true;
resolve();
} catch (renameErr) {
fail(renameErr);
}
});
});
req.on('error', fail);
};
followRedirect(url, 0);
});
}
function sha256(file) {
const hash = crypto.createHash('sha256');
hash.update(fs.readFileSync(file));
return hash.digest('hex');
}
function powershellExpandArchiveInvocation(archive, dest) {
const archiveEnv = 'GITNEXUS_MOVE_FLOW_ARCHIVE_PATH';
const destinationEnv = 'GITNEXUS_MOVE_FLOW_DESTINATION_PATH';
return {
args: [
'-NoProfile',
'-NonInteractive',
'-Command',
`Expand-Archive -LiteralPath $env:${archiveEnv} -DestinationPath $env:${destinationEnv} -Force`,
],
env: {
...process.env,
[archiveEnv]: archive,
[destinationEnv]: dest,
},
};
}
function extractZip(archive, dest) {
fs.mkdirSync(dest, { recursive: true });
try {
if (process.platform === 'win32') {
const ps = process.env.ComSpec ? 'powershell.exe' : 'powershell';
const invocation = powershellExpandArchiveInvocation(archive, dest);
execFileSync(ps, invocation.args, {
stdio: 'ignore',
timeout: 30000,
env: invocation.env,
});
return;
}
execFileSync('unzip', ['-q', archive, '-d', dest], { stdio: 'ignore', timeout: 30000 });
} catch (err) {
throw new Error(
`could not extract ${path.basename(archive)} (${err instanceof Error ? err.message : err}). ` +
'Install unzip, or set MOVE_FLOW to an existing move-flow binary.',
);
}
}
function findExtractedBinary(root) {
const stack = [root];
const names = new Set([BIN_NAME, 'move-flow']);
while (stack.length > 0) {
const current = stack.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(full);
} else if (names.has(entry.name)) {
return full;
}
}
}
return null;
}
function expectedSha(sumsText, assetName) {
for (const raw of sumsText.split('\n')) {
const line = raw.trim();
if (!line) continue;
// Format: "<sha256> <filename>" (BSD-style "SHA256 (file) = <hex>" also tolerated).
const m = /^([0-9a-f]{64})[ \t*]+(\S.*)$/i.exec(line);
if (m && m[2] === assetName) return m[1].toLowerCase();
const bsd = /^SHA256\s*\((.*)\)\s*=\s*([0-9a-f]{64})$/i.exec(line);
if (bsd && bsd[1] === assetName) return bsd[2].toLowerCase();
}
return null;
}
function verifyArchiveChecksum(sumsText, assetName, archive) {
const expected = expectedSha(sumsText, assetName);
if (!expected) return { status: 'missing' };
const actual = sha256(archive);
if (actual !== expected) return { status: 'mismatch', expected, actual };
return { status: 'match', expected, actual };
}
async function main() {
for (const flag of SKIP_FLAGS) {
if (process.env[flag] === '1') {
console.warn(`[move-flow] Skipping install (${flag}=1).`);
process.exit(0);
}
}
const key = platformKey();
if (!key) {
console.warn(
`[move-flow] Unsupported platform ${process.platform}-${process.arch} — skipping. ` +
'Set $MOVE_FLOW to provide a binary, or set GITNEXUS_SKIP_MOVE_FLOW=1 to silence this notice.',
);
process.exit(0);
}
const releaseTarget = releaseTargetForPlatform(key);
const { dir, file } = targetBinaryPath(key);
if (fs.existsSync(file) && versionMatches(file)) {
// Idempotent: already provisioned at the right version.
process.exit(0);
}
const assetName = `${RELEASE_TAG}-${releaseTarget}.zip`;
const sumsName = 'SHA256SUMS';
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'move-flow-'));
const tmpArchive = path.join(tmpDir, assetName);
const extractDir = path.join(tmpDir, 'extract');
const tmpSums = path.join(tmpDir, sumsName);
try {
await downloadToFile(`${RELEASE_BASE}/${sumsName}`, tmpSums);
await downloadToFile(`${RELEASE_BASE}/${assetName}`, tmpArchive);
const verification = verifyArchiveChecksum(
fs.readFileSync(tmpSums, 'utf8'),
assetName,
tmpArchive,
);
if (verification.status === 'missing') {
console.warn(
`[move-flow] ${sumsName} does not list ${assetName} — refusing to install. ` +
'Move ingestion will be unavailable. Non-Move functionality is unaffected.',
);
process.exit(0);
}
if (verification.status === 'mismatch') {
console.warn(
`[move-flow] Checksum mismatch for ${assetName} (expected ${verification.expected}, got ${verification.actual}) — refusing to install. ` +
'Move ingestion will be unavailable. Non-Move functionality is unaffected.',
);
process.exit(0);
}
extractZip(tmpArchive, extractDir);
const extracted = findExtractedBinary(extractDir);
if (!extracted) {
console.warn(
`[move-flow] ${assetName} did not contain ${BIN_NAME} — refusing to install. ` +
'Move ingestion will be unavailable. Non-Move functionality is unaffected.',
);
process.exit(0);
}
fs.mkdirSync(dir, { recursive: true });
fs.copyFileSync(extracted, file);
try {
fs.chmodSync(file, 0o755);
} catch {
/* no-op on Windows */
}
if (!versionMatches(file)) {
fs.rmSync(file, { force: true });
console.warn(
`[move-flow] Installed binary did not report version ${MOVE_FLOW_VERSION} — refusing to keep it. ` +
'Move ingestion will be unavailable. Non-Move functionality is unaffected.',
);
}
} catch (err) {
console.warn(`[move-flow] Download failed: ${err instanceof Error ? err.message : err}`);
console.warn(
'[move-flow] Move ingestion will be unavailable. Set $MOVE_FLOW to a local binary, ' +
'or set GITNEXUS_SKIP_MOVE_FLOW=1 to silence this notice. Non-Move functionality is unaffected.',
);
process.exit(0);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
module.exports = {
downloadToFile,
expectedSha,
sha256,
verifyArchiveChecksum,
powershellExpandArchiveInvocation,
};
if (require.main === module) {
main().catch((err) => {
// Never hard-fail the gitnexus install.
console.warn(`[move-flow] Unexpected error during install probe: ${err?.message ?? err}`);
process.exit(0);
});
}
+23
View File
@@ -40,9 +40,11 @@ import {
GitNexusRcError,
} from './analyze-config.js';
import { runFullAnalysis } from '../core/run-analyze.js';
import { repoHasMove } from '../core/move/discovery.js';
import { getRuntimeFingerprint } from '../core/platform/capabilities.js';
import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
import { warnMissingOptionalGrammars, getOptionalGrammarExtensions } from './optional-grammars.js';
import { warnIfMoveUnavailable } from './move-availability.js';
import { glob } from 'glob';
import fs from 'fs/promises';
import { cliError } from './cli-message.js';
@@ -81,6 +83,16 @@ const realStderrWrite = process.stderr.write.bind(process.stderr);
const realStdoutWrite = process.stdout.write.bind(process.stdout);
const writeFatalToStderr = (label: string, err: unknown): void => {
// Walk to the innermost error tagged `userActionable` (set on errors whose
// remediation is an operator action — missing/old external tool, stale
// config). Render those as a single line; stack/cause are noise for them.
for (let cur: unknown = err, depth = 0; cur instanceof Error && depth < 5; depth++) {
if ((cur as { userActionable?: boolean }).userActionable) {
realStderrWrite(`\n ${label}: ${cur.message}\n`);
return;
}
cur = (cur as { cause?: unknown }).cause;
}
const isErr = err instanceof Error;
const message = isErr ? err.message : String(err);
realStderrWrite(`\n ${label}: ${message}\n`);
@@ -1210,6 +1222,17 @@ const analyzeCommandImpl = async (
// Best-effort warning \u2014 never block analyze on the precheck.
}
// Move ingestion is compiler-first via move-flow; warn once if the repo
// has Move sources but no usable binary is reachable. Uses the shared
// `repoHasMove` helper so the precheck keys off the same Move.toml signal
// that the ingestion phase actually uses (a repo with loose `.move` files
// but no Move.toml would warn but ingest nothing).
try {
warnIfMoveUnavailable({ context: 'analyze', repoHasMove: await repoHasMove(repoPath) });
} catch {
// Best-effort \u2014 never block analyze on the precheck.
}
// KuzuDB migration cleanup is handled by runFullAnalysis internally.
// Note: --skills is handled after runFullAnalysis using the returned pipelineResult.
+14
View File
@@ -0,0 +1,14 @@
/** Warn once if a repo has Move sources but no usable move-flow binary is reachable. */
import { tryCreateMoveFlowClient } from '../core/move/mcp-client.js';
import { cliWarn } from './cli-message.js';
export function warnIfMoveUnavailable(opts: { repoHasMove: boolean; context?: string }): void {
if (!opts.repoHasMove) return;
if (tryCreateMoveFlowClient()) return;
const ctx = opts.context ? ` [${opts.context}]` : '';
cliWarn(
`GitNexus${ctx}: move-flow is unavailable — .move files will not be indexed. Reinstall without GITNEXUS_SKIP_MOVE_FLOW=1, or set MOVE_FLOW to a move-flow binary from aptos-labs/aptos-ai.`,
{ binary: 'move-flow', context: opts.context },
);
}
@@ -25,6 +25,7 @@ import { swiftProvider } from './swift.js';
import { dartProvider } from './dart.js';
import { vueProvider } from './vue.js';
import { cobolProvider } from './cobol.js';
import { moveProvider } from './move.js';
export const providers = {
[SupportedLanguages.JavaScript]: javascriptProvider,
@@ -43,6 +44,7 @@ export const providers = {
[SupportedLanguages.Dart]: dartProvider,
[SupportedLanguages.Vue]: vueProvider,
[SupportedLanguages.Cobol]: cobolProvider,
[SupportedLanguages.Move]: moveProvider,
} satisfies Record<SupportedLanguages, LanguageProvider>;
/** Get provider by language enum (always succeeds for SupportedLanguages). */
@@ -0,0 +1,28 @@
/**
* Move Language Provider (stub).
*
* Move/Aptos is ingested compiler-first via the move-flow MCP server in the
* `moveIngest` phase — GitNexus never tree-sits or regex-scans Move source.
* This provider exists only to satisfy the exhaustive `SupportedLanguages`
* provider table; it declares no extensions, so `.move` files are never routed
* through the generic tree-sitter / scope-resolution pipeline (the `parse`
* phase additionally excludes every file ingested by `moveIngest`).
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { defineLanguage } from '../language-provider.js';
export const moveProvider = defineLanguage({
id: SupportedLanguages.Move,
parseStrategy: 'standalone',
extensions: [], // .move files are handled by the moveIngest phase, not here
entryPointPatterns: [],
astFrameworkPatterns: [],
treeSitterQueries: '',
typeConfig: {
declarationNodeTypes: new Set(),
extractDeclaration: () => null,
extractParameter: () => null,
},
exportChecker: () => false,
importResolver: () => null,
});
@@ -182,6 +182,7 @@ const LABEL_BEHAVIOR = {
Section: 'inert',
Route: 'inert',
Tool: 'inert',
EnumVariant: 'inert',
// Taint/PDG substrate (issue #2080) — a control-flow node, never a
// symbol-resolution target. Inert: file index only, no owner scope.
BasicBlock: 'inert',
@@ -9,6 +9,7 @@
export { scanPhase, type ScanOutput } from './scan.js';
export { structurePhase, type StructureOutput } from './structure.js';
export { emptyStandaloneIngestPhase, type StandaloneIngestOutput } from './standalone-ingest.js';
export { markdownPhase, type MarkdownOutput } from './markdown.js';
export { cobolPhase, type CobolOutput } from './cobol.js';
export { parsePhase, type ParseOutput } from './parse.js';
@@ -19,6 +19,7 @@
import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js';
import { getPhaseOutput } from './types.js';
import type { StructureOutput } from './structure.js';
import type { StandaloneIngestOutput } from './standalone-ingest.js';
import type { BindingAccumulator } from '../binding-accumulator.js';
import type { ParsedFile } from 'gitnexus-shared';
import type {
@@ -60,8 +61,6 @@ export interface ParseOutput {
model: MutableSemanticModel;
/** Pass-through: all file paths for downstream phases. */
readonly allPaths: readonly string[];
/** Pass-through: shared `allPathSet` from structure (built once, not per-phase). */
readonly allPathSet: ReadonlySet<string>;
/** Pass-through: total file count for progress reporting. */
totalFiles: number;
/**
@@ -84,16 +83,24 @@ export interface ParseOutput {
export const parsePhase: PipelinePhase<ParseOutput> = {
name: 'parse',
deps: ['structure', 'markdown', 'cobol'],
deps: ['structure', 'standaloneIngest', 'markdown', 'cobol'],
async execute(
ctx: PipelineContext,
deps: ReadonlyMap<string, PhaseResult<unknown>>,
): Promise<ParseOutput> {
const { scannedFiles, allPaths, allPathSet, totalFiles } = getPhaseOutput<StructureOutput>(
deps,
'structure',
);
const structure = getPhaseOutput<StructureOutput>(deps, 'structure');
const { totalFiles } = structure;
// Files claimed by a standalone ingester must not be parsed a second time.
const standaloneIngest = getPhaseOutput<StandaloneIngestOutput>(deps, 'standaloneIngest');
const ingested = standaloneIngest.ingestedFiles;
const scannedFiles = ingested.size
? structure.scannedFiles.filter((f) => !ingested.has(f.path))
: structure.scannedFiles;
const allPaths = ingested.size
? structure.allPaths.filter((p) => !ingested.has(p))
: structure.allPaths;
const result = await runChunkedParseAndResolve(
ctx.graph,
@@ -109,7 +116,6 @@ export const parsePhase: PipelinePhase<ParseOutput> = {
return {
...result,
allPaths,
allPathSet,
totalFiles,
};
},
@@ -0,0 +1,16 @@
import type { PipelinePhase } from './types.js';
/**
* Shared output contract for compiler-backed or otherwise standalone ingesters.
* Claimed files are excluded from the generic parser to prevent duplicate nodes.
*/
export interface StandaloneIngestOutput {
readonly ingestedFiles: ReadonlySet<string>;
}
/** Default no-op used when the caller does not supply a standalone ingester. */
export const emptyStandaloneIngestPhase: PipelinePhase<StandaloneIngestOutput> = {
name: 'standaloneIngest',
deps: ['structure'],
execute: () => Promise.resolve({ ingestedFiles: new Set<string>() }),
};
+5
View File
@@ -23,6 +23,7 @@ import {
getPhaseOutput,
scanPhase,
structurePhase,
emptyStandaloneIngestPhase,
markdownPhase,
cobolPhase,
parsePhase,
@@ -40,6 +41,7 @@ import {
processesPhase,
PhaseRegistry,
type ScopeResolutionOutput,
type StandaloneIngestOutput,
type PipelinePhase,
type CommunitiesOutput,
type ProcessesOutput,
@@ -224,6 +226,8 @@ export interface PipelineOptions {
* `process.env` state across invocations. When undefined, the env var decides.
*/
keepLocalValueSymbols?: boolean;
/** Optional compiler-backed or otherwise standalone ingester. */
standaloneIngestPhase?: PipelinePhase<StandaloneIngestOutput>;
/**
* Extra fetch-wrapper function names to treat as HTTP consumers, threaded
* from `.gitnexusrc` `fetchWrappers` via `AnalyzeOptions` (#1589/#1852
@@ -261,6 +265,7 @@ export function buildPhaseList(options?: PipelineOptions): PipelinePhase[] {
new PhaseRegistry<PipelineOptions>()
.register(scanPhase)
.register(structurePhase)
.register(options?.standaloneIngestPhase ?? emptyStandaloneIngestPhase)
.register(markdownPhase)
.register(cobolPhase)
.register(parsePhase)
@@ -1787,4 +1787,5 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[SupportedLanguages.Dart]: DART_QUERIES,
[SupportedLanguages.Vue]: TYPESCRIPT_QUERIES, // Vue <script> blocks are parsed as TypeScript
[SupportedLanguages.Cobol]: '', // Standalone regex processor — no tree-sitter queries
[SupportedLanguages.Move]: '', // Compiler-first via move-flow — no tree-sitter queries
};
+161 -30
View File
@@ -22,6 +22,15 @@ import { RelPairRouter } from './rel-pair-routing.js';
import { parseTruthyEnv } from '../ingestion/utils/env.js';
import { SYMBOL_NODE_LABELS } from '../ingestion/utils/symbol-labels.js';
import { applyCjkSegmentationIfEnabled } from '../search/cjk-segmentation.js';
import {
CODE_ELEMENT_COLUMNS,
MOVE_CONST_COLUMNS,
MOVE_ENUM_VARIANT_COLUMNS,
MOVE_FUNCTION_COLUMNS,
MOVE_MODULE_COLUMNS,
MOVE_STRUCT_LIKE_COLUMNS,
MULTI_LANG_BASE_COLUMNS,
} from './move-columns.js';
/**
* Deterministic output ordering — optional (out-of-core / windowed-resolve
@@ -117,6 +126,25 @@ export const escapeCSVNumber = (
return String(value);
};
/**
* LadybugDB STRING[] literal inside a quoted CSV field. Elements containing a
* list metacharacter (' , [ ]) are single-quoted so comma-bearing types like
* `Table<address, u64>` survive as one element. LadybugDB keeps the quotes on
* read, so user-facing columns must strip them (see moveResources fieldList).
*/
export const escapeCSVStringArray = (value: unknown): string => {
const items = Array.isArray(value) ? value : [];
const encoded = items.map((item) => {
const s = item == null ? '' : String(item);
return /[',\[\]]/.test(s) ? `'${s.replace(/'/g, "''")}'` : s;
});
return escapeCSVField(`[${encoded.join(',')}]`);
};
export const escapeCSVBoolean = (value: unknown): string => {
return value === true ? 'true' : 'false';
};
// ============================================================================
// CONTENT EXTRACTION (lazy — reads from disk on demand)
// ============================================================================
@@ -423,10 +451,10 @@ export const streamAllCSVsToDisk = async (
'id,name,filePath,content',
);
const folderWriter = new BufferedCSVWriter(path.join(csvDir, 'folder.csv'), 'id,name,filePath');
const codeElementHeader = 'id,name,filePath,startLine,endLine,isExported,content,description';
const codeElementHeader = CODE_ELEMENT_COLUMNS.join(',');
const functionWriter = new BufferedCSVWriter(
path.join(csvDir, 'function.csv'),
codeElementHeader,
MOVE_FUNCTION_COLUMNS.join(','),
);
const classWriter = new BufferedCSVWriter(path.join(csvDir, 'class.csv'), codeElementHeader);
const interfaceWriter = new BufferedCSVWriter(
@@ -475,10 +503,15 @@ export const streamAllCSVsToDisk = async (
);
// Multi-language node types share the same CSV shape (no isExported column)
const multiLangHeader = 'id,name,filePath,startLine,endLine,content,description';
const multiLangHeader = MULTI_LANG_BASE_COLUMNS.join(',');
const moveStructLikeHeader = MOVE_STRUCT_LIKE_COLUMNS.join(',');
const moveConstHeader = MOVE_CONST_COLUMNS.join(',');
const moveEnumVariantHeader = MOVE_ENUM_VARIANT_COLUMNS.join(',');
const moveModuleHeader = MOVE_MODULE_COLUMNS.join(',');
const MULTI_LANG_TYPES = [
'Struct',
'Enum',
'EnumVariant',
'Macro',
'Typedef',
'Union',
@@ -504,7 +537,17 @@ export const streamAllCSVsToDisk = async (
t,
new BufferedCSVWriter(
path.join(csvDir, `${t.toLowerCase()}.csv`),
t === 'Property' ? propertyHeader : multiLangHeader,
t === 'Property'
? propertyHeader
: t === 'Struct' || t === 'Enum'
? moveStructLikeHeader
: t === 'EnumVariant'
? moveEnumVariantHeader
: t === 'Const'
? moveConstHeader
: t === 'Module'
? moveModuleHeader
: multiLangHeader,
),
);
}
@@ -661,37 +704,125 @@ export const streamAllCSVsToDisk = async (
const writer = codeWriterMap[node.label];
if (writer) {
const content = await extractContent(node, contentCache);
pending = writer.addRow(
[
escapeCSVField(node.id),
escapeCSVField(node.properties.name || ''),
escapeCSVField(node.properties.filePath || ''),
escapeCSVNumber(node.properties.startLine, -1),
escapeCSVNumber(node.properties.endLine, -1),
node.properties.isExported ? 'true' : 'false',
escapeCSVField(content),
escapeCSVField(formatFtsDescription(node.properties.description || '')),
].join(','),
);
const baseFields = [
escapeCSVField(node.id),
escapeCSVField(node.properties.name || ''),
escapeCSVField(node.properties.filePath || ''),
escapeCSVNumber(node.properties.startLine, -1),
escapeCSVNumber(node.properties.endLine, -1),
node.properties.isExported ? 'true' : 'false',
escapeCSVField(content),
escapeCSVField(formatFtsDescription(node.properties.description || '')),
];
if (node.label === 'Function') {
pending = writer.addRow(
[
...baseFields,
escapeCSVField(node.properties.language || ''),
escapeCSVField(node.properties.qualifiedName || ''),
escapeCSVField(node.properties.moduleQualifiedName || ''),
escapeCSVField(node.properties.visibility || ''),
escapeCSVField(node.properties.visibilityModifier || ''),
escapeCSVBoolean(node.properties.isEntry),
escapeCSVBoolean(node.properties.isView),
escapeCSVBoolean(node.properties.isInline),
escapeCSVBoolean(node.properties.isNative),
escapeCSVNumber(node.properties.parameterCount, 0),
escapeCSVField(node.properties.returnType || ''),
escapeCSVStringArray(node.properties.acquires),
escapeCSVStringArray(node.properties.usedTypes),
escapeCSVStringArray(node.properties.attributes),
escapeCSVField(String(node.properties.attributesJson ?? '')),
escapeCSVField(String(node.properties.typeParamsJson ?? '')),
escapeCSVField(String(node.properties.locationFidelity ?? '')),
].join(','),
);
} else {
pending = writer.addRow(baseFields.join(','));
}
} else {
// Multi-language node types (Struct, Impl, Trait, Macro, etc.)
const mlWriter = multiLangWriters.get(node.label);
if (mlWriter) {
const content = await extractContent(node, contentCache);
pending = mlWriter.addRow(
[
escapeCSVField(node.id),
escapeCSVField(node.properties.name || ''),
escapeCSVField(node.properties.filePath || ''),
escapeCSVNumber(node.properties.startLine, -1),
escapeCSVNumber(node.properties.endLine, -1),
escapeCSVField(content),
escapeCSVField(formatFtsDescription(node.properties.description || '')),
...(node.label === 'Property'
? [escapeCSVField(node.properties.declaredType || '')]
: []),
].join(','),
);
const baseFields = [
escapeCSVField(node.id),
escapeCSVField(node.properties.name || ''),
escapeCSVField(node.properties.filePath || ''),
escapeCSVNumber(node.properties.startLine, -1),
escapeCSVNumber(node.properties.endLine, -1),
escapeCSVField(content),
escapeCSVField(formatFtsDescription(node.properties.description || '')),
];
if (node.label === 'Struct' || node.label === 'Enum') {
pending = mlWriter.addRow(
[
...baseFields,
escapeCSVField(node.properties.language || ''),
escapeCSVField(node.properties.qualifiedName || ''),
escapeCSVField(node.properties.moduleQualifiedName || ''),
escapeCSVField(node.properties.moduleAddress || ''),
escapeCSVStringArray(node.properties.abilities),
escapeCSVBoolean(node.properties.isResource),
escapeCSVBoolean(node.properties.isEvent),
escapeCSVStringArray(node.properties.fieldList),
escapeCSVStringArray(node.properties.attributes),
escapeCSVField(String(node.properties.attributesJson ?? '')),
escapeCSVField(String(node.properties.typeParamsJson ?? '')),
escapeCSVField(node.properties.moveDeclarationKind || ''),
escapeCSVField(String(node.properties.locationFidelity ?? '')),
].join(','),
);
} else if (node.label === 'EnumVariant') {
pending = mlWriter.addRow(
[
...baseFields,
escapeCSVField(node.properties.language || ''),
escapeCSVField(node.properties.qualifiedName || ''),
escapeCSVField(String(node.properties.parentEnum || '')),
escapeCSVField(String(node.properties.moduleQualifiedName || '')),
escapeCSVField(String(node.properties.variantKind || '')),
escapeCSVField(String(node.properties.fieldsJson ?? '')),
escapeCSVStringArray(node.properties.attributes),
escapeCSVField(String(node.properties.attributesJson ?? '')),
escapeCSVField(String(node.properties.locationFidelity ?? '')),
].join(','),
);
} else if (node.label === 'Const') {
pending = mlWriter.addRow(
[
...baseFields,
escapeCSVField(node.properties.language || ''),
escapeCSVField(node.properties.qualifiedName || ''),
escapeCSVField(node.properties.moduleQualifiedName || ''),
escapeCSVField(String(node.properties.constType ?? '')),
escapeCSVField(String(node.properties.constValue ?? '')),
escapeCSVBoolean(node.properties.isErrorCode),
escapeCSVField(String(node.properties.locationFidelity ?? '')),
].join(','),
);
} else if (node.label === 'Module') {
pending = mlWriter.addRow(
[
...baseFields,
escapeCSVField(node.properties.language || ''),
escapeCSVField(node.properties.qualifiedName || ''),
escapeCSVField(node.properties.moduleAddress || ''),
escapeCSVStringArray(node.properties.attributes),
escapeCSVField(String(node.properties.attributesJson ?? '')),
escapeCSVField(String(node.properties.locationFidelity ?? '')),
].join(','),
);
} else {
pending = mlWriter.addRow(
[
...baseFields,
...(node.label === 'Property'
? [escapeCSVField(node.properties.declaredType || '')]
: []),
].join(','),
);
}
} else {
// Unknown label: not in codeWriterMap or multiLangWriters, so there
// is no CSV table for it and it is intentionally NOT persisted —
+24
View File
@@ -23,6 +23,13 @@ import { streamAllCSVsToDisk, type StreamedCSVResult } from './csv-generator.js'
import type { PdgEmitManifest } from './pdg-emit-sink.js';
import { getNodeLabel as deriveNodeLabel, type WriteStreamFactory } from './rel-pair-routing.js';
import { EMBEDDABLE_LABELS, type CachedEmbedding } from '../embeddings/types.js';
import {
MOVE_CONST_COLUMNS,
MOVE_ENUM_VARIANT_COLUMNS,
MOVE_FUNCTION_COLUMNS,
MOVE_MODULE_COLUMNS,
MOVE_STRUCT_LIKE_COLUMNS,
} from './move-columns.js';
import { extensionManager, type ExtensionEnsureOptions } from './extension-loader.js';
import {
classifyDeleteAllError,
@@ -1346,6 +1353,8 @@ const TABLES_WITH_EXPORTED = new Set<string>([
'CodeElement',
]);
const copyColumns = (columns: readonly string[]): string => columns.join(', ');
export const getCopyQuery = (table: NodeTableName, filePath: string): string => {
const t = escapeTableName(table);
if (table === 'File') {
@@ -1381,6 +1390,21 @@ export const getCopyQuery = (table: NodeTableName, filePath: string): string =>
if (table === 'Property') {
return `COPY ${t}(id, name, filePath, startLine, endLine, content, description, declaredType) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'Function') {
return `COPY ${t}(${copyColumns(MOVE_FUNCTION_COLUMNS)}) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'Struct' || table === 'Enum') {
return `COPY ${t}(${copyColumns(MOVE_STRUCT_LIKE_COLUMNS)}) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'EnumVariant') {
return `COPY ${t}(${copyColumns(MOVE_ENUM_VARIANT_COLUMNS)}) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'Const') {
return `COPY ${t}(${copyColumns(MOVE_CONST_COLUMNS)}) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'Module') {
return `COPY ${t}(${copyColumns(MOVE_MODULE_COLUMNS)}) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
// TypeScript/JS code element tables have isExported; multi-language tables do not
if (TABLES_WITH_EXPORTED.has(table)) {
return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
+92
View File
@@ -0,0 +1,92 @@
export const CODE_ELEMENT_COLUMNS = [
'id',
'name',
'filePath',
'startLine',
'endLine',
'isExported',
'content',
'description',
] as const;
export const MULTI_LANG_BASE_COLUMNS = [
'id',
'name',
'filePath',
'startLine',
'endLine',
'content',
'description',
] as const;
export const MOVE_FUNCTION_COLUMNS = [
...CODE_ELEMENT_COLUMNS,
'language',
'qualifiedName',
'moduleQualifiedName',
'visibility',
'visibilityModifier',
'isEntry',
'isView',
'isInline',
'isNative',
'parameterCount',
'returnType',
'acquires',
'usedTypes',
'attributes',
'attributesJson',
'typeParamsJson',
'locationFidelity',
] as const;
export const MOVE_STRUCT_LIKE_COLUMNS = [
...MULTI_LANG_BASE_COLUMNS,
'language',
'qualifiedName',
'moduleQualifiedName',
'moduleAddress',
'abilities',
'isResource',
'isEvent',
'fieldList',
'attributes',
'attributesJson',
'typeParamsJson',
'moveDeclarationKind',
'locationFidelity',
] as const;
export const MOVE_ENUM_VARIANT_COLUMNS = [
...MULTI_LANG_BASE_COLUMNS,
'language',
'qualifiedName',
'parentEnum',
'moduleQualifiedName',
'variantKind',
'fieldsJson',
'attributes',
'attributesJson',
'locationFidelity',
] as const;
export const MOVE_CONST_COLUMNS = [
...MULTI_LANG_BASE_COLUMNS,
'language',
'qualifiedName',
'moduleQualifiedName',
'constType',
'constValue',
'isErrorCode',
'locationFidelity',
] as const;
export const MOVE_MODULE_COLUMNS = [
...MULTI_LANG_BASE_COLUMNS,
'language',
'qualifiedName',
'moduleAddress',
'attributes',
'attributesJson',
'locationFidelity',
] as const;
+113 -4
View File
@@ -46,6 +46,23 @@ CREATE NODE TABLE Function (
isExported BOOLEAN,
content STRING,
description STRING,
language STRING,
qualifiedName STRING,
moduleQualifiedName STRING,
visibility STRING,
visibilityModifier STRING,
isEntry BOOLEAN,
isView BOOLEAN,
isInline BOOLEAN,
isNative BOOLEAN,
parameterCount INT32,
returnType STRING,
acquires STRING[],
usedTypes STRING[],
attributes STRING[],
attributesJson STRING,
typeParamsJson STRING,
locationFidelity STRING,
PRIMARY KEY (id)
)`;
@@ -155,8 +172,93 @@ CREATE NODE TABLE \`${name}\` (
PRIMARY KEY (id)
)`;
export const STRUCT_SCHEMA = CODE_ELEMENT_BASE('Struct');
export const ENUM_SCHEMA = CODE_ELEMENT_BASE('Enum');
// Move struct/enum carry compiler-sourced abilities/resource/event/field facts.
const MOVE_STRUCT_LIKE_SCHEMA = (name: string) => `
CREATE NODE TABLE \`${name}\` (
id STRING,
name STRING,
filePath STRING,
startLine INT64,
endLine INT64,
content STRING,
description STRING,
language STRING,
qualifiedName STRING,
moduleQualifiedName STRING,
moduleAddress STRING,
abilities STRING[],
isResource BOOLEAN,
isEvent BOOLEAN,
fieldList STRING[],
attributes STRING[],
attributesJson STRING,
typeParamsJson STRING,
moveDeclarationKind STRING,
locationFidelity STRING,
PRIMARY KEY (id)
)`;
const MOVE_ENUM_VARIANT_SCHEMA = `
CREATE NODE TABLE \`EnumVariant\` (
id STRING,
name STRING,
filePath STRING,
startLine INT64,
endLine INT64,
content STRING,
description STRING,
language STRING,
qualifiedName STRING,
parentEnum STRING,
moduleQualifiedName STRING,
variantKind STRING,
fieldsJson STRING,
attributes STRING[],
attributesJson STRING,
locationFidelity STRING,
PRIMARY KEY (id)
)`;
const MOVE_MODULE_SCHEMA = `
CREATE NODE TABLE \`Module\` (
id STRING,
name STRING,
filePath STRING,
startLine INT64,
endLine INT64,
content STRING,
description STRING,
language STRING,
qualifiedName STRING,
moduleAddress STRING,
attributes STRING[],
attributesJson STRING,
locationFidelity STRING,
PRIMARY KEY (id)
)`;
const MOVE_CONST_SCHEMA = `
CREATE NODE TABLE \`Const\` (
id STRING,
name STRING,
filePath STRING,
startLine INT64,
endLine INT64,
content STRING,
description STRING,
language STRING,
qualifiedName STRING,
moduleQualifiedName STRING,
constType STRING,
constValue STRING,
isErrorCode BOOLEAN,
locationFidelity STRING,
PRIMARY KEY (id)
)`;
export const STRUCT_SCHEMA = MOVE_STRUCT_LIKE_SCHEMA('Struct');
export const ENUM_SCHEMA = MOVE_STRUCT_LIKE_SCHEMA('Enum');
export const ENUM_VARIANT_SCHEMA = MOVE_ENUM_VARIANT_SCHEMA;
export const MACRO_SCHEMA = CODE_ELEMENT_BASE('Macro');
export const TYPEDEF_SCHEMA = CODE_ELEMENT_BASE('Typedef');
export const UNION_SCHEMA = CODE_ELEMENT_BASE('Union');
@@ -164,7 +266,7 @@ export const NAMESPACE_SCHEMA = CODE_ELEMENT_BASE('Namespace');
export const TRAIT_SCHEMA = CODE_ELEMENT_BASE('Trait');
export const IMPL_SCHEMA = CODE_ELEMENT_BASE('Impl');
export const TYPE_ALIAS_SCHEMA = CODE_ELEMENT_BASE('TypeAlias');
export const CONST_SCHEMA = CODE_ELEMENT_BASE('Const');
export const CONST_SCHEMA = MOVE_CONST_SCHEMA;
export const STATIC_SCHEMA = CODE_ELEMENT_BASE('Static');
export const VARIABLE_SCHEMA = CODE_ELEMENT_BASE('Variable');
export const PROPERTY_SCHEMA = `
@@ -184,7 +286,7 @@ export const DELEGATE_SCHEMA = CODE_ELEMENT_BASE('Delegate');
export const ANNOTATION_SCHEMA = CODE_ELEMENT_BASE('Annotation');
export const CONSTRUCTOR_SCHEMA = CODE_ELEMENT_BASE('Constructor');
export const TEMPLATE_SCHEMA = CODE_ELEMENT_BASE('Template');
export const MODULE_SCHEMA = CODE_ELEMENT_BASE('Module');
export const MODULE_SCHEMA = MOVE_MODULE_SCHEMA;
// API route endpoints (Next.js, Express, etc.)
export const ROUTE_SCHEMA = `
CREATE NODE TABLE Route (
@@ -379,6 +481,12 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM \`Macro\` TO Method,
FROM \`Module\` TO Function,
FROM \`Module\` TO Method,
// Move/Aptos: module defines structs/enums/consts; enums contain variants.
FROM \`Module\` TO \`Struct\`,
FROM \`Module\` TO \`Enum\`,
FROM \`Module\` TO \`Const\`,
FROM \`Enum\` TO \`EnumVariant\`,
FROM \`Module\` TO \`EnumVariant\`,
FROM \`Typedef\` TO Community,
FROM \`Union\` TO Community,
FROM \`Namespace\` TO Community,
@@ -520,6 +628,7 @@ export const NODE_SCHEMA_QUERIES = [
// Multi-language support
STRUCT_SCHEMA,
ENUM_SCHEMA,
ENUM_VARIANT_SCHEMA,
MACRO_SCHEMA,
TYPEDEF_SCHEMA,
UNION_SCHEMA,
+44
View File
@@ -0,0 +1,44 @@
# Move compiler integration
This directory implements GitNexus's compiler-first ingestion for Move packages.
GitNexus discovers packages from `Move.toml`, communicates with `move-flow` over
MCP, and projects compiler facts into the standard GitNexus knowledge graph.
Declaration and semantic data come from the compiler-backed `facts` and
`call_graph` queries rather than raw-source parsing.
Cold compiler builds for large packages may take several minutes. Tool calls
default to a five-minute timeout; override it in milliseconds with
`GITNEXUS_MOVE_FLOW_TIMEOUT_MS` when a repository needs a larger budget.
```text
Move package
-> move-flow MCP
-> compiler facts and call graph
-> GitNexus nodes and relationships
-> consistency validation
```
## Components
- `mcp-client.ts` owns the `move-flow mcp` process, JSON-RPC transport, and the
client contract consumed by ingestion.
- `compiler-facts.ts` defines the normalized compiler response shapes used by
downstream projections.
- `move-ingest.ts` implements the standalone ingestion phase, including package
discovery, compiler queries, and cross-package resolution.
- `facts-mapper.ts` maps compiler facts to deterministic GitNexus nodes and
relationships.
- `consistency.ts` validates the resulting graph and reports incomplete or
malformed compiler evidence.
## Upstream references
- [Aptos Core](https://github.com/aptos-labs/aptos-core) contains the upstream
Move compiler and runtime. MoveFlow releases are built from matching
`move-flow-v<version>` tags in this repository.
- [MoveFlow in Aptos AI](https://github.com/aptos-labs/aptos-ai) provides the
`move-flow` binary and MCP tools consumed by this integration. GitNexus
currently targets
[MoveFlow 2.0.0](https://github.com/aptos-labs/aptos-ai/releases/tag/move-flow-v2.0.0).
- [The Move Book](https://aptos-labs.github.io/move-book/) is the language
reference for Move concepts represented in the graph.
+138
View File
@@ -0,0 +1,138 @@
/**
* Normalized compiler facts for Move.
*
* `move-flow` is the authoritative source for package/module/function facts.
* This module keeps the raw MCP shapes in one place and exposes a stable,
* parsed representation that downstream projections can consume without
* re-querying move-flow or reparsing signatures independently.
*/
export interface MoveFlowConstant {
name: string;
type: string;
value: string;
}
export type CallGraphMap = Record<string, string[]>;
// ─────────────────────────────────────────────────────────────────────────
// move-flow `facts` query — full-fidelity, compiler-sourced per-module facts.
//
// Shape mirrors the live `move_package_query { query: "facts" }` response.
// These types are the source of truth for the thin facts→graph mapper; the
// `facts` query is the ONLY ingestion path — move-flow builds without it are
// rejected by the ingest phase's hard-require gate.
// ─────────────────────────────────────────────────────────────────────────
/** A generic type parameter as reported by the facts query. */
export interface MoveFactsTypeParam {
name: string;
abilities: string[];
isPhantom: boolean;
}
/** A struct field or enum-variant field. */
export interface MoveFactsField {
/** Field name, or the positional index (`"0"`, `"1"`, …) for positional fields. */
name: string;
type: string;
positional: boolean;
}
/**
* A parsed attribute. Recursive shape: nested `args` and assignment `value`s
* are serialized (keys absent when none/empty, per serde skip rules). Examples:
* `{ name: "view" }`
* `{ name: "lint::skip", args: [{ name: "needless_bool" }] }`
* `{ name: "resource_group_member", args: [{ name: "group", value: "0xa::vault::Group" }] }`
*/
export interface MoveFactsAttribute {
name: string;
/** Assignment value, e.g. `group = "0xa::vault::Group"` -> `"0xa::vault::Group"`. */
value?: string;
/** Nested attribute arguments (recursive). */
args?: MoveFactsAttribute[];
[key: string]: unknown;
}
/** A `friend` declaration target. */
export interface MoveFactsFriend {
module: string;
}
/** One enum variant. */
export interface MoveFactsVariant {
name: string;
kind: 'unit' | 'positional' | 'named';
fields: MoveFactsField[];
attributes: MoveFactsAttribute[];
}
/** AST-derived resource access for a function. Every value is a
* fully-qualified type expression such as `"0xa::vault::Holding"`
* (lowercase, non-zero-padded hex addresses). */
export interface MoveFactsResourceAccess {
reads: string[];
writes: string[];
}
/** A function as reported by the facts query. */
export interface MoveFactsFunction {
name: string;
file?: string;
span?: [number, number];
visibility: string;
isEntry: boolean;
isInline: boolean;
isNative: boolean;
isView: boolean;
attributes?: MoveFactsAttribute[];
typeParams?: MoveFactsTypeParam[];
params?: { name: string; type: string }[];
/**
* Return types, one entry per tuple element: `[]` for no return, one entry
* for a scalar, N entries for a tuple.
*/
returnTypes: string[];
/** Fully-qualified resource names the function acquires (e.g. `0xa::coin::CoinStore`). */
acquiresInferred?: string[];
resourceAccess?: MoveFactsResourceAccess;
/** Compiler-synthesized lambda-lifted function. */
isLambdaLifted?: boolean;
/** Host function's local name for lambda-lifted functions (resolved through
* nested closures by move-flow). */
definedIn?: string;
}
/**
* A struct or enum as reported by the facts query. move-flow groups both under
* the module's `structs` array and distinguishes them by `kind`.
*/
export interface MoveFactsType {
kind: 'struct' | 'enum';
name: string;
file?: string;
span?: [number, number];
abilities: string[];
typeParams: MoveFactsTypeParam[];
/** Present for structs. */
fields?: MoveFactsField[];
/** Present for enums. */
variants?: MoveFactsVariant[];
attributes: MoveFactsAttribute[];
}
/** Per-module facts. */
export interface MoveFactsModule {
file?: string;
span?: [number, number];
friends?: MoveFactsFriend[];
attributes?: MoveFactsAttribute[];
functions?: MoveFactsFunction[];
/** Structs *and* enums (each tagged by `kind`), per the facts `structs` key. */
structs?: MoveFactsType[];
constants?: MoveFlowConstant[];
}
/** The full facts query response: qualified module name → facts. */
export type MoveFactsMap = Record<string, MoveFactsModule>;
+138
View File
@@ -0,0 +1,138 @@
import type { KnowledgeGraph } from '../graph/types.js';
import type { MovePackageStatus } from './mcp-client.js';
import type { MoveIngestOutput } from './move-ingest.js';
import { moveModuleQualifiedName } from './symbol-id.js';
export type MoveConsistencySeverity = 'warning' | 'error';
export interface MoveConsistencyIssue {
code:
| 'missing-owned-caller'
| 'missing-owned-callee'
| 'malformed-source-evidence'
| 'unresolved-resource-target'
/** Package with .move sources returned facts `{}` - severity policy in
* `emptyFactsIssue` below. */
| 'empty-package-facts';
severity: MoveConsistencySeverity;
message: string;
details?: Record<string, unknown>;
}
/** A sources-but-empty-facts package plus what `move_package_status` said
* (`null` = build status unavailable: older move-flow, or probe failure). */
export interface EmptyFactsPackage {
pkgRoot: string;
moveFileCount: number;
status: MovePackageStatus | null;
}
/**
* Severity policy for a package whose facts came back `{}` despite .move
* sources: compiling -> warning (likely test-only, elided from facts), failing
* -> error carrying the compiler diagnostics, status unavailable -> pessimistic
* error. Pure mapping - probing the status (client I/O) stays in the phase.
*/
export function emptyFactsIssue(pkg: EmptyFactsPackage): MoveConsistencyIssue {
const { pkgRoot, moveFileCount, status } = pkg;
if (status === null) {
return {
code: 'empty-package-facts',
severity: 'error',
message:
`Move package produced no facts despite containing .move sources ` +
`(does it compile? check \`move_package_status\`): ${pkgRoot}`,
details: { packageRoot: pkgRoot, moveFileCount },
};
}
if (status.ok) {
return {
code: 'empty-package-facts',
severity: 'warning',
message:
`Move package compiles but produced no facts - likely test-only ` +
`(#[test]/#[test_only] items are elided); its .move files stay un-ingested: ${pkgRoot}`,
details: { packageRoot: pkgRoot, moveFileCount },
};
}
const firstLine = status.diagnostics.split('\n', 1)[0]?.trim();
return {
code: 'empty-package-facts',
severity: 'error',
message:
`Move package produced no facts because it does not compile` +
(firstLine ? ` (${firstLine})` : '') +
`: ${pkgRoot}`,
details: { packageRoot: pkgRoot, moveFileCount, diagnostics: status.diagnostics },
};
}
export function validateMoveIngestOutput(
graph: KnowledgeGraph,
moveIngest: MoveIngestOutput,
): MoveConsistencyIssue[] {
const issues: MoveConsistencyIssue[] = [];
for (const [moduleQualified, filePath] of moveIngest.moduleFileMap) {
if (!filePath.endsWith('.move')) {
issues.push({
code: 'malformed-source-evidence',
severity: 'warning',
message: `Move module ${moduleQualified} has non-Move source evidence: ${filePath}`,
details: { moduleQualified, filePath },
});
continue;
}
const fileNode = graph.getNode(`File:${filePath}`);
const knownSource = moveIngest.ingestedFiles.has(filePath) || !!fileNode;
if (!knownSource) {
issues.push({
code: 'malformed-source-evidence',
severity: 'warning',
message: `Move module ${moduleQualified} points at a source file not seen by ingestion: ${filePath}`,
details: { moduleQualified, filePath },
});
}
}
for (const [packageRoot, callGraph] of moveIngest.callGraphByPackage) {
for (const [callerQualified, callees] of Object.entries(callGraph)) {
const callerModule = moveModuleQualifiedName(callerQualified);
if (
moveIngest.modulePackageMap.has(callerModule) &&
!moveIngest.functionNodeMap.has(callerQualified)
) {
issues.push({
code: 'missing-owned-caller',
severity: 'warning',
message: `Move call graph caller has package ownership but no function node: ${callerQualified}`,
details: { packageRoot, callerQualified },
});
}
for (const calleeQualified of callees) {
const calleeModule = moveModuleQualifiedName(calleeQualified);
if (!moveIngest.modulePackageMap.has(calleeModule)) continue;
if (moveIngest.functionNodeMap.has(calleeQualified)) continue;
issues.push({
code: 'missing-owned-callee',
severity: 'warning',
message: `Move call graph callee belongs to this repo but has no function node: ${calleeQualified}`,
details: { packageRoot, callerQualified, calleeQualified },
});
}
}
}
const dropped = moveIngest.droppedResourceRefs;
if (dropped && dropped.length > 0) {
issues.push({
code: 'unresolved-resource-target',
severity: 'warning',
message: `${dropped.length} resource read/write/acquires target(s) could not be resolved.`,
details: { count: dropped.length, sample: dropped.slice(0, 5) },
});
}
return issues;
}
+115
View File
@@ -0,0 +1,115 @@
/**
* Shared Move/Aptos constants and helpers — single source of truth for the
* magic strings that span the mapper, the ingest phase, the entry-point linker,
* and the MCP backend. A typo in any duplicated literal silently breaks a query
* with no compile error, so they live here.
*/
import path from 'node:path';
/** `NodeProperties.language` tag for every Move symbol. */
export const MOVE_LANGUAGE = 'move';
/**
* Parsed Move attribute names of interest. The mapper persists the full
* attribute list on every Function node as `attributes: STRING[]`, so the
* canonical Cypher query is `WHERE '<name>' IN f.attributes`. These constants
* exist so the mapper, downstream filters, and tests share a typo-resistant
* single source of truth for the names that matter (compiler attribute names
* follow the move-flow `facts` payload verbatim).
*/
export const MOVE_ATTR = {
EVENT: 'event',
/** `#[persistent]` — vault burn reentrancy guard and similar invariants. */
PERSISTENT: 'persistent',
/** `#[randomness]` — randomness-attestation entry function. */
RANDOMNESS: 'randomness',
/** `#[deprecated]` — marked obsolete; flagged by Move lints. */
DEPRECATED: 'deprecated',
/** `#[lint::skip(...)]` — suppresses one or more Move lints. */
LINT_SKIP: 'lint::skip',
/** `#[resource_group(scope = ...)]` - declares a resource group. */
RESOURCE_GROUP: 'resource_group',
/** `#[resource_group_member(group = <qualified struct>)]` - group membership. */
RESOURCE_GROUP_MEMBER: 'resource_group_member',
/** `#[verify_only]` — visible to the Move Prover only; excluded from runtime. */
VERIFY_ONLY: 'verify_only',
/** `#[view]` — read-only view function. */
VIEW: 'view',
} as const;
/** Move struct abilities of interest. */
export const MOVE_ABILITY = {
KEY: 'key',
} as const;
/**
* `reason` strings for Move graph edges.
*
* Note on `friend`: move-flow's `friends` array on a module is a *compiler-
* derived* set that conflates two source-level concepts: explicit
* `friend X::Y;` declarations and the Move-2 cross-module visibility that
* `package fun` (and friend-restricted `public(friend)`) implicitly grants.
* The facts payload does not preserve which of the two produced each entry,
* so the `move-friend-or-package` reason is intentionally ambiguous; a
* downstream filter (e.g. re-parsing source) is needed to distinguish them.
*/
export const MOVE_EDGE_REASON = {
definesFunction: 'move-module-defines-function',
definesStruct: 'move-module-defines-struct',
definesEnum: 'move-module-defines-enum',
definesConst: 'move-module-defines-const',
containsVariant: 'move-enum-contains-variant',
friend: 'move-friend-or-package',
calls: 'move-compiler-call-graph',
crossModuleDependency: 'move-cross-module-dependency',
moduleInFile: 'move-module-in-file',
// Resource-access edges (function → resource struct)
readsResource: 'move-reads_resource',
writesResource: 'move-writes_resource',
acquires: 'move-acquires',
fnParamType: 'move-fn-param-type',
fnReturnType: 'move-fn-return-type',
// Struct -> resource-group struct (from `#[resource_group_member(group = ...)]`)
resourceGroupMember: 'move-resource-group-member',
// Field edges (struct → field)
hasField: 'move-struct-has-field',
// Lambda → host edges (host fn → __lambda__N__host)
lambdaHost: 'move-lambda-of-host',
// Entry-point edge reasons (ENTRY_POINT_OF)
entryFunction: 'move-entry-function',
viewFunction: 'move-view-function',
} as const;
/** Error-code constant naming convention (e.g. `E_NOT_REGISTERED`). */
export const ERROR_CODE_PATTERN = /^E[_A-Z]/;
/** True when a file can change compiler facts for an entire Move package. */
export function isMoveCompilerInputPath(filePath: string): boolean {
const normalized = filePath.replaceAll('\\', '/');
const basename = normalized.slice(normalized.lastIndexOf('/') + 1);
return normalized.endsWith('.move') || basename === 'Move.toml' || basename === 'Move.lock';
}
/** An available compiler must backfill indexes created while it was absent. */
export function moveAvailabilityRequiresFullRebuild(
hasMovePackages: boolean,
compilerAvailable: boolean,
indexedWithCompiler: boolean | undefined,
): boolean {
return hasMovePackages && compilerAvailable && indexedWithCompiler !== true;
}
/**
* Make an absolute path repo-relative so node IDs and `File:` node links align.
* Returns the input unchanged when `repoPath` is absent or non-matching.
*/
export function moveRepoRelativePath(absPath: string, repoPath?: string): string {
if (!repoPath) return absPath;
const relative = path.relative(repoPath, absPath);
if (relative === '') return relative;
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
return absPath;
}
return relative.replaceAll(path.sep, '/');
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Move project discovery utilities.
*
* Cross-platform: uses the `glob` package instead of shelling out to the
* POSIX-only `find` command, so this works on native Windows.
*/
import { glob } from 'glob';
/**
* Returns true when the repo contains at least one Move.toml.
*/
export async function repoHasMove(repoPath: string): Promise<boolean> {
try {
const matches = await glob(['**/Move.toml'], {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'],
nodir: true,
absolute: false,
dot: false,
});
return matches.length > 0;
} catch {
return false;
}
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Move entry point detection.
*
* Creates ENTRY_POINT_OF edges for Move functions that serve as external
* entry points into the contract:
* - entry functions (transaction entry points)
* - #[view] functions (read-only API queries)
*
* Must run AFTER moveIngest, which creates the Function nodes and sets the
* isEntry/isView flags from compiler facts.
*/
import type { KnowledgeGraph } from '../graph/types.js';
import type { MoveIngestOutput } from './move-ingest.js';
import { moveModuleNodeId, moveModuleQualifiedName, moveRelId } from './symbol-id.js';
import { MOVE_EDGE_REASON } from './constants.js';
export function createMoveEntryPointEdges(
graph: KnowledgeGraph,
moveIngest: MoveIngestOutput,
): void {
for (const [funcQualified, funcNodeId] of moveIngest.functionNodeMap) {
const funcNode = graph.getNode(funcNodeId);
if (!funcNode) continue;
const isEntry = funcNode.properties.isEntry === true;
const isView = funcNode.properties.isView === true;
if (!isEntry && !isView) continue;
const moduleQualified = moveModuleQualifiedName(funcQualified);
const moduleFilePath = moveIngest.moduleFileMap.get(moduleQualified);
if (!moduleFilePath) continue;
const moduleNodeId = moveModuleNodeId(moduleQualified, moduleFilePath);
if (!graph.getNode(moduleNodeId)) continue;
const reason = isEntry ? MOVE_EDGE_REASON.entryFunction : MOVE_EDGE_REASON.viewFunction;
graph.addRelationship({
id: moveRelId(funcNodeId, 'ENTRY_POINT_OF', moduleNodeId, reason),
sourceId: funcNodeId,
targetId: moduleNodeId,
type: 'ENTRY_POINT_OF',
confidence: 1.0,
reason,
});
}
}
+685
View File
@@ -0,0 +1,685 @@
/**
* Thin facts → graph mapper.
*
* Consumes the move-flow `facts` query response (the compiler's full-fidelity,
* per-module structured facts) and produces standard GraphNode / GraphRelationship
* objects. This is the heart of the compiler-first Move ingestion: there is
* **no raw-source scanning** — every fact (visibility, entry/view, attributes,
* acquires, resource reads/writes, enum variants, friends, locations) comes
* straight from move-flow.
*/
import type {
GraphNode,
GraphRelationship,
NodeLabel,
NodeProperties,
RelationshipType,
} from 'gitnexus-shared';
import type { MoveFactsAttribute, MoveFactsMap, MoveFactsTypeParam } from './compiler-facts.js';
import {
moveModuleNodeId,
moveFunctionNodeId,
moveStructNodeId,
moveEnumNodeId,
moveConstNodeId,
moveEnumVariantNodeId,
moveFieldNodeId,
moveLocalName,
moveRelId,
parseMoveLambdaHostName,
parseMoveModuleQualifiedName,
} from './symbol-id.js';
import {
ERROR_CODE_PATTERN,
MOVE_ABILITY,
MOVE_ATTR,
MOVE_EDGE_REASON,
MOVE_LANGUAGE,
moveRepoRelativePath,
} from './constants.js';
import { extractTypeNames } from './type-parser.js';
import { toZeroBasedLine } from '../ingestion/utils/line-base.js';
const spanLine = (span: [number, number] | undefined, idx: 0 | 1): number | undefined =>
span ? toZeroBasedLine(span[idx]) : undefined;
export interface MoveFactsMapResult {
nodes: GraphNode[];
edges: GraphRelationship[];
/** Module qualified name → source file path. */
moduleFileMap: Map<string, string>;
/** Function qualified name → graph node ID. */
functionNodeMap: Map<string, string>;
/** Struct/enum qualified name → graph node ID. */
structNodeMap: Map<string, string>;
/** Resource edges that need the full cross-package type index. */
pendingResource: PendingResource[];
/** Friend edges that need the full cross-package module index. */
pendingFriends: PendingFriend[];
/** Signature type refs that need the full cross-package type index. */
pendingTypeRef: PendingTypeRef[];
/** Lambda → host links that need the host's Function node id (resolved post-pass). */
pendingLambdaHosts: PendingLambdaHost[];
}
export interface PendingLambdaHost {
/** The lambda's Function node id (target of the CALLS edge). */
lambdaFnNodeId: string;
/** Host function's fully-qualified name (`<module>::<localHost>`). */
hostQualified: string;
}
export interface PendingResource {
fnNodeId: string;
moduleQualified: string;
type: RelationshipType;
target: string;
reason: string;
}
export interface PendingFriend {
moduleNodeId: string;
friend: string;
}
export interface PendingTypeRef {
/** Source node of the USES_TYPE edge - a Function, or a Struct for
* `resource_group_member` membership refs. */
sourceNodeId: string;
moduleQualified: string;
target: string;
reason: string;
}
/** Strip generic type arguments: `CoinStore<CoinType>` → `CoinStore`. */
function stripTypeArgs(typeName: string): string {
const idx = typeName.indexOf('<');
return (idx === -1 ? typeName : typeName.slice(0, idx)).trim();
}
/** Defensive: move-flow omits optional array fields for some symbols. */
function arr<T>(value: T[] | null | undefined): T[] {
return Array.isArray(value) ? value : [];
}
function typeParamsJson(typeParams: MoveFactsTypeParam[] | null | undefined): string {
const list = arr(typeParams).map((tp) => ({
name: tp.name,
constraints: arr(tp.abilities),
isPhantom: tp.isPhantom,
}));
return JSON.stringify(list);
}
function attributeNames(attributes: { name: string }[] | null | undefined): string[] {
return arr(attributes).map((a) => a.name);
}
/** Full attribute payload (nested `args` / assignment `value`s) as JSON;
* undefined (not `"[]"`) when the symbol carries no attributes. */
function attributesJson(attributes: MoveFactsAttribute[] | null | undefined): string | undefined {
const list = arr(attributes);
return list.length > 0 ? JSON.stringify(list) : undefined;
}
/** `#[resource_group_member(group = <qualified struct>)]` -> the group struct ref. */
function resourceGroupOf(attributes: MoveFactsAttribute[] | null | undefined): string | undefined {
for (const a of arr(attributes)) {
if (a.name !== MOVE_ATTR.RESOURCE_GROUP_MEMBER) continue;
for (const groupArg of arr(a.args)) {
if (groupArg.name === 'group' && typeof groupArg.value === 'string') {
return groupArg.value;
}
}
}
return undefined;
}
/**
* Scalar projection of `returnTypes` for the lbug `returnType` STRING column:
* `[]` -> undefined, single -> the element, tuple -> `'(a, b)'`.
*/
function returnTypeProjection(returnTypes: string[]): string | undefined {
if (returnTypes.length === 0) return undefined;
return returnTypes.length === 1 ? returnTypes[0] : `(${returnTypes.join(', ')})`;
}
/**
* Boundary shape sentinel: reject the legacy scalar `returnType` shape. The
* mapper requires `returnTypes: string[]`; silently mapping the old scalar
* `returnType` would drop every return-type fact, so hard-fail with an
* operator-actionable upgrade message. The shape is uniform across a payload,
* so the first function encountered decides for all of them.
*/
function assertFactsShape(facts: MoveFactsMap): void {
for (const mod of Object.values(facts)) {
for (const fn of arr(mod.functions)) {
if (!Array.isArray(fn.returnTypes) && 'returnType' in fn) {
throw Object.assign(
new Error(
'move-flow returned the legacy scalar `returnType` facts shape - upgrade ' +
'move-flow to a build that emits `returnTypes: string[]` (>=2.0.0).',
),
{ userActionable: true },
);
}
return;
}
}
}
/**
* Map a full `facts` response (covering every module in a package) to graph
* nodes + edges. `packageRoot` is used only as a location fallback when the
* compiler omits a per-symbol file (it never does today, but the field is
* optional in the schema). When `repoPath` is given, absolute file paths from
* the compiler are made repo-relative so node IDs align with `File:` nodes.
*/
export function mapFactsToGraph(
facts: MoveFactsMap,
packageRoot: string,
repoPath?: string,
): MoveFactsMapResult {
assertFactsShape(facts);
const nodes: GraphNode[] = [];
const edges: GraphRelationship[] = [];
const moduleFileMap = new Map<string, string>();
const functionNodeMap = new Map<string, string>();
const structNodeMap = new Map<string, string>();
const edge = (
sourceId: string,
targetId: string,
type: RelationshipType,
confidence: number,
reason: string,
): void => {
edges.push({
id: moveRelId(sourceId, type, targetId, reason),
sourceId,
targetId,
type,
confidence,
reason,
});
};
// Deferred work that needs the full struct/module index (pass B).
const pendingResource: PendingResource[] = [];
const pendingFriends: PendingFriend[] = [];
const pendingTypeRef: PendingTypeRef[] = [];
const pendingLambdaHosts: PendingLambdaHost[] = [];
// ── Pass A: nodes (modules, functions, types, constants) ─────────────────
for (const [moduleQualified, mod] of Object.entries(facts)) {
const moduleFileAbs = mod.file ?? packageRoot;
const file = moveRepoRelativePath(moduleFileAbs, repoPath);
const { address, moduleName } = parseMoveModuleQualifiedName(moduleQualified);
moduleFileMap.set(moduleQualified, file);
const moduleNodeId = moveModuleNodeId(moduleQualified, file);
nodes.push({
id: moduleNodeId,
label: 'Module',
properties: {
name: moduleName,
filePath: file,
language: MOVE_LANGUAGE,
qualifiedName: moduleQualified,
moduleQualifiedName: moduleQualified,
moduleAddress: address,
startLine: spanLine(mod.span, 0),
endLine: spanLine(mod.span, 1),
attributes: attributeNames(mod.attributes),
attributesJson: attributesJson(mod.attributes),
locationFidelity: mod.file ? 'precise' : 'package',
},
});
for (const friend of arr(mod.friends)) {
pendingFriends.push({ moduleNodeId, friend: friend.module });
}
// Functions
for (const fn of arr(mod.functions)) {
const fnQualified = `${moduleQualified}::${fn.name}`;
const fnFile = moveRepoRelativePath(fn.file ?? moduleFileAbs, repoPath);
const fnNodeId = moveFunctionNodeId(fnQualified, fnFile);
functionNodeMap.set(fnQualified, fnNodeId);
const attrs = attributeNames(fn.attributes);
const seenTypeRefs = new Set<string>();
// Skip the function's own type parameters (e.g. `CoinType`): they are not
// nominal types and would create spurious USES_TYPE edges.
const fnTypeParamNames = new Set(arr(fn.typeParams).map((tp) => tp.name));
const addSignatureTypes = (typeExpr: string | null | undefined, reason: string): void => {
if (!typeExpr) return;
for (const typeName of extractTypeNames(typeExpr)) {
if (fnTypeParamNames.has(typeName)) continue;
const key = `${reason}\0${typeName}`;
if (seenTypeRefs.has(key)) continue;
seenTypeRefs.add(key);
pendingTypeRef.push({
sourceNodeId: fnNodeId,
moduleQualified,
target: typeName,
reason,
});
}
};
for (const p of arr(fn.params)) {
addSignatureTypes(p.type, MOVE_EDGE_REASON.fnParamType);
}
for (const rt of arr(fn.returnTypes)) {
addSignatureTypes(rt, MOVE_EDGE_REASON.fnReturnType);
}
const fnProps: NodeProperties = {
name: fn.name,
filePath: fnFile,
language: MOVE_LANGUAGE,
qualifiedName: fnQualified,
moduleQualifiedName: moduleQualified,
startLine: spanLine(fn.span, 0),
endLine: spanLine(fn.span, 1),
visibility: fn.visibility === 'internal' ? 'private' : fn.visibility,
visibilityModifier: fn.visibility,
isEntry: fn.isEntry,
isView: fn.isView,
isInline: fn.isInline,
isNative: fn.isNative,
attributes: attrs,
attributesJson: attributesJson(fn.attributes),
typeParamsJson: typeParamsJson(fn.typeParams),
acquires: arr(fn.acquiresInferred),
usedTypes: [],
returnType: returnTypeProjection(arr(fn.returnTypes)),
parameterCount: arr(fn.params).length,
locationFidelity: fn.file ? 'precise' : 'package',
};
// Lambda → host link queued for Pass 2 (host fn node may not yet exist
// when this lambda is mapped). The canonical Cypher path for "is this a
// lambda?" is the inbound CALLS edge with reason 'move-lambda-of-host';
// we do not project an `isLambda` boolean property because the lbug
// Function table does not carry it. move-flow reports the host explicitly
// via `definedIn` (resolved through nested closures); the
// `__lambda__N__<host>` name parse remains as fallback.
const lambdaHostLocal =
(fn.isLambdaLifted ? fn.definedIn : undefined) ?? parseMoveLambdaHostName(fn.name);
if (lambdaHostLocal) {
pendingLambdaHosts.push({
lambdaFnNodeId: fnNodeId,
hostQualified: `${moduleQualified}::${lambdaHostLocal}`,
});
}
nodes.push({
id: fnNodeId,
label: 'Function',
properties: fnProps,
});
edge(moduleNodeId, fnNodeId, 'DEFINES', 1.0, MOVE_EDGE_REASON.definesFunction);
for (const r of arr(fn.resourceAccess?.reads)) {
pendingResource.push({
fnNodeId,
moduleQualified,
type: 'READS_RESOURCE',
target: stripTypeArgs(r),
reason: MOVE_EDGE_REASON.readsResource,
});
}
for (const w of arr(fn.resourceAccess?.writes)) {
pendingResource.push({
fnNodeId,
moduleQualified,
type: 'WRITES_RESOURCE',
target: stripTypeArgs(w),
reason: MOVE_EDGE_REASON.writesResource,
});
}
for (const a of arr(fn.acquiresInferred)) {
pendingResource.push({
fnNodeId,
moduleQualified,
type: 'ACQUIRES',
target: a,
reason: MOVE_EDGE_REASON.acquires,
});
}
}
// Types (structs + enums)
for (const ty of arr(mod.structs)) {
const tyQualified = `${moduleQualified}::${ty.name}`;
const tyFile = moveRepoRelativePath(ty.file ?? moduleFileAbs, repoPath);
const attrs = attributeNames(ty.attributes);
if (ty.kind === 'struct') {
const structNodeId = moveStructNodeId(tyQualified, tyFile);
structNodeMap.set(tyQualified, structNodeId);
nodes.push({
id: structNodeId,
label: 'Struct',
properties: {
name: ty.name,
filePath: tyFile,
language: MOVE_LANGUAGE,
qualifiedName: tyQualified,
moduleQualifiedName: moduleQualified,
moduleAddress: address,
startLine: spanLine(ty.span, 0),
endLine: spanLine(ty.span, 1),
abilities: arr(ty.abilities),
isResource: arr(ty.abilities).includes(MOVE_ABILITY.KEY),
isEvent: attrs.includes(MOVE_ATTR.EVENT),
attributes: attrs,
attributesJson: attributesJson(ty.attributes),
typeParamsJson: typeParamsJson(ty.typeParams),
fields: (ty.fields ?? []).map((f) => ({
name: f.name,
type: f.type,
positional: f.positional,
})),
// STRING[] projection persisted to lbug (`fieldList` column).
fieldList: (ty.fields ?? []).map((f) => `${f.name}: ${f.type}`),
moveDeclarationKind: 'struct',
locationFidelity: ty.file ? 'precise' : 'package',
},
});
edge(moduleNodeId, structNodeId, 'DEFINES', 1.0, MOVE_EDGE_REASON.definesStruct);
// `#[resource_group_member(group = ...)]` carries an exact qualified
// struct ref (e.g. '0xa::vault::Group') - queue a membership USES_TYPE
// edge resolved against the cross-package type index in Pass B.
const resourceGroup = resourceGroupOf(ty.attributes);
if (resourceGroup) {
pendingTypeRef.push({
sourceNodeId: structNodeId,
moduleQualified,
target: resourceGroup,
reason: MOVE_EDGE_REASON.resourceGroupMember,
});
}
// Per-field Property nodes + HAS_PROPERTY edges so ACCESSES queries and
// field-level taint can target individual fields. The composite name +
// type live on the Struct as a `fields` array projection, but only the
// per-field nodes let Cypher join on `(:Struct)-[:HAS_PROPERTY]->(:Property)`.
for (const field of arr(ty.fields)) {
const propId = moveFieldNodeId(tyQualified, field.name, tyFile);
nodes.push({
id: propId,
label: 'Property',
properties: {
name: field.name,
filePath: tyFile,
language: MOVE_LANGUAGE,
qualifiedName: `${tyQualified}.${field.name}`,
parentStruct: tyQualified,
moduleQualifiedName: moduleQualified,
declaredType: field.type,
positional: field.positional,
startLine: spanLine(ty.span, 0),
},
});
edge(structNodeId, propId, 'HAS_PROPERTY', 1.0, MOVE_EDGE_REASON.hasField);
}
} else {
const eId = moveEnumNodeId(tyQualified, tyFile);
structNodeMap.set(tyQualified, eId);
nodes.push({
id: eId,
label: 'Enum',
properties: {
name: ty.name,
filePath: tyFile,
language: MOVE_LANGUAGE,
qualifiedName: tyQualified,
moduleQualifiedName: moduleQualified,
moduleAddress: address,
startLine: spanLine(ty.span, 0),
endLine: spanLine(ty.span, 1),
abilities: arr(ty.abilities),
isResource: arr(ty.abilities).includes(MOVE_ABILITY.KEY),
isEvent: attrs.includes(MOVE_ATTR.EVENT),
attributes: attrs,
attributesJson: attributesJson(ty.attributes),
typeParamsJson: typeParamsJson(ty.typeParams),
moveDeclarationKind: 'enum',
locationFidelity: ty.file ? 'precise' : 'package',
},
});
edge(moduleNodeId, eId, 'DEFINES', 1.0, MOVE_EDGE_REASON.definesEnum);
for (const variant of arr(ty.variants)) {
const vId = moveEnumVariantNodeId(tyQualified, variant.name, tyFile);
nodes.push({
id: vId,
label: 'EnumVariant',
properties: {
name: variant.name,
filePath: tyFile,
language: MOVE_LANGUAGE,
qualifiedName: `${tyQualified}::${variant.name}`,
parentEnum: tyQualified,
moduleQualifiedName: moduleQualified,
variantKind: variant.kind,
fieldsJson: JSON.stringify(
arr(variant.fields).map((f) => ({
name: f.name,
type: f.type,
positional: f.positional,
})),
),
attributes: attributeNames(variant.attributes),
attributesJson: attributesJson(variant.attributes),
locationFidelity: ty.file ? 'module' : 'package',
},
});
edge(eId, vId, 'CONTAINS', 1.0, MOVE_EDGE_REASON.containsVariant);
}
}
}
// Constants
for (const c of arr(mod.constants)) {
const cQualified = `${moduleQualified}::${c.name}`;
const cNodeId = moveConstNodeId(cQualified, file);
nodes.push({
id: cNodeId,
label: 'Const',
properties: {
name: c.name,
filePath: file,
language: MOVE_LANGUAGE,
qualifiedName: cQualified,
moduleQualifiedName: moduleQualified,
constType: c.type,
constValue: c.value,
isErrorCode: ERROR_CODE_PATTERN.test(c.name),
locationFidelity: mod.file ? 'module' : 'package',
},
});
edge(moduleNodeId, cNodeId, 'DEFINES', 1.0, MOVE_EDGE_REASON.definesConst);
}
}
return {
nodes,
edges,
moduleFileMap,
functionNodeMap,
structNodeMap,
pendingResource,
pendingFriends,
pendingTypeRef,
pendingLambdaHosts,
};
}
/**
* Resolve queued lambda→host links into CALLS edges. Run after every package's
* facts have been mapped so the host's Function node is guaranteed to exist
* (the host may be defined in a different module within the package, or — for
* cross-package lambda capture — in another package altogether).
*/
export function resolveLambdaHostEdges(
pendingLambdaHosts: readonly PendingLambdaHost[],
functionNodeMap: ReadonlyMap<string, string>,
edgeSink: (rel: GraphRelationship) => void,
): void {
const seen = new Set<string>();
for (const p of pendingLambdaHosts) {
const hostId = functionNodeMap.get(p.hostQualified);
if (!hostId) continue;
const key = `${hostId}\0${p.lambdaFnNodeId}`;
if (seen.has(key)) continue;
seen.add(key);
edgeSink({
id: moveRelId(hostId, 'CALLS', p.lambdaFnNodeId, MOVE_EDGE_REASON.lambdaHost),
sourceId: hostId,
targetId: p.lambdaFnNodeId,
type: 'CALLS',
confidence: 0.9,
reason: MOVE_EDGE_REASON.lambdaHost,
});
}
}
export function buildLocalNameIndex(
structNodeMap: ReadonlyMap<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;
}
function resolveStructRef(
localOrQualified: string,
callerModule: string,
structNodeMap: ReadonlyMap<string, string>,
structIdsByLocalName: ReadonlyMap<string, readonly string[]>,
): { targetId: string } | { unresolved: true } | { ambiguous: true } {
const exact = structNodeMap.get(localOrQualified);
if (exact) return { targetId: exact };
const base = stripTypeArgs(localOrQualified);
const baseExact = structNodeMap.get(base);
if (baseExact) return { targetId: baseExact };
// move-flow emits every resourceAccess/param/return ref fully qualified, so a
// qualified ref that misses the exact lookup is a type outside the indexed
// graph (e.g. a dependency-only 0x1::coin::CoinStore). Falling through to the
// bare-name heuristic would silently mis-bind it to a same-named repo struct
// under a different address - report unresolved instead. The heuristics below
// remain only for unqualified inputs.
if (base.includes('::')) return { unresolved: true };
const sameModule = structNodeMap.get(`${callerModule}::${base}`);
if (sameModule) return { targetId: sameModule };
const matches = structIdsByLocalName.get(moveLocalName(base)) ?? [];
if (matches.length === 1) return { targetId: matches[0] };
return matches.length > 1 ? { ambiguous: true } : { unresolved: true };
}
export function resolveResourceEdges(
pendingResource: readonly PendingResource[],
structNodeMap: ReadonlyMap<string, string>,
structIdsByLocalName: ReadonlyMap<string, readonly string[]>,
edgeSink: (rel: GraphRelationship) => void,
onUnresolved?: (pending: PendingResource) => void,
onAmbiguous?: (pending: PendingResource) => void,
): void {
const seenResourceEdges = new Set<string>();
for (const pr of pendingResource) {
const resolved = resolveStructRef(
pr.target,
pr.moduleQualified,
structNodeMap,
structIdsByLocalName,
);
if ('unresolved' in resolved) {
onUnresolved?.(pr);
continue;
}
if ('ambiguous' in resolved) {
onAmbiguous?.(pr);
continue;
}
const key = `${pr.fnNodeId}\0${pr.type}\0${resolved.targetId}`;
if (seenResourceEdges.has(key)) continue;
seenResourceEdges.add(key);
edgeSink({
id: moveRelId(pr.fnNodeId, pr.type, resolved.targetId, pr.reason),
sourceId: pr.fnNodeId,
targetId: resolved.targetId,
type: pr.type,
confidence: 1.0,
reason: pr.reason,
});
}
}
export function resolveFriendEdges(
pendingFriends: readonly PendingFriend[],
moduleFileMap: ReadonlyMap<string, string>,
edgeSink: (rel: GraphRelationship) => void,
): void {
for (const pf of pendingFriends) {
const friendFile = moduleFileMap.get(pf.friend);
if (!friendFile) continue;
const targetId = moveModuleNodeId(pf.friend, friendFile);
edgeSink({
id: moveRelId(pf.moduleNodeId, 'FRIEND_OF', targetId, MOVE_EDGE_REASON.friend),
sourceId: pf.moduleNodeId,
targetId,
type: 'FRIEND_OF',
confidence: 1.0,
reason: MOVE_EDGE_REASON.friend,
});
}
}
export function resolveTypeRefEdges(
pendingTypeRef: readonly PendingTypeRef[],
structNodeMap: ReadonlyMap<string, string>,
structIdsByLocalName: ReadonlyMap<string, readonly string[]>,
edgeSink: (rel: GraphRelationship) => void,
onUnresolved?: (pending: PendingTypeRef) => void,
onAmbiguous?: (pending: PendingTypeRef) => void,
): void {
const seenTypeEdges = new Set<string>();
for (const pr of pendingTypeRef) {
const resolved = resolveStructRef(
pr.target,
pr.moduleQualified,
structNodeMap,
structIdsByLocalName,
);
if ('unresolved' in resolved) {
onUnresolved?.(pr);
continue;
}
if ('ambiguous' in resolved) {
onAmbiguous?.(pr);
continue;
}
const key = `${pr.sourceNodeId}\0${pr.reason}\0${resolved.targetId}`;
if (seenTypeEdges.has(key)) continue;
seenTypeEdges.add(key);
edgeSink({
id: moveRelId(pr.sourceNodeId, 'USES_TYPE', resolved.targetId, pr.reason),
sourceId: pr.sourceNodeId,
targetId: resolved.targetId,
type: 'USES_TYPE',
confidence: 1.0,
reason: pr.reason,
});
}
}
// Re-export for callers that prefer the label type.
export type { NodeLabel };
+598
View File
@@ -0,0 +1,598 @@
/**
* MoveFlowClient implementation that spawns `move-flow mcp` and communicates
* via MCP JSON-RPC over stdio (newline-delimited JSON).
*
* The move-flow binary (Rust/rmcp) is expected at `process.env.MOVE_FLOW`
* or on $PATH as `move-flow`.
*/
import { spawn, execFileSync, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { createInterface } from 'node:readline';
import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as path from 'node:path';
import type { MoveFactsMap, CallGraphMap } from './compiler-facts.js';
interface JsonRpcResponse {
jsonrpc: '2.0';
id?: number;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
}
interface JsonRpcNotification {
jsonrpc: '2.0';
method: string;
params?: Record<string, unknown>;
}
/** Shape of a successful MCP tools/call result. */
interface McpCallToolResult {
isError?: boolean;
content?: Array<{ text?: string }>;
}
function isMcpCallToolResult(v: unknown): v is McpCallToolResult {
return typeof v === 'object' && v !== null;
}
/** Shape of a tools/list response. */
interface McpListToolResult {
tools?: unknown[];
}
function isMcpListToolResult(v: unknown): v is McpListToolResult {
return typeof v === 'object' && v !== null;
}
/** JSON-RPC `invalid_params` - move-flow uses it for caller/input errors
* (and some builds route package build failures through it). */
const JSON_RPC_INVALID_PARAMS = -32602;
/** Real Aptos packages may need several minutes for a cold compiler build. */
const DEFAULT_MOVE_FLOW_TOOL_TIMEOUT_MS = 300_000;
function resolveMoveFlowToolTimeoutMs(): number {
const configured = Number(process.env.GITNEXUS_MOVE_FLOW_TIMEOUT_MS);
return Number.isSafeInteger(configured) && configured > 0
? configured
: DEFAULT_MOVE_FLOW_TOOL_TIMEOUT_MS;
}
/**
* A move-flow tool call that failed in user space: the package could not be
* built (an `isError: true` tool result whose content text carries the
* diagnostic, e.g. "failed to build package `<path>`: <reason>") or the request
* parameters were rejected (JSON-RPC -32602 invalid_params). Distinguishes
* caller/input errors from transport and server faults so callers can render
* them as user-actionable.
*/
export class MoveFlowToolCallError extends Error {
/** JSON-RPC error code when the failure surfaced as a protocol error. */
readonly code?: number;
constructor(message: string, opts: { code?: number } = {}) {
super(message);
this.name = 'MoveFlowToolCallError';
this.code = opts.code;
}
}
/**
* 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
* the client, never the reverse.
*/
export interface MoveFlowClient {
/** Full-fidelity per-module facts (move_package_query query:"facts"). */
facts(packagePath: string): Promise<MoveFactsMap>;
/** Function-level call graph (caller qualified name → callee qualified names). */
callGraph(packagePath: string): Promise<CallGraphMap>;
/**
* 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 -
* gate calls solely on `capabilities().hasStatusTool` (like `facts` on
* `hasFactsQuery`).
*/
packageStatus(packagePath: string): Promise<MovePackageStatus>;
/** Capability probe (cached): which queries this move-flow build supports. */
capabilities(): Promise<MoveFlowCapabilities>;
shutdown(): Promise<void>;
}
/**
* Result of `move_package_status`: whether the package builds, plus the raw
* text move-flow returned ("no errors or warnings" on success, the compiler
* diagnostics on failure).
*/
export interface MovePackageStatus {
ok: boolean;
diagnostics: string;
}
/** What a given move-flow build can answer. */
export interface MoveFlowCapabilities {
/** `facts` query available (rich, compiler-sourced per-module facts). */
hasFactsQuery: boolean;
/** `move_package_status` tool available (build status + diagnostics). */
hasStatusTool: boolean;
}
/** Minimal shape of an entry in an MCP `tools/list` response. */
export interface MoveFlowToolInfo {
name: string;
inputSchema?: unknown;
}
/**
* Derive move-flow capabilities from a `tools/list` response.
*
* `facts` is a *query type* on `move_package_query` (a `const` in the tool's
* `inputSchema` QueryType enum), not a standalone tool — so we detect it by
* inspecting the schema. A hypothetical future standalone `move_package_facts`
* tool is also honoured for forward-compatibility.
*
* Accepts either bare tool-name strings or `{ name, inputSchema }` entries.
*/
export function detectMoveFlowCapabilities(
tools: ReadonlyArray<string | MoveFlowToolInfo>,
): MoveFlowCapabilities {
const names = new Set<string>();
let querySchema: unknown;
for (const t of tools) {
if (typeof t === 'string') {
names.add(t);
} else {
names.add(t.name);
if (t.name === 'move_package_query') querySchema = t.inputSchema;
}
}
const hasFactsQuery = names.has('move_package_facts') || schemaMentionsFactsQuery(querySchema);
return { hasFactsQuery, hasStatusTool: names.has('move_package_status') };
}
/** True if the `move_package_query` inputSchema declares a `"facts"` query const. */
function schemaMentionsFactsQuery(schema: unknown): boolean {
if (!schema || typeof schema !== 'object') return false;
// Walk the JSON-schema object looking for a `const: "facts"` or
// `enum: [... "facts" ...]` anywhere under the QueryType definition.
const stack: unknown[] = [schema];
while (stack.length) {
const node = stack.pop();
if (!node || typeof node !== 'object') continue;
const obj = node as Record<string, unknown>;
if (obj.const === 'facts') return true;
if (Array.isArray(obj.enum) && obj.enum.includes('facts')) return true;
for (const v of Object.values(obj)) {
if (v && typeof v === 'object') stack.push(v);
}
}
return false;
}
export class MoveFlowMcpClient implements MoveFlowClient {
private proc: ChildProcessWithoutNullStreams | null = null;
private requestId = 0;
private pending = new Map<
number,
{
resolve: (v: unknown) => void;
reject: (e: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}
>();
private initialized = false;
private initPromise: Promise<void> | null = null;
private capsPromise: Promise<MoveFlowCapabilities> | null = null;
private binaryPath: string;
private stderrLines: string[] = [];
private static readonly MAX_STDERR = 20;
private stderrContext(): string {
if (this.stderrLines.length === 0) return '';
return `\nstderr (last ${this.stderrLines.length} lines):\n${this.stderrLines.join('\n')}`;
}
constructor(binaryPath?: string) {
this.binaryPath = binaryPath || process.env.MOVE_FLOW || 'move-flow';
}
private async ensureStarted(): Promise<void> {
if (this.initialized) return;
if (this.initPromise) return this.initPromise;
const start = this._start().catch((err) => {
// A failed startup must not poison the client for the rest of the run.
// Only clear the promise for this attempt so a late event from an older
// child cannot reset a newer retry.
if (this.initPromise === start) {
this.initialized = false;
this.initPromise = null;
this.capsPromise = null;
}
throw err;
});
this.initPromise = start;
return start;
}
private async _start(): Promise<void> {
return new Promise<void>((resolve, reject) => {
this.stderrLines.length = 0;
const proc = spawn(this.binaryPath, ['mcp'], {
stdio: ['pipe', 'pipe', 'pipe'],
});
this.proc = proc;
let initId: number | null = null;
let initSettled = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
let rl: ReturnType<typeof createInterface> | null = null;
const clearInitTimeout = (): void => {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
};
const failInitialization = (err: Error, killProcess = true): void => {
if (initSettled) return;
initSettled = true;
clearInitTimeout();
if (initId !== null) this.pending.delete(initId);
rl?.close();
// An old child's late error/exit must not tear down a newer retry.
if (this.proc === proc) {
this.proc = null;
this.initialized = false;
}
if (killProcess) {
try {
proc.kill();
} catch {
/* process may already be dead */
}
}
reject(err);
};
const failRunningProcess = (err: Error, killProcess = false): void => {
if (this.proc !== proc) return;
for (const [, pending] of this.pending) {
clearTimeout(pending.timeout);
pending.reject(err);
}
this.pending.clear();
this.initialized = false;
this.initPromise = null;
this.capsPromise = null;
this.proc = null;
if (killProcess) {
try {
proc.kill();
} catch {
/* process may already be dead */
}
}
};
timeout = setTimeout(() => {
failInitialization(new Error('move-flow MCP server did not respond within 30s'));
}, 30000);
proc.stderr.on('data', (chunk: Buffer) => {
for (const line of chunk.toString().split('\n')) {
if (!line) continue;
this.stderrLines.push(line);
if (this.stderrLines.length > MoveFlowMcpClient.MAX_STDERR) {
this.stderrLines.shift();
}
}
});
// Absorb async EPIPE from writes to a dead child so it does not become an
// uncaughtException; the 'exit' handler settles in-flight requests.
proc.stdin.on('error', (err) => {
this.stderrLines.push(`stdin error: ${err.message}`);
if (this.stderrLines.length > MoveFlowMcpClient.MAX_STDERR) {
this.stderrLines.shift();
}
});
proc.on('error', (err) => {
const wrapped = new Error(
`Failed to spawn move-flow: ${err.message}${this.stderrContext()}`,
);
if (!initSettled) {
failInitialization(wrapped);
} else {
failRunningProcess(wrapped, true);
}
});
proc.on('exit', (code) => {
if (this.proc !== proc) return;
if (!initSettled) {
failInitialization(
new Error(`move-flow exited with code ${code} during init${this.stderrContext()}`),
false,
);
return;
}
failRunningProcess(
new Error(`move-flow exited unexpectedly (code ${code})${this.stderrContext()}`),
);
});
rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
rl.on('line', (line) => {
if (!line.trim()) return;
try {
const msg = JSON.parse(line) as JsonRpcResponse;
if (msg.id != null) {
const p = this.pending.get(msg.id);
if (p) {
this.pending.delete(msg.id);
if (msg.error) {
p.reject(
msg.error.code === JSON_RPC_INVALID_PARAMS
? new MoveFlowToolCallError(msg.error.message, { code: msg.error.code })
: new Error(`MCP error ${msg.error.code}: ${msg.error.message}`),
);
} else {
p.resolve(msg.result);
}
}
}
} catch {
/* ignore non-JSON lines */
}
});
// Send initialize
initId = ++this.requestId;
this.pending.set(initId, {
resolve: () => {
if (initSettled) return;
clearInitTimeout();
const notif: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'notifications/initialized',
};
try {
this.send(notif);
} catch (err) {
failInitialization(err instanceof Error ? err : new Error(String(err)));
return;
}
initSettled = true;
this.initialized = true;
resolve();
},
reject: (err) => {
failInitialization(err);
},
timeout,
});
try {
this.send({
jsonrpc: '2.0',
id: initId,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'gitnexus', version: '1.0.0' },
},
});
} catch (err) {
failInitialization(err instanceof Error ? err : new Error(String(err)));
}
});
}
private send(msg: Record<string, unknown> | JsonRpcNotification): void {
if (!this.proc) throw new Error('move-flow MCP server is not running');
this.proc.stdin.write(JSON.stringify(msg) + '\n');
}
private async callTool(toolName: string, args: Record<string, unknown>): Promise<unknown> {
await this.ensureStarted();
return new Promise<unknown>((resolve, reject) => {
const id = ++this.requestId;
const timeoutMs = resolveMoveFlowToolTimeoutMs();
const timeout = setTimeout(() => {
this.pending.delete(id);
try {
this.proc?.kill();
} catch {
/* process may already be dead */
}
reject(
new Error(
`move-flow '${toolName}' timed out after ${timeoutMs}ms ` +
'(raise GITNEXUS_MOVE_FLOW_TIMEOUT_MS for large packages)',
),
);
}, timeoutMs);
this.pending.set(id, {
resolve: (result) => {
clearTimeout(timeout);
if (!isMcpCallToolResult(result)) {
resolve(result);
return;
}
// Tool-level failures (e.g. package build failures) arrive as a
// SUCCESS result with `isError: true` and the diagnostic as content
// text - reject rather than hand the error text to callers as data.
if (result.isError === true) {
const text =
typeof result.content?.[0]?.text === 'string'
? result.content[0].text
: `move-flow '${toolName}' reported an unspecified tool error`;
reject(new MoveFlowToolCallError(text));
return;
}
if (result.content?.[0]?.text) {
try {
resolve(JSON.parse(result.content[0].text));
} catch {
resolve(result.content[0].text);
}
} else {
resolve(result);
}
},
reject: (err) => {
clearTimeout(timeout);
reject(err);
},
timeout,
});
this.send({
jsonrpc: '2.0',
id,
method: 'tools/call',
params: { name: toolName, arguments: args },
});
});
}
async callGraph(packagePath: string): Promise<CallGraphMap> {
return (await this.callTool('move_package_query', {
package_path: packagePath,
query: 'call_graph',
})) as CallGraphMap;
}
async facts(packagePath: string): Promise<MoveFactsMap> {
return (await this.callTool('move_package_query', {
package_path: packagePath,
query: 'facts',
})) as MoveFactsMap;
}
async packageStatus(packagePath: string): Promise<MovePackageStatus> {
try {
const result = await this.callTool('move_package_status', { package_path: packagePath });
return { ok: true, diagnostics: typeof result === 'string' ? result : '' };
} catch (err) {
// A failing build arrives as isError:true with the compiler diagnostics
// as content text - for this tool that IS the answer, not a call failure.
if (err instanceof MoveFlowToolCallError) {
return { ok: false, diagnostics: err.message };
}
throw err;
}
}
/** Raw JSON-RPC request (non-`tools/call`), e.g. `tools/list`. */
private async rpcRequest(method: string, params?: unknown): Promise<unknown> {
await this.ensureStarted();
return new Promise<unknown>((resolve, reject) => {
const id = ++this.requestId;
const timeout = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`move-flow '${method}' timed out after 30s`));
}, 30000);
this.pending.set(id, {
resolve: (result) => {
clearTimeout(timeout);
resolve(result);
},
reject: (err) => {
clearTimeout(timeout);
reject(err);
},
timeout,
});
this.send({ jsonrpc: '2.0', id, method, params });
});
}
async capabilities(): Promise<MoveFlowCapabilities> {
if (this.capsPromise) return this.capsPromise;
this.capsPromise = (async () => {
try {
const listed = await this.rpcRequest('tools/list', {});
const tools = (
isMcpListToolResult(listed) ? (listed.tools ?? []) : []
) as MoveFlowToolInfo[];
return detectMoveFlowCapabilities(tools);
} catch {
// Probe failure → facts unavailable; the ingest phase trips its hard gate.
return { hasFactsQuery: false, hasStatusTool: false };
}
})();
return this.capsPromise;
}
async shutdown(): Promise<void> {
const shutdownError = new Error('move-flow client shutdown');
for (const [, p] of this.pending) {
clearTimeout(p.timeout);
p.reject(shutdownError);
}
this.pending.clear();
if (this.proc) {
this.proc.stdin?.end();
this.proc.kill();
this.proc = null;
}
this.initialized = false;
this.initPromise = null;
this.capsPromise = null;
this.stderrLines.length = 0;
}
}
/**
* Try to create a MoveFlowMcpClient. Returns null if move-flow binary
* is not found on the system.
*
* Resolution order:
* 1. `$MOVE_FLOW` (explicit override for power users / CI).
* 2. Bundled `vendor/move-flow/<platform>/move-flow[.exe]` (the postinstall
* probe installs here — see `scripts/install-move-flow.cjs`).
* 3. `move-flow` on `$PATH` (host install).
*/
function bundledMoveFlowPath(): string | null {
const { platform, arch } = process;
let key: string | null = null;
if (platform === 'linux' && arch === 'x64') key = 'linux-x64';
else if (platform === 'linux' && arch === 'arm64') key = 'linux-arm64';
else if (platform === 'darwin' && arch === 'arm64') key = 'darwin-arm64';
else if (platform === 'darwin' && arch === 'x64') key = 'darwin-x64';
else if (platform === 'win32' && arch === 'x64') key = 'win32-x64';
if (!key) return null;
const name = platform === 'win32' ? 'move-flow.exe' : 'move-flow';
// mcp-client.ts lives at gitnexus/src/core/move/; vendor/ is two levels above src/.
const here = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(here, '..', '..', '..', 'vendor', 'move-flow', key, name);
}
function probeBinary(binary: string): boolean {
try {
execFileSync(binary, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
export function tryCreateMoveFlowClient(): MoveFlowMcpClient | null {
const explicit = process.env.MOVE_FLOW;
if (explicit) {
return probeBinary(explicit) ? new MoveFlowMcpClient(explicit) : null;
}
const bundled = bundledMoveFlowPath();
if (bundled && existsSync(bundled) && probeBinary(bundled)) {
return new MoveFlowMcpClient(bundled);
}
const onPath = 'move-flow';
return probeBinary(onPath) ? new MoveFlowMcpClient(onPath) : null;
}
+443
View File
@@ -0,0 +1,443 @@
/**
* Phase: moveIngest
*
* Compiler-first Move ingestion via the move-flow MCP server. **No raw Move
* source is ever read.** The `facts` query is the ONLY ingestion path: if the
* move-flow build doesn't expose it, the phase fails fast with a clear upgrade
* error before touching any package. Call edges come from the `call_graph`
* query.
*
* Note: move-flow's `facts` query elides all `#[test]` and `#[test_only]`
* symbols (functions, structs, constants). The graph therefore contains only
* production symbols; queries to find test functions or exclude tests from
* impact do not need a symbol-level test filter.
*
* @deps structure
* @reads scannedFiles (from structure phase)
* @writes graph (Module/Function/Struct/Enum/EnumVariant/Const nodes +
* CALLS/DEFINES/CONTAINS/FRIEND_OF/READS_RESOURCE/WRITES_RESOURCE/
* ACQUIRES/USES_TYPE/ENTRY_POINT_OF/IMPORTS edges)
*/
import * as path from 'node:path';
import type {
PipelinePhase,
PipelineContext,
PhaseResult,
} 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 { StandaloneIngestOutput } from '../ingestion/pipeline-phases/standalone-ingest.js';
import type { KnowledgeGraph } from '../graph/types.js';
import { MOVE_EDGE_REASON, moveRepoRelativePath } from './constants.js';
import {
MoveFlowToolCallError,
type MoveFlowClient,
type MovePackageStatus,
} from './mcp-client.js';
import {
buildLocalNameIndex,
mapFactsToGraph,
resolveFriendEdges,
resolveLambdaHostEdges,
resolveResourceEdges,
resolveTypeRefEdges,
type MoveFactsMapResult,
type PendingFriend,
type PendingLambdaHost,
type PendingResource,
type PendingTypeRef,
} from './facts-mapper.js';
import type { CallGraphMap, MoveFactsMap } from './compiler-facts.js';
import { moveModuleNodeId, moveModuleQualifiedName, moveRelId } from './symbol-id.js';
import {
emptyFactsIssue,
validateMoveIngestOutput,
type EmptyFactsPackage,
type MoveConsistencyIssue,
} from './consistency.js';
import { createMoveEntryPointEdges } from './entry-points.js';
// ── Phase output ───────────────────────────────────────────────────────────
export interface MoveIngestOutput extends StandaloneIngestOutput {
/** Move package roots (absolute directories containing Move.toml). */
packageRoots: string[];
/** Module qualified names → source file path (repo-relative or package-root). */
moduleFileMap: ReadonlyMap<string, string>;
/** Function qualified names → graph node IDs. */
functionNodeMap: ReadonlyMap<string, string>;
/** Struct/enum qualified names → graph node IDs. */
structNodeMap: ReadonlyMap<string, string>;
/** Module qualified names → absolute package root. */
modulePackageMap: ReadonlyMap<string, string>;
/** Repo-relative file paths → absolute package root. */
filePackageMap: ReadonlyMap<string, string>;
/** Absolute package root → compiler call graph for that package. */
callGraphByPackage: ReadonlyMap<string, CallGraphMap>;
/** Resource references dropped during global resolution. */
droppedResourceRefs?: { fnNodeId: string; target: string }[];
/** Non-fatal consistency issues found after Move ingestion. */
consistencyIssues: MoveConsistencyIssue[];
}
/** Mutable accumulator shared while ingesting every package. */
interface MoveIngestState {
ingestedFiles: Set<string>;
moduleFileMap: Map<string, string>;
functionNodeMap: Map<string, string>;
structNodeMap: Map<string, string>;
modulePackageMap: Map<string, string>;
filePackageMap: Map<string, string>;
callGraphByPackage: Map<string, CallGraphMap>;
pendingResource: PendingResource[];
pendingFriends: PendingFriend[];
pendingTypeRef: PendingTypeRef[];
pendingLambdaHosts: PendingLambdaHost[];
droppedResourceRefs: { fnNodeId: string; target: string }[];
}
function createState(): MoveIngestState {
return {
ingestedFiles: new Set(),
moduleFileMap: new Map(),
functionNodeMap: new Map(),
structNodeMap: new Map(),
modulePackageMap: new Map(),
filePackageMap: new Map(),
callGraphByPackage: new Map(),
pendingResource: [],
pendingFriends: [],
pendingTypeRef: [],
pendingLambdaHosts: [],
droppedResourceRefs: [],
};
}
function toOutput(
state: MoveIngestState,
packageRoots: string[],
consistencyIssues: MoveConsistencyIssue[] = [],
): MoveIngestOutput {
return {
ingestedFiles: state.ingestedFiles,
packageRoots,
moduleFileMap: state.moduleFileMap,
functionNodeMap: state.functionNodeMap,
structNodeMap: state.structNodeMap,
modulePackageMap: state.modulePackageMap,
filePackageMap: state.filePackageMap,
callGraphByPackage: state.callGraphByPackage,
droppedResourceRefs: state.droppedResourceRefs,
consistencyIssues,
};
}
/** Add a mapped package's nodes/edges to the graph and merge its identity maps. */
function applyMapped(
graph: KnowledgeGraph,
mapped: MoveFactsMapResult,
pkgRoot: string,
state: MoveIngestState,
): void {
for (const node of mapped.nodes) graph.addNode(node);
for (const rel of mapped.edges) graph.addRelationship(rel);
for (const [qn, file] of mapped.moduleFileMap) {
state.moduleFileMap.set(qn, file);
state.modulePackageMap.set(qn, pkgRoot);
state.filePackageMap.set(file, pkgRoot);
}
for (const [qn, id] of mapped.functionNodeMap) state.functionNodeMap.set(qn, id);
for (const [qn, id] of mapped.structNodeMap) state.structNodeMap.set(qn, id);
state.pendingResource.push(...mapped.pendingResource);
state.pendingFriends.push(...mapped.pendingFriends);
state.pendingTypeRef.push(...mapped.pendingTypeRef);
state.pendingLambdaHosts.push(...mapped.pendingLambdaHosts);
}
export function createMoveIngestPhase(
client: MoveFlowClient | null,
): PipelinePhase<MoveIngestOutput> {
return {
name: 'standaloneIngest',
deps: ['structure'],
async execute(
ctx: PipelineContext,
deps: ReadonlyMap<string, PhaseResult<unknown>>,
): Promise<MoveIngestOutput> {
const { scannedFiles, totalFiles } = getPhaseOutput<StructureOutput>(deps, 'structure');
const packageRoots = [
...new Set(
scannedFiles
.map((f) => f.path)
.filter((p) => p.endsWith('Move.toml'))
.map((p) => path.dirname(path.resolve(ctx.repoPath, p))),
),
].sort();
if (!client || packageRoots.length === 0) {
return toOutput(createState(), packageRoots);
}
const { hasFactsQuery, hasStatusTool } = await client.capabilities();
if (!hasFactsQuery) {
// userActionable: rendered as a one-liner without a stack — the fix is
// an operator action (upgrade move-flow), not a code bug.
throw Object.assign(
new Error(
'move-flow is too old — upgrade to a build that exposes `move_package_query { query: "facts" }`.',
),
{ userActionable: true },
);
}
const state = createState();
// Group scanned .move files by their (innermost) owning package. Files
// are marked as ingested per package only AFTER its facts arrive, so a
// package whose facts come back empty is left un-ingested rather than
// silently absent from the graph (the generic parse phase still sees it).
const moveFilesByPackage = new Map<string, string[]>();
for (const f of scannedFiles) {
if (!f.path.endsWith('.move')) continue;
const abs = path.resolve(ctx.repoPath, f.path);
// Boundary-safe: bare startsWith would let a sibling directory sharing
// a name prefix (pkg_ab vs root pkg_a) claim the file.
const owners = packageRoots.filter((root) => {
const prefix = root.endsWith(path.sep) ? root : root + path.sep;
return abs.startsWith(prefix);
});
if (owners.length === 0) continue;
const owner = owners.reduce((a, b) => (b.length > a.length ? b : a));
const files = moveFilesByPackage.get(owner) ?? [];
files.push(moveRepoRelativePath(abs, ctx.repoPath));
moveFilesByPackage.set(owner, files);
}
const emptyFactsPackages: EmptyFactsPackage[] = [];
// Pass 1: per-package nodes/edges (all packages first, so cross-package
// CALLS in Pass 2 can resolve callees in later packages).
for (const pkgRoot of packageRoots) {
ctx.onProgress({
phase: 'moveIngest',
percent: 18,
message: `Ingesting Move package: ${path.basename(pkgRoot)}`,
stats: { filesProcessed: 0, totalFiles, nodesCreated: ctx.graph.nodeCount },
});
let callGraphData: CallGraphMap;
let factsMap: MoveFactsMap;
try {
callGraphData = await client.callGraph(pkgRoot);
factsMap = await client.facts(pkgRoot);
} catch (err) {
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.
throw Object.assign(
new Error(`move-flow could not build Move package ${pkgRoot}: ${err.message}`),
{ userActionable: true },
);
}
throw err;
}
const pkgMoveFiles = moveFilesByPackage.get(pkgRoot) ?? [];
if (Object.keys(factsMap).length === 0 && pkgMoveFiles.length > 0) {
// Facts `{}` is ambiguous: syntax-broken packages return it as a
// SUCCESS (the compiler diagnostic only surfaces via
// `move_package_status`), but so do valid packages whose every item
// is `#[test]`/`#[test_only]` (elided from facts - see file header).
// Cross-check the build status to tell them apart; either way skip
// the package (its files stay un-ingested) and record an issue.
emptyFactsPackages.push({
pkgRoot,
moveFileCount: pkgMoveFiles.length,
status: await probePackageStatus(client, pkgRoot, hasStatusTool),
});
continue;
}
// Only past the gate: a skipped package must have zero footprint in
// Pass 2 linking and consistency validation.
state.callGraphByPackage.set(pkgRoot, callGraphData);
for (const rel of pkgMoveFiles) state.ingestedFiles.add(rel);
applyMapped(ctx.graph, mapFactsToGraph(factsMap, pkgRoot, ctx.repoPath), pkgRoot, state);
}
// Pass 2+: link edges that need the full cross-package node index.
linkCallEdges(ctx.graph, state);
linkLambdaHostEdges(ctx.graph, state);
linkResourceAndFriendEdges(ctx.graph, state);
linkFileImports(ctx.graph, state);
linkFileModuleContains(ctx.graph, state);
const output = toOutput(state, packageRoots);
createMoveEntryPointEdges(ctx.graph, output);
const consistencyIssues: MoveConsistencyIssue[] = emptyFactsPackages.map(emptyFactsIssue);
consistencyIssues.push(...validateMoveIngestOutput(ctx.graph, output));
reportConsistencyIssues(ctx, consistencyIssues);
return { ...output, consistencyIssues };
},
};
}
// --- Empty-facts discrimination ---
/** Cross-check the build status of a sources-but-empty-facts package.
* `null` = status unavailable (no status tool, or the probe itself failed). */
async function probePackageStatus(
client: MoveFlowClient,
pkgRoot: string,
hasStatusTool: boolean,
): Promise<MovePackageStatus | null> {
if (!hasStatusTool) return null;
try {
return await client.packageStatus(pkgRoot);
} catch {
// Probe failure -> cannot discriminate; fall back to the pessimistic issue.
return null;
}
}
/** CALLS edges from each package's call graph (resolved across all packages). */
function linkCallEdges(graph: KnowledgeGraph, state: MoveIngestState): void {
for (const callGraph of state.callGraphByPackage.values()) {
for (const [callerQualified, callees] of Object.entries(callGraph)) {
const callerId = state.functionNodeMap.get(callerQualified);
if (!callerId) continue;
for (const calleeQualified of callees) {
const calleeId = state.functionNodeMap.get(calleeQualified);
if (!calleeId) continue;
graph.addRelationship({
id: moveRelId(callerId, 'CALLS', calleeId, MOVE_EDGE_REASON.calls),
sourceId: callerId,
targetId: calleeId,
type: 'CALLS',
confidence: 1.0,
reason: MOVE_EDGE_REASON.calls,
});
}
}
}
}
/**
* CALLS edges from each `__lambda__N__host` function back to its host. move-flow
* synthesises lambdas as standalone functions but does NOT include the host-to-
* lambda link in `call_graph` — without it, upstream traversal from the lambda
* dead-ends and processes that route through a callback (e.g. market_callbacks
* → settle_trade) lose the bridge.
*/
function linkLambdaHostEdges(graph: KnowledgeGraph, state: MoveIngestState): void {
resolveLambdaHostEdges(state.pendingLambdaHosts, state.functionNodeMap, (rel) =>
graph.addRelationship(rel),
);
}
/** Resource/friend edges from facts, resolved after all package nodes exist. */
function linkResourceAndFriendEdges(graph: KnowledgeGraph, state: MoveIngestState): void {
const structIdsByLocalName = buildLocalNameIndex(state.structNodeMap);
resolveResourceEdges(
state.pendingResource,
state.structNodeMap,
structIdsByLocalName,
(rel) => graph.addRelationship(rel),
(pending) =>
state.droppedResourceRefs.push({ fnNodeId: pending.fnNodeId, target: pending.target }),
(pending) =>
state.droppedResourceRefs.push({ fnNodeId: pending.fnNodeId, target: pending.target }),
);
resolveFriendEdges(state.pendingFriends, state.moduleFileMap, (rel) =>
graph.addRelationship(rel),
);
resolveTypeRefEdges(state.pendingTypeRef, state.structNodeMap, structIdsByLocalName, (rel) => {
graph.addRelationship(rel);
addUsedType(graph, rel.sourceId, rel.targetId);
});
}
function addUsedType(graph: KnowledgeGraph, functionNodeId: string, typeNodeId: string): void {
const fnNode = graph.getNode(functionNodeId);
const typeNode = graph.getNode(typeNodeId);
const qualifiedName = typeNode?.properties.qualifiedName;
// Only Function nodes carry `usedTypes` - resource-group membership USES_TYPE
// edges have a Struct source and must not grow the property there.
if (!fnNode || fnNode.label !== 'Function' || typeof qualifiedName !== 'string') return;
const current = Array.isArray(fnNode.properties.usedTypes) ? fnNode.properties.usedTypes : [];
if (current.includes(qualifiedName)) return;
fnNode.properties.usedTypes = [...current, qualifiedName];
}
/** File→File IMPORTS derived from cross-module CALLS (deduped against existing). */
function linkFileImports(graph: KnowledgeGraph, state: MoveIngestState): void {
const seen = new Set<string>();
for (const r of graph.iterRelationshipsByType('IMPORTS')) {
if (!r.sourceId.startsWith('File:') || !r.targetId.startsWith('File:')) continue;
seen.add(`${r.sourceId.slice(5)}\0${r.targetId.slice(5)}`);
}
for (const callGraph of state.callGraphByPackage.values()) {
for (const [callerQualified, callees] of Object.entries(callGraph)) {
const callerFile = state.moduleFileMap.get(moveModuleQualifiedName(callerQualified));
if (!callerFile) continue;
for (const calleeQualified of callees) {
const calleeFile = state.moduleFileMap.get(moveModuleQualifiedName(calleeQualified));
if (!calleeFile || calleeFile === callerFile) continue;
const key = `${callerFile}\0${calleeFile}`;
if (seen.has(key)) continue;
seen.add(key);
const sourceFileId = `File:${callerFile}`;
const targetFileId = `File:${calleeFile}`;
if (graph.getNode(sourceFileId) && graph.getNode(targetFileId)) {
graph.addRelationship({
id: moveRelId(
sourceFileId,
'IMPORTS',
targetFileId,
MOVE_EDGE_REASON.crossModuleDependency,
),
sourceId: sourceFileId,
targetId: targetFileId,
type: 'IMPORTS',
confidence: 0.9,
reason: MOVE_EDGE_REASON.crossModuleDependency,
});
}
}
}
}
}
/** File→Module CONTAINS where the File node exists (from the structure phase). */
function linkFileModuleContains(graph: KnowledgeGraph, state: MoveIngestState): void {
for (const [qn, file] of state.moduleFileMap) {
const fileNodeId = `File:${file}`;
const moduleNodeId = moveModuleNodeId(qn, file);
if (graph.getNode(fileNodeId) && graph.getNode(moduleNodeId)) {
graph.addRelationship({
id: moveRelId(fileNodeId, 'CONTAINS', moduleNodeId, MOVE_EDGE_REASON.moduleInFile),
sourceId: fileNodeId,
targetId: moduleNodeId,
type: 'CONTAINS',
confidence: 1.0,
reason: MOVE_EDGE_REASON.moduleInFile,
});
}
}
}
/** Surface consistency errors so they reach a human/log (not just the output). */
function reportConsistencyIssues(ctx: PipelineContext, issues: MoveConsistencyIssue[]): void {
const errors = issues.filter((i) => i.severity === 'error');
if (errors.length === 0) return;
ctx.onProgress({
phase: 'moveIngest',
percent: 22,
message: `Move ingest: ${errors.length} consistency error(s) (e.g. ${errors[0].message})`,
stats: { filesProcessed: 0, totalFiles: 0, nodesCreated: ctx.graph.nodeCount },
});
}
+95
View File
@@ -0,0 +1,95 @@
/**
* Canonical Move symbol identity helpers.
*
* Move symbols need the fully-qualified address/module path in their graph IDs.
* File path + local name is not unique when a file contains multiple modules or
* two named-address packages expose the same local names.
*/
export interface ParsedMoveModuleName {
address: string;
moduleName: string;
}
export function parseMoveModuleQualifiedName(qualifiedName: string): ParsedMoveModuleName {
const sep = qualifiedName.indexOf('::');
if (sep === -1) return { address: '', moduleName: qualifiedName };
return { address: qualifiedName.slice(0, sep), moduleName: qualifiedName.slice(sep + 2) };
}
export function moveModuleQualifiedName(symbolQualifiedName: string): string {
const sep = symbolQualifiedName.lastIndexOf('::');
return sep === -1 ? symbolQualifiedName : symbolQualifiedName.slice(0, sep);
}
export function moveLocalName(qualifiedName: string): string {
const sep = qualifiedName.lastIndexOf('::');
return sep === -1 ? qualifiedName : qualifiedName.slice(sep + 2);
}
export function moveShortSymbol(qualifiedName: string): string {
const moduleQualified = moveModuleQualifiedName(qualifiedName);
const { moduleName } = parseMoveModuleQualifiedName(moduleQualified);
return `${moduleName}::${moveLocalName(qualifiedName)}`;
}
/**
* Deterministic relationship id: re-analysis is reproducible and edges dedup
* by id. `reason` is part of the key so edges differing only by reason
* (e.g. USES_TYPE param vs return) stay distinct.
*/
export function moveRelId(
sourceId: string,
type: string,
targetId: string,
reason: string,
): string {
return `rel:${type}:${sourceId}->${targetId}:${reason}`;
}
export function moveModuleNodeId(moduleQualifiedName: string, filePath: string): string {
return `Module:${filePath}:${moduleQualifiedName}`;
}
export function moveFunctionNodeId(functionQualifiedName: string, filePath: string): string {
return `Function:${filePath}:${functionQualifiedName}`;
}
export function moveStructNodeId(structQualifiedName: string, filePath: string): string {
return `Struct:${filePath}:${structQualifiedName}`;
}
export function moveEnumNodeId(enumQualifiedName: string, filePath: string): string {
return `Enum:${filePath}:${enumQualifiedName}`;
}
export function moveConstNodeId(constQualifiedName: string, filePath: string): string {
return `Const:${filePath}:${constQualifiedName}`;
}
export function moveEnumVariantNodeId(
enumQualifiedName: string,
variantName: string,
filePath: string,
): string {
return `EnumVariant:${filePath}:${enumQualifiedName}::${variantName}`;
}
export function moveFieldNodeId(
structQualifiedName: string,
fieldName: string,
filePath: string,
): string {
return `Property:${filePath}:${structQualifiedName}.${fieldName}`;
}
/**
* Parse `__lambda__<n>__<host>` into the host function's local name, or null
* when the input is not a lambda symbol. move-flow synthesises lambdas as
* Function symbols whose names follow this convention so they can be referenced
* by the call graph; the host is always in the same module as the lambda.
*/
export function parseMoveLambdaHostName(localName: string): string | null {
const match = /^__lambda__\d+__(.+)$/.exec(localName);
return match ? match[1] : null;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Tiny extractor for Move type expressions returned by move-flow facts.
*
* Input examples: `&CoinStore<Pool<T>>`, `vector<u8>`,
* `aptos_framework::coin::CoinStore<T>`.
*
* Output is the ordered list of type-name tokens that could resolve to a graph
* struct or enum. References and primitives are stripped; outer and inner
* generic type names are both returned.
*/
const MOVE_PRIMITIVES = new Set([
'bool',
'u8',
'u16',
'u32',
'u64',
'u128',
'u256',
'address',
'signer',
'&signer',
'vector',
]);
export function extractTypeNames(typeExpr: string): string[] {
if (!typeExpr) return [];
const expr = typeExpr.trim().replace(/^&(mut\s+)?/, '');
if (MOVE_PRIMITIVES.has(expr)) return [];
const tokens: string[] = [];
for (const raw of expr.split(/[<>,]/)) {
const name = raw.trim().replace(/^&(mut\s+)?/, '');
if (!name || MOVE_PRIMITIVES.has(name)) continue;
tokens.push(name);
}
return tokens;
}
+77 -35
View File
@@ -13,6 +13,10 @@ import path from 'path';
import fs from 'fs/promises';
import { execFileSync } from 'child_process';
import { runPipelineFromRepo } from './ingestion/pipeline.js';
import { isMoveCompilerInputPath, moveAvailabilityRequiresFullRebuild } from './move/constants.js';
import { createMoveIngestPhase } from './move/move-ingest.js';
import { tryCreateMoveFlowClient } from './move/mcp-client.js';
import { repoHasMove } from './move/discovery.js';
import { resetDegradedParseCounter } from './tree-sitter/safe-parse.js';
import {
initLbug,
@@ -937,6 +941,23 @@ export async function runFullAnalysis(
options = { ...options, force: true };
}
// Probe before the same-commit fast path: an index built while move-flow was
// unavailable must be backfilled once the compiler becomes available.
const hasMovePackages = await repoHasMove(repoPath);
const moveFlowClient = hasMovePackages ? tryCreateMoveFlowClient() : null;
const moveIngestAvailable = hasMovePackages ? moveFlowClient !== null : undefined;
if (
existingMeta &&
moveAvailabilityRequiresFullRebuild(
hasMovePackages,
moveFlowClient !== null,
existingMeta.moveIngestAvailable,
)
) {
log('Move compiler became available; forcing a full rebuild to backfill Move symbols.');
options = { ...options, force: true };
}
// ── Early-return: already up to date ──────────────────────────────
if (
existingMeta &&
@@ -1037,6 +1058,7 @@ export async function runFullAnalysis(
}
}
await ensureGitNexusIgnored(repoPath);
await moveFlowClient?.shutdown();
return {
// `resolveRepoIdentityRoot` collapses worktree roots to the
// canonical repo basename (#1259) but leaves arbitrary subdirs
@@ -1150,39 +1172,47 @@ export async function runFullAnalysis(
const parseCache = await loadParseCache(storagePath);
// ── Phase 1: Full Pipeline (0–60%) ────────────────────────────────
const pipelineResult = await runPipelineFromRepo(
repoPath,
(p) => {
const phaseLabel = PHASE_LABELS[p.phase] || p.phase;
const scaled = Math.round(p.percent * 0.6);
const message = p.detail
? `${p.message || phaseLabel} (${p.detail})`
: p.message || phaseLabel;
progress(p.phase, scaled, message);
},
{
parseCache,
workerPoolSize: options.workerPoolSize,
// CFG/PDG opt-in (#2081 M1). PipelineOptions.pdg fans out to the worker
// build gate (workerData.pdg) and the scope-resolution emit gate.
pdg: options.pdg === true,
pdgMaxFunctionLines: options.pdgMaxFunctionLines,
pdgMaxEdgesPerFunction: options.pdgMaxEdgesPerFunction,
pdgMaxReachingDefEdgesPerFunction: options.pdgMaxReachingDefEdgesPerFunction,
pdgMaxCdgEdgesPerFunction: options.pdgMaxCdgEdgesPerFunction,
pdgMaxTaintFindingsPerFunction: options.pdgMaxTaintFindingsPerFunction,
pdgMaxTaintHops: options.pdgMaxTaintHops,
pdgMaxInterprocFindings: options.pdgMaxInterprocFindings,
pdgMaxInterprocHops: options.pdgMaxInterprocHops,
pdgMaxInterprocEdges: options.pdgMaxInterprocEdges,
// Streaming/chunked PDG emit (#2202) — gated to full-rebuild runs
// (force === true) so the incremental writeback never reads back an
// offloaded BasicBlock layer. Memory-only; byte-identical output.
streamPdgEmit: resolveStreamPdgEmit(options),
pdgEmitChunkSize: resolvePdgEmitChunkSize(options),
fetchWrappers: options.fetchWrappers,
},
);
// `finally` guarantees the spawned move-flow child is released even if the
// pipeline throws — important for long-running hosts (MCP daemon, eval-server).
let pipelineResult: Awaited<ReturnType<typeof runPipelineFromRepo>>;
try {
pipelineResult = await runPipelineFromRepo(
repoPath,
(p) => {
const phaseLabel = PHASE_LABELS[p.phase] || p.phase;
const scaled = Math.round(p.percent * 0.6);
const message = p.detail
? `${p.message || phaseLabel} (${p.detail})`
: p.message || phaseLabel;
progress(p.phase, scaled, message);
},
{
parseCache,
workerPoolSize: options.workerPoolSize,
standaloneIngestPhase: createMoveIngestPhase(moveFlowClient),
// CFG/PDG opt-in (#2081 M1). PipelineOptions.pdg fans out to the worker
// build gate (workerData.pdg) and the scope-resolution emit gate.
pdg: options.pdg === true,
pdgMaxFunctionLines: options.pdgMaxFunctionLines,
pdgMaxEdgesPerFunction: options.pdgMaxEdgesPerFunction,
pdgMaxReachingDefEdgesPerFunction: options.pdgMaxReachingDefEdgesPerFunction,
pdgMaxCdgEdgesPerFunction: options.pdgMaxCdgEdgesPerFunction,
pdgMaxTaintFindingsPerFunction: options.pdgMaxTaintFindingsPerFunction,
pdgMaxTaintHops: options.pdgMaxTaintHops,
pdgMaxInterprocFindings: options.pdgMaxInterprocFindings,
pdgMaxInterprocHops: options.pdgMaxInterprocHops,
pdgMaxInterprocEdges: options.pdgMaxInterprocEdges,
// Streaming/chunked PDG emit (#2202) — gated to full-rebuild runs
// (force === true) so the incremental writeback never reads back an
// offloaded BasicBlock layer. Memory-only; byte-identical output.
streamPdgEmit: resolveStreamPdgEmit(options),
pdgEmitChunkSize: resolvePdgEmitChunkSize(options),
fetchWrappers: options.fetchWrappers,
},
);
} finally {
await moveFlowClient?.shutdown();
}
// ── Phase 2: LadybugDB (60–85%) ──────────────────────────────────
progress('lbug', 60, 'Loading into LadybugDB...');
@@ -1205,7 +1235,7 @@ export async function runFullAnalysis(
// (Bugbot review on PR #1479: a prediction that flipped post-pipeline
// could skip the embedding cache load and then take the full-rebuild
// path, silently losing embeddings).
const isIncremental =
const incrementalCandidate =
!options.force &&
!!existingMeta &&
existingMeta.schemaVersion === INCREMENTAL_SCHEMA_VERSION &&
@@ -1214,9 +1244,20 @@ export async function runFullAnalysis(
repoHasGit &&
allFilePaths.length > 0;
const hashDiff = isIncremental
const candidateHashDiff = incrementalCandidate
? diffFileHashes(newFileHashes, existingMeta!.fileHashes)
: undefined;
const moveCompilerInputsChanged =
candidateHashDiff !== undefined &&
[...candidateHashDiff.changed, ...candidateHashDiff.added, ...candidateHashDiff.deleted].some(
isMoveCompilerInputPath,
);
const isIncremental = incrementalCandidate && !moveCompilerInputsChanged;
const hashDiff = isIncremental ? candidateHashDiff : undefined;
if (incrementalCandidate && moveCompilerInputsChanged) {
log('Move compiler inputs changed; using a full rebuild for package-wide consistency.');
}
if (isIncremental && hashDiff) {
log(
@@ -2067,6 +2108,7 @@ export async function runFullAnalysis(
// absence, so this is never conditionally omitted.
cjkSegmentation: getSearchFTSCjkSegmentation(),
fileHashes: hasGitDir(repoPath) ? newFileHashesRecord : undefined,
moveIngestAvailable,
// This branch's full live chunk-key set (#2106 R6). `usedKeys` is every
// chunk hash touched in this scan — cache HITS included (see parse-impl
// usedKeys.add) — so it's complete even on an incremental run. Persisted
+2
View File
@@ -44,4 +44,6 @@ export const FTS_INDEXES: readonly FTSIndexDefinition[] = [
{ table: 'Union', indexName: 'union_fts', properties: FTS_PROPERTIES },
{ table: 'Static', indexName: 'static_fts', properties: FTS_PROPERTIES },
{ table: 'Variable', indexName: 'variable_fts', properties: FTS_PROPERTIES },
// Move/Aptos module nodes also carry description/content columns.
{ table: 'Module', indexName: 'module_fts', properties: FTS_PROPERTIES },
];
+172
View File
@@ -290,6 +290,10 @@ export const VALID_RELATION_TYPES = new Set([
// (WRAPS/FETCHES precedent): the 0.5 unknown-type floor applies there,
// and the edges carry their own confidence (0.8) in the graph.
'INJECTS',
'ACQUIRES',
'READS_RESOURCE',
'WRITES_RESOURCE',
'USES_TYPE',
]);
/**
@@ -711,6 +715,39 @@ export function parseListReposPagination(
return { limit, offset };
}
interface MoveEntryRow {
name: unknown;
qualifiedName: unknown;
filePath: unknown;
isEntry: unknown;
isView: unknown;
isInline: unknown;
isNative: unknown;
visibility: unknown;
acquires: unknown;
attributes: unknown;
returnType: unknown;
}
interface MoveResourceAccessor {
caller?: unknown;
reason?: unknown;
isEntry?: unknown;
isView?: unknown;
}
interface MoveResourceAccessorRow extends MoveResourceAccessor {
resourceQualifiedName: unknown;
}
interface MoveResourceRow {
name: unknown;
qualifiedName: unknown;
filePath: unknown;
abilities: unknown;
fieldList: unknown;
}
export class LocalBackend {
private repos: Map<string, RepoHandle> = new Map();
private contextCache: Map<string, CodebaseContext> = new Map();
@@ -1783,6 +1820,18 @@ export class LocalBackend {
return this.toolMap(repo, p);
case 'api_impact':
return this.apiImpact(repo, p);
// Move/Aptos tools (graph-property/Cypher only — no move-flow shell-out).
case 'move_entries':
return this.moveEntries(repo, p);
case 'move_resources':
return this.moveResources(repo, p);
case 'move_impact':
return this.moveImpact(repo, {
target: typeof p.target === 'string' ? p.target : '',
direction:
p.direction === 'upstream' || p.direction === 'downstream' ? p.direction : undefined,
maxDepth: typeof p.maxDepth === 'number' ? p.maxDepth : undefined,
});
case 'trace':
return this.trace(repo, p);
default:
@@ -4531,6 +4580,129 @@ export class LocalBackend {
};
}
private async moveEntries(
repo: RepoHandle,
params: {
module?: string;
kind?: 'entry' | 'view' | 'inline' | 'native';
attribute?: string;
},
): Promise<{ entries: MoveEntryRow[]; count: number } | { error: string }> {
await this.ensureInitialized(repo);
if (!isLbugReady(repo.lbugPath))
return { error: 'LadybugDB not ready. Index may be corrupted.' };
const clauses: string[] = ["f.language = 'move'"];
switch (params?.kind) {
case 'entry':
clauses.push('f.isEntry = true');
break;
case 'view':
clauses.push('f.isView = true');
break;
case 'inline':
clauses.push('f.isInline = true');
break;
case 'native':
clauses.push('f.isNative = true');
break;
default:
clauses.push('(f.isEntry = true OR f.isView = true)');
}
const qp: Record<string, unknown> = {};
if (typeof params?.module === 'string' && params.module) {
clauses.push('f.moduleQualifiedName = $module');
qp.module = params.module;
}
const rows = (await executeParameterized(
repo.lbugPath,
`MATCH (f:Function) WHERE ${clauses.join(' AND ')}
RETURN f.name AS name, f.qualifiedName AS qualifiedName, f.filePath AS filePath,
f.isEntry AS isEntry, f.isView AS isView,
f.isInline AS isInline, f.isNative AS isNative, f.visibility AS visibility,
f.acquires AS acquires, f.attributes AS attributes, f.returnType AS returnType
ORDER BY f.filePath, f.name`,
qp,
)) as MoveEntryRow[];
const attribute = params?.attribute;
const filtered =
typeof attribute === 'string' && attribute
? rows.filter((r) => Array.isArray(r.attributes) && r.attributes.includes(attribute))
: rows;
return { entries: filtered, count: filtered.length };
}
private async moveResources(
repo: RepoHandle,
params: { module?: string },
): Promise<
| { resources: Array<MoveResourceRow & { accessors: MoveResourceAccessor[] }>; count: number }
| { error: string }
> {
await this.ensureInitialized(repo);
if (!isLbugReady(repo.lbugPath))
return { error: 'LadybugDB not ready. Index may be corrupted.' };
const qp: Record<string, unknown> = {};
let moduleFilter = '';
if (typeof params?.module === 'string' && params.module) {
moduleFilter = ' AND s.moduleQualifiedName = $module';
qp.module = params.module;
}
const resourceQuery = (label: 'Struct' | 'Enum') => `
MATCH (s:\`${label}\`) WHERE s.language = 'move' AND s.isResource = true${moduleFilter}
RETURN s.name AS name, s.qualifiedName AS qualifiedName, s.filePath AS filePath,
s.abilities AS abilities, s.fieldList AS fieldList
ORDER BY s.qualifiedName`;
const accessorQuery = (label: 'Struct' | 'Enum') => `
MATCH (f:Function)-[r:CodeRelation]->(s:\`${label}\`)
WHERE s.language = 'move' AND s.isResource = true${moduleFilter}
AND (r.type = 'READS_RESOURCE' OR r.type = 'WRITES_RESOURCE' OR r.type = 'ACQUIRES')
RETURN s.qualifiedName AS resourceQualifiedName, f.qualifiedName AS caller,
r.type AS reason, f.isEntry AS isEntry, f.isView AS isView`;
const [structs, enums, structAccessors, enumAccessors] = await Promise.all([
executeParameterized(repo.lbugPath, resourceQuery('Struct'), qp),
executeParameterized(repo.lbugPath, resourceQuery('Enum'), qp),
executeParameterized(repo.lbugPath, accessorQuery('Struct'), qp),
executeParameterized(repo.lbugPath, accessorQuery('Enum'), qp),
]);
const rows = [...structs, ...enums].sort((a: MoveResourceRow, b: MoveResourceRow) =>
String(a.qualifiedName).localeCompare(String(b.qualifiedName)),
) as MoveResourceRow[];
const accessorsByResource = new Map<string, MoveResourceAccessor[]>();
for (const row of [...structAccessors, ...enumAccessors] as MoveResourceAccessorRow[]) {
const resourceQualifiedName = String(row.resourceQualifiedName ?? '');
if (!resourceQualifiedName || !row.caller) continue;
const accessors = accessorsByResource.get(resourceQualifiedName) ?? [];
accessors.push({
caller: row.caller,
reason: row.reason,
isEntry: row.isEntry,
isView: row.isView,
});
accessorsByResource.set(resourceQualifiedName, accessors);
}
const resources = rows.map((r) => ({
...r,
fieldList: Array.isArray(r.fieldList)
? r.fieldList.map((v: unknown) => String(v).replace(/^['"]|['"]$/g, ''))
: r.fieldList,
accessors: accessorsByResource.get(String(r.qualifiedName)) ?? [],
}));
return { resources, count: resources.length };
}
private async moveImpact(
repo: RepoHandle,
params: { target: string; direction?: 'upstream' | 'downstream'; maxDepth?: number },
): Promise<unknown> {
const impactParams: ImpactParams = {
target: params.target,
direction: params.direction ?? 'upstream',
maxDepth: params.maxDepth,
relationTypes: ['CALLS', 'READS_RESOURCE', 'WRITES_RESOURCE', 'ACQUIRES', 'USES_TYPE'],
};
return this.impact(repo, impactParams);
}
private async trace(repo: RepoHandle, params: TraceParams): Promise<any> {
try {
return await this._traceImpl(repo, params);
+74
View File
@@ -897,6 +897,77 @@ DESTINATION TRACE (cross-repo): for an "@groupName" trace, OMIT to/to_uid/to_fil
required: [],
},
},
{
name: 'move_entries',
description: `List Move/Aptos entry points (compiler-sourced).
WHEN TO USE: To enumerate a Move package's external surface - \`entry\` functions (transaction entry points) and \`#[view]\` functions (read-only queries). Filter by module, kind, or attribute.
Returns: { entries: [{ name, qualifiedName, filePath, isEntry, isView, visibility, acquires, attributes, returnType }], count }.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
module: {
type: 'string',
description: 'Filter to one module (qualified name, e.g. 0xa::coin).',
},
kind: {
type: 'string',
enum: ['entry', 'view', 'inline', 'native'],
description: 'Restrict to a single entry kind. Omit for entry+view.',
},
attribute: {
type: 'string',
description: 'Only functions carrying this attribute (e.g. "view").',
},
repo: { type: 'string', description: 'Repository name or path.' },
},
required: [],
},
},
{
name: 'move_resources',
description: `List Move/Aptos resources (structs/enums with the \`key\` ability) and their accessors.
WHEN TO USE: To audit on-chain storage — which resources exist and which functions read/write/acquire them (from READS_RESOURCE / WRITES_RESOURCE / ACQUIRES edges).
Returns: { resources: [{ name, qualifiedName, filePath, abilities, fieldList, accessors: [{ caller, reason, isEntry, isView }] }], count }.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
module: { type: 'string', description: 'Filter to one module (qualified name).' },
repo: { type: 'string', description: 'Repository name or path.' },
},
required: [],
},
},
{
name: 'move_impact',
description: `Blast radius for a Move/Aptos symbol over Move edges (CALLS / READS_RESOURCE / WRITES_RESOURCE / ACQUIRES / USES_TYPE).
WHEN TO USE: BEFORE editing a Move function or resource/type. Reports callers, resource-access dependents, and signature type users. Restricts the generic impact traversal to Move semantic edges.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
target: {
type: 'string',
description: 'Move symbol (qualified name preferred, e.g. 0xa::coin::transfer).',
},
direction: {
type: 'string',
enum: ['upstream', 'downstream'],
description:
'upstream = who depends on the target (default); downstream = what the target depends on.',
},
maxDepth: { type: 'number', description: 'Traversal depth (default 3).' },
repo: { type: 'string', description: 'Repository name or path.' },
},
required: ['target'],
},
},
];
/**
@@ -921,6 +992,9 @@ const BRANCH_SCOPED_TOOLS = new Set([
'shape_check',
'api_impact',
'trace',
'move_entries',
'move_resources',
'move_impact',
]);
for (const tool of GITNEXUS_TOOLS) {
+10 -1
View File
@@ -161,6 +161,11 @@ export interface RepoMeta {
* Map keys are repo-relative paths.
*/
fileHashes?: Record<string, string>;
/**
* Whether compiler-backed Move ingestion was available for this index.
* Absent on legacy metadata and non-Move repositories.
*/
moveIngestAvailable?: boolean;
/**
* Crash-recovery dirty flag — a generic marker written to the metadata
* file (gitnexus.json + its meta.json mirror) BEFORE any destructive DB
@@ -366,8 +371,12 @@ export interface RepoMeta {
* unchanged files — a top-up against a pre-v8 index would strand the old
* `Worker.run`-keyed Method nodes alongside the new ones (the v5 Route
* precedent); force a full re-analyze instead.
* v9: `attributesJson` column added to the Move node tables (Function, Struct,
* Enum, EnumVariant, Module) — full attribute payloads (nested args/values). A
* pre-v9 index lacks the column, so the bulk COPY referencing it would fail on
* an incremental top-up; force a full re-analyze (same contract as v3).
*/
export const INCREMENTAL_SCHEMA_VERSION = 8;
export const INCREMENTAL_SCHEMA_VERSION = 9;
export interface IndexedRepo {
repoPath: string;
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "pkg_a"
version = "1.0.0"
[addresses]
app = "0xA"
oracle = "0xBEEF"
std = "0x1"
[dependencies]
pkg_b = { local = "../pkg_b" }
@@ -0,0 +1,15 @@
// module 0xBAD::commented {}
module app::vault {
use oracle::price::get;
#[view]
public fun price(): u64 {
get()
}
}
module app::shared {
public fun ping(): u64 {
1
}
}
@@ -0,0 +1,4 @@
module app::ledger {
public entry fun publish(account: &signer) {
}
}
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "pkg_b"
version = "1.0.0"
[addresses]
oracle = "0xBEEF"
app = "0xA"
@@ -0,0 +1,6 @@
module oracle::price {
#[view]
public fun get(): u64 {
42
}
}
@@ -0,0 +1,7 @@
[package]
name = "coin"
version = "0.0.1"
[addresses]
aptos_framework = "0xA"
std = "0x1"
@@ -0,0 +1,55 @@
module aptos_framework::coin {
friend aptos_framework::coin_admin;
/// A generic coin balance held under an account.
struct CoinStore<phantom CoinType> has key {
balance: u64
}
#[event]
struct TransferEvent has drop, store {
from: address,
to: address,
amount: u64
}
const E_NOT_REGISTERED: u64 = 1;
const E_INSUFFICIENT_BALANCE: u64 = 2;
public entry fun register<CoinType>(account: &signer) {
move_to(account, CoinStore<CoinType> { balance: 0 });
}
public entry fun transfer<CoinType>(
from: address, to: address, amount: u64
) acquires CoinStore {
let from_store = borrow_global_mut<CoinStore<CoinType>>(from);
assert!(from_store.balance >= amount, E_INSUFFICIENT_BALANCE);
from_store.balance = from_store.balance - amount;
let to_store = borrow_global_mut<CoinStore<CoinType>>(to);
to_store.balance = to_store.balance + amount;
}
#[view]
public fun balance_of<CoinType>(addr: address): u64 acquires CoinStore {
let store = borrow_global<CoinStore<CoinType>>(addr);
store.balance
}
public(friend) fun mint_internal<CoinType>(to: address, amount: u64) acquires CoinStore {
let store = borrow_global_mut<CoinStore<CoinType>>(to);
store.balance = store.balance + amount;
}
public(friend) fun burn_internal<CoinType>(
from: address
): CoinStore<CoinType> acquires CoinStore {
move_from<CoinStore<CoinType>>(from)
}
}
module aptos_framework::coin_admin {
public fun mint<CoinType>(to: address, amount: u64) {
aptos_framework::coin::mint_internal<CoinType>(to, amount);
}
}
@@ -0,0 +1,7 @@
[package]
name = "object"
version = "0.0.1"
[addresses]
aptos_framework = "0xA"
std = "0x1"
@@ -0,0 +1,41 @@
module aptos_framework::object {
friend aptos_framework::token;
#[resource_group(scope = global)]
struct ObjectGroup {}
#[resource_group_member(group = aptos_framework::object::ObjectGroup)]
struct ObjectCore has key {
owner: address
}
public fun create(owner_signer: &signer, owner: address) {
move_to(owner_signer, ObjectCore { owner });
}
#[view]
public fun owner_of(addr: address): address acquires ObjectCore {
let core = borrow_global<ObjectCore>(addr);
core.owner
}
public(friend) fun transfer_ownership(
addr: address, new_owner: address
) acquires ObjectCore {
let core = borrow_global_mut<ObjectCore>(addr);
core.owner = new_owner;
}
}
module aptos_framework::token {
friend aptos_framework::object;
#[resource_group_member(group = aptos_framework::object::ObjectGroup)]
struct Token has key, store {
supply: u64
}
public fun mint(account: &signer, supply: u64) {
move_to(account, Token { supply });
}
}
@@ -0,0 +1,6 @@
[package]
name = "math64"
version = "0.0.1"
[addresses]
aptos_std = "0xA"
@@ -0,0 +1,9 @@
module aptos_std::consumer {
use aptos_std::math64;
public fun compound(a: u64, b: u64, c: u64): u64 {
let top = math64::max(a, b);
let bot = math64::min(a, b);
math64::clamp(c, bot, top)
}
}
@@ -0,0 +1,19 @@
module aptos_std::math64 {
/// Inline helper — produces NO CALLS edges in the GitNexus graph.
/// Used by `consumer::compound` below; the inline-fun gotcha skill
/// applies here.
public inline fun max(a: u64, b: u64): u64 {
if (a > b) a else b
}
public inline fun min(a: u64, b: u64): u64 {
if (a < b) a else b
}
/// Non-inline neighbour so a baseline CALLS edge exists for comparison.
public fun clamp(x: u64, lo: u64, hi: u64): u64 {
if (x < lo) { lo }
else if (x > hi) { hi }
else { x }
}
}
+6
View File
@@ -0,0 +1,6 @@
[package]
name = "enums_demo"
version = "0.0.1"
[addresses]
enums_demo = "0xA"
+35
View File
@@ -0,0 +1,35 @@
module enums_demo::shapes {
/// All four Move 2 enum variant shapes.
enum Shape has copy, drop {
/// Unit variant.
Empty,
/// Positional variant.
Circle(u64),
/// Named-field variant.
Rectangle {
width: u64,
height: u64
},
/// Multi-positional variant.
Triangle(u64, u64, u64)
}
/// Generic enum with a phantom type parameter on a variant.
enum Result<T, phantom E> has drop {
Ok(T),
Err
}
public fun area(s: &Shape): u64 {
match(s) {
Shape::Empty => 0,
Shape::Circle(r) => 3 * (*r) * (*r),
Shape::Rectangle { width, height } => (*width) * (*height),
Shape::Triangle(a, b, c) => ((*a) + (*b) + (*c)) / 2
}
}
public fun ok_value<T: copy, E>(r: &Result<T, E>): T {
match(r) { Result::Ok(v) => *v, Result::Err => abort 0 }
}
}
@@ -0,0 +1,7 @@
[package]
name = "pkg_x"
version = "0.0.1"
[addresses]
pkg_x = "0xA"
pkg_y = "0xB"
@@ -0,0 +1,11 @@
module pkg_x::m_x {
friend pkg_y::m_y;
public(friend) fun secret(): u64 {
7
}
public fun call_into_y(): u64 {
pkg_y::m_y::peek()
}
}
@@ -0,0 +1,7 @@
[package]
name = "pkg_y"
version = "0.0.1"
[addresses]
pkg_x = "0xA"
pkg_y = "0xB"
@@ -0,0 +1,7 @@
module pkg_y::m_y {
friend pkg_x::m_x;
public(friend) fun peek(): u64 {
pkg_x::m_x::secret()
}
}
+6
View File
@@ -0,0 +1,6 @@
[package]
name = "scripts_demo"
version = "0.0.1"
[addresses]
scripts_demo = "0xA"
@@ -0,0 +1,7 @@
script {
use scripts_demo::treasury;
fun airdrop(admin: &signer, recipient: address, amount: u64) {
treasury::pay(admin, recipient, amount);
}
}
@@ -0,0 +1,7 @@
script {
use scripts_demo::treasury;
fun sweep(admin: &signer) {
treasury::sweep_all(admin);
}
}
@@ -0,0 +1,9 @@
module scripts_demo::treasury {
public fun pay(_admin: &signer, _recipient: address, _amount: u64) {
// no-op for fixture purposes
}
public fun sweep_all(_admin: &signer) {
// no-op for fixture purposes
}
}
+6
View File
@@ -0,0 +1,6 @@
[package]
name = "spec_demo"
version = "0.0.1"
[addresses]
spec_demo = "0xA"
@@ -0,0 +1,37 @@
module spec_demo::vault {
struct Vault has key {
balance: u64
}
public fun deposit(account: &signer, addr: address, amount: u64) acquires Vault {
if (!exists<Vault>(addr)) {
move_to(account, Vault { balance: 0 });
};
let v = borrow_global_mut<Vault>(addr);
v.balance = v.balance + amount;
}
public fun withdraw(addr: address, amount: u64): u64 acquires Vault {
let v = borrow_global_mut<Vault>(addr);
v.balance = v.balance - amount;
amount
}
spec module {
pragma verify = true;
}
spec deposit {
ensures global<Vault>(addr).balance
== old(global<Vault>(addr).balance) + amount;
}
spec withdraw {
aborts_if global<Vault>(addr).balance < amount;
}
spec schema BalancePositive {
addr: address;
ensures global<Vault>(addr).balance >= 0;
}
}
@@ -0,0 +1,41 @@
/**
* Harness for driving the moveIngest phase directly (no full pipeline): builds
* the synthetic structure-phase deps map + PipelineContext the phase expects.
* Shared by the unit (move-ingest-*) and integration (move-live) Move tests.
*/
import { createMoveIngestPhase, type MoveIngestOutput } from '../../src/core/move/move-ingest.js';
import type { MoveFlowClient } from '../../src/core/move/mcp-client.js';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import type { PhaseResult } from '../../src/core/ingestion/pipeline-phases/types.js';
/** Run the moveIngest phase over `filePaths` (repo-relative) under `repoPath`. */
export async function runMoveIngestPhase(
client: MoveFlowClient | null,
repoPath: string,
filePaths: readonly string[],
): Promise<MoveIngestOutput> {
const deps = new Map<string, PhaseResult<unknown>>([
[
'structure',
{
phaseName: 'structure',
output: {
scannedFiles: filePaths.map((path) => ({ path, size: 0 })),
allPaths: [],
allPathSet: new Set<string>(),
totalFiles: filePaths.length,
},
durationMs: 0,
},
],
]);
return createMoveIngestPhase(client).execute(
{
repoPath,
graph: createKnowledgeGraph(),
onProgress: () => {},
pipelineStart: Date.now(),
},
deps,
);
}
+146
View File
@@ -0,0 +1,146 @@
/**
* Live end-to-end Move ingestion against the real move-flow binary.
*
* Gated on move-flow being installed (skipped in CI without the binary). Proves
* the full compiler-first chain: capability probe → facts query → thin
* facts→graph mapper → pipeline graph, with full fidelity (resource/friend
* edges, resource structs, precise locations).
*/
import { describe, it, expect, afterAll } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import { tryCreateMoveFlowClient } from '../../src/core/move/mcp-client.js';
import { createMoveIngestPhase } from '../../src/core/move/move-ingest.js';
import { runMoveIngestPhase } from '../helpers/move-ingest-harness.js';
const client = tryCreateMoveFlowClient();
const coinFixture = path.resolve(process.cwd(), 'test/fixtures/move/aptos-framework/coin');
function writePackage(root: string, name: string, addr: string, source: string): void {
mkdirSync(path.join(root, 'sources'), { recursive: true });
writeFileSync(
path.join(root, 'Move.toml'),
`[package]\nname = "${name}"\nversion = "1.0.0"\n\n[addresses]\n${addr} = "0x42"\n`,
);
writeFileSync(path.join(root, 'sources', 'mod.move'), source);
}
/** Run just the moveIngest phase on a one-package repo, returning its issues. */
async function ingestIssues(repoRoot: string) {
const output = await runMoveIngestPhase(client, repoRoot, ['Move.toml', 'sources/mod.move']);
return output.consistencyIssues;
}
describe.skipIf(!client)('live move-flow ingestion (coin fixture)', () => {
afterAll(async () => {
await client?.shutdown();
});
it('detects the facts query capability', async () => {
const caps = await client!.capabilities();
expect(caps.hasFactsQuery).toBe(true);
});
it('builds a full-fidelity Move graph from compiler facts', async () => {
const result = await runPipelineFromRepo(coinFixture, () => {}, {
standaloneIngestPhase: createMoveIngestPhase(client),
skipGraphPhases: true,
});
const nodes = [...result.graph.iterNodes()];
const edges = [...result.graph.iterRelationships()];
const moveFns = nodes.filter((n) => n.label === 'Function' && n.properties.language === 'move');
expect(moveFns.some((n) => n.properties.name === 'register' && n.properties.isEntry)).toBe(
true,
);
expect(moveFns.some((n) => n.properties.name === 'balance_of' && n.properties.isView)).toBe(
true,
);
const coinStore = nodes.find((n) => n.label === 'Struct' && n.properties.name === 'CoinStore');
expect(coinStore?.properties.isResource).toBe(true);
expect(coinStore?.properties.locationFidelity).toBe('precise');
expect(edges.some((e) => e.type === 'WRITES_RESOURCE')).toBe(true);
expect(edges.some((e) => e.type === 'FRIEND_OF')).toBe(true);
expect(edges.some((e) => e.type === 'DEFINES')).toBe(true);
// returnTypes -> scalar returnType projection.
const balanceOf = moveFns.find((n) => n.properties.name === 'balance_of');
expect(balanceOf?.properties.returnType).toBe('u64');
const register = moveFns.find((n) => n.properties.name === 'register');
expect(register?.properties.returnType).toBeUndefined();
// burn_internal returns CoinStore<CoinType> -> a move-fn-return-type edge.
const burn = moveFns.find((n) => n.properties.name === 'burn_internal');
expect(burn?.properties.returnType).toContain('CoinStore');
expect(
edges.some(
(e) =>
e.type === 'USES_TYPE' &&
e.reason === 'move-fn-return-type' &&
e.sourceId === burn?.id &&
e.targetId === coinStore?.id,
),
).toBe(true);
expect(burn?.properties.usedTypes).toContain(coinStore?.properties.qualifiedName);
}, 60000);
it('serves the post-2.0.0 facts wire shape (returnTypes arrays, lambda-lift fields, no legacy returnType)', async () => {
const factsMap = await client!.facts(coinFixture);
const fns = Object.values(factsMap).flatMap((mod) => mod.functions ?? []);
expect(fns.length).toBeGreaterThan(0);
for (const fn of fns) {
expect(Array.isArray(fn.returnTypes)).toBe(true);
expect(typeof fn.isLambdaLifted).toBe('boolean');
expect('returnType' in fn).toBe(false);
}
const burn = fns.find((fn) => fn.name === 'burn_internal');
expect(burn?.returnTypes).toHaveLength(1);
expect(burn?.returnTypes[0]).toContain('CoinStore');
}, 60000);
it('reports build status for a compiling package', async () => {
const caps = await client!.capabilities();
expect(caps.hasStatusTool).toBe(true);
const status = await client!.packageStatus(coinFixture);
expect(status.ok).toBe(true);
}, 60000);
it('discriminates test-only from broken empty-facts packages via move_package_status', async () => {
// Both packages return facts `{}` with isError:false - byte-identical on
// the wire. Only the build-status cross-check tells them apart.
const tmp = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-move-status-'));
try {
const testonly = path.join(tmp, 'testonly');
writePackage(
testonly,
'live_testonly',
't',
'#[test_only]\nmodule t::helpers { public fun one(): u64 { 1 } }\n',
);
const testonlyIssues = await ingestIssues(testonly);
const testonlyEmpties = testonlyIssues.filter((i) => i.code === 'empty-package-facts');
expect(testonlyEmpties).toHaveLength(1);
expect(testonlyEmpties[0].severity).toBe('warning');
expect(testonlyIssues.some((i) => i.severity === 'error')).toBe(false);
const broken = path.join(tmp, 'broken');
writePackage(
broken,
'live_broken',
'b',
'module b::broken {\n public fun oops(): u64 { 1 +\n}\n',
);
const brokenIssues = await ingestIssues(broken);
const brokenEmpties = brokenIssues.filter((i) => i.code === 'empty-package-facts');
expect(brokenEmpties).toHaveLength(1);
expect(brokenEmpties[0].severity).toBe('error');
expect(brokenEmpties[0].message).toContain('does not compile');
expect(String(brokenEmpties[0].details?.diagnostics)).toContain('error');
} finally {
rmSync(tmp, { recursive: true, force: true });
}
}, 300000);
});
@@ -33,6 +33,7 @@ const CALLABLE_FLOW_PROVIDER_COVERAGE = {
[SupportedLanguages.Dart]: 'matrix',
[SupportedLanguages.Vue]: 'matrix',
[SupportedLanguages.Cobol]: 'matrix',
[SupportedLanguages.Move]: 'dedicated',
} as const satisfies Record<SupportedLanguages, 'matrix' | 'dedicated'>;
const PROVIDER_FLOW_CASES = [
@@ -73,8 +73,8 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => {
});
describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
it('INCREMENTAL_SCHEMA_VERSION is bumped to 8 (Java anonymous-class node-identity re-index window)', () => {
expect(INCREMENTAL_SCHEMA_VERSION).toBe(8);
it('INCREMENTAL_SCHEMA_VERSION is bumped to 9 (Move attributesJson column re-index window)', () => {
expect(INCREMENTAL_SCHEMA_VERSION).toBe(9);
});
it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => {
@@ -103,7 +103,10 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
// (#2550) — `Worker.run`-keyed Method nodes would be stranded alongside
// the re-keyed `Worker$N.run` ones on unchanged files → must NOT reuse.
expect(passesReuseGate(7)).toBe(false);
// A pre-v9 (v8) index lacks the Move `attributesJson` node-table column, so
// the bulk COPY referencing it would fail on a top-up → must NOT reuse.
expect(passesReuseGate(8)).toBe(false);
// A current-version stamp passes the gate (incremental top-up eligible).
expect(passesReuseGate(8)).toBe(true);
expect(passesReuseGate(9)).toBe(true);
});
});
+14 -1
View File
@@ -107,7 +107,20 @@ describe('CLI commands', () => {
// (vendored-grammars.ts), so postinstall no longer materializes anything.
expect(pkg.default.scripts.postinstall).not.toContain('materialize-vendor-grammars.cjs');
expect(pkg.default.scripts.postinstall).toContain('build-tree-sitter-grammars.cjs');
expect(pkg.default.files).toContain('vendor');
expect(pkg.default.files).toEqual(
expect.arrayContaining([
'vendor/leiden',
'vendor/tree-sitter-c',
'vendor/tree-sitter-dart',
'vendor/tree-sitter-kotlin',
'vendor/tree-sitter-proto',
'vendor/tree-sitter-swift',
]),
);
// move-flow is downloaded per-platform and must never leak from a local
// install/cache into the cross-platform npm tarball.
expect(pkg.default.files).not.toContain('vendor');
expect(pkg.default.files).not.toContain('vendor/move-flow');
});
it('declares node-gyp-build/node-addon-api as regular dependencies (runtime-load contract)', async () => {
@@ -1,4 +1,6 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { PhaseRegistry } from '../../../src/core/ingestion/pipeline-phases/registry.js';
import type { PipelinePhase } from '../../../src/core/ingestion/pipeline-phases/types.js';
import { buildPhaseList } from '../../../src/core/ingestion/pipeline.js';
@@ -66,6 +68,7 @@ describe('PhaseRegistry', () => {
const FULL_ORDER = [
'scan',
'structure',
'standaloneIngest',
'markdown',
'cobol',
'parse',
@@ -102,6 +105,23 @@ describe('buildPhaseList parity (registry refactor, #2080)', () => {
});
});
describe('shared ingestion architecture', () => {
it('keeps language-specific standalone ingestion outside the shared pipeline', () => {
const sources = [
'../../../src/core/ingestion/pipeline.ts',
'../../../src/core/ingestion/pipeline-phases/parse.ts',
].map((relativePath) =>
readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8'),
);
for (const source of sources) {
expect(source).not.toMatch(
/\b(?:MoveFlowClient|MoveIngestOutput|createMoveIngestPhase|moveFlowClient|moveIngest)\b/,
);
}
});
});
// ---------------------------------------------------------------------------
// M4 (#2084): the taintSummaries phase is the first real opt-in pdg-gated
// registration. Off (the default) ⇒ ABSENT ⇒ byte-identical phase list; on ⇒
+209
View File
@@ -0,0 +1,209 @@
import { describe, it, expect } from 'vitest';
import { validateMoveIngestOutput } from '../../../src/core/move/consistency.js';
import type { MoveIngestOutput } from '../../../src/core/move/move-ingest.js';
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
function addFileNode(graph: ReturnType<typeof createKnowledgeGraph>, filePath: string): void {
graph.addNode({
id: `File:${filePath}`,
label: 'File',
properties: { name: filePath.split('/').pop() ?? filePath, filePath },
});
}
function makeOutput(overrides: Partial<MoveIngestOutput> = {}): MoveIngestOutput {
return {
ingestedFiles: new Set<string>(),
packageRoots: [],
moduleFileMap: new Map(),
functionNodeMap: new Map(),
structNodeMap: new Map(),
modulePackageMap: new Map(),
filePackageMap: new Map(),
callGraphByPackage: new Map(),
consistencyIssues: [],
...overrides,
};
}
describe('validateMoveIngestOutput', () => {
it('returns [] for clean input', () => {
const graph = createKnowledgeGraph();
const file = 'sources/coin.move';
addFileNode(graph, file);
const output = makeOutput({
ingestedFiles: new Set([file]),
moduleFileMap: new Map([['0xa::coin', file]]),
functionNodeMap: new Map([['0xa::coin::register', `Function:${file}:0xa::coin::register`]]),
structNodeMap: new Map([['0xa::coin::CoinStore', `Struct:${file}:0xa::coin::CoinStore`]]),
modulePackageMap: new Map([['0xa::coin', '/pkg']]),
callGraphByPackage: new Map([['/pkg', { '0xa::coin::register': [] }]]),
});
expect(validateMoveIngestOutput(graph, output)).toEqual([]);
});
it('warns malformed-source-evidence when a module file path does not end in .move', () => {
const graph = createKnowledgeGraph();
const output = makeOutput({
ingestedFiles: new Set(['sources/coin.txt']),
moduleFileMap: new Map([['0xa::coin', 'sources/coin.txt']]),
});
const issues = validateMoveIngestOutput(graph, output);
const issue = issues.find((i) => i.code === 'malformed-source-evidence');
expect(issue?.severity).toBe('warning');
expect(issue?.details?.filePath).toBe('sources/coin.txt');
});
it('warns malformed-source-evidence when a module file was not seen by ingestion', () => {
const graph = createKnowledgeGraph();
const output = makeOutput({
// ingestedFiles deliberately empty; no File node either → unknown source.
moduleFileMap: new Map([['0xa::coin', 'sources/coin.move']]),
});
const issues = validateMoveIngestOutput(graph, output);
expect(
issues.some((i) => i.code === 'malformed-source-evidence' && i.severity === 'warning'),
).toBe(true);
});
it('accepts a module whose source is unseen by ingestedFiles but has a File node in the graph', () => {
const graph = createKnowledgeGraph();
const file = 'sources/coin.move';
addFileNode(graph, file);
const output = makeOutput({
// ingestedFiles empty — the File node is what saves it.
moduleFileMap: new Map([['0xa::coin', file]]),
});
expect(validateMoveIngestOutput(graph, output)).toEqual([]);
});
it('warns missing-owned-caller when an owned caller has no function node', () => {
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']]),
// caller's module belongs to /pkg but the caller never got a Function node.
callGraphByPackage: new Map([['/pkg', { '0xa::coin::register': [] }]]),
});
const issues = validateMoveIngestOutput(graph, output);
const issue = issues.find((i) => i.code === 'missing-owned-caller');
expect(issue?.severity).toBe('warning');
expect(issue?.details?.callerQualified).toBe('0xa::coin::register');
});
it('does NOT warn missing-owned-caller when the caller is not owned by the current package', () => {
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']]),
// Caller module not in modulePackageMap -> foreign -> no warning.
callGraphByPackage: new Map([['/pkg', { '0xa::other::foreign': [] }]]),
});
const issues = validateMoveIngestOutput(graph, output);
expect(issues.some((i) => i.code === 'missing-owned-caller')).toBe(false);
});
it('warns missing-owned-callee when an owned callee module has no function node for the callee', () => {
const graph = createKnowledgeGraph();
const file = 'sources/coin.move';
addFileNode(graph, file);
const callerId = `Function:${file}:0xa::coin::register`;
const output = makeOutput({
ingestedFiles: new Set([file]),
moduleFileMap: new Map([
['0xa::coin', file],
['0xa::coin_admin', file],
]),
modulePackageMap: new Map([
['0xa::coin', '/pkg'],
['0xa::coin_admin', '/pkg'],
]),
functionNodeMap: new Map([['0xa::coin::register', callerId]]),
callGraphByPackage: new Map([
['/pkg', { '0xa::coin::register': ['0xa::coin_admin::missing'] }],
]),
});
const issues = validateMoveIngestOutput(graph, output);
const issue = issues.find((i) => i.code === 'missing-owned-callee');
expect(issue?.severity).toBe('warning');
expect(issue?.details?.calleeQualified).toBe('0xa::coin_admin::missing');
});
it('does NOT warn missing-owned-callee for callees in modules not owned by this repo', () => {
const graph = createKnowledgeGraph();
const file = 'sources/coin.move';
addFileNode(graph, file);
const callerId = `Function:${file}:0xa::coin::register`;
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', callerId]]),
callGraphByPackage: new Map([
// callee module 0x1::aptos_framework::stdlib is not in modulePackageMap → foreign → skip.
['/pkg', { '0xa::coin::register': ['0x1::aptos_framework::stdlib_call'] }],
]),
});
const issues = validateMoveIngestOutput(graph, output);
expect(issues.some((i) => i.code === 'missing-owned-callee')).toBe(false);
});
it('does not warn when inline functions have no callers (move-flow omits inline callees)', () => {
const graph = createKnowledgeGraph();
const file = 'sources/m.move';
addFileNode(graph, file);
graph.addNode({
id: 'Function:sources/m.move:0xa::m::helper',
label: 'Function',
properties: {
name: 'helper',
filePath: file,
language: 'move',
qualifiedName: '0xa::m::helper',
isInline: true,
},
});
graph.addNode({
id: 'Function:sources/m.move:0xa::m::caller',
label: 'Function',
properties: {
name: 'caller',
filePath: file,
language: 'move',
qualifiedName: '0xa::m::caller',
isInline: false,
},
});
const output = makeOutput({
ingestedFiles: new Set([file]),
moduleFileMap: new Map([['0xa::m', file]]),
functionNodeMap: new Map([
['0xa::m::helper', 'Function:sources/m.move:0xa::m::helper'],
['0xa::m::caller', 'Function:sources/m.move:0xa::m::caller'],
]),
});
const issues = validateMoveIngestOutput(graph, output);
expect(issues).toEqual([]);
});
it('warns when resource targets were silently dropped (ambiguous local name)', () => {
const graph = createKnowledgeGraph();
const file = 'sources/m.move';
addFileNode(graph, file);
const output = makeOutput({
ingestedFiles: new Set([file]),
moduleFileMap: new Map([['0xa::m', file]]),
droppedResourceRefs: [{ fnNodeId: 'Function:f', target: 'Config' }],
});
const issues = validateMoveIngestOutput(graph, output);
expect(
issues.some((i) => i.code === 'unresolved-resource-target' && i.severity === 'warning'),
).toBe(true);
});
});
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import path from 'node:path';
import { moveRepoRelativePath } from '../../../src/core/move/constants.js';
describe('moveRepoRelativePath', () => {
it('returns a slash-normalized repo-relative path on the host platform', () => {
const repoRoot = path.resolve('/repo');
const source = path.join(repoRoot, 'pkg', 'sources', 'module.move');
expect(moveRepoRelativePath(source, repoRoot)).toBe('pkg/sources/module.move');
});
it('does not claim a sibling path that merely shares the repo prefix', () => {
const repoRoot = path.resolve('/repo/pkg');
const sibling = path.resolve('/repo/pkg_other/sources/module.move');
expect(moveRepoRelativePath(sibling, repoRoot)).toBe(sibling);
});
});
@@ -0,0 +1,63 @@
import path from 'node:path';
import { describe, it, expect } from 'vitest';
import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js';
import type { MoveFactsMap } from '../../../src/core/move/compiler-facts.js';
import type { MoveFlowClient } from '../../../src/core/move/mcp-client.js';
import { createMoveIngestPhase } from '../../../src/core/move/move-ingest.js';
const friendGraphFixture = path.resolve(process.cwd(), 'test/fixtures/move/friend_graph');
const factsByPackage: Record<string, MoveFactsMap> = {
pkg_x: {
'0xa::m_x': {
file: path.join(friendGraphFixture, 'pkg_x/sources/x.move'),
span: [1, 11],
friends: [{ module: '0xb::m_y' }],
attributes: [],
functions: [],
structs: [],
constants: [],
},
},
pkg_y: {
'0xb::m_y': {
file: path.join(friendGraphFixture, 'pkg_y/sources/y.move'),
span: [1, 7],
friends: [{ module: '0xa::m_x' }],
attributes: [],
functions: [],
structs: [],
constants: [],
},
},
};
const client: MoveFlowClient = {
async facts(packagePath: string): Promise<MoveFactsMap> {
return packagePath.endsWith('pkg_x') ? factsByPackage.pkg_x : factsByPackage.pkg_y;
},
async callGraph() {
return {};
},
async packageStatus() {
return { ok: true, diagnostics: '' };
},
async capabilities() {
return { hasFactsQuery: true, hasStatusTool: false };
},
async shutdown() {},
};
describe('move ingest - cross-package edges', () => {
it('emits cross-package FRIEND_OF edges after the global pass', async () => {
const result = await runPipelineFromRepo(friendGraphFixture, () => {}, {
standaloneIngestPhase: createMoveIngestPhase(client),
skipGraphPhases: true,
});
const edges = [...result.graph.iterRelationships()];
const crossPkgFriend = edges.find(
(e) => e.type === 'FRIEND_OF' && e.sourceId.includes('m_x') && e.targetId.includes('m_y'),
);
expect(crossPkgFriend).toBeDefined();
});
});
@@ -0,0 +1,713 @@
import { describe, it, expect } from 'vitest';
import {
buildLocalNameIndex,
mapFactsToGraph,
resolveFriendEdges,
resolveResourceEdges,
resolveTypeRefEdges,
} from '../../../src/core/move/facts-mapper.js';
import {
type MoveFactsFunction,
type MoveFactsMap,
} from '../../../src/core/move/compiler-facts.js';
/** A minimal function facts entry with per-test overrides. */
function fnFixture(name: string, overrides: Partial<MoveFactsFunction> = {}): MoveFactsFunction {
return {
name,
file: '/pkg/sources/fixture.move',
span: [1, 3],
visibility: 'public',
isEntry: false,
isInline: false,
isNative: false,
isView: false,
attributes: [],
typeParams: [],
params: [],
returnTypes: [],
acquiresInferred: [],
resourceAccess: { reads: [], writes: [] },
isLambdaLifted: false,
...overrides,
};
}
// Trimmed-but-faithful slice of a real `move_package_query { query: "facts" }`
// response for the `coin` fixture (coin + coin_admin modules).
const facts: MoveFactsMap = {
'0xa::coin': {
file: '/pkg/sources/coin.move',
span: [1, 49],
friends: [{ module: '0xa::coin_admin' }],
attributes: [],
functions: [
{
name: 'register',
file: '/pkg/sources/coin.move',
span: [19, 21],
visibility: 'public',
isEntry: true,
isInline: false,
isNative: false,
isView: false,
attributes: [],
typeParams: [{ name: 'CoinType', abilities: [], isPhantom: false }],
params: [{ name: 'account', type: '&signer' }],
returnTypes: [],
acquiresInferred: [],
resourceAccess: { reads: [], writes: ['0xa::coin::CoinStore<CoinType>'] },
isLambdaLifted: false,
},
{
name: 'balance_of',
file: '/pkg/sources/coin.move',
span: [34, 37],
visibility: 'public',
isEntry: false,
isInline: false,
isNative: false,
isView: true,
attributes: [{ name: 'view' }],
typeParams: [{ name: 'CoinType', abilities: [], isPhantom: false }],
params: [{ name: 'addr', type: 'address' }],
returnTypes: ['u64'],
acquiresInferred: ['0xa::coin::CoinStore'],
resourceAccess: { reads: ['0xa::coin::CoinStore<CoinType>'], writes: [] },
isLambdaLifted: false,
},
],
structs: [
{
kind: 'struct',
name: 'CoinStore',
file: '/pkg/sources/coin.move',
span: [5, 7],
abilities: ['key'],
typeParams: [{ name: 'CoinType', abilities: [], isPhantom: true }],
fields: [{ name: 'balance', type: 'u64', positional: false }],
attributes: [],
},
{
kind: 'struct',
name: 'TransferEvent',
file: '/pkg/sources/coin.move',
span: [10, 14],
abilities: ['drop', 'store'],
typeParams: [],
fields: [{ name: 'amount', type: 'u64', positional: false }],
attributes: [{ name: 'event' }],
},
],
constants: [{ name: 'E_NOT_REGISTERED', type: 'u64', value: '1' }],
},
'0xa::coin_admin': {
file: '/pkg/sources/coin.move',
span: [51, 55],
friends: [],
attributes: [],
functions: [],
structs: [],
constants: [],
},
};
function mapFactsToGraphWithResolvedEdges(
factsMap: MoveFactsMap,
): ReturnType<typeof mapFactsToGraph> {
const mapped = mapFactsToGraph(factsMap, '/pkg');
const edges = [...mapped.edges];
const localNameIndex = buildLocalNameIndex(mapped.structNodeMap);
const addResolvedUsedType = (sourceId: string, targetId: string): void => {
const fnNode = mapped.nodes.find((n) => n.id === sourceId);
const typeNode = mapped.nodes.find((n) => n.id === targetId);
const qualifiedName = typeNode?.properties.qualifiedName;
if (!fnNode || typeof qualifiedName !== 'string') return;
const current = Array.isArray(fnNode.properties.usedTypes) ? fnNode.properties.usedTypes : [];
if (!current.includes(qualifiedName)) fnNode.properties.usedTypes = [...current, qualifiedName];
};
resolveResourceEdges(mapped.pendingResource, mapped.structNodeMap, localNameIndex, (rel) =>
edges.push(rel),
);
resolveFriendEdges(mapped.pendingFriends, mapped.moduleFileMap, (rel) => edges.push(rel));
resolveTypeRefEdges(mapped.pendingTypeRef, mapped.structNodeMap, localNameIndex, (rel) => {
edges.push(rel);
addResolvedUsedType(rel.sourceId, rel.targetId);
});
return { ...mapped, edges };
}
describe('mapFactsToGraph', () => {
it('emits a WRITES_RESOURCE edge from register to CoinStore', () => {
const { edges } = mapFactsToGraphWithResolvedEdges(facts);
expect(
edges.some(
(e) =>
e.type === 'WRITES_RESOURCE' &&
e.sourceId.includes('register') &&
e.targetId.includes('CoinStore'),
),
).toBe(true);
});
it('emits a READS_RESOURCE edge from balance_of to CoinStore', () => {
const { edges } = mapFactsToGraphWithResolvedEdges(facts);
expect(
edges.some(
(e) =>
e.type === 'READS_RESOURCE' &&
e.sourceId.includes('balance_of') &&
e.targetId.includes('CoinStore'),
),
).toBe(true);
});
it('emits an ACQUIRES edge from the fully-qualified acquiresInferred name', () => {
const { edges } = mapFactsToGraphWithResolvedEdges(facts);
expect(edges.some((e) => e.type === 'ACQUIRES' && e.sourceId.includes('balance_of'))).toBe(
true,
);
});
it('emits a FRIEND_OF edge to coin_admin', () => {
const { edges } = mapFactsToGraphWithResolvedEdges(facts);
expect(edges.some((e) => e.type === 'FRIEND_OF' && e.targetId.includes('coin_admin'))).toBe(
true,
);
});
it('marks struct CoinStore as a resource (key ability)', () => {
const { nodes } = mapFactsToGraph(facts, '/pkg');
const cs = nodes.find((n) => n.properties.name === 'CoinStore');
expect(cs?.label).toBe('Struct');
expect(cs?.properties.isResource).toBe(true);
});
it('marks struct TransferEvent as an event (event attribute)', () => {
const { nodes } = mapFactsToGraph(facts, '/pkg');
const ev = nodes.find((n) => n.properties.name === 'TransferEvent');
expect(ev?.properties.isEvent).toBe(true);
});
it('marks balance_of as a view entry point with precise location', () => {
const { nodes } = mapFactsToGraph(facts, '/pkg');
const fn = nodes.find((n) => n.properties.name === 'balance_of');
expect(fn?.properties.isView).toBe(true);
expect(fn?.properties.locationFidelity).toBe('precise');
expect(fn?.properties.startLine).toBe(33);
});
it('tolerates functions/modules with missing optional arrays (real move-flow shape)', () => {
// move-flow omits optional array fields (acquiresInferred, resourceAccess,
// params, typeParams, attributes, friends, types, constants) for some symbols.
// The mapper must not throw "not iterable" and skip the whole package.
const sparse = {
'0xb::sparse': {
file: '/pkg/sources/sparse.move',
// no friends / attributes / types / constants
functions: [
{
name: 'do_thing',
visibility: 'public',
isEntry: true,
isInline: false,
isNative: false,
isView: false,
// acquiresInferred, resourceAccess, params, typeParams, attributes all absent
},
],
},
};
expect(() => mapFactsToGraph(sparse, '/pkg')).not.toThrow();
const { nodes } = mapFactsToGraph(sparse, '/pkg');
const fn = nodes.find((n) => n.properties.name === 'do_thing');
expect(fn?.properties.isEntry).toBe(true);
expect(fn?.properties.parameterCount).toBe(0);
expect(fn?.properties.acquires).toEqual([]);
});
it('emits Module/Function/Struct/Const nodes and DEFINES edges', () => {
const { nodes, edges, functionNodeMap, structNodeMap, moduleFileMap } = mapFactsToGraph(
facts,
'/pkg',
);
expect(nodes.some((n) => n.label === 'Module' && n.properties.name === 'coin')).toBe(true);
expect(nodes.some((n) => n.label === 'Const' && n.properties.name === 'E_NOT_REGISTERED')).toBe(
true,
);
expect(edges.some((e) => e.type === 'DEFINES')).toBe(true);
expect(functionNodeMap.has('0xa::coin::register')).toBe(true);
expect(structNodeMap.has('0xa::coin::CoinStore')).toBe(true);
expect(moduleFileMap.get('0xa::coin')).toBe('/pkg/sources/coin.move');
});
it('uses the move-friend-or-package reason on FRIEND_OF edges (compiler-derived friends include package-visibility)', () => {
const { edges } = mapFactsToGraphWithResolvedEdges(facts);
const friendEdge = edges.find((e) => e.type === 'FRIEND_OF');
expect(friendEdge?.reason).toBe('move-friend-or-package');
});
it('serializes Struct typeParams as typeParamsJson string', () => {
const { nodes } = mapFactsToGraph(facts, '/pkg');
const cs = nodes.find((n) => n.label === 'Struct' && n.properties.name === 'CoinStore');
expect(cs?.properties.typeParamsJson).toBe(
JSON.stringify([{ name: 'CoinType', constraints: [], isPhantom: true }]),
);
expect(cs?.properties.typeParams).toBeUndefined();
});
it('serializes Enum typeParams as typeParamsJson string', () => {
const enumFacts: MoveFactsMap = {
'0xa::orders': {
file: '/pkg/sources/orders.move',
friends: [],
attributes: [],
functions: [],
structs: [
{
kind: 'enum',
name: 'OrderState',
file: '/pkg/sources/orders.move',
span: [1, 5],
abilities: ['drop'],
typeParams: [{ name: 'T', abilities: ['copy'], isPhantom: false }],
variants: [{ name: 'Open', kind: 'unit', fields: [], attributes: [] }],
attributes: [],
},
],
constants: [],
},
};
const { nodes } = mapFactsToGraph(enumFacts, '/pkg');
const en = nodes.find((n) => n.label === 'Enum');
expect(en?.properties.typeParamsJson).toBe(
JSON.stringify([{ name: 'T', constraints: ['copy'], isPhantom: false }]),
);
expect(en?.properties.typeParams).toBeUndefined();
});
it('marks key enums as resources', () => {
const enumFacts: MoveFactsMap = {
'0xa::accounts': {
file: '/pkg/sources/accounts.move',
friends: [],
attributes: [],
functions: [],
structs: [
{
kind: 'enum',
name: 'GlobalAccountStates',
file: '/pkg/sources/accounts.move',
span: [1, 5],
abilities: ['key'],
typeParams: [],
variants: [{ name: 'Empty', kind: 'unit', fields: [], attributes: [] }],
attributes: [],
},
],
constants: [],
},
};
const { nodes } = mapFactsToGraph(enumFacts, '/pkg');
const en = nodes.find((n) => n.label === 'Enum' && n.properties.name === 'GlobalAccountStates');
expect(en?.properties.isResource).toBe(true);
});
it('exposes EnumVariant attributes on the node', () => {
const enumFacts: MoveFactsMap = {
'0xa::orders': {
file: '/pkg/sources/orders.move',
friends: [],
attributes: [],
functions: [],
structs: [
{
kind: 'enum',
name: 'OrderState',
file: '/pkg/sources/orders.move',
span: [1, 5],
abilities: [],
typeParams: [],
variants: [
{
name: 'Cancelled',
kind: 'unit',
fields: [],
attributes: [{ name: 'deprecated' }],
},
],
attributes: [],
},
],
constants: [],
},
};
const { nodes } = mapFactsToGraph(enumFacts, '/pkg');
const v = nodes.find((n) => n.label === 'EnumVariant');
expect(v?.properties.attributes).toEqual(['deprecated']);
expect(v?.properties.locationFidelity).toBe('module');
});
it('does not write moduleAddress on Function nodes (only Module/Struct/Enum carry it)', () => {
const { nodes } = mapFactsToGraph(facts, '/pkg');
const fn = nodes.find((n) => n.label === 'Function' && n.properties.name === 'register');
expect(fn?.properties.moduleAddress).toBeUndefined();
const mod = nodes.find((n) => n.label === 'Module' && n.properties.name === 'coin');
expect(mod?.properties.moduleAddress).toBe('0xa');
const cs = nodes.find((n) => n.label === 'Struct' && n.properties.name === 'CoinStore');
expect(cs?.properties.moduleAddress).toBe('0xa');
});
it('writes Const data under constType/constValue (matching schema column names)', () => {
const { nodes } = mapFactsToGraph(facts, '/pkg');
const c = nodes.find((n) => n.label === 'Const' && n.properties.name === 'E_NOT_REGISTERED');
expect(c?.properties.constType).toBe('u64');
expect(c?.properties.constValue).toBe('1');
expect(c?.properties.declaredType).toBeUndefined();
expect(c?.properties.value).toBeUndefined();
expect(c?.properties.locationFidelity).toBe('module');
});
it('emits USES_TYPE edges from function signature types (params + return)', () => {
const sigFacts: MoveFactsMap = {
'0xa::coin': {
file: '/pkg/sources/coin.move',
friends: [],
attributes: [],
functions: [
{
name: 'wrap',
file: '/pkg/sources/coin.move',
span: [1, 5],
visibility: 'public',
isEntry: false,
isInline: false,
isNative: false,
isView: false,
attributes: [],
typeParams: [],
params: [{ name: 'store', type: '&CoinStore' }],
returnTypes: ['TransferEvent'],
acquiresInferred: [],
resourceAccess: { reads: [], writes: [] },
},
],
structs: [
{
kind: 'struct',
name: 'CoinStore',
file: '/pkg/sources/coin.move',
span: [10, 12],
abilities: ['key'],
typeParams: [],
fields: [],
attributes: [],
},
{
kind: 'struct',
name: 'TransferEvent',
file: '/pkg/sources/coin.move',
span: [20, 22],
abilities: ['drop'],
typeParams: [],
fields: [],
attributes: [],
},
],
constants: [],
},
};
const { edges } = mapFactsToGraphWithResolvedEdges(sigFacts);
const usesEdges = edges.filter((e) => e.type === 'USES_TYPE');
const toCoinStore = usesEdges.find((e) => e.targetId.includes('CoinStore'));
const toEvent = usesEdges.find((e) => e.targetId.includes('TransferEvent'));
expect(toCoinStore?.reason).toBe('move-fn-param-type');
expect(toEvent?.reason).toBe('move-fn-return-type');
});
it('populates usedTypes from resolved signature type edges only', () => {
const sigFacts: MoveFactsMap = {
'0xa::coin': {
file: '/pkg/sources/coin.move',
friends: [],
attributes: [],
functions: [
{
name: 'wrap',
file: '/pkg/sources/coin.move',
span: [1, 5],
visibility: 'public',
isEntry: false,
isInline: false,
isNative: false,
isView: false,
attributes: [],
typeParams: [],
params: [
{ name: 'stores', type: 'vector<CoinStore<T>>' },
{ name: 'unknown', type: 'UnresolvedType' },
],
returnTypes: ['u64', 'TransferEvent'],
acquiresInferred: [],
resourceAccess: { reads: [], writes: [] },
},
],
structs: [
{
kind: 'struct',
name: 'CoinStore',
file: '/pkg/sources/coin.move',
span: [10, 12],
abilities: ['key'],
typeParams: [],
fields: [],
attributes: [],
},
{
kind: 'struct',
name: 'TransferEvent',
file: '/pkg/sources/coin.move',
span: [20, 22],
abilities: ['drop'],
typeParams: [],
fields: [],
attributes: [],
},
],
constants: [],
},
};
const { nodes } = mapFactsToGraphWithResolvedEdges(sigFacts);
const wrap = nodes.find((n) => n.label === 'Function' && n.properties.name === 'wrap');
expect(wrap?.properties.usedTypes).toEqual([
'0xa::coin::CoinStore',
'0xa::coin::TransferEvent',
]);
});
it('populates usedTypes string array on the Function node', () => {
const { nodes } = mapFactsToGraph(facts, '/pkg');
const balanceOf = nodes.find(
(n) => n.label === 'Function' && n.properties.name === 'balance_of',
);
expect(balanceOf?.properties.usedTypes).toEqual([]);
const register = nodes.find((n) => n.label === 'Function' && n.properties.name === 'register');
expect(register?.properties.usedTypes).toEqual([]);
});
it('does not write isTest/isTestOnly on Function nodes (move-flow strips test items)', () => {
const { nodes } = mapFactsToGraph(facts, '/pkg');
const fn = nodes.find((n) => n.label === 'Function' && n.properties.name === 'balance_of');
expect(fn?.properties.isTest).toBeUndefined();
expect(fn?.properties.isTestOnly).toBeUndefined();
});
it('projects returnTypes onto the scalar returnType column ([] -> undefined, single -> element, tuple -> joined)', () => {
const rtFacts: MoveFactsMap = {
'0xa::rt': {
file: '/pkg/sources/rt.move',
friends: [],
attributes: [],
functions: [
fnFixture('none', { returnTypes: [] }),
fnFixture('single', { returnTypes: ['u64'] }),
fnFixture('tuple', { returnTypes: ['u64', '0xbeef::price::PriceInfo'] }),
],
structs: [],
constants: [],
},
};
const { nodes } = mapFactsToGraph(rtFacts, '/pkg');
const byName = (name: string) => nodes.find((n) => n.properties.name === name);
expect(byName('none')?.properties.returnType).toBeUndefined();
expect(byName('single')?.properties.returnType).toBe('u64');
expect(byName('tuple')?.properties.returnType).toBe('(u64, 0xbeef::price::PriceInfo)');
});
it('queues one move-fn-return-type ref per tuple element', () => {
const rtFacts: MoveFactsMap = {
'0xa::rt': {
file: '/pkg/sources/rt.move',
friends: [],
attributes: [],
functions: [fnFixture('snapshot', { returnTypes: ['u64', '0xbeef::price::PriceInfo'] })],
structs: [],
constants: [],
},
};
const { pendingTypeRef } = mapFactsToGraph(rtFacts, '/pkg');
const returnRefs = pendingTypeRef.filter((p) => p.reason === 'move-fn-return-type');
expect(returnRefs.map((p) => p.target)).toEqual(['0xbeef::price::PriceInfo']);
});
it('rejects the legacy scalar returnType shape with a userActionable error (shape sentinel)', () => {
const legacy = {
'0xa::old': {
file: '/pkg/sources/old.move',
functions: [{ ...fnFixture('legacy'), returnTypes: undefined, returnType: 'u64' }],
},
} as unknown as MoveFactsMap;
let caught: unknown;
try {
mapFactsToGraph(legacy, '/pkg');
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(Error);
expect((caught as Error).message).toContain('returnTypes');
expect((caught as { userActionable?: boolean }).userActionable).toBe(true);
});
it('does not bind a qualified ref with no exact match to a same-named struct under a different address', () => {
// Dependency-only 0x1::coin::CoinStore must NOT resolve to the repo's
// 0xa::coin::CoinStore just because the bare names collide.
const qualFacts: MoveFactsMap = {
'0xa::coin': {
file: '/pkg/sources/coin.move',
friends: [],
attributes: [],
functions: [
fnFixture('probe', {
acquiresInferred: ['0x1::coin::CoinStore'],
resourceAccess: { reads: ['0x1::coin::CoinStore'], writes: [] },
}),
],
structs: [
{
kind: 'struct',
name: 'CoinStore',
file: '/pkg/sources/coin.move',
span: [1, 3],
abilities: ['key'],
typeParams: [],
fields: [],
attributes: [],
},
],
constants: [],
},
};
const mapped = mapFactsToGraph(qualFacts, '/pkg');
const localNameIndex = buildLocalNameIndex(mapped.structNodeMap);
const resolvedEdges: unknown[] = [];
const unresolved: string[] = [];
resolveResourceEdges(
mapped.pendingResource,
mapped.structNodeMap,
localNameIndex,
(rel) => resolvedEdges.push(rel),
(pending) => unresolved.push(pending.target),
);
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', () => {
const attrFacts: MoveFactsMap = {
'0xa::vault': {
file: '/pkg/sources/vault.move',
friends: [],
attributes: [],
functions: [
fnFixture('is_big', {
attributes: [{ name: 'lint::skip', args: [{ name: 'needless_bool' }] }],
}),
],
structs: [
{
kind: 'struct',
name: 'Group',
file: '/pkg/sources/vault.move',
span: [1, 1],
abilities: [],
typeParams: [],
fields: [],
attributes: [{ name: 'resource_group', args: [{ name: 'scope', value: 'global' }] }],
},
],
constants: [],
},
};
const { nodes } = mapFactsToGraph(attrFacts, '/pkg');
const fn = nodes.find((n) => n.label === 'Function' && n.properties.name === 'is_big');
expect(fn?.properties.attributes).toEqual(['lint::skip']);
expect(fn?.properties.attributesJson).toBe(
JSON.stringify([{ name: 'lint::skip', args: [{ name: 'needless_bool' }] }]),
);
const group = nodes.find((n) => n.label === 'Struct' && n.properties.name === 'Group');
expect(group?.properties.attributesJson).toBe(
JSON.stringify([{ name: 'resource_group', args: [{ name: 'scope', value: 'global' }] }]),
);
});
it('emits a resource-group membership USES_TYPE edge from resource_group_member(group = ...)', () => {
const groupFacts: MoveFactsMap = {
'0xa::vault': {
file: '/pkg/sources/vault.move',
friends: [],
attributes: [],
functions: [],
structs: [
{
kind: 'struct',
name: 'Group',
file: '/pkg/sources/vault.move',
span: [1, 1],
abilities: [],
typeParams: [],
fields: [],
attributes: [{ name: 'resource_group', args: [{ name: 'scope', value: 'global' }] }],
},
{
kind: 'struct',
name: 'Meta',
file: '/pkg/sources/vault.move',
span: [3, 5],
abilities: ['key'],
typeParams: [],
fields: [],
attributes: [
{
name: 'resource_group_member',
args: [{ name: 'group', value: '0xa::vault::Group' }],
},
],
},
],
constants: [],
},
};
const { edges } = mapFactsToGraphWithResolvedEdges(groupFacts);
const membership = edges.find((e) => e.reason === 'move-resource-group-member');
expect(membership?.type).toBe('USES_TYPE');
expect(membership?.sourceId).toContain('Meta');
expect(membership?.targetId).toContain('Group');
});
it('prefers the compiler-reported definedIn over name parsing for lambda hosts', () => {
const lambdaFacts: MoveFactsMap = {
'0xa::vault': {
file: '/pkg/sources/vault.move',
friends: [],
attributes: [],
functions: [
fnFixture('lifted'),
fnFixture('__lambda__1__lifted', { isLambdaLifted: true, definedIn: 'lifted' }),
// Nested closure: the name parse would yield '__lambda__1__outer',
// but move-flow resolves the host through nested closures.
fnFixture('__lambda__2____lambda__1__outer', {
isLambdaLifted: true,
definedIn: 'outer',
}),
],
structs: [],
constants: [],
},
};
const { pendingLambdaHosts } = mapFactsToGraph(lambdaFacts, '/pkg');
expect(pendingLambdaHosts.map((p) => p.hostQualified)).toEqual([
'0xa::vault::lifted',
'0xa::vault::outer',
]);
});
});
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import {
isMoveCompilerInputPath,
moveAvailabilityRequiresFullRebuild,
} from '../../../src/core/move/constants.js';
describe('Move incremental safety', () => {
it.each([
'sources/coin.move',
'packages/coin/Move.toml',
'packages/coin/Move.lock',
'packages\\coin\\sources\\coin.move',
])('treats %s as a package-wide compiler input', (filePath) => {
expect(isMoveCompilerInputPath(filePath)).toBe(true);
});
it.each(['src/move.ts', 'packages/coin/move.toml', 'README.md'])(
'does not classify %s as a Move compiler input',
(filePath) => {
expect(isMoveCompilerInputPath(filePath)).toBe(false);
},
);
it('rebuilds only when a Move repo gains compiler availability', () => {
expect(moveAvailabilityRequiresFullRebuild(true, true, undefined)).toBe(true);
expect(moveAvailabilityRequiresFullRebuild(true, true, false)).toBe(true);
expect(moveAvailabilityRequiresFullRebuild(true, true, true)).toBe(false);
expect(moveAvailabilityRequiresFullRebuild(true, false, true)).toBe(false);
expect(moveAvailabilityRequiresFullRebuild(false, true, undefined)).toBe(false);
});
});
@@ -0,0 +1,137 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createRequire } from 'node:module';
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
interface DownloadResponse extends PassThrough {
statusCode?: number;
headers: { location?: string };
}
type DownloadGet = (url: string, onResponse: (response: DownloadResponse) => void) => EventEmitter;
type ChecksumVerification =
| { status: 'match'; expected: string; actual: string }
| { status: 'mismatch'; expected: string; actual: string }
| { status: 'missing' };
interface InstallerHelpers {
downloadToFile(url: string, dest: string, get?: DownloadGet): Promise<void>;
expectedSha(sumsText: string, assetName: string): string | null;
sha256(file: string): string;
verifyArchiveChecksum(sumsText: string, assetName: string, archive: string): ChecksumVerification;
powershellExpandArchiveInvocation(
archive: string,
dest: string,
): {
args: string[];
env: NodeJS.ProcessEnv;
};
}
const require = createRequire(import.meta.url);
const {
downloadToFile,
expectedSha,
sha256,
verifyArchiveChecksum,
powershellExpandArchiveInvocation,
} = require('../../../scripts/install-move-flow.cjs') as InstallerHelpers;
const tempRoots: string[] = [];
afterEach(() => {
for (const root of tempRoots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});
describe('install-move-flow', () => {
const assetName = 'move-flow-v2.0.0-x86_64-unknown-linux-gnu.zip';
function writeArchive(contents = 'verified move-flow archive'): string {
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-checksum-'));
tempRoots.push(root);
const archive = path.join(root, assetName);
writeFileSync(archive, contents);
return archive;
}
it('accepts an exact asset entry whose checksum matches the downloaded archive', () => {
const archive = writeArchive();
const digest = sha256(archive);
const sums = `${digest.toUpperCase()} ${assetName}\n`;
expect(expectedSha(sums, assetName)).toBe(digest);
expect(verifyArchiveChecksum(sums, assetName, archive)).toEqual({
status: 'match',
expected: digest,
actual: digest,
});
// Preserve the release parser's supported BSD checksum format too.
expect(expectedSha(`SHA256 (${assetName}) = ${digest.toUpperCase()}`, assetName)).toBe(digest);
});
it('reports a checksum mismatch instead of authorizing the archive', () => {
const archive = writeArchive('tampered archive');
const expected = '0'.repeat(64);
const actual = sha256(archive);
expect(verifyArchiveChecksum(`${expected} ${assetName}`, assetName, archive)).toEqual({
status: 'mismatch',
expected,
actual,
});
});
it('treats a suffix-collision entry as an omitted asset', () => {
const archive = writeArchive();
const digest = sha256(archive);
const sums = `${digest} prefixed-${assetName}`;
expect(expectedSha(sums, assetName)).toBeNull();
expect(expectedSha(`SHA256 (prefixed-${assetName}) = ${digest}`, assetName)).toBeNull();
expect(verifyArchiveChecksum(sums, assetName, archive)).toEqual({ status: 'missing' });
});
it('rejects a mid-download response error and removes the partial file', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-download-'));
tempRoots.push(root);
const dest = path.join(root, 'move-flow.zip');
const get = vi.fn<DownloadGet>((_url, onResponse) => {
const request = new EventEmitter();
queueMicrotask(() => {
const response = new PassThrough() as DownloadResponse;
response.statusCode = 200;
response.headers = {};
onResponse(response);
response.write('partial archive');
response.destroy(new Error('connection reset during download'));
});
return request;
});
await expect(downloadToFile('https://example.test/move-flow.zip', dest, get)).rejects.toThrow(
'connection reset during download',
);
expect(existsSync(dest)).toBe(false);
expect(existsSync(`${dest}.partial`)).toBe(false);
});
it('passes PowerShell paths as environment data instead of command source', () => {
const archive = `C:\\temp\\move flow's "archive".zip`;
const dest = `C:\\temp\\destination's folder`;
const invocation = powershellExpandArchiveInvocation(archive, dest);
const command = invocation.args.join(' ');
expect(command).toContain('$env:GITNEXUS_MOVE_FLOW_ARCHIVE_PATH');
expect(command).toContain('$env:GITNEXUS_MOVE_FLOW_DESTINATION_PATH');
expect(command).not.toContain(archive);
expect(command).not.toContain(dest);
expect(invocation.env.GITNEXUS_MOVE_FLOW_ARCHIVE_PATH).toBe(archive);
expect(invocation.env.GITNEXUS_MOVE_FLOW_DESTINATION_PATH).toBe(dest);
});
});
+372
View File
@@ -0,0 +1,372 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
// Mock child_process before importing the module under test
vi.mock('node:child_process', () => ({
spawn: vi.fn(),
execFileSync: vi.fn(),
}));
// Keep PATH-resolution tests independent of a developer or CI cache that may
// already contain vendor/move-flow/<platform>/move-flow.
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return { ...actual, existsSync: vi.fn(() => false) };
});
import {
MoveFlowMcpClient,
MoveFlowToolCallError,
tryCreateMoveFlowClient,
detectMoveFlowCapabilities,
} from '../../../src/core/move/mcp-client.js';
import { spawn, execFileSync } from 'node:child_process';
const mockSpawn = vi.mocked(spawn);
const mockExecFileSync = vi.mocked(execFileSync);
function createMockProc() {
const proc = new EventEmitter() as any;
proc.stdin = new PassThrough();
proc.stdout = new PassThrough();
proc.stderr = new PassThrough();
proc.kill = vi.fn();
proc.pid = 99999;
return proc;
}
describe('tryCreateMoveFlowClient', () => {
beforeEach(() => {
vi.resetAllMocks();
delete process.env.MOVE_FLOW;
});
it('returns MoveFlowMcpClient when binary is found', () => {
mockExecFileSync.mockReturnValue(Buffer.from(''));
const client = tryCreateMoveFlowClient();
expect(client).toBeInstanceOf(MoveFlowMcpClient);
expect(mockExecFileSync).toHaveBeenCalledWith(
'move-flow',
['--version'],
expect.objectContaining({ stdio: 'ignore' }),
);
});
it('returns null when binary is not found', () => {
mockExecFileSync.mockImplementation(() => {
throw new Error('not found');
});
expect(tryCreateMoveFlowClient()).toBeNull();
});
it.each([
'/usr/local/bin/move-flow',
'C:\\Program Files\\move-flow.exe',
'move-flow;literal-name',
])('passes an explicit binary path directly to execFileSync: %s', (binary) => {
process.env.MOVE_FLOW = binary;
mockExecFileSync.mockReturnValue(Buffer.from(''));
const client = tryCreateMoveFlowClient();
expect(client).toBeInstanceOf(MoveFlowMcpClient);
expect(mockExecFileSync).toHaveBeenCalledWith(binary, ['--version'], {
stdio: 'ignore',
timeout: 5000,
});
});
});
describe('MoveFlowMcpClient', () => {
beforeEach(() => {
mockSpawn.mockReset();
delete process.env.GITNEXUS_MOVE_FLOW_TIMEOUT_MS;
});
afterEach(() => {
vi.useRealTimers();
delete process.env.GITNEXUS_MOVE_FLOW_TIMEOUT_MS;
});
it('shutdown clears all state', async () => {
const proc = createMockProc();
mockSpawn.mockReturnValue(proc as any);
const client = new MoveFlowMcpClient('move-flow');
// Manually set internal state to simulate initialized client
(client as any).proc = proc;
(client as any).initialized = true;
await client.shutdown();
expect(proc.kill).toHaveBeenCalled();
expect((client as any).proc).toBeNull();
expect((client as any).initialized).toBe(false);
expect((client as any).initPromise).toBeNull();
expect((client as any).pending.size).toBe(0);
});
it('shutdown is safe to call when not started', async () => {
const client = new MoveFlowMcpClient('move-flow');
await client.shutdown(); // Should not throw
});
/** Wire the mock server: reply to initialize, and to tools/call with `reply`. */
function serveToolsCall(proc: any, reply: Record<string, unknown>): void {
proc.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') {
proc.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} }) + '\n');
} else if (msg.method === 'tools/call') {
proc.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, ...reply }) + '\n');
}
}
});
}
it('kills and clears a timed-out initialization, ignores its late response, and retries', async () => {
vi.useFakeTimers();
const slowProc = createMockProc();
const retryProc = createMockProc();
mockSpawn.mockReturnValueOnce(slowProc as any).mockReturnValueOnce(retryProc as any);
const client = new MoveFlowMcpClient('move-flow');
const firstCall = client.facts('/slow');
const firstFailure = expect(firstCall).rejects.toThrow(
'move-flow MCP server did not respond within 30s',
);
await vi.advanceTimersByTimeAsync(30000);
await firstFailure;
expect(slowProc.kill).toHaveBeenCalledOnce();
expect((client as any).proc).toBeNull();
expect((client as any).initialized).toBe(false);
expect((client as any).initPromise).toBeNull();
expect((client as any).pending.size).toBe(0);
// The dead process may still flush an initialize response after the timer.
// It must not resurrect the failed attempt or interfere with the retry.
slowProc.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: 1, result: {} }) + '\n');
expect((client as any).initialized).toBe(false);
expect((client as any).pending.size).toBe(0);
serveToolsCall(retryProc, {
result: { content: [{ type: 'text', text: '{"retry":"ok"}' }], isError: false },
});
await expect(client.facts('/retry')).resolves.toEqual({ retry: 'ok' });
expect(mockSpawn).toHaveBeenCalledTimes(2);
await client.shutdown();
});
it('honours the Move tool timeout override for large or test packages', async () => {
vi.useFakeTimers();
process.env.GITNEXUS_MOVE_FLOW_TIMEOUT_MS = '25';
const proc = createMockProc();
mockSpawn.mockReturnValue(proc as any);
proc.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') {
proc.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} }) + '\n');
}
}
});
const client = new MoveFlowMcpClient('move-flow');
const request = client.facts('/slow');
const failure = expect(request).rejects.toThrow(
"move-flow 'move_package_query' timed out after 25ms",
);
await vi.advanceTimersByTimeAsync(25);
await failure;
expect(proc.kill).toHaveBeenCalledOnce();
await client.shutdown();
});
it('clears initialization state when move-flow exits during launch so the next call retries', async () => {
const crashedProc = createMockProc();
const retryProc = createMockProc();
mockSpawn.mockReturnValueOnce(crashedProc as any).mockReturnValueOnce(retryProc as any);
const client = new MoveFlowMcpClient('move-flow');
const firstCall = client.facts('/crash');
const firstFailure = expect(firstCall).rejects.toThrow(
'move-flow exited with code 1 during init',
);
crashedProc.emit('exit', 1);
await firstFailure;
expect((client as any).proc).toBeNull();
expect((client as any).initialized).toBe(false);
expect((client as any).initPromise).toBeNull();
expect((client as any).pending.size).toBe(0);
serveToolsCall(retryProc, {
result: { content: [{ type: 'text', text: '{"retry":"ok"}' }], isError: false },
});
await expect(client.facts('/retry')).resolves.toEqual({ retry: 'ok' });
expect(mockSpawn).toHaveBeenCalledTimes(2);
await client.shutdown();
});
it('kills and clears initialization state when spawning move-flow emits an error', async () => {
const failedProc = createMockProc();
const retryProc = createMockProc();
mockSpawn.mockReturnValueOnce(failedProc as any).mockReturnValueOnce(retryProc as any);
const client = new MoveFlowMcpClient('move-flow');
const firstCall = client.facts('/missing-binary');
const firstFailure = expect(firstCall).rejects.toThrow(
'Failed to spawn move-flow: spawn ENOENT',
);
failedProc.emit('error', new Error('spawn ENOENT'));
await firstFailure;
expect(failedProc.kill).toHaveBeenCalledOnce();
expect((client as any).proc).toBeNull();
expect((client as any).initialized).toBe(false);
expect((client as any).initPromise).toBeNull();
expect((client as any).pending.size).toBe(0);
serveToolsCall(retryProc, {
result: { content: [{ type: 'text', text: '{"retry":"ok"}' }], isError: false },
});
await expect(client.facts('/retry')).resolves.toEqual({ retry: 'ok' });
expect(mockSpawn).toHaveBeenCalledTimes(2);
await client.shutdown();
});
it('rejects isError:true tool results as MoveFlowToolCallError instead of resolving the error text as data', async () => {
// move-flow reports package build failures as a SUCCESS result with
// isError:true; resolving its text would feed an error string to the facts
// mapper and ingest per-character garbage Module nodes.
const proc = createMockProc();
mockSpawn.mockReturnValue(proc as any);
const client = new MoveFlowMcpClient('move-flow');
serveToolsCall(proc, {
result: {
content: [{ type: 'text', text: 'failed to build package `/broken`: bad manifest' }],
isError: true,
},
});
await expect(client.facts('/broken')).rejects.toThrow(MoveFlowToolCallError);
await expect(client.facts('/broken')).rejects.toThrow(
'failed to build package `/broken`: bad manifest',
);
await client.shutdown();
});
it('classifies JSON-RPC -32602 (invalid_params) as MoveFlowToolCallError with the code attached', async () => {
const proc = createMockProc();
mockSpawn.mockReturnValue(proc as any);
const client = new MoveFlowMcpClient('move-flow');
serveToolsCall(proc, {
error: { code: -32602, message: 'failed to build package `/broken`: no manifest' },
});
await expect(client.facts('/broken')).rejects.toMatchObject({
name: 'MoveFlowToolCallError',
code: -32602,
});
await client.shutdown();
});
it('packageStatus reports ok on an isError:false status result', async () => {
// move-flow returns "no errors or warnings" for a compiling package.
const proc = createMockProc();
mockSpawn.mockReturnValue(proc as any);
const client = new MoveFlowMcpClient('move-flow');
serveToolsCall(proc, {
result: { content: [{ type: 'text', text: 'no errors or warnings' }], isError: false },
});
await expect(client.packageStatus('/testonly')).resolves.toEqual({
ok: true,
diagnostics: 'no errors or warnings',
});
await client.shutdown();
});
it('packageStatus reports the compiler diagnostics on an isError:true status result', async () => {
// move-flow returns the diagnostics as isError:true content text.
const diagnostic = "error: unexpected token\n3 | }\nUnexpected '}'";
const proc = createMockProc();
mockSpawn.mockReturnValue(proc as any);
const client = new MoveFlowMcpClient('move-flow');
serveToolsCall(proc, {
result: { content: [{ type: 'text', text: diagnostic }], isError: true },
});
await expect(client.packageStatus('/broken')).resolves.toEqual({
ok: false,
diagnostics: diagnostic,
});
await client.shutdown();
});
});
describe('detectMoveFlowCapabilities', () => {
it('reports facts support from a standalone move_package_facts tool name', () => {
const caps = detectMoveFlowCapabilities(['move_package_query', 'move_package_facts']);
expect(caps.hasFactsQuery).toBe(true);
});
it('detects the facts query from the move_package_query inputSchema enum', () => {
// Reflects reality: `facts` is a `const` in the QueryType `$defs`, not a tool.
const caps = detectMoveFlowCapabilities([
{ name: 'move_package_manifest' },
{
name: 'move_package_query',
inputSchema: {
$defs: {
QueryType: {
oneOf: [{ const: 'module_summary' }, { const: 'call_graph' }, { const: 'facts' }],
},
},
},
},
]);
expect(caps.hasFactsQuery).toBe(true);
});
it('also detects facts from a flat enum schema', () => {
const caps = detectMoveFlowCapabilities([
{
name: 'move_package_query',
inputSchema: { properties: { query: { enum: ['module_summary', 'facts'] } } },
},
]);
expect(caps.hasFactsQuery).toBe(true);
});
it('reports facts absent when the schema omits it', () => {
const caps = detectMoveFlowCapabilities([
{
name: 'move_package_query',
inputSchema: { $defs: { QueryType: { oneOf: [{ const: 'module_summary' }] } } },
},
'move_package_manifest',
]);
expect(caps.hasFactsQuery).toBe(false);
});
it('reports facts absent when move_package_query is missing entirely', () => {
const caps = detectMoveFlowCapabilities(['move_package_status']);
expect(caps.hasFactsQuery).toBe(false);
expect(caps.hasStatusTool).toBe(true);
});
it('reports the status tool absent on older builds that do not list it', () => {
const caps = detectMoveFlowCapabilities(['move_package_query']);
expect(caps.hasStatusTool).toBe(false);
});
});
@@ -0,0 +1,119 @@
/**
* Empty-facts discrimination in the moveIngest phase.
*
* move-flow returns facts `{}` with isError:false BOTH for a valid package
* whose every item is `#[test_only]` (facts elide test items) AND for a
* syntax-broken package. The phase must cross-check `move_package_status` to
* tell them apart: compiling -> warning (likely test-only), failing -> error
* (with the compiler diagnostics), status unavailable -> error (old behavior).
*/
import { describe, it, expect } from 'vitest';
import path from 'node:path';
import type { MoveFlowClient } from '../../../src/core/move/mcp-client.js';
import { runMoveIngestPhase } from '../../helpers/move-ingest-harness.js';
const REPO_ROOT = path.resolve('/repo');
function makeClient(overrides: Partial<MoveFlowClient> = {}): MoveFlowClient {
return {
facts: async () => ({}),
callGraph: async () => ({}),
packageStatus: async () => ({ ok: true, diagnostics: 'no errors or warnings' }),
capabilities: async () => ({ hasFactsQuery: true, hasStatusTool: true }),
shutdown: async () => {},
...overrides,
};
}
/** Run the phase against a fake repo with one package holding one .move file. */
async function runPhase(client: MoveFlowClient) {
return runMoveIngestPhase(client, REPO_ROOT, ['pkg/Move.toml', 'pkg/sources/t.move']);
}
function emptyFactsIssues(output: Awaited<ReturnType<typeof runPhase>>) {
return output.consistencyIssues.filter((i) => i.code === 'empty-package-facts');
}
describe('moveIngest empty-facts discrimination', () => {
it('downgrades to a warning when the package compiles (test-only package)', async () => {
const output = await runPhase(makeClient());
const issues = emptyFactsIssues(output);
expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('warning');
expect(issues[0].message).toContain('compiles but produced no facts');
expect(issues[0].message).toContain('test-only');
expect(output.consistencyIssues.filter((i) => i.severity === 'error')).toHaveLength(0);
// The package's files must stay un-ingested (acceptable for test-only).
expect(output.ingestedFiles.size).toBe(0);
});
it('keeps the error and folds in the diagnostics when the build fails', async () => {
const diagnostics = "error: unexpected token\n3 | }\nUnexpected '}'";
const output = await runPhase(
makeClient({ packageStatus: async () => ({ ok: false, diagnostics }) }),
);
const issues = emptyFactsIssues(output);
expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('error');
expect(issues[0].message).toContain('does not compile');
expect(issues[0].message).toContain('error: unexpected token');
expect(issues[0].details?.diagnostics).toBe(diagnostics);
});
it('falls back to the error-level issue when move_package_status is unavailable', async () => {
const output = await runPhase(
makeClient({
capabilities: async () => ({ hasFactsQuery: true, hasStatusTool: false }),
}),
);
const issues = emptyFactsIssues(output);
expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('error');
expect(issues[0].message).toContain('does it compile?');
});
it('falls back to the error-level issue when the status probe itself fails', async () => {
const output = await runPhase(
makeClient({
packageStatus: async () => {
throw new Error('move-flow exited unexpectedly');
},
}),
);
const issues = emptyFactsIssues(output);
expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('error');
expect(issues[0].message).toContain('does it compile?');
});
it('does not probe status when facts are non-empty', async () => {
let statusCalls = 0;
const output = await runPhase(
makeClient({
facts: async () => ({
'0xa::t': {
file: path.join(REPO_ROOT, 'pkg', 'sources', 't.move'),
span: [1, 3] as [number, number],
friends: [],
attributes: [],
functions: [],
structs: [],
constants: [],
},
}),
packageStatus: async () => {
statusCalls += 1;
return { ok: true, diagnostics: '' };
},
}),
);
expect(statusCalls).toBe(0);
expect(emptyFactsIssues(output)).toHaveLength(0);
expect(output.ingestedFiles.has('pkg/sources/t.move')).toBe(true);
});
});
@@ -0,0 +1,58 @@
/**
* Package-ownership attribution in the moveIngest phase: a .move file belongs
* to a package only when it lives UNDER the package root. A sibling directory
* sharing a name prefix ('pkg_ab' next to root 'pkg_a') must never match - a
* bare startsWith('/repo/pkg_a') would claim '/repo/pkg_ab/sources/x.move'.
*/
import { describe, it, expect } from 'vitest';
import path from 'node:path';
import type { MoveFlowClient } from '../../../src/core/move/mcp-client.js';
import { runMoveIngestPhase } from '../../helpers/move-ingest-harness.js';
const REPO_ROOT = path.resolve('/repo');
/** Client returning empty facts for every package, without a status tool. */
function emptyFactsClient(): MoveFlowClient {
return {
facts: async () => ({}),
callGraph: async () => ({}),
packageStatus: async () => ({ ok: true, diagnostics: '' }),
capabilities: async () => ({ hasFactsQuery: true, hasStatusTool: false }),
shutdown: async () => {},
};
}
describe('moveIngest package-ownership attribution', () => {
it('attributes each file to its own package across sibling packages pkg_a / pkg_ab', async () => {
const output = await runMoveIngestPhase(emptyFactsClient(), REPO_ROOT, [
'pkg_a/Move.toml',
'pkg_a/sources/a.move',
'pkg_ab/Move.toml',
'pkg_ab/sources/ab.move',
]);
const issues = output.consistencyIssues.filter((i) => i.code === 'empty-package-facts');
expect(issues.map((i) => i.details?.packageRoot).sort()).toEqual([
path.join(REPO_ROOT, 'pkg_a'),
path.join(REPO_ROOT, 'pkg_ab'),
]);
// Exactly one owned .move file each - no prefix bleed between siblings.
expect(issues.map((i) => i.details?.moveFileCount)).toEqual([1, 1]);
});
it('does not attribute files of a prefix-sharing non-package sibling (pkg_ab) to pkg_a', async () => {
// pkg_ab has no Move.toml, so its file belongs to NO package. With a bare
// startsWith ownership test it would count towards pkg_a's empty-facts
// diagnostics (moveFileCount 2 instead of 1).
const output = await runMoveIngestPhase(emptyFactsClient(), REPO_ROOT, [
'pkg_a/Move.toml',
'pkg_a/sources/a.move',
'pkg_ab/sources/x.move',
]);
const issues = output.consistencyIssues.filter((i) => i.code === 'empty-package-facts');
expect(issues).toHaveLength(1);
expect(issues[0].details?.packageRoot).toBe(path.join(REPO_ROOT, 'pkg_a'));
expect(issues[0].details?.moveFileCount).toBe(1);
});
});
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { extractTypeNames } from '../../../src/core/move/type-parser.js';
describe('extractTypeNames', () => {
it('returns [] for primitive types', () => {
expect(extractTypeNames('u64')).toEqual([]);
expect(extractTypeNames('address')).toEqual([]);
expect(extractTypeNames('bool')).toEqual([]);
expect(extractTypeNames('&signer')).toEqual([]);
});
it('returns the bare type name for a simple reference', () => {
expect(extractTypeNames('CoinStore')).toEqual(['CoinStore']);
expect(extractTypeNames('&CoinStore')).toEqual(['CoinStore']);
expect(extractTypeNames('&mut CoinStore')).toEqual(['CoinStore']);
});
it('extracts both outer and inner types from generics', () => {
expect(extractTypeNames('CoinStore<CoinType>')).toEqual(['CoinStore', 'CoinType']);
expect(extractTypeNames('Object<Pool<Coin>>')).toEqual(['Object', 'Pool', 'Coin']);
});
it('handles qualified type names', () => {
expect(extractTypeNames('aptos_framework::coin::CoinStore<T>')).toEqual([
'aptos_framework::coin::CoinStore',
'T',
]);
});
it('handles vectors and options', () => {
expect(extractTypeNames('vector<u8>')).toEqual([]);
expect(extractTypeNames('Option<Vault>')).toEqual(['Option', 'Vault']);
});
});
+13 -6
View File
@@ -46,6 +46,7 @@ describe('LadybugDB Schema', () => {
const multiLang = [
'Struct',
'Enum',
'EnumVariant',
'Macro',
'Typedef',
'Union',
@@ -74,8 +75,8 @@ describe('LadybugDB Schema', () => {
});
it('has expected total count', () => {
// 9 core + 19 multi-language + Route + Tool + BasicBlock = 32
expect(NODE_TABLES).toHaveLength(32);
// 9 core + 20 multi-language/Move + Route + Tool + BasicBlock = 33
expect(NODE_TABLES).toHaveLength(33);
});
});
@@ -90,6 +91,12 @@ describe('LadybugDB Schema', () => {
'IMPLEMENTS',
'MEMBER_OF',
'STEP_IN_PROCESS',
'FRIEND_OF',
'READS_RESOURCE',
'WRITES_RESOURCE',
'ACQUIRES',
'USES_TYPE',
'EMITS',
];
for (const t of expected) {
expect(REL_TYPES).toContain(t);
@@ -246,8 +253,8 @@ describe('LadybugDB Schema', () => {
describe('schema query ordering', () => {
it('NODE_SCHEMA_QUERIES has correct count', () => {
// 31 + BasicBlock = 32
expect(NODE_SCHEMA_QUERIES).toHaveLength(32);
// 31 + EnumVariant + BasicBlock = 33
expect(NODE_SCHEMA_QUERIES).toHaveLength(33);
});
it('REL_SCHEMA_QUERIES has one relation table', () => {
@@ -255,8 +262,8 @@ describe('LadybugDB Schema', () => {
});
it('SCHEMA_QUERIES includes all node + rel + embedding schemas', () => {
// 32 node + 1 rel + 1 embedding = 34
expect(SCHEMA_QUERIES).toHaveLength(34);
// 33 node + 1 rel + 1 embedding = 35
expect(SCHEMA_QUERIES).toHaveLength(35);
});
it('node schemas come before relation schemas in SCHEMA_QUERIES', () => {
+6
View File
@@ -39,6 +39,12 @@ describe('VALID_RELATION_TYPES', () => {
'WRAPS',
// Spring DI @Autowired collection injection (#2200)
'INJECTS',
// Move/Aptos impact edges (compiler-first via move-flow `facts`) added to
// the allowlist alongside the Move graph integration.
'ACQUIRES',
'READS_RESOURCE',
'WRITES_RESOURCE',
'USES_TYPE',
] as const;
it('contains all expected relation types', () => {
+6 -3
View File
@@ -2,7 +2,7 @@
* Unit Tests: MCP Tool Definitions
*
* Tests: GITNEXUS_TOOLS from tools.ts
* - All 17 tools are defined (per-repo + group_list/group_sync)
* - All 20 tools are defined (per-repo + Move + group_list/group_sync)
* - Each tool has valid name, description, inputSchema
* - Required fields are correct
* - Optional repo parameter is present on tools that need it
@@ -21,8 +21,8 @@ const MUTATING_TOOLS = new Set(['rename', 'group_sync']);
const OPEN_WORLD_READ_ONLY_TOOLS = new Set(['query']);
describe('GITNEXUS_TOOLS', () => {
it('exports all tools (8 base + 1 explain + 1 pdg_query + 3 route/tool/shape + 1 api_impact + 1 trace + 2 group)', () => {
expect(GITNEXUS_TOOLS).toHaveLength(17);
it('exports all tools (base + explain/pdg/route/tool/shape/api/trace + Move + group)', () => {
expect(GITNEXUS_TOOLS).toHaveLength(20);
});
it('contains all expected tool names', () => {
@@ -41,6 +41,9 @@ describe('GITNEXUS_TOOLS', () => {
'pdg_query',
'api_impact',
'trace',
'move_entries',
'move_resources',
'move_impact',
]),
);
});