mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-28 01:31:23 +00:00
Merge remote-tracking branch 'origin' into refactor/language-provider-consolidation
This commit is contained in:
commit
8e09b42130
46 changed files with 2559 additions and 213 deletions
96
.github/workflows/claude-code-review.yml
vendored
96
.github/workflows/claude-code-review.yml
vendored
|
|
@ -1,96 +0,0 @@
|
|||
name: Claude Code Review
|
||||
|
||||
# Uses pull_request_target so the workflow runs as defined on the default branch,
|
||||
# which allows access to secrets for posting review comments on fork PRs.
|
||||
# SECURITY: The checkout pins the fork's HEAD SHA (not the branch name) to
|
||||
# prevent TOCTOU races (force-push between trigger and checkout). The
|
||||
# claude-code-action sandboxes execution — it does NOT run arbitrary code
|
||||
# from the checked-out source.
|
||||
|
||||
on:
|
||||
# Trigger only when explicitly requested:
|
||||
# - Add the "claude-review" label to a PR, OR
|
||||
# - Comment "@claude" or "/review" on a PR
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Serialize per-PR to avoid racing review comments.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Run only when:
|
||||
# 1. The "claude-review" label is added to a non-draft PR by a trusted contributor, OR
|
||||
# 2. A trusted contributor comments "@claude" or "/review" on a PR
|
||||
if: |
|
||||
(
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.label.name == 'claude-review' &&
|
||||
github.event.pull_request.draft == false &&
|
||||
(github.event.pull_request.author_association == 'OWNER' ||
|
||||
github.event.pull_request.author_association == 'MEMBER' ||
|
||||
github.event.pull_request.author_association == 'COLLABORATOR')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
(contains(github.event.comment.body, '@claude') ||
|
||||
contains(github.event.comment.body, '/review')) &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
# For issue_comment triggers, resolve the PR number, head SHA, and fork repo
|
||||
- name: Resolve PR context
|
||||
id: pr
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
|
||||
with:
|
||||
script: |
|
||||
let pr;
|
||||
if (context.eventName === 'issue_comment') {
|
||||
const resp = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.issue.number,
|
||||
});
|
||||
pr = resp.data;
|
||||
} else {
|
||||
pr = context.payload.pull_request;
|
||||
}
|
||||
core.setOutput('number', pr.number);
|
||||
core.setOutput('sha', pr.head.sha);
|
||||
core.setOutput('repo', pr.head.repo.full_name);
|
||||
core.setOutput('branch', pr.head.ref);
|
||||
|
||||
- name: Checkout PR head
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: ${{ steps.pr.outputs.repo }}
|
||||
ref: ${{ steps.pr.outputs.sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: '*'
|
||||
show_full_output: true
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ steps.pr.outputs.number }}'
|
||||
77
.github/workflows/claude.yml
vendored
77
.github/workflows/claude.yml
vendored
|
|
@ -1,8 +1,17 @@
|
|||
name: Claude Code
|
||||
|
||||
# Label-triggered code-review requests use pull_request_target so the workflow
|
||||
# runs as defined on the default branch, which allows access to secrets for
|
||||
# posting review comments on fork PRs. SECURITY: PR checkouts pin the fork's
|
||||
# HEAD SHA (not the branch name) to prevent TOCTOU races.
|
||||
# The claude-code-action sandboxes execution; it does not run arbitrary code
|
||||
# from the checked-out source.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
|
|
@ -21,7 +30,10 @@ jobs:
|
|||
if: |
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
(
|
||||
contains(github.event.comment.body, '@claude') ||
|
||||
(github.event.issue.pull_request && contains(github.event.comment.body, '/review'))
|
||||
) &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR')
|
||||
|
|
@ -46,6 +58,14 @@ jobs:
|
|||
(github.event.issue.author_association == 'OWNER' ||
|
||||
github.event.issue.author_association == 'MEMBER' ||
|
||||
github.event.issue.author_association == 'COLLABORATOR')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.label.name == 'claude-review' &&
|
||||
github.event.pull_request.draft == false &&
|
||||
(github.event.pull_request.author_association == 'OWNER' ||
|
||||
github.event.pull_request.author_association == 'MEMBER' ||
|
||||
github.event.pull_request.author_association == 'COLLABORATOR')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
|
@ -63,33 +83,48 @@ jobs:
|
|||
with:
|
||||
script: |
|
||||
// Determine if this event is PR-related
|
||||
let prNumber = null;
|
||||
let pr = null;
|
||||
if (context.eventName === 'issue_comment' && context.payload.issue.pull_request) {
|
||||
prNumber = context.payload.issue.number;
|
||||
const resp = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.issue.number,
|
||||
});
|
||||
pr = resp.data;
|
||||
} else if (context.eventName === 'pull_request_review_comment') {
|
||||
prNumber = context.payload.pull_request.number;
|
||||
pr = context.payload.pull_request;
|
||||
} else if (context.eventName === 'pull_request_review') {
|
||||
prNumber = context.payload.pull_request.number;
|
||||
pr = context.payload.pull_request;
|
||||
} else if (context.eventName === 'pull_request_target') {
|
||||
pr = context.payload.pull_request;
|
||||
}
|
||||
|
||||
if (!prNumber) {
|
||||
if (!pr) {
|
||||
core.setOutput('is_pr', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
const pr = resp.data;
|
||||
|
||||
core.setOutput('is_pr', 'true');
|
||||
core.setOutput('number', String(prNumber));
|
||||
core.setOutput('number', String(pr.number));
|
||||
core.setOutput('sha', pr.head.sha);
|
||||
core.setOutput('repo', pr.head.repo.full_name);
|
||||
core.setOutput('branch', pr.head.ref);
|
||||
|
||||
- name: Resolve Claude mode
|
||||
id: mode
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
|
||||
with:
|
||||
script: |
|
||||
const body = (context.payload.comment?.body ?? '').toLowerCase();
|
||||
const isCodeReview =
|
||||
(context.eventName === 'pull_request_target' &&
|
||||
context.payload.label?.name === 'claude-review') ||
|
||||
(context.eventName === 'issue_comment' &&
|
||||
Boolean(context.payload.issue?.pull_request) &&
|
||||
body.includes('/review'));
|
||||
|
||||
core.setOutput('code_review', isCodeReview ? 'true' : 'false');
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
|
|
@ -98,6 +133,7 @@ jobs:
|
|||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
if: steps.mode.outputs.code_review != 'true'
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # v1
|
||||
with:
|
||||
|
|
@ -109,3 +145,16 @@ jobs:
|
|||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
- name: Run Claude Code Review
|
||||
if: steps.mode.outputs.code_review == 'true'
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: '*'
|
||||
show_full_output: true
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ steps.pr.outputs.number }}'
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import fs from 'fs/promises';
|
|||
import {
|
||||
getStoragePaths,
|
||||
loadMeta,
|
||||
addToGitignore,
|
||||
ensureGitNexusIgnored,
|
||||
registerRepo,
|
||||
} from '../storage/repo-manager.js';
|
||||
import { getGitRoot, getRemoteUrl, isGitRepo } from '../storage/git.js';
|
||||
|
|
@ -115,7 +115,7 @@ export const indexCommand = async (inputPathParts?: string[], options?: IndexOpt
|
|||
meta.remoteUrl = getRemoteUrl(repoPath);
|
||||
}
|
||||
await registerRepo(repoPath, meta);
|
||||
await addToGitignore(repoPath);
|
||||
await ensureGitNexusIgnored(repoPath);
|
||||
|
||||
const projectName = path.basename(repoPath);
|
||||
const { stats } = meta;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ if (!process.env.ORT_LOG_LEVEL) {
|
|||
}
|
||||
|
||||
import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers';
|
||||
import os from 'os';
|
||||
import { existsSync } from 'fs';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
|
|
@ -23,6 +22,7 @@ import { createRequire } from 'module';
|
|||
import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig, type ModelProgress } from './types.js';
|
||||
import { isHttpMode, getHttpDimensions, httpEmbed } from './http-client.js';
|
||||
import { resolveEmbeddingConfig } from './config.js';
|
||||
import { applyHfEnvOverrides } from './hf-env.js';
|
||||
|
||||
/**
|
||||
* Check whether the onnxruntime-node package that @huggingface/transformers
|
||||
|
|
@ -158,11 +158,11 @@ export const initEmbedder = async (
|
|||
try {
|
||||
// Configure transformers.js environment
|
||||
env.allowLocalModels = false;
|
||||
// Default cache to user-writable location. transformers.js defaults to
|
||||
// ./node_modules/.cache inside its own install dir, which is unwritable
|
||||
// when gitnexus is installed globally (e.g. /usr/lib/node_modules/).
|
||||
// Respect HF_HOME if set, otherwise fall back to ~/.cache/huggingface.
|
||||
env.cacheDir = process.env.HF_HOME ?? join(os.homedir(), '.cache', 'huggingface');
|
||||
// Bridge user-controlled env vars to transformers.js: HF_HOME →
|
||||
// env.cacheDir, HF_ENDPOINT → env.remoteHost (#1205). Centralised in
|
||||
// applyHfEnvOverrides so the MCP embedder entry point behaves
|
||||
// identically.
|
||||
applyHfEnvOverrides(env);
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
if (isDev) {
|
||||
|
|
|
|||
62
gitnexus/src/core/embeddings/hf-env.ts
Normal file
62
gitnexus/src/core/embeddings/hf-env.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import os from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* @internal Exported only for unit tests and the two embedder entry points
|
||||
* (`core/embeddings/embedder.ts` + `mcp/core/embedder.ts`). Not part of the
|
||||
* public package API.
|
||||
*
|
||||
* Minimal subset of `@huggingface/transformers`' `env` object that gitnexus
|
||||
* mutates. Defining a local structural type keeps this helper free of a
|
||||
* transitive dependency on transformers' generated `.d.ts` while still
|
||||
* giving full type-checking on the two fields we actually touch.
|
||||
*/
|
||||
export interface HfEnvSubset {
|
||||
cacheDir: string;
|
||||
remoteHost: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Exported only for unit tests and the two embedder entry points
|
||||
* (`core/embeddings/embedder.ts` + `mcp/core/embedder.ts`). Not part of the
|
||||
* public package API.
|
||||
*
|
||||
* Apply user-controlled HuggingFace environment overrides to the
|
||||
* `@huggingface/transformers` `env` object. Centralises the two env-var
|
||||
* bridges so every gitnexus embedder entry point (the analyze pipeline
|
||||
* and the MCP server) behaves identically.
|
||||
*
|
||||
* - **`HF_HOME`** → `env.cacheDir` (default: `~/.cache/huggingface`).
|
||||
* transformers.js otherwise defaults to `./node_modules/.cache` inside
|
||||
* its own install dir, which is unwritable when gitnexus is installed
|
||||
* globally (e.g. `/usr/lib/node_modules/`).
|
||||
*
|
||||
* - **`HF_ENDPOINT`** → `env.remoteHost` (#1205). transformers.js does
|
||||
* not read `HF_ENDPOINT` on its own — it reads `env.remoteHost` —
|
||||
* even though `HF_ENDPOINT` is the standard env var the upstream
|
||||
* `huggingface_hub` Python client and the official HF mirror docs
|
||||
* tell users to set. Bridging the two unblocks `--embeddings` for
|
||||
* users behind networks where `huggingface.co` is unreachable
|
||||
* (corporate proxies, the GFW, air-gapped mirrors). The trailing
|
||||
* slash is normalised because transformers.js builds URLs by string
|
||||
* concatenation and a missing slash silently falls through to its
|
||||
* default `huggingface.co/...` host.
|
||||
*
|
||||
* Mutation rather than return-and-apply because callers already hold a
|
||||
* reference to the live `env` object imported from
|
||||
* `@huggingface/transformers` — passing the same reference in keeps the
|
||||
* call site a single line at each entry point.
|
||||
*/
|
||||
export function applyHfEnvOverrides(env: HfEnvSubset): void {
|
||||
env.cacheDir = process.env.HF_HOME ?? join(os.homedir(), '.cache', 'huggingface');
|
||||
// `.trim()` guards against the common copy-paste failure mode of
|
||||
// `HF_ENDPOINT=" https://hf-mirror.com "` (leading/trailing whitespace
|
||||
// from shell scripts or docs) — without it, a whitespace-only value
|
||||
// would be truthy and produce an invalid `env.remoteHost = ' /'` that
|
||||
// silently misroutes downloads. Empty string remains falsy in JS so the
|
||||
// truthy guard already handles the unset/empty cases.
|
||||
const endpoint = process.env.HF_ENDPOINT?.trim();
|
||||
if (endpoint) {
|
||||
env.remoteHost = endpoint.endsWith('/') ? endpoint : endpoint + '/';
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'path';
|
||||
import { readRegistry, type RegistryEntry, type CwdMatch } from '../storage/repo-manager.js';
|
||||
import { getGitRoot, getCurrentCommit, getRemoteUrl } from '../storage/git.js';
|
||||
import { findGitRootByDotGit, getCurrentCommit, getRemoteUrl } from '../storage/git.js';
|
||||
|
||||
export interface StalenessInfo {
|
||||
isStale: boolean;
|
||||
|
|
@ -101,9 +101,10 @@ export async function checkCwdMatch(cwd: string): Promise<CwdMatch> {
|
|||
}
|
||||
if (bestPath) return { match: 'path', entry: bestPath };
|
||||
|
||||
// 2) Sibling-by-remote: locate the cwd's git root, get its remote
|
||||
// URL, and look for any registered entry with the same fingerprint.
|
||||
const cwdGitRoot = getGitRoot(cwdResolved);
|
||||
// 2) Sibling-by-remote: locate the cwd's git root using only ancestor
|
||||
// `.git` checks before shelling out. This keeps MCP startup from
|
||||
// running git in an unrelated launch cwd such as $HOME (#1138).
|
||||
const cwdGitRoot = findGitRootByDotGit(cwdResolved);
|
||||
if (!cwdGitRoot) return { match: 'none' };
|
||||
|
||||
const cwdRemote = getRemoteUrl(cwdGitRoot);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const DEFAULT_DETECT = {
|
|||
topics: true,
|
||||
shared_libs: true,
|
||||
embedding_fallback: true,
|
||||
workspace_deps: true,
|
||||
};
|
||||
|
||||
const DEFAULT_MATCHING = {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import * as path from 'node:path';
|
||||
import { glob } from 'glob';
|
||||
import Parser from 'tree-sitter';
|
||||
import { createIgnoreFilter } from '../../../config/ignore-service.js';
|
||||
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
|
||||
import type { ExtractedContract, RepoHandle } from '../types.js';
|
||||
import { readSafe } from './fs-utils.js';
|
||||
|
|
@ -227,11 +228,16 @@ async function buildProtoContext(repoPath: string): Promise<{
|
|||
servicesByName: Map<string, ProtoServiceInfo[]>;
|
||||
}> {
|
||||
const servicesByName = new Map<string, ProtoServiceInfo[]>();
|
||||
// `.gitnexusignore` / `.gitignore` honoured via the shared IgnoreService —
|
||||
// see `filesystem-walker.ts` for the canonical pattern. Replaces a
|
||||
// hardcoded `[node_modules, .git, vendor]` array; those names plus the
|
||||
// rest of `DEFAULT_IGNORE_LIST` are still excluded by default (#1185).
|
||||
const protoIgnoreFilter = await createIgnoreFilter(repoPath);
|
||||
const protoFiles = await glob('**/*.proto', {
|
||||
cwd: repoPath,
|
||||
absolute: false,
|
||||
nodir: true,
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**'],
|
||||
ignore: protoIgnoreFilter,
|
||||
});
|
||||
const contents = new Map<string, string>();
|
||||
|
||||
|
|
@ -401,9 +407,14 @@ export class GrpcExtractor implements ContractExtractor {
|
|||
}
|
||||
|
||||
// ─── Source files (+ .proto when plugin available) ────────────
|
||||
// Honour `.gitnexusignore` / `.gitignore` via the shared IgnoreService —
|
||||
// mirrors `filesystem-walker.ts`. Replaces a hardcoded
|
||||
// `[node_modules, .git, vendor, dist, build]` array; those names are all
|
||||
// in `DEFAULT_IGNORE_LIST`, so default behaviour is preserved (#1185).
|
||||
const sourceIgnoreFilter = await createIgnoreFilter(repoPath);
|
||||
const sourceFiles = await glob(GRPC_SCAN_GLOB, {
|
||||
cwd: repoPath,
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'],
|
||||
ignore: sourceIgnoreFilter,
|
||||
nodir: true,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import * as path from 'node:path';
|
||||
import { glob } from 'glob';
|
||||
import Parser from 'tree-sitter';
|
||||
import { createIgnoreFilter } from '../../../config/ignore-service.js';
|
||||
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
|
||||
import type { ExtractedContract, RepoHandle } from '../types.js';
|
||||
import { readSafe } from './fs-utils.js';
|
||||
|
|
@ -208,9 +209,16 @@ export class HttpRouteExtractor implements ContractExtractor {
|
|||
}
|
||||
|
||||
private async scanFiles(repoPath: string): Promise<string[]> {
|
||||
// Honour `.gitnexusignore` and `.gitignore` via the shared IgnoreService
|
||||
// so contract extraction respects the same exclusion rules as the rest of
|
||||
// the ingestion pipeline. Mirrors `filesystem-walker.ts` which uses the
|
||||
// same shape. Replaces a hardcoded `[node_modules, .git, dist, build,
|
||||
// vendor]` array — those names are still in `DEFAULT_IGNORE_LIST`, so
|
||||
// default behaviour is preserved (#1185).
|
||||
const ignoreFilter = await createIgnoreFilter(repoPath);
|
||||
return glob(HTTP_SCAN_GLOB, {
|
||||
cwd: repoPath,
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**', '**/vendor/**'],
|
||||
ignore: ignoreFilter,
|
||||
nodir: true,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -268,6 +268,19 @@ export class ManifestExtractor {
|
|||
LIMIT 1`,
|
||||
{ contract: link.contract },
|
||||
);
|
||||
} else if (link.type === 'custom') {
|
||||
// V1: exact name-only match on code-definition nodes.
|
||||
// Positive allowlist mirrors other contract types. If multiple code
|
||||
// symbols share the same name, ORDER BY filePath ASC LIMIT 1 picks
|
||||
// the alphabetically-first occurrence deterministically.
|
||||
rows = await executor(
|
||||
`MATCH (n:Function|Method|Class|Interface|Struct|Enum|Trait|Constructor|TypeAlias|Impl|Macro|Union|Typedef|Property|Record|Delegate|Annotation|Template|Const|Static|CodeElement)
|
||||
WHERE n.name = $contract
|
||||
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
|
||||
ORDER BY n.filePath ASC
|
||||
LIMIT 1`,
|
||||
{ contract: link.contract },
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
270
gitnexus/src/core/group/extractors/rust-workspace-extractor.ts
Normal file
270
gitnexus/src/core/group/extractors/rust-workspace-extractor.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { CypherExecutor } from '../contract-extractor.js';
|
||||
import type { GroupManifestLink, ContractRole } from '../types.js';
|
||||
import { shouldIgnorePath } from '../../../config/ignore-service.js';
|
||||
import { loadIgnoreRules } from '../../../config/ignore-service.js';
|
||||
|
||||
/**
|
||||
* Discover cross-crate contracts in a Rust workspace by reading each
|
||||
* member's `Cargo.toml` dependencies and scanning source files for
|
||||
* `use <workspace_dep>::<Type>` imports.
|
||||
*
|
||||
* Emits `GroupManifestLink[]` with `type: 'custom'` that feed into the
|
||||
* existing ManifestExtractor pipeline — no new matching logic needed.
|
||||
*
|
||||
* Designed for the group-level sync pipeline: it receives all repos in
|
||||
* a group and produces cross-repo links between them.
|
||||
*/
|
||||
|
||||
interface CrateMeta {
|
||||
name: string;
|
||||
groupPath: string;
|
||||
repoPath: string;
|
||||
workspaceDeps: string[];
|
||||
}
|
||||
|
||||
interface ImportedSymbol {
|
||||
crateName: string;
|
||||
symbolName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Cargo.toml to extract the crate name and workspace dependency
|
||||
* names. Uses simple line-based parsing — no TOML library needed for
|
||||
* the subset we care about.
|
||||
*/
|
||||
async function parseCrateManifest(
|
||||
repoPath: string,
|
||||
): Promise<{ name: string; workspaceDeps: string[] } | null> {
|
||||
const cargoPath = path.join(repoPath, 'Cargo.toml');
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(cargoPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let name = '';
|
||||
const workspaceDeps: string[] = [];
|
||||
|
||||
const nameMatch = content.match(/^\[package\]\s*\n(?:[^\[]*?\n)*?name\s*=\s*"([^"]+)"/m);
|
||||
if (nameMatch) name = nameMatch[1];
|
||||
|
||||
// Match dependencies that use workspace = true, which indicates they
|
||||
// are workspace-internal deps:
|
||||
// dep_name = { workspace = true }
|
||||
// dep_name.workspace = true
|
||||
//
|
||||
// Also match plain path dependencies:
|
||||
// dep_name = { path = "../other" }
|
||||
const depSections = content.matchAll(
|
||||
/\[(dependencies|dev-dependencies|build-dependencies)\]\s*\n([\s\S]*?)(?=\n\[|$)/g,
|
||||
);
|
||||
|
||||
for (const section of depSections) {
|
||||
const sectionBody = section[2];
|
||||
// workspace = true style
|
||||
const wsMatches = sectionBody.matchAll(
|
||||
/^(\w[\w-]*)\s*=\s*\{[^}]*workspace\s*=\s*true[^}]*\}/gm,
|
||||
);
|
||||
for (const m of wsMatches) workspaceDeps.push(m[1]);
|
||||
|
||||
// dotted workspace style: dep_name.workspace = true
|
||||
const dottedMatches = sectionBody.matchAll(/^(\w[\w-]*)\.workspace\s*=\s*true/gm);
|
||||
for (const m of dottedMatches) workspaceDeps.push(m[1]);
|
||||
|
||||
// path = "../other" style (local path deps within workspace)
|
||||
const pathMatches = sectionBody.matchAll(
|
||||
/^(\w[\w-]*)\s*=\s*\{[^}]*path\s*=\s*"[^"]*"[^}]*\}/gm,
|
||||
);
|
||||
for (const m of pathMatches) workspaceDeps.push(m[1]);
|
||||
}
|
||||
|
||||
if (!name) return null;
|
||||
return { name, workspaceDeps: [...new Set(workspaceDeps)] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan Rust source files for `use <crate>::<path>::<Symbol>` patterns
|
||||
* where <crate> is a known workspace dependency.
|
||||
*/
|
||||
async function scanImports(repoPath: string, knownCrates: Set<string>): Promise<ImportedSymbol[]> {
|
||||
const results: ImportedSymbol[] = [];
|
||||
|
||||
const normalizedCrates = new Map<string, string>();
|
||||
for (const c of knownCrates) {
|
||||
normalizedCrates.set(c.replace(/-/g, '_'), c);
|
||||
}
|
||||
|
||||
const sourceFiles = await findRustFiles(repoPath);
|
||||
for (const relFile of sourceFiles) {
|
||||
const absPath = path.join(repoPath, relFile);
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(absPath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match patterns:
|
||||
// use crate_name::Type;
|
||||
// use crate_name::module::Type;
|
||||
// use crate_name::{Type1, Type2};
|
||||
// use crate_name::module::{Type1, Type2};
|
||||
const useRegex = /^use\s+(\w+)::(.+);/gm;
|
||||
let match;
|
||||
while ((match = useRegex.exec(content)) !== null) {
|
||||
const crateName = match[1];
|
||||
const originalCrateName = normalizedCrates.get(crateName);
|
||||
if (!originalCrateName) continue;
|
||||
|
||||
const importPath = match[2].trim();
|
||||
|
||||
// Handle grouped imports: {Type1, Type2, module::Type3}
|
||||
const braceMatch = importPath.match(/\{([^}]+)\}/);
|
||||
if (braceMatch) {
|
||||
const items = braceMatch[1].split(',').map((s) => s.trim());
|
||||
for (const item of items) {
|
||||
const symbolName = extractSymbolName(item);
|
||||
if (symbolName && isTypeName(symbolName)) {
|
||||
results.push({ crateName: originalCrateName, symbolName, filePath: relFile });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const symbolName = extractSymbolName(importPath);
|
||||
if (symbolName && isTypeName(symbolName)) {
|
||||
results.push({ crateName: originalCrateName, symbolName, filePath: relFile });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Extract the final symbol name from a path like `module::submod::TypeName`. */
|
||||
function extractSymbolName(importPath: string): string | null {
|
||||
const trimmed = importPath.trim();
|
||||
if (!trimmed || trimmed === '*' || trimmed === 'self') return null;
|
||||
const parts = trimmed.split('::');
|
||||
return parts[parts.length - 1].trim() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic: in Rust, types (structs, enums, traits) are PascalCase.
|
||||
* Functions and modules are snake_case. We only want types as cross-crate
|
||||
* contracts — functions are too granular and modules too broad.
|
||||
*/
|
||||
function isTypeName(name: string): boolean {
|
||||
return /^[A-Z][A-Za-z0-9]*$/.test(name);
|
||||
}
|
||||
|
||||
async function findRustFiles(repoPath: string): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
const ig = await loadIgnoreRules(repoPath);
|
||||
|
||||
async function walk(dir: string, rel: string): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (ig && ig.ignores(childRel + '/')) continue;
|
||||
await walk(path.join(dir, entry.name), childRel);
|
||||
} else if (entry.name.endsWith('.rs')) {
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (ig && ig.ignores(childRel)) continue;
|
||||
results.push(childRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(repoPath, '');
|
||||
return results;
|
||||
}
|
||||
|
||||
export interface RustWorkspaceResult {
|
||||
links: GroupManifestLink[];
|
||||
discoveredCrates: Map<string, CrateMeta>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover cross-crate contracts across all Rust repos in a group.
|
||||
*
|
||||
* Returns `GroupManifestLink[]` ready to feed into `ManifestExtractor`.
|
||||
*/
|
||||
export async function extractRustWorkspaceLinks(
|
||||
repos: Record<string, string>,
|
||||
repoPaths: Map<string, string>,
|
||||
_dbExecutors?: Map<string, CypherExecutor>,
|
||||
): Promise<RustWorkspaceResult> {
|
||||
// Phase 1: Parse all Cargo.toml files to build crate registry
|
||||
const cratesByName = new Map<string, CrateMeta>();
|
||||
const cratesByGroupPath = new Map<string, CrateMeta>();
|
||||
|
||||
for (const [groupPath] of Object.entries(repos)) {
|
||||
const repoPath = repoPaths.get(groupPath);
|
||||
if (!repoPath) continue;
|
||||
|
||||
const manifest = await parseCrateManifest(repoPath);
|
||||
if (!manifest) continue;
|
||||
|
||||
const meta: CrateMeta = {
|
||||
name: manifest.name,
|
||||
groupPath,
|
||||
repoPath,
|
||||
workspaceDeps: manifest.workspaceDeps,
|
||||
};
|
||||
const existing = cratesByName.get(manifest.name);
|
||||
if (existing) {
|
||||
console.warn(
|
||||
`[rust-workspace-extractor] duplicate crate name "${manifest.name}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
cratesByName.set(manifest.name, meta);
|
||||
cratesByGroupPath.set(groupPath, meta);
|
||||
}
|
||||
|
||||
// Phase 2: For each crate, identify which of its workspace deps are
|
||||
// also in this group (i.e., repos we can link to)
|
||||
const links: GroupManifestLink[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [, crate] of cratesByGroupPath) {
|
||||
const groupCrateDeps = crate.workspaceDeps.filter((d) => cratesByName.has(d));
|
||||
if (groupCrateDeps.length === 0) continue;
|
||||
|
||||
// Phase 3: Scan source files for imports from workspace deps
|
||||
const knownCrates = new Set(groupCrateDeps);
|
||||
const imports = await scanImports(crate.repoPath, knownCrates);
|
||||
|
||||
for (const imp of imports) {
|
||||
const providerCrate = cratesByName.get(imp.crateName);
|
||||
if (!providerCrate) continue;
|
||||
|
||||
const qualifiedContract = `${imp.crateName}::${imp.symbolName}`;
|
||||
const key = `${crate.groupPath}→${providerCrate.groupPath}::${qualifiedContract}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
const link: GroupManifestLink = {
|
||||
from: providerCrate.groupPath,
|
||||
to: crate.groupPath,
|
||||
type: 'custom',
|
||||
contract: qualifiedContract,
|
||||
role: 'provider' as ContractRole,
|
||||
};
|
||||
links.push(link);
|
||||
}
|
||||
}
|
||||
|
||||
return { links, discoveredCrates: cratesByGroupPath };
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { glob } from 'glob';
|
||||
import Parser from 'tree-sitter';
|
||||
import { createIgnoreFilter } from '../../../config/ignore-service.js';
|
||||
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
|
||||
import type { ExtractedContract, RepoHandle } from '../types.js';
|
||||
import { readSafe } from './fs-utils.js';
|
||||
|
|
@ -56,22 +57,21 @@ export class TopicExtractor implements ContractExtractor {
|
|||
repoPath: string,
|
||||
_repo: RepoHandle,
|
||||
): Promise<ExtractedContract[]> {
|
||||
// Honour `.gitnexusignore` / `.gitignore` via the shared IgnoreService —
|
||||
// mirrors `filesystem-walker.ts`. The 5-name hardcoded list
|
||||
// (`node_modules, .git, vendor, dist, build`) is preserved because every
|
||||
// entry is in `DEFAULT_IGNORE_LIST`, so default behaviour is unchanged
|
||||
// (#1185). The Go-specific `**/*_test.go` filter is layered on top via a
|
||||
// small wrapper so glob-level pruning is preserved (we never read those
|
||||
// files); the wrapper short-circuits before calling the base filter.
|
||||
const baseFilter = await createIgnoreFilter(repoPath);
|
||||
const ignoreFilter: typeof baseFilter = {
|
||||
ignored: (p) => p.relative().endsWith('_test.go') || baseFilter.ignored(p),
|
||||
childrenIgnored: (p) => baseFilter.childrenIgnored(p),
|
||||
};
|
||||
const files = await glob(TOPIC_SCAN_GLOB, {
|
||||
cwd: repoPath,
|
||||
ignore: [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
'**/vendor/**',
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
// Language-level test file conventions. Go test files
|
||||
// `*_test.go` live next to source; other languages either use
|
||||
// separate test directories (Python's `tests/`, Java's
|
||||
// `src/test/`) or are already covered by the dist/build ignores.
|
||||
// Pushed to the glob level so the orchestrator stays
|
||||
// language-agnostic.
|
||||
'**/*_test.go',
|
||||
],
|
||||
ignore: ignoreFilter,
|
||||
nodir: true,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { HttpRouteExtractor } from './extractors/http-route-extractor.js';
|
|||
import { GrpcExtractor } from './extractors/grpc-extractor.js';
|
||||
import { TopicExtractor } from './extractors/topic-extractor.js';
|
||||
import { ManifestExtractor } from './extractors/manifest-extractor.js';
|
||||
import { extractRustWorkspaceLinks } from './extractors/rust-workspace-extractor.js';
|
||||
import { runExactMatch } from './matching.js';
|
||||
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
|
||||
import type { CypherExecutor } from './contract-extractor.js';
|
||||
|
|
@ -84,12 +85,14 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
let autoContracts: StoredContract[] = [];
|
||||
let manifestCrossLinks: CrossLink[] = [];
|
||||
let dbExecutors: Map<string, CypherExecutor> | undefined;
|
||||
let registryEntries: RegistryEntry[] | undefined;
|
||||
|
||||
const eo = opts?.extractorOverride;
|
||||
if (eo && eo.length === 0) {
|
||||
autoContracts = await (eo as () => Promise<StoredContract[]>)();
|
||||
} else {
|
||||
const entries = await readRegistry();
|
||||
registryEntries = await readRegistry();
|
||||
const entries = registryEntries;
|
||||
const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries);
|
||||
const httpEx = new HttpRouteExtractor();
|
||||
const grpcEx = new GrpcExtractor();
|
||||
|
|
@ -177,18 +180,39 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
}
|
||||
}
|
||||
|
||||
// Process manifest links declared in group.yaml.
|
||||
// Auto-discover workspace dependency contracts (Rust Cargo workspaces, etc.)
|
||||
// and merge them with explicit manifest links. Discovered links use the same
|
||||
// ManifestExtractor pipeline as hand-written links in group.yaml.
|
||||
let allLinks = [...config.links];
|
||||
|
||||
if (config.detect.workspace_deps) {
|
||||
const repoPaths = new Map<string, string>();
|
||||
if (!registryEntries) registryEntries = await readRegistry();
|
||||
for (const [groupPath, regName] of Object.entries(config.repos)) {
|
||||
const e = registryEntries.find((en) => en.name === regName);
|
||||
if (e) repoPaths.set(groupPath, e.path);
|
||||
}
|
||||
|
||||
const wsResult = await extractRustWorkspaceLinks(config.repos, repoPaths, dbExecutors);
|
||||
if (wsResult.links.length > 0) {
|
||||
allLinks = [...allLinks, ...wsResult.links];
|
||||
if (opts?.verbose) {
|
||||
console.log(
|
||||
` workspace-deps: discovered ${wsResult.links.length} cross-crate links from ${wsResult.discoveredCrates.size} Rust crates`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process manifest links declared in group.yaml (plus any auto-discovered).
|
||||
// ManifestExtractor is fully implemented but was never wired into this
|
||||
// pipeline — config.links were parsed and validated but silently dropped.
|
||||
// Placed after the DB try/finally: resolveSymbol falls back to synthetic
|
||||
// UIDs when dbExecutors is undefined or a pool is closed, so cross-links
|
||||
// are always generated regardless of whether real DB executors are available.
|
||||
if (config.links.length > 0) {
|
||||
// Warn about dangling links that reference repos not declared in config.repos.
|
||||
// They still generate cross-links via synthetic UIDs (determinism is preserved),
|
||||
// but the operator probably meant something that now silently does nothing useful.
|
||||
if (allLinks.length > 0) {
|
||||
const knownRepos = new Set(Object.keys(config.repos));
|
||||
for (const link of config.links) {
|
||||
for (const link of allLinks) {
|
||||
const dangling = [link.from, link.to].filter((r) => !knownRepos.has(r));
|
||||
if (dangling.length > 0) {
|
||||
console.warn(
|
||||
|
|
@ -198,12 +222,12 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
}
|
||||
|
||||
const manifestEx = new ManifestExtractor();
|
||||
const manifestResult = await manifestEx.extractFromManifest(config.links, dbExecutors);
|
||||
const manifestResult = await manifestEx.extractFromManifest(allLinks, dbExecutors);
|
||||
autoContracts.push(...manifestResult.contracts);
|
||||
manifestCrossLinks = manifestResult.crossLinks;
|
||||
if (opts?.verbose) {
|
||||
console.log(
|
||||
` manifest: ${manifestCrossLinks.length} cross-links from ${config.links.length} declared links`,
|
||||
` manifest: ${manifestCrossLinks.length} cross-links from ${allLinks.length} links (${config.links.length} declared + ${allLinks.length - config.links.length} discovered)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export interface DetectConfig {
|
|||
topics: boolean;
|
||||
shared_libs: boolean;
|
||||
embedding_fallback: boolean;
|
||||
workspace_deps: boolean;
|
||||
}
|
||||
|
||||
export interface MatchingConfig {
|
||||
|
|
|
|||
|
|
@ -61,60 +61,159 @@ export function resolvePythonImportTarget(
|
|||
const pathLike = parsedImport.targetRaw.replace(/\./g, '/');
|
||||
if (pathLike.includes('/')) {
|
||||
const [leadingSegment] = pathLike.split('/').filter(Boolean);
|
||||
if (!leadingSegment || !hasRepoCandidate(leadingSegment, ctx.allFilePaths)) {
|
||||
if (!leadingSegment || !hasRepoCandidate(leadingSegment, ctx.allFilePaths, ctx.fromFile)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-segment absolute resolve: try exact paths first, then suffix
|
||||
// match in nested repos. Using direct `Set.has` + `endsWith` instead of
|
||||
// `suffixResolve`'s shared helper because that helper requires a
|
||||
// pre-built `SuffixIndex` to disambiguate ties — without one it falls
|
||||
// back to an O(files) scan that silently picks the wrong file when
|
||||
// the last segment collides across directories (e.g. `accounts.models`
|
||||
// matching `billing/models.py` when both files exist).
|
||||
return resolveAbsoluteFromFiles(pathLike, ctx.allFilePaths);
|
||||
// Multi-segment absolute resolve: try exact paths first, then ancestor
|
||||
// walk (mirrors the single-segment ancestor walk in
|
||||
// `resolvePythonImportInternal`), then a suffix match in nested repos.
|
||||
// Using direct `Set.has` + `endsWith` instead of `suffixResolve`'s shared
|
||||
// helper because that helper requires a pre-built `SuffixIndex` to
|
||||
// disambiguate ties — without one it falls back to an O(files) scan that
|
||||
// silently picks the wrong file when the last segment collides across
|
||||
// directories (e.g. `accounts.models` matching `billing/models.py` when
|
||||
// both files exist).
|
||||
return resolveAbsoluteFromFiles(pathLike, ctx.allFilePaths, ctx.fromFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `package/sub/module` style paths (already dot-flattened) to a
|
||||
* concrete file in `allFilePaths`. Tries the exact path first, then the
|
||||
* `__init__.py` variant, then a suffix match for nested layouts.
|
||||
* concrete file in `allFilePaths`. Tries the exact path first, then walks
|
||||
* ancestors of `fromFile` looking for `<ancestor>/<pathLike>.py` (or
|
||||
* `__init__.py`), then falls back to a suffix match for nested layouts.
|
||||
* Returns the original (un-normalized) path from the set.
|
||||
*
|
||||
* Precedence order:
|
||||
* 1. Workspace-root direct hit (`<pathLike>.py`, `<pathLike>/__init__.py`).
|
||||
* 2. Closest-ancestor match walking up from the importer's directory.
|
||||
* 3. Suffix fallback (deterministic: fewest path segments, then
|
||||
* lexicographic on the normalized path).
|
||||
*
|
||||
* Root wins over ancestor by construction — if both `services/sync.py` and
|
||||
* `backend/services/sync.py` exist, `backend/routers/cron.py`'s
|
||||
* `from services.sync import X` resolves to the root file. This mirrors
|
||||
* Python's `sys.path` semantics where the project root is searched first.
|
||||
*
|
||||
* The ancestor walk mirrors the single-segment behavior in
|
||||
* `resolvePythonImportInternal`. For `from services.sync import X` in
|
||||
* `backend/routers/cron.py`, walk up: `backend/routers/services/sync.py` →
|
||||
* `backend/services/sync.py` ✓.
|
||||
*/
|
||||
function resolveAbsoluteFromFiles(pathLike: string, allFilePaths: Set<string>): string | null {
|
||||
function resolveAbsoluteFromFiles(
|
||||
pathLike: string,
|
||||
allFilePaths: Set<string>,
|
||||
fromFile: string,
|
||||
): string | null {
|
||||
const directFile = `${pathLike}.py`;
|
||||
const directPkg = `${pathLike}/__init__.py`;
|
||||
const suffixFile = `/${directFile}`;
|
||||
const suffixPkg = `/${directPkg}`;
|
||||
|
||||
let suffixMatch: string | null = null;
|
||||
for (const raw of allFilePaths) {
|
||||
const f = raw.replace(/\\/g, '/');
|
||||
if (f === directFile || f === directPkg) return raw;
|
||||
if (suffixMatch === null && (f.endsWith(suffixFile) || f.endsWith(suffixPkg))) {
|
||||
suffixMatch = raw;
|
||||
// Direct hit at workspace root.
|
||||
if (allFilePaths.has(directFile)) return directFile;
|
||||
if (allFilePaths.has(directPkg)) return directPkg;
|
||||
|
||||
// Ancestor walk — match the single-segment resolver's behavior at
|
||||
// multi-segment granularity. Closest match wins. Stop at `i > 0` because
|
||||
// `i === 0` would re-check the workspace-root candidates already covered
|
||||
// by the direct check above.
|
||||
const importerDir = fromFile.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
|
||||
if (importerDir) {
|
||||
const dirParts = importerDir.split('/').filter(Boolean);
|
||||
for (let i = dirParts.length; i > 0; i--) {
|
||||
const ancestor = dirParts.slice(0, i).join('/');
|
||||
const prefix = `${ancestor}/`;
|
||||
const candidateFile = `${prefix}${directFile}`;
|
||||
const candidatePkg = `${prefix}${directPkg}`;
|
||||
if (allFilePaths.has(candidateFile)) return candidateFile;
|
||||
if (allFilePaths.has(candidatePkg)) return candidatePkg;
|
||||
}
|
||||
}
|
||||
return suffixMatch;
|
||||
|
||||
// Suffix-match fallback (preserved for monorepo/nested-repo layouts
|
||||
// that don't share a directory ancestor with the importer).
|
||||
//
|
||||
// Tie-break order when multiple files match the same suffix:
|
||||
// 1. Fewest path segments (shorter, more canonical paths win — `lib/x.py`
|
||||
// beats `tooling/extras/x.py`).
|
||||
// 2. Lexicographic order over the normalized path (final stable
|
||||
// tiebreak independent of file-set insertion order).
|
||||
//
|
||||
// Without an explicit tie-break the previous implementation returned
|
||||
// the first match in `Set` iteration order, which depended on file
|
||||
// ingestion order and produced non-deterministic edges across runs in
|
||||
// multi-directory collision repos.
|
||||
const suffixFile = `/${directFile}`;
|
||||
const suffixPkg = `/${directPkg}`;
|
||||
const matches: { raw: string; norm: string }[] = [];
|
||||
for (const raw of allFilePaths) {
|
||||
const norm = raw.replace(/\\/g, '/');
|
||||
if (norm.endsWith(suffixFile) || norm.endsWith(suffixPkg)) {
|
||||
matches.push({ raw, norm });
|
||||
}
|
||||
}
|
||||
if (matches.length === 0) return null;
|
||||
if (matches.length === 1) return matches[0].raw;
|
||||
matches.sort((a, b) => {
|
||||
const aDepth = a.norm.split('/').length;
|
||||
const bDepth = b.norm.split('/').length;
|
||||
if (aDepth !== bDepth) return aDepth - bDepth;
|
||||
if (a.norm < b.norm) return -1;
|
||||
if (a.norm > b.norm) return 1;
|
||||
return 0;
|
||||
});
|
||||
return matches[0].raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the repo contain a module/package named `leadingSegment` at the top
|
||||
* level? Used to guard against false-positive suffix matches on external
|
||||
* dotted imports (e.g. `django.apps` matching a local `accounts/apps.py`).
|
||||
* Does the repo contain a module/package named `leadingSegment` somewhere
|
||||
* the importer can plausibly reach?
|
||||
*
|
||||
* Checks, in order: `<segment>.py` root file, `<segment>/__init__.py`
|
||||
* regular package, or any `<segment>/**.py` file (namespace package).
|
||||
* Used to guard against false-positive suffix matches on external dotted
|
||||
* imports (e.g. `django.apps` matching a local `accounts/apps.py`).
|
||||
*
|
||||
* Checks, in order:
|
||||
* 1. `SEGMENT.py` root file or `SEGMENT/__init__.py` regular package.
|
||||
* 2. Any `SEGMENT/...py` file at the workspace root (namespace package).
|
||||
* 3. Any `<importer-ancestor>/SEGMENT/...py` file (nested namespace
|
||||
* package the importer could reach via an ancestor walk, e.g.
|
||||
* `backend/services/sync.py` from `backend/routers/cron.py`).
|
||||
*
|
||||
* The nested case is bounded to the importer's own ancestors so a
|
||||
* vendored copy of an external package (e.g. `vendor/django/urls.py`)
|
||||
* does not gate-pass external imports like `from django.urls import path`
|
||||
* issued from `app/main.py`. Files inside the vendored tree itself
|
||||
* (importer under `vendor/django/...`) still resolve correctly because
|
||||
* the ancestor walk includes their own parents.
|
||||
*/
|
||||
function hasRepoCandidate(leadingSegment: string, allFilePaths: Set<string>): boolean {
|
||||
function hasRepoCandidate(
|
||||
leadingSegment: string,
|
||||
allFilePaths: Set<string>,
|
||||
fromFile: string,
|
||||
): boolean {
|
||||
const prefix = `${leadingSegment}/`;
|
||||
const rootFile = `${leadingSegment}.py`;
|
||||
const initFile = `${leadingSegment}/__init__.py`;
|
||||
|
||||
// Build importer-ancestor prefixes: for `backend/routers/cron.py`,
|
||||
// produces `["backend/routers/services/", "backend/services/"]` for
|
||||
// segment `services` (closest first, root excluded — covered above).
|
||||
const importerDir = fromFile.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
|
||||
const dirParts = importerDir ? importerDir.split('/').filter(Boolean) : [];
|
||||
const ancestorPrefixes: string[] = [];
|
||||
for (let i = dirParts.length; i > 0; i--) {
|
||||
ancestorPrefixes.push(`${dirParts.slice(0, i).join('/')}/${leadingSegment}/`);
|
||||
}
|
||||
|
||||
for (const raw of allFilePaths) {
|
||||
const f = raw.replace(/\\/g, '/');
|
||||
if (f === rootFile || f === initFile) return true;
|
||||
if (f.startsWith(prefix) && f.endsWith('.py')) return true;
|
||||
if (f.endsWith('.py')) {
|
||||
for (const ap of ancestorPrefixes) {
|
||||
if (f.startsWith(ap)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import {
|
|||
getStoragePaths,
|
||||
saveMeta,
|
||||
loadMeta,
|
||||
addToGitignore,
|
||||
ensureGitNexusIgnored,
|
||||
registerRepo,
|
||||
cleanupOldKuzuFiles,
|
||||
} from '../storage/repo-manager.js';
|
||||
|
|
@ -166,6 +166,7 @@ export async function runFullAnalysis(
|
|||
if (existingMeta && !options.force && existingMeta.lastCommit === currentCommit) {
|
||||
// Non-git folders have currentCommit = '' — always rebuild since we can't detect changes
|
||||
if (currentCommit !== '') {
|
||||
await ensureGitNexusIgnored(repoPath);
|
||||
return {
|
||||
repoName: options.registryName ?? getInferredRepoName(repoPath) ?? path.basename(repoPath),
|
||||
repoPath,
|
||||
|
|
@ -447,10 +448,8 @@ export async function runFullAnalysis(
|
|||
allowDuplicateName: options.allowDuplicateName,
|
||||
});
|
||||
|
||||
// Only attempt to update .gitignore when a .git directory is present.
|
||||
if (hasGitDir(repoPath)) {
|
||||
await addToGitignore(repoPath);
|
||||
}
|
||||
// Keep generated .gitnexus contents ignored without editing the user's root .gitignore.
|
||||
await ensureGitNexusIgnored(repoPath);
|
||||
|
||||
// ── Generate AI context files (best-effort) ───────────────────────
|
||||
let aggregatedClusterCount = 0;
|
||||
|
|
|
|||
|
|
@ -6,14 +6,13 @@
|
|||
*/
|
||||
|
||||
import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers';
|
||||
import os from 'os';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
isHttpMode,
|
||||
getHttpDimensions,
|
||||
httpEmbedQuery,
|
||||
} from '../../core/embeddings/http-client.js';
|
||||
import { resolveEmbeddingConfig } from '../../core/embeddings/config.js';
|
||||
import { applyHfEnvOverrides } from '../../core/embeddings/hf-env.js';
|
||||
import { silenceStdout, restoreStdout, realStderrWrite } from '../../core/lbug/pool-adapter.js';
|
||||
|
||||
// Model config
|
||||
|
|
@ -45,11 +44,11 @@ export const initEmbedder = async (): Promise<FeatureExtractionPipeline> => {
|
|||
initPromise = (async () => {
|
||||
try {
|
||||
env.allowLocalModels = false;
|
||||
// Default cache to user-writable location. transformers.js defaults to
|
||||
// ./node_modules/.cache inside its own install dir, which is unwritable
|
||||
// when gitnexus is installed globally (e.g. /usr/lib/node_modules/).
|
||||
// Respect HF_HOME if set, otherwise fall back to ~/.cache/huggingface.
|
||||
env.cacheDir = process.env.HF_HOME ?? join(os.homedir(), '.cache', 'huggingface');
|
||||
// Bridge user-controlled env vars to transformers.js: HF_HOME →
|
||||
// env.cacheDir, HF_ENDPOINT → env.remoteHost (#1205). Centralised in
|
||||
// applyHfEnvOverrides so this MCP entry point behaves identically to
|
||||
// the analyze pipeline embedder.
|
||||
applyHfEnvOverrides(env);
|
||||
const embeddingConfig = resolveEmbeddingConfig();
|
||||
|
||||
console.error('GitNexus: Loading embedding model (first search may take a moment)...');
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ export function createMCPServer(backend: LocalBackend): Server {
|
|||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
annotations: tool.annotations,
|
||||
})),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,12 @@
|
|||
* All tools support an optional `repo` parameter for multi-repo setups.
|
||||
*/
|
||||
|
||||
import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
annotations: ToolAnnotations;
|
||||
inputSchema: {
|
||||
type: 'object';
|
||||
properties: Record<
|
||||
|
|
@ -27,6 +30,27 @@ export interface ToolDefinition {
|
|||
};
|
||||
}
|
||||
|
||||
const READ_ONLY_TOOL_ANNOTATIONS: ToolAnnotations = {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
};
|
||||
|
||||
const QUERY_TOOL_ANNOTATIONS: ToolAnnotations = {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: true,
|
||||
};
|
||||
|
||||
const DESTRUCTIVE_TOOL_ANNOTATIONS: ToolAnnotations = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
};
|
||||
|
||||
export const GITNEXUS_TOOLS: ToolDefinition[] = [
|
||||
{
|
||||
name: 'list_repos',
|
||||
|
|
@ -39,6 +63,7 @@ AFTER THIS: READ gitnexus://repo/{name}/context for the repo you want to work wi
|
|||
|
||||
When multiple repos are indexed, you MUST specify the "repo" parameter
|
||||
on other tools (query, context, impact, etc.) to target the correct one.`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
|
|
@ -63,6 +88,7 @@ Hybrid ranking: BM25 keyword + semantic vector search, ranked by Reciprocal Rank
|
|||
GROUP MODE: set "repo" to "@<groupName>" to search all member repos in that group (merged via RRF), or "@<groupName>/<groupRepoPath>" to run against a single member (same path keys as in group.yaml). If you use "@<groupName>" only, the member repo defaults to the lexicographically first key in group.yaml "repos". Prefer resources for contracts/status (see migration from legacy group_* tools).
|
||||
|
||||
SERVICE: optional monorepo path prefix (POSIX-style, case-sensitive segments). When "repo" starts with "@", only processes whose symbols fall under that prefix are included. For a normal indexed repo name (no leading @), this field is currently ignored by the server.`,
|
||||
annotations: QUERY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -156,6 +182,7 @@ TIPS:
|
|||
- Community = auto-detected functional area (Leiden algorithm). Properties: heuristicLabel, cohesion, symbolCount, keywords, description, enrichedBy
|
||||
- Process = execution flow trace from entry point to terminal. Properties: heuristicLabel, processType, stepCount, communities, entryPointId, terminalId
|
||||
- Use heuristicLabel (not label) for human-readable community/process names`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -183,6 +210,7 @@ NOTE: ACCESSES edges (field read/write tracking) are included in context results
|
|||
GROUP MODE: set "repo" to "@<groupName>" to run context in each member repo (aggregated list), or "@<groupName>/<groupRepoPath>" for one member. If you use "@<groupName>" only, the member defaults to the lexicographically first key in group.yaml "repos".
|
||||
|
||||
SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", prefix-matches resolved symbol file paths; when a hit is outside the prefix, that member returns an empty payload for the symbol. Ignored for a normal indexed repo name.`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -226,6 +254,7 @@ WHEN TO USE: Before committing — to understand what your changes affect. Pre-c
|
|||
AFTER THIS: Review affected processes. Use context() on high-risk symbols. READ gitnexus://repo/{name}/process/{name} for full traces.
|
||||
|
||||
Returns: changed symbols, affected processes, and a risk summary.`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -258,6 +287,7 @@ AFTER THIS: Run detect_changes() to verify no unexpected side effects.
|
|||
Each edit is tagged with confidence:
|
||||
- "graph": found via knowledge graph relationships (high confidence, safe to accept)
|
||||
- "text_search": found via regex text search (lower confidence, review carefully)`,
|
||||
annotations: DESTRUCTIVE_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -311,6 +341,7 @@ Confidence: 1.0 = certain, <0.8 = fuzzy match
|
|||
GROUP MODE: set "repo" to "@<groupName>" for cross-repo impact anchored at the default member (lexicographically first key in group.yaml "repos"), or "@<groupName>/<groupRepoPath>" to choose the member (same path keys as in group.yaml). Phase-1 walk runs in that member; cross-boundary fan-out uses the group bridge.
|
||||
|
||||
SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", scopes the local impact walk and cross-repo symbol paths to files under that prefix; ignored for a normal indexed repo name.`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -404,6 +435,7 @@ WHEN TO USE: Understanding API consumption patterns, finding orphaned routes. Fo
|
|||
AFTER THIS: Use impact() on specific route handlers to see full blast radius.
|
||||
|
||||
Returns: route nodes with their handlers, middleware wrapper chains (e.g., withAuth, withRateLimit), and consumers.`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -426,6 +458,7 @@ Returns: route nodes with their handlers, middleware wrapper chains (e.g., withA
|
|||
WHEN TO USE: Understanding tool APIs, finding tool implementations, impact analysis for tool changes.
|
||||
|
||||
Returns: tool nodes with their handler files and descriptions.`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -443,6 +476,7 @@ WHEN TO USE: Detecting mismatches between what an API route returns and what con
|
|||
REQUIRES: Route nodes with responseKeys (extracted from .json({...}) calls during indexing).
|
||||
|
||||
Returns routes that have both detected response keys AND consumers. Shows top-level keys each endpoint returns (e.g., data, pagination, error) and what keys each consumer accesses. Reports MISMATCH status when a consumer accesses keys not present in the route's response shape.`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -467,6 +501,7 @@ WHEN TO USE: BEFORE modifying any API route handler. Shows what consumers depend
|
|||
Risk levels: LOW (0-3 consumers), MEDIUM (4-9 or any mismatches), HIGH (10+ consumers or mismatches with 4+ consumers). Mismatches with confidence "low" indicate the consumer file fetches multiple routes — property attribution is approximate.
|
||||
|
||||
Returns: single route object when one match, or { routes: [...], total: N } for multiple matches. Combines route_map, shape_check, and impact data.`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -482,6 +517,7 @@ Returns: single route object when one match, or { routes: [...], total: N } for
|
|||
description: `List all configured repository groups, or return details for one group (repos, manifest links).
|
||||
|
||||
WHEN TO USE: Discover groups before group_sync. Optional "name" returns a single group's config.`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -495,6 +531,9 @@ WHEN TO USE: Discover groups before group_sync. Optional "name" returns a single
|
|||
description: `Rebuild the Contract Registry (contracts.json) for a group: extract HTTP contracts, apply manifest links, exact-match cross-links.
|
||||
|
||||
WHEN TO USE: After changing group.yaml or re-indexing member repos.`,
|
||||
// Writes contracts.json on every call; conservatively non-idempotent
|
||||
// even though output is deterministic for identical input.
|
||||
annotations: DESTRUCTIVE_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
|
|||
|
|
@ -93,6 +93,34 @@ export const getGitRoot = (fromPath: string): string | null => {
|
|||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Find a git root by checking only `.git` entries on the ancestor chain.
|
||||
*
|
||||
* Unlike `getGitRoot`, this does not spawn `git`, so MCP can cheaply decide
|
||||
* whether a launch cwd is a worktree before running any subprocess there.
|
||||
*/
|
||||
export const findGitRootByDotGit = (fromPath: string): string | null => {
|
||||
let current = path.resolve(fromPath);
|
||||
try {
|
||||
if (!statSync(current).isDirectory()) {
|
||||
current = path.dirname(current);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
statSync(path.join(current, '.git'));
|
||||
return current;
|
||||
} catch {
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return null;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Check whether a directory contains a .git entry (file or folder).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export interface RegistryEntry {
|
|||
}
|
||||
|
||||
const GITNEXUS_DIR = '.gitnexus';
|
||||
const GITNEXUS_EXCLUDE_ENTRY = `${GITNEXUS_DIR}/`;
|
||||
|
||||
// ─── Local Storage Helpers ─────────────────────────────────────────────
|
||||
|
||||
|
|
@ -238,23 +239,45 @@ export const findRepo = async (startPath: string): Promise<IndexedRepo | null> =
|
|||
};
|
||||
|
||||
/**
|
||||
* Add .gitnexus to .gitignore if not already present
|
||||
* Keep generated index files ignored without modifying the user's root .gitignore.
|
||||
*/
|
||||
export const addToGitignore = async (repoPath: string): Promise<void> => {
|
||||
const gitignorePath = path.join(repoPath, '.gitignore');
|
||||
export const ensureGitNexusIgnored = async (repoPath: string): Promise<void> => {
|
||||
const gitignorePath = path.join(getStoragePath(repoPath), '.gitignore');
|
||||
|
||||
await fs.mkdir(path.dirname(gitignorePath), { recursive: true });
|
||||
await fs.writeFile(gitignorePath, '*\n', 'utf-8');
|
||||
|
||||
await ensureGitInfoExclude(repoPath);
|
||||
};
|
||||
|
||||
const ensureGitInfoExclude = async (repoPath: string): Promise<void> => {
|
||||
const gitDirPath = path.join(path.resolve(repoPath), '.git');
|
||||
const excludePath = path.join(gitDirPath, 'info', 'exclude');
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(gitignorePath, 'utf-8');
|
||||
if (content.includes(GITNEXUS_DIR)) return;
|
||||
|
||||
const newContent = content.endsWith('\n')
|
||||
? `${content}${GITNEXUS_DIR}\n`
|
||||
: `${content}\n${GITNEXUS_DIR}\n`;
|
||||
await fs.writeFile(gitignorePath, newContent, 'utf-8');
|
||||
const gitDir = await fs.stat(gitDirPath);
|
||||
if (!gitDir.isDirectory()) return;
|
||||
} catch {
|
||||
// .gitignore doesn't exist, create it
|
||||
await fs.writeFile(gitignorePath, `${GITNEXUS_DIR}\n`, 'utf-8');
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(excludePath), { recursive: true });
|
||||
|
||||
let content = '';
|
||||
try {
|
||||
content = await fs.readFile(excludePath, 'utf-8');
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'ENOENT') throw err;
|
||||
}
|
||||
|
||||
const excludes = content
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith('#'));
|
||||
if (excludes.includes(GITNEXUS_DIR) || excludes.includes(GITNEXUS_EXCLUDE_ENTRY)) return;
|
||||
|
||||
const separator = content.length === 0 || content.endsWith('\n') ? '' : '\n';
|
||||
await fs.writeFile(excludePath, `${content}${separator}${GITNEXUS_EXCLUDE_ENTRY}\n`, 'utf-8');
|
||||
};
|
||||
|
||||
// ─── Global Registry (~/.gitnexus/registry.json) ───────────────────────
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
def verify_cron_secret(token):
|
||||
return token == "expected"
|
||||
|
||||
|
||||
def get_org_id_from_header(headers):
|
||||
return headers.get("x-org-id") if headers else None
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
def send_daily_alerts():
|
||||
return "sent"
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
from auth_utils import verify_cron_secret, get_org_id_from_header
|
||||
from services.sync import _start_cron_run, _complete_cron_run
|
||||
from services.alerts import _create_ops_alert
|
||||
from routers.alerts import send_daily_alerts
|
||||
|
||||
|
||||
def handler_a():
|
||||
if verify_cron_secret("x"):
|
||||
org = get_org_id_from_header(None)
|
||||
_start_cron_run("a")
|
||||
_create_ops_alert("a")
|
||||
send_daily_alerts()
|
||||
_complete_cron_run("a")
|
||||
return org
|
||||
|
||||
|
||||
def handler_b():
|
||||
_start_cron_run("b")
|
||||
_create_ops_alert("b")
|
||||
send_daily_alerts()
|
||||
|
||||
|
||||
def handler_c():
|
||||
_start_cron_run("c")
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
def _create_ops_alert(name):
|
||||
return f"alert:{name}"
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
def _start_cron_run(name):
|
||||
return f"started:{name}"
|
||||
|
||||
|
||||
def _complete_cron_run(name):
|
||||
return f"completed:{name}"
|
||||
|
|
@ -198,6 +198,8 @@ describe('CLI end-to-end', () => {
|
|||
const gitnexusDir = path.join(MINI_REPO, '.gitnexus');
|
||||
expect(fs.existsSync(gitnexusDir)).toBe(true);
|
||||
expect(fs.statSync(gitnexusDir).isDirectory()).toBe(true);
|
||||
expect(fs.existsSync(path.join(MINI_REPO, '.gitignore'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(gitnexusDir, '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
}, 60_000);
|
||||
|
||||
// Regression guard for issue #1169 — analyze must produce BOTH a
|
||||
|
|
|
|||
|
|
@ -12,6 +12,16 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
|
|||
csharp: new Set([
|
||||
'emits the using-import edge App/Program.cs -> Models/User.cs through the scope-resolution path',
|
||||
]),
|
||||
python: new Set([
|
||||
// Suffix-fallback lex tiebreak depends on the registry-primary
|
||||
// resolver's deterministic sort. The legacy resolver returns the
|
||||
// first match in `Set` iteration order, which is insertion-order
|
||||
// dependent and not aligned with this guarantee. Backporting the
|
||||
// sort to legacy is out of scope.
|
||||
'picks the lexicographically smaller path on equal-depth ties',
|
||||
'binds the call to alpha/services/sync.py, not omega',
|
||||
'lex tiebreak still picks alpha/services/sync.py with reversed file-write order',
|
||||
]),
|
||||
};
|
||||
|
||||
type ResolverParityEnv = Readonly<Record<string, string | undefined>>;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
/**
|
||||
* Python: relative imports + class inheritance + ambiguous module disambiguation
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { describe, expect, beforeAll, afterAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import {
|
||||
FIXTURES,
|
||||
CROSS_FILE_FIXTURES,
|
||||
createResolverParityIt,
|
||||
getRelationships,
|
||||
getNodesByLabel,
|
||||
getNodesByLabelFull,
|
||||
|
|
@ -16,6 +17,11 @@ import {
|
|||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
// Mirrors `csharp.test.ts`: skips tests in `LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.python`
|
||||
// when the legacy-resolver parity sweep runs (`REGISTRY_PRIMARY_PYTHON=0`). For the
|
||||
// default registry-primary CI run this is a transparent passthrough to vitest's `it`.
|
||||
const it = createResolverParityIt('python');
|
||||
|
||||
function writeFixtureRepo(root: string, files: Record<string, string>): void {
|
||||
for (const [relPath, content] of Object.entries(files)) {
|
||||
const fullPath = path.join(root, relPath);
|
||||
|
|
@ -416,6 +422,432 @@ describe('Python ancestor directory import resolution (Issue #417)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-segment ancestor walk: `from services.sync import X` style imports
|
||||
// from a sibling sub-package nested under a shared root directory.
|
||||
//
|
||||
// Before this fix, single-segment ancestor walks worked (`from middleware
|
||||
// import X` from `backend/services/auth.py` → `backend/middleware.py`) but
|
||||
// multi-segment dotted imports were only resolved against the workspace
|
||||
// root. In a `backend/`-prefixed repo, `from services.sync import X` from
|
||||
// `backend/routers/cron.py` would silently drop because `services/sync.py`
|
||||
// does not exist at the workspace root — only `backend/services/sync.py`
|
||||
// does. The fix mirrors the single-segment ancestor walk for multi-segment
|
||||
// paths.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Python multi-segment ancestor directory import resolution', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'python-multi-segment-ancestor-import'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('resolves from services.sync import to backend/services/sync.py via ancestor walk', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const syncImport = imports.find(
|
||||
(i) =>
|
||||
i.sourceFilePath === 'backend/routers/cron.py' &&
|
||||
i.targetFilePath === 'backend/services/sync.py',
|
||||
);
|
||||
expect(syncImport).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves from services.alerts import to backend/services/alerts.py via ancestor walk', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const alertsImport = imports.find(
|
||||
(i) =>
|
||||
i.sourceFilePath === 'backend/routers/cron.py' &&
|
||||
i.targetFilePath === 'backend/services/alerts.py',
|
||||
);
|
||||
expect(alertsImport).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves from routers.alerts import to backend/routers/alerts.py (sibling sub-package)', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const routerImport = imports.find(
|
||||
(i) =>
|
||||
i.sourceFilePath === 'backend/routers/cron.py' &&
|
||||
i.targetFilePath === 'backend/routers/alerts.py',
|
||||
);
|
||||
expect(routerImport).toBeDefined();
|
||||
});
|
||||
|
||||
it('emits CALLS edges for every multi-segment-imported callee', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.sourceFilePath === 'backend/routers/cron.py',
|
||||
);
|
||||
|
||||
const startCronRunCalls = calls.filter((c) => c.target === '_start_cron_run');
|
||||
expect(startCronRunCalls.length).toBe(3);
|
||||
expect(startCronRunCalls.every((c) => c.targetFilePath === 'backend/services/sync.py')).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const completeCronRunCalls = calls.filter((c) => c.target === '_complete_cron_run');
|
||||
expect(completeCronRunCalls.length).toBe(1);
|
||||
expect(completeCronRunCalls[0].targetFilePath).toBe('backend/services/sync.py');
|
||||
|
||||
const opsAlertCalls = calls.filter((c) => c.target === '_create_ops_alert');
|
||||
expect(opsAlertCalls.length).toBe(2);
|
||||
expect(opsAlertCalls.every((c) => c.targetFilePath === 'backend/services/alerts.py')).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const sendDailyCalls = calls.filter((c) => c.target === 'send_daily_alerts');
|
||||
expect(sendDailyCalls.length).toBe(2);
|
||||
expect(sendDailyCalls.every((c) => c.targetFilePath === 'backend/routers/alerts.py')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves single-segment ancestor walk (regression check for from auth_utils import X)', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.sourceFilePath === 'backend/routers/cron.py',
|
||||
);
|
||||
|
||||
const verifyCalls = calls.filter((c) => c.target === 'verify_cron_secret');
|
||||
expect(verifyCalls.length).toBe(1);
|
||||
expect(verifyCalls[0].targetFilePath).toBe('backend/auth_utils.py');
|
||||
|
||||
const orgCalls = calls.filter((c) => c.target === 'get_org_id_from_header');
|
||||
expect(orgCalls.length).toBe(1);
|
||||
expect(orgCalls[0].targetFilePath).toBe('backend/auth_utils.py');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Negative case for `hasRepoCandidate` widening: a vendored copy of an
|
||||
// external package (e.g. `vendor/django/urls.py`) must not cause an external
|
||||
// import like `from django.urls import path` issued from an unrelated file
|
||||
// (`app/main.py`) to be treated as a local candidate. The ancestor-bounded
|
||||
// nested check rejects vendored matches that don't sit on the importer's
|
||||
// own ancestor path.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Python multi-segment widening: vendored external package false-positive guard', () => {
|
||||
let repoDir: string;
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-vendored-django-'));
|
||||
writeFixtureRepo(repoDir, {
|
||||
'app/main.py': `from django.urls import path
|
||||
|
||||
def boot():
|
||||
path("/")
|
||||
`,
|
||||
'vendor/django/__init__.py': '',
|
||||
'vendor/django/urls.py': `def path(p):
|
||||
return p
|
||||
`,
|
||||
});
|
||||
result = await runPipelineFromRepo(repoDir, () => {});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('does not resolve from django.urls to vendor/django/urls.py from an unrelated importer', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const stray = imports.find(
|
||||
(i) => i.sourceFilePath === 'app/main.py' && i.targetFilePath === 'vendor/django/urls.py',
|
||||
);
|
||||
expect(stray).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not emit a CALLS edge from app/main.py:boot to vendor/django/urls.py:path', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.sourceFilePath === 'app/main.py',
|
||||
);
|
||||
const stray = calls.find(
|
||||
(c) => c.target === 'path' && c.targetFilePath === 'vendor/django/urls.py',
|
||||
);
|
||||
expect(stray).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace-root precedence: when both `services/sync.py` (root) and
|
||||
// `backend/services/sync.py` (ancestor) exist, an importer at
|
||||
// `backend/routers/cron.py` doing `from services.sync import X` resolves to
|
||||
// the root file. Mirrors Python's `sys.path` semantics where the project
|
||||
// root is searched before package-local namespaces.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Python multi-segment resolution: workspace root wins over ancestor', () => {
|
||||
let repoDir: string;
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-root-precedence-'));
|
||||
writeFixtureRepo(repoDir, {
|
||||
'services/__init__.py': '',
|
||||
'services/sync.py': `def root_marker():
|
||||
return "root"
|
||||
`,
|
||||
'backend/__init__.py': '',
|
||||
'backend/services/__init__.py': '',
|
||||
'backend/services/sync.py': `def ancestor_marker():
|
||||
return "ancestor"
|
||||
`,
|
||||
'backend/routers/__init__.py': '',
|
||||
'backend/routers/cron.py': `from services.sync import root_marker
|
||||
|
||||
def handler():
|
||||
return root_marker()
|
||||
`,
|
||||
});
|
||||
result = await runPipelineFromRepo(repoDir, () => {});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('resolves the import edge to the root services/sync.py, not backend/services/sync.py', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS').filter(
|
||||
(i) => i.sourceFilePath === 'backend/routers/cron.py',
|
||||
);
|
||||
const rootEdge = imports.find((i) => i.targetFilePath === 'services/sync.py');
|
||||
expect(rootEdge).toBeDefined();
|
||||
|
||||
const ancestorEdge = imports.find((i) => i.targetFilePath === 'backend/services/sync.py');
|
||||
expect(ancestorEdge).toBeUndefined();
|
||||
});
|
||||
|
||||
it('binds the imported name to the root file, not the ancestor copy', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.sourceFilePath === 'backend/routers/cron.py' && c.target === 'root_marker',
|
||||
);
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0].targetFilePath).toBe('services/sync.py');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suffix-fallback determinism: when both root + ancestor walk miss but the
|
||||
// suffix scan finds multiple candidates in unrelated trees, the resolver
|
||||
// must pick the same file regardless of file-set insertion order. The
|
||||
// previous implementation returned the first match in `Set` iteration
|
||||
// order, which depended on file ingestion order and produced flapping
|
||||
// edges across runs in multi-directory collision repos.
|
||||
//
|
||||
// Tie-break order: fewest path segments, then lexicographic.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Python multi-segment resolution: suffix fallback determinism', () => {
|
||||
let repoDir: string;
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-suffix-determinism-'));
|
||||
writeFixtureRepo(repoDir, {
|
||||
// Importer's package. The `app/services/marker.py` file makes the
|
||||
// `services` segment gate-pass under the ancestor-bounded
|
||||
// `hasRepoCandidate` check, but `app/services/sync.py` is
|
||||
// intentionally absent so the ancestor walk misses and the suffix
|
||||
// fallback fires.
|
||||
'app/services/marker.py': `def _marker(): return True
|
||||
`,
|
||||
'app/main.py': `from services.sync import handler
|
||||
|
||||
def boot():
|
||||
return handler()
|
||||
`,
|
||||
// Two suffix candidates outside the importer's ancestor tree.
|
||||
// `lib/services/sync.py` has 3 path segments, the alternative has
|
||||
// 4 — the deterministic pick is `lib/services/sync.py`.
|
||||
'lib/services/sync.py': `def handler():
|
||||
return "lib"
|
||||
`,
|
||||
'tooling/extras/services/sync.py': `def handler():
|
||||
return "tooling"
|
||||
`,
|
||||
});
|
||||
result = await runPipelineFromRepo(repoDir, () => {});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('picks the shortest-path candidate (lib/services/sync.py) and only that one', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS').filter(
|
||||
(i) => i.sourceFilePath === 'app/main.py',
|
||||
);
|
||||
|
||||
const libEdge = imports.find((i) => i.targetFilePath === 'lib/services/sync.py');
|
||||
expect(libEdge).toBeDefined();
|
||||
|
||||
const toolingEdge = imports.find((i) => i.targetFilePath === 'tooling/extras/services/sync.py');
|
||||
expect(toolingEdge).toBeUndefined();
|
||||
});
|
||||
|
||||
it('binds the call to the deterministic pick, not the alternate copy', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.sourceFilePath === 'app/main.py' && c.target === 'handler',
|
||||
);
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0].targetFilePath).toBe('lib/services/sync.py');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lexicographic tiebreak: when two suffix candidates have the same
|
||||
// directory depth, the lexicographically smaller path wins. Without this,
|
||||
// equal-depth collisions would still depend on file-set insertion order.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Python multi-segment resolution: suffix fallback lexicographic tiebreak', () => {
|
||||
let repoDir: string;
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-suffix-lex-tiebreak-'));
|
||||
writeFixtureRepo(repoDir, {
|
||||
// Same gate-passing pattern as the determinism test — non-init
|
||||
// marker file makes the `services` segment satisfy
|
||||
// `hasRepoCandidate` for an importer at `app/main.py`.
|
||||
'app/services/marker.py': `def _marker(): return True
|
||||
`,
|
||||
'app/main.py': `from services.sync import handler
|
||||
|
||||
def boot():
|
||||
return handler()
|
||||
`,
|
||||
// Both candidates have depth 3, so directory-depth alone cannot
|
||||
// disambiguate. Lexicographic order picks `alpha/...` over
|
||||
// `omega/...` regardless of which file was ingested first.
|
||||
'alpha/services/sync.py': `def handler():
|
||||
return "alpha"
|
||||
`,
|
||||
'omega/services/sync.py': `def handler():
|
||||
return "omega"
|
||||
`,
|
||||
});
|
||||
result = await runPipelineFromRepo(repoDir, () => {});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('picks the lexicographically smaller path on equal-depth ties', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS').filter(
|
||||
(i) => i.sourceFilePath === 'app/main.py',
|
||||
);
|
||||
|
||||
const alphaEdge = imports.find((i) => i.targetFilePath === 'alpha/services/sync.py');
|
||||
expect(alphaEdge).toBeDefined();
|
||||
|
||||
const omegaEdge = imports.find((i) => i.targetFilePath === 'omega/services/sync.py');
|
||||
expect(omegaEdge).toBeUndefined();
|
||||
});
|
||||
|
||||
it('binds the call to alpha/services/sync.py, not omega', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.sourceFilePath === 'app/main.py' && c.target === 'handler',
|
||||
);
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0].targetFilePath).toBe('alpha/services/sync.py');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Insertion-order independence: re-runs the depth and lexicographic
|
||||
// scenarios with the candidate files written in reverse order. The
|
||||
// deterministic sort in `resolveAbsoluteFromFiles` should pick the same
|
||||
// winner regardless. If a future refactor accidentally drops the sort
|
||||
// and falls back to `Set` insertion order, these tests pin the
|
||||
// regression directly.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Python multi-segment resolution: suffix fallback insertion-order independence', () => {
|
||||
let depthRepoDir: string;
|
||||
let lexRepoDir: string;
|
||||
let depthResult: PipelineResult;
|
||||
let lexResult: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Depth scenario, files written in reverse order: tooling first, lib
|
||||
// second. `writeFixtureRepo` iterates in object-property insertion
|
||||
// order, and the pipeline scanner's directory traversal is also
|
||||
// affected by mtime/inode order on most filesystems. The expected
|
||||
// winner is still `lib/services/sync.py` (depth 3 < depth 4).
|
||||
depthRepoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-suffix-determinism-rev-'));
|
||||
writeFixtureRepo(depthRepoDir, {
|
||||
'tooling/extras/services/sync.py': `def handler():
|
||||
return "tooling"
|
||||
`,
|
||||
'lib/services/sync.py': `def handler():
|
||||
return "lib"
|
||||
`,
|
||||
'app/services/marker.py': `def _marker(): return True
|
||||
`,
|
||||
'app/main.py': `from services.sync import handler
|
||||
|
||||
def boot():
|
||||
return handler()
|
||||
`,
|
||||
});
|
||||
depthResult = await runPipelineFromRepo(depthRepoDir, () => {});
|
||||
|
||||
// Lexicographic scenario, files written in reverse order: omega first.
|
||||
// Expected winner is still `alpha/services/sync.py`.
|
||||
lexRepoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-suffix-lex-rev-'));
|
||||
writeFixtureRepo(lexRepoDir, {
|
||||
'omega/services/sync.py': `def handler():
|
||||
return "omega"
|
||||
`,
|
||||
'alpha/services/sync.py': `def handler():
|
||||
return "alpha"
|
||||
`,
|
||||
'app/services/marker.py': `def _marker(): return True
|
||||
`,
|
||||
'app/main.py': `from services.sync import handler
|
||||
|
||||
def boot():
|
||||
return handler()
|
||||
`,
|
||||
});
|
||||
lexResult = await runPipelineFromRepo(lexRepoDir, () => {});
|
||||
}, 120000);
|
||||
|
||||
afterAll(() => {
|
||||
if (depthRepoDir !== undefined) fs.rmSync(depthRepoDir, { recursive: true, force: true });
|
||||
if (lexRepoDir !== undefined) fs.rmSync(lexRepoDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('depth tiebreak still picks lib/services/sync.py with reversed file-write order', () => {
|
||||
const imports = getRelationships(depthResult, 'IMPORTS').filter(
|
||||
(i) => i.sourceFilePath === 'app/main.py',
|
||||
);
|
||||
|
||||
const libEdge = imports.find((i) => i.targetFilePath === 'lib/services/sync.py');
|
||||
expect(libEdge).toBeDefined();
|
||||
|
||||
const toolingEdge = imports.find((i) => i.targetFilePath === 'tooling/extras/services/sync.py');
|
||||
expect(toolingEdge).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lex tiebreak still picks alpha/services/sync.py with reversed file-write order', () => {
|
||||
const imports = getRelationships(lexResult, 'IMPORTS').filter(
|
||||
(i) => i.sourceFilePath === 'app/main.py',
|
||||
);
|
||||
|
||||
const alphaEdge = imports.find((i) => i.targetFilePath === 'alpha/services/sync.py');
|
||||
expect(alphaEdge).toBeDefined();
|
||||
|
||||
const omegaEdge = imports.find((i) => i.targetFilePath === 'omega/services/sync.py');
|
||||
expect(omegaEdge).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-export chain: from .base import X barrel pattern via __init__.py
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { execSync } from 'child_process';
|
||||
import { isGitRepo, getCurrentCommit, getGitRoot } from '../../src/storage/git.js';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import {
|
||||
isGitRepo,
|
||||
getCurrentCommit,
|
||||
getGitRoot,
|
||||
findGitRootByDotGit,
|
||||
} from '../../src/storage/git.js';
|
||||
|
||||
// Mock child_process.execSync
|
||||
vi.mock('child_process', () => ({
|
||||
|
|
@ -92,4 +100,68 @@ describe('git utilities', () => {
|
|||
expect(result!.trim()).toBe(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findGitRootByDotGit', () => {
|
||||
it('finds an ancestor .git directory without spawning git', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-dotgit-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(tmpDir, '.git'));
|
||||
const nested = path.join(tmpDir, 'packages', 'app');
|
||||
fs.mkdirSync(nested, { recursive: true });
|
||||
|
||||
expect(findGitRootByDotGit(nested)).toBe(path.resolve(tmpDir));
|
||||
expect(mockExecSync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null outside a git worktree without spawning git', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-nonrepo-'));
|
||||
try {
|
||||
expect(findGitRootByDotGit(tmpDir)).toBeNull();
|
||||
expect(mockExecSync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Linked worktrees and submodules use a `.git` file (not directory) that
|
||||
// points at the real gitdir. statSync succeeds for both, so the ancestor
|
||||
// walk should treat such roots identically to ordinary repos.
|
||||
it('treats a .git file (linked worktree) as a valid root', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worktree-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(tmpDir, '.git'), 'gitdir: /fake/worktrees/wt\n');
|
||||
const nested = path.join(tmpDir, 'src', 'pkg');
|
||||
fs.mkdirSync(nested, { recursive: true });
|
||||
|
||||
expect(findGitRootByDotGit(nested)).toBe(path.resolve(tmpDir));
|
||||
expect(mockExecSync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null when the input path does not exist', () => {
|
||||
const missing = path.join(os.tmpdir(), `gitnexus-missing-${Date.now()}-${Math.random()}`);
|
||||
expect(findGitRootByDotGit(missing)).toBeNull();
|
||||
expect(mockExecSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('walks from a file input by starting at its parent directory', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-fileinput-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(tmpDir, '.git'));
|
||||
const filePath = path.join(tmpDir, 'pkg', 'index.ts');
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, 'export {};\n');
|
||||
|
||||
expect(findGitRootByDotGit(filePath)).toBe(path.resolve(tmpDir));
|
||||
expect(mockExecSync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -613,6 +613,69 @@ export class AuthGateway {
|
|||
expect(contracts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #1185: gRPC extractor must honour .gitnexusignore ──────────────
|
||||
//
|
||||
// Both the `.proto` glob (in `buildProtoContext`) and the source-scan
|
||||
// glob (in `extract`) used a hardcoded ignore array that bypassed
|
||||
// `IgnoreService`. Both globs now consume the shared filter (mirrors
|
||||
// `filesystem-walker.ts`) so any `.gitnexusignore` pattern is
|
||||
// honoured. The single test below exercises BOTH paths in the same
|
||||
// run: a `.proto` under `mentor_env/` (proto-context build) AND a
|
||||
// Python `_pb2_grpc.<Name>Stub` consumer under `mentor_env/`
|
||||
// (source-scan path) — neither produces a contract.
|
||||
describe('respects .gitnexusignore (#1185)', () => {
|
||||
it('proto + source globs both skip files matched by .gitnexusignore', async () => {
|
||||
// Control: a regular .proto in a non-ignored dir.
|
||||
writeFile(
|
||||
'proto/auth.proto',
|
||||
`syntax = "proto3";
|
||||
package auth;
|
||||
service AuthService {
|
||||
rpc Login (LoginRequest) returns (LoginResponse);
|
||||
}`,
|
||||
);
|
||||
// Vendored proto under a venv-style dir — exercises proto-context glob.
|
||||
writeFile(
|
||||
'mentor_env/lib/leaked.proto',
|
||||
`syntax = "proto3";
|
||||
package leaked;
|
||||
service LeakedService {
|
||||
rpc Ping (PingRequest) returns (PingResponse);
|
||||
}`,
|
||||
);
|
||||
// Vendored Python consumer under the same venv-style dir —
|
||||
// exercises the second glob in `extract()` (source-scan path).
|
||||
// Mirrors the canonical pattern from
|
||||
// `test_extract_python_stub_returns_consumer` above; without the
|
||||
// `.gitnexusignore` filter this WOULD emit a `grpc::*/LeakedService`
|
||||
// consumer contract.
|
||||
writeFile(
|
||||
'mentor_env/lib/leaked_consumer.py',
|
||||
`import grpc
|
||||
from proto import leaked_pb2_grpc
|
||||
|
||||
channel = grpc.insecure_channel('localhost:50051')
|
||||
stub = leaked_pb2_grpc.LeakedServiceStub(channel)`,
|
||||
);
|
||||
writeFile('.gitnexusignore', 'mentor_env/\n');
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
// Control proto provider is still emitted.
|
||||
expect(contracts.find((c) => c.contractId === 'grpc::auth.AuthService/Login')).toBeDefined();
|
||||
// Defence-in-depth: no contract — provider OR consumer — has a
|
||||
// `symbolRef` path under the ignored directory. Catches both globs
|
||||
// at once.
|
||||
expect(contracts.some((c) => c.symbolRef?.filePath?.startsWith('mentor_env/'))).toBe(false);
|
||||
// Specific assertions per glob path.
|
||||
expect(
|
||||
contracts.find((c) => c.contractId === 'grpc::leaked.LeakedService/Ping'),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
contracts.some((c) => c.role === 'consumer' && /LeakedService/.test(c.contractId)),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildProtoMap', () => {
|
||||
|
|
|
|||
|
|
@ -729,4 +729,90 @@ router.get('/api/posts/{postId}', handler2);
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #1185: contract extractors must honour .gitnexusignore ─────────
|
||||
//
|
||||
// Pre-#1185 the source-scan path used a hardcoded
|
||||
// `[node_modules, .git, dist, build, vendor]` glob ignore array, so a
|
||||
// user's `.gitnexusignore` pattern (e.g. a Python venv `mentor_env/`,
|
||||
// a generated stubs dir, a noisy fixture tree) was silently scanned
|
||||
// anyway. Since #1185 the source-scan path consumes the shared
|
||||
// `IgnoreService` (mirrors `filesystem-walker.ts`), so any pattern in
|
||||
// `.gitnexusignore` (or `.gitignore`) prunes the glob.
|
||||
describe('respects .gitnexusignore (#1185)', () => {
|
||||
it('source-scan glob skips files matched by .gitnexusignore', async () => {
|
||||
const dir = path.join(tmpDir, 'gitnexusignore-honoured');
|
||||
fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, 'mentor_env/lib'), { recursive: true });
|
||||
// Control: a normal route file that SHOULD be discovered.
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src/routes/users.ts'),
|
||||
`import { Router } from 'express';
|
||||
const router = Router();
|
||||
router.get('/api/users', (req, res) => res.json([]));
|
||||
export default router;
|
||||
`,
|
||||
);
|
||||
// Vendored source under a venv-style dir: the same Express
|
||||
// pattern, but inside a directory the user wants excluded.
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'mentor_env/lib/leaked.ts'),
|
||||
`import { Router } from 'express';
|
||||
const r = Router();
|
||||
r.get('/api/leaked', (req, res) => res.json([]));
|
||||
export default r;
|
||||
`,
|
||||
);
|
||||
fs.writeFileSync(path.join(dir, '.gitnexusignore'), 'mentor_env/\n');
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const providers = contracts.filter((c) => c.role === 'provider');
|
||||
// Control survives.
|
||||
expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
|
||||
// Excluded path is pruned at the glob level — nothing emitted.
|
||||
expect(providers.find((c) => c.contractId === 'http::GET::/api/leaked')).toBeUndefined();
|
||||
// Defence-in-depth: no contract whose symbolRef is under mentor_env/.
|
||||
expect(contracts.some((c) => c.symbolRef?.filePath?.startsWith('mentor_env/'))).toBe(false);
|
||||
});
|
||||
|
||||
// Pinned by the @claude review on PR #1247: above, only `.gitnexusignore`
|
||||
// is exercised. `createIgnoreFilter` reads `.gitignore` too via
|
||||
// `loadIgnoreRules`, but that integration is only proven at the
|
||||
// `IgnoreService` level — no extractor-level test for the
|
||||
// `.gitignore`-only code path. Adding one minimal extractor-level
|
||||
// assertion here closes the gap (one shared test is sufficient
|
||||
// because all three extractors consume the same filter object).
|
||||
it('source-scan glob also skips files matched by `.gitignore` (no `.gitnexusignore`)', async () => {
|
||||
const dir = path.join(tmpDir, 'gitignore-honoured');
|
||||
fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, 'mentor_env/lib'), { recursive: true });
|
||||
// Same Express pattern as above so detection logic is identical.
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src/routes/users.ts'),
|
||||
`import { Router } from 'express';
|
||||
const router = Router();
|
||||
router.get('/api/users', (req, res) => res.json([]));
|
||||
export default router;
|
||||
`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'mentor_env/lib/leaked.ts'),
|
||||
`import { Router } from 'express';
|
||||
const r = Router();
|
||||
r.get('/api/leaked', (req, res) => res.json([]));
|
||||
export default r;
|
||||
`,
|
||||
);
|
||||
// Note: NO .gitnexusignore — only `.gitignore`. This proves the
|
||||
// `.gitignore` code path inside `createIgnoreFilter` is wired to
|
||||
// the extractors' globs.
|
||||
fs.writeFileSync(path.join(dir, '.gitignore'), 'mentor_env/\n');
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const providers = contracts.filter((c) => c.role === 'provider');
|
||||
expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
|
||||
expect(providers.find((c) => c.contractId === 'http::GET::/api/leaked')).toBeUndefined();
|
||||
expect(contracts.some((c) => c.symbolRef?.filePath?.startsWith('mentor_env/'))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -578,6 +578,164 @@ describe('ManifestExtractor', () => {
|
|||
expect(lowerContractId).toBe(upperContractId);
|
||||
});
|
||||
|
||||
it('resolves custom manifest links by exact symbol name', async () => {
|
||||
const links: GroupManifestLink[] = [
|
||||
{
|
||||
from: 'parser/mathlex',
|
||||
to: 'engine/thales',
|
||||
type: 'custom',
|
||||
contract: 'Expression',
|
||||
role: 'provider',
|
||||
},
|
||||
];
|
||||
|
||||
const dbExecutors = new Map<
|
||||
string,
|
||||
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
|
||||
>([
|
||||
[
|
||||
'engine/thales',
|
||||
async (_cypher, params) => {
|
||||
if (params?.contract === 'Expression') {
|
||||
return [
|
||||
{
|
||||
uid: 'uid-expression-struct',
|
||||
name: 'Expression',
|
||||
filePath: 'src/expression.rs',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
],
|
||||
[
|
||||
'parser/mathlex',
|
||||
async (_cypher, params) => {
|
||||
if (params?.contract === 'Expression') {
|
||||
return [
|
||||
{
|
||||
uid: 'uid-expression-enum',
|
||||
name: 'Expression',
|
||||
filePath: 'src/ast.rs',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const result = await extractor.extractFromManifest(links, dbExecutors);
|
||||
|
||||
const provider = result.contracts.find((c) => c.role === 'provider');
|
||||
const consumer = result.contracts.find((c) => c.role === 'consumer');
|
||||
|
||||
expect(provider?.symbolUid).toBe('uid-expression-enum');
|
||||
expect(provider?.symbolRef.filePath).toBe('src/ast.rs');
|
||||
|
||||
expect(consumer?.symbolUid).toBe('uid-expression-struct');
|
||||
expect(consumer?.symbolRef.filePath).toBe('src/expression.rs');
|
||||
|
||||
expect(result.crossLinks).toHaveLength(1);
|
||||
expect(result.crossLinks[0].matchType).toBe('manifest');
|
||||
});
|
||||
|
||||
it('falls back to synthetic uid when custom symbol not found in graph', async () => {
|
||||
const links: GroupManifestLink[] = [
|
||||
{
|
||||
from: 'core/units',
|
||||
to: 'engine/thales',
|
||||
type: 'custom',
|
||||
contract: 'Dimension',
|
||||
role: 'provider',
|
||||
},
|
||||
];
|
||||
|
||||
const dbExecutors = new Map<
|
||||
string,
|
||||
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
|
||||
>([
|
||||
['engine/thales', async () => []],
|
||||
['core/units', async () => []],
|
||||
]);
|
||||
|
||||
const result = await extractor.extractFromManifest(links, dbExecutors);
|
||||
|
||||
const provider = result.contracts.find((c) => c.role === 'provider');
|
||||
expect(provider?.symbolUid).toBe('manifest::core/units::custom::Dimension');
|
||||
});
|
||||
|
||||
it('custom contract query uses positive label allowlist (not negative exclusion)', async () => {
|
||||
const links: GroupManifestLink[] = [
|
||||
{
|
||||
from: 'parser/mathlex',
|
||||
to: 'engine/thales',
|
||||
type: 'custom',
|
||||
contract: 'Route',
|
||||
role: 'provider',
|
||||
},
|
||||
];
|
||||
let capturedCypher = '';
|
||||
const dbExecutors = new Map<
|
||||
string,
|
||||
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
|
||||
>([
|
||||
[
|
||||
'parser/mathlex',
|
||||
async (cypher) => {
|
||||
capturedCypher = cypher;
|
||||
return [];
|
||||
},
|
||||
],
|
||||
['engine/thales', async () => []],
|
||||
]);
|
||||
|
||||
await extractor.extractFromManifest(links, dbExecutors);
|
||||
|
||||
expect(capturedCypher).toContain('Function|Method|Class|Interface|Struct|Enum|Trait');
|
||||
expect(capturedCypher).not.toContain('NOT n:File');
|
||||
});
|
||||
|
||||
it('custom contract with ambiguous name returns first-by-filePath deterministically', async () => {
|
||||
const links: GroupManifestLink[] = [
|
||||
{
|
||||
from: 'parser/mathlex',
|
||||
to: 'engine/thales',
|
||||
type: 'custom',
|
||||
contract: 'Token',
|
||||
role: 'provider',
|
||||
},
|
||||
];
|
||||
|
||||
const dbExecutors = new Map<
|
||||
string,
|
||||
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
|
||||
>([
|
||||
[
|
||||
'parser/mathlex',
|
||||
async (_cypher, params) => {
|
||||
if (params?.contract === 'Token') {
|
||||
return [{ uid: 'uid-token-first', name: 'Token', filePath: 'src/ast.rs' }];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
],
|
||||
[
|
||||
'engine/thales',
|
||||
async (_cypher, params) => {
|
||||
if (params?.contract === 'Token') {
|
||||
return [{ uid: 'uid-token-consumer', name: 'Token', filePath: 'src/lexer.rs' }];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const result = await extractor.extractFromManifest(links, dbExecutors);
|
||||
const provider = result.contracts.find((c) => c.role === 'provider');
|
||||
expect(provider?.symbolUid).toBe('uid-token-first');
|
||||
});
|
||||
|
||||
it('returns empty for no links', async () => {
|
||||
const result = await extractor.extractFromManifest([]);
|
||||
expect(result.contracts).toHaveLength(0);
|
||||
|
|
|
|||
335
gitnexus/test/unit/group/rust-workspace-extractor.test.ts
Normal file
335
gitnexus/test/unit/group/rust-workspace-extractor.test.ts
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { extractRustWorkspaceLinks } from '../../../src/core/group/extractors/rust-workspace-extractor.js';
|
||||
|
||||
describe('RustWorkspaceExtractor', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-rust-ws-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeFile(relPath: string, content: string) {
|
||||
const absPath = path.join(tmpDir, relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(absPath, content, 'utf-8');
|
||||
}
|
||||
|
||||
it('discovers cross-crate imports from workspace dependencies', async () => {
|
||||
// Crate A: defines Expression
|
||||
await writeFile(
|
||||
'crate-a/Cargo.toml',
|
||||
`[package]\nname = "mathlex"\nversion = "0.1.0"\n\n[dependencies]\n`,
|
||||
);
|
||||
await writeFile('crate-a/src/lib.rs', 'pub struct Expression {}\npub struct Token {}\n');
|
||||
|
||||
// Crate B: depends on A via workspace, imports Expression
|
||||
await writeFile(
|
||||
'crate-b/Cargo.toml',
|
||||
`[package]\nname = "thales"\nversion = "0.1.0"\n\n[dependencies]\nmathlex = { workspace = true }\n`,
|
||||
);
|
||||
await writeFile('crate-b/src/main.rs', 'use mathlex::Expression;\nfn eval(e: Expression) {}\n');
|
||||
|
||||
const repos = {
|
||||
'parser/mathlex': 'mathlex',
|
||||
'engine/thales': 'thales',
|
||||
};
|
||||
const repoPaths = new Map([
|
||||
['parser/mathlex', path.join(tmpDir, 'crate-a')],
|
||||
['engine/thales', path.join(tmpDir, 'crate-b')],
|
||||
]);
|
||||
|
||||
const result = await extractRustWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0]).toEqual({
|
||||
from: 'parser/mathlex',
|
||||
to: 'engine/thales',
|
||||
type: 'custom',
|
||||
contract: 'mathlex::Expression',
|
||||
role: 'provider',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles hyphenated crate names (converted to underscores in use statements)', async () => {
|
||||
await writeFile(
|
||||
'units/Cargo.toml',
|
||||
`[package]\nname = "mathcore-units"\nversion = "0.1.0"\n\n[dependencies]\n`,
|
||||
);
|
||||
await writeFile('units/src/lib.rs', 'pub struct Unit {}\npub struct Dimension {}\n');
|
||||
|
||||
await writeFile(
|
||||
'engine/Cargo.toml',
|
||||
`[package]\nname = "thales"\nversion = "0.1.0"\n\n[dependencies]\nmathcore-units = { workspace = true }\n`,
|
||||
);
|
||||
await writeFile(
|
||||
'engine/src/main.rs',
|
||||
'use mathcore_units::Unit;\nuse mathcore_units::Dimension;\n',
|
||||
);
|
||||
|
||||
const repos = {
|
||||
'core/units': 'mathcore-units',
|
||||
'engine/thales': 'thales',
|
||||
};
|
||||
const repoPaths = new Map([
|
||||
['core/units', path.join(tmpDir, 'units')],
|
||||
['engine/thales', path.join(tmpDir, 'engine')],
|
||||
]);
|
||||
|
||||
const result = await extractRustWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(2);
|
||||
const contracts = result.links.map((l) => l.contract).sort();
|
||||
expect(contracts).toEqual(['mathcore-units::Dimension', 'mathcore-units::Unit']);
|
||||
});
|
||||
|
||||
it('handles grouped imports (use crate::{Type1, Type2})', async () => {
|
||||
await writeFile(
|
||||
'lib/Cargo.toml',
|
||||
`[package]\nname = "shared"\nversion = "0.1.0"\n\n[dependencies]\n`,
|
||||
);
|
||||
await writeFile('lib/src/lib.rs', 'pub struct Foo {}\npub struct Bar {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/Cargo.toml',
|
||||
`[package]\nname = "myapp"\nversion = "0.1.0"\n\n[dependencies]\nshared = { workspace = true }\n`,
|
||||
);
|
||||
await writeFile('app/src/main.rs', 'use shared::{Foo, Bar};\n');
|
||||
|
||||
const repos = { lib: 'shared', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractRustWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(2);
|
||||
const contracts = result.links.map((l) => l.contract).sort();
|
||||
expect(contracts).toEqual(['shared::Bar', 'shared::Foo']);
|
||||
});
|
||||
|
||||
it('ignores snake_case imports (functions/modules, not types)', async () => {
|
||||
await writeFile(
|
||||
'lib/Cargo.toml',
|
||||
`[package]\nname = "utils"\nversion = "0.1.0"\n\n[dependencies]\n`,
|
||||
);
|
||||
await writeFile('lib/src/lib.rs', 'pub fn helper() {}\npub struct Config {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/Cargo.toml',
|
||||
`[package]\nname = "myapp"\nversion = "0.1.0"\n\n[dependencies]\nutils = { workspace = true }\n`,
|
||||
);
|
||||
await writeFile('app/src/main.rs', 'use utils::helper;\nuse utils::Config;\n');
|
||||
|
||||
const repos = { lib: 'utils', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractRustWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('utils::Config');
|
||||
});
|
||||
|
||||
it('skips repos without Cargo.toml', async () => {
|
||||
await writeFile('js-app/package.json', '{"name": "js-app"}');
|
||||
await writeFile('js-app/src/index.ts', 'export const x = 1;');
|
||||
|
||||
const repos = { app: 'js-app' };
|
||||
const repoPaths = new Map([['app', path.join(tmpDir, 'js-app')]]);
|
||||
|
||||
const result = await extractRustWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(0);
|
||||
expect(result.discoveredCrates.size).toBe(0);
|
||||
});
|
||||
|
||||
it('deduplicates identical imports from multiple files', async () => {
|
||||
await writeFile(
|
||||
'lib/Cargo.toml',
|
||||
`[package]\nname = "shared"\nversion = "0.1.0"\n\n[dependencies]\n`,
|
||||
);
|
||||
await writeFile('lib/src/lib.rs', 'pub struct Config {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/Cargo.toml',
|
||||
`[package]\nname = "myapp"\nversion = "0.1.0"\n\n[dependencies]\nshared = { workspace = true }\n`,
|
||||
);
|
||||
await writeFile('app/src/main.rs', 'use shared::Config;\n');
|
||||
await writeFile('app/src/other.rs', 'use shared::Config;\n');
|
||||
|
||||
const repos = { lib: 'shared', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractRustWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles path dependencies alongside workspace deps', async () => {
|
||||
await writeFile(
|
||||
'lib/Cargo.toml',
|
||||
`[package]\nname = "mylib"\nversion = "0.1.0"\n\n[dependencies]\n`,
|
||||
);
|
||||
await writeFile('lib/src/lib.rs', 'pub trait Handler {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/Cargo.toml',
|
||||
`[package]\nname = "myapp"\nversion = "0.1.0"\n\n[dependencies]\nmylib = { path = "../lib" }\n`,
|
||||
);
|
||||
await writeFile('app/src/main.rs', 'use mylib::Handler;\n');
|
||||
|
||||
const repos = { lib: 'mylib', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractRustWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('mylib::Handler');
|
||||
});
|
||||
|
||||
it('warns and skips duplicate crate names', async () => {
|
||||
await writeFile(
|
||||
'repo-a/Cargo.toml',
|
||||
`[package]\nname = "shared"\nversion = "0.1.0"\n\n[dependencies]\n`,
|
||||
);
|
||||
await writeFile('repo-a/src/lib.rs', 'pub struct Alpha {}\n');
|
||||
|
||||
await writeFile(
|
||||
'repo-b/Cargo.toml',
|
||||
`[package]\nname = "shared"\nversion = "0.2.0"\n\n[dependencies]\n`,
|
||||
);
|
||||
await writeFile('repo-b/src/lib.rs', 'pub struct Beta {}\n');
|
||||
|
||||
await writeFile(
|
||||
'consumer/Cargo.toml',
|
||||
`[package]\nname = "consumer"\nversion = "0.1.0"\n\n[dependencies]\nshared = { workspace = true }\n`,
|
||||
);
|
||||
await writeFile('consumer/src/main.rs', 'use shared::Alpha;\n');
|
||||
|
||||
const repos = { a: 'shared-a', b: 'shared-b', consumer: 'consumer' };
|
||||
const repoPaths = new Map([
|
||||
['a', path.join(tmpDir, 'repo-a')],
|
||||
['b', path.join(tmpDir, 'repo-b')],
|
||||
['consumer', path.join(tmpDir, 'consumer')],
|
||||
]);
|
||||
|
||||
const warnings: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warnings.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const result = await extractRustWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(warnings.some((w) => w.includes('duplicate crate name "shared"'))).toBe(true);
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].from).toBe('a');
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
|
||||
it('produces distinct contracts when two crates export same symbol name', async () => {
|
||||
await writeFile(
|
||||
'lib-a/Cargo.toml',
|
||||
`[package]\nname = "alpha"\nversion = "0.1.0"\n\n[dependencies]\n`,
|
||||
);
|
||||
await writeFile('lib-a/src/lib.rs', 'pub struct Config {}\n');
|
||||
|
||||
await writeFile(
|
||||
'lib-b/Cargo.toml',
|
||||
`[package]\nname = "beta"\nversion = "0.1.0"\n\n[dependencies]\n`,
|
||||
);
|
||||
await writeFile('lib-b/src/lib.rs', 'pub struct Config {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/Cargo.toml',
|
||||
`[package]\nname = "myapp"\nversion = "0.1.0"\n\n[dependencies]\nalpha = { workspace = true }\nbeta = { workspace = true }\n`,
|
||||
);
|
||||
await writeFile('app/src/main.rs', 'use alpha::Config;\nuse beta::Config;\n');
|
||||
|
||||
const repos = { alpha: 'alpha', beta: 'beta', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['alpha', path.join(tmpDir, 'lib-a')],
|
||||
['beta', path.join(tmpDir, 'lib-b')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractRustWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(2);
|
||||
const contracts = result.links.map((l) => l.contract).sort();
|
||||
expect(contracts).toEqual(['alpha::Config', 'beta::Config']);
|
||||
});
|
||||
|
||||
it('respects .gitnexusignore patterns', async () => {
|
||||
await writeFile(
|
||||
'lib/Cargo.toml',
|
||||
`[package]\nname = "mylib"\nversion = "0.1.0"\n\n[dependencies]\n`,
|
||||
);
|
||||
await writeFile('lib/src/lib.rs', 'pub struct Real {}\n');
|
||||
await writeFile('lib/generated/gen.rs', 'pub struct Fake {}\n');
|
||||
await writeFile('lib/.gitnexusignore', 'generated/\n');
|
||||
|
||||
await writeFile(
|
||||
'app/Cargo.toml',
|
||||
`[package]\nname = "myapp"\nversion = "0.1.0"\n\n[dependencies]\nmylib = { workspace = true }\n`,
|
||||
);
|
||||
await writeFile('app/src/main.rs', 'use mylib::Real;\n');
|
||||
await writeFile('app/generated/gen.rs', 'use mylib::Fake;\n');
|
||||
await writeFile('app/.gitnexusignore', 'generated/\n');
|
||||
|
||||
const repos = { lib: 'mylib', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractRustWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('mylib::Real');
|
||||
});
|
||||
|
||||
it('handles nested module imports (use crate::module::Type)', async () => {
|
||||
await writeFile(
|
||||
'lib/Cargo.toml',
|
||||
`[package]\nname = "shared"\nversion = "0.1.0"\n\n[dependencies]\n`,
|
||||
);
|
||||
await writeFile('lib/src/lib.rs', '');
|
||||
|
||||
await writeFile(
|
||||
'app/Cargo.toml',
|
||||
`[package]\nname = "myapp"\nversion = "0.1.0"\n\n[dependencies]\nshared = { workspace = true }\n`,
|
||||
);
|
||||
await writeFile('app/src/main.rs', 'use shared::models::User;\nuse shared::auth::Token;\n');
|
||||
|
||||
const repos = { lib: 'shared', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractRustWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(2);
|
||||
const contracts = result.links.map((l) => l.contract).sort();
|
||||
expect(contracts).toEqual(['shared::Token', 'shared::User']);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
|
|
@ -335,6 +335,201 @@ describe('syncGroup', () => {
|
|||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('workspace_deps integration', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
function makeWsConfig(repos: Record<string, string>, workspaceDeps: boolean): GroupConfig {
|
||||
return {
|
||||
version: 1,
|
||||
name: 'test',
|
||||
description: '',
|
||||
repos,
|
||||
links: [],
|
||||
packages: {},
|
||||
detect: {
|
||||
http: false,
|
||||
grpc: false,
|
||||
topics: false,
|
||||
shared_libs: false,
|
||||
embedding_fallback: false,
|
||||
workspace_deps: workspaceDeps,
|
||||
},
|
||||
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
|
||||
};
|
||||
}
|
||||
|
||||
function writeFileSync(relPath: string, content: string) {
|
||||
const absPath = path.join(tmpDir, relPath);
|
||||
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
||||
fs.writeFileSync(absPath, content, 'utf-8');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('workspace_deps: true discovers Rust crate links through syncGroup', async () => {
|
||||
tmpDir = path.join(os.tmpdir(), `gitnexus-sync-ws-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
'crate-a/Cargo.toml',
|
||||
'[package]\nname = "mathlex"\nversion = "0.1.0"\n\n[dependencies]\n',
|
||||
);
|
||||
writeFileSync('crate-a/src/lib.rs', 'pub struct Expression {}\n');
|
||||
|
||||
writeFileSync(
|
||||
'crate-b/Cargo.toml',
|
||||
'[package]\nname = "thales"\nversion = "0.1.0"\n\n[dependencies]\nmathlex = { workspace = true }\n',
|
||||
);
|
||||
writeFileSync('crate-b/src/main.rs', 'use mathlex::Expression;\n');
|
||||
|
||||
const mockEntries: RegistryEntry[] = [
|
||||
{
|
||||
name: 'mathlex',
|
||||
path: path.join(tmpDir, 'crate-a'),
|
||||
storagePath: path.join(tmpDir, 'crate-a', '.gitnexus'),
|
||||
indexedAt: '',
|
||||
lastCommit: '',
|
||||
},
|
||||
{
|
||||
name: 'thales',
|
||||
path: path.join(tmpDir, 'crate-b'),
|
||||
storagePath: path.join(tmpDir, 'crate-b', '.gitnexus'),
|
||||
indexedAt: '',
|
||||
lastCommit: '',
|
||||
},
|
||||
];
|
||||
|
||||
const repoManager = await import('../../../src/storage/repo-manager.js');
|
||||
vi.spyOn(repoManager, 'readRegistry').mockResolvedValue(mockEntries);
|
||||
|
||||
const config = makeWsConfig({ 'parser/mathlex': 'mathlex', 'engine/thales': 'thales' }, true);
|
||||
|
||||
const result = await syncGroup(config, {
|
||||
extractorOverride: async () => [],
|
||||
skipWrite: true,
|
||||
});
|
||||
|
||||
const manifestLinks = result.crossLinks.filter((cl) => cl.matchType === 'manifest');
|
||||
expect(manifestLinks).toHaveLength(1);
|
||||
expect(manifestLinks[0].contractId).toBe('custom::mathlex::Expression');
|
||||
expect(manifestLinks[0].from.repo).toBe('engine/thales');
|
||||
expect(manifestLinks[0].to.repo).toBe('parser/mathlex');
|
||||
});
|
||||
|
||||
it('workspace_deps: false skips Rust workspace extraction', async () => {
|
||||
tmpDir = path.join(os.tmpdir(), `gitnexus-sync-ws-off-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
'crate-a/Cargo.toml',
|
||||
'[package]\nname = "mathlex"\nversion = "0.1.0"\n\n[dependencies]\n',
|
||||
);
|
||||
writeFileSync('crate-a/src/lib.rs', 'pub struct Expression {}\n');
|
||||
|
||||
writeFileSync(
|
||||
'crate-b/Cargo.toml',
|
||||
'[package]\nname = "thales"\nversion = "0.1.0"\n\n[dependencies]\nmathlex = { workspace = true }\n',
|
||||
);
|
||||
writeFileSync('crate-b/src/main.rs', 'use mathlex::Expression;\n');
|
||||
|
||||
const repoManager = await import('../../../src/storage/repo-manager.js');
|
||||
vi.spyOn(repoManager, 'readRegistry').mockResolvedValue([]);
|
||||
|
||||
const config = makeWsConfig(
|
||||
{ 'parser/mathlex': 'mathlex', 'engine/thales': 'thales' },
|
||||
false,
|
||||
);
|
||||
|
||||
const result = await syncGroup(config, {
|
||||
extractorOverride: async () => [],
|
||||
skipWrite: true,
|
||||
});
|
||||
|
||||
expect(result.crossLinks).toHaveLength(0);
|
||||
expect(result.contracts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('discovered workspace links merge with explicit manifest links', async () => {
|
||||
tmpDir = path.join(os.tmpdir(), `gitnexus-sync-ws-merge-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
'crate-a/Cargo.toml',
|
||||
'[package]\nname = "mathlex"\nversion = "0.1.0"\n\n[dependencies]\n',
|
||||
);
|
||||
writeFileSync('crate-a/src/lib.rs', 'pub struct Expression {}\n');
|
||||
|
||||
writeFileSync(
|
||||
'crate-b/Cargo.toml',
|
||||
'[package]\nname = "thales"\nversion = "0.1.0"\n\n[dependencies]\nmathlex = { workspace = true }\n',
|
||||
);
|
||||
writeFileSync('crate-b/src/main.rs', 'use mathlex::Expression;\n');
|
||||
|
||||
const mockEntries: RegistryEntry[] = [
|
||||
{
|
||||
name: 'mathlex',
|
||||
path: path.join(tmpDir, 'crate-a'),
|
||||
storagePath: path.join(tmpDir, 'crate-a', '.gitnexus'),
|
||||
indexedAt: '',
|
||||
lastCommit: '',
|
||||
},
|
||||
{
|
||||
name: 'thales',
|
||||
path: path.join(tmpDir, 'crate-b'),
|
||||
storagePath: path.join(tmpDir, 'crate-b', '.gitnexus'),
|
||||
indexedAt: '',
|
||||
lastCommit: '',
|
||||
},
|
||||
];
|
||||
|
||||
const repoManager = await import('../../../src/storage/repo-manager.js');
|
||||
vi.spyOn(repoManager, 'readRegistry').mockResolvedValue(mockEntries);
|
||||
|
||||
const explicitLinks: GroupManifestLink[] = [
|
||||
{
|
||||
from: 'parser/mathlex',
|
||||
to: 'engine/thales',
|
||||
type: 'http',
|
||||
contract: 'GET::/api/parse',
|
||||
role: 'provider',
|
||||
},
|
||||
];
|
||||
|
||||
const config: GroupConfig = {
|
||||
version: 1,
|
||||
name: 'test',
|
||||
description: '',
|
||||
repos: { 'parser/mathlex': 'mathlex', 'engine/thales': 'thales' },
|
||||
links: explicitLinks,
|
||||
packages: {},
|
||||
detect: {
|
||||
http: false,
|
||||
grpc: false,
|
||||
topics: false,
|
||||
shared_libs: false,
|
||||
embedding_fallback: false,
|
||||
workspace_deps: true,
|
||||
},
|
||||
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
|
||||
};
|
||||
|
||||
const result = await syncGroup(config, {
|
||||
extractorOverride: async () => [],
|
||||
skipWrite: true,
|
||||
});
|
||||
|
||||
const manifestLinks = result.crossLinks.filter((cl) => cl.matchType === 'manifest');
|
||||
expect(manifestLinks.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const contractIds = manifestLinks.map((cl) => cl.contractId);
|
||||
expect(contractIds).toContain('http::GET::/api/parse');
|
||||
expect(contractIds).toContain('custom::mathlex::Expression');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stableRepoPoolId', () => {
|
||||
|
|
|
|||
|
|
@ -483,4 +483,59 @@ await consumer.subscribe({ topic: 'order.placed' });`,
|
|||
expect(contracts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #1185: topic extractor must honour .gitnexusignore ─────────────
|
||||
//
|
||||
// The source-scan glob used to use a hardcoded ignore array; it now
|
||||
// consumes the shared `IgnoreService` (mirrors `filesystem-walker.ts`)
|
||||
// so any `.gitnexusignore` pattern excludes those files from contract
|
||||
// extraction. Special case for this extractor: the Go-specific
|
||||
// `_test.go` filter is preserved via a small wrapper around the base
|
||||
// filter (so glob-level pruning still applies); the second test below
|
||||
// pins that behaviour against accidental regressions.
|
||||
describe('respects .gitnexusignore (#1185)', () => {
|
||||
it('source-scan glob skips files matched by .gitnexusignore', async () => {
|
||||
// Control: a regular @KafkaListener that SHOULD be discovered.
|
||||
writeFile(
|
||||
'src/EventHandler.java',
|
||||
`@KafkaListener(topics = "user.created")
|
||||
public void handleUserCreated(ConsumerRecord<String, String> record) {}`,
|
||||
);
|
||||
// Vendored Java handler under a venv-style dir.
|
||||
writeFile(
|
||||
'mentor_env/lib/LeakedHandler.java',
|
||||
`@KafkaListener(topics = "leaked.event")
|
||||
public void handleLeaked(ConsumerRecord<String, String> r) {}`,
|
||||
);
|
||||
writeFile('.gitnexusignore', 'mentor_env/\n');
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const ids = contracts.map((c) => c.contractId);
|
||||
expect(ids).toContain('topic::user.created');
|
||||
expect(ids).not.toContain('topic::leaked.event');
|
||||
expect(contracts.some((c) => c.symbolRef?.filePath?.startsWith('mentor_env/'))).toBe(false);
|
||||
});
|
||||
|
||||
it('still prunes `*_test.go` even when .gitnexusignore is empty (wrapper preserves glob-level filter)', async () => {
|
||||
// Regression guard: replacing the hardcoded `**/*_test.go` glob
|
||||
// entry with a wrapper around `createIgnoreFilter` must keep this
|
||||
// file out of the scan. Without the wrapper, the previous test
|
||||
// ('skips Go test files (`*_test.go`)') would still pass because
|
||||
// the test scenario set up no detections, but here we WRITE a
|
||||
// valid Sarama consumer call inside `_test.go` and assert the
|
||||
// extractor never sees it.
|
||||
writeFile(
|
||||
'src/orders_test.go',
|
||||
`package orders
|
||||
import "github.com/IBM/sarama"
|
||||
func TestSomething() {
|
||||
consumer.ConsumePartition("real-topic-from-test", 0, sarama.OffsetNewest)
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
expect(contracts.find((c) => c.contractId === 'topic::real-topic-from-test')).toBeUndefined();
|
||||
expect(contracts).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
84
gitnexus/test/unit/hf-env.test.ts
Normal file
84
gitnexus/test/unit/hf-env.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import os from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { applyHfEnvOverrides, type HfEnvSubset } from '../../src/core/embeddings/hf-env.js';
|
||||
|
||||
describe('applyHfEnvOverrides', () => {
|
||||
let envStub: HfEnvSubset;
|
||||
// Snapshot the two env vars so tests don't leak state into each other (or
|
||||
// into the rest of the test run). `delete` + restore is the simplest pattern
|
||||
// — vitest doesn't reset `process.env` between tests by default.
|
||||
let originalHfHome: string | undefined;
|
||||
let originalHfEndpoint: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
envStub = { cacheDir: '', remoteHost: '' };
|
||||
originalHfHome = process.env.HF_HOME;
|
||||
originalHfEndpoint = process.env.HF_ENDPOINT;
|
||||
delete process.env.HF_HOME;
|
||||
delete process.env.HF_ENDPOINT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHfHome === undefined) delete process.env.HF_HOME;
|
||||
else process.env.HF_HOME = originalHfHome;
|
||||
if (originalHfEndpoint === undefined) delete process.env.HF_ENDPOINT;
|
||||
else process.env.HF_ENDPOINT = originalHfEndpoint;
|
||||
});
|
||||
|
||||
it('cacheDir defaults to ~/.cache/huggingface when HF_HOME is unset', () => {
|
||||
applyHfEnvOverrides(envStub);
|
||||
expect(envStub.cacheDir).toBe(join(os.homedir(), '.cache', 'huggingface'));
|
||||
});
|
||||
|
||||
it('cacheDir respects HF_HOME when set', () => {
|
||||
process.env.HF_HOME = '/custom/hf/cache';
|
||||
applyHfEnvOverrides(envStub);
|
||||
expect(envStub.cacheDir).toBe('/custom/hf/cache');
|
||||
});
|
||||
|
||||
it('remoteHost is set when HF_ENDPOINT is set, with a trailing slash appended', () => {
|
||||
process.env.HF_ENDPOINT = 'https://hf-mirror.com';
|
||||
applyHfEnvOverrides(envStub);
|
||||
expect(envStub.remoteHost).toBe('https://hf-mirror.com/');
|
||||
});
|
||||
|
||||
it('remoteHost preserves existing trailing slash on HF_ENDPOINT', () => {
|
||||
process.env.HF_ENDPOINT = 'https://hf-mirror.com/';
|
||||
applyHfEnvOverrides(envStub);
|
||||
expect(envStub.remoteHost).toBe('https://hf-mirror.com/');
|
||||
});
|
||||
|
||||
it('remoteHost is left untouched when HF_ENDPOINT is unset', () => {
|
||||
// Pre-populate to a sentinel so we can prove the function does NOT
|
||||
// overwrite remoteHost when no env var is set. Without this guard a
|
||||
// future refactor that always assigns `env.remoteHost = ...` would
|
||||
// silently break consumers that have already configured it elsewhere.
|
||||
envStub.remoteHost = 'pre-existing-do-not-touch';
|
||||
applyHfEnvOverrides(envStub);
|
||||
expect(envStub.remoteHost).toBe('pre-existing-do-not-touch');
|
||||
});
|
||||
|
||||
it('remoteHost is left untouched when HF_ENDPOINT is whitespace-only', () => {
|
||||
// Common copy-paste failure mode for users on restricted networks who
|
||||
// pull `HF_ENDPOINT` values from shell scripts or docs with stray
|
||||
// whitespace. The `.trim()` + truthiness guard ensures this is treated
|
||||
// as "unset" rather than as an invalid host like `' /'` that would
|
||||
// silently misroute model downloads. Pinned by the @claude review on
|
||||
// PR #1252.
|
||||
process.env.HF_ENDPOINT = ' ';
|
||||
envStub.remoteHost = 'sentinel';
|
||||
applyHfEnvOverrides(envStub);
|
||||
expect(envStub.remoteHost).toBe('sentinel');
|
||||
});
|
||||
|
||||
it('remoteHost trims surrounding whitespace from HF_ENDPOINT', () => {
|
||||
// Compatible mirror of the previous test for the case where the env
|
||||
// var is non-empty AFTER trimming. Without `.trim()`, the bogus
|
||||
// leading/trailing space would survive into the URL and break
|
||||
// downloads.
|
||||
process.env.HF_ENDPOINT = ' https://hf-mirror.com ';
|
||||
applyHfEnvOverrides(envStub);
|
||||
expect(envStub.remoteHost).toBe('https://hf-mirror.com/');
|
||||
});
|
||||
});
|
||||
|
|
@ -5,7 +5,7 @@ const mockAccess = vi.fn();
|
|||
const mockGetStoragePaths = vi.fn();
|
||||
const mockLoadMeta = vi.fn();
|
||||
const mockRegisterRepo = vi.fn();
|
||||
const mockAddToGitignore = vi.fn();
|
||||
const mockEnsureGitNexusIgnored = vi.fn();
|
||||
const mockGetGitRoot = vi.fn();
|
||||
const mockIsGitRepo = vi.fn();
|
||||
|
||||
|
|
@ -19,7 +19,7 @@ vi.mock('../../src/storage/repo-manager.js', () => ({
|
|||
getStoragePaths: mockGetStoragePaths,
|
||||
loadMeta: mockLoadMeta,
|
||||
registerRepo: mockRegisterRepo,
|
||||
addToGitignore: mockAddToGitignore,
|
||||
ensureGitNexusIgnored: mockEnsureGitNexusIgnored,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/git.js', () => ({
|
||||
|
|
@ -53,7 +53,7 @@ describe('indexCommand', () => {
|
|||
stats: { nodes: 10, edges: 20 },
|
||||
});
|
||||
mockAccess.mockResolvedValue(undefined);
|
||||
mockAddToGitignore.mockResolvedValue(undefined);
|
||||
mockEnsureGitNexusIgnored.mockResolvedValue(undefined);
|
||||
mockGetGitRoot.mockReturnValue(resolvedRepo);
|
||||
mockIsGitRepo.mockReturnValue(true);
|
||||
});
|
||||
|
|
@ -134,8 +134,8 @@ describe('indexCommand', () => {
|
|||
resolvedRepo,
|
||||
expect.objectContaining({ repoPath: resolvedRepo }),
|
||||
);
|
||||
expect(mockAddToGitignore).toHaveBeenCalledTimes(1);
|
||||
expect(mockAddToGitignore).toHaveBeenCalledWith(resolvedRepo);
|
||||
expect(mockEnsureGitNexusIgnored).toHaveBeenCalledTimes(1);
|
||||
expect(mockEnsureGitNexusIgnored).toHaveBeenCalledWith(resolvedRepo);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
@ -170,7 +170,7 @@ describe('indexCommand', () => {
|
|||
resolvedRepo,
|
||||
expect.objectContaining({ repoPath: resolvedRepo }),
|
||||
);
|
||||
expect(mockAddToGitignore).toHaveBeenCalledWith(resolvedRepo);
|
||||
expect(mockEnsureGitNexusIgnored).toHaveBeenCalledWith(resolvedRepo);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
@ -189,7 +189,7 @@ describe('indexCommand', () => {
|
|||
await indexCommand(['/repo', '/other']);
|
||||
|
||||
expect(mockRegisterRepo).not.toHaveBeenCalled();
|
||||
expect(mockAddToGitignore).not.toHaveBeenCalled();
|
||||
expect(mockEnsureGitNexusIgnored).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(logSpy).toHaveBeenCalledWith(' The `index` command accepts a single path only.');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import fs from 'fs/promises';
|
|||
import {
|
||||
getStoragePath,
|
||||
getStoragePaths,
|
||||
ensureGitNexusIgnored,
|
||||
readRegistry,
|
||||
loadCLIConfig,
|
||||
registerRepo,
|
||||
|
|
@ -62,6 +63,84 @@ describe('getStoragePaths', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ─── GitNexus ignore rules (#1233) ─────────────────────────────────────
|
||||
|
||||
describe('ensureGitNexusIgnored (#1233)', () => {
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpRepo = await createTempDir('gitnexus-internal-gitignore-');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await tmpRepo.cleanup();
|
||||
});
|
||||
|
||||
it('creates .gitnexus/.gitignore containing a catch-all ignore rule', async () => {
|
||||
await ensureGitNexusIgnored(tmpRepo.dbPath);
|
||||
|
||||
await expect(
|
||||
fs.readFile(path.join(tmpRepo.dbPath, '.gitnexus', '.gitignore'), 'utf-8'),
|
||||
).resolves.toBe('*\n');
|
||||
});
|
||||
|
||||
it('does not create or modify the repository root .gitignore', async () => {
|
||||
const rootGitignorePath = path.join(tmpRepo.dbPath, '.gitignore');
|
||||
await fs.writeFile(rootGitignorePath, 'node_modules/\n');
|
||||
|
||||
await ensureGitNexusIgnored(tmpRepo.dbPath);
|
||||
|
||||
await expect(fs.readFile(rootGitignorePath, 'utf-8')).resolves.toBe('node_modules/\n');
|
||||
});
|
||||
|
||||
it('adds .gitnexus/ to .git/info/exclude when the repo has a real .git directory', async () => {
|
||||
const excludePath = path.join(tmpRepo.dbPath, '.git', 'info', 'exclude');
|
||||
await fs.mkdir(path.dirname(excludePath), { recursive: true });
|
||||
|
||||
await ensureGitNexusIgnored(tmpRepo.dbPath);
|
||||
|
||||
await expect(fs.readFile(excludePath, 'utf-8')).resolves.toBe('.gitnexus/\n');
|
||||
});
|
||||
|
||||
it('appends .gitnexus/ to .git/info/exclude once without disturbing existing rules', async () => {
|
||||
const excludePath = path.join(tmpRepo.dbPath, '.git', 'info', 'exclude');
|
||||
await fs.mkdir(path.dirname(excludePath), { recursive: true });
|
||||
await fs.writeFile(excludePath, '# local excludes\nnode_modules/\n');
|
||||
|
||||
await ensureGitNexusIgnored(tmpRepo.dbPath);
|
||||
await ensureGitNexusIgnored(tmpRepo.dbPath);
|
||||
|
||||
await expect(fs.readFile(excludePath, 'utf-8')).resolves.toBe(
|
||||
'# local excludes\nnode_modules/\n.gitnexus/\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not create .git/info/exclude when .git is not a directory', async () => {
|
||||
await fs.writeFile(path.join(tmpRepo.dbPath, '.git'), 'gitdir: ../real-git-dir\n');
|
||||
|
||||
await ensureGitNexusIgnored(tmpRepo.dbPath);
|
||||
|
||||
await expect(fs.access(path.join(tmpRepo.dbPath, '.git', 'info', 'exclude'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('keeps generated .gitnexus files out of git status', async () => {
|
||||
execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' });
|
||||
execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', {
|
||||
cwd: tmpRepo.dbPath,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
await ensureGitNexusIgnored(tmpRepo.dbPath);
|
||||
await fs.writeFile(path.join(tmpRepo.dbPath, '.gitnexus', 'meta.json'), '{}\n');
|
||||
|
||||
const status = execSync('git status --short', {
|
||||
cwd: tmpRepo.dbPath,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
expect(status).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── readRegistry ────────────────────────────────────────────────────
|
||||
|
||||
describe('readRegistry', () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { execSync } from 'child_process';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deriveEmbeddingMode } from '../../src/core/embedding-mode.js';
|
||||
import { getStoragePaths, saveMeta, type RepoMeta } from '../../src/storage/repo-manager.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
describe('run-analyze module', () => {
|
||||
it('exports runFullAnalysis as a function', async () => {
|
||||
|
|
@ -12,6 +17,44 @@ describe('run-analyze module', () => {
|
|||
expect(mod.PHASE_LABELS).toBeDefined();
|
||||
expect(mod.PHASE_LABELS.parsing).toBe('Parsing code');
|
||||
});
|
||||
|
||||
it('creates .gitnexus/.gitignore on the already-up-to-date fast path (#1233)', async () => {
|
||||
const tmpRepo = await createTempDir('gitnexus-run-analyze-fast-path-');
|
||||
try {
|
||||
execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' });
|
||||
execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', {
|
||||
cwd: tmpRepo.dbPath,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const currentCommit = execSync('git rev-parse HEAD', {
|
||||
cwd: tmpRepo.dbPath,
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
const { storagePath } = getStoragePaths(tmpRepo.dbPath);
|
||||
const meta: RepoMeta = {
|
||||
repoPath: tmpRepo.dbPath,
|
||||
lastCommit: currentCommit,
|
||||
indexedAt: new Date().toISOString(),
|
||||
};
|
||||
await saveMeta(storagePath, meta);
|
||||
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
const result = await runFullAnalysis(
|
||||
tmpRepo.dbPath,
|
||||
{},
|
||||
{
|
||||
onProgress: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.alreadyUpToDate).toBe(true);
|
||||
await expect(
|
||||
fs.readFile(path.join(tmpRepo.dbPath, '.gitnexus', '.gitignore'), 'utf-8'),
|
||||
).resolves.toBe('*\n');
|
||||
} finally {
|
||||
await tmpRepo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveEmbeddingMode', () => {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@
|
|||
* directly through the MCP Server's handler dispatch.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||
import { createMCPServer } from '../../src/mcp/server.js';
|
||||
import { GITNEXUS_TOOLS } from '../../src/mcp/tools.js';
|
||||
|
||||
// ─── Mock backend ──────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -52,6 +55,28 @@ describe('createMCPServer', () => {
|
|||
// The server has registered handlers — verify it was created without errors
|
||||
expect(server).toBeTruthy();
|
||||
});
|
||||
|
||||
it('tools/list response includes tool annotations', async () => {
|
||||
const backend = createMockBackend();
|
||||
const server = createMCPServer(backend);
|
||||
const client = new Client({ name: 'test-client', version: '0.0.0' });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
try {
|
||||
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
||||
|
||||
const response = await client.listTools();
|
||||
expect(response.tools).toHaveLength(GITNEXUS_TOOLS.length);
|
||||
|
||||
for (const tool of response.tools) {
|
||||
const definition = GITNEXUS_TOOLS.find((t) => t.name === tool.name)!;
|
||||
expect(tool.annotations).toEqual(definition.annotations);
|
||||
}
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getNextStepHint (tested indirectly via server tool handler) ──────
|
||||
|
|
|
|||
|
|
@ -14,9 +14,24 @@
|
|||
* `checkCwdMatch` API.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import path from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
import { execSync, execFileSync } from 'child_process';
|
||||
|
||||
// Wrap child_process exports in spies that pass through to the real
|
||||
// implementation. Test setup (initRepoWithCommit, etc.) keeps working
|
||||
// against real git; individual tests can clear + assert call counts to
|
||||
// prove no subprocess was launched. Both module specifiers are mocked
|
||||
// because git-staleness.ts imports from 'node:child_process' while this
|
||||
// file (and storage/git.ts) import from 'child_process'.
|
||||
vi.mock('child_process', async () => {
|
||||
const actual = await vi.importActual<typeof import('child_process')>('child_process');
|
||||
return { ...actual, execSync: vi.fn(actual.execSync), execFileSync: vi.fn(actual.execFileSync) };
|
||||
});
|
||||
vi.mock('node:child_process', async () => {
|
||||
const actual = await vi.importActual<typeof import('node:child_process')>('node:child_process');
|
||||
return { ...actual, execSync: vi.fn(actual.execSync), execFileSync: vi.fn(actual.execFileSync) };
|
||||
});
|
||||
import {
|
||||
registerRepo,
|
||||
readRegistry,
|
||||
|
|
@ -233,6 +248,44 @@ describe('checkCwdMatch', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('returns match=none for a non-git cwd before resolving sibling remotes', async () => {
|
||||
const indexed = await createTempDir('cwd-home-indexed-');
|
||||
const nonGitCwd = await createTempDir('cwd-home-non-git-');
|
||||
try {
|
||||
const indexedHead = initRepoWithCommit(indexed.dbPath, 'https://example.com/foo/bar');
|
||||
await registerRepo(indexed.dbPath, {
|
||||
repoPath: indexed.dbPath,
|
||||
lastCommit: indexedHead,
|
||||
indexedAt: new Date().toISOString(),
|
||||
remoteUrl: 'https://example.com/foo/bar',
|
||||
});
|
||||
|
||||
// Clear after setup so we only count subprocess calls made by
|
||||
// checkCwdMatch itself. The fix in #1138 must guarantee that no
|
||||
// git subprocess is launched when the cwd is outside any .git
|
||||
// ancestor — a return value of 'none' alone does not prove that
|
||||
// (the pre-fix code also returned 'none', just by failing the
|
||||
// spawn). The mocked module exports are vi.fn() wrappers that
|
||||
// pass through to real implementations; clearing tracks only the
|
||||
// calls made by checkCwdMatch below.
|
||||
vi.mocked(execSync).mockClear();
|
||||
vi.mocked(execFileSync).mockClear();
|
||||
const nodeCp = await import('node:child_process');
|
||||
vi.mocked(nodeCp.execSync).mockClear();
|
||||
vi.mocked(nodeCp.execFileSync).mockClear();
|
||||
|
||||
const m = await checkCwdMatch(nonGitCwd.dbPath);
|
||||
expect(m.match).toBe('none');
|
||||
expect(execSync).not.toHaveBeenCalled();
|
||||
expect(execFileSync).not.toHaveBeenCalled();
|
||||
expect(nodeCp.execSync).not.toHaveBeenCalled();
|
||||
expect(nodeCp.execFileSync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await indexed.cleanup();
|
||||
await nonGitCwd.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports sibling-by-remote with a stale hint when cwd HEAD has advanced', async () => {
|
||||
// Polecat-style scenario from the issue: index at path A, query
|
||||
// from cwd=path B (same repo), get a warning rather than
|
||||
|
|
|
|||
|
|
@ -40,18 +40,19 @@ describe('--skip-git CLI flag', () => {
|
|||
describe('--skip-git does not walk up to parent git repo (#1232)', () => {
|
||||
const cliPath = path.resolve(__dirname, '../../dist/cli/index.js');
|
||||
let parentDir: string;
|
||||
let gitnexusHome: string;
|
||||
|
||||
function testEnv() {
|
||||
return {
|
||||
...process.env,
|
||||
HOME: parentDir,
|
||||
GITNEXUS_HOME: path.join(parentDir, '.gitnexus-home'),
|
||||
GITNEXUS_HOME: gitnexusHome,
|
||||
GITNEXUS_LBUG_EXTENSION_INSTALL: 'never',
|
||||
};
|
||||
}
|
||||
|
||||
function readRegistry(): Array<{ name: string; path: string }> {
|
||||
const registryPath = path.join(parentDir, '.gitnexus-home', 'registry.json');
|
||||
const registryPath = path.join(gitnexusHome, 'registry.json');
|
||||
expect(fs.existsSync(registryPath)).toBe(true);
|
||||
return JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
||||
}
|
||||
|
|
@ -99,6 +100,7 @@ describe('--skip-git CLI flag', () => {
|
|||
// package.json
|
||||
// src/index.ts
|
||||
parentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-skip-git-'));
|
||||
gitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-skip-git-home-'));
|
||||
initParentGitRepo();
|
||||
fs.mkdirSync(path.join(parentDir, 'COOLIO', 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
|
|
@ -125,6 +127,9 @@ describe('--skip-git CLI flag', () => {
|
|||
if (parentDir) {
|
||||
fs.rmSync(parentDir, { recursive: true, force: true });
|
||||
}
|
||||
if (gitnexusHome) {
|
||||
fs.rmSync(gitnexusHome, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
it('from subdir inside parent git repo, indexes subdir not parent', () => {
|
||||
|
|
@ -155,6 +160,36 @@ describe('--skip-git CLI flag', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('keeps parent git status clean for --skip-git subdir analyze (#1233)', () => {
|
||||
createTestStructure();
|
||||
try {
|
||||
fs.writeFileSync(path.join(parentDir, '.gitignore'), '.claude/\n');
|
||||
execSync('git add .gitignore COOLIO SubWooder', { cwd: parentDir, stdio: 'ignore' });
|
||||
execSync('git -c user.name=test -c user.email=test@example.com commit -m fixtures', {
|
||||
cwd: parentDir,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
|
||||
execSync(`node "${cliPath}" analyze --skip-git --skip-agents-md`, {
|
||||
cwd: path.join(parentDir, 'COOLIO'),
|
||||
encoding: 'utf8',
|
||||
timeout: 60000,
|
||||
env: testEnv(),
|
||||
});
|
||||
|
||||
expect(
|
||||
fs.readFileSync(path.join(parentDir, 'COOLIO', '.gitnexus', '.gitignore'), 'utf8'),
|
||||
).toBe('*\n');
|
||||
const status = execSync('git status --short', {
|
||||
cwd: parentDir,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
expect(status).toBe('');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('explicit input path with --skip-git indexes subdir', () => {
|
||||
createTestStructure();
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ import { describe, it, expect } from 'vitest';
|
|||
import { GITNEXUS_TOOLS } from '../../src/mcp/tools.js';
|
||||
|
||||
const GROUP_TOOLS = new Set(['group_list', 'group_sync']);
|
||||
const MUTATING_TOOLS = new Set(['rename', 'group_sync']);
|
||||
// Read-only tools that legitimately reach external systems. Add a tool name
|
||||
// here when introducing a read-only tool that needs openWorldHint: true.
|
||||
const OPEN_WORLD_READ_ONLY_TOOLS = new Set(['query']);
|
||||
|
||||
describe('GITNEXUS_TOOLS', () => {
|
||||
it('exports all tools (7 base + 3 route/tool/shape + 1 api_impact + 2 group)', () => {
|
||||
|
|
@ -39,6 +43,7 @@ describe('GITNEXUS_TOOLS', () => {
|
|||
expect(typeof tool.name).toBe('string');
|
||||
expect(tool.description).toBeTruthy();
|
||||
expect(typeof tool.description).toBe('string');
|
||||
expect(tool.annotations).toBeDefined();
|
||||
expect(tool.inputSchema).toBeDefined();
|
||||
expect(tool.inputSchema.type).toBe('object');
|
||||
expect(tool.inputSchema.properties).toBeDefined();
|
||||
|
|
@ -46,6 +51,48 @@ describe('GITNEXUS_TOOLS', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('each tool exposes all MCP safety annotations', () => {
|
||||
for (const tool of GITNEXUS_TOOLS) {
|
||||
expect(typeof tool.annotations.readOnlyHint).toBe('boolean');
|
||||
expect(typeof tool.annotations.destructiveHint).toBe('boolean');
|
||||
expect(typeof tool.annotations.idempotentHint).toBe('boolean');
|
||||
expect(typeof tool.annotations.openWorldHint).toBe('boolean');
|
||||
}
|
||||
});
|
||||
|
||||
it('read-only tools are marked non-destructive and idempotent', () => {
|
||||
for (const tool of GITNEXUS_TOOLS) {
|
||||
if (MUTATING_TOOLS.has(tool.name)) continue;
|
||||
|
||||
expect(tool.annotations.readOnlyHint).toBe(true);
|
||||
expect(tool.annotations.destructiveHint).toBe(false);
|
||||
expect(tool.annotations.idempotentHint).toBe(true);
|
||||
expect(tool.annotations.openWorldHint).toBe(OPEN_WORLD_READ_ONLY_TOOLS.has(tool.name));
|
||||
}
|
||||
});
|
||||
|
||||
it('query is marked open-world because it may use external embeddings', () => {
|
||||
const queryTool = GITNEXUS_TOOLS.find((t) => t.name === 'query')!;
|
||||
expect(queryTool.annotations).toEqual({
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('rename and group_sync are marked mutating and non-idempotent', () => {
|
||||
for (const name of ['rename', 'group_sync'] as const) {
|
||||
const tool = GITNEXUS_TOOLS.find((t) => t.name === name)!;
|
||||
expect(tool.annotations).toEqual({
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('query tool requires "query" parameter', () => {
|
||||
const queryTool = GITNEXUS_TOOLS.find((t) => t.name === 'query')!;
|
||||
expect(queryTool.inputSchema.required).toContain('query');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue