Merge pull request #2622 from zwxxb/upstream/move-runtime-reliability

fix(move): provision move-flow at analyze time
This commit is contained in:
Abhigyan Patwari 2026-07-23 03:55:21 +05:30 • committed by GitHub
commit 6706cb2e88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2952 additions and 1093 deletions

View file

@ -26,6 +26,9 @@ jobs:
# FTS-dependent lbug integration suites are guaranteed to run in CI.
env:
GITNEXUS_REQUIRE_FTS: '1'
# The live Move suite provisions the pinned release on the shard that
# owns it and fails loudly if the compiler path is unavailable.
GITNEXUS_REQUIRE_MOVE_FLOW: '1'
steps:
# persist-credentials: false — runs tests + uploads a blob artifact; the
# default-persisted token must not be capturable through it (zizmor
@ -34,17 +37,16 @@ jobs:
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.
# Cache the on-demand move-flow install so the shard owning the live Move
# suite (GITNEXUS_REQUIRE_MOVE_FLOW=1 hard-fails on provisioning errors)
# doesn't re-download the release every run and doesn't hard-fail on a
# transient GitHub Releases outage. A restored cache is still verified
# (metadata + binary sha256 + version probe) before use. Keep the key
# tied to MOVE_FLOW_RELEASE.version in gitnexus/src/core/move/release.ts.
- name: Cache move-flow binary
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: gitnexus/vendor/move-flow
path: ~/.gitnexus/tools/move-flow
key: ${{ runner.os }}-move-flow-v2.0.0
- uses: ./.github/actions/setup-gitnexus
@ -56,7 +58,7 @@ jobs:
# resolves the extension at module load and can't self-install, so sharding
# could otherwise drop it into a shard with no installer sibling.
- name: Cache LadybugDB FTS extension
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.lbdb/extension
key: lbug-fts-${{ runner.os }}-${{ hashFiles('gitnexus/package-lock.json') }}
@ -209,8 +211,8 @@ jobs:
timeout-minutes: 25
# Same guarantee on the platform-sensitive runners: FTS-dependent suites in
# 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.
# exercised by this subset either, so GITNEXUS_SKIP_MOVE_FLOW prevents any
# fixture-driven analyze smoke from provisioning it as 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
@ -250,7 +252,7 @@ jobs:
# extensions are native binaries. (Key name kept as lbug-fts for cache
# continuity — the path covers every extension in the shared home.)
- name: Cache LadybugDB FTS extension
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.lbdb/extension
key: lbug-fts-${{ runner.os }}-${{ hashFiles('gitnexus/package-lock.json') }}

5
.gitignore vendored
View file

@ -87,8 +87,9 @@ 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.
# Legacy pre-#2622 postinstall artifact — the managed MoveFlow binary now
# lives under ~/.gitnexus/tools/move-flow and this path is never used, but
# old checkouts may still carry a downloaded binary here. Never commit it.
gitnexus/vendor/move-flow/
/github/scripts/triage/__pycache__/

View file

@ -51,8 +51,9 @@ RUN npm run postinstall --prefix gitnexus
# node:22-bookworm-slim
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
# curl for the healthcheck; git for cloning; ca-certificates for TLS verification.
RUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates && rm -rf /var/lib/apt/lists/* \
# curl for the healthcheck; git for cloning; unzip for on-demand Move Flow;
# ca-certificates for TLS verification.
RUN apt-get update && apt-get install -y --no-install-recommends curl git unzip ca-certificates && rm -rf /var/lib/apt/lists/* \
&& rm -rf /usr/local/lib/node_modules/npm \
&& rm -rf /usr/local/lib/node_modules/corepack \
&& rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack

View file

@ -72,7 +72,7 @@ That's it. `analyze` indexes the codebase, installs agent skills, registers Clau
> **Fastest MCP startup:** install globally (`npm i -g gitnexus`) before running `gitnexus setup` — this writes an absolute-path MCP config that bypasses `npx` entirely. On a cold cache, an `npx`-based MCP install can exceed Claude Code's `MCP_TIMEOUT` default (~30s).
> **No C++ toolchain?** Set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip the vendored grammar materialize/build for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` — those four languages won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild.
> **No C++ toolchain?** Set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip the vendored grammar materialize/build for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` — those four languages won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. At analyze time, strict `=1` also disables automatic `move-flow` downloads; an explicit `MOVE_FLOW`, verified cache entry, or compatible binary on `PATH` can still provide Move support.
> **Behind an HTTP proxy / regional firewall?** `onnxruntime-node`'s postinstall downloads optional CUDA binaries from `api.nuget.org` and ignores `HTTP_PROXY`/`HTTPS_PROXY` ([#2370](https://github.com/abhigyanpatwari/GitNexus/issues/2370)). The embedding stack is an optional dependency, so a failed download no longer breaks the install — and it self-heals: the first `gitnexus analyze --embeddings` (or `gitnexus embeddings install`) fetches the stack through your npm registry config (mirrors/proxies apply, no NuGet) into `~/.gitnexus/embedding-runtime` (override with `GITNEXUS_EMBEDDING_RUNTIME_DIR`). The on-demand prefix needs Node with `module.registerHooks` (≥ 22.15 on 22.x, ≥ 23.5 on 23.x); on older Node, keep the stack in the install itself with `ONNXRUNTIME_NODE_INSTALL=skip npm install -g gitnexus` (works on every supported Node).
@ -90,7 +90,7 @@ That's it. `analyze` indexes the codebase, installs agent skills, registers Clau
| **Install** | `npm install -g gitnexus` | No install — [gitnexus.vercel.app](https://gitnexus.vercel.app) |
| **Storage** | LadybugDB native (fast, persistent) | LadybugDB WASM (in-memory, per session) |
| **Parsing** | Tree-sitter native bindings | Tree-sitter WASM |
| **Privacy** | Everything local, no network | Everything in-browser, no server |
| **Privacy** | Source stays local; optional runtimes may be downloaded | Everything in-browser, no server |
> **Bridge mode:** `gitnexus serve` connects the two — the web UI auto-detects the local server and can browse all your CLI-indexed repos without re-uploading or re-indexing.
@ -472,6 +472,29 @@ Notes:
</details>
<details>
<summary><strong>Move compiler runtime</strong></summary>
Repositories containing `Move.toml` use the compiler-backed `move-flow` runtime. `gitnexus analyze` resolves it in this order:
1. the explicit `MOVE_FLOW` path;
2. a checksum- and version-verified cache under `~/.gitnexus/tools/move-flow`;
3. a compatible `move-flow` on `PATH`;
4. the pinned GitHub release, downloaded over HTTPS and verified against its `SHA256SUMS`.
The managed install is serialized across processes and published atomically. For air-gapped hosts, install a compatible binary ahead of time and set `MOVE_FLOW`, or pre-seed the managed cache; set `GITNEXUS_SKIP_MOVE_FLOW=1` to disable automatic downloads. An invalid explicit `MOVE_FLOW` remains authoritative and does not fall back to a network install.
GitNexus records the compiler identity that produced Move facts. A compiler change forces a full rebuild; if a previously compiler-backed graph needs updating while `move-flow` is unavailable, analysis fails before mutating the existing graph. Managed identities include the verified binary hash; after replacing an explicit or `PATH` binary with a different same-version build, run `gitnexus analyze --force`. Rolling back this behavior only requires reverting the GitNexus change; removing the managed cache is optional, and reanalysis is needed only when restoring desired compiler-derived facts.
Move runtime controls:
- `MOVE_FLOW` selects an authoritative executable; `GITNEXUS_SKIP_MOVE_FLOW=1` disables automatic downloads.
- `GITNEXUS_MOVE_FLOW_DIR` changes the cache root; `GITNEXUS_MOVE_FLOW_HTTP_TIMEOUT_MS` changes the 30-second per-download deadline.
- `GITNEXUS_MOVE_FLOW_TIMEOUT_MS` changes the five-minute compiler-tool timeout; `GITNEXUS_MOVE_FLOW_COMPAT=1` forces the Linux compatibility build.
- `GITNEXUS_MOVE_FLOW_VERSION`, `GITNEXUS_MOVE_FLOW_REPO`, and `GITNEXUS_MOVE_FLOW_TAG` override trusted release coordinates for advanced testing.
</details>
<details>
<summary><strong>Environment variables</strong></summary>
@ -501,7 +524,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction. On breach the file keeps the captures accumulated so far and logs a warning — the worker returns to JS instead of stalling in native-heavy loops (#2432). `0` expires immediately. | Pathological generated C++ that still exceeds the budget after the indexed lookups; raise for completeness, lower to fail-fast. |
| `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Chunk boundary used for cache-key composition and dispatch. Smaller = finer-grained cache hits but more dispatch overhead. | Tuning incremental-analyze cache behavior on monorepos. |
| `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). |
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips the vendored grammar materialize for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` at install time (and the Dart/Proto source builds). Those four won't be parsed; the install still succeeds. | Installing on a host without a C++ toolchain or where the vendored prebuilds don't match; willing to skip Dart/Proto/Swift/Kotlin parsing. |
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips optional grammar materialization at install time, disables those grammars at runtime, and disables automatic `move-flow` downloads. Existing verified/cache/PATH Move runtimes remain usable. | Running without optional native runtimes and accepting that affected languages may not be indexed. |
| `GITNEXUS_MCP_READ_ONLY` | unset | Set to `1` to expose only proven single-repository read tools and resources; `0` disables the policy and any other value fails startup. | The MCP server runs in an environment where graph mutation, raw Cypher, and cross-repository group routing must be unavailable. |
| `GITNEXUS_MCP_ALLOWED_REPOS` | unset | Comma-separated allowlist of canonical indexed repository names or absolute paths. Invalid, ambiguous, or blank entries fail startup. | One MCP process must expose only a bounded subset of the repositories in the global registry. |
| `GITNEXUS_MCP_DEFAULT_REPO` | unset | Canonical indexed repository name or absolute path used when a tool or resource omits its repository. Must belong to the allowlist when one is set. | Several repositories are available but unqualified MCP calls should resolve deterministically. |

View file

@ -28,7 +28,9 @@ from .proposer_sandbox import (
SandboxError,
)
PINNED_GITNEXUS_VERSION = "1.6.9"
# main-aptos carries an -aptos prerelease suffix; keep this pin in lock-step
# with gitnexus/package.json on THIS branch (release bumps must update both).
PINNED_GITNEXUS_VERSION = "1.6.9-aptos"
HARNESS_ROOT = Path(__file__).resolve().parents[2]
CE_ARMS = frozenset({"ce_workflow", "ce_workflow_direct", "ce_review"})

View file

@ -436,7 +436,7 @@ gitnexus serve
### Installation fails with native module errors
Some optional language grammars (Dart, Proto, Swift, Kotlin) require native compilation. If they fail, GitNexus still works — those languages will be skipped. To skip them intentionally (no C++ toolchain needed), set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before installing.
Some optional language grammars (Dart, Proto, Swift, Kotlin) require native compilation. If they fail, GitNexus still works — those languages will be skipped. To skip them intentionally (no C++ toolchain needed), set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before installing. Strict `=1` also disables automatic `move-flow` downloads during analysis; a verified cache entry, explicit `MOVE_FLOW`, or compatible binary on `PATH` remains usable.
If `npm install -g gitnexus` fails on native modules:
@ -449,6 +449,24 @@ If `npm install -g gitnexus` fails on native modules:
npm install -g gitnexus
```
### Move compiler provisioning
When a repository contains `Move.toml`, `gitnexus analyze` resolves `move-flow`
from the authoritative `MOVE_FLOW` path, a verified cache under
`~/.gitnexus/tools/move-flow`, `PATH`, or the pinned GitHub release. Managed
downloads use HTTPS, checksum and version verification, a cross-process lock,
and atomic publication.
For air-gapped hosts, set `MOVE_FLOW` to a preinstalled compatible binary or
pre-seed the managed cache. Set `GITNEXUS_SKIP_MOVE_FLOW=1` to disable automatic
downloads. The cache root and network/compiler timeouts can be configured with
`GITNEXUS_MOVE_FLOW_DIR`, `GITNEXUS_MOVE_FLOW_HTTP_TIMEOUT_MS`, and
`GITNEXUS_MOVE_FLOW_TIMEOUT_MS`.
GitNexus records the compiler identity that produced Move facts. A compiler
change forces a full rebuild; if an existing compiler-backed graph needs an
update while `move-flow` is unavailable, analysis fails before mutating it.
### Installation fails behind an HTTP proxy (`onnxruntime-node` postinstall)
`onnxruntime-node`'s postinstall downloads optional CUDA GPU binaries from `api.nuget.org` — outside the npm registry, so registry mirrors don't cover it, and its proxy layer (`global-agent`) ignores the standard `HTTP_PROXY`/`HTTPS_PROXY` variables and rejects 302 redirects ([#2370](https://github.com/abhigyanpatwari/GitNexus/issues/2370)).

View file

@ -54,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 && node scripts/install-move-flow.cjs",
"postinstall": "node scripts/build-tree-sitter-grammars.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",

View file

@ -69,6 +69,9 @@ const PLATFORM_LOGIC = [
// array form. Runs on every platform (the ubuntu suite covers Linux; this
// registration adds windows + macos).
'test/unit/embedding-install-arg-delivery.test.ts',
// Lazy MoveFlow install coordinates, PowerShell invocation data, archive
// streaming, and filesystem leases all vary across operating systems.
'test/unit/move/install-move-flow.test.ts',
// Structural FTS-extension classifier against REAL binaries (#2374): on this
// matrix `process.execPath` / `lbugjs.node` are a real PE (windows) and Mach-O
// (macos), so the header parsing is proven on genuine binaries, not synthetic
@ -95,6 +98,9 @@ const LBUG_NATIVE = [
'test/integration/lbug-orphan-sidecar-recovery.test.ts',
'test/integration/lbug-readonly-init.test.ts',
'test/integration/lbug-non-ascii-path.test.ts',
// Shared Move/non-Move rows must retain their schema defaults through the
// real native database on every supported desktop platform.
'test/integration/move-mixed-language-roundtrip.test.ts',
// Cross-repo trace e2e: builds two real lbug indexes + a real bridge and
// opens them through the pool adapter (native addon + bridge file locking).
// Windows is skipped in-file (describeReopen) due to the bridge reopen lock.

View file

@ -1,371 +0,0 @@
#!/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);
});
}

View file

@ -42,11 +42,9 @@ 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';
@ -1236,17 +1234,6 @@ 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.

View file

@ -1,14 +0,0 @@
/** 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 },
);
}

View file

@ -262,11 +262,12 @@ export interface PipelineOptions {
* options combination.
*/
export function buildPhaseList(options?: PipelineOptions): PipelinePhase[] {
const { standaloneIngestPhase = emptyStandaloneIngestPhase } = options ?? {};
return (
new PhaseRegistry<PipelineOptions>()
.register(scanPhase)
.register(structurePhase)
.register(options?.standaloneIngestPhase ?? emptyStandaloneIngestPhase)
.register(standaloneIngestPhase)
.register(springConfigPhase)
.register(markdownPhase)
.register(cobolPhase)

View file

@ -24,13 +24,12 @@ 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,
getNodeTableCsvHeader,
getNodeTableLayout,
hasNodeTableLayout,
MULTI_LANG_BASE_COLUMNS,
} from './move-columns.js';
type LayoutTableName,
} from './node-table-layout.js';
/**
* Deterministic output ordering — optional (out-of-core / windowed-resolve
@ -141,8 +140,10 @@ export const escapeCSVStringArray = (value: unknown): string => {
return escapeCSVField(`[${encoded.join(',')}]`);
};
// Truthy coercion, matching the incremental CREATE path (`!!properties.isExported`
// in lbug-adapter) so bulk and incremental loads classify the same values alike.
export const escapeCSVBoolean = (value: unknown): string => {
return value === true ? 'true' : 'false';
return value ? 'true' : 'false';
};
const formatCSVStringArray = (value: unknown): string => {
@ -252,6 +253,48 @@ export const normalizeFtsText = (text: string): string => text.replace(/[\r\n\t]
const formatFtsDescription = (description: string): string =>
normalizeFtsText(applyCjkSegmentationIfEnabled(description));
/**
* Encode a row from the same ordered layout that owns its DDL, CSV header,
* and COPY column list. These tables are shared across languages, so missing
* optional properties deliberately serialize to typed defaults.
*/
export const buildLayoutNodeRow = (
table: LayoutTableName,
node: GraphNode,
content: string,
): string => {
const layout = getNodeTableLayout(table);
return layout.columns
.map((spec) => {
const value = node.properties[spec.name] ?? spec.defaultValue;
switch (spec.csvEncoding) {
case 'node-id':
return escapeCSVField(node.id);
case 'number':
return escapeCSVNumber(
typeof value === 'number' ? value : undefined,
typeof spec.defaultValue === 'number' ? spec.defaultValue : -1,
);
case 'boolean':
return escapeCSVBoolean(value);
case 'string-array':
return escapeCSVStringArray(value);
case 'content':
return escapeCSVField(content);
case 'description':
return escapeCSVField(formatFtsDescription(String(value ?? '')));
case 'string':
return escapeCSVField(String(value ?? ''));
default: {
const _exhaustive: never = spec.csvEncoding;
throw new Error(`Unsupported CSV column encoding: ${String(_exhaustive)}`);
}
}
})
.join(',');
};
// Labels that get exact source-span content (no ±2 window). Single source of
// truth in `symbol-labels.ts` — see there for why the exactness depends on the
// 0-based line invariant. Kept as a named alias to read intent at the use site.
@ -465,7 +508,7 @@ export const streamAllCSVsToDisk = async (
const codeElementHeader = CODE_ELEMENT_COLUMNS.join(',');
const functionWriter = new BufferedCSVWriter(
path.join(csvDir, 'function.csv'),
MOVE_FUNCTION_COLUMNS.join(','),
getNodeTableCsvHeader('Function'),
);
const classWriter = new BufferedCSVWriter(
path.join(csvDir, 'class.csv'),
@ -518,10 +561,6 @@ export const streamAllCSVsToDisk = async (
// Multi-language node types share the same CSV shape (no isExported column)
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',
@ -553,15 +592,9 @@ export const streamAllCSVsToDisk = async (
path.join(csvDir, `${t.toLowerCase()}.csv`),
t === 'Property'
? propertyHeader
: t === 'Struct' || t === 'Enum'
? moveStructLikeHeader
: t === 'EnumVariant'
? moveEnumVariantHeader
: t === 'Const'
? moveConstHeader
: t === 'Module'
? moveModuleHeader
: multiLangHeader,
: hasNodeTableLayout(t)
? getNodeTableCsvHeader(t)
: multiLangHeader,
),
);
}
@ -718,126 +751,53 @@ export const streamAllCSVsToDisk = async (
const writer = codeWriterMap[node.label];
if (writer) {
const content = await extractContent(node, contentCache);
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(','),
);
pending = writer.addRow(buildLayoutNodeRow('Function', node, content));
} else if (node.label === 'Class') {
pending = writer.addRow(
[
...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 || '')),
escapeCSVField(formatCSVStringArray(node.properties.frameworkAnnotations)),
].join(','),
);
} else {
pending = writer.addRow(baseFields.join(','));
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(','),
);
}
} else {
// Multi-language node types (Struct, Impl, Trait, Macro, etc.)
const mlWriter = multiLangWriters.get(node.label);
if (mlWriter) {
const content = await extractContent(node, contentCache);
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(','),
);
if (hasNodeTableLayout(node.label)) {
pending = mlWriter.addRow(buildLayoutNodeRow(node.label, node, content));
} else {
pending = mlWriter.addRow(
[
...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 || '')),
...(node.label === 'Property'
? [escapeCSVField(node.properties.declaredType || '')]
: []),

View file

@ -23,13 +23,7 @@ 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 { getNodeTableColumnNames } from './node-table-layout.js';
import {
extensionManager,
resolveAnalyzeInstallPolicy,
@ -1368,6 +1362,10 @@ const copyColumns = (columns: readonly string[]): string => columns.join(', ');
export const getCopyQuery = (table: NodeTableName, filePath: string): string => {
const t = escapeTableName(table);
const layoutColumns = getNodeTableColumnNames(table);
if (layoutColumns) {
return `COPY ${t}(${copyColumns(layoutColumns)}) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'File') {
return `COPY ${t}(id, name, filePath, content) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
@ -1404,21 +1402,6 @@ 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}`;

View file

@ -1,92 +0,0 @@
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;

View file

@ -0,0 +1,168 @@
import type { NodeTableName } from 'gitnexus-shared';
export type CsvColumnEncoding =
| 'node-id'
| 'string'
| 'number'
| 'boolean'
| 'string-array'
| 'content'
| 'description';
export interface NodeTableColumnSpec {
name: string;
ddlType: string;
csvEncoding: CsvColumnEncoding;
defaultValue?: string | number | boolean;
}
export interface NodeTableLayout {
table: NodeTableName;
columns: readonly NodeTableColumnSpec[];
quoteTableName?: boolean;
}
const column = (
name: string,
ddlType: string,
csvEncoding: CsvColumnEncoding = 'string',
defaultValue?: string | number | boolean,
): NodeTableColumnSpec => ({ name, ddlType, csvEncoding, defaultValue });
const CODE_ELEMENT_BASE = [
column('id', 'STRING', 'node-id'),
column('name', 'STRING'),
column('filePath', 'STRING'),
column('startLine', 'INT64', 'number', -1),
column('endLine', 'INT64', 'number', -1),
column('isExported', 'BOOLEAN', 'boolean', false),
column('content', 'STRING', 'content'),
column('description', 'STRING', 'description'),
] as const;
// Same base as code elements minus isExported (multi-language tables have none).
const MULTI_LANGUAGE_BASE = CODE_ELEMENT_BASE.filter(({ name }) => name !== 'isExported');
export const CODE_ELEMENT_COLUMNS = CODE_ELEMENT_BASE.map(({ name }) => name);
export const MULTI_LANG_BASE_COLUMNS = MULTI_LANGUAGE_BASE.map(({ name }) => name);
const FUNCTION_LAYOUT: NodeTableLayout = {
table: 'Function',
columns: [
...CODE_ELEMENT_BASE,
column('language', 'STRING'),
column('qualifiedName', 'STRING'),
column('moduleQualifiedName', 'STRING'),
column('visibility', 'STRING'),
column('visibilityModifier', 'STRING'),
column('isEntry', 'BOOLEAN', 'boolean', false),
column('isView', 'BOOLEAN', 'boolean', false),
column('isInline', 'BOOLEAN', 'boolean', false),
column('isNative', 'BOOLEAN', 'boolean', false),
column('parameterCount', 'INT32', 'number', 0),
column('returnType', 'STRING'),
column('acquires', 'STRING[]', 'string-array'),
column('usedTypes', 'STRING[]', 'string-array'),
column('attributes', 'STRING[]', 'string-array'),
column('attributesJson', 'STRING'),
column('typeParamsJson', 'STRING'),
column('locationFidelity', 'STRING'),
],
};
const structLikeLayout = (table: 'Struct' | 'Enum'): NodeTableLayout => ({
table,
quoteTableName: true,
columns: [
...MULTI_LANGUAGE_BASE,
column('language', 'STRING'),
column('qualifiedName', 'STRING'),
column('moduleQualifiedName', 'STRING'),
column('moduleAddress', 'STRING'),
column('abilities', 'STRING[]', 'string-array'),
column('isResource', 'BOOLEAN', 'boolean', false),
column('isEvent', 'BOOLEAN', 'boolean', false),
column('fieldList', 'STRING[]', 'string-array'),
column('attributes', 'STRING[]', 'string-array'),
column('attributesJson', 'STRING'),
column('typeParamsJson', 'STRING'),
column('moveDeclarationKind', 'STRING'),
column('locationFidelity', 'STRING'),
],
});
const ENUM_VARIANT_LAYOUT: NodeTableLayout = {
table: 'EnumVariant',
quoteTableName: true,
columns: [
...MULTI_LANGUAGE_BASE,
column('language', 'STRING'),
column('qualifiedName', 'STRING'),
column('parentEnum', 'STRING'),
column('moduleQualifiedName', 'STRING'),
column('variantKind', 'STRING'),
column('fieldsJson', 'STRING'),
column('attributes', 'STRING[]', 'string-array'),
column('attributesJson', 'STRING'),
column('locationFidelity', 'STRING'),
],
};
const CONST_LAYOUT: NodeTableLayout = {
table: 'Const',
quoteTableName: true,
columns: [
...MULTI_LANGUAGE_BASE,
column('language', 'STRING'),
column('qualifiedName', 'STRING'),
column('moduleQualifiedName', 'STRING'),
column('constType', 'STRING'),
column('constValue', 'STRING'),
column('isErrorCode', 'BOOLEAN', 'boolean', false),
column('locationFidelity', 'STRING'),
],
};
const MODULE_LAYOUT: NodeTableLayout = {
table: 'Module',
quoteTableName: true,
columns: [
...MULTI_LANGUAGE_BASE,
column('language', 'STRING'),
column('qualifiedName', 'STRING'),
column('moduleAddress', 'STRING'),
column('attributes', 'STRING[]', 'string-array'),
column('attributesJson', 'STRING'),
column('locationFidelity', 'STRING'),
],
};
export const NODE_TABLE_LAYOUTS = {
Function: FUNCTION_LAYOUT,
Struct: structLikeLayout('Struct'),
Enum: structLikeLayout('Enum'),
EnumVariant: ENUM_VARIANT_LAYOUT,
Const: CONST_LAYOUT,
Module: MODULE_LAYOUT,
} as const satisfies Partial<Record<NodeTableName, NodeTableLayout>>;
export type LayoutTableName = keyof typeof NODE_TABLE_LAYOUTS;
export const hasNodeTableLayout = (table: string): table is LayoutTableName =>
Object.hasOwn(NODE_TABLE_LAYOUTS, table);
export const getNodeTableLayout = (table: LayoutTableName): NodeTableLayout =>
NODE_TABLE_LAYOUTS[table];
export const getNodeTableColumnNames = (table: NodeTableName): readonly string[] | undefined =>
hasNodeTableLayout(table) ? getNodeTableLayout(table).columns.map(({ name }) => name) : undefined;
export const getNodeTableCsvHeader = (table: LayoutTableName): string =>
NODE_TABLE_LAYOUTS[table].columns.map(({ name }) => name).join(',');
export const buildNodeTableSchema = (table: LayoutTableName): string => {
const layout = NODE_TABLE_LAYOUTS[table];
const tableName = layout.quoteTableName ? `\`${layout.table}\`` : layout.table;
const columns = layout.columns.map(({ name, ddlType }) => ` ${name} ${ddlType},`).join('\n');
return `\nCREATE NODE TABLE ${tableName} (\n${columns}\n PRIMARY KEY (id)\n)`;
};

View file

@ -11,6 +11,7 @@
// Import from shared package (single source of truth) — used in DDL templates below
import { NODE_TABLES, REL_TABLE_NAME, REL_TYPES, EMBEDDING_TABLE_NAME } from 'gitnexus-shared';
import { buildNodeTableSchema } from './node-table-layout.js';
// Re-export so downstream consumers keep the same import path
export { NODE_TABLES, REL_TABLE_NAME, REL_TYPES, EMBEDDING_TABLE_NAME };
export type { NodeTableName, RelType } from 'gitnexus-shared';
@ -36,35 +37,7 @@ CREATE NODE TABLE Folder (
PRIMARY KEY (id)
)`;
export const FUNCTION_SCHEMA = `
CREATE NODE TABLE Function (
id STRING,
name STRING,
filePath STRING,
startLine INT64,
endLine INT64,
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)
)`;
export const FUNCTION_SCHEMA = buildNodeTableSchema('Function');
export const CLASS_SCHEMA = `
CREATE NODE TABLE Class (
@ -173,93 +146,9 @@ CREATE NODE TABLE \`${name}\` (
PRIMARY KEY (id)
)`;
// 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 STRUCT_SCHEMA = buildNodeTableSchema('Struct');
export const ENUM_SCHEMA = buildNodeTableSchema('Enum');
export const ENUM_VARIANT_SCHEMA = buildNodeTableSchema('EnumVariant');
export const MACRO_SCHEMA = CODE_ELEMENT_BASE('Macro');
export const TYPEDEF_SCHEMA = CODE_ELEMENT_BASE('Typedef');
export const UNION_SCHEMA = CODE_ELEMENT_BASE('Union');
@ -267,7 +156,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 = MOVE_CONST_SCHEMA;
export const CONST_SCHEMA = buildNodeTableSchema('Const');
export const STATIC_SCHEMA = CODE_ELEMENT_BASE('Static');
export const VARIABLE_SCHEMA = CODE_ELEMENT_BASE('Variable');
export const PROPERTY_SCHEMA = `
@ -287,7 +176,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 = MOVE_MODULE_SCHEMA;
export const MODULE_SCHEMA = buildNodeTableSchema('Module');
// API route endpoints (Next.js, Express, etc.)
export const ROUTE_SCHEMA = `
CREATE NODE TABLE Route (

View file

@ -10,6 +10,33 @@ Cold compiler builds for large packages may take several minutes. Tool calls
default to a five-minute timeout; override it in milliseconds with
`GITNEXUS_MOVE_FLOW_TIMEOUT_MS` when a repository needs a larger budget.
## Runtime provisioning
When `analyze` finds a `Move.toml`, it resolves `move-flow` from the authoritative
`MOVE_FLOW` path, the verified managed cache, `PATH`, or finally the pinned
release in `release.ts`. Managed releases live under
`~/.gitnexus/tools/move-flow` by default, are downloaded over HTTPS, checked
against `SHA256SUMS`, version-probed, and atomically published while a
heartbeat lease serializes concurrent installers.
Set `GITNEXUS_SKIP_MOVE_FLOW=1` to disable automatic downloads. The umbrella
`GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` does the same while also disabling optional
grammars. Air-gapped hosts should set `MOVE_FLOW` to a preinstalled compatible
binary or pre-seed the managed cache. Advanced trusted-release overrides are
`GITNEXUS_MOVE_FLOW_DIR`, `GITNEXUS_MOVE_FLOW_VERSION`,
`GITNEXUS_MOVE_FLOW_REPO`, `GITNEXUS_MOVE_FLOW_TAG`,
`GITNEXUS_MOVE_FLOW_COMPAT`, and `GITNEXUS_MOVE_FLOW_HTTP_TIMEOUT_MS`.
The repository metadata records the compiler identity used for Move facts.
Changing that identity forces a full rebuild. If an existing compiler-backed
graph needs updating while the compiler is unavailable, analysis fails before
mutating the graph; restore `move-flow` or set `MOVE_FLOW` and retry.
Managed identities include the verified binary hash; explicit and `PATH`
identities use their locator and reported version, so replace a same-version
local build with `gitnexus analyze --force`.
Filesystem errors while discovering `Move.toml` are also surfaced instead of
silently treating the repository as non-Move.
```text
Move package
-> move-flow MCP

View file

@ -0,0 +1,222 @@
import { createHash } from 'node:crypto';
import { createReadStream, createWriteStream } from 'node:fs';
import { rename, rm } from 'node:fs/promises';
import type { ClientRequest, IncomingMessage } from 'node:http';
import { get as httpsGet, type RequestOptions } from 'node:https';
import { Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { setTimeout as wait } from 'node:timers/promises';
export type HttpsGet = (
url: string | URL,
options: RequestOptions,
callback: (response: IncomingMessage) => void,
) => ClientRequest;
export const MAX_ARCHIVE_BYTES = 256 * 1024 * 1024;
const MAX_REDIRECTS = 5;
const MAX_ATTEMPTS = 3;
const RETRYABLE_CODES = new Set([
'EAI_AGAIN',
'ECONNREFUSED',
'ECONNRESET',
'EPIPE',
'ETIMEDOUT',
'ERR_STREAM_PREMATURE_CLOSE',
'ENETDOWN',
'ENETUNREACH',
]);
class RetryableDownloadError extends Error {
constructor(
message: string,
readonly retryAfterMs = 0,
) {
super(message);
}
}
const parseRetryAfter = (value: string | string[] | undefined): number => {
const raw = Array.isArray(value) ? value[0] : value;
if (!raw) return 0;
const seconds = Number(raw);
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1_000;
const date = Date.parse(raw);
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0;
};
const safeRequestLabel = (target: string): string => {
const parsed = new URL(target);
return `${parsed.origin}${parsed.pathname}`;
};
const isRetryableError = (error: unknown): error is Error =>
error instanceof RetryableDownloadError ||
(error instanceof Error && RETRYABLE_CODES.has((error as NodeJS.ErrnoException).code ?? ''));
const downloadAttempt = async (
initialUrl: string,
partial: string,
deadline: number,
get: HttpsGet,
maxBytes: number,
): Promise<void> => {
if (new URL(initialUrl).protocol !== 'https:') {
throw new Error('move-flow downloads require HTTPS');
}
let target = initialUrl;
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
const remaining = deadline - Date.now();
if (remaining <= 0) throw new Error('download timed out');
const result = await new Promise<{ redirect?: string }>((resolve, reject) => {
let activeResponse: IncomingMessage | null = null;
let pipelineStarted = false;
const req = get(target, {}, (response) => {
const status = response.statusCode ?? 0;
if (status >= 300 && status < 400 && response.headers.location) {
let redirect: URL;
try {
redirect = new URL(response.headers.location, target);
} catch {
response.destroy();
reject(new Error(`invalid redirect from ${safeRequestLabel(target)}`));
return;
}
response.destroy();
if (redirect.protocol !== 'https:') {
reject(new Error(`refusing non-HTTPS redirect to ${redirect.protocol}`));
return;
}
resolve({ redirect: redirect.toString() });
return;
}
if (status !== 200) {
response.destroy();
const message = `HTTP ${status} for ${safeRequestLabel(target)}`;
if (status === 408 || status === 429 || status >= 500) {
reject(
new RetryableDownloadError(message, parseRetryAfter(response.headers['retry-after'])),
);
} else {
reject(new Error(message));
}
return;
}
const rawLength = response.headers['content-length'];
const contentLength = Number(Array.isArray(rawLength) ? rawLength[0] : rawLength);
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
response.destroy();
reject(new Error(`download exceeds ${maxBytes} bytes`));
return;
}
let received = 0;
const limit = new Transform({
transform(chunk: Buffer, _encoding, callback) {
received += chunk.length;
callback(
received > maxBytes ? new Error(`download exceeds ${maxBytes} bytes`) : null,
chunk,
);
},
});
activeResponse = response;
pipelineStarted = true;
void pipeline(response, limit, createWriteStream(partial)).then(() => resolve({}), reject);
});
const timeout = setTimeout(
() => req.destroy(Object.assign(new Error('download timed out'), { code: 'ETIMEDOUT' })),
Math.max(1, remaining),
);
req.once('close', () => clearTimeout(timeout));
req.once('error', (error) => {
if (pipelineStarted) {
// Once the body pipeline owns the response, it must be the only
// operation that settles this attempt. Otherwise cleanup/retry can
// reuse the partial path while the old response is still writing.
activeResponse?.destroy(error);
return;
}
reject(error);
});
});
if (!result.redirect) return;
target = result.redirect;
}
throw new Error('too many redirects');
};
export const downloadToFile = async (
url: string,
destination: string,
timeoutMs: number,
get: HttpsGet = httpsGet,
maxBytes = MAX_ARCHIVE_BYTES,
): Promise<void> => {
const partial = `${destination}.partial`;
const deadline = Date.now() + timeoutMs;
try {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
await rm(partial, { force: true });
try {
await downloadAttempt(url, partial, deadline, get, maxBytes);
await rename(partial, destination);
return;
} catch (error) {
await rm(partial, { force: true });
if (!isRetryableError(error) || attempt === MAX_ATTEMPTS) throw error;
const retryAfter =
error instanceof RetryableDownloadError ? error.retryAfterMs : attempt * 100;
const remaining = deadline - Date.now();
if (remaining <= 1) throw error;
await wait(Math.min(Math.max(0, retryAfter), remaining - 1));
}
}
} finally {
await rm(partial, { force: true });
}
};
export const sha256File = async (file: string): Promise<string> => {
const hash = createHash('sha256');
for await (const chunk of createReadStream(file)) hash.update(chunk);
return hash.digest('hex');
};
export const expectedSha = (sumsText: string, assetName: string): string | null => {
for (const raw of sumsText.split('\n')) {
const line = raw.trim();
if (!line) continue;
const standard = /^([0-9a-f]{64})[ \t*]+(\S.*)$/i.exec(line);
if (standard && standard[2] === assetName) return standard[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;
};
export type ChecksumVerification =
| { status: 'match'; expected: string; actual: string }
| { status: 'mismatch'; expected: string; actual: string }
| { status: 'missing' };
export const verifyArchiveChecksum = async (
sumsText: string,
assetName: string,
archivePath: string,
): Promise<ChecksumVerification> => {
const expected = expectedSha(sumsText, assetName);
if (!expected) return { status: 'missing' };
const actual = await sha256File(archivePath);
return actual === expected
? { status: 'match', expected, actual }
: { status: 'mismatch', expected, actual };
};

View file

@ -91,13 +91,73 @@ export function isMoveCompilerInputPath(filePath: string): boolean {
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,
export interface MoveCompilerIdentity {
version: string;
source: 'release' | 'explicit' | 'path';
fingerprint: string;
}
/** Dynamic release coordinates used to produce a managed move-flow runtime. */
export interface MoveFlowReleaseSelection {
version: string;
repository: string;
tag: string;
assetName: string;
}
function sameMoveFlowReleaseSelection(
left: MoveFlowReleaseSelection | undefined,
right: MoveFlowReleaseSelection | undefined,
): boolean {
return hasMovePackages && compilerAvailable && indexedWithCompiler !== true;
return (
left !== undefined &&
right !== undefined &&
left.version === right.version &&
left.repository === right.repository &&
left.tag === right.tag &&
left.assetName === right.assetName
);
}
/** Legacy metadata with recorded Move inputs is treated conservatively. */
export function persistedMoveGraphRequiresCompiler(
moveIngestAvailable: boolean | undefined,
fileHashes: Record<string, string> | undefined,
): boolean {
if (moveIngestAvailable !== undefined) return moveIngestAvailable;
return Object.keys(fileHashes ?? {}).some(isMoveCompilerInputPath);
}
/** Decide whether managed provisioning must run before the clean-repo fast path. */
export function shouldProvisionMoveFlowBeforeFastPath(
hasMovePackages: boolean,
persistedGraphRequiresCompiler: boolean,
indexedCompiler: MoveCompilerIdentity | undefined,
indexedRelease: MoveFlowReleaseSelection | undefined,
desiredRelease: MoveFlowReleaseSelection | undefined,
): boolean {
if (!hasMovePackages) return false;
if (!persistedGraphRequiresCompiler) return true;
if (!indexedCompiler) return true;
if (indexedCompiler.source !== 'release') return false;
return !sameMoveFlowReleaseSelection(indexedRelease, desiredRelease);
}
/** Compiler-backed facts must be rebuilt when their producer changes. */
export function moveCompilerRequiresFullRebuild(
hasMovePackages: boolean,
compiler: MoveCompilerIdentity | undefined,
indexedWithCompiler: boolean | undefined,
indexedCompiler: MoveCompilerIdentity | undefined,
): boolean {
if (!hasMovePackages || !compiler) return false;
return (
indexedWithCompiler !== true ||
!indexedCompiler ||
indexedCompiler.version !== compiler.version ||
indexedCompiler.source !== compiler.source ||
indexedCompiler.fingerprint !== compiler.fingerprint
);
}
/**

View file

@ -5,22 +5,20 @@
* POSIX-only `find` command, so this works on native Windows.
*/
import { glob } from 'glob';
import { globIterate } 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;
for await (const _match of globIterate(['**/Move.toml'], {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'],
nodir: true,
absolute: false,
dot: false,
})) {
return true;
}
return false;
}

View file

@ -0,0 +1,124 @@
import { randomUUID } from 'node:crypto';
import type { Stats } from 'node:fs';
import { type FileHandle, mkdir, open, readFile, rm, stat, utimes } from 'node:fs/promises';
import path from 'node:path';
import { setTimeout as wait } from 'node:timers/promises';
export interface LockOptions {
waitTimeoutMs?: number;
leaseMs?: number;
heartbeatMs?: number;
retryMs?: number;
}
export interface InstallLockIo {
openLock(lockPath: string): Promise<FileHandle>;
removeLock(lockPath: string): Promise<void>;
}
const DEFAULT_LOCK_WAIT_MS = 60_000;
const INSTALL_COMPLETION_GRACE_MS = 60_000;
const DEFAULT_LOCK_LEASE_MS = 60_000;
const DEFAULT_HEARTBEAT_MS = 5_000;
const DEFAULT_LOCK_RETRY_MS = 100;
const defaultLockIo: InstallLockIo = {
openLock: (lockPath) => open(lockPath, 'wx'),
removeLock: async (lockPath) => {
await rm(lockPath, { force: true });
},
};
/** Allow for the artifact download, extraction, publication, and version probe. */
export const moveFlowInstallLockWaitMs = (httpTimeoutMs: number): number =>
Math.max(DEFAULT_LOCK_WAIT_MS, httpTimeoutMs * 2 + INSTALL_COMPLETION_GRACE_MS);
const readLock = async (lockPath: string): Promise<string | null> => {
try {
const parsed = JSON.parse(await readFile(lockPath, 'utf8')) as { token?: unknown };
return typeof parsed.token === 'string' ? parsed.token : null;
} catch {
return null;
}
};
const statOrNull = async (file: string): Promise<Stats | null> => {
try {
return await stat(file);
} catch {
return null;
}
};
const removeStaleLock = async (lockPath: string, leaseMs: number): Promise<void> => {
const first = await statOrNull(lockPath);
if (!first || Date.now() - first.mtimeMs <= leaseMs) return;
const token = await readLock(lockPath);
await wait(25);
const second = await statOrNull(lockPath);
if (
!second ||
second.mtimeMs !== first.mtimeMs ||
Date.now() - second.mtimeMs <= leaseMs ||
(await readLock(lockPath)) !== token
) {
return;
}
await rm(lockPath, { force: true });
};
export async function acquireInstallLock(
lockPath: string,
options: LockOptions = {},
io: InstallLockIo = defaultLockIo,
): Promise<() => Promise<void>> {
const waitTimeoutMs = options.waitTimeoutMs ?? DEFAULT_LOCK_WAIT_MS;
const leaseMs = options.leaseMs ?? DEFAULT_LOCK_LEASE_MS;
const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
const retryMs = options.retryMs ?? DEFAULT_LOCK_RETRY_MS;
const deadline = Date.now() + waitTimeoutMs;
const token = randomUUID();
await mkdir(path.dirname(lockPath), { recursive: true });
while (Date.now() < deadline) {
let created = false;
try {
const handle = await io.openLock(lockPath);
created = true;
try {
await handle.writeFile(JSON.stringify({ token, pid: process.pid }));
} finally {
await handle.close();
}
let heartbeatRunning = false;
const heartbeat = setInterval(() => {
if (heartbeatRunning) return;
heartbeatRunning = true;
void (async () => {
if ((await readLock(lockPath)) !== token) return;
const now = new Date();
await utimes(lockPath, now, now);
})()
.catch(() => {})
.finally(() => {
heartbeatRunning = false;
});
}, heartbeatMs);
heartbeat.unref();
return async () => {
clearInterval(heartbeat);
if ((await readLock(lockPath)) === token) await rm(lockPath, { force: true });
};
} catch (error) {
if (created) await io.removeLock(lockPath).catch(() => {});
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
await removeStaleLock(lockPath, leaseMs);
await wait(retryMs);
}
}
throw new Error('timed out waiting for another move-flow installation');
}

View file

@ -0,0 +1,532 @@
import { createHash } from 'node:crypto';
import { execFile } from 'node:child_process';
import { constants as fsConstants, type Dirent } from 'node:fs';
import {
access,
chmod,
copyFile,
lstat,
mkdir,
mkdtemp,
open,
readFile,
readdir,
rename,
rm,
writeFile,
type FileHandle,
} from 'node:fs/promises';
import { get as httpsGet } from 'node:https';
import os from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import {
downloadToFile,
MAX_ARCHIVE_BYTES,
sha256File,
verifyArchiveChecksum,
} from './artifact-download.js';
import { acquireInstallLock, moveFlowInstallLockWaitMs } from './install-lock.js';
import type { MoveFlowReleaseSelection } from './constants.js';
import { isCompatibleMoveFlowVersion, MOVE_FLOW_RELEASE } from './release.js';
export interface VerifiedMoveFlowBinary {
binaryPath: string;
version: string;
fingerprint: string;
}
export type MoveFlowInstallStatus =
| 'available'
| 'installed'
| 'skipped'
| 'unsupported'
| 'failed';
export interface MoveFlowInstallResult {
status: MoveFlowInstallStatus;
message?: string;
/** Verified runtime descriptor, set on 'available' and 'installed'. */
binary?: VerifiedMoveFlowBinary;
}
export interface MoveFlowInstallConfig {
version: string;
repository: string;
tag: string;
releaseTarget: string;
assetName: string;
releaseBase: string;
binaryName: string;
installDir: string;
binaryPath: string;
metadataPath: string;
lockPath: string;
httpTimeoutMs: number;
}
interface MoveFlowCacheMetadata {
schemaVersion: 1;
version: string;
repository: string;
tag: string;
assetName: string;
archiveSha256: string;
binarySha256: string;
}
interface PowerShellInvocation {
args: string[];
env: NodeJS.ProcessEnv;
}
const DEFAULT_HTTP_TIMEOUT_MS = 30_000;
const MAX_CHECKSUM_BYTES = 1024 * 1024;
const MAX_BINARY_BYTES = 128 * 1024 * 1024;
const MAX_METADATA_BYTES = 64 * 1024;
const execFileAsync = promisify(execFile);
const parsePositiveInteger = (value: string | undefined, fallback: number): number => {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
};
const validateReleaseCoordinates = (version: string, repository: string, tag: string): void => {
if (!/^\d+\.\d+\.\d+$/.test(version)) {
throw new Error(`invalid move-flow version '${version}'; expected X.Y.Z`);
}
if (!isCompatibleMoveFlowVersion(version)) {
throw new Error(`unsupported move-flow major version '${version}'`);
}
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
throw new Error(`invalid move-flow repository '${repository}'; expected owner/name`);
}
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(tag)) {
throw new Error(`invalid move-flow release tag '${tag}'`);
}
};
const platformKey = (platform = process.platform, arch = process.arch): string | null => {
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;
};
const execFileOutput = (
file: string,
args: string[],
options: { timeout: number; env?: NodeJS.ProcessEnv; encoding?: BufferEncoding },
): Promise<string> =>
execFileAsync(file, args, {
timeout: options.timeout,
env: options.env,
encoding: options.encoding ?? 'utf8',
}).then(({ stdout }) => String(stdout));
let detectedLinuxCompatibility: Promise<boolean> | undefined;
const detectLinuxCompatibility = async (): Promise<boolean> => {
if (process.arch === 'x64') {
try {
const cpuinfo = await readFile('/proc/cpuinfo', 'utf8');
if (!/(^|\s)avx2(\s|$)/m.test(cpuinfo)) return true;
} catch {
return true;
}
}
try {
const output = await execFileOutput('ldd', ['--version'], { timeout: 3_000 });
const match = /(\d+)\.(\d+)/.exec(output.split('\n')[0] ?? '');
if (!match) return true;
const major = Number(match[1]);
const minor = Number(match[2]);
return major < 2 || (major === 2 && minor < 34);
} catch {
return true;
}
};
const linuxNeedsCompatBuild = (env: NodeJS.ProcessEnv): Promise<boolean> => {
if (process.platform !== 'linux') return Promise.resolve(false);
if (env.GITNEXUS_MOVE_FLOW_COMPAT === '1') return Promise.resolve(true);
detectedLinuxCompatibility ??= detectLinuxCompatibility();
return detectedLinuxCompatibility;
};
const releaseTargetForPlatform = async (
key: string,
env: NodeJS.ProcessEnv,
): Promise<string | null> => {
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${(await linuxNeedsCompatBuild(env)) ? '-compat' : ''}`;
case 'linux-x64':
return `x86_64-unknown-linux-gnu${(await linuxNeedsCompatBuild(env)) ? '-compat' : ''}`;
case 'win32-x64':
return 'x86_64-pc-windows-msvc';
default:
return null;
}
};
export async function getMoveFlowInstallConfig(
env: NodeJS.ProcessEnv = process.env,
): Promise<MoveFlowInstallConfig | null> {
const key = platformKey();
if (!key) return null;
const version = env.GITNEXUS_MOVE_FLOW_VERSION?.trim() || MOVE_FLOW_RELEASE.version;
const repository = env.GITNEXUS_MOVE_FLOW_REPO?.trim() || MOVE_FLOW_RELEASE.repository;
const tag = env.GITNEXUS_MOVE_FLOW_TAG?.trim() || `${MOVE_FLOW_RELEASE.tagPrefix}${version}`;
validateReleaseCoordinates(version, repository, tag);
const releaseTarget = await releaseTargetForPlatform(key, env);
if (!releaseTarget) return null;
const assetName = `${tag}-${releaseTarget}.zip`;
if (path.basename(assetName) !== assetName) throw new Error('invalid move-flow asset name');
const identity = createHash('sha256')
.update(`${repository}\0${tag}\0${assetName}`)
.digest('hex')
.slice(0, 12);
const cacheRoot = env.GITNEXUS_MOVE_FLOW_DIR?.trim()
? path.resolve(env.GITNEXUS_MOVE_FLOW_DIR)
: path.join(
env.GITNEXUS_HOME?.trim() || path.join(os.homedir(), '.gitnexus'),
'tools',
'move-flow',
);
const installDir = path.join(cacheRoot, version, `${key}-${identity}`);
const binaryName = process.platform === 'win32' ? 'move-flow.exe' : 'move-flow';
return {
version,
repository,
tag,
releaseTarget,
assetName,
releaseBase: `https://github.com/${repository}/releases/download/${tag}`,
binaryName,
installDir,
binaryPath: path.join(installDir, binaryName),
metadataPath: path.join(installDir, 'release.json'),
lockPath: `${installDir}.install.lock`,
httpTimeoutMs: parsePositiveInteger(
env.GITNEXUS_MOVE_FLOW_HTTP_TIMEOUT_MS,
DEFAULT_HTTP_TIMEOUT_MS,
),
};
}
/** Resolve the configured release without downloading or starting move-flow. */
export async function getMoveFlowReleaseSelection(
env: NodeJS.ProcessEnv = process.env,
): Promise<MoveFlowReleaseSelection | undefined> {
const config = await getMoveFlowInstallConfig(env);
return config
? {
version: config.version,
repository: config.repository,
tag: config.tag,
assetName: config.assetName,
}
: undefined;
}
export const powershellExpandArchiveInvocation = (
archive: string,
destination: string,
): PowerShellInvocation => {
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]: destination,
},
};
};
export const reportedMoveFlowVersion = (output: string): string | null =>
/(?:^|\s)v?(\d+\.\d+\.\d+)(?:\s|$)/.exec(output)?.[1] ?? null;
const exactVersionMatches = async (binaryPath: string, version: string): Promise<boolean> => {
try {
const output = await execFileOutput(binaryPath, ['--version'], { timeout: 5_000 });
return reportedMoveFlowVersion(output) === version;
} catch {
return false;
}
};
const expectedMetadata = (
config: MoveFlowInstallConfig,
): Pick<MoveFlowCacheMetadata, 'version' | 'repository' | 'tag' | 'assetName'> => ({
version: config.version,
repository: config.repository,
tag: config.tag,
assetName: config.assetName,
});
const verifiedBinary = (
config: MoveFlowInstallConfig,
metadata: MoveFlowCacheMetadata,
): VerifiedMoveFlowBinary => ({
binaryPath: config.binaryPath,
version: metadata.version,
fingerprint: createHash('sha256')
.update(
`${metadata.repository}\0${metadata.tag}\0${metadata.assetName}\0${metadata.binarySha256}`,
)
.digest('hex'),
});
const validCachedInstall = async (
config: MoveFlowInstallConfig,
): Promise<VerifiedMoveFlowBinary | null> => {
let metadataHandle: FileHandle | undefined;
try {
// Single open, then every check reads through the handle, so the type/size
// gates and the JSON read cannot race a concurrent swap of the file
// (CodeQL js/file-system-race). O_NOFOLLOW makes the kernel reject a
// symlinked final component atomically on POSIX; Windows has no such flag
// (0 fallback) — there the fstat isFile() gate plus the downstream
// metadata/binary-hash validation carry the (home-dir, local-writer)
// threat model.
metadataHandle = await open(
config.metadataPath,
fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0),
);
const metadataStat = await metadataHandle.stat();
if (
!metadataStat.isFile() ||
metadataStat.size <= 0 ||
metadataStat.size > MAX_METADATA_BYTES
) {
return null;
}
const metadata = JSON.parse(
await metadataHandle.readFile({ encoding: 'utf8' }),
) as MoveFlowCacheMetadata;
const expected = expectedMetadata(config);
const binaryStat = await lstat(config.binaryPath);
if (!binaryStat.isFile() || binaryStat.size <= 0 || binaryStat.size > MAX_BINARY_BYTES) {
return null;
}
await access(
config.binaryPath,
process.platform === 'win32' ? fsConstants.F_OK : fsConstants.X_OK,
);
if (
metadata.schemaVersion !== 1 ||
metadata.version !== expected.version ||
metadata.repository !== expected.repository ||
metadata.tag !== expected.tag ||
metadata.assetName !== expected.assetName ||
metadata.binarySha256 !== (await sha256File(config.binaryPath))
) {
return null;
}
if (!(await exactVersionMatches(config.binaryPath, config.version))) {
return null;
}
return verifiedBinary(config, metadata);
} catch {
return null;
} finally {
await metadataHandle?.close().catch(() => {});
}
};
export async function findCachedMoveFlowBinary(
env: NodeJS.ProcessEnv = process.env,
): Promise<VerifiedMoveFlowBinary | null> {
try {
const config = await getMoveFlowInstallConfig(env);
if (!config) return null;
return validCachedInstall(config);
} catch {
return null;
}
}
const extractZip = async (archive: string, destination: string): Promise<void> => {
await mkdir(destination, { recursive: true });
try {
if (process.platform === 'win32') {
const shell = process.env.ComSpec ? 'powershell.exe' : 'powershell';
const invocation = powershellExpandArchiveInvocation(archive, destination);
await execFileOutput(shell, invocation.args, { timeout: 30_000, env: invocation.env });
return;
}
await execFileOutput('unzip', ['-q', archive, '-d', destination], { timeout: 30_000 });
} catch (error) {
throw new Error(
`could not extract ${path.basename(archive)} (${error instanceof Error ? error.message : String(error)}). ` +
'Install unzip, or set MOVE_FLOW to an existing move-flow binary.',
);
}
};
const findExtractedBinary = async (root: string, binaryName: string): Promise<string | null> => {
const stack = [root];
const names = new Set([binaryName, 'move-flow']);
const candidates: string[] = [];
while (stack.length > 0) {
const current = stack.pop();
if (!current) break;
const entries: Dirent[] = await readdir(current, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) stack.push(full);
else if (entry.isFile() && names.has(entry.name)) candidates.push(full);
}
}
if (candidates.length !== 1) return null;
const candidate = candidates[0];
const binaryStat = await lstat(candidate);
return binaryStat.isFile() && binaryStat.size > 0 && binaryStat.size <= MAX_BINARY_BYTES
? candidate
: null;
};
export async function installMoveFlow(
env: NodeJS.ProcessEnv = process.env,
): Promise<MoveFlowInstallResult> {
// GITNEXUS_SKIP_OPTIONAL_GRAMMARS is the umbrella opt-out for all optional
// binary downloads (honored by the retired postinstall probe too).
for (const flag of ['GITNEXUS_SKIP_MOVE_FLOW', 'GITNEXUS_SKIP_OPTIONAL_GRAMMARS'] as const) {
if (env[flag] === '1') {
return { status: 'skipped', message: `installation disabled by ${flag}=1` };
}
}
let config: MoveFlowInstallConfig | null;
try {
config = await getMoveFlowInstallConfig(env);
} catch (error) {
return {
status: 'failed',
message: error instanceof Error ? error.message : String(error),
};
}
if (!config) {
return {
status: 'unsupported',
message: `unsupported platform ${process.platform}-${process.arch}; set MOVE_FLOW to provide a binary`,
};
}
const cached = await validCachedInstall(config);
if (cached) {
return { status: 'available', binary: cached };
}
let releaseLock: (() => Promise<void>) | undefined;
let tempDir: string | undefined;
let stagedDir: string | undefined;
try {
releaseLock = await acquireInstallLock(config.lockPath, {
waitTimeoutMs: moveFlowInstallLockWaitMs(config.httpTimeoutMs),
});
const published = await validCachedInstall(config);
if (published) {
return { status: 'available', binary: published };
}
tempDir = await mkdtemp(path.join(os.tmpdir(), 'move-flow-'));
const archivePath = path.join(tempDir, config.assetName);
const sumsPath = path.join(tempDir, 'SHA256SUMS');
const extractDir = path.join(tempDir, 'extract');
await downloadToFile(
`${config.releaseBase}/SHA256SUMS`,
sumsPath,
config.httpTimeoutMs,
httpsGet,
MAX_CHECKSUM_BYTES,
);
await downloadToFile(
`${config.releaseBase}/${config.assetName}`,
archivePath,
config.httpTimeoutMs,
httpsGet,
MAX_ARCHIVE_BYTES,
);
const verification = await verifyArchiveChecksum(
await readFile(sumsPath, 'utf8'),
config.assetName,
archivePath,
);
if (verification.status === 'missing') {
return {
status: 'failed',
message: `SHA256SUMS does not list ${config.assetName}; refusing to install`,
};
}
if (verification.status === 'mismatch') {
return {
status: 'failed',
message: `checksum mismatch for ${config.assetName}; refusing to install`,
};
}
await extractZip(archivePath, extractDir);
const extracted = await findExtractedBinary(extractDir, config.binaryName);
if (!extracted) {
return {
status: 'failed',
message: `${config.assetName} did not contain ${config.binaryName}; refusing to install`,
};
}
await mkdir(path.dirname(config.installDir), { recursive: true });
stagedDir = await mkdtemp(`${config.installDir}.partial-`);
const stagedBinary = path.join(stagedDir, config.binaryName);
await copyFile(extracted, stagedBinary);
if (process.platform !== 'win32') await chmod(stagedBinary, 0o755);
if (!(await exactVersionMatches(stagedBinary, config.version))) {
return {
status: 'failed',
message: `installed binary did not report exact version ${config.version}; refusing to keep it`,
};
}
const metadata: MoveFlowCacheMetadata = {
schemaVersion: 1,
...expectedMetadata(config),
archiveSha256: verification.actual,
binarySha256: await sha256File(stagedBinary),
};
await writeFile(path.join(stagedDir, 'release.json'), JSON.stringify(metadata, null, 2));
await rm(config.installDir, { recursive: true, force: true });
await rename(stagedDir, config.installDir);
stagedDir = undefined;
return { status: 'installed', binary: verifiedBinary(config, metadata) };
} catch (error) {
return {
status: 'failed',
message: `installation failed: ${error instanceof Error ? error.message : String(error)}`,
};
} finally {
await Promise.allSettled([
tempDir && rm(tempDir, { recursive: true, force: true }),
stagedDir && rm(stagedDir, { recursive: true, force: true }),
releaseLock?.(),
]);
}
}

View file

@ -8,10 +8,8 @@
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';
import { isCompatibleMoveFlowVersion } from './release.js';
interface JsonRpcResponse {
jsonrpc: '2.0';
@ -195,6 +193,30 @@ export class MoveFlowMcpClient implements MoveFlowClient {
this.binaryPath = binaryPath || process.env.MOVE_FLOW || 'move-flow';
}
private failProcess(
proc: ChildProcessWithoutNullStreams,
error: Error,
killProcess = true,
): void {
if (this.proc !== proc) return;
this.proc = null;
this.initialized = false;
this.initPromise = null;
this.capsPromise = null;
for (const pending of this.pending.values()) {
clearTimeout(pending.timeout);
pending.reject(error);
}
this.pending.clear();
if (killProcess) {
try {
proc.kill();
} catch {
/* process may already be dead */
}
}
}
private async ensureStarted(): Promise<void> {
if (this.initialized) return;
if (this.initPromise) return this.initPromise;
@ -238,42 +260,10 @@ export class MoveFlowMcpClient implements MoveFlowClient {
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 */
}
}
this.failProcess(proc, err, killProcess);
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);
@ -295,6 +285,9 @@ export class MoveFlowMcpClient implements MoveFlowClient {
if (this.stderrLines.length > MoveFlowMcpClient.MAX_STDERR) {
this.stderrLines.shift();
}
const wrapped = new Error(`move-flow stdin failed: ${err.message}${this.stderrContext()}`);
if (!initSettled) failInitialization(wrapped);
else this.failProcess(proc, wrapped);
});
proc.on('error', (err) => {
@ -304,7 +297,7 @@ export class MoveFlowMcpClient implements MoveFlowClient {
if (!initSettled) {
failInitialization(wrapped);
} else {
failRunningProcess(wrapped, true);
this.failProcess(proc, wrapped);
}
});
@ -317,13 +310,16 @@ export class MoveFlowMcpClient implements MoveFlowClient {
);
return;
}
failRunningProcess(
this.failProcess(
proc,
new Error(`move-flow exited unexpectedly (code ${code})${this.stderrContext()}`),
false,
);
});
rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
rl.on('line', (line) => {
if (this.proc !== proc) return;
if (!line.trim()) return;
try {
const msg = JSON.parse(line) as JsonRpcResponse;
@ -395,71 +391,66 @@ export class MoveFlowMcpClient implements MoveFlowClient {
this.proc.stdin.write(JSON.stringify(msg) + '\n');
}
private async callTool(toolName: string, args: Record<string, unknown>): Promise<unknown> {
private async request(
method: string,
params: unknown,
timeoutMs: number,
timeoutMessage: string,
): Promise<unknown> {
await this.ensureStarted();
const proc = this.proc;
if (!proc) throw new Error('move-flow MCP server is not running');
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)',
),
);
this.failProcess(proc, new Error(timeoutMessage));
}, 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);
}
resolve(result);
},
reject: (err) => {
reject: (error) => {
clearTimeout(timeout);
reject(err);
reject(error);
},
timeout,
});
this.send({
jsonrpc: '2.0',
id,
method: 'tools/call',
params: { name: toolName, arguments: args },
});
try {
this.send({ jsonrpc: '2.0', id, method, params });
} catch (error) {
this.failProcess(proc, error instanceof Error ? error : new Error(String(error)));
}
});
}
private async callTool(toolName: string, args: Record<string, unknown>): Promise<unknown> {
const timeoutMs = resolveMoveFlowToolTimeoutMs();
const result = await this.request(
'tools/call',
{ name: toolName, arguments: args },
timeoutMs,
`move-flow '${toolName}' timed out after ${timeoutMs}ms ` +
'(raise GITNEXUS_MOVE_FLOW_TIMEOUT_MS for large packages)',
);
if (!isMcpCallToolResult(result)) return result;
if (result.isError === true) {
const text =
typeof result.content?.[0]?.text === 'string'
? result.content[0].text
: `move-flow '${toolName}' reported an unspecified tool error`;
throw new MoveFlowToolCallError(text);
}
if (!result.content?.[0]?.text) return result;
try {
return JSON.parse(result.content[0].text);
} catch {
return result.content[0].text;
}
}
async callGraph(packagePath: string): Promise<CallGraphMap> {
return (await this.callTool('move_package_query', {
package_path: packagePath,
@ -490,109 +481,69 @@ export class MoveFlowMcpClient implements MoveFlowClient {
/** Raw JSON-RPC request (non-`tools/call`), e.g. `tools/list`. */
private async rpcRequest(method: string, params?: unknown): Promise<unknown> {
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 });
});
return this.request(method, params, 30_000, `move-flow '${method}' timed out after 30s`);
}
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;
const probe = this.rpcRequest('tools/list', {}).then((listed) => {
const tools = (isMcpListToolResult(listed) ? (listed.tools ?? []) : []) as MoveFlowToolInfo[];
return detectMoveFlowCapabilities(tools);
});
this.capsPromise = probe;
void probe.catch(() => {
if (this.capsPromise === probe) this.capsPromise = null;
});
return probe;
}
async shutdown(): Promise<void> {
const shutdownError = new Error('move-flow client shutdown');
for (const [, p] of this.pending) {
clearTimeout(p.timeout);
p.reject(shutdownError);
const proc = this.proc;
if (proc) {
proc.stdin?.end();
this.failProcess(proc, new Error('move-flow client shutdown'));
} else {
this.initialized = false;
this.initPromise = null;
this.capsPromise = null;
}
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 {
function probeBinary(binary: string): string | null {
try {
execFileSync(binary, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
const output = execFileSync(binary, ['--version'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
});
// Prerelease/build suffixes ("2.1.0-rc1") are accepted; only the major
// version gates protocol compatibility, and it follows the release pin.
const version = /(?:^|\s)v?((\d+)\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)(?:\s|$)/.exec(output);
return version !== null && isCompatibleMoveFlowVersion(version[1]) ? version[1] : null;
} catch {
return false;
return null;
}
}
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;
export interface ResolvedMoveFlowClient {
client: MoveFlowMcpClient;
version: string;
}
/** Construct a client for a binary that was already verified by the installer. */
export const createMoveFlowClient = (binaryPath: string): MoveFlowMcpClient =>
new MoveFlowMcpClient(binaryPath);
/**
* Probe and resolve a move-flow candidate exactly once, returning null when
* the binary is missing or reports an incompatible major version. When
* `binaryPath` is provided only that path is probed; otherwise resolution is
* `$MOVE_FLOW` (explicit override), then `move-flow` on `$PATH`.
*/
export function tryResolveMoveFlowClient(binaryPath?: string): ResolvedMoveFlowClient | null {
const binary = binaryPath || process.env.MOVE_FLOW || 'move-flow';
const version = probeBinary(binary);
return version ? { client: createMoveFlowClient(binary), version } : null;
}

View file

@ -0,0 +1,156 @@
import { createHash } from 'node:crypto';
import type { MoveCompilerIdentity } from './constants.js';
import {
createMoveFlowClient,
tryResolveMoveFlowClient,
type MoveFlowMcpClient,
type ResolvedMoveFlowClient,
} from './mcp-client.js';
import {
findCachedMoveFlowBinary,
installMoveFlow,
type MoveFlowInstallResult,
type VerifiedMoveFlowBinary,
} from './install.js';
import { isCompatibleMoveFlowVersion, MOVE_FLOW_RELEASE } from './release.js';
const installAttempts = new WeakMap<
MoveFlowProvisionDependencies,
Promise<MoveFlowInstallResult>
>();
export interface ProvisionedMoveFlow {
client: MoveFlowMcpClient;
identity: MoveCompilerIdentity;
}
export interface MoveFlowProvisionOptions {
onLog?: (message: string) => void;
install?: boolean;
}
export interface MoveFlowProvisionDependencies {
resolveClient: (binaryPath?: string) => ResolvedMoveFlowClient | null;
createClient: (binaryPath: string) => MoveFlowMcpClient;
findCached: () => Promise<VerifiedMoveFlowBinary | null>;
install: () => Promise<MoveFlowInstallResult>;
}
const defaultDependencies: MoveFlowProvisionDependencies = {
resolveClient: tryResolveMoveFlowClient,
createClient: createMoveFlowClient,
findCached: findCachedMoveFlowBinary,
install: installMoveFlow,
};
const localIdentity = (
source: 'explicit' | 'path',
locator: string,
version: string,
): MoveCompilerIdentity => ({
version,
source,
fingerprint: createHash('sha256').update(`${source}\0${locator}\0${version}`).digest('hex'),
});
const verifiedRuntime = (
binary: VerifiedMoveFlowBinary,
dependencies: MoveFlowProvisionDependencies,
): ProvisionedMoveFlow | null =>
isCompatibleMoveFlowVersion(binary.version)
? {
client: dependencies.createClient(binary.binaryPath),
identity: {
version: binary.version,
source: 'release',
fingerprint: binary.fingerprint,
},
}
: null;
const getInstallAttempt = (
dependencies: MoveFlowProvisionDependencies,
): Promise<MoveFlowInstallResult> => {
const active = installAttempts.get(dependencies);
if (active) return active;
const created = Promise.resolve().then(dependencies.install);
installAttempts.set(dependencies, created);
void created.then(
(result) => {
if (result.binary && installAttempts.get(dependencies) === created) {
installAttempts.delete(dependencies);
}
},
() => {},
);
return created;
};
/** Resolve move-flow after the caller has already detected Move code. */
export async function ensureMoveFlowRuntime(
options: MoveFlowProvisionOptions = {},
dependencies: MoveFlowProvisionDependencies = defaultDependencies,
): Promise<ProvisionedMoveFlow | null> {
const explicitPath = process.env.MOVE_FLOW;
if (explicitPath) {
const resolved = dependencies.resolveClient(explicitPath);
if (resolved) {
return {
client: resolved.client,
identity: localIdentity('explicit', explicitPath, resolved.version),
};
}
options.onLog?.(
`MOVE_FLOW points to an unavailable binary (${explicitPath}); automatic installation was skipped.`,
);
return null;
}
const cached = await dependencies.findCached();
if (cached) {
const runtime = verifiedRuntime(cached, dependencies);
if (runtime) return runtime;
}
const existing = dependencies.resolveClient();
if (existing) {
// The locator must be stable across shells: $PATH itself differs between
// terminals/CI steps, and a fingerprint churn forces a full re-index.
return {
client: existing.client,
identity: localIdentity('path', 'move-flow', existing.version),
};
}
if (options.install === false) return null;
options.onLog?.(
`Move code detected; ensuring move-flow ${MOVE_FLOW_RELEASE.version} is available.`,
);
let result: MoveFlowInstallResult;
try {
result = await getInstallAttempt(dependencies);
} catch (err) {
options.onLog?.(
`move-flow installation failed: ${err instanceof Error ? err.message : String(err)}; ` +
'.move files will not be indexed.',
);
return null;
}
if (!result.binary) {
options.onLog?.(
`move-flow is unavailable${result.message ? `: ${result.message}` : ''}; ` +
'.move files will not be indexed.',
);
return null;
}
const runtime = verifiedRuntime(result.binary, dependencies);
if (!runtime) {
options.onLog?.(
`move-flow ${result.binary.version} is protocol-incompatible; .move files will not be indexed.`,
);
}
return runtime;
}

View file

@ -0,0 +1,12 @@
export const MOVE_FLOW_RELEASE = {
version: '2.0.0',
repository: 'aptos-labs/aptos-ai',
tagPrefix: 'move-flow-v',
} as const;
const COMPATIBLE_MAJOR = Number(MOVE_FLOW_RELEASE.version.split('.')[0]);
export const isCompatibleMoveFlowVersion = (version: string): boolean => {
const match = /^(\d+)\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.exec(version);
return match !== null && Number(match[1]) === COMPATIBLE_MAJOR;
};

View file

@ -13,10 +13,16 @@ import path from 'path';
import fs from 'fs/promises';
import { retryRename } from '../storage/fs-atomic.js';
import { runPipelineFromRepo } from './ingestion/pipeline.js';
import { isMoveCompilerInputPath, moveAvailabilityRequiresFullRebuild } from './move/constants.js';
import {
isMoveCompilerInputPath,
moveCompilerRequiresFullRebuild,
persistedMoveGraphRequiresCompiler,
shouldProvisionMoveFlowBeforeFastPath,
} 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 { getMoveFlowReleaseSelection } from './move/install.js';
import { ensureMoveFlowRuntime } from './move/provision.js';
import type { KnowledgeGraph } from './graph/types.js';
import { resetDegradedParseCounter } from './tree-sitter/safe-parse.js';
import {
@ -1067,22 +1073,52 @@ 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.
// Resolve local runtimes before the same-commit fast path. Managed
// provisioning is needed for a degraded/legacy graph and whenever the
// configured dynamic release differs from the one that built the index.
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.');
const desiredMoveFlowRelease = hasMovePackages ? await getMoveFlowReleaseSelection() : undefined;
const persistedMoveCompilerRequired = persistedMoveGraphRequiresCompiler(
existingMeta?.moveIngestAvailable,
existingMeta?.fileHashes,
);
const provisionBeforeFastPath = shouldProvisionMoveFlowBeforeFastPath(
hasMovePackages,
persistedMoveCompilerRequired,
existingMeta?.moveCompilerIdentity,
existingMeta?.moveFlowReleaseSelection,
desiredMoveFlowRelease,
);
let moveFlow = hasMovePackages
? await ensureMoveFlowRuntime({
onLog: log,
install: provisionBeforeFastPath,
})
: null;
let moveCompilerRebuildApplied = false;
const applyMoveCompilerRebuildPolicy = (): void => {
if (
!existingMeta ||
!moveCompilerRequiresFullRebuild(
hasMovePackages,
moveFlow?.identity,
existingMeta.moveIngestAvailable,
existingMeta.moveCompilerIdentity,
)
) {
return;
}
if (!moveCompilerRebuildApplied) {
log(
existingMeta.moveIngestAvailable === true
? 'Move compiler changed; forcing a full rebuild so persisted facts match it.'
: 'Move compiler became available; forcing a full rebuild to backfill Move symbols.',
);
}
moveCompilerRebuildApplied = true;
options = { ...options, force: true };
}
};
applyMoveCompilerRebuildPolicy();
// ── Early-return: already up to date ──────────────────────────────
if (
@ -1155,7 +1191,7 @@ export async function runFullAnalysis(
}
}
await ensureGitNexusIgnored(repoPath);
await moveFlowClient?.shutdown();
await moveFlow?.client.shutdown();
return {
// `resolveRepoIdentityRoot` collapses worktree roots to the
// canonical repo basename (#1259) but leaves arbitrary subdirs
@ -1173,6 +1209,23 @@ export async function runFullAnalysis(
}
}
// From this point onward analysis may write or derive graph data. Never run
// that plan without the compiler that produced an existing Move graph: a
// partial rebuild would silently erase Move rows or degrade global clusters.
if (hasMovePackages && !moveFlow) {
moveFlow = await ensureMoveFlowRuntime({ onLog: log });
if (persistedMoveCompilerRequired && !moveFlow) {
throw new Error(
'move-flow is unavailable; the existing Move graph was left unchanged. ' +
'Restore move-flow or set MOVE_FLOW, then retry analysis.',
);
}
}
const moveFlowClient = moveFlow?.client ?? null;
const moveIngestAvailable = hasMovePackages ? moveFlow !== null : undefined;
applyMoveCompilerRebuildPolicy();
// ── Cache embeddings from existing index before rebuild ────────────
// Four modes:
// --embeddings -> load cache, restore, then generate any new ones
@ -2329,6 +2382,13 @@ export async function runFullAnalysis(
const newFileHashesRecord: Record<string, string> = {};
for (const [k, v] of newFileHashes) newFileHashesRecord[k] = v;
// Incremental runs never rewrite Move facts (changed Move compiler inputs
// force a full rebuild), so when the compiler is transiently unavailable
// the previous record still describes the persisted facts. Stamping
// false/undefined here would force a needless full rebuild the moment the
// compiler returns.
const preserveMoveMeta = hasMovePackages && !moveFlow && isIncremental;
// Annotated so the capabilities stamp below is compile-checked against
// RepoMeta's status unions (tri-review 4669518496 P1/U3) — an unannotated
// literal widens the vectorSearch.status ternary to `string` and the
@ -2389,7 +2449,17 @@ export async function runFullAnalysis(
// absence, so this is never conditionally omitted.
cjkSegmentation: getSearchFTSCjkSegmentation(),
fileHashes: hasGitDir(repoPath) ? newFileHashesRecord : undefined,
moveIngestAvailable,
moveIngestAvailable: preserveMoveMeta
? existingMeta?.moveIngestAvailable
: moveIngestAvailable,
moveCompilerIdentity: preserveMoveMeta
? existingMeta?.moveCompilerIdentity
: moveFlow?.identity,
moveFlowReleaseSelection: preserveMoveMeta
? existingMeta?.moveFlowReleaseSelection
: moveFlow?.identity.source === 'release'
? desiredMoveFlowRelease
: undefined,
// This branch's full live chunk-key set (#2106 R6). `usedKeys` is every
// chunk hash touched in this scan — cache HITS included (see parse-impl
// usedKeys.add) — so it's complete even on an incremental run. Persisted

View file

@ -22,6 +22,7 @@ import { randomBytes } from 'crypto';
import { getInferredRepoName, resolveRepoIdentityRoot } from './git.js';
import { retryRename } from './fs-atomic.js';
import { logger } from '../core/logger.js';
import type { MoveCompilerIdentity, MoveFlowReleaseSelection } from '../core/move/constants.js';
import {
branchSlug,
BRANCHES_DIR,
@ -219,10 +220,14 @@ export interface RepoMeta {
*/
fileHashes?: Record<string, string>;
/**
* Whether compiler-backed Move ingestion was available for this index.
* Absent on legacy metadata and non-Move repositories.
* Whether the persisted Move facts are compiler-backed. Absent on legacy
* metadata and non-Move repositories.
*/
moveIngestAvailable?: boolean;
/** Compiler identity that produced the persisted Move facts. */
moveCompilerIdentity?: MoveCompilerIdentity;
/** Managed release coordinates, kept separate from the binary fingerprint. */
moveFlowReleaseSelection?: MoveFlowReleaseSelection;
/**
* Crash-recovery dirty flag — a generic marker written to the metadata
* file (gitnexus.json + its meta.json mirror) BEFORE any destructive DB

View file

@ -1,21 +1,34 @@
/**
* 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).
* Locally this suite skips when move-flow is unavailable. CI sets
* GITNEXUS_REQUIRE_MOVE_FLOW=1, which exercises on-demand provisioning and
* makes an unavailable release a hard failure. 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 { tryResolveMoveFlowClient } from '../../src/core/move/mcp-client.js';
import { createMoveIngestPhase } from '../../src/core/move/move-ingest.js';
import { ensureMoveFlowRuntime } from '../../src/core/move/provision.js';
import { runMoveIngestPhase } from '../helpers/move-ingest-harness.js';
const client = tryCreateMoveFlowClient();
const requireMoveFlow = process.env.GITNEXUS_REQUIRE_MOVE_FLOW === '1';
const client = requireMoveFlow
? (
await ensureMoveFlowRuntime({
onLog: (message) => console.info(`[move-live] ${message}`),
})
)?.client
: tryResolveMoveFlowClient()?.client;
if (requireMoveFlow && !client) {
throw new Error('GITNEXUS_REQUIRE_MOVE_FLOW=1 but MoveFlow provisioning failed');
}
const coinFixture = path.resolve(process.cwd(), 'test/fixtures/move/aptos-framework/coin');
function writePackage(root: string, name: string, addr: string, source: string): void {

View file

@ -0,0 +1,97 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import type { NodeLabel } from '../../src/core/graph/types.js';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import { buildTestGraph } from '../helpers/test-graph.js';
const sharedTables = [
{
table: 'Function',
otherLanguage: 'typescript',
detailProperty: 'isEntry',
moveDetail: true,
},
{ table: 'Struct', otherLanguage: 'rust', detailProperty: 'isResource', moveDetail: true },
{ table: 'Enum', otherLanguage: 'rust', detailProperty: 'isEvent', moveDetail: true },
{
table: 'EnumVariant',
otherLanguage: 'rust',
detailProperty: 'parentEnum',
moveDetail: '0x1::sample::Choice',
},
{ table: 'Const', otherLanguage: 'typescript', detailProperty: 'isErrorCode', moveDetail: true },
{ table: 'Module', otherLanguage: 'python', detailProperty: 'moduleAddress', moveDetail: '0x1' },
] as const;
withTestLbugDB(
'move-mixed-language-roundtrip',
() => {
describe('mixed-language persistence', () => {
it('stores Move facts and safe defaults in every shared table', async () => {
const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');
for (const testCase of sharedTables) {
const rows = (await executeQuery(
`MATCH (n:\`${testCase.table}\`) ` +
`RETURN n.id AS id, n.language AS language, n.qualifiedName AS qualifiedName, ` +
`n.${testCase.detailProperty} AS detail ORDER BY n.id`,
)) as Array<Record<string, unknown>>;
expect(rows).toEqual([
{
id: `${testCase.table}:move`,
language: 'move',
qualifiedName: `0x1::sample::${testCase.table}`,
detail: testCase.moveDetail,
},
{
id: `${testCase.table}:${testCase.otherLanguage}`,
language: testCase.otherLanguage,
qualifiedName: null,
detail: typeof testCase.moveDetail === 'boolean' ? false : null,
},
]);
}
});
});
},
{
beforeFTS: async (dbPath) => {
const repoPath = path.join(path.dirname(dbPath), 'repo');
const storagePath = path.join(path.dirname(dbPath), 'storage');
await fs.mkdir(repoPath, { recursive: true });
const graph = buildTestGraph(
sharedTables.flatMap((testCase) => [
{
id: `${testCase.table}:move`,
label: testCase.table as NodeLabel,
name: testCase.table,
filePath: 'sources/sample.move',
startLine: 1,
endLine: 2,
isExported: testCase.table === 'Function',
extra: {
language: 'move',
qualifiedName: `0x1::sample::${testCase.table}`,
[testCase.detailProperty]: testCase.moveDetail,
},
},
{
id: `${testCase.table}:${testCase.otherLanguage}`,
label: testCase.table as NodeLabel,
name: testCase.table,
filePath: `src/sample.${testCase.otherLanguage}`,
startLine: 1,
endLine: 2,
isExported: testCase.table === 'Function',
extra: { language: testCase.otherLanguage },
},
]),
);
const { loadGraphToLbug } = await import('../../src/core/lbug/lbug-adapter.js');
await loadGraphToLbug(graph, repoPath, storagePath);
},
},
);

View file

@ -0,0 +1,173 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { closeLbug, executeQuery, initLbug } from '../../src/core/lbug/lbug-adapter.js';
import { getMoveFlowInstallConfig } from '../../src/core/move/install.js';
import { ensureMoveFlowRuntime } from '../../src/core/move/provision.js';
import { MOVE_FLOW_RELEASE } from '../../src/core/move/release.js';
import { runFullAnalysis } from '../../src/core/run-analyze.js';
import { getStoragePaths, loadMeta } from '../../src/storage/repo-manager.js';
import { createTempDir } from '../helpers/test-db.js';
const requireMoveFlow = process.env.GITNEXUS_REQUIRE_MOVE_FLOW === '1';
const skipMoveFlow = process.env.GITNEXUS_SKIP_MOVE_FLOW === '1';
const runtime =
!requireMoveFlow && skipMoveFlow
? null
: await ensureMoveFlowRuntime({
install: requireMoveFlow,
onLog: requireMoveFlow
? (message) => console.info(`[move-run-analyze] ${message}`)
: undefined,
});
if (requireMoveFlow && !runtime) {
throw new Error('GITNEXUS_REQUIRE_MOVE_FLOW=1 but MoveFlow provisioning failed');
}
const expectedCompilerIdentity = runtime?.identity;
await runtime?.client.shutdown();
const managedConfig =
expectedCompilerIdentity?.source === 'release' ? await getMoveFlowInstallConfig() : null;
const managedCacheRoot = managedConfig
? path.dirname(path.dirname(managedConfig.installDir))
: undefined;
const analysisOptions = {
skipAgentsMd: true,
skipSkills: true,
noStats: true,
workerPoolSize: 2,
} as const;
const analyze = (repoPath: string, force: boolean) =>
runFullAnalysis(repoPath, { ...analysisOptions, force }, { onProgress: () => {} });
interface GraphSnapshot {
nodes: Array<Record<string, unknown>>;
relationships: Array<Record<string, unknown>>;
}
const queryGraphSnapshot = async (): Promise<GraphSnapshot> => ({
nodes: (await executeQuery(
'MATCH (n) RETURN labels(n) AS label, n AS node ORDER BY n.id',
)) as Array<Record<string, unknown>>,
relationships: (await executeQuery(
'MATCH (source)-[relation:CodeRelation]->(target) ' +
'RETURN source.id AS sourceId, target.id AS targetId, relation.type AS type, ' +
'relation.confidence AS confidence, relation.reason AS reason, relation.step AS step ' +
'ORDER BY sourceId, targetId, type, reason, step',
)) as Array<Record<string, unknown>>,
});
const readGraphSnapshot = async (lbugPath: string): Promise<GraphSnapshot> => {
await initLbug(lbugPath);
try {
return await queryGraphSnapshot();
} finally {
await closeLbug();
}
};
const initializeGitRepo = (repoPath: string): void => {
execFileSync('git', ['init', '-q'], { cwd: repoPath });
execFileSync('git', ['add', '.'], { cwd: repoPath });
execFileSync(
'git',
[
'-c',
'user.name=GitNexus Test',
'-c',
'user.email=test@gitnexus.local',
'-c',
'commit.gpgsign=false',
'commit',
'-q',
'-m',
'initial',
],
{ cwd: repoPath },
);
};
describe.skipIf(!expectedCompilerIdentity)('Move runFullAnalysis persistence', () => {
it('preserves a real compiler-backed graph when the compiler disappears', async () => {
const repo = await createTempDir('gitnexus-move-run-analyze-');
const home = await createTempDir('gitnexus-move-run-analyze-home-');
try {
vi.stubEnv('GITNEXUS_HOME', home.dbPath);
if (managedCacheRoot) vi.stubEnv('GITNEXUS_MOVE_FLOW_DIR', managedCacheRoot);
vi.stubEnv('GITNEXUS_LBUG_EXTENSION_INSTALL', 'never');
await fs.mkdir(path.join(repo.dbPath, 'sources'), { recursive: true });
await fs.writeFile(
path.join(repo.dbPath, 'Move.toml'),
'[package]\nname = "live_e2e"\nversion = "1.0.0"\n\n[addresses]\nlive = "0x42"\n',
);
await fs.writeFile(
path.join(repo.dbPath, 'sources', 'main.move'),
'module live::main { public entry fun start(_account: &signer) {} }\n',
);
await fs.writeFile(
path.join(repo.dbPath, 'helper.ts'),
'export function helper(): number { return 1; }\n',
);
initializeGitRepo(repo.dbPath);
await analyze(repo.dbPath, true);
const { storagePath, lbugPath } = getStoragePaths(repo.dbPath);
const meta = await loadMeta(storagePath);
expect(meta?.moveIngestAvailable).toBe(true);
expect(meta?.moveCompilerIdentity).toEqual(expectedCompilerIdentity);
if (requireMoveFlow) {
expect(meta?.moveCompilerIdentity?.version).toBe(MOVE_FLOW_RELEASE.version);
}
let graphBeforeFailure: GraphSnapshot;
await initLbug(lbugPath);
try {
await expect(
executeQuery(
"MATCH (f:Function) WHERE f.qualifiedName = '0x42::main::start' " +
'RETURN f.name AS name, f.isEntry AS isEntry',
),
).resolves.toEqual([{ name: 'start', isEntry: true }]);
await expect(
executeQuery(
"MATCH (f:Function) WHERE f.name = 'helper' " +
'RETURN f.language AS language, f.qualifiedName AS qualifiedName',
),
).resolves.toEqual([{ language: 'typescript', qualifiedName: null }]);
await expect(
executeQuery(
"MATCH (m:Module)-[r:CodeRelation]->(f:Function) WHERE r.type = 'DEFINES' " +
"AND f.qualifiedName = '0x42::main::start' " +
'RETURN m.qualifiedName AS module, r.type AS type',
),
).resolves.toEqual([{ module: '0x42::main', type: 'DEFINES' }]);
await expect(
executeQuery(
'MATCH (n) WITH n.id AS id, count(n) AS copies ' + 'WHERE copies > 1 RETURN id, copies',
),
).resolves.toEqual([]);
graphBeforeFailure = await queryGraphSnapshot();
} finally {
await closeLbug();
}
vi.stubEnv('MOVE_FLOW', path.join(repo.dbPath, 'missing-move-flow'));
await fs.appendFile(path.join(repo.dbPath, 'helper.ts'), '// compiler unavailable\n');
for (const force of [false, true]) {
await expect(analyze(repo.dbPath, force)).rejects.toThrow(
'move-flow is unavailable; the existing Move graph was left unchanged',
);
expect(await loadMeta(storagePath)).toEqual(meta);
expect(await readGraphSnapshot(lbugPath)).toEqual(graphBeforeFailure);
}
} finally {
vi.unstubAllEnvs();
await repo.cleanup();
await home.cleanup();
}
}, 180_000);
});

View file

@ -117,10 +117,11 @@ describe('CLI commands', () => {
'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.
// The package must never ship a blanket vendor/ entry — grammars are
// enumerated individually, and per-platform artifacts (e.g. the managed
// move-flow binary, now cached under ~/.gitnexus/tools) stay out of 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 () => {

View file

@ -0,0 +1,35 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('glob', () => ({ globIterate: vi.fn() }));
import { globIterate } from 'glob';
import { repoHasMove } from '../../../src/core/move/discovery.js';
const mockGlobIterate = vi.mocked(globIterate);
const results = async function* (values: string[]): AsyncGenerator<string> {
yield* values;
};
describe('repoHasMove', () => {
beforeEach(() => {
mockGlobIterate.mockReset();
});
it('distinguishes an empty repository from a discovery failure', async () => {
mockGlobIterate.mockReturnValueOnce(results([])).mockReturnValueOnce(
(async function* () {
throw new Error('permission denied');
})(),
);
await expect(repoHasMove('/repo')).resolves.toBe(false);
await expect(repoHasMove('/repo')).rejects.toThrow('permission denied');
});
it('reports Move when a manifest is found', async () => {
mockGlobIterate.mockReturnValue(results(['contracts/Move.toml']));
await expect(repoHasMove('/repo')).resolves.toBe(true);
});
});

View file

@ -1,9 +1,30 @@
import { describe, expect, it } from 'vitest';
import {
isMoveCompilerInputPath,
moveAvailabilityRequiresFullRebuild,
moveCompilerRequiresFullRebuild,
persistedMoveGraphRequiresCompiler,
shouldProvisionMoveFlowBeforeFastPath,
type MoveCompilerIdentity,
type MoveFlowReleaseSelection,
} from '../../../src/core/move/constants.js';
const compiler = (
version = '2.0.0',
source: MoveCompilerIdentity['source'] = 'release',
fingerprint = 'sha-2.0.0',
): MoveCompilerIdentity => ({ version, source, fingerprint });
const release = (
version = '2.0.0',
repository = 'aptos-labs/aptos-ai',
tag = `move-flow-v${version}`,
): MoveFlowReleaseSelection => ({
version,
repository,
tag,
assetName: `${tag}-aarch64-apple-darwin.zip`,
});
describe('Move incremental safety', () => {
it.each([
'sources/coin.move',
@ -21,11 +42,59 @@ describe('Move incremental safety', () => {
},
);
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);
it('rebuilds when compiler-backed facts are missing or their producer changes', () => {
expect(moveCompilerRequiresFullRebuild(true, compiler(), undefined, undefined)).toBe(true);
expect(moveCompilerRequiresFullRebuild(true, compiler(), false, undefined)).toBe(true);
expect(moveCompilerRequiresFullRebuild(true, compiler(), true, undefined)).toBe(true);
expect(moveCompilerRequiresFullRebuild(true, compiler(), true, compiler())).toBe(false);
expect(moveCompilerRequiresFullRebuild(true, compiler('2.1.0'), true, compiler())).toBe(true);
expect(
moveCompilerRequiresFullRebuild(true, compiler('2.0.0', 'explicit'), true, compiler()),
).toBe(true);
expect(
moveCompilerRequiresFullRebuild(
true,
compiler('2.0.0', 'release', 'new-binary'),
true,
compiler(),
),
).toBe(true);
expect(moveCompilerRequiresFullRebuild(true, undefined, true, compiler())).toBe(false);
expect(moveCompilerRequiresFullRebuild(false, compiler(), undefined, undefined)).toBe(false);
});
it('recognizes compiler-backed legacy metadata from recorded Move inputs', () => {
expect(persistedMoveGraphRequiresCompiler(true, undefined)).toBe(true);
expect(persistedMoveGraphRequiresCompiler(false, { 'Move.toml': 'hash' })).toBe(false);
expect(persistedMoveGraphRequiresCompiler(undefined, { 'Move.toml': 'hash' })).toBe(true);
expect(persistedMoveGraphRequiresCompiler(undefined, { 'src/main.ts': 'hash' })).toBe(false);
});
it('provisions before the fast path when a managed release selection changes', () => {
const current = release();
expect(shouldProvisionMoveFlowBeforeFastPath(true, true, compiler(), current, current)).toBe(
false,
);
expect(
shouldProvisionMoveFlowBeforeFastPath(true, true, compiler(), current, release('2.1.0')),
).toBe(true);
expect(
shouldProvisionMoveFlowBeforeFastPath(
true,
true,
compiler(),
current,
release('2.0.0', 'fork/move-flow', 'custom-v2'),
),
).toBe(true);
expect(
shouldProvisionMoveFlowBeforeFastPath(
true,
true,
compiler('2.0.0', 'explicit'),
undefined,
current,
),
).toBe(false);
});
});

View file

@ -1,55 +1,46 @@
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 {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
utimesSync,
writeFileSync,
} from 'node:fs';
import type { FileHandle } from 'node:fs/promises';
import type { ClientRequest, IncomingMessage } from 'node:http';
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 {
import { PassThrough } from 'node:stream';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
downloadToFile,
expectedSha,
sha256,
sha256File,
verifyArchiveChecksum,
} from '../../../src/core/move/artifact-download.js';
import {
acquireInstallLock,
moveFlowInstallLockWaitMs,
} from '../../../src/core/move/install-lock.js';
import {
findCachedMoveFlowBinary,
getMoveFlowInstallConfig,
installMoveFlow,
powershellExpandArchiveInvocation,
} = require('../../../scripts/install-move-flow.cjs') as InstallerHelpers;
reportedMoveFlowVersion,
} from '../../../src/core/move/install.js';
const tempRoots: string[] = [];
afterEach(() => {
for (const root of tempRoots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true });
});
describe('install-move-flow', () => {
describe('move-flow installer', () => {
const assetName = 'move-flow-v2.0.0-x86_64-unknown-linux-gnu.zip';
function writeArchive(contents = 'verified move-flow archive'): string {
@ -60,51 +51,50 @@ describe('install-move-flow', () => {
return archive;
}
it('accepts an exact asset entry whose checksum matches the downloaded archive', () => {
it('accepts only an exact asset entry whose checksum matches', async () => {
const archive = writeArchive();
const digest = sha256(archive);
const digest = await sha256File(archive);
const sums = `${digest.toUpperCase()} ${assetName}\n`;
expect(expectedSha(sums, assetName)).toBe(digest);
expect(verifyArchiveChecksum(sums, assetName, archive)).toEqual({
await expect(verifyArchiveChecksum(sums, assetName, archive)).resolves.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);
expect(expectedSha(`${digest} prefixed-${assetName}`, assetName)).toBeNull();
});
it('reports a checksum mismatch instead of authorizing the archive', () => {
it('rejects a checksum mismatch', async () => {
const archive = writeArchive('tampered archive');
const expected = '0'.repeat(64);
const actual = sha256(archive);
const actual = await sha256File(archive);
expect(verifyArchiveChecksum(`${expected} ${assetName}`, assetName, archive)).toEqual({
status: 'mismatch',
expected,
actual,
});
await expect(
verifyArchiveChecksum(`${expected} ${assetName}`, assetName, archive),
).resolves.toEqual({ status: 'mismatch', expected, actual });
});
it('treats a suffix-collision entry as an omitted asset', () => {
it('rejects a checksum manifest that omits the selected asset', async () => {
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' });
await expect(
verifyArchiveChecksum(`${'0'.repeat(64)} another-asset.zip`, assetName, archive),
).resolves.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();
const destination = path.join(root, 'move-flow.zip');
const get = vi.fn((_url, _options, onResponse) => {
const request = new EventEmitter() as ClientRequest;
request.destroy = vi.fn((error?: Error) => {
if (error) request.emit('error', error);
return request;
});
queueMicrotask(() => {
const response = new PassThrough() as DownloadResponse;
const response = new PassThrough() as IncomingMessage;
response.statusCode = 200;
response.headers = {};
onResponse(response);
@ -114,24 +104,411 @@ describe('install-move-flow', () => {
return request;
});
await expect(downloadToFile('https://example.test/move-flow.zip', dest, get)).rejects.toThrow(
'connection reset during download',
await expect(
downloadToFile('https://example.test/move-flow.zip', destination, 1_000, get),
).rejects.toThrow('connection reset during download');
expect(existsSync(destination)).toBe(false);
expect(existsSync(`${destination}.partial`)).toBe(false);
});
it('settles the response pipeline before cleaning up a request error after headers', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-request-race-'));
tempRoots.push(root);
const destination = path.join(root, 'move-flow.zip');
let response!: PassThrough & IncomingMessage;
const get = vi.fn((_url, _options, onResponse) => {
const request = new EventEmitter() as ClientRequest;
request.destroy = vi.fn();
queueMicrotask(() => {
response = new PassThrough() as PassThrough & IncomingMessage;
response.statusCode = 200;
response.headers = {};
onResponse(response);
response.write('partial archive');
request.emit('error', new Error('request failed after headers'));
request.emit('close');
});
return request;
});
await expect(
downloadToFile('https://example.test/move-flow.zip', destination, 1_000, get),
).rejects.toThrow('request failed after headers');
expect(response.destroyed).toBe(true);
expect(get).toHaveBeenCalledOnce();
expect(existsSync(destination)).toBe(false);
expect(existsSync(`${destination}.partial`)).toBe(false);
});
it.each([
['declared', { 'content-length': '17' }],
['streamed', {}],
])('rejects %s downloads that exceed the byte limit', async (kind, headers) => {
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-download-limit-'));
tempRoots.push(root);
const destination = path.join(root, 'move-flow.zip');
let response!: PassThrough & IncomingMessage;
const get = vi.fn((_url, _options, onResponse) => {
const request = new EventEmitter() as ClientRequest;
request.destroy = vi.fn();
queueMicrotask(() => {
response = new PassThrough() as PassThrough & IncomingMessage;
response.statusCode = 200;
response.headers = headers;
onResponse(response);
response.end('sixteen-byte-body');
});
return request;
});
await expect(
downloadToFile('https://example.test/move-flow.zip', destination, 1_000, get, 8),
).rejects.toThrow('download exceeds 8 bytes');
if (kind === 'declared') expect(response.destroyed).toBe(true);
expect(existsSync(destination)).toBe(false);
expect(existsSync(`${destination}.partial`)).toBe(false);
});
it('retries transient HTTP failures within the same deadline', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-download-retry-'));
tempRoots.push(root);
const destination = path.join(root, 'move-flow.zip');
let attempt = 0;
const get = vi.fn((_url, _options, onResponse) => {
const request = new EventEmitter() as ClientRequest;
request.destroy = vi.fn((error?: Error) => {
if (error) request.emit('error', error);
return request;
});
queueMicrotask(() => {
const response = new PassThrough() as IncomingMessage;
response.statusCode = attempt++ === 0 ? 503 : 200;
response.headers = {};
onResponse(response);
response.end(response.statusCode === 200 ? 'verified archive' : undefined);
request.emit('close');
});
return request;
});
await expect(
downloadToFile('https://example.test/move-flow.zip', destination, 1_000, get),
).resolves.toBeUndefined();
expect(get).toHaveBeenCalledTimes(2);
expect(readFileSync(destination, 'utf8')).toBe('verified archive');
});
it('rejects redirects that downgrade HTTPS', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-download-redirect-'));
tempRoots.push(root);
const destination = path.join(root, 'move-flow.zip');
const get = vi.fn((_url, _options, onResponse) => {
const request = new EventEmitter() as ClientRequest;
request.destroy = vi.fn();
queueMicrotask(() => {
const response = new PassThrough() as IncomingMessage;
response.statusCode = 302;
response.headers = { location: 'http://example.test/insecure.zip' };
onResponse(response);
request.emit('close');
});
return request;
});
await expect(
downloadToFile('https://example.test/move-flow.zip', destination, 1_000, get),
).rejects.toThrow('refusing non-HTTPS redirect');
expect(get).toHaveBeenCalledOnce();
});
it('rejects an initial non-HTTPS artifact URL before opening a request', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-download-http-'));
tempRoots.push(root);
const get = vi.fn();
await expect(
downloadToFile(
'http://example.test/move-flow.zip',
path.join(root, 'move-flow.zip'),
1_000,
get,
),
).rejects.toThrow('downloads require HTTPS');
expect(get).not.toHaveBeenCalled();
});
it.skipIf(process.platform === 'win32')(
'rejects a hash-valid cache whose execute bit was lost',
async () => {
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-non-executable-'));
tempRoots.push(root);
const env = {
...process.env,
GITNEXUS_HOME: root,
GITNEXUS_SKIP_MOVE_FLOW: '0',
};
const config = await getMoveFlowInstallConfig(env);
expect(config).not.toBeNull();
if (!config) throw new Error('expected a supported platform');
mkdirSync(config.installDir, { recursive: true });
writeFileSync(config.binaryPath, 'cached move-flow');
chmodSync(config.binaryPath, 0o644);
writeFileSync(
config.metadataPath,
JSON.stringify({
version: config.version,
repository: config.repository,
tag: config.tag,
assetName: config.assetName,
binarySha256: await sha256File(config.binaryPath),
}),
);
await expect(findCachedMoveFlowBinary(env)).resolves.toBeNull();
},
);
it.skipIf(process.platform === 'win32')(
'returns the verified descriptor for a live verified cache',
async () => {
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-valid-cache-'));
tempRoots.push(root);
const env = { ...process.env, GITNEXUS_HOME: root, GITNEXUS_SKIP_MOVE_FLOW: '0' };
const config = await getMoveFlowInstallConfig(env);
if (!config) throw new Error('expected a supported platform');
mkdirSync(config.installDir, { recursive: true });
writeFileSync(config.binaryPath, '#!/bin/sh\nprintf "move-flow 2.0.0\\n"\n');
chmodSync(config.binaryPath, 0o755);
writeFileSync(
config.metadataPath,
JSON.stringify({
schemaVersion: 1,
version: config.version,
repository: config.repository,
tag: config.tag,
assetName: config.assetName,
archiveSha256: '0'.repeat(64),
binarySha256: await sha256File(config.binaryPath),
}),
);
await expect(findCachedMoveFlowBinary(env)).resolves.toMatchObject({
binaryPath: config.binaryPath,
version: '2.0.0',
});
},
);
it.skipIf(process.platform === 'win32')('rejects a symlinked cached binary', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-symlink-cache-'));
tempRoots.push(root);
const env = { ...process.env, GITNEXUS_HOME: root, GITNEXUS_SKIP_MOVE_FLOW: '0' };
const config = await getMoveFlowInstallConfig(env);
if (!config) throw new Error('expected a supported platform');
mkdirSync(config.installDir, { recursive: true });
const target = path.join(root, 'move-flow-target');
writeFileSync(target, '#!/bin/sh\nprintf "move-flow 2.0.0\\n"\n');
chmodSync(target, 0o755);
symlinkSync(target, config.binaryPath);
writeFileSync(
config.metadataPath,
JSON.stringify({
schemaVersion: 1,
version: config.version,
repository: config.repository,
tag: config.tag,
assetName: config.assetName,
archiveSha256: '0'.repeat(64),
binarySha256: await sha256File(target),
}),
);
expect(existsSync(dest)).toBe(false);
expect(existsSync(`${dest}.partial`)).toBe(false);
await expect(findCachedMoveFlowBinary(env)).resolves.toBeNull();
});
it('rejects oversized cache metadata before parsing it', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-large-metadata-'));
tempRoots.push(root);
const env = { ...process.env, GITNEXUS_HOME: root, GITNEXUS_SKIP_MOVE_FLOW: '0' };
const config = await getMoveFlowInstallConfig(env);
if (!config) throw new Error('expected a supported platform');
mkdirSync(config.installDir, { recursive: true });
writeFileSync(config.metadataPath, 'x'.repeat(65 * 1024));
await expect(findCachedMoveFlowBinary(env)).resolves.toBeNull();
});
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 destination = `C:\\temp\\destination's folder`;
const invocation = powershellExpandArchiveInvocation(archive, destination);
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(command).not.toContain(destination);
expect(invocation.env.GITNEXUS_MOVE_FLOW_ARCHIVE_PATH).toBe(archive);
expect(invocation.env.GITNEXUS_MOVE_FLOW_DESTINATION_PATH).toBe(dest);
expect(invocation.env.GITNEXUS_MOVE_FLOW_DESTINATION_PATH).toBe(destination);
});
it('uses an artifact-identified versioned user cache', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-home-'));
tempRoots.push(root);
const config = await getMoveFlowInstallConfig({
...process.env,
GITNEXUS_HOME: root,
GITNEXUS_MOVE_FLOW_COMPAT: '1',
});
expect(config).not.toBeNull();
expect(config?.installDir.startsWith(path.join(root, 'tools', 'move-flow', '2.0.0'))).toBe(
true,
);
expect(path.basename(config?.installDir ?? '')).toMatch(
/^(linux-x64|linux-arm64|darwin-arm64|darwin-x64|win32-x64)-[0-9a-f]{12}$/,
);
if (process.platform === 'linux') expect(config?.releaseTarget).toContain('compat');
});
it('keeps compatible release coordinates dynamic', async () => {
const config = await getMoveFlowInstallConfig({
...process.env,
GITNEXUS_MOVE_FLOW_VERSION: '2.7.9',
GITNEXUS_MOVE_FLOW_REPO: 'example/move-flow',
GITNEXUS_MOVE_FLOW_TAG: 'release-2.7.9',
});
expect(config).toMatchObject({
version: '2.7.9',
repository: 'example/move-flow',
tag: 'release-2.7.9',
});
});
it('rejects an incompatible configured major before downloading', async () => {
await expect(
getMoveFlowInstallConfig({ ...process.env, GITNEXUS_MOVE_FLOW_VERSION: '3.0.0' }),
).rejects.toThrow('unsupported move-flow major version');
});
it('keeps install waiters alive beyond the full download budget', () => {
expect(moveFlowInstallLockWaitMs(30_000)).toBe(120_000);
expect(moveFlowInstallLockWaitMs(90_000)).toBe(240_000);
});
it('matches the reported semantic version exactly', () => {
expect(reportedMoveFlowVersion('move-flow 2.0.0')).toBe('2.0.0');
expect(reportedMoveFlowVersion('move-flow 12.0.0')).not.toBe('2.0.0');
});
it.each([
['GITNEXUS_MOVE_FLOW_VERSION', '../escape'],
['GITNEXUS_MOVE_FLOW_TAG', '../../outside'],
['GITNEXUS_MOVE_FLOW_REPO', 'owner/repo/extra'],
])('rejects unsafe release coordinate %s', async (key, value) => {
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-invalid-config-'));
tempRoots.push(root);
const env = {
...process.env,
// Cross-platform CI disables automatic provisioning globally; this case
// must still reach release-coordinate validation.
GITNEXUS_SKIP_MOVE_FLOW: '0',
GITNEXUS_MOVE_FLOW_DIR: root,
[key]: value,
};
await expect(getMoveFlowInstallConfig(env)).rejects.toThrow('invalid move-flow');
await expect(installMoveFlow(env)).resolves.toMatchObject({ status: 'failed' });
expect(existsSync(path.join(root, 'escape'))).toBe(false);
});
it.each(['GITNEXUS_SKIP_MOVE_FLOW', 'GITNEXUS_SKIP_OPTIONAL_GRAMMARS'] as const)(
'%s=1 returns a structured skip result without touching the network',
async (flag) => {
const env = {
...process.env,
GITNEXUS_SKIP_MOVE_FLOW: '0',
GITNEXUS_SKIP_OPTIONAL_GRAMMARS: '0',
[flag]: '1',
};
await expect(installMoveFlow(env)).resolves.toMatchObject({
status: 'skipped',
message: expect.stringContaining(flag),
});
},
);
it('serializes concurrent installers and transfers lock ownership safely', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-lock-'));
tempRoots.push(root);
const lockPath = path.join(root, 'move-flow.install.lock');
const options = { waitTimeoutMs: 2_000, leaseMs: 500, heartbeatMs: 20, retryMs: 10 };
const releaseFirst = await acquireInstallLock(lockPath, options);
let secondAcquired = false;
const second = acquireInstallLock(lockPath, options).then((release) => {
secondAcquired = true;
return release;
});
await new Promise((resolve) => setTimeout(resolve, 80));
expect(secondAcquired).toBe(false);
await releaseFirst();
const releaseSecond = await second;
expect(secondAcquired).toBe(true);
expect(existsSync(lockPath)).toBe(true);
await releaseSecond();
expect(existsSync(lockPath)).toBe(false);
});
it('removes a lock when writing its ownership token fails', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-lock-write-'));
tempRoots.push(root);
const lockPath = path.join(root, 'move-flow.install.lock');
const handle = {
writeFile: vi.fn(async () => {
throw new Error('simulated disk failure');
}),
close: vi.fn(async () => {}),
} as unknown as FileHandle;
const removeLock = vi.fn(async (file: string) => {
rmSync(file, { force: true });
});
await expect(
acquireInstallLock(
lockPath,
{ waitTimeoutMs: 100 },
{
openLock: async () => {
writeFileSync(lockPath, '');
return handle;
},
removeLock,
},
),
).rejects.toThrow('simulated disk failure');
expect(handle.close).toHaveBeenCalledOnce();
expect(removeLock).toHaveBeenCalledWith(lockPath);
expect(existsSync(lockPath)).toBe(false);
});
it('reclaims a stable malformed lock after its lease expires', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-lock-stale-'));
tempRoots.push(root);
const lockPath = path.join(root, 'move-flow.install.lock');
writeFileSync(lockPath, 'not-json');
const old = new Date(Date.now() - 10_000);
utimesSync(lockPath, old, old);
const release = await acquireInstallLock(lockPath, {
waitTimeoutMs: 1_000,
leaseMs: 50,
heartbeatMs: 10,
retryMs: 5,
});
expect(existsSync(lockPath)).toBe(true);
await release();
expect(existsSync(lockPath)).toBe(false);
});
});

View file

@ -8,17 +8,10 @@ vi.mock('node:child_process', () => ({
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,
tryResolveMoveFlowClient,
detectMoveFlowCapabilities,
} from '../../../src/core/move/mcp-client.js';
import { spawn, execFileSync } from 'node:child_process';
@ -36,28 +29,37 @@ function createMockProc() {
return proc;
}
describe('tryCreateMoveFlowClient', () => {
describe('tryResolveMoveFlowClient', () => {
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);
it('resolves a client when the binary is found', () => {
mockExecFileSync.mockReturnValue('move-flow 2.0.0');
expect(tryResolveMoveFlowClient()?.client).toBeInstanceOf(MoveFlowMcpClient);
expect(mockExecFileSync).toHaveBeenCalledWith(
'move-flow',
['--version'],
expect.objectContaining({ stdio: 'ignore' }),
expect.objectContaining({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }),
);
});
it('returns the reported version with the resolved client', () => {
mockExecFileSync.mockReturnValue('move-flow 2.3.4-rc1');
expect(tryResolveMoveFlowClient('/custom/move-flow')).toMatchObject({
client: expect.any(MoveFlowMcpClient),
version: '2.3.4-rc1',
});
expect(mockExecFileSync).toHaveBeenCalledOnce();
});
it('returns null when binary is not found', () => {
mockExecFileSync.mockImplementation(() => {
throw new Error('not found');
});
expect(tryCreateMoveFlowClient()).toBeNull();
expect(tryResolveMoveFlowClient()).toBeNull();
});
it.each([
@ -66,14 +68,32 @@ describe('tryCreateMoveFlowClient', () => {
'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);
mockExecFileSync.mockReturnValue('move-flow 2.0.0');
expect(tryResolveMoveFlowClient()?.client).toBeInstanceOf(MoveFlowMcpClient);
expect(mockExecFileSync).toHaveBeenCalledWith(binary, ['--version'], {
stdio: 'ignore',
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
});
});
it.each(['move-flow 1.9.9', 'move-flow 3.0.0', 'unknown build'])(
'rejects an incompatible version response: %s',
(versionOutput) => {
mockExecFileSync.mockReturnValue(versionOutput);
expect(tryResolveMoveFlowClient('/custom/move-flow')).toBeNull();
},
);
it.each(['move-flow 2.0.0', 'move-flow 2.1.0-rc1', 'move-flow v2.3.4'])(
'accepts a compatible-major version response: %s',
(versionOutput) => {
mockExecFileSync.mockReturnValue(versionOutput);
expect(tryResolveMoveFlowClient('/custom/move-flow')?.client).toBeInstanceOf(
MoveFlowMcpClient,
);
},
);
});
describe('MoveFlowMcpClient', () => {
@ -186,6 +206,77 @@ describe('MoveFlowMcpClient', () => {
await vi.advanceTimersByTimeAsync(25);
await failure;
expect(proc.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);
await client.shutdown();
});
it('ignores a late response after a tool timeout and starts a fresh child', async () => {
vi.useFakeTimers();
process.env.GITNEXUS_MOVE_FLOW_TIMEOUT_MS = '25';
const timedOutProc = createMockProc();
const retryProc = createMockProc();
mockSpawn.mockReturnValueOnce(timedOutProc as any).mockReturnValueOnce(retryProc as any);
timedOutProc.stdin.on('data', (chunk: Buffer) => {
for (const line of chunk.toString().split('\n')) {
if (!line.trim()) continue;
const msg = JSON.parse(line);
if (msg.method === 'initialize') {
timedOutProc.stdout.write(
JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} }) + '\n',
);
}
}
});
const client = new MoveFlowMcpClient('move-flow');
const first = client.facts('/slow');
const failure = expect(first).rejects.toThrow("move-flow 'move_package_query' timed out");
await vi.advanceTimersByTimeAsync(25);
await failure;
timedOutProc.stdout.write(
JSON.stringify({
jsonrpc: '2.0',
id: 2,
result: { content: [{ type: 'text', text: '{"late":true}' }] },
}) + '\n',
);
expect((client as any).pending.size).toBe(0);
serveToolsCall(retryProc, {
result: { content: [{ type: 'text', text: '{"fresh":true}' }], isError: false },
});
await expect(client.facts('/retry')).resolves.toEqual({ fresh: true });
expect(mockSpawn).toHaveBeenCalledTimes(2);
await client.shutdown();
});
it('propagates a capabilities transport timeout and clears it for retry', async () => {
vi.useFakeTimers();
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 capabilities = client.capabilities();
const failure = expect(capabilities).rejects.toThrow("move-flow 'tools/list' timed out");
await vi.advanceTimersByTimeAsync(30_000);
await failure;
expect(proc.kill).toHaveBeenCalledOnce();
expect((client as any).capsPromise).toBeNull();
expect((client as any).proc).toBeNull();
await client.shutdown();
});

View file

@ -0,0 +1,170 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
MoveFlowMcpClient,
ResolvedMoveFlowClient,
} from '../../../src/core/move/mcp-client.js';
import {
ensureMoveFlowRuntime,
type MoveFlowProvisionDependencies,
} from '../../../src/core/move/provision.js';
import type { VerifiedMoveFlowBinary } from '../../../src/core/move/install.js';
const originalMoveFlow = process.env.MOVE_FLOW;
afterEach(() => {
if (originalMoveFlow === undefined) delete process.env.MOVE_FLOW;
else process.env.MOVE_FLOW = originalMoveFlow;
});
const client = {} as MoveFlowMcpClient;
const resolved: ResolvedMoveFlowClient = { client, version: '2.0.0' };
const cached: VerifiedMoveFlowBinary = {
binaryPath: '/cache/move-flow',
version: '2.0.0',
fingerprint: 'release-fingerprint',
};
const dependencies = (
overrides: Partial<MoveFlowProvisionDependencies> = {},
): MoveFlowProvisionDependencies => ({
resolveClient: vi.fn(() => null),
createClient: vi.fn(() => client),
findCached: vi.fn(async () => null),
install: vi.fn(async () => ({ status: 'failed', message: 'offline' })),
...overrides,
});
describe('ensureMoveFlowRuntime', () => {
it('does not install when an explicit or PATH binary already resolves', async () => {
const deps = dependencies({ resolveClient: vi.fn(() => resolved) });
await expect(ensureMoveFlowRuntime({}, deps)).resolves.toMatchObject({ client });
expect(deps.install).not.toHaveBeenCalled();
});
it('keeps an invalid explicit MOVE_FLOW authoritative', async () => {
process.env.MOVE_FLOW = '/missing/move-flow';
const deps = dependencies();
await expect(ensureMoveFlowRuntime({}, deps)).resolves.toBeNull();
expect(deps.install).not.toHaveBeenCalled();
expect(process.env.MOVE_FLOW).toBe('/missing/move-flow');
});
it('supports local-only resolution without starting installation', async () => {
delete process.env.MOVE_FLOW;
const deps = dependencies();
await expect(ensureMoveFlowRuntime({ install: false }, deps)).resolves.toBeNull();
expect(deps.install).not.toHaveBeenCalled();
});
it('installs once and returns the verified release identity', async () => {
delete process.env.MOVE_FLOW;
const deps = dependencies({
install: vi.fn(async () => ({ status: 'installed' as const, binary: cached })),
});
await expect(ensureMoveFlowRuntime({}, deps)).resolves.toEqual({
client,
identity: {
version: '2.0.0',
source: 'release',
fingerprint: 'release-fingerprint',
},
});
expect(deps.install).toHaveBeenCalledOnce();
expect(deps.createClient).toHaveBeenCalledWith('/cache/move-flow');
expect(deps.resolveClient).toHaveBeenCalledTimes(1);
});
it('does not retry a rejected installation within the same process', async () => {
delete process.env.MOVE_FLOW;
const install = vi
.fn<MoveFlowProvisionDependencies['install']>()
.mockRejectedValue(new Error('offline'));
const deps = dependencies({ install });
await expect(ensureMoveFlowRuntime({}, deps)).resolves.toBeNull();
await expect(ensureMoveFlowRuntime({}, deps)).resolves.toBeNull();
expect(install).toHaveBeenCalledOnce();
});
it('does not retry a soft installation failure within the same process', async () => {
delete process.env.MOVE_FLOW;
const install = vi
.fn<MoveFlowProvisionDependencies['install']>()
.mockResolvedValue({ status: 'failed', message: 'checksum service unavailable' });
const deps = dependencies({ install });
await expect(ensureMoveFlowRuntime({}, deps)).resolves.toBeNull();
await expect(ensureMoveFlowRuntime({}, deps)).resolves.toBeNull();
expect(install).toHaveBeenCalledOnce();
});
it('shares only an in-flight install attempt', async () => {
delete process.env.MOVE_FLOW;
let finish!: (value: { status: 'installed'; binary: VerifiedMoveFlowBinary }) => void;
const pending = new Promise<{ status: 'installed'; binary: VerifiedMoveFlowBinary }>(
(resolve) => {
finish = resolve;
},
);
const install = vi.fn(() => pending);
const deps = dependencies({ install });
const first = ensureMoveFlowRuntime({}, deps);
const second = ensureMoveFlowRuntime({}, deps);
await vi.waitFor(() => expect(install).toHaveBeenCalledOnce());
finish({ status: 'installed', binary: cached });
await expect(Promise.all([first, second])).resolves.toHaveLength(2);
expect(deps.createClient).toHaveBeenCalledTimes(2);
});
it('clears a successful install attempt so a later cache miss can reinstall', async () => {
delete process.env.MOVE_FLOW;
const install = vi.fn(async () => ({ status: 'installed' as const, binary: cached }));
const deps = dependencies({ install });
await expect(ensureMoveFlowRuntime({}, deps)).resolves.toMatchObject({ client });
await expect(ensureMoveFlowRuntime({}, deps)).resolves.toMatchObject({ client });
expect(install).toHaveBeenCalledTimes(2);
});
it('uses the verified cache without another version probe', async () => {
delete process.env.MOVE_FLOW;
const deps = dependencies({ findCached: vi.fn(async () => cached) });
await expect(ensureMoveFlowRuntime({}, deps)).resolves.toMatchObject({
client,
identity: { source: 'release', fingerprint: 'release-fingerprint' },
});
expect(deps.resolveClient).not.toHaveBeenCalled();
expect(deps.createClient).toHaveBeenCalledWith('/cache/move-flow');
expect(deps.install).not.toHaveBeenCalled();
});
it('rejects an incompatible verified cache without constructing a client', async () => {
delete process.env.MOVE_FLOW;
const deps = dependencies({
findCached: vi.fn(async () => ({ ...cached, version: '3.0.0' })),
});
await expect(ensureMoveFlowRuntime({}, deps)).resolves.toBeNull();
expect(deps.createClient).not.toHaveBeenCalled();
});
it('rejects an incompatible installed result without constructing a client', async () => {
delete process.env.MOVE_FLOW;
const deps = dependencies({
install: vi.fn(async () => ({
status: 'installed' as const,
binary: { ...cached, version: '3.0.0' },
})),
});
await expect(ensureMoveFlowRuntime({}, deps)).resolves.toBeNull();
expect(deps.createClient).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import type { GraphNode } from 'gitnexus-shared';
import {
CONST_SCHEMA,
ENUM_SCHEMA,
ENUM_VARIANT_SCHEMA,
FUNCTION_SCHEMA,
MODULE_SCHEMA,
STRUCT_SCHEMA,
} from '../../src/core/lbug/schema.js';
import {
getNodeTableCsvHeader,
NODE_TABLE_LAYOUTS,
type LayoutTableName,
} from '../../src/core/lbug/node-table-layout.js';
import { buildLayoutNodeRow, escapeCSVBoolean } from '../../src/core/lbug/csv-generator.js';
import { getCopyQuery } from '../../src/core/lbug/lbug-adapter.js';
const schemas: Record<LayoutTableName, string> = {
Function: FUNCTION_SCHEMA,
Struct: STRUCT_SCHEMA,
Enum: ENUM_SCHEMA,
EnumVariant: ENUM_VARIANT_SCHEMA,
Const: CONST_SCHEMA,
Module: MODULE_SCHEMA,
};
const ddlColumns = (ddl: string): string[] =>
ddl
.split('\n')
.map((line) => line.trim())
.filter((line) => line.endsWith(',') && !line.startsWith('PRIMARY KEY'))
.map((line) => line.split(/\s+/, 1)[0]);
const copyColumns = (query: string): string[] => {
const match = /^COPY\s+`?\w+`?\(([^)]+)\)/.exec(query);
if (!match) throw new Error(`Could not parse COPY query: ${query}`);
return match[1].split(',').map((value) => value.trim());
};
const csvCellCount = (row: string): number => {
let cells = 1;
let quoted = false;
for (let index = 0; index < row.length; index++) {
if (row[index] === '"') {
if (quoted && row[index + 1] === '"') index++;
else quoted = !quoted;
} else if (row[index] === ',' && !quoted) {
cells++;
}
}
return cells;
};
describe('shared node-table persistence layouts', () => {
it.each(Object.keys(NODE_TABLE_LAYOUTS) as LayoutTableName[])(
'%s keeps DDL, CSV, row encoding, and COPY order aligned',
(table) => {
const columns = NODE_TABLE_LAYOUTS[table].columns.map(({ name }) => name);
const node = {
id: `${table}:fixture`,
label: table,
properties: {
name: 'fixture',
filePath: 'src/fixture.move',
startLine: 1,
endLine: 2,
language: table === 'Function' ? 'typescript' : 'rust',
},
} as GraphNode;
expect(ddlColumns(schemas[table])).toEqual(columns);
expect(getNodeTableCsvHeader(table).split(',')).toEqual(columns);
expect(csvCellCount(buildLayoutNodeRow(table, node, 'fixture content'))).toBe(columns.length);
expect(copyColumns(getCopyQuery(table, '/tmp/fixture.csv'))).toEqual(columns);
},
);
it('escapeCSVBoolean matches the incremental path truthy coercion (!!v) for non-boolean inputs', () => {
// The bulk CSV path and the incremental CREATE/MERGE path (`!!properties.x`
// in lbug-adapter) must classify the same value identically, or an
// incremental top-up would flip a boolean column relative to a full
// rebuild. Exercise the values a misbehaving extractor could emit.
for (const value of [true, false, 1, 0, '0', '', 'false', undefined, null, {}]) {
expect(escapeCSVBoolean(value)).toBe(value ? 'true' : 'false');
}
});
it('rejects an unknown CSV column encoding instead of emitting an empty cell', () => {
const column = NODE_TABLE_LAYOUTS.Function.columns[0];
const mutableColumn = column as { csvEncoding: string };
const originalEncoding = column.csvEncoding;
const node = {
id: 'Function:fixture',
label: 'Function',
properties: { name: 'fixture', filePath: 'src/fixture.ts' },
} as GraphNode;
try {
mutableColumn.csvEncoding = 'future-encoding';
expect(() => buildLayoutNodeRow('Function', node, 'fixture content')).toThrow(
'Unsupported CSV column encoding: future-encoding',
);
} finally {
mutableColumn.csvEncoding = originalEncoding;
}
});
});

View file

@ -90,6 +90,8 @@ export default defineConfig({
'test/integration/extension-binary-real.test.ts',
'test/integration/lbug-delete-nodes-for-files.test.ts',
'test/integration/lbug-query-importers-batch.test.ts',
'test/integration/move-mixed-language-roundtrip.test.ts',
'test/integration/move-run-analyze-e2e.test.ts',
'test/unit/incremental-dirty-recovery.test.ts',
'test/unit/incremental-orchestration.test.ts',
],
@ -138,6 +140,8 @@ export default defineConfig({
'test/integration/extension-binary-real.test.ts',
'test/integration/lbug-delete-nodes-for-files.test.ts',
'test/integration/lbug-query-importers-batch.test.ts',
'test/integration/move-mixed-language-roundtrip.test.ts',
'test/integration/move-run-analyze-e2e.test.ts',
'test/unit/incremental-dirty-recovery.test.ts',
'test/unit/incremental-orchestration.test.ts',
],