From 92ad0f5491d13a89c7689ca54f0f4ad52d884820 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 05:38:26 +0100 Subject: [PATCH 1/3] chore(deps): bump idna in /eval in the uv group across 1 directory (#1713) --- eval/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/eval/uv.lock b/eval/uv.lock index 04b89f336..a78eadf06 100644 --- a/eval/uv.lock +++ b/eval/uv.lock @@ -760,11 +760,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] From d7e1815aa310ff497178f240cde77819d1cf750f Mon Sep 17 00:00:00 2001 From: Nilotpal Kashyap <87768618+NilotpalK@users.noreply.github.com> Date: Wed, 20 May 2026 11:16:16 +0530 Subject: [PATCH 2/3] fix(detect-changes): guard resolveWorktreeCwd against overriding a separately-indexed worktree (#1691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(detect-changes): guard resolveWorktreeCwd against overriding a separately-indexed worktree When the repo registry entry points to a linked worktree (both main checkout and worktree indexed separately), resolveWorktreeCwd was incorrectly replacing the correct worktree repoPath with the server's main-checkout launch directory. Both share the same canonical root so the existing same-repo check passed, causing git diff to run from the wrong directory and return 0 changes (issue #1659). Fix: early-exit guard — if tryRealpath(repoPath) differs from tryRealpath(getCanonicalRepoRoot(repoPath)), repoPath is itself a linked worktree and is returned unchanged. Auto-detection only fires when repoPath equals the canonical main-checkout root. Also normalises the launchCanonical comparison in the auto-detect path to use tryRealpath for cross-platform consistency. Regression test: 'returns worktreeDir unchanged when repoPath IS a linked worktree and launchCwd is the main checkout'. * test(detect-changes): add worktreeA→worktreeB case and assumption comment Cover the missing case from the production-readiness review: repoPath = wt-A (indexed), launchCwd = wt-B (server on a different linked worktree). The guard fires on repoPath being a worktree regardless of launchCwd, so wt-A is returned unchanged. Also add an inline comment documenting the assumption that repoPath is a git root or linked-worktree root (not an arbitrary subdirectory), as noted in Finding 2 of the review. * refactor(detect-changes): validate repoPath is a git root before canonical comparison Instead of relying on a comment asserting repoPath is always a git root, call getGitRoot(repoPath) first. Only if the result matches repoPath itself do we call getCanonicalRepoRoot and apply the guard. This eliminates the over-classification risk for subdirectory repoPath values and makes the assumption explicit in code. repoCanonical is shared across both the guard and the auto-detect block. --------- Co-authored-by: Gergő Magyar --- gitnexus/src/mcp/local/local-backend.ts | 31 +++++++- .../test/unit/detect-changes-worktree.test.ts | 75 +++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 720e73eaa..4cff47a28 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -248,6 +248,29 @@ function tryRealpath(p: string): string { */ export function resolveWorktreeCwd(repoPath: string, launchCwd: string): string { try { + // Verify repoPath is a git root before comparing against its canonical + // root. If getGitRoot returns a different path, repoPath is an arbitrary + // subdirectory — skip both the linked-worktree guard and auto-detection + // and fall through to the repoPath fallback. + const repoGitRoot = getGitRoot(repoPath); + const repoCanonical = + repoGitRoot && tryRealpath(repoGitRoot) === tryRealpath(repoPath) + ? getCanonicalRepoRoot(repoPath) + : null; + + // Early exit: if repoPath is a linked worktree (differs from its canonical + // main-checkout root), return it unchanged. Do NOT override it with the + // server's launch directory — that would silently replace the explicitly- + // resolved worktree index with the main checkout. + // + // getCanonicalRepoRoot returns the main-checkout path for both the checkout + // and all linked worktrees: + // repoPath === canonical → main checkout (auto-detect may fire below) + // repoPath !== canonical → linked worktree (return as-is) + if (repoCanonical && tryRealpath(repoPath) !== tryRealpath(repoCanonical)) { + return repoPath; + } + const launchGitRoot = getGitRoot(launchCwd); if (launchGitRoot) { // Normalise via realpathSync before comparing so macOS /var → /private/var @@ -256,8 +279,12 @@ export function resolveWorktreeCwd(repoPath: string, launchCwd: string): string const realRepo = tryRealpath(repoPath); if (realLaunch !== realRepo) { const launchCanonical = getCanonicalRepoRoot(launchCwd); - const repoCanonical = getCanonicalRepoRoot(repoPath); - if (launchCanonical && repoCanonical && launchCanonical === repoCanonical) { + // Use tryRealpath on both canonical values for cross-platform safety. + if ( + launchCanonical && + repoCanonical && + tryRealpath(launchCanonical) === tryRealpath(repoCanonical) + ) { return launchGitRoot; } } diff --git a/gitnexus/test/unit/detect-changes-worktree.test.ts b/gitnexus/test/unit/detect-changes-worktree.test.ts index 02e440100..925731118 100644 --- a/gitnexus/test/unit/detect-changes-worktree.test.ts +++ b/gitnexus/test/unit/detect-changes-worktree.test.ts @@ -189,6 +189,81 @@ describe('resolveWorktreeCwd — auto-detection helper', () => { rmSync(repoB, { recursive: true, force: true }); } }); + + it('returns worktreeDir unchanged when repoPath IS a linked worktree and launchCwd is the main checkout', () => { + // Regression for: detect_changes returns no changes when the MCP server + // runs from the main checkout but the resolved repo index is a separately- + // indexed linked worktree (issue #1659 / dpearson2699 report). + // + // Before the fix, resolveWorktreeCwd would detect that launchCwd (main + // checkout) and repoPath (worktree) share the same canonical root and + // wrongly override repoPath with the main-checkout path, causing git diff + // to run from the wrong directory and return 0 changes. + const repoDir = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-rwc-idx-wt-')); + try { + execSync('git init -q', { cwd: repoDir, stdio: 'ignore' }); + execSync('git config user.email "test@example.com"', { cwd: repoDir, stdio: 'ignore' }); + execSync('git config user.name "Test"', { cwd: repoDir, stdio: 'ignore' }); + writeFileSync(path.join(repoDir, 'x.ts'), 'export const x = 1;\n'); + execSync('git add x.ts', { cwd: repoDir, stdio: 'ignore' }); + execSync('git commit -q -m "initial"', { cwd: repoDir, stdio: 'ignore' }); + + const worktreeDir = path.join(repoDir, 'wt-indexed'); + execSync(`git worktree add -q -b indexed "${worktreeDir}"`, { + cwd: repoDir, + stdio: 'ignore', + }); + + // Simulate: repo registry entry points to the worktree (repoPath = worktreeDir) + // but the MCP server was launched from the main checkout (launchCwd = repoDir). + // resolveWorktreeCwd must NOT override the correct worktree path with repoDir. + const result = resolveWorktreeCwd(worktreeDir, repoDir); + expect(realpathSync.native(result)).toBe(realpathSync.native(worktreeDir)); + expect(realpathSync.native(result)).not.toBe(realpathSync.native(repoDir)); + } finally { + try { + execSync('git worktree remove -f wt-indexed', { cwd: repoDir, stdio: 'ignore' }); + } catch { + // ignore + } + rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it('returns worktreeA unchanged when both repoPath and launchCwd are different linked worktrees of the same repo', () => { + // Covers: repoPath = wt-A (indexed), launchCwd = wt-B (server launched from another worktree). + // The guard fires on repoPath being a linked worktree regardless of what launchCwd is, + // so wt-A must be returned unchanged — not wt-B, not the main checkout. + const repoDir = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-rwc-two-wt-')); + try { + execSync('git init -q', { cwd: repoDir, stdio: 'ignore' }); + execSync('git config user.email "test@example.com"', { cwd: repoDir, stdio: 'ignore' }); + execSync('git config user.name "Test"', { cwd: repoDir, stdio: 'ignore' }); + writeFileSync(path.join(repoDir, 'x.ts'), 'export const x = 1;\n'); + execSync('git add x.ts', { cwd: repoDir, stdio: 'ignore' }); + execSync('git commit -q -m "initial"', { cwd: repoDir, stdio: 'ignore' }); + + const worktreeA = path.join(repoDir, 'wt-a'); + const worktreeB = path.join(repoDir, 'wt-b'); + execSync(`git worktree add -q -b branch-a "${worktreeA}"`, { cwd: repoDir, stdio: 'ignore' }); + execSync(`git worktree add -q -b branch-b "${worktreeB}"`, { cwd: repoDir, stdio: 'ignore' }); + + // repoPath = wt-A (the indexed worktree), launchCwd = wt-B (where the server runs). + // resolveWorktreeCwd must return wt-A — the indexed path — unchanged. + const result = resolveWorktreeCwd(worktreeA, worktreeB); + expect(realpathSync.native(result)).toBe(realpathSync.native(worktreeA)); + expect(realpathSync.native(result)).not.toBe(realpathSync.native(worktreeB)); + expect(realpathSync.native(result)).not.toBe(realpathSync.native(repoDir)); + } finally { + try { + execSync('git worktree remove -f wt-a', { cwd: repoDir, stdio: 'ignore' }); + execSync('git worktree remove -f wt-b', { cwd: repoDir, stdio: 'ignore' }); + } catch { + // ignore + } + rmSync(repoDir, { recursive: true, force: true }); + } + }); }); // ── Guard logic via real path arithmetic ───────────────────────────────────── From b4a2a4b91eaa744357f0d5ad4ae9265617d7f805 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 08:00:03 +0100 Subject: [PATCH 3/3] fix(ingestion): Prioritize same-module Java type resolution for duplicate FQNs across modules (#1712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * Fix Java same-name type resolution with same-module priority Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/df0843e3-e244-4e0f-a94a-311df3899bd0 * Refine Java ambiguity fallback safety check Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/df0843e3-e244-4e0f-a94a-311df3899bd0 * Remove Java-specific fallback from shared scope walkers Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e882906a-2c96-411e-94a5-123a345421a9 * Harden Java module key and ambiguous owner fallback Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e882906a-2c96-411e-94a5-123a345421a9 * Add negative assertions for duplicate-FQN module edges Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e882906a-2c96-411e-94a5-123a345421a9 * Make Java same-module ordering path-agnostic Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b317a759-f6bc-4590-bd2a-628f0ee9c477 * Refine generic Java path-affinity ordering safeguards Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b317a759-f6bc-4590-bd2a-628f0ee9c477 * Polish Java path-affinity ordering clarity Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b317a759-f6bc-4590-bd2a-628f0ee9c477 * Simplify Java path-affinity ordering logic Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b317a759-f6bc-4590-bd2a-628f0ee9c477 * Revert legacy DAG Java ambiguity ordering changes Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/94e50cf2-9733-4e69-a0eb-9fd38cbdb589 * Skip duplicate-FQN Java assertions in legacy parity mode Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/1b560efa-1b3b-4697-b590-c6ef447f431e * Tighten duplicate-FQN Java CALLS edge cardinality assertions Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/67c18f93-5e56-4b15-8404-cdf1be9b4485 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Gergő Magyar --- gitnexus/src/core/ingestion/languages/java.ts | 44 +++++++++++++ .../src/main/java/com/example/Module1App.java | 8 +++ .../main/java/com/example/UserService.java | 6 ++ .../src/main/java/com/example/Module2App.java | 8 +++ .../main/java/com/example/UserService.java | 6 ++ .../test/integration/resolvers/helpers.ts | 7 ++ .../test/integration/resolvers/java.test.ts | 66 +++++++++++++++++++ 7 files changed, 145 insertions(+) create mode 100644 gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module1/src/main/java/com/example/Module1App.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module1/src/main/java/com/example/UserService.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module2/src/main/java/com/example/Module2App.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module2/src/main/java/com/example/UserService.java diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index c70eacb10..20755d7cd 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -27,6 +27,7 @@ import { javaMethodConfig } from '../method-extractors/configs/jvm.js'; import { createVariableExtractor } from '../variable-extractors/generic.js'; import { javaVariableConfig } from '../variable-extractors/configs/jvm.js'; import { createHeritageExtractor } from '../heritage-extractors/generic.js'; +import type { SymbolDefinition } from 'gitnexus-shared'; import { emitJavaScopeCaptures, interpretJavaImport, @@ -39,6 +40,48 @@ import { resolveJavaImportTarget, } from './java/index.js'; +const orderJavaSameNameTypeCandidates = ({ + callSiteFilePath, + candidates, +}: { + readonly typeName: string; + readonly callSiteFilePath: string; + readonly candidates: readonly SymbolDefinition[]; +}): readonly SymbolDefinition[] | null => { + if (!callSiteFilePath.endsWith('.java')) return null; + if (candidates.length <= 1) return null; + const callerDir = splitDirectorySegments(callSiteFilePath); + + const scored = candidates.map((candidate, index) => ({ + candidate, + index, + score: sharedPrefixLength(callerDir, splitDirectorySegments(candidate.filePath)), + })); + const bestScore = Math.max(...scored.map((entry) => entry.score)); + // When all candidates tie, we have no structural signal to prefer one path. + // Returning null keeps downstream ambiguity handling conservative. + if (scored.every((entry) => entry.score === bestScore)) return null; + + const ordered = [...scored] + .sort((a, b) => b.score - a.score || a.index - b.index) + .map((entry) => entry.candidate); + return ordered; +}; + +const splitDirectorySegments = (filePath: string): string[] => { + const normalized = filePath.replace(/\\/g, '/'); + // Remove empty segments from leading/trailing/multiple slashes, then drop filename. + const segments = normalized.split('/').filter(Boolean); + return segments.slice(0, -1); +}; + +const sharedPrefixLength = (left: readonly string[], right: readonly string[]): number => { + const max = Math.min(left.length, right.length); + let idx = 0; + while (idx < max && left[idx] === right[idx]) idx += 1; + return idx; +}; + export const javaProvider = defineLanguage({ id: SupportedLanguages.Java, extensions: ['.java'], @@ -87,4 +130,5 @@ export const javaProvider = defineLanguage({ receiverBinding: javaReceiverBinding, arityCompatibility: javaArityCompatibility, resolveImportTarget: resolveJavaImportTarget, + orderSameNameTypeCandidates: orderJavaSameNameTypeCandidates, }); diff --git a/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module1/src/main/java/com/example/Module1App.java b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module1/src/main/java/com/example/Module1App.java new file mode 100644 index 000000000..242956497 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module1/src/main/java/com/example/Module1App.java @@ -0,0 +1,8 @@ +package com.example; + +public class Module1App { + public void run() { + UserService service = new UserService(); + service.ping(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module1/src/main/java/com/example/UserService.java b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module1/src/main/java/com/example/UserService.java new file mode 100644 index 000000000..9daea3d45 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module1/src/main/java/com/example/UserService.java @@ -0,0 +1,6 @@ +package com.example; + +public class UserService { + public void ping() { + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module2/src/main/java/com/example/Module2App.java b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module2/src/main/java/com/example/Module2App.java new file mode 100644 index 000000000..7d24142b8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module2/src/main/java/com/example/Module2App.java @@ -0,0 +1,8 @@ +package com.example; + +public class Module2App { + public void run() { + UserService service = new UserService(); + service.ping(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module2/src/main/java/com/example/UserService.java b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module2/src/main/java/com/example/UserService.java new file mode 100644 index 000000000..9daea3d45 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module2/src/main/java/com/example/UserService.java @@ -0,0 +1,6 @@ +package com.example; + +public class UserService { + public void ping() { + } +} diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index cc2697849..bd5eaa474 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -34,6 +34,13 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly { }); }); +describe('Java same-module priority for duplicate FQNs', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'java-duplicate-fqn-modules'), () => {}); + }, 60000); + + it('resolves Module1App.run calls to module1 UserService, not module2', () => { + const calls = getRelationships(result, 'CALLS'); + const module1ToModule1 = calls.filter( + (c) => + c.source === 'run' && + c.target === 'UserService' && + c.sourceFilePath === 'module1/src/main/java/com/example/Module1App.java' && + c.targetFilePath === 'module1/src/main/java/com/example/UserService.java', + ); + const module1ToModule2 = calls.filter( + (c) => + c.source === 'run' && + c.target === 'UserService' && + c.sourceFilePath === 'module1/src/main/java/com/example/Module1App.java' && + c.targetFilePath === 'module2/src/main/java/com/example/UserService.java', + ); + const module1ToAnyUserService = calls.filter( + (c) => + c.source === 'run' && + c.target === 'UserService' && + c.sourceFilePath === 'module1/src/main/java/com/example/Module1App.java' && + /module[12]\/src\/main\/java\/com\/example\/UserService\.java/.test(c.targetFilePath), + ); + + expect(module1ToModule1.length).toBe(1); + expect(module1ToModule2.length).toBe(0); + expect(module1ToAnyUserService.length).toBe(1); + }); + + it('resolves Module2App.run calls to module2 UserService, not module1', () => { + const calls = getRelationships(result, 'CALLS'); + const module2ToModule2 = calls.filter( + (c) => + c.source === 'run' && + c.target === 'UserService' && + c.sourceFilePath === 'module2/src/main/java/com/example/Module2App.java' && + c.targetFilePath === 'module2/src/main/java/com/example/UserService.java', + ); + const module2ToModule1 = calls.filter( + (c) => + c.source === 'run' && + c.target === 'UserService' && + c.sourceFilePath === 'module2/src/main/java/com/example/Module2App.java' && + c.targetFilePath === 'module1/src/main/java/com/example/UserService.java', + ); + const module2ToAnyUserService = calls.filter( + (c) => + c.source === 'run' && + c.target === 'UserService' && + c.sourceFilePath === 'module2/src/main/java/com/example/Module2App.java' && + /module[12]\/src\/main\/java\/com\/example\/UserService\.java/.test(c.targetFilePath), + ); + + expect(module2ToModule2.length).toBe(1); + expect(module2ToModule1.length).toBe(0); + expect(module2ToAnyUserService.length).toBe(1); + }); +}); + // --------------------------------------------------------------------------- // Member-call resolution: obj.method() resolves through pipeline // ---------------------------------------------------------------------------