Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7abb2c9bb5 | ||
|
|
8127b75c0f | ||
|
|
be1dad7f70 | ||
|
|
6c8ed81783 | ||
|
|
e3a3d6b58c | ||
|
|
5c6f887b63 | ||
|
|
2582b9351c | ||
|
|
edb8afb726 | ||
|
|
31933c706a | ||
|
|
d3998f4788 | ||
|
|
a67ea56320 | ||
|
|
30fdb059fd | ||
|
|
1c58114e57 |
@@ -6,7 +6,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.9",
|
||||
"version": "1.6.9-aptos",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./gitnexus-claude-plugin"
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@ name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, main-aptos]
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
|
||||
workflow_call:
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ name: CodeQL
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, main-aptos]
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
@@ -8,7 +8,7 @@ name: Dependency Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, main-aptos]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -8,7 +8,7 @@ name: Gitleaks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, main-aptos]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,7 +11,7 @@ name: Workflow Lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, main-aptos]
|
||||
paths:
|
||||
- '.github/**'
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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`."
|
||||
|
||||
Generated
+2
-2
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>() }),
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, '/');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "enums_demo"
|
||||
version = "0.0.1"
|
||||
|
||||
[addresses]
|
||||
enums_demo = "0xA"
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ⇒
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user