From 231ad71d401195d91c7f9eb9f768324c748727d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Thu, 21 May 2026 19:21:25 +0100 Subject: [PATCH 01/16] fix(mcp): disambiguate duplicate-name repo resolution for worktrees (#1753) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mcp): disambiguate duplicate-name repo resolution for worktrees When multiple indexed repos share the same registry name (main checkout plus linked worktrees), MCP tools no longer silently pick the first sibling. Resolution prefers the repo matching process.cwd()'s git root, throws RegistryAmbiguousTargetError when still ambiguous, and uses canonical path matching aligned with the CLI registry. Fixes #1658. Complements worktree detect_changes fixes in #1654/#1691. * fix(mcp): refresh registry on duplicate-name ambiguity before failing resolveRepo now retries resolveRepoFromCache after RegistryAmbiguousTargetError so stale in-memory siblings clear when the registry changes. Adds detect_changes callTool ambiguity test, registry-refresh regression test, pickRepoHandleForCwd MCP cwd doc, and temp-dir cleanup in #1658 fixtures. * chore(autofix): apply prettier + eslint fixes via /autofix command * fix(mcp): PR #1753 review follow-ups + collision-id case bug Address Findings 3-6 from the production-readiness review on PR #1753, plus a latent bug surfaced while writing the F5 regression test: - F3: drop the no-op `try { ... } catch (err) { throw err; }` wrapper around the miss-path retry in `resolveRepo`; the catch only re-threw. - F4: rewrite the misleading "child/repo" example on the relative-path tier — `child/repo` would be classified as path-like and never reach this branch. Comment now describes bare, separator-free names resolved against `process.cwd()`. - F5: add regression test for the stable hashed-id tier so a duplicate sibling can be reached by its `-` id. Writing this test exposed that `repoId()` produced a mixed-case base64url suffix while `resolveRepoFromCache` lowercased the param before the Map lookup, so collision ids with any uppercase byte in the hash were unreachable. Fix: lowercase the hash in `repoId` so it survives `paramLower`. - F6: add regression test asserting two repos sharing a name prefix (`project-a`, `project-b`) cause `resolveRepo("project")` to reject as not-found rather than silently returning the first partial match. * refactor(mcp): tighten PR #1753 follow-up tests + pin hash length Address three P2 maintainability findings from the ce-code-review pass on commit aa7f2050: - Export `REPO_ID_HASH_LENGTH` from local-backend.ts and use it in both `repoId()` and the hashed-id test. Closes the silent-drift hole where the test's inline formula could fall out of sync with the source without any signal. - Extract `makeSharedPrefixFixture(nameA, nameB)` next to `makeDuplicateNameFixture`. Centralises the temp-dir + `.gitnexus` scaffolding + `duplicateFixtureDirs.push()` cleanup contract so future callers can't drop the cleanup step. - Reorder the hashed-id test's comment block so the intentional-coupling rationale leads, before the description of the formula being mirrored. * chore(autofix): apply prettier + eslint fixes via /autofix command * chore: re-run CI --------- Co-authored-by: Test Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- gitnexus/src/mcp/local/local-backend.ts | 135 ++++++++++++-- gitnexus/test/unit/calltool-dispatch.test.ts | 174 ++++++++++++++++++- 2 files changed, 286 insertions(+), 23 deletions(-) diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index d1297ea17..331cffb2c 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -31,6 +31,8 @@ import { realpathSync } from 'fs'; import { listRegisteredRepos, cleanupOldKuzuFiles, + canonicalizePath, + RegistryAmbiguousTargetError, type RegistryEntry, } from '../../storage/repo-manager.js'; import { GroupService, type GroupToolPort } from '../../core/group/service.js'; @@ -295,6 +297,13 @@ export function resolveWorktreeCwd(repoPath: string, launchCwd: string): string return repoPath; } +/** + * Length of the base64url path hash appended to a colliding repo id. + * Exported so tests can pin the suffix shape without re-deriving the + * literal; see `repoId()` and the hashed-id resolution tier (#1658). + */ +export const REPO_ID_HASH_LENGTH = 6; + export class LocalBackend { private repos: Map = new Map(); private contextCache: Map = new Map(); @@ -423,7 +432,13 @@ export class LocalBackend { for (const [id, handle] of this.repos) { if (id === base && handle.repoPath !== path.resolve(repoPath)) { // Collision — use path hash - const hash = Buffer.from(repoPath).toString('base64url').slice(0, 6); + // Lowercase the hash so it survives the `paramLower` lookup in + // resolveRepoFromCache — base64url retains mixed case, but the id + // tier compares against `repoParam.toLowerCase()` (#1658 follow-up). + const hash = Buffer.from(repoPath) + .toString('base64url') + .slice(0, REPO_ID_HASH_LENGTH) + .toLowerCase(); return `${base}-${hash}`; } } @@ -442,7 +457,19 @@ export class LocalBackend { * while the MCP server was running. */ async resolveRepo(repoParam?: string): Promise { - const result = this.resolveRepoFromCache(repoParam); + let refreshedAfterAmbiguity = false; + let result: RepoHandle | null; + try { + result = this.resolveRepoFromCache(repoParam); + } catch (err) { + if (!(err instanceof RegistryAmbiguousTargetError)) throw err; + // Stale in-memory duplicate siblings can linger after unregister; refresh + // once before re-throwing so a resolved registry can disambiguate (#1658). + await this.refreshRepos(); + refreshedAfterAmbiguity = true; + result = this.resolveRepoFromCache(repoParam); + } + if (result) { // Issue: silent graph drift across sibling clones. // If the caller's cwd lives in a *different* on-disk clone of @@ -456,8 +483,10 @@ export class LocalBackend { return result; } - // Miss — refresh registry and try once more - await this.refreshRepos(); + // Miss — refresh registry and try once more (skip if already refreshed above) + if (!refreshedAfterAmbiguity) { + await this.refreshRepos(); + } const retried = this.resolveRepoFromCache(repoParam); if (retried) { this.maybeWarnSiblingDrift(retried).catch(() => {}); @@ -492,27 +521,66 @@ export class LocalBackend { /** * Try to resolve a repo from the in-memory cache. Returns null on miss. + * Throws {@link RegistryAmbiguousTargetError} when `repoParam` matches + * multiple handles by name and cwd cannot disambiguate (#1658). */ private resolveRepoFromCache(repoParam?: string): RepoHandle | null { if (this.repos.size === 0) return null; if (repoParam) { const paramLower = repoParam.toLowerCase(); - // Match by id + const looksLikePath = + path.isAbsolute(repoParam) || repoParam.includes(path.sep) || repoParam.includes('/'); + + const resolvePathMatch = (): RepoHandle | undefined => { + const canonicalTarget = canonicalizePath(repoParam); + return [...this.repos.values()].find((handle) => { + const stored = canonicalizePath(handle.repoPath); + return process.platform === 'win32' + ? stored.toLowerCase() === canonicalTarget.toLowerCase() + : stored === canonicalTarget; + }); + }; + + // Path-like params first (absolute or contains separators) — aligns with + // resolveRegistryEntry (#829). Bare aliases such as ".tmp-repro-mini" must + // not be resolved via path.resolve(cwd) before duplicate-name handling. + if (looksLikePath) { + const pathMatch = resolvePathMatch(); + if (pathMatch) return pathMatch; + } + + // Exact name before id — the first duplicate sibling keeps id === name + // (e.g. id "shared"), so a name lookup must not be captured by the id tier. + const nameMatches = [...this.repos.values()].filter( + (handle) => handle.name.toLowerCase() === paramLower, + ); + if (nameMatches.length === 1) return nameMatches[0]; + if (nameMatches.length > 1) { + const cwdPick = this.pickRepoHandleForCwd(nameMatches); + if (cwdPick) return cwdPick; + throw new RegistryAmbiguousTargetError( + repoParam, + nameMatches.map((h) => this.handleToRegistryEntry(h)), + ); + } + + // Stable hashed id (e.g. "shared-abc123") from repoId() collision suffix if (this.repos.has(paramLower)) return this.repos.get(paramLower)!; - // Match by name (case-insensitive) - for (const handle of this.repos.values()) { - if (handle.name.toLowerCase() === paramLower) return handle; - } - // Match by path (substring) - const resolved = path.resolve(repoParam); - for (const handle of this.repos.values()) { - if (handle.repoPath === resolved) return handle; - } - // Match by partial name - for (const handle of this.repos.values()) { - if (handle.name.toLowerCase().includes(paramLower)) return handle; + + // Bare name resolved as a cwd-relative path (e.g. "myrepo" against process.cwd()), + // after name/id tiers. Path-like strings with separators were handled at the top. + if (!looksLikePath) { + const pathMatch = resolvePathMatch(); + if (pathMatch) return pathMatch; } + + // Partial name — only when unambiguous + const partialMatches = [...this.repos.values()].filter((handle) => + handle.name.toLowerCase().includes(paramLower), + ); + if (partialMatches.length === 1) return partialMatches[0]; + return null; } @@ -523,6 +591,39 @@ export class LocalBackend { return null; // Multiple repos, no param — ambiguous } + /** + * Prefer the indexed repo whose path matches the git root of process.cwd(). + * + * In MCP stdio server mode, `process.cwd()` is the server's launch directory, + * not the agent client's cwd. If the server was started from an unrelated + * directory, `getGitRoot` returns null and duplicate-name resolution throws + * {@link RegistryAmbiguousTargetError} — callers should pass an absolute path. + */ + private pickRepoHandleForCwd(candidates: RepoHandle[]): RepoHandle | null { + const cwdRoot = getGitRoot(process.cwd()); + if (!cwdRoot) return null; + const canonicalCwd = canonicalizePath(cwdRoot); + const cwdMatches = candidates.filter((handle) => { + const stored = canonicalizePath(handle.repoPath); + return process.platform === 'win32' + ? stored.toLowerCase() === canonicalCwd.toLowerCase() + : stored === canonicalCwd; + }); + return cwdMatches.length === 1 ? cwdMatches[0] : null; + } + + private handleToRegistryEntry(handle: RepoHandle): RegistryEntry { + return { + name: handle.name, + path: handle.repoPath, + storagePath: handle.storagePath, + indexedAt: handle.indexedAt, + lastCommit: handle.lastCommit, + stats: handle.stats, + remoteUrl: handle.remoteUrl, + }; + } + // ─── Lazy LadybugDB Init ──────────────────────────────────────────── private async ensureInitialized(repoId: string): Promise { diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 8c69a23d5..9596d6b7a 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -8,6 +8,9 @@ * the dispatch and error handling logic in isolation. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import os from 'os'; +import path from 'path'; // We need to mock the LadybugDB adapter and repo-manager BEFORE importing LocalBackend. // local-backend.ts imports from core/lbug/pool-adapter.js; the mcp/core/lbug-adapter.js @@ -37,11 +40,15 @@ vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { return { ...actual, ...lbugMocks }; }); -vi.mock('../../src/storage/repo-manager.js', () => ({ - listRegisteredRepos: vi.fn().mockResolvedValue([]), - cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), - findSiblingClones: vi.fn().mockResolvedValue([]), -})); +vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listRegisteredRepos: vi.fn().mockResolvedValue([]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), + findSiblingClones: vi.fn().mockResolvedValue([]), + }; +}); // `core/git-staleness` is also imported by `local-backend.ts` (for // `checkStaleness` and `checkCwdMatch`). Stub it out here so unit @@ -52,6 +59,14 @@ vi.mock('../../src/core/git-staleness.js', () => ({ checkCwdMatch: vi.fn().mockResolvedValue({ match: 'none' }), })); +vi.mock('../../src/storage/git.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getGitRoot: vi.fn().mockReturnValue(null), + }; +}); + vi.mock('../../src/core/platform/capabilities.js', async (importOriginal) => { const actual = await importOriginal(); return { @@ -70,8 +85,9 @@ vi.mock('../../src/mcp/core/embedder.js', () => ({ getEmbeddingDims: vi.fn().mockReturnValue(384), })); -import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { LocalBackend, REPO_ID_HASH_LENGTH } from '../../src/mcp/local/local-backend.js'; import { listRegisteredRepos, cleanupOldKuzuFiles } from '../../src/storage/repo-manager.js'; +import { getGitRoot } from '../../src/storage/git.js'; import { _captureLogger } from '../../src/core/logger.js'; import { initLbug, @@ -112,6 +128,56 @@ function setupNoRepos() { (listRegisteredRepos as any).mockResolvedValue([]); } +const duplicateFixtureDirs: string[] = []; + +function makeDuplicateNameFixture() { + const mainDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-shared-main-')); + const wtDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-shared-wt-')); + duplicateFixtureDirs.push(mainDir, wtDir); + for (const dir of [mainDir, wtDir]) { + const storagePath = path.join(dir, '.gitnexus'); + mkdirSync(path.join(storagePath, 'lbug'), { recursive: true }); + writeFileSync(path.join(storagePath, 'meta.json'), '{}'); + } + return { + mainDir, + wtDir, + entries: [ + { + ...MOCK_REPO_ENTRY, + name: 'shared', + path: mainDir, + storagePath: path.join(mainDir, '.gitnexus'), + }, + { + ...MOCK_REPO_ENTRY, + name: 'shared', + path: wtDir, + storagePath: path.join(wtDir, '.gitnexus'), + }, + ], + }; +} + +function makeSharedPrefixFixture(nameA: string, nameB: string) { + const dirA = mkdtempSync(path.join(os.tmpdir(), `gnx-${nameA}-`)); + const dirB = mkdtempSync(path.join(os.tmpdir(), `gnx-${nameB}-`)); + duplicateFixtureDirs.push(dirA, dirB); + for (const dir of [dirA, dirB]) { + const storagePath = path.join(dir, '.gitnexus'); + mkdirSync(path.join(storagePath, 'lbug'), { recursive: true }); + writeFileSync(path.join(storagePath, 'meta.json'), '{}'); + } + return { + dirA, + dirB, + entries: [ + { ...MOCK_REPO_ENTRY, name: nameA, path: dirA, storagePath: path.join(dirA, '.gitnexus') }, + { ...MOCK_REPO_ENTRY, name: nameB, path: dirB, storagePath: path.join(dirB, '.gitnexus') }, + ], + }; +} + // ─── LocalBackend lifecycle ────────────────────────────────────────── describe('LocalBackend.init', () => { @@ -783,9 +849,16 @@ describe('LocalBackend.resolveRepo', () => { beforeEach(async () => { vi.clearAllMocks(); + (getGitRoot as any).mockReturnValue(null); backend = new LocalBackend(); }); + afterEach(() => { + for (const dir of duplicateFixtureDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('resolves single repo without param', async () => { setupSingleRepo(); await backend.init(); @@ -829,6 +902,95 @@ describe('LocalBackend.resolveRepo', () => { ); }); + it('prefers duplicate-name repo matching process.cwd() git root (#1658)', async () => { + const { wtDir, entries } = makeDuplicateNameFixture(); + (listRegisteredRepos as any).mockResolvedValue(entries); + (getGitRoot as any).mockReturnValue(wtDir); + await backend.init(); + (executeParameterized as any).mockResolvedValue([]); + await backend.callTool('query', { query: 'test', repo: 'shared' }); + const resolved = await backend.resolveRepo('shared'); + expect(resolved.repoPath).toBe(wtDir); + }); + + it('throws RegistryAmbiguousTargetError when duplicate name cannot be disambiguated (#1658)', async () => { + const { entries } = makeDuplicateNameFixture(); + (listRegisteredRepos as any).mockResolvedValue(entries); + (getGitRoot as any).mockReturnValue(null); + await backend.init(); + await expect(backend.resolveRepo('shared')).rejects.toThrow(/Multiple registered repos match/); + await expect(backend.resolveRepo('shared')).rejects.toThrow(/absolute path/i); + }); + + it('resolves duplicate-name repos by absolute path before name (#1658)', async () => { + const { mainDir, wtDir, entries } = makeDuplicateNameFixture(); + (listRegisteredRepos as any).mockResolvedValue(entries); + (getGitRoot as any).mockReturnValue(mainDir); + await backend.init(); + (executeParameterized as any).mockResolvedValue([]); + const resolved = await backend.resolveRepo(wtDir); + expect(resolved.repoPath).toBe(wtDir); + }); + + it('does not treat a bare duplicate alias as a relative path (#1658)', async () => { + const { entries } = makeDuplicateNameFixture(); + (listRegisteredRepos as any).mockResolvedValue(entries); + (getGitRoot as any).mockReturnValue(null); + await backend.init(); + await expect(backend.resolveRepo('shared')).rejects.toThrow(/Multiple registered repos match/); + }); + + it('refreshes registry after ambiguity when duplicates are removed (#1658)', async () => { + const { mainDir, entries } = makeDuplicateNameFixture(); + const singleEntry = [entries[0]]; + (listRegisteredRepos as any).mockResolvedValueOnce(entries).mockResolvedValueOnce(singleEntry); + (getGitRoot as any).mockReturnValue(null); + await backend.init(); + const resolved = await backend.resolveRepo('shared'); + expect(resolved.repoPath).toBe(mainDir); + }); + + it('detect_changes surfaces RegistryAmbiguousTargetError on duplicate repo name (#1658)', async () => { + const { entries } = makeDuplicateNameFixture(); + (listRegisteredRepos as any).mockResolvedValue(entries); + (getGitRoot as any).mockReturnValue(null); + await backend.init(); + await expect( + backend.callTool('detect_changes', { scope: 'unstaged', repo: 'shared' }), + ).rejects.toThrow(/Multiple registered repos match/); + }); + + it('resolves second duplicate-name repo by its stable hashed id (#1658)', async () => { + const { wtDir, entries } = makeDuplicateNameFixture(); + (listRegisteredRepos as any).mockResolvedValue(entries); + // Couples this test to repoId's suffix formula on purpose — if repoId changes + // its suffix, this assertion should fail and force a re-review of the hashed-id + // resolution tier. Mirrors LocalBackend.repoId: base64url(repoPath) sliced to + // REPO_ID_HASH_LENGTH and lowercased so it survives the paramLower lookup in + // resolveRepoFromCache. + const wtId = `shared-${Buffer.from(wtDir) + .toString('base64url') + .slice(0, REPO_ID_HASH_LENGTH) + .toLowerCase()}`; + await backend.init(); + const resolved = await backend.resolveRepo(wtId); + expect(resolved.repoPath).toBe(wtDir); + }); + + it('does not silently return first partial match for ambiguous prefix (#1658)', async () => { + const { dirA, entries } = makeSharedPrefixFixture('project-a', 'project-b'); + (listRegisteredRepos as any).mockResolvedValue(entries); + (getGitRoot as any).mockReturnValue(null); + await backend.init(); + + await expect(backend.resolveRepo('project')).rejects.toThrow(/Repository "project" not found/); + + // Sanity: exact names still resolve unambiguously against the same fixture. + const exact = await backend.resolveRepo('project-a'); + expect(exact.name).toBe('project-a'); + expect(exact.repoPath).toBe(dirA); + }); + it('resolves repo case-insensitively', async () => { setupSingleRepo(); await backend.init(); From 8c1983a8bfee6d932e75f6e52ad26e786dead76e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 04:41:57 +0100 Subject: [PATCH 02/16] chore(deps)(deps-dev): bump @types/node in /gitnexus (#1767) --- gitnexus/package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index ec4883ed6..14d6c7ad4 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1792,12 +1792,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.7.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", - "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", "license": "MIT", "dependencies": { - "undici-types": "~7.21.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/qs": { @@ -5115,9 +5115,9 @@ } }, "node_modules/undici-types": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", - "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, "node_modules/universalify": { From 5a0f5e81db209a254f88947027689117dae94e07 Mon Sep 17 00:00:00 2001 From: Abhigyan Patwari <126312502+abhigyanpatwari@users.noreply.github.com> Date: Fri, 22 May 2026 05:08:42 +0100 Subject: [PATCH 03/16] ci(web): use npm ci for deterministic Vercel installs (#1764) --- gitnexus-web/vercel.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitnexus-web/vercel.json b/gitnexus-web/vercel.json index 88fd4e83d..284b15735 100644 --- a/gitnexus-web/vercel.json +++ b/gitnexus-web/vercel.json @@ -1,3 +1,3 @@ { - "installCommand": "cd ../gitnexus-shared && npm install && npm run build && cd ../gitnexus-web && npm install" + "installCommand": "cd ../gitnexus-shared && npm install && npm run build && cd ../gitnexus-web && npm ci --include=dev" } From dc96bb048a049f854b37d8aafbbb171af0c7e374 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 05:27:51 +0100 Subject: [PATCH 04/16] chore(deps)(deps-dev): bump tsx from 4.21.1 to 4.22.0 in /gitnexus (#1768) --- gitnexus/package-lock.json | 222 ++++++++++++++++++------------------- 1 file changed, 111 insertions(+), 111 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 14d6c7ad4..a20f1728f 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -172,9 +172,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", - "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", "cpu": [ "ppc64" ], @@ -189,9 +189,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", - "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "cpu": [ "arm" ], @@ -206,9 +206,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", - "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "cpu": [ "arm64" ], @@ -223,9 +223,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", - "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "cpu": [ "x64" ], @@ -240,9 +240,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", - "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", "cpu": [ "arm64" ], @@ -257,9 +257,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", - "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", "cpu": [ "x64" ], @@ -274,9 +274,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", - "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", "cpu": [ "arm64" ], @@ -291,9 +291,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", - "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", "cpu": [ "x64" ], @@ -308,9 +308,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", - "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", "cpu": [ "arm" ], @@ -325,9 +325,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", - "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", "cpu": [ "arm64" ], @@ -342,9 +342,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", - "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", "cpu": [ "ia32" ], @@ -359,9 +359,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", - "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", "cpu": [ "loong64" ], @@ -376,9 +376,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", - "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", "cpu": [ "mips64el" ], @@ -393,9 +393,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", - "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", "cpu": [ "ppc64" ], @@ -410,9 +410,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", - "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "cpu": [ "riscv64" ], @@ -427,9 +427,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", - "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "cpu": [ "s390x" ], @@ -444,9 +444,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", - "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", "cpu": [ "x64" ], @@ -461,9 +461,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", - "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", "cpu": [ "arm64" ], @@ -478,9 +478,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", - "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "cpu": [ "x64" ], @@ -495,9 +495,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", - "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", "cpu": [ "arm64" ], @@ -512,9 +512,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", - "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "cpu": [ "x64" ], @@ -529,9 +529,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", - "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", "cpu": [ "arm64" ], @@ -546,9 +546,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", - "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "cpu": [ "x64" ], @@ -563,9 +563,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", - "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "cpu": [ "arm64" ], @@ -580,9 +580,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", - "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", "cpu": [ "ia32" ], @@ -597,9 +597,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", - "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", "cpu": [ "x64" ], @@ -2558,9 +2558,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", - "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2571,32 +2571,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.4", - "@esbuild/android-arm": "0.27.4", - "@esbuild/android-arm64": "0.27.4", - "@esbuild/android-x64": "0.27.4", - "@esbuild/darwin-arm64": "0.27.4", - "@esbuild/darwin-x64": "0.27.4", - "@esbuild/freebsd-arm64": "0.27.4", - "@esbuild/freebsd-x64": "0.27.4", - "@esbuild/linux-arm": "0.27.4", - "@esbuild/linux-arm64": "0.27.4", - "@esbuild/linux-ia32": "0.27.4", - "@esbuild/linux-loong64": "0.27.4", - "@esbuild/linux-mips64el": "0.27.4", - "@esbuild/linux-ppc64": "0.27.4", - "@esbuild/linux-riscv64": "0.27.4", - "@esbuild/linux-s390x": "0.27.4", - "@esbuild/linux-x64": "0.27.4", - "@esbuild/netbsd-arm64": "0.27.4", - "@esbuild/netbsd-x64": "0.27.4", - "@esbuild/openbsd-arm64": "0.27.4", - "@esbuild/openbsd-x64": "0.27.4", - "@esbuild/openharmony-arm64": "0.27.4", - "@esbuild/sunos-x64": "0.27.4", - "@esbuild/win32-arm64": "0.27.4", - "@esbuild/win32-ia32": "0.27.4", - "@esbuild/win32-x64": "0.27.4" + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" } }, "node_modules/escalade": { @@ -5039,13 +5039,13 @@ "optional": true }, "node_modules/tsx": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.1.tgz", - "integrity": "sha512-5QE2Q04cN1u0993w0LT5rPw3faZqZU1fFn1mGE0pV53N1Dn7c+QFFxQu1mBeSgeOXwFyTicZw02wVgp3Tb5cAQ==", + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.0.tgz", + "integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" From be3833d9c9f0fb530109de08ae8ee9464ff7b39e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 06:26:09 +0100 Subject: [PATCH 05/16] chore(security): upgrade `@vercel/node` in `gitnexus-web` and remediate transitive advisories (#1705) --- gitnexus-web/package-lock.json | 591 +++++++++++++++++---------------- gitnexus-web/package.json | 17 +- 2 files changed, 312 insertions(+), 296 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 47803c469..e718eb7f2 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -54,7 +54,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", - "@vercel/node": "^5.5.16", + "@vercel/node": "^5.8.2", "@vitejs/plugin-react": "^5.1.4", "@vitest/coverage-v8": "^4.1.5", "jsdom": "^29.1.1", @@ -522,6 +522,13 @@ "specificity": "bin/cli.js" } }, + "node_modules/@bytecodealliance/preview2-shim": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@bytecodealliance/preview2-shim/-/preview2-shim-0.17.6.tgz", + "integrity": "sha512-n3cM88gTen5980UOBAD6xDcNNL3ocTK8keab21bpx1ONdA+ARj7uD1qoFxOWCyKlkpSi195FH+GeAut7Oc6zZw==", + "dev": true, + "license": "(Apache-2.0 WITH LLVM-exception)" + }, "node_modules/@cfworker/json-schema": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", @@ -534,30 +541,6 @@ "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", "license": "Apache-2.0" }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", @@ -1216,16 +1199,6 @@ } } }, - "node_modules/@fastify/busboy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, "node_modules/@google/generative-ai": { "version": "0.24.1", "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz", @@ -1723,6 +1696,17 @@ "node": ">=18" } }, + "node_modules/@renovatebot/pep440": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@renovatebot/pep440/-/pep440-4.2.1.tgz", + "integrity": "sha512-2FK1hF93Fuf1laSdfiEmJvSJPVIDHEUTz68D3Fi9s0IZrrpaEcj6pTFBTbYvsgC5du4ogrtf5re7yMMvrKNgkw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.9.0 || ^22.11.0 || ^24", + "pnpm": "^10.0.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.18", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz", @@ -2430,6 +2414,24 @@ "path-browserify": "^1.0.1" } }, + "node_modules/@ts-morph/common/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ts-morph/common/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/@ts-morph/common/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2443,34 +2445,6 @@ "node": "*" } }, - "node_modules/@tsconfig/node10": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", - "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT" - }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -2951,23 +2925,35 @@ } }, "node_modules/@vercel/build-utils": { - "version": "13.2.11", - "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.2.11.tgz", - "integrity": "sha512-jbsg78iS8SLpOkLw378bBLchmzeQ+YtPnztMMuEFBORjY1G4lDxiStMacD3xp5HImCAl1wz4dNV4I8jHKd/3Tg==", + "version": "13.25.0", + "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.25.0.tgz", + "integrity": "sha512-p2wqxi2I95T+g/+uP+Dc/uq2PApW7F9RbE/Vvwp28JY8SoCGHNUs2pxigttgaNDNF6IlUEMOTz+eJvsXToV/1w==", "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "dependencies": { + "@vercel/python-analysis": "0.11.1", + "cjs-module-lexer": "1.2.3", + "es-module-lexer": "1.5.0" + } + }, + "node_modules/@vercel/build-utils/node_modules/es-module-lexer": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.0.tgz", + "integrity": "sha512-pqrTKmwEIgafsYZAGw9kszYzmagcE/n4dbgwGWLEXg7J4QFJVQRBld8j3Q3GNez79jzxZshq0bcT962QHOghjw==", + "dev": true, + "license": "MIT" }, "node_modules/@vercel/error-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@vercel/error-utils/-/error-utils-2.0.3.tgz", - "integrity": "sha512-CqC01WZxbLUxoiVdh9B/poPbNpY9U+tO1N9oWHwTl5YAZxcqXmmWJ8KNMFItJCUUWdY3J3xv8LvAuQv2KZ5YdQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@vercel/error-utils/-/error-utils-2.1.0.tgz", + "integrity": "sha512-DiJcXBOB9N6QM4d7hYPM9Ck/AUjzBl58XNQPxS74o7CuvIanjzrGgygP/70VsyEASeIJMazk1LrhwcNTR/eZGQ==", "dev": true, "license": "Apache-2.0" }, "node_modules/@vercel/nft": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-1.1.1.tgz", - "integrity": "sha512-mKMGa7CEUcXU75474kOeqHbtvK1kAcu4wiahhmlUenB5JbTQB8wVlDI8CyHR3rpGo0qlzoRWqcDzI41FUoBJCA==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-1.5.0.tgz", + "integrity": "sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==", "dev": true, "license": "MIT", "dependencies": { @@ -2992,20 +2978,20 @@ } }, "node_modules/@vercel/node": { - "version": "5.5.23", - "resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.5.23.tgz", - "integrity": "sha512-dDJtroLF4D/H9vRMt/x/qI2bKujMOPbk6aIqRKI9WXddngjKziuHxsjcF3zEm5YXGUYDSC2lEVEFrXPbbP+hhw==", + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.8.2.tgz", + "integrity": "sha512-Wt6KBr0LoIhUuzeH7E8S+1HRlS6oSA+98FJH/59wY2tYoxsHXbx5uiNkSBqBsM0nIwlJWR3B86tm9q9Xi22XdQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@edge-runtime/node-utils": "2.3.0", "@edge-runtime/primitives": "4.1.0", "@edge-runtime/vm": "3.2.0", - "@types/node": "16.18.11", - "@vercel/build-utils": "13.2.11", - "@vercel/error-utils": "2.0.3", - "@vercel/nft": "1.1.1", - "@vercel/static-config": "3.1.2", + "@types/node": "20.11.0", + "@vercel/build-utils": "13.25.0", + "@vercel/error-utils": "2.1.0", + "@vercel/nft": "1.5.0", + "@vercel/static-config": "3.3.0", "async-listen": "3.0.0", "cjs-module-lexer": "1.2.3", "edge-runtime": "2.5.9", @@ -3017,37 +3003,58 @@ "path-to-regexp": "6.1.0", "path-to-regexp-updated": "npm:path-to-regexp@6.3.0", "ts-morph": "12.0.0", - "ts-node": "10.9.1", - "typescript": "4.9.5", - "typescript5": "npm:typescript@5.9.3", + "tsx": "4.21.0", + "typescript": "npm:typescript@5.9.3", "undici": "5.28.4" } }, "node_modules/@vercel/node/node_modules/@types/node": { - "version": "16.18.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.11.tgz", - "integrity": "sha512-3oJbGBUWuS6ahSnEq1eN2XrCyf4YsWI8OyCvo7c64zQJNplk3mO84t53o8lfTk+2ji59g5ycfc6qQ3fdHliHuA==", + "version": "20.11.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz", + "integrity": "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@vercel/node/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "dev": true, "license": "MIT" }, - "node_modules/@vercel/node/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "node_modules/@vercel/python-analysis": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@vercel/python-analysis/-/python-analysis-0.11.1.tgz", + "integrity": "sha512-EPPLuXJQhIDUx08H9nG76AR2HSgBquwe3OAX5s2w20M923iaWeGGVkhX/4yZ89CJfXEZgE1Aj/mX7lVHOVIcYA==", "dev": true, "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" + "dependencies": { + "@bytecodealliance/preview2-shim": "0.17.6", + "@renovatebot/pep440": "4.2.1", + "fs-extra": "11.1.1", + "js-yaml": "4.1.1", + "minimatch": "10.1.1", + "smol-toml": "1.5.2", + "zod": "3.22.4" + } + }, + "node_modules/@vercel/python-analysis/node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/@vercel/static-config": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@vercel/static-config/-/static-config-3.1.2.tgz", - "integrity": "sha512-2d+TXr6K30w86a+WbMbGm2W91O0UzO5VeemZYBBUJbCjk/5FLLGIi8aV6RS2+WmaRvtcqNTn2pUA7nCOK3bGcQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@vercel/static-config/-/static-config-3.3.0.tgz", + "integrity": "sha512-GpS3tPwUeDJCkrKbMNtS2XLRFgfxTlN7YNUL+Bo23+fGolrDw6Oq79R3yvxTYgqRaJMGSEqC7iMw6mj6I5loxg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3274,19 +3281,6 @@ "acorn": "^8" } }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -3298,16 +3292,16 @@ } }, "node_modules/ajv": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.6.3.tgz", - "integrity": "sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -3337,12 +3331,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "MIT" + "license": "Python-2.0" }, "node_modules/aria-query": { "version": "5.3.0", @@ -3438,11 +3432,14 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/base64-js": { "version": "1.5.1", @@ -3495,14 +3492,16 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -3762,13 +3761,6 @@ "layout-base": "^1.0.0" } }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT" - }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -4448,16 +4440,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -4759,6 +4741,23 @@ "node": ">=8.6.0" } }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -4863,6 +4862,21 @@ "node": ">=0.4.x" } }, + "node_modules/fs-extra": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", + "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4933,23 +4947,36 @@ "node": ">= 0.4" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/gitnexus-shared": { "resolved": "../gitnexus-shared", "link": true }, "node_modules/glob": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", - "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "path-scurry": "^2.0.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -4968,6 +4995,22 @@ "node": ">= 6" } }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5475,6 +5518,19 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsdom": { "version": "29.1.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", @@ -5610,6 +5666,19 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/katex": { "version": "0.16.27", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.27.tgz", @@ -6049,13 +6118,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -7037,13 +7099,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz", + "integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.2" }, "engines": { "node": "18 || 20 || >=22" @@ -7052,29 +7114,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimatch/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/minimatch/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -7086,11 +7125,11 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -7417,9 +7456,9 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -7427,16 +7466,16 @@ "minipass": "^7.1.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-to-regexp": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.1.0.tgz", - "integrity": "sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true, "license": "MIT" }, @@ -7881,6 +7920,16 @@ "node": ">=8" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -8057,6 +8106,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -8181,9 +8243,9 @@ } }, "node_modules/tar": { - "version": "7.5.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", - "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "version": "7.5.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", + "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -8364,50 +8426,6 @@ "code-block-writer": "^10.1.1" } }, - "node_modules/ts-node": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, "node_modules/ts-toolbelt": { "version": "6.15.5", "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-6.15.5.tgz", @@ -8422,22 +8440,27 @@ "devOptional": true, "license": "0BSD" }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsx": "dist/cli.mjs" }, "engines": { - "node": ">=14.17" + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" } }, - "node_modules/typescript5": { - "name": "typescript", + "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", @@ -8458,16 +8481,13 @@ "license": "MIT" }, "node_modules/undici": { - "version": "5.28.4", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", - "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", + "version": "6.24.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.0.tgz", + "integrity": "sha512-lVLNosgqo5EkGqh5XUDhGfsMSoO8K0BAN0TyJLvwNRSl4xWGZlCVYsAIpa/OpA3TvmnM01GWcoKmc3ZWo5wKKA==", "dev": true, "license": "MIT", - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, "engines": { - "node": ">=14.0" + "node": ">=18.17" } }, "node_modules/undici-types": { @@ -8564,6 +8584,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -8595,16 +8625,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/uuid": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", @@ -8618,13 +8638,6 @@ "uuid": "dist-node/bin/uuid" } }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT" - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -8938,16 +8951,6 @@ "node": ">=18" } }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 6a2bc0187..de51268d0 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -18,7 +18,6 @@ "test:e2e:report": "playwright show-report" }, "dependencies": { - "gitnexus-shared": "file:../gitnexus-shared", "@langchain/anthropic": "^1.3.29", "@langchain/core": "^1.1.44", "@langchain/google-genai": "^2.1.30", @@ -30,6 +29,7 @@ "axios": "^1.16.0", "d3": "^7.9.0", "dompurify": "^3.4.3", + "gitnexus-shared": "file:../gitnexus-shared", "graphology": "^0.26.0", "graphology-indices": "^0.17.0", "graphology-layout-force": "^0.2.4", @@ -64,7 +64,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", - "@vercel/node": "^5.5.16", + "@vercel/node": "^5.8.2", "@vitejs/plugin-react": "^5.1.4", "@vitest/coverage-v8": "^4.1.5", "jsdom": "^29.1.1", @@ -73,5 +73,18 @@ "vite": "^8.0.11", "vitest": "^4.1.5", "wait-on": "^9.0.5" + }, + "overrides": { + "@vercel/static-config": { + "ajv": "8.18.0" + }, + "@vercel/node": { + "path-to-regexp": "6.3.0", + "undici": "6.24.0" + }, + "@vercel/python-analysis": { + "minimatch": "10.2.3", + "smol-toml": "1.6.1" + } } } From 954b1842486bd381c80133d68ef08de4872d28cf Mon Sep 17 00:00:00 2001 From: Emmanuel Date: Fri, 22 May 2026 07:03:40 +0100 Subject: [PATCH 06/16] fix(cli): apply --no-stats to keep-marker stats line (#1706) (#1765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): apply --no-stats to keep-marker stats line (#1706) The keep-marker branch of upsertGitNexusSection rebuilt the index-summary line on every analyze and always re-injected the volatile counts, ignoring --no-stats. For teams that commit a trimmed AGENTS.md/CLAUDE.md with a gitnexus:keep marker, that produced recurring no-value merge conflicts — exactly what --no-stats exists to prevent. Thread noStats into upsertGitNexusSection. Under --no-stats the keep-path stats line becomes "Indexed as ****" with no (N symbols, ...) parenthetical; the project name still refreshes so renames propagate. The statsPattern parenthetical is now optional so a count-free line left by a prior --no-stats run still matches. * test(cli): cover count-return and AGENTS.md parity for --no-stats keep path Addresses review findings F1 and F2 on PR #1765: - F1: add a test that counts RETURN when --no-stats is dropped after a prior count-free run — guards against the flag becoming sticky. - F2: extend the noStats+keep "drops the volatile counts" test to assert AGENTS.md alongside CLAUDE.md, so a future asymmetry between the two upsertGitNexusSection call sites is caught. --------- Co-authored-by: Emmanuel Alawode Co-authored-by: Gergő Magyar --- gitnexus/src/cli/ai-context.ts | 34 +++++++-- gitnexus/test/unit/ai-context.test.ts | 105 ++++++++++++++++++++++---- 2 files changed, 118 insertions(+), 21 deletions(-) diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 42dcb7aa7..1eecc00b4 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -201,6 +201,7 @@ async function upsertGitNexusSection( content: string, projectName: string, stats: RepoStats, + noStats?: boolean, ): Promise<'created' | 'updated' | 'appended' | 'preserved'> { const exists = await fileExists(filePath); @@ -246,14 +247,23 @@ async function upsertGitNexusSection( // like `({target: "symbolName", direction: "upstream"})` // when noStats is set // Passing projectName + stats explicitly makes the contract obvious. - // noStats controls template generation, not keep-section stat updates — the user opted into a stats line by keeping it. + // --no-stats wins in the keep path too (#1706): a lean block committed + // to git would otherwise churn the volatile counts on every analyze, + // producing no-value merge conflicts between branches. Under noStats we + // drop the parenthetical but still refresh the project name so renames + // propagate. const newStatsInner = `${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows`; - const statsLine = `Indexed as **${projectName}** (${newStatsInner})`; + const statsLine = noStats + ? `Indexed as **${projectName}**` + : `Indexed as **${projectName}** (${newStatsInner})`; // Match either canonical phrasing at line start (`^` with `m` flag) so we // cannot replace prose embedded mid-paragraph. Deliberately no `$`: text - // after the closing `)` on the same line (e.g. ". MCP tools.") stays intact. - const statsPattern = /^(?:Indexed as|indexed by GitNexus as) \*\*[^*]+\*\* \([^)]+\)/m; + // after the line on the same line (e.g. ". MCP tools.") stays intact. + // The parenthetical is optional so a count-free line left by a prior + // --no-stats run still matches — letting the name refresh, and letting + // counts return if --no-stats is later dropped. + const statsPattern = /^(?:Indexed as|indexed by GitNexus as) \*\*[^*]+\*\*(?: \([^)]+\))?/m; if (statsPattern.test(existingSection)) { const updatedSection = existingSection.replace(statsPattern, statsLine); @@ -389,12 +399,24 @@ export async function generateAIContextFiles( if (!options?.skipAgentsMd) { // Create AGENTS.md (standard for Cursor, Windsurf, OpenCode, Cline, etc.) const agentsPath = path.join(repoPath, 'AGENTS.md'); - const agentsResult = await upsertGitNexusSection(agentsPath, content, projectName, stats); + const agentsResult = await upsertGitNexusSection( + agentsPath, + content, + projectName, + stats, + options?.noStats, + ); createdFiles.push(`AGENTS.md (${agentsResult})`); // Create CLAUDE.md (for Claude Code) const claudePath = path.join(repoPath, 'CLAUDE.md'); - const claudeResult = await upsertGitNexusSection(claudePath, content, projectName, stats); + const claudeResult = await upsertGitNexusSection( + claudePath, + content, + projectName, + stats, + options?.noStats, + ); createdFiles.push(`CLAUDE.md (${claudeResult})`); } else { createdFiles.push('AGENTS.md (skipped via --skip-agents-md)'); diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index 68dee21dd..b0f17ae61 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -626,21 +626,28 @@ Indexed as **Idem** (1 symbols, 2 relationships, 3 execution flows). Custom. } }); - it('noStats + keep marker: stats line update is NOT corrupted by Always-Do tuple text (#1508 review F3)', async () => { - // Regression guard: with the old fallback regex `\(([^)]+)\)`, when - // noStats=true suppressed the canonical stats line from generated - // content, the fallback matched the FIRST parenthesized text in the - // template, which was `({target: "symbolName", direction: "upstream"})` - // from the Always Do bullet — silently writing that as the stats line. + it('noStats + keep marker: stats line drops the volatile counts (#1706)', async () => { + // #1706: --no-stats must win in the keep-marker path too. A lean block + // committed to git would otherwise churn the parenthetical counts on + // every analyze, producing no-value merge conflicts between branches. + // The parenthetical is stripped; the project name still refreshes. + // + // Also a regression guard (#1508 review F3): the rewritten stats line + // MUST NOT pick up the `({target: "symbolName", direction: "upstream"})` + // tuple from the Always Do bullet. + // + // Asserted for BOTH AGENTS.md and CLAUDE.md: generateAIContextFiles + // updates them through separate upsertGitNexusSection call sites, so the + // parity check guards against a future asymmetry between the two. const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-nostats-')); try { - const claudePath = path.join(dir, 'CLAUDE.md'); const seed = ` Indexed as **NoStatsTest** (1 symbols, 1 relationships, 1 execution flows). Custom. `; - await fs.writeFile(claudePath, seed, 'utf-8'); + await fs.writeFile(path.join(dir, 'CLAUDE.md'), seed, 'utf-8'); + await fs.writeFile(path.join(dir, 'AGENTS.md'), seed, 'utf-8'); const stats = { nodes: 42, edges: 84, processes: 3 }; await generateAIContextFiles( @@ -649,16 +656,84 @@ Indexed as **NoStatsTest** (1 symbols, 1 relationships, 1 execution flows). Cust 'NoStatsTest', stats, undefined, - { noStats: true }, + { + noStats: true, + }, ); + for (const f of ['CLAUDE.md', 'AGENTS.md']) { + const result = await fs.readFile(path.join(dir, f), 'utf-8'); + // Stats line MUST NOT have been corrupted with the Always-Do tuple text + expect(result, f).not.toMatch(/\(\{target:/); + expect(result, f).not.toMatch(/direction:\s*"upstream"/); + // The volatile counts MUST be gone — no parenthetical, no leaked numbers. + expect(result, f).not.toContain('42 symbols'); + expect(result, f).not.toMatch(/\(\d+\s+symbols,/); + // The count-free stats line is still present and the name refreshed. + expect(result, f).toContain('Indexed as **NoStatsTest**'); + // Custom prose still preserved + expect(result, f).toContain('Custom.'); + } + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it('noStats + keep marker: project name still refreshes when counts are stripped (#1706)', async () => { + // Stripping the parenthetical must not freeze the whole line: a repo + // rename should still propagate into the keep-section stats line, even + // when the existing line has no parenthetical to match against. + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-nostats-rename-')); + try { + const claudePath = path.join(dir, 'CLAUDE.md'); + // Seed already in the count-free shape a prior --no-stats run produces. + const seed = ` + +Indexed as **OldName**. Custom. + +`; + await fs.writeFile(claudePath, seed, 'utf-8'); + + const stats = { nodes: 7, edges: 8, processes: 9 }; + await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), 'NewName', stats, undefined, { + noStats: true, + }); + const result = await fs.readFile(claudePath, 'utf-8'); - // Stats line MUST NOT have been corrupted with the Always-Do tuple text - expect(result).not.toMatch(/\(\{target:/); - expect(result).not.toMatch(/direction:\s*"upstream"/); - // Stats line should reflect a sensible numeric update (passed stats) - expect(result).toContain('42 symbols'); - // Custom prose still preserved + expect(result).toContain('Indexed as **NewName**'); + expect(result).not.toContain('OldName'); + expect(result).not.toMatch(/\(\d+\s+symbols,/); + expect(result).toContain('Custom.'); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it('noStats + keep marker: counts return when --no-stats is dropped after a count-free run (#1706)', async () => { + // --no-stats must not be sticky: once a prior run has left the + // keep-section line count-free, a later run WITHOUT --no-stats must + // restore the parenthetical. The optional parenthetical in statsPattern + // is what keeps the count-free line re-matchable. + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-counts-return-')); + try { + const claudePath = path.join(dir, 'CLAUDE.md'); + // Seed already in the count-free shape a prior --no-stats run produces. + const seed = ` + +Indexed as **FreezeTest**. Custom. + +`; + await fs.writeFile(claudePath, seed, 'utf-8'); + + const stats = { nodes: 11, edges: 22, processes: 3 }; + // No noStats option — the counts must come back. + await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), 'FreezeTest', stats); + + const result = await fs.readFile(claudePath, 'utf-8'); + expect(result).toContain( + 'Indexed as **FreezeTest** (11 symbols, 22 relationships, 3 execution flows)', + ); + // Suffix prose after the stats line is preserved. expect(result).toContain('Custom.'); } finally { await fs.rm(dir, { recursive: true, force: true }); From 9ad48c173e2d18ad1b0570a8a833c1d226b25504 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Fri, 22 May 2026 09:22:53 +0100 Subject: [PATCH 07/16] fix(lang-kotlin): overload target-id selection by parameter types (#1761) (#1777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same-arity Kotlin class-method overloads collapsed onto whichever node was registered first. `lookup("alice")` resolved to `lookup(Int)` — not because the picker chose wrong, but because `resolveDefGraphId` fell through to the simple-name fallback after its parameter-typed key lookup missed. Root cause: `populateKotlinOwners` (which calls `populateClassOwnedMembers`) assigned `ownerId` and qualified names to class-owned function defs but left `def.type === 'Function'`. The graph parsing-processor, in contrast, emits a `Method` node label for class members. `resolveDefGraphId`'s parameter-typed key lookup is gated on `def.type === 'Method'` (graph-bridge/ids.ts:108-116), so it was skipped for every Kotlin class method. With the type-keyed lookup skipped, the resolver fell through to `simpleKey`, which is first-wins by registration order — and the Int overload always registered first in these fixtures. Fix: `populateKotlinOwners` now upgrades `def.type` from `Function` to `Method` after `populateClassOwnedMembers` assigns `ownerId`. This aligns the scope-resolution model with the graph's node labels so parameter-typed key registration and lookup operate in the same keyspace. Picker logic in `pickImplicitThisOverload` / `narrowOverloadCandidates` was already correct — verified by trace: it narrowed `lookup("alice")` to the `[String]` def. Only the graph-id lookup was broken. Verification (REGISTRY_PRIMARY_KOTLIN=1): - Forced-mode: 21 -> 18 failing of 175 (3 fewer; tests 1620, 1659, 1692 in `test/integration/resolvers/kotlin.test.ts` now green). - Default-mode Kotlin: 175/175 unchanged. - Full resolver suite: 2216/2216 unchanged. - Remaining 18 failures are tracked by sibling sub-issues (#1758, #1759, #1760, #1762, #1763). Does NOT add Kotlin to MIGRATED_LANGUAGES per parent #1746 flip criteria. Closes #1761. Refs #1746. Co-authored-by: Test --- .../core/ingestion/languages/kotlin/owners.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/gitnexus/src/core/ingestion/languages/kotlin/owners.ts b/gitnexus/src/core/ingestion/languages/kotlin/owners.ts index db6ddf632..f034bafd7 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/owners.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/owners.ts @@ -4,6 +4,29 @@ import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/s export function populateKotlinOwners(parsed: ParsedFile): void { populateClassOwnedMembers(parsed); populateCompanionMembersOnEnclosingClass(parsed); + upgradeClassOwnedFunctionsToMethods(parsed); +} + +/** + * Align scope-resolution `def.type` with the graph's node-label + * conventions: a `Function` def that lives inside a class body becomes + * a `Method`. The Kotlin extractor labels every `function_declaration` + * as `Function`, but the graph parsing-processor emits a `Method` + * graph-node label for class members. Without this realignment, + * `resolveDefGraphId`'s parameter-typed key lookup (gated on + * `def.type === 'Method'`) falls through to the simple-name fallback + * for class methods, collapsing same-name same-arity overloads onto + * the first-registered node (#1761). + * + * Only Method-bearing types are touched. Methods have a class owner + * (set by `populateClassOwnedMembers`) and a class-qualified name. + */ +function upgradeClassOwnedFunctionsToMethods(parsed: ParsedFile): void { + for (const def of parsed.localDefs) { + if (def.type !== 'Function') continue; + if (def.ownerId === undefined) continue; + (def as { type: SymbolDefinition['type'] }).type = 'Method'; + } } function populateCompanionMembersOnEnclosingClass(parsed: ParsedFile): void { From ccf0b8b73cad774be3c634855712572f267f1541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Fri, 22 May 2026 09:44:19 +0100 Subject: [PATCH 08/16] fix(lang-kotlin): smart-cast type refinement for when/is and if/is (#1758) (#1774) Adds tree-sitter @scope.block captures for Kotlin when-arm bodies and if-then bodies, plus a synthesizer that emits narrowed type-bindings anchored on those bodies. The receiver-bound calls pass then resolves `obj.member()` inside `is T` arms against `T` without leaking the narrowing to sibling arms, `else` branches, or the enclosing function. Implementation: - query.ts: @scope.block on `(when_entry (when_condition (type_test)) (control_structure_body))` and `(if_expression (check_expression) (control_structure_body))`. - captures.ts: synthesizeKotlinSmartCastBindings walks `when_expression` and `if_expression` nodes; emits `@type-binding.annotation` with a `@type-binding.narrowed` marker so kotlinBindingScopeFor in simple-hooks.ts overrides the scope-extractor's auto-hoist (which would otherwise promote unbraced-arm bindings to the function scope because the body anchor coincides with the Block scope's range). - simple-hooks.ts: kotlinBindingScopeFor checks the marker and pins the binding to the innermost (Block) scope. Verification (REGISTRY_PRIMARY_KOTLIN=1): - Forced-mode: 21 -> 9 failing of 175 (12 fewer; all 12 when/is tests now green: lines 957, 966, 975, 1096, 1107, 1118, 1131, 1142, 1153, 1164, 1182, 1195 in test/integration/resolvers/kotlin.test.ts). - Default-mode: 175/175 unchanged. - Full resolver suite: 2216/2216 unchanged. - Remaining 9 failures are tracked by sibling sub-issues (#1759-#1763). Does NOT add Kotlin to MIGRATED_LANGUAGES per parent #1746 flip criteria. Closes #1758. Refs #1746. Co-authored-by: Test --- .../ingestion/languages/kotlin/captures.ts | 99 +++++++++++++++++++ .../core/ingestion/languages/kotlin/query.ts | 13 +++ .../languages/kotlin/simple-hooks.ts | 7 ++ 3 files changed, 119 insertions(+) diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index be8b3503d..ca8d34da3 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -35,6 +35,7 @@ export function emitKotlinScopeCaptures( const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode); out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes)); out.push(...synthesizeKotlinLoopBindings(tree.rootNode, returnTypes)); + out.push(...synthesizeKotlinSmartCastBindings(tree.rootNode)); for (const match of getKotlinScopeQuery().matches(tree.rootNode)) { const grouped: Record = {}; @@ -186,6 +187,104 @@ function synthesizeKotlinLoopBindings( return out; } +/** + * Synthesize narrowed type-bindings for Kotlin smart-cast forms — issue #1758. + * + * For each `when (x) { is T -> body }` and `if (x is T) body`, emits a + * `@type-binding.annotation` capture binding `x → T` anchored on the body + * node. The capture lands in the matching `@scope.block` scope (see query.ts + * smart-cast scopes), shadowing the outer parameter binding for calls inside + * the body without leaking across sibling arms or to `else`. + * + * Only narrows when: + * - the `when` subject is a `simple_identifier` (not a call or field chain); + * - the `when_entry` condition is exactly one `type_test` (skips `!is`, + * compound conditions, range/`in`/value patterns); + * - the `if_expression` condition is a `check_expression` of the form + * ` is ` and the then-branch is a + * `control_structure_body`. + * + * `else` arms and non-narrowing conditions emit nothing — the fall-through to + * the outer scope's declared type is the correct semantic. + */ +function synthesizeKotlinSmartCastBindings(rootNode: SyntaxNode): CaptureMatch[] { + const out: CaptureMatch[] = []; + + for (const whenNode of descendantsOfType(rootNode, 'when_expression')) { + const subjectName = extractWhenSubjectIdentifier(whenNode); + if (subjectName === null) continue; + + for (const entry of whenNode.namedChildren) { + if (entry.type !== 'when_entry') continue; + const narrowedType = extractIsTestTargetType(entry); + if (narrowedType === null) continue; + const body = entry.namedChildren.find((child) => child.type === 'control_structure_body'); + if (body === undefined) continue; + out.push(buildNarrowedTypeBindingCapture(subjectName.node, body, narrowedType)); + } + } + + for (const ifNode of descendantsOfType(rootNode, 'if_expression')) { + const check = ifNode.namedChildren.find((child) => child.type === 'check_expression'); + if (check === undefined) continue; + const subject = check.namedChildren.find((child) => child.type === 'simple_identifier'); + const typeNode = check.namedChildren.find((child) => isKotlinTypeNode(child)); + if (subject === undefined || typeNode === undefined) continue; + // The first control_structure_body sibling is the then-branch; else + // branches (when present) appear as the second control_structure_body + // and are intentionally not narrowed. + const body = ifNode.namedChildren.find((child) => child.type === 'control_structure_body'); + if (body === undefined) continue; + out.push(buildNarrowedTypeBindingCapture(subject, body, typeNode)); + } + + return out; +} + +function extractWhenSubjectIdentifier(whenNode: SyntaxNode): { node: SyntaxNode } | null { + const subject = whenNode.namedChildren.find((child) => child.type === 'when_subject'); + if (subject === undefined) return null; + const ident = subject.namedChildren.find((child) => child.type === 'simple_identifier'); + return ident === undefined ? null : { node: ident }; +} + +function extractIsTestTargetType(whenEntry: SyntaxNode): SyntaxNode | null { + const condition = whenEntry.namedChildren.find((child) => child.type === 'when_condition'); + if (condition === undefined) return null; + // Exactly one when_condition child must be a positive type_test. + // Compound conditions (multiple `when_condition` siblings joined with + // commas in some grammars) or negated `!is` are not safe to narrow. + if (condition.namedChildCount !== 1) return null; + const test = condition.namedChild(0); + if (test === null || test.type !== 'type_test') return null; + // `!is` produces a different node (`negated_type_test` in some grammars, + // or an extra `!` child in others) — defend by checking text prefix. + if (test.text.trim().startsWith('!')) return null; + return test.namedChildren.find((child) => isKotlinTypeNode(child)) ?? null; +} + +function buildNarrowedTypeBindingCapture( + subject: SyntaxNode, + bodyAnchor: SyntaxNode, + typeNode: SyntaxNode, +): CaptureMatch { + return { + '@type-binding.annotation': nodeToCapture('@type-binding.annotation', bodyAnchor), + '@type-binding.name': syntheticCapture('@type-binding.name', subject, subject.text), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + typeNode, + normalizeKotlinType(typeNode.text), + ), + // Marker consumed by `kotlinBindingScopeFor` in simple-hooks.ts to + // override the scope-extractor's auto-hoist. Unbraced arm bodies + // (`is User -> obj.save()`) make the body anchor coincide with the + // Block scope's range; without this marker the binding would hoist + // to the enclosing function scope and lose its arm-local narrowing. + '@type-binding.narrowed': syntheticCapture('@type-binding.narrowed', bodyAnchor, '1'), + }; +} + function synthesizeKotlinLocalAssignmentBindings( rootNode: SyntaxNode, returnTypes: ReadonlyMap, diff --git a/gitnexus/src/core/ingestion/languages/kotlin/query.ts b/gitnexus/src/core/ingestion/languages/kotlin/query.ts index d8b0244b8..5a2968055 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/query.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/query.ts @@ -9,6 +9,19 @@ const KOTLIN_SCOPE_QUERY = ` (companion_object) @scope.class (function_declaration) @scope.function +;; Smart-cast narrowing scopes (RFC #909 Ring 3, issue #1758). +;; Each is-test arm body and each if-then body becomes its own Block +;; scope so synthesized narrowed type-bindings (see captures.ts +;; synthesizeKotlinSmartCastBindings) shadow the outer parameter +;; binding for calls inside the body — without leaking across arms. +(when_entry + (when_condition (type_test)) + (control_structure_body) @scope.block) + +(if_expression + (check_expression) + (control_structure_body) @scope.block) + ;; Declarations — types (class_declaration "interface" diff --git a/gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts index be07602eb..bbd34e160 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts @@ -12,6 +12,13 @@ export function kotlinBindingScopeFor( innermost: Scope, tree: ScopeTree, ): ScopeId | null { + // Smart-cast narrowed bindings (issue #1758) must stay at the innermost + // (Block) scope. Their anchor coincides with the Block's range for + // unbraced arm bodies (`is User -> obj.save()`), which would otherwise + // trigger scope-extractor auto-hoist into the enclosing function scope + // and erase the arm-local narrowing. + if (decl['@type-binding.narrowed'] !== undefined) return innermost.id; + if (decl['@type-binding.return'] === undefined) return null; let current: Scope | undefined = innermost; From a3e7dfa8a6c717c72a45edf526aaa0879f7f0e2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Fri, 22 May 2026 10:24:02 +0100 Subject: [PATCH 09/16] fix(lang-kotlin): interface default method dispatch via implements-split MRO (#1763) (#1779) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1763. `user.validate()` on `class User : Validator` resolved to no edge under REGISTRY_PRIMARY_KOTLIN=1 when validate() was a default method declared on the Validator interface: class User(val name: String) : Validator interface Validator { fun validate(): Boolean = true } fun run() { val user = User("alice"); user.validate() } The generic `buildMro` walks EXTENDS edges only. Kotlin classes implement interfaces via IMPLEMENTS edges (per the parsing-processor), so the implementor's MRO never picked up the interface's default methods — `findOwnedMember(User, validate)` returned undefined and no fallback walked to Validator. Fix: replace `defaultLinearize` with a Kotlin-specific MRO builder modeled after PHP's `buildPhpMro` (trait composition): 1. Run the generic `buildMro` (EXTENDS-only). 2. Collect direct IMPLEMENTS edges as class -> interface[] map. 3. For each class, walk its EXTENDS-MRO ancestors AND its own IMPLEMENTS edges to seed interface candidates, then BFS-close to pick up transitive interface inheritance (interface A : B). 4. Append the interface closure to the class's MRO (after the EXTENDS chain — Kotlin requires explicit override on conflict, so this ordering is a safe approximation for method lookup). 5. Classes with no EXTENDS but with IMPLEMENTS edges (the #1763 fixture shape) get their MRO seeded directly from their interfaces. Verification (REGISTRY_PRIMARY_KOTLIN=1): - Forced-mode: 21 -> 20 failing of 175 (1 fewer; test 2062 in `test/integration/resolvers/kotlin.test.ts` now green). - Default-mode Kotlin: 175/175 unchanged. - Full resolver suite: 2216/2216 unchanged. - Remaining 20 failures are tracked by sibling sub-issues (#1758, #1759, #1760, #1761, #1762). Does NOT add Kotlin to MIGRATED_LANGUAGES per parent #1746 flip criteria. Closes #1763. Refs #1746. Co-authored-by: Test --- .../languages/kotlin/scope-resolver.ts | 98 ++++++++++++++++++- 1 file changed, 96 insertions(+), 2 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts index 2b20808ad..5f69a2713 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts @@ -1,6 +1,10 @@ import { SupportedLanguages, type ParsedFile } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../../graph/types.js'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; +import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import { isClassLike } from '../../scope-resolution/scope/walkers.js'; import { kotlinProvider } from '../kotlin.js'; import { kotlinArityCompatibility, @@ -42,8 +46,7 @@ export const kotlinScopeResolver: ScopeResolver = { arityCompatibility: (callsite, def) => kotlinArityCompatibility(def, callsite), - buildMro: (graph, parsedFiles, nodeLookup) => - buildMro(graph, parsedFiles, nodeLookup, defaultLinearize), + buildMro: (graph, parsedFiles, nodeLookup) => buildKotlinMro(graph, parsedFiles, nodeLookup), populateOwners: (parsed: ParsedFile) => populateKotlinOwners(parsed), @@ -54,3 +57,94 @@ export const kotlinScopeResolver: ScopeResolver = { collapseMemberCallsByCallerTarget: false, hoistTypeBindingsToModule: true, }; + +/** + * Kotlin MRO builder — extends `defaultLinearize` (EXTENDS-only) with + * interface ancestors discovered via `IMPLEMENTS` edges. Interface + * default methods (`interface Validator { fun validate(): Boolean = true }`) + * are inherited by implementing classes without an explicit override; + * the generic MRO would not surface them because the implementor has + * no `EXTENDS` link to the interface (#1763). + * + * Interfaces are appended after the EXTENDS chain (Kotlin resolves + * conflicts by requiring an explicit override, so first-seen-in-MRO + * ordering is a reasonable approximation for method lookup). Transitive + * interface inheritance (`interface A : B`) is closed via BFS. + */ +function buildKotlinMro( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, +): Map { + const mro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize); + + const defIdByGraphId = new Map(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + if (!isClassLike(def.type)) continue; + const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup); + if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId); + } + } + + // Direct IMPLEMENTS targets per class-like def. + const directImpls = new Map(); + for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) { + const source = defIdByGraphId.get(rel.sourceId); + const target = defIdByGraphId.get(rel.targetId); + if (source === undefined || target === undefined) continue; + let list = directImpls.get(source); + if (list === undefined) { + list = []; + directImpls.set(source, list); + } + if (!list.includes(target)) list.push(target); + } + + // For each class, append the transitive closure of interfaces reachable + // through its own + ancestor classes' IMPLEMENTS edges. Walking + // ancestors picks up interfaces inherited via the EXTENDS chain + // (e.g. `class C : B; class B : A; interface A` — C inherits A's + // interface methods through B). + for (const [classDefId, extendsMro] of mro) { + const ancestorChain = [classDefId, ...extendsMro]; + const seeds: string[] = []; + for (const ancestorId of ancestorChain) { + for (const ifaceId of directImpls.get(ancestorId) ?? []) { + seeds.push(ifaceId); + } + } + if (seeds.length === 0) continue; + const interfaces = closeInterfaces(seeds, directImpls); + mro.set(classDefId, [...extendsMro, ...interfaces.filter((i) => !extendsMro.includes(i))]); + } + + // Classes with no EXTENDS still need an MRO entry when they implement + // interfaces (e.g. `class User : Validator` — no `mro` entry from the + // EXTENDS-only pass because no EXTENDS edges exist). + for (const [classDefId, ifaces] of directImpls) { + if (mro.has(classDefId)) continue; + mro.set(classDefId, closeInterfaces([...ifaces], directImpls)); + } + + return mro; +} + +function closeInterfaces( + seeds: readonly string[], + directImpls: ReadonlyMap, +): string[] { + const out: string[] = []; + const seen = new Set(); + const queue: string[] = [...seeds]; + while (queue.length > 0) { + const cur = queue.shift()!; + if (seen.has(cur)) continue; + seen.add(cur); + out.push(cur); + for (const next of directImpls.get(cur) ?? []) { + if (!seen.has(next)) queue.push(next); + } + } + return out; +} From 67cc4c6d94a5229eb3fd4b40c40ba88e88832bfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Fri, 22 May 2026 10:40:32 +0100 Subject: [PATCH 10/16] fix(lang-kotlin): cross-file iterable return propagation (#1759) (#1775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related bugs surfaced in REGISTRY_PRIMARY_KOTLIN=1 forced mode: 1. `import models.getRepo` silently resolved to `models/User.kt` (the first `.kt` file inside `models/` by iteration order) when no file was named after the symbol. `findKotlinFile` returned a single directory child as a fallback, so the importer's module-scope mirror only ever picked up the first arbitrary candidate — `getUser → User` landed but `getRepo → Repo` never did, and downstream `repo.save()` resolution fell through to no edge. 2. `for (x in importedCallable())` produced no for-loop type binding when the callee's return type lived in another file, because `inferKotlinIterableElementType`'s call-expression arm consulted only the local file's `returnTypes` map. Fix: - Split `findKotlinFile` into `findKotlinExactOrSuffix` (exact / suffix match only) and `findKotlinDirectoryChild` (legacy single-child fallback). Add `findKotlinPackageFiles` returning every `.kt`/`.kts` file inside a package directory. The resolver now fans out the stripped path through `findKotlinExactOrSuffix → findKotlinPackageFiles`, returning a `readonly string[]` candidate set. The finalize pass walks each candidate and picks the one whose `localDefs` actually export the imported name — exactly the multi-target contract `FinalizeHooks.resolveImportTarget` already supports. - `inferKotlinIterableElementType` for `call_expression` now falls back to the callee's identifier text when the local return-type map has no entry. `propagateImportedReturnTypes` chain-follows `loopvar → callee → ElementType` once the imported `callee → Element` mirror lands at module scope (which now works thanks to fix #1). Verification (REGISTRY_PRIMARY_KOTLIN=1): - Forced-mode: 21 -> 18 failing of 175 (3 fewer; tests 487, 1242, 1251 in test/integration/resolvers/kotlin.test.ts now green). - Default-mode Kotlin: 175/175 unchanged. - Full resolver suite: 2216/2216 unchanged (incl. `kotlin-calls` `util.OneArg.writeAudit` regression check at line 176). - Remaining 18 failures are tracked by sibling sub-issues (#1758, #1760, #1761, #1762, #1763). Does NOT add Kotlin to MIGRATED_LANGUAGES per parent #1746 flip criteria. Closes #1759. Refs #1746. Co-authored-by: Test --- .../ingestion/languages/kotlin/captures.ts | 11 +- .../languages/kotlin/import-target.ts | 114 +++++++++++++++--- 2 files changed, 108 insertions(+), 17 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index ca8d34da3..1c5f71a27 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -411,7 +411,16 @@ function inferKotlinIterableElementType( const callee = iterable.namedChildren.find((child) => child.type === 'simple_identifier'); if (callee === undefined) return null; const raw = returnTypes.get(callee.text); - return raw === undefined ? null : kotlinContainerElementType(raw, 'values'); + if (raw !== undefined) return kotlinContainerElementType(raw, 'values'); + // Cross-file fallback (#1759): the callee's return type is unknown + // locally because the function lives in another file. Emit the + // callee name itself as the binding's rawName; `propagateImported + // ReturnTypes` will chain-follow `loopvar → callee → ` + // once the imported module's `callee → ElementType` mirror lands at + // module scope. If `callee` isn't actually an imported callable + // (e.g. a local lambda or unrelated symbol), chain-follow fails + // safely and no edge is emitted. + return callee.text; } return null; diff --git a/gitnexus/src/core/ingestion/languages/kotlin/import-target.ts b/gitnexus/src/core/ingestion/languages/kotlin/import-target.ts index 63b89b113..71d6a6b53 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/import-target.ts @@ -8,7 +8,7 @@ export interface KotlinResolveContext { export function resolveKotlinImportTarget( parsedImport: ParsedImport, workspaceIndex: WorkspaceIndex, -): string | null { +): string | readonly string[] | null { const ctx = workspaceIndex as KotlinResolveContext | undefined; if ( ctx === undefined || @@ -25,22 +25,49 @@ export function resolveKotlinImportTarget( : parsedImport.targetRaw; const pathLike = target.replace(/\./g, '/'); + // Resolution tiers, most-specific first: + // 1. The full `pathLike` matches a `.kt`/`.kts` file directly + // (`import util.User` → `util/User.kt`). + // 2. Stripped (last-segment removed) `pathLike` matches a file + // directly (`import util.OneArg.writeAudit` → `util/OneArg.kt`, + // a class-or-object holding `writeAudit`). + // 3. Stripped `pathLike` matches a *package directory* — fan out to + // every `.kt`/`.kts` file inside it (`import models.getRepo` → + // `[models/User.kt, models/Repo.kt]`). The finalize pass walks + // each candidate and picks the one whose `localDefs` actually + // export the imported name (#1759). + // 4. Progressive prefix strip for deeper namespace aliases that + // don't map 1:1 to directories. + const stripped = pathLike.split('/').slice(0, -1).join('/'); return ( findKotlinFile(ctx.allFilePaths, pathLike) ?? - findKotlinFile(ctx.allFilePaths, pathLike.split('/').slice(0, -1).join('/')) ?? + findKotlinExactOrSuffix(ctx.allFilePaths, stripped) ?? + findKotlinPackageFiles(ctx.allFilePaths, stripped) ?? findByProgressivePrefixStrip(ctx.allFilePaths, pathLike) ); } function findKotlinFile(allFilePaths: ReadonlySet, pathLike: string): string | null { + return ( + findKotlinExactOrSuffix(allFilePaths, pathLike) ?? + findKotlinDirectoryChild(allFilePaths, pathLike) + ); +} + +/** Exact (`file === pathLike+ext`) or suffix (`file ends with /pathLike+ext`) + * match — does NOT fall back to picking an arbitrary file inside a + * `pathLike/` directory. Used by the stripped-path tier in + * `resolveKotlinImportTarget` so a package import like `models.getRepo` + * delegates to `findKotlinPackageFiles` (multi-file fan-out) instead of + * silently committing to the first directory child. */ +function findKotlinExactOrSuffix( + allFilePaths: ReadonlySet, + pathLike: string, +): string | null { if (pathLike === '') return null; const extensions = ['.kt', '.kts']; const suffix = `/${pathLike}`; - const dirPrefix = `${pathLike}/`; - const suffixDirPrefix = `/${dirPrefix}`; - let suffixFile: string | null = null; - let directoryChild: string | null = null; for (const raw of allFilePaths) { const file = raw.replace(/\\/g, '/'); @@ -49,18 +76,73 @@ function findKotlinFile(allFilePaths: ReadonlySet, pathLike: string): st if (file === `${pathLike}${ext}`) return raw; if (suffixFile === null && file.endsWith(`${suffix}${ext}`)) suffixFile = raw; } - if (directoryChild === null) { - const atRoot = file.startsWith(dirPrefix); - const atNested = file.includes(suffixDirPrefix); - if (atRoot || atNested) { - const idx = atRoot ? 0 : file.indexOf(suffixDirPrefix) + 1; - const after = file.slice(idx + dirPrefix.length); - if (after.length > 0 && !after.includes('/')) directoryChild = raw; - } - } } - return suffixFile ?? directoryChild; + return suffixFile; +} + +/** First directory child of `pathLike/` — preserves the legacy single- + * file fallback for cases where `pathLike` itself is an unqualified + * package reference (rare in real Kotlin code; some fixtures rely on + * it). Multi-file package fan-out goes through + * `findKotlinPackageFiles` instead. */ +function findKotlinDirectoryChild( + allFilePaths: ReadonlySet, + pathLike: string, +): string | null { + if (pathLike === '') return null; + const extensions = ['.kt', '.kts']; + const dirPrefix = `${pathLike}/`; + const suffixDirPrefix = `/${dirPrefix}`; + + for (const raw of allFilePaths) { + const file = raw.replace(/\\/g, '/'); + if (!extensions.some((ext) => file.endsWith(ext))) continue; + const atRoot = file.startsWith(dirPrefix); + const atNested = file.includes(suffixDirPrefix); + if (!atRoot && !atNested) continue; + const idx = atRoot ? 0 : file.indexOf(suffixDirPrefix) + 1; + const after = file.slice(idx + dirPrefix.length); + if (after.length > 0 && !after.includes('/')) return raw; + } + + return null; +} + +/** + * Return every `.kt`/`.kts` file inside the package directory `dirPath` + * (e.g. `models` → `['models/User.kt', 'models/Repo.kt']`). Used as a + * fallback when an import like `models.getRepo` does not resolve to a + * file named after the symbol — in Kotlin the symbol can live in any + * file inside the package directory. The finalize pass walks each + * candidate and picks the one whose `localDefs` actually export the + * imported name (#1759). + */ +function findKotlinPackageFiles( + allFilePaths: ReadonlySet, + dirPath: string, +): readonly string[] | null { + if (dirPath === '') return null; + const extensions = ['.kt', '.kts']; + const dirPrefix = `${dirPath}/`; + const suffixDirPrefix = `/${dirPrefix}`; + const out: string[] = []; + + for (const raw of allFilePaths) { + const file = raw.replace(/\\/g, '/'); + if (!extensions.some((ext) => file.endsWith(ext))) continue; + const atRoot = file.startsWith(dirPrefix); + const atNested = file.includes(suffixDirPrefix); + if (!atRoot && !atNested) continue; + const idx = atRoot ? 0 : file.indexOf(suffixDirPrefix) + 1; + const after = file.slice(idx + dirPrefix.length); + // Direct children only — `models/sub/Util.kt` is a different package + // (`models.sub`) and must not be merged with `models`. + if (after.length === 0 || after.includes('/')) continue; + out.push(raw); + } + + return out.length === 0 ? null : out; } function findByProgressivePrefixStrip( From 64efc202f68678522ae2ec3f613983d0197472fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Fri, 22 May 2026 10:40:46 +0100 Subject: [PATCH 11/16] fix(lang-kotlin): method-chain fixpoint receiver types (#1760) (#1776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1760. Multi-step intra-file chains like val user = getUser() val addr = user.address val city = addr.getCity() city.save() produced no `CALLS` edge for `city.save()` because the Kotlin extractor only inferred property types for `simple_identifier` values (`val x = y`) and call expressions with simple-identifier callees (`val x = fn()`). Navigation expressions (`val addr = user.address`) and call expressions with navigation-expression callees (`val city = addr.getCity()`) returned null, leaving `addr` and `city` unbound — the chain broke two hops before `city.save()`. Implementation: - `collectKotlinClassMembers(rootNode)` indexes per-file class fields (primary-constructor `val`/`var` params + body property declarations) and method return types. Per-file scope matches the existing extractor design. - `inferKotlinPropertyType` gains two new cases: 1. `navigation_expression` value — receiver type via `localTypes`, field type via `classMembers.fields`. 2. `call_expression` with `navigation_expression` callee — receiver type via `localTypes`, method return type via `classMembers.methods`. Both return null when any link is unknown (safe / over-conservative). Verification (REGISTRY_PRIMARY_KOTLIN=1): - Forced-mode: 21 -> 20 failing of 175 (1 fewer; test 1491 in `test/integration/resolvers/kotlin.test.ts` now green). - Default-mode Kotlin: 175/175 unchanged. - Full resolver suite: 2216/2216 unchanged. - Remaining 20 failures are tracked by sibling sub-issues (#1758, #1759, #1761, #1762, #1763). Does NOT add Kotlin to MIGRATED_LANGUAGES per parent #1746 flip criteria. Closes #1760. Refs #1746. Co-authored-by: Test --- .../ingestion/languages/kotlin/captures.ts | 141 +++++++++++++++++- 1 file changed, 135 insertions(+), 6 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index 1c5f71a27..a0f376da7 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -290,10 +290,11 @@ function synthesizeKotlinLocalAssignmentBindings( returnTypes: ReadonlyMap, ): CaptureMatch[] { const out: CaptureMatch[] = []; + const classMembers = collectKotlinClassMembers(rootNode); for (const fnNode of descendantsOfType(rootNode, 'function_declaration')) { const localTypes = new Map(); for (const prop of descendantsOfType(fnNode, 'property_declaration')) { - const inferred = inferKotlinPropertyType(prop, localTypes, returnTypes); + const inferred = inferKotlinPropertyType(prop, localTypes, returnTypes, classMembers); if (inferred === null) continue; localTypes.set(inferred.name.text, inferred.rawType); if (inferred.synthetic) { @@ -316,6 +317,77 @@ function synthesizeKotlinLocalAssignmentBindings( return out; } +interface KotlinClassMembers { + /** className → fieldName → raw type text */ + readonly fields: ReadonlyMap>; + /** className → methodName → raw return type text */ + readonly methods: ReadonlyMap>; +} + +/** + * Per-file class-member index — primary-constructor `val`/`var` params, + * body property declarations, and method return types. Used by + * `inferKotlinPropertyType` to walk single-level field and method chains + * like `val addr = user.address` and `val city = addr.getCity()` (#1760). + * + * Indexes by simple class name only. Multi-class collisions inside a + * single file will pick whichever class was visited last for that name + * — acceptable because Kotlin forbids same-name top-level classes in + * one file and per-file resolution is the design boundary here. + */ +function collectKotlinClassMembers(rootNode: SyntaxNode): KotlinClassMembers { + const fields = new Map>(); + const methods = new Map>(); + for (const cls of descendantsOfType(rootNode, 'class_declaration')) { + const className = cls.namedChildren.find((child) => child.type === 'type_identifier')?.text; + if (className === undefined) continue; + const fmap = fields.get(className) ?? new Map(); + const mmap = methods.get(className) ?? new Map(); + + const primary = cls.namedChildren.find((child) => child.type === 'primary_constructor'); + if (primary !== undefined) { + for (const param of primary.namedChildren) { + if (param.type !== 'class_parameter') continue; + // Constructor params are class fields ONLY when prefixed with + // `val`/`var` (binding_pattern_kind). Plain `fn(x: Int)`-style + // params remain locals to the constructor. + if (param.namedChildren.find((c) => c.type === 'binding_pattern_kind') === undefined) { + continue; + } + const fname = param.namedChildren.find((c) => c.type === 'simple_identifier')?.text; + const ftype = param.namedChildren.find((c) => isKotlinTypeNode(c))?.text; + if (fname !== undefined && ftype !== undefined) fmap.set(fname, ftype); + } + } + + const body = cls.namedChildren.find((child) => child.type === 'class_body'); + if (body !== undefined) { + for (const member of body.namedChildren) { + if (member.type === 'property_declaration') { + const v = member.namedChildren.find((c) => c.type === 'variable_declaration'); + const fname = v?.namedChildren.find((c) => c.type === 'simple_identifier')?.text; + const ftype = v?.namedChildren.find((c) => isKotlinTypeNode(c))?.text; + if (fname !== undefined && ftype !== undefined) fmap.set(fname, ftype); + } else if (member.type === 'function_declaration') { + const mname = member.namedChildren.find((c) => c.type === 'simple_identifier')?.text; + const paramsIdx = member.namedChildren.findIndex( + (c) => c.type === 'function_value_parameters', + ); + const rtype = + paramsIdx < 0 + ? undefined + : member.namedChildren.slice(paramsIdx + 1).find((c) => isKotlinTypeNode(c))?.text; + if (mname !== undefined && rtype !== undefined) mmap.set(mname, rtype); + } + } + } + + fields.set(className, fmap); + methods.set(className, mmap); + } + return { fields, methods }; +} + function collectKotlinLocalTypeTexts( fnNode: SyntaxNode, returnTypes: ReadonlyMap, @@ -357,6 +429,7 @@ function inferKotlinPropertyType( prop: SyntaxNode, localTypes: ReadonlyMap, returnTypes: ReadonlyMap, + classMembers?: KotlinClassMembers, ): { name: SyntaxNode; rawType: string; source: SyntaxNode; synthetic: boolean } | null { const variable = prop.namedChildren.find((child) => child.type === 'variable_declaration'); const name = variable?.namedChildren.find((child) => child.type === 'simple_identifier'); @@ -375,18 +448,74 @@ function inferKotlinPropertyType( return rawType === undefined ? null : { name, rawType, source: value, synthetic: true }; } + if (value?.type === 'navigation_expression') { + // `val addr = user.address` — receiver type → field on that class (#1760). + const chained = inferKotlinNavigationFieldType(value, localTypes, classMembers); + if (chained === null) return null; + return { name, rawType: chained, source: value, synthetic: true }; + } + if (value?.type === 'call_expression') { - const callee = value.namedChildren.find((child) => child.type === 'simple_identifier'); + const callee = value.namedChildren.find( + (child) => child.type === 'simple_identifier' || child.type === 'navigation_expression', + ); if (callee === undefined) return null; - const rawType = - returnTypes.get(callee.text) ?? (isUppercaseName(callee.text) ? callee.text : null); - if (rawType === null) return null; - return { name, rawType, source: callee, synthetic: true }; + if (callee.type === 'simple_identifier') { + const rawType = + returnTypes.get(callee.text) ?? (isUppercaseName(callee.text) ? callee.text : null); + if (rawType === null) return null; + return { name, rawType, source: callee, synthetic: true }; + } + // `val city = addr.getCity()` — receiver type → method return on that class (#1760). + const chained = inferKotlinNavigationCallReturnType(callee, localTypes, classMembers); + if (chained === null) return null; + return { name, rawType: chained, source: callee, synthetic: true }; } return null; } +/** Resolve `receiver.field` → field's declared type, where `receiver` + * is a simple identifier whose type is in `localTypes` and `field` + * is declared on that type in `classMembers.fields`. Returns null + * when any link in the chain is unknown — safe over-conservative. */ +function inferKotlinNavigationFieldType( + nav: SyntaxNode, + localTypes: ReadonlyMap, + classMembers: KotlinClassMembers | undefined, +): string | null { + if (classMembers === undefined) return null; + const receiver = nav.namedChild(0); + if (receiver === null || receiver.type !== 'simple_identifier') return null; + const member = nav.namedChildren + .find((c) => c.type === 'navigation_suffix') + ?.namedChildren.find((c) => c.type === 'simple_identifier')?.text; + if (member === undefined) return null; + const recvType = localTypes.get(receiver.text); + if (recvType === undefined) return null; + return classMembers.fields.get(normalizeKotlinType(recvType))?.get(member) ?? null; +} + +/** Resolve `receiver.method()` → method's declared return type, where + * `receiver` is a simple identifier whose type is in `localTypes` and + * `method` is declared on that type in `classMembers.methods`. */ +function inferKotlinNavigationCallReturnType( + navCallee: SyntaxNode, + localTypes: ReadonlyMap, + classMembers: KotlinClassMembers | undefined, +): string | null { + if (classMembers === undefined) return null; + const receiver = navCallee.namedChild(0); + if (receiver === null || receiver.type !== 'simple_identifier') return null; + const methodName = navCallee.namedChildren + .find((c) => c.type === 'navigation_suffix') + ?.namedChildren.find((c) => c.type === 'simple_identifier')?.text; + if (methodName === undefined) return null; + const recvType = localTypes.get(receiver.text); + if (recvType === undefined) return null; + return classMembers.methods.get(normalizeKotlinType(recvType))?.get(methodName) ?? null; +} + function inferKotlinIterableElementType( iterable: SyntaxNode, localTypes: ReadonlyMap, From f72d9a99c6250a669078bf2e9daf580be22a821d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Fri, 22 May 2026 10:40:59 +0100 Subject: [PATCH 12/16] fix(lang-kotlin): virtual dispatch via constructor type override (#1762) (#1778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1762. `val animal: Animal = Dog(); animal.speak()` resolved to `Animal.speak` (or no edge to Dog at all) under `REGISTRY_PRIMARY_KOTLIN=1` because the Kotlin scope query emits BOTH an annotation type-binding (`animal -> Animal`) and a constructor- inferred type-binding (`animal -> Dog`). The generic scope-extractor ranks annotation sources higher than constructor-inferred sources (see `typeBindingStrength` in scope-extractor.ts), so the annotation always won and `animal.speak()` dispatched against the static type. Kotlin's virtual dispatch semantics expect the dynamic type — the overriding `Dog.speak` should win when the RHS is a constructor call, because that's what runs at runtime. Fix: in `emitKotlinScopeCaptures`, suppress the `@type-binding. annotation` capture when the underlying `property_declaration` has a `call_expression` value sibling. The constructor-inferred capture remains, becomes the sole binding for the variable, and receiver-bound resolution dispatches against the constructed class (and walks its MRO). This is intentionally Kotlin-specific — flipping precedence globally would change behavior for other languages whose static-type annotations are still the right binding when present. Kotlin is the language where the constructor RHS is the dispatch target by design. Verification (REGISTRY_PRIMARY_KOTLIN=1): - Forced-mode: 21 -> 20 failing of 175 (1 fewer; test 1715 in `test/integration/resolvers/kotlin.test.ts` now green). - Default-mode Kotlin: 175/175 unchanged. - Full resolver suite: 2216/2216 unchanged. - Remaining 20 failures are tracked by sibling sub-issues (#1758, #1759, #1760, #1761, #1763). Does NOT add Kotlin to MIGRATED_LANGUAGES per parent #1746 flip criteria. Closes #1762. Refs #1746. Co-authored-by: Test --- .../ingestion/languages/kotlin/captures.ts | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index a0f376da7..4864d7d38 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -1,4 +1,4 @@ -import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import type { Capture, CaptureMatch, Range } from 'gitnexus-shared'; import { findNodeAtRange, nodeToCapture, @@ -73,6 +73,30 @@ export function emitKotlinScopeCaptures( if (navNode === null || !shouldEmitReadMember(navNode)) continue; } + // Virtual dispatch via constructor type (#1762). When a property + // declaration carries BOTH an explicit type annotation AND a + // constructor-style call value (e.g. `val animal: Animal = Dog()`), + // suppress the annotation capture so the constructor-inferred + // binding wins. This matches Kotlin's virtual dispatch semantics: + // `animal.speak()` should resolve to the overriding `Dog.speak` + // (the dynamic type), not `Animal.speak` (the static annotation). + // + // The annotation source has higher precedence than constructor- + // inferred in the generic scope-extractor (see + // `typeBindingStrength` in scope-extractor.ts), so the only way to + // make the constructor type prevail is to drop the annotation at + // emission time. + if ( + grouped['@type-binding.annotation'] !== undefined && + grouped['@type-binding.name'] !== undefined && + grouped['@type-binding.type'] !== undefined + ) { + const annotation = grouped['@type-binding.annotation']!; + if (propertyDeclHasConstructorValue(tree.rootNode, annotation.range)) { + continue; + } + } + if (grouped['@scope.function'] !== undefined) { out.push(grouped); const fnNode = findNodeAtRange( @@ -646,6 +670,21 @@ function shouldEmitReadMember(navNode: SyntaxNode): boolean { return true; } +/** True when the property_declaration anchored at `range` has a + * `call_expression` value sibling (i.e. `val x: T = Foo()`). Used to + * suppress the explicit-annotation type-binding capture so the + * constructor-inferred binding wins (#1762). */ +function propertyDeclHasConstructorValue(rootNode: SyntaxNode, range: Range): boolean { + const propNode = findNodeAtRange(rootNode, range, 'property_declaration'); + if (propNode === null) return false; + const variable = propNode.namedChildren.find((c) => c.type === 'variable_declaration'); + if (variable === undefined) return false; + const value = propNode.namedChildren.find( + (c) => c.id !== variable.id && c.type !== 'binding_pattern_kind', + ); + return value?.type === 'call_expression'; +} + function callArguments(callNode: SyntaxNode): SyntaxNode[] { const suffix = callNode.namedChildren.find((child) => child.type === 'call_suffix'); if (suffix === undefined) return []; From d15f8bef54017b624b947ffd3f1ac1a0999db1c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Fri, 22 May 2026 12:37:30 +0100 Subject: [PATCH 13/16] feat(ingestion): log deferred resolution progress when verbose (#1741) (#1773) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ingestion): log deferred resolution progress when verbose Add [deferred-profile] timing logs for post-chunk import, heritage, heritage-map, and legacy call resolution. Enabled on GITNEXUS_VERBOSE / analyze -v (and optionally GITNEXUS_PROFILE_DEFERRED) to diagnose analyze stalls on large repos (issue #1741). Co-authored-by: Cursor * chore(autofix): apply prettier + eslint fixes via /autofix command * fix(ingestion): address PR #1773 production-readiness review Move deferred call progress logs after the registry-primary skip so sites= counts match files actually resolved. Only time buildHeritageMap when heritage records exist; otherwise log an explicit skip. Add wiring tests that assert [deferred-profile] emission from buildHeritageMap and processCallsFromExtracted. Snapshot GITNEXUS_PROFILE_DEFERRED env vars in analyze CLI isolation. Co-authored-by: Cursor * fix(ingestion): address PR #1773 code-review findings P0 - Replace forbidden toBeGreaterThanOrEqual/toBeLessThan in profileElapsedMs test with exact-arithmetic vi.spyOn(hrtime.bigint) asserting .toBe(2.5) and .toBe(0). DoD §2.7 compliance. P2 - Use Number() (not parseInt) when parsing GITNEXUS_PROFILE_DEFERRED_SLOW_MS so scientific notation like '1e9' doesn't silently parse to 1 and turn the slow-file log into a per-file log storm. - Introduce startTimer(enabled): bigint | null and endTimer(start, format) helpers in deferred-resolution-profile.ts; refactor 6+ timing blocks in parse-impl.ts and call-processor.ts to use them. Removes the 0n sentinel that conflated 'disabled' with 'zero elapsed time' and let TS narrow correctly. - Split the call-processor file counter: filesProcessed (all iterated) vs resolvedFiles (post registry-primary skip). Key the every-N progress log and the start-of-phase log on resolvedFiles so mixed Python+JVM repos where the skipped language sorts first still emit 'calls 1/1 file=...' on the first non-skipped file. Adds a wiring test for the mixed-language ordering case. P3 - Restore the original isDev '🔗 E1: Seeded ...' logger.info line so log scrapers keyed on the emoji marker still match; emit the [deferred-profile] variant only when deferredProfile && !isDev. - Move tFile = startTimer(profileCalls) below the registry-primary skip so skipped files don't trigger an hrtime.bigint() call. - Document GITNEXUS_PROFILE_DEFERRED and GITNEXUS_PROFILE_DEFERRED_SLOW_MS in the README env-var table. * refactor(ingestion): extract parseTruthyEnv to shared utils (U5) Three narrow-form env-var truthy checkers (verbose.ts, registry-primary-flag.ts, deferred-resolution-profile.ts) each had their own `'1' | 'true' | 'yes'` parser with subtle divergences (trim or no trim, set vs disjunction). Consolidate on a single `parseTruthyEnv(raw)` helper in utils/env.ts — the module already serves as the centralization point for shared ingestion env constants. logger.ts's broader `isTruthyEnv` (negative-list, pino-debug convention) stays untouched — different intent, different semantics. New table-driven test at test/unit/env.test.ts covers case variants, whitespace, and rejection of falsy / unknown tokens. * refactor(ingestion): named constants for deferred-profile log gates (U6) Replace magic literals 10 / 100 / 3_000 / 5_000 in deferred-resolution-profile.ts with module-private named constants LOG_EVERY_N_VERBOSE, LOG_EVERY_N_PROFILE, DEFAULT_SLOW_MS_VERBOSE, DEFAULT_SLOW_MS. Not exported — internal tuning knobs. Pure refactor; existing tests assert the exact values and still pass unchanged. * fix(ingestion): pre-pass denominator for deferred call progress (U1, A1) The live per-file denominator in processCallsFromExtracted previously read `totalFiles - skippedRegistryPrimaryFiles` at log time. On mixed Python+JVM repos where the skipped language interleaves with the resolved one, the denominator drifts upward as the loop iterates — files iterated before later skips have been seen carry an inflated denominator. The live ratio only self-corrects after the final file has been classified. Fix: one-pass pre-count over byFile.keys() before the work loop computes resolvedTotal once. The denominator is then stable from the first emission onward. The pre-pass runs only on the enabled path (profileCalls=true) so the disabled path keeps zero extra work. Adds a wiring test exercising the alternating [ts, py, ts, py, ...] order that triggered the drift, asserting every emitted line uses `/4` and no other denominator slips through. * fix(ingestion): E1 enrichment log emits on both dev and profile flags (U2, A2) The post-chunk E1 enrichment log used `if (isDev) {...} else if (deferredProfile) {...}` which is mutually exclusive. On combined runs (NODE_ENV=development + GITNEXUS_PROFILE_DEFERRED=1) the [deferred- profile] line was silently swallowed — operators grepping that prefix saw a gap between wildcard-synth and heritage timings, while the inline comment promised dual emission. Fix: two independent `if` statements so both branches fire when both flags are set. The original emoji-prefixed `🔗 E1: Seeded` line keeps its phrasing for any dev-mode log scrapers that depend on the marker. Pinning test (parse-impl-e1-emission-shape.test.ts) reads the source and asserts (a) both branches exist as standalone `if` statements and (b) the closing `}` of the isDev branch is followed by `if`, not `else if`. Source-shape pins are the right test scope for a purely structural change — the regression we are guarding against is exactly how a future reader greps for it. * feat(ingestion): unresolved-side counters in heritage-map profile (U7) The existing maxNameCartesian / ambiguousHeritageRecords counters in buildHeritageMap only observed records where BOTH the child and parent name lookups resolved. On JVM monorepos the actual pathological case is one side empty (typically an unresolved external supertype with many same-named children, or vice versa) — those records were silently dropped from the metric. Add `unresolvedChildLookups` and `unresolvedParentLookups` in a separate `if (profileHeritage)` block placed immediately after the two `lookupClassByName` calls (so it observes the unresolved cases the length-guarded ambiguity block below cannot see). Both counters reuse the existing childDefs / parentDefs values — no additional lookups. Done-summary log extended to include the two new counters. Wiring test covers both directions (unresolved parent, unresolved child) plus the existing "both resolved" baseline now asserts the new counters report zero for that case. * fix(ingestion): endTimer formatter exception safety (U3) Wrap the format callback in endTimer in a try/catch so a throwing formatter (custom toString, JSON.stringify on a circular object, future heavier serializers) cannot abort the deferred resolution band. Observability code must never escalate to a load-bearing failure mode. On catch we emit a single `[deferred-profile] formatter error: …` line via logDeferredProfile and return; the caller's stage continues as if profiling had no-op'd for this timer. DoD §2.8 is satisfied — the failure is surfaced, not silently swallowed. Tests cover the four cases: happy path emits the formatted line, null start no-ops without invoking the formatter, throwing formatter is caught and surfaces one error line, non-Error throws are coerced via String() in the message. * fix(ingestion): defensive wrap + dropped-line counter for logDeferredProfile (U4) Wrap logger.info inside logDeferredProfile in a try/catch so a throwing underlying logger cannot abort the deferred resolution band. Pino with sync:false (the current SonicBoom destination) does not throw synchronously for `info(string)` calls, but first-use construction paths (pino-pretty resolve, level validation) and any future transport reconfiguration could. The wrap is belt-and-suspenders coverage; the counter makes silent failures visible. A module-private droppedLogLines counter accumulates dropped lines. Two helpers — getDeferredProfileDroppedCount() and resetDeferredProfileDroppedCount() — expose the counter. The handler deliberately does NOT call the failing logger; that would risk an infinite loop if the failure is steady-state. processCallsFromExtracted resets the counter at entry (so each analyze run gets a fresh count rather than accumulating across the process lifetime — relevant for the MCP server, eval harness, integration tests), and surfaces the count in the done-summary as `note: N profile log lines dropped (logger errors)` when greater than zero. DoD §2.8 (no silent diagnostic catches) is satisfied. Tests cover the helper API (zero at entry, idempotent reset) and the happy path; the catch arm is pinned via source-shape assertion since the logger Proxy can't be vi.spyOn'd directly (lazy `get` trap, no own-property to wrap). * docs(readme): clarify GITNEXUS_PROFILE_DEFERRED_SLOW_MS coercion (U8) The env-var row mentioned integer / scientific notation only, but the underlying parser (`Number(raw)` since the U2 fix in PR #1773) also accepts decimals like `.5` and hex like `0x10`. Document the actual acceptance set plus the non-finite / non-positive fallback so operators setting unusual values know what to expect. --------- Co-authored-by: Test Co-authored-by: Cursor Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- README.md | 2 + gitnexus/src/cli/analyze.ts | 2 + gitnexus/src/core/ingestion/call-processor.ts | 76 +++++- .../src/core/ingestion/model/heritage-map.ts | 39 +++ .../ingestion/pipeline-phases/parse-impl.ts | 70 +++++- .../core/ingestion/registry-primary-flag.ts | 7 +- .../utils/deferred-resolution-profile.ts | 120 +++++++++ gitnexus/src/core/ingestion/utils/env.ts | 18 ++ gitnexus/src/core/ingestion/utils/verbose.ts | 10 +- ...deferred-resolution-profile-wiring.test.ts | 230 ++++++++++++++++++ .../unit/deferred-resolution-profile.test.ts | 193 +++++++++++++++ gitnexus/test/unit/env.test.ts | 38 +++ .../unit/parse-impl-e1-emission-shape.test.ts | 63 +++++ 13 files changed, 850 insertions(+), 18 deletions(-) create mode 100644 gitnexus/src/core/ingestion/utils/deferred-resolution-profile.ts create mode 100644 gitnexus/test/unit/deferred-resolution-profile-wiring.test.ts create mode 100644 gitnexus/test/unit/deferred-resolution-profile.test.ts create mode 100644 gitnexus/test/unit/env.test.ts create mode 100644 gitnexus/test/unit/parse-impl-e1-emission-shape.test.ts diff --git a/README.md b/README.md index 9b14396a8..d55e8ef94 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,8 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max | `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size. `0` disables the pool (sequential fallback). Equivalent to `--workers `. | Constrained containers (cgroup CPU limits), CI runners with explicit quotas, or debugging a worker-only crash via `0`. | | `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. | | `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. | +| `GITNEXUS_PROFILE_DEFERRED` | unset | When `1`, emits `[deferred-profile]` timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by `GITNEXUS_VERBOSE`. | Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise. | +| `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. | | `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size `. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. | | `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout ` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. | | `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. | diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 29253f969..55995b76b 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -473,6 +473,8 @@ async function ensureHeap(): Promise { */ const ANALYZE_CLI_ENV_KEYS = [ 'GITNEXUS_VERBOSE', + 'GITNEXUS_PROFILE_DEFERRED', + 'GITNEXUS_PROFILE_DEFERRED_SLOW_MS', 'GITNEXUS_MAX_FILE_SIZE', 'GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS', 'GITNEXUS_EMBEDDING_THREADS', diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 1b5a234b4..0ab961a7a 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -39,6 +39,16 @@ import { generateId } from '../../lib/utils.js'; import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared'; import { isRegistryPrimary } from './registry-primary-flag.js'; import { isVerboseIngestionEnabled } from './utils/verbose.js'; +import { + deferredCallFileSlowMs, + deferredCallLogEveryN, + getDeferredProfileDroppedCount, + isDeferredResolutionProfileEnabled, + logDeferredProfile, + profileElapsedMs, + resetDeferredProfileDroppedCount, + startTimer, +} from './utils/deferred-resolution-profile.js'; import { yieldToEventLoop } from './utils/event-loop.js'; import { parseSourceSafe } from '../tree-sitter/safe-parse.js'; import { @@ -2909,6 +2919,39 @@ export const processCallsFromExtracted = async ( } const totalFiles = byFile.size; let filesProcessed = 0; + // Counts only files that survived the registry-primary skip — what the user + // is actually waiting on. Keyed by this counter, the first per-file progress + // log fires on the first *resolved* file rather than file #1 of byFile, + // which would silently land inside the skip block on mixed Python+JVM repos + // where the skipped language sorts first. + let resolvedFiles = 0; + const profileCalls = isDeferredResolutionProfileEnabled(); + const slowFileMs = profileCalls ? deferredCallFileSlowMs() : 0; + const logEveryN = profileCalls ? deferredCallLogEveryN() : 0; + let skippedRegistryPrimaryFiles = 0; + + // Fresh dropped-log counter per analyze run — the module-private counter + // in deferred-resolution-profile.ts is process-lived, so without a reset + // here it would accumulate across consecutive analyze invocations in the + // same Node process (e.g., the MCP server, eval harness, integration + // tests). + if (profileCalls) resetDeferredProfileDroppedCount(); + + // One-pass pre-count of the eventual non-skipped total so the live progress + // denominator stays stable as the loop iterates. Otherwise `${totalFiles - + // skippedRegistryPrimaryFiles}` drifts upward — files iterated before later + // registry-primary skips have been seen carry an inflated denominator, and + // the ratio only self-corrects after every file has been classified. Pre- + // count runs only on the enabled path so the disabled path stays free of + // the extra Map iteration. Defaults to 0 on the disabled path; the live log + // gate is also disabled there, so the value is never read. + let resolvedTotal = 0; + if (profileCalls) { + for (const filePath of byFile.keys()) { + const lang = getLanguageFromFilename(filePath); + if (!lang || !isRegistryPrimary(lang)) resolvedTotal++; + } + } for (const [filePath, calls] of byFile) { filesProcessed++; @@ -2920,7 +2963,19 @@ export const processCallsFromExtracted = async ( // Registry-primary gate: skip Python (etc.) entirely when the // scope-based phase owns CALLS for this language. const fileLanguage = getLanguageFromFilename(filePath); - if (fileLanguage && isRegistryPrimary(fileLanguage)) continue; + if (fileLanguage && isRegistryPrimary(fileLanguage)) { + skippedRegistryPrimaryFiles++; + continue; + } + + resolvedFiles++; + const tFile = startTimer(profileCalls); + + if (profileCalls && (resolvedFiles === 1 || resolvedFiles % logEveryN === 0)) { + logDeferredProfile( + `calls ${resolvedFiles}/${resolvedTotal} file=${filePath} sites=${calls.length}`, + ); + } ctx.enableCache(filePath); const widenCache: WidenCache = new Map(); @@ -3079,6 +3134,25 @@ export const processCallsFromExtracted = async ( } ctx.clearCache(); + + if (tFile !== null) { + const elapsed = profileElapsedMs(tFile); + if (elapsed >= slowFileMs) { + logDeferredProfile( + `slow file ${elapsed.toFixed(0)}ms path=${filePath} calls=${calls.length} lang=${fileLanguage ?? 'unknown'}`, + ); + } + } + } + + if (profileCalls) { + logDeferredProfile( + `processCallsFromExtracted done: ${totalFiles} files, ${extractedCalls.length} call sites, skipped registry-primary files=${skippedRegistryPrimaryFiles}`, + ); + const droppedCount = getDeferredProfileDroppedCount(); + if (droppedCount > 0) { + logDeferredProfile(`note: ${droppedCount} profile log lines dropped (logger errors)`); + } } onProgress?.(totalFiles, totalFiles); diff --git a/gitnexus/src/core/ingestion/model/heritage-map.ts b/gitnexus/src/core/ingestion/model/heritage-map.ts index ae89022b4..4cee244e3 100644 --- a/gitnexus/src/core/ingestion/model/heritage-map.ts +++ b/gitnexus/src/core/ingestion/model/heritage-map.ts @@ -15,6 +15,10 @@ import type { ResolutionContext } from './resolution-context.js'; import { getLanguageFromFilename, type SupportedLanguages } from 'gitnexus-shared'; +import { + isDeferredResolutionProfileEnabled, + logDeferredProfile, +} from '../utils/deferred-resolution-profile.js'; // --------------------------------------------------------------------------- // ExtractedHeritage — the shape produced by the parse worker / heritage @@ -176,11 +180,35 @@ export const buildHeritageMap = ( // interfaceName → Set (implementor lookup for interface dispatch) const implementorFiles = new Map>(); + const profileHeritage = isDeferredResolutionProfileEnabled(); + let maxNameCartesian = 0; + let ambiguousHeritageRecords = 0; + let unresolvedChildLookups = 0; + let unresolvedParentLookups = 0; + for (const h of heritage) { // ── Parent lookup (nodeId-based) ──────────────────────────────── const childDefs = ctx.model.types.lookupClassByName(h.className); const parentDefs = ctx.model.types.lookupClassByName(h.parentName); + // Unresolved-side counters live in a separate guard so they observe + // records the ambiguity block below skips. On JVM monorepos the + // pathological fan-out case is precisely "many same-named children + // with an unresolved external supertype" (or the inverse) — both + // sides non-empty is the case `ambiguousHeritageRecords` already + // covers; the unresolved cases were silently dropped from the + // metric before this counter. + if (profileHeritage) { + if (childDefs.length === 0) unresolvedChildLookups++; + if (parentDefs.length === 0) unresolvedParentLookups++; + } + + if (profileHeritage && childDefs.length > 0 && parentDefs.length > 0) { + const product = childDefs.length * parentDefs.length; + if (product > 1) ambiguousHeritageRecords++; + if (product > maxNameCartesian) maxNameCartesian = product; + } + if (childDefs.length > 0 && parentDefs.length > 0) { for (const child of childDefs) { for (const parent of parentDefs) { @@ -368,6 +396,17 @@ export const buildHeritageMap = ( return implementorFiles.get(interfaceName) ?? EMPTY_SET; }; + if (profileHeritage) { + logDeferredProfile( + `buildHeritageMap: ${heritage.length} heritage records, ` + + `${ambiguousHeritageRecords} with child×parent lookup product >1, ` + + `max product ${maxNameCartesian}, ` + + `${unresolvedChildLookups} unresolved child lookups, ` + + `${unresolvedParentLookups} unresolved parent lookups, ` + + `${implementorFiles.size} interface implementor keys`, + ); + } + return { getParents, getAncestors, diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index 10e4557d2..bf954b5cf 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -71,6 +71,12 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { isDev } from '../utils/env.js'; import { isVerboseIngestionEnabled } from '../utils/verbose.js'; +import { + endTimer, + isDeferredResolutionProfileEnabled, + logDeferredProfile, + startTimer, +} from '../utils/deferred-resolution-profile.js'; import { synthesizeWildcardImportBindings, needsSynthesis } from './wildcard-synthesis.js'; import { extractORMQueriesInline } from './orm-extraction.js'; @@ -698,7 +704,15 @@ export async function runChunkedParseAndResolve( // heritage: 75 -> 80 (5) // routes: 80 -> 85 (5) // calls: 85 -> 95 (10) + const deferredProfile = isDeferredResolutionProfileEnabled(); + if (deferredProfile) { + logDeferredProfile( + `deferred band start: imports=${deferredWorkerImports.length} heritage=${deferredWorkerHeritage.length} ` + + `calls=${deferredWorkerCalls.length} routes=${allExtractedRoutes.length}`, + ); + } if (deferredWorkerImports.length > 0) { + const tImports = startTimer(deferredProfile); await processImportsFromExtracted( graph, allPathObjects, @@ -721,6 +735,11 @@ export async function runChunkedParseAndResolve( repoPath, importCtx, ); + endTimer( + tImports, + (ms) => + `processImportsFromExtracted: ${ms.toFixed(0)}ms (${deferredWorkerImports.length} import batches before drain)`, + ); // U15 (lightweight M1): processImportsFromExtracted is the sole // consumer of `deferredWorkerImports`. Free the array now so the // GC can reclaim the per-file ExtractedImport records before the @@ -732,8 +751,10 @@ export async function runChunkedParseAndResolve( deferredWorkerImports.length = 0; } if (anyChunkNeedsWildcardSynth) { + const tWildcard = startTimer(deferredProfile); synthesizeWildcardImportBindings(graph, ctx); hasSynthesized = true; + endTimer(tWildcard, (ms) => `synthesizeWildcardImportBindings: ${ms.toFixed(0)}ms`); } // L5 from PR #1693 review: populate `exportedTypeMap` from the in-progress // graph BEFORE `seedCrossFileReceiverTypes` runs. Previously the seeding @@ -754,11 +775,22 @@ export async function runChunkedParseAndResolve( ctx.namedImportMap, exportedTypeMap, ); - if (isDev && enrichedCount > 0) { - logger.info(`🔗 E1: Seeded ${enrichedCount} cross-file receiver types (all chunks)`); + if (enrichedCount > 0) { + // Two independent gates, not else-if: when both isDev AND + // deferredProfile are active, BOTH lines fire — log scrapers keyed + // on the original "🔗 E1" emoji marker keep matching, AND operators + // grepping the [deferred-profile] prefix see no gap between the + // wildcard-synth and heritage timings. + if (isDev) { + logger.info(`🔗 E1: Seeded ${enrichedCount} cross-file receiver types (all chunks)`); + } + if (deferredProfile) { + logDeferredProfile(`E1: seeded ${enrichedCount} cross-file receiver types (all chunks)`); + } } } if (deferredWorkerHeritage.length > 0) { + const tHeritage = startTimer(deferredProfile); await processHeritageFromExtracted(graph, deferredWorkerHeritage, ctx, (current, total) => { const ratio = total > 0 ? current / total : 1; onProgress({ @@ -773,8 +805,14 @@ export async function runChunkedParseAndResolve( }, }); }); + endTimer( + tHeritage, + (ms) => + `processHeritageFromExtracted: ${ms.toFixed(0)}ms (${deferredWorkerHeritage.length} records)`, + ); } if (allExtractedRoutes.length > 0) { + const tRoutes = startTimer(deferredProfile); await processRoutesFromExtracted(graph, allExtractedRoutes, ctx, (current, total) => { const ratio = total > 0 ? current / total : 1; onProgress({ @@ -789,12 +827,25 @@ export async function runChunkedParseAndResolve( }, }); }); + endTimer( + tRoutes, + (ms) => + `processRoutesFromExtracted: ${ms.toFixed(0)}ms (${allExtractedRoutes.length} routes)`, + ); } - const fullWorkerHeritageMap = - deferredWorkerHeritage.length > 0 - ? buildHeritageMap(deferredWorkerHeritage, ctx, getHeritageStrategyForLanguage) - : undefined; + let fullWorkerHeritageMap: ReturnType | undefined; + if (deferredWorkerHeritage.length > 0) { + const tBuildHeritage = startTimer(deferredProfile); + fullWorkerHeritageMap = buildHeritageMap( + deferredWorkerHeritage, + ctx, + getHeritageStrategyForLanguage, + ); + endTimer(tBuildHeritage, (ms) => `buildHeritageMap wall: ${ms.toFixed(0)}ms`); + } else if (deferredProfile) { + logDeferredProfile('buildHeritageMap: skipped (no heritage records)'); + } // U15 (lightweight M1): buildHeritageMap is the LAST consumer of the // raw `deferredWorkerHeritage` records — processCallsFromExtracted // below reads from the derived `fullWorkerHeritageMap` instead. Free @@ -804,6 +855,12 @@ export async function runChunkedParseAndResolve( deferredWorkerHeritage.length = 0; if (deferredWorkerCalls.length > 0) { + if (deferredProfile) { + logDeferredProfile( + `processCallsFromExtracted: starting (${deferredWorkerCalls.length} call sites, heritageMap=${fullWorkerHeritageMap !== undefined})`, + ); + } + const tCalls = startTimer(deferredProfile); await processCallsFromExtracted( graph, deferredWorkerCalls, @@ -829,6 +886,7 @@ export async function runChunkedParseAndResolve( fullWorkerHeritageMap, bindingAccumulator, ); + endTimer(tCalls, (ms) => `processCallsFromExtracted: ${ms.toFixed(0)}ms total`); } if (deferredAssignments.length > 0) { diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index e050c6553..94fc172dc 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -37,6 +37,7 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; +import { parseTruthyEnv } from './utils/env.js'; /** * Languages whose RFC #909 Ring 3 scope-resolution migration is complete. @@ -115,10 +116,6 @@ export function primaryLanguages(): ReadonlySet { // ─── Internal ─────────────────────────────────────────────────────────────── -/** Accepted truthy strings (case-insensitive, trimmed). */ -const TRUTHY_VALUES: ReadonlySet = new Set(['true', '1', 'yes']); - function parseFlag(raw: string | undefined): boolean { - if (raw === undefined) return false; - return TRUTHY_VALUES.has(raw.trim().toLowerCase()); + return parseTruthyEnv(raw); } diff --git a/gitnexus/src/core/ingestion/utils/deferred-resolution-profile.ts b/gitnexus/src/core/ingestion/utils/deferred-resolution-profile.ts new file mode 100644 index 000000000..697ca3bb6 --- /dev/null +++ b/gitnexus/src/core/ingestion/utils/deferred-resolution-profile.ts @@ -0,0 +1,120 @@ +/** + * Wall-clock logging for the post-chunk deferred resolution band + * (imports → heritage → heritage map → legacy call resolution). + * + * Enabled when either: + * - `GITNEXUS_VERBOSE=1` / `gitnexus analyze -v` (primary path for #1741), or + * - `GITNEXUS_PROFILE_DEFERRED=1` (force on without full verbose ingestion noise) + * + * Issue #1741: large Java/Kotlin repos appear stuck at "Resolving calls" + * because the UI progress bar updates every 100 files and intermediate + * stages emit little to the log. + */ + +import { logger } from '../../logger.js'; +import { parseTruthyEnv } from './env.js'; +import { isVerboseIngestionEnabled } from './verbose.js'; + +// Module-private tuning constants for the gates below. Not exported — these +// are internal knobs, not part of the module's API surface. +const LOG_EVERY_N_VERBOSE = 10; +const LOG_EVERY_N_PROFILE = 100; +const DEFAULT_SLOW_MS_VERBOSE = 3_000; +const DEFAULT_SLOW_MS = 5_000; + +/** True when deferred-stage timing / progress logs should emit. */ +export const isDeferredResolutionProfileEnabled = (): boolean => + isVerboseIngestionEnabled() || parseTruthyEnv(process.env.GITNEXUS_PROFILE_DEFERRED); + +/** Log a call-resolution progress line every N files (finer when verbose). */ +export const deferredCallLogEveryN = (): number => + isVerboseIngestionEnabled() ? LOG_EVERY_N_VERBOSE : LOG_EVERY_N_PROFILE; + +/** Per-file call-resolution log threshold (ms). Lower default when verbose. */ +export const deferredCallFileSlowMs = (): number => { + const raw = process.env.GITNEXUS_PROFILE_DEFERRED_SLOW_MS; + if (raw) { + // Use Number() not parseInt: parseInt('1e9', 10) === 1 (prefix-parses, drops the exponent), + // which would turn a user-intended "effectively disabled" threshold into a 1 ms log storm. + const n = Number(raw); + if (Number.isFinite(n) && n > 0) return n; + } + return isVerboseIngestionEnabled() ? DEFAULT_SLOW_MS_VERBOSE : DEFAULT_SLOW_MS; +}; + +export const profileNow = (): bigint => process.hrtime.bigint(); + +export const profileElapsedMs = (start: bigint): number => + Number(process.hrtime.bigint() - start) / 1e6; + +// Module-private counter for `[deferred-profile]` log lines the underlying +// logger refused to accept. Pino's SonicBoom transport is sync:false today, +// so steady-state `logger.info(string)` calls don't throw — but first-use +// construction paths (pino-pretty resolve, level validation) and any future +// transport reconfiguration could. The wrap below catches and counts so a +// failing logger cannot abort the deferred band, and the count surfaces in +// the deferred-band done-summary (see processCallsFromExtracted) so the +// failure is visible rather than silently swallowed (DoD §2.8). +let droppedLogLines = 0; + +/** + * Number of `logDeferredProfile` calls whose underlying `logger.info` threw. + * Surfaced in the deferred-band done-summary when greater than zero. + */ +export const getDeferredProfileDroppedCount = (): number => droppedLogLines; + +/** + * Reset the dropped-line counter. Call from test `afterEach` to keep the + * module-private state from leaking across tests. Also used inside + * `processCallsFromExtracted` at function entry so each analyze run gets + * a fresh count rather than accumulating across the process lifetime. + */ +export const resetDeferredProfileDroppedCount = (): void => { + droppedLogLines = 0; +}; + +export const logDeferredProfile = (message: string): void => { + try { + logger.info(`[deferred-profile] ${message}`); + } catch { + // Do not call the failing logger from the handler — that would risk + // an infinite loop if the failure mode is steady-state. Just count. + droppedLogLines++; + } +}; + +/** + * Capture a monotonic timestamp when profiling is enabled; otherwise return null. + * Pair with `endTimer` so the type system narrows correctly — using `null` instead + * of a `0n` sentinel makes "profiling disabled" structurally distinct from + * "zero elapsed time" and lets TypeScript catch missing guards. + */ +export const startTimer = (enabled: boolean): bigint | null => + enabled ? process.hrtime.bigint() : null; + +/** + * Emit a `[deferred-profile]` log line for a captured timer. No-op when the + * timer is `null` (profiling was disabled at capture time). The formatter + * receives elapsed ms so the call sites stay readable. + * + * The format callback runs inside a try/catch so a throwing formatter + * (custom toString, JSON.stringify on a circular object) cannot abort the + * deferred resolution band — observability code must never escalate to a + * load-bearing failure. On catch we emit a single `formatter error: …` + * line via logDeferredProfile and return; the caller's stage continues + * as if profiling had no-op'd for this timer. DoD §2.8 ("no silent + * catches that swallow diagnostics") is satisfied by surfacing the + * failure message rather than dropping it. + */ +export const endTimer = (start: bigint | null, format: (elapsedMs: number) => string): void => { + if (start === null) return; + const elapsedMs = profileElapsedMs(start); + let message: string; + try { + message = format(elapsedMs); + } catch (err) { + logDeferredProfile(`formatter error: ${err instanceof Error ? err.message : String(err)}`); + return; + } + logDeferredProfile(message); +}; diff --git a/gitnexus/src/core/ingestion/utils/env.ts b/gitnexus/src/core/ingestion/utils/env.ts index 6cb9d549f..5beeb818f 100644 --- a/gitnexus/src/core/ingestion/utils/env.ts +++ b/gitnexus/src/core/ingestion/utils/env.ts @@ -10,6 +10,24 @@ /** Whether we're running in development mode (enables verbose console logging). */ export const isDev = process.env.NODE_ENV === 'development'; +/** + * Parse a narrow-form truthy env-var value. Accepts `'1'`, `'true'`, `'yes'` + * (case-insensitive, whitespace-trimmed). Anything else — including + * `undefined`, empty string, `'0'`, `'false'`, `'no'`, or unknown tokens — + * returns `false`. + * + * This is the shared helper for narrow-form truthy parsing across the + * ingestion module. `logger.ts` uses a broader negative-list form + * (`isTruthyEnv`) that intentionally accepts anything except a small set of + * falsy tokens — that lives separately because it follows pino-debug + * conventions and serves a different purpose. + */ +export const parseTruthyEnv = (raw: string | undefined): boolean => { + if (raw === undefined) return false; + const value = raw.trim().toLowerCase(); + return value === '1' || value === 'true' || value === 'yes'; +}; + /** * Whether scope-resolution dev validators (e.g. `validateBindingsImmutability`) * should run AND emit warnings. Off by default in CLI runs to avoid silent diff --git a/gitnexus/src/core/ingestion/utils/verbose.ts b/gitnexus/src/core/ingestion/utils/verbose.ts index 0e616dca9..c697a5aaa 100644 --- a/gitnexus/src/core/ingestion/utils/verbose.ts +++ b/gitnexus/src/core/ingestion/utils/verbose.ts @@ -1,6 +1,4 @@ -export const isVerboseIngestionEnabled = (): boolean => { - const raw = process.env.GITNEXUS_VERBOSE; - if (!raw) return false; - const value = raw.toLowerCase(); - return value === '1' || value === 'true' || value === 'yes'; -}; +import { parseTruthyEnv } from './env.js'; + +export const isVerboseIngestionEnabled = (): boolean => + parseTruthyEnv(process.env.GITNEXUS_VERBOSE); diff --git a/gitnexus/test/unit/deferred-resolution-profile-wiring.test.ts b/gitnexus/test/unit/deferred-resolution-profile-wiring.test.ts new file mode 100644 index 000000000..0fe4ead86 --- /dev/null +++ b/gitnexus/test/unit/deferred-resolution-profile-wiring.test.ts @@ -0,0 +1,230 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { _captureLogger } from '../../src/core/logger.js'; +import { processCallsFromExtracted } from '../../src/core/ingestion/call-processor.js'; +import { buildHeritageMap } from '../../src/core/ingestion/model/heritage-map.js'; +import { createResolutionContext } from '../../src/core/ingestion/model/resolution-context.js'; +import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; +import { + getDeferredProfileDroppedCount, + resetDeferredProfileDroppedCount, +} from '../../src/core/ingestion/utils/deferred-resolution-profile.js'; +import type { ExtractedHeritage } from '../../src/core/ingestion/model/heritage-map.js'; +import type { ExtractedCall } from '../../src/core/ingestion/workers/parse-worker.js'; + +describe('deferred-resolution-profile wiring', () => { + let cap: ReturnType; + let prevProfileDeferred: string | undefined; + let prevVerbose: string | undefined; + let prevRegistryTypeScript: string | undefined; + + beforeEach(() => { + cap = _captureLogger(); + prevProfileDeferred = process.env.GITNEXUS_PROFILE_DEFERRED; + prevVerbose = process.env.GITNEXUS_VERBOSE; + prevRegistryTypeScript = process.env.REGISTRY_PRIMARY_TYPESCRIPT; + process.env.GITNEXUS_PROFILE_DEFERRED = '1'; + delete process.env.GITNEXUS_VERBOSE; + process.env.REGISTRY_PRIMARY_TYPESCRIPT = 'false'; + }); + + afterEach(() => { + cap.restore(); + if (prevProfileDeferred === undefined) delete process.env.GITNEXUS_PROFILE_DEFERRED; + else process.env.GITNEXUS_PROFILE_DEFERRED = prevProfileDeferred; + if (prevVerbose === undefined) delete process.env.GITNEXUS_VERBOSE; + else process.env.GITNEXUS_VERBOSE = prevVerbose; + if (prevRegistryTypeScript === undefined) delete process.env.REGISTRY_PRIMARY_TYPESCRIPT; + else process.env.REGISTRY_PRIMARY_TYPESCRIPT = prevRegistryTypeScript; + resetDeferredProfileDroppedCount(); + vi.restoreAllMocks(); + }); + + const deferredMsgs = (): string[] => + cap + .records() + .map((r) => String(r.msg ?? '')) + .filter((m) => m.includes('[deferred-profile]')); + + it('buildHeritageMap emits profile stats when GITNEXUS_PROFILE_DEFERRED=1', () => { + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/a.java', 'Foo', 'class:a:Foo', 'Class'); + ctx.model.symbols.add('src/b.java', 'Foo', 'class:b:Foo', 'Class'); + ctx.model.symbols.add('src/c.java', 'Bar', 'class:c:Bar', 'Class'); + ctx.model.symbols.add('src/d.java', 'Bar', 'class:d:Bar', 'Class'); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/a.java', className: 'Foo', parentName: 'Bar', kind: 'extends' }, + ]; + + buildHeritageMap(heritage, ctx); + + expect( + deferredMsgs().some( + (m) => + m.includes('buildHeritageMap:') && + m.includes('child×parent lookup product >1') && + m.includes('max product') && + m.includes('0 unresolved child lookups') && + m.includes('0 unresolved parent lookups'), + ), + ).toBe(true); + }); + + it('buildHeritageMap counts unresolved parent lookups (U7, JVM pathological case)', () => { + const ctx = createResolutionContext(); + // Many same-named children all resolved. + ctx.model.symbols.add('src/a.java', 'Foo', 'class:a:Foo', 'Class'); + ctx.model.symbols.add('src/b.java', 'Foo', 'class:b:Foo', 'Class'); + // Parent (e.g., external library) is NOT in the symbol index — lookup + // returns []. The legacy counter would silently drop this record from + // the metric. With U7, it shows up as an unresolved-parent lookup. + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/a.java', className: 'Foo', parentName: 'ExternalBase', kind: 'extends' }, + ]; + + buildHeritageMap(heritage, ctx); + + expect(deferredMsgs().some((m) => m.includes('1 unresolved parent lookups'))).toBe(true); + expect(deferredMsgs().some((m) => m.includes('0 unresolved child lookups'))).toBe(true); + }); + + it('buildHeritageMap counts unresolved child lookups (U7, inverse case)', () => { + const ctx = createResolutionContext(); + // Parent resolved, child name not in symbol index. + ctx.model.symbols.add('src/c.java', 'Bar', 'class:c:Bar', 'Class'); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/x.java', className: 'UnknownChild', parentName: 'Bar', kind: 'extends' }, + ]; + + buildHeritageMap(heritage, ctx); + + expect(deferredMsgs().some((m) => m.includes('1 unresolved child lookups'))).toBe(true); + expect(deferredMsgs().some((m) => m.includes('0 unresolved parent lookups'))).toBe(true); + }); + + it('processCallsFromExtracted emits done summary with skipped registry-primary count', async () => { + const graph = createKnowledgeGraph(); + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function'); + + const calls: ExtractedCall[] = [ + { + filePath: 'src/index.ts', + calledName: 'helper', + sourceId: 'Function:src/index.ts:main', + }, + { + filePath: 'src/main.py', + calledName: 'run', + sourceId: 'Function:src/main.py:main', + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + expect( + deferredMsgs().some( + (m) => + m.includes('processCallsFromExtracted done:') && + m.includes('skipped registry-primary files=1'), + ), + ).toBe(true); + }); + + it('processCallsFromExtracted logs the first non-skipped file as 1/1 even when a registry-primary file sorts first', async () => { + const graph = createKnowledgeGraph(); + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function'); + + // Python sorts before TypeScript in byFile insertion order. Before the + // fix for #4 the first per-file log was keyed on filesProcessed===1, which + // was consumed by the Python skip and never emitted for the TS file. + const calls: ExtractedCall[] = [ + { filePath: 'src/early.py', calledName: 'run', sourceId: 'Function:src/early.py:main' }, + { filePath: 'src/index.ts', calledName: 'helper', sourceId: 'Function:src/index.ts:main' }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + expect(deferredMsgs().some((m) => m.includes('calls 1/1 file=src/index.ts'))).toBe(true); + expect(deferredMsgs().some((m) => m.includes('skipped registry-primary files=1'))).toBe(true); + }); + + it('processCallsFromExtracted denominator stays stable across mixed-language interleaving (A1 pre-pass)', async () => { + const graph = createKnowledgeGraph(); + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/a.ts', 'a', 'Function:src/a.ts:a', 'Function'); + ctx.model.symbols.add('src/b.ts', 'b', 'Function:src/b.ts:b', 'Function'); + ctx.model.symbols.add('src/c.ts', 'c', 'Function:src/c.ts:c', 'Function'); + ctx.model.symbols.add('src/d.ts', 'd', 'Function:src/d.ts:d', 'Function'); + + // Alternating TS / PY order: byFile = [ts, py, ts, py, ts, py, ts, py]. + // Before the U1 pre-pass, the first per-file log carried denominator 8 + // (totalFiles - 0 skips) and self-corrected only after every skip was + // observed. With the pre-pass, the denominator is 4 from the first + // emission onward — every entry uses the same resolvedTotal. + const calls: ExtractedCall[] = [ + { filePath: 'src/a.ts', calledName: 'a', sourceId: 'Function:src/a.ts:f' }, + { filePath: 'src/p1.py', calledName: 'a', sourceId: 'Function:src/p1.py:f' }, + { filePath: 'src/b.ts', calledName: 'b', sourceId: 'Function:src/b.ts:f' }, + { filePath: 'src/p2.py', calledName: 'b', sourceId: 'Function:src/p2.py:f' }, + { filePath: 'src/c.ts', calledName: 'c', sourceId: 'Function:src/c.ts:f' }, + { filePath: 'src/p3.py', calledName: 'c', sourceId: 'Function:src/p3.py:f' }, + { filePath: 'src/d.ts', calledName: 'd', sourceId: 'Function:src/d.ts:f' }, + { filePath: 'src/p4.py', calledName: 'd', sourceId: 'Function:src/p4.py:f' }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + // Every per-file emission carries `/4` (the eventual resolved-file + // total), not the in-flight `totalFiles - skippedSoFar`. + expect(deferredMsgs().some((m) => m.includes('calls 1/4 file=src/a.ts'))).toBe(true); + expect(deferredMsgs().some((m) => /calls \d+\/[^4]/.test(m))).toBe(false); + expect(deferredMsgs().some((m) => m.includes('skipped registry-primary files=4'))).toBe(true); + }); + + it('processCallsFromExtracted resets the dropped-line counter at entry (U4)', async () => { + // logger is a Proxy that vi.spyOn can't override; we seed the counter by + // directly mutating it via the public reset / observation surface. The + // test then verifies processCallsFromExtracted brings the counter back to + // zero at the start of its run. + resetDeferredProfileDroppedCount(); + // Force-bump the counter by simulating a dropped line: there's no public + // increment, but we can prove the reset happens by setting up a non-zero + // counter state via processCallsFromExtracted's own reset path called + // twice in a row — both invocations should leave the counter at zero. + const graph = createKnowledgeGraph(); + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function'); + const calls: ExtractedCall[] = [ + { filePath: 'src/index.ts', calledName: 'helper', sourceId: 'Function:src/index.ts:main' }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + expect(getDeferredProfileDroppedCount()).toBe(0); + + // Second run: counter is still zero (idempotent reset). + await processCallsFromExtracted(graph, calls, ctx); + expect(getDeferredProfileDroppedCount()).toBe(0); + }); + + it('processCallsFromExtracted does not log per-file progress for registry-primary skips', async () => { + const graph = createKnowledgeGraph(); + const ctx = createResolutionContext(); + + const calls: ExtractedCall[] = [ + { + filePath: 'src/only.py', + calledName: 'run', + sourceId: 'Function:src/only.py:main', + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + expect(deferredMsgs().some((m) => m.includes('calls 1/1 file=src/only.py'))).toBe(false); + expect(deferredMsgs().some((m) => m.includes('skipped registry-primary files=1'))).toBe(true); + }); +}); diff --git a/gitnexus/test/unit/deferred-resolution-profile.test.ts b/gitnexus/test/unit/deferred-resolution-profile.test.ts new file mode 100644 index 000000000..21f4a8551 --- /dev/null +++ b/gitnexus/test/unit/deferred-resolution-profile.test.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + deferredCallFileSlowMs, + deferredCallLogEveryN, + endTimer, + getDeferredProfileDroppedCount, + isDeferredResolutionProfileEnabled, + logDeferredProfile, + profileElapsedMs, + profileNow, + resetDeferredProfileDroppedCount, + startTimer, +} from '../../src/core/ingestion/utils/deferred-resolution-profile.js'; +import { _captureLogger } from '../../src/core/logger.js'; + +describe('deferred-resolution-profile', () => { + afterEach(() => { + delete process.env.GITNEXUS_PROFILE_DEFERRED; + delete process.env.GITNEXUS_PROFILE_DEFERRED_SLOW_MS; + delete process.env.GITNEXUS_VERBOSE; + resetDeferredProfileDroppedCount(); + vi.restoreAllMocks(); + }); + + it('is off by default', () => { + expect(isDeferredResolutionProfileEnabled()).toBe(false); + }); + + it('enables on GITNEXUS_VERBOSE=1', () => { + process.env.GITNEXUS_VERBOSE = '1'; + expect(isDeferredResolutionProfileEnabled()).toBe(true); + expect(deferredCallLogEveryN()).toBe(10); + expect(deferredCallFileSlowMs()).toBe(3000); + }); + + it('enables on GITNEXUS_PROFILE_DEFERRED=1', () => { + process.env.GITNEXUS_PROFILE_DEFERRED = '1'; + expect(isDeferredResolutionProfileEnabled()).toBe(true); + expect(deferredCallLogEveryN()).toBe(100); + }); + + it('reads slow-file threshold from env', () => { + process.env.GITNEXUS_PROFILE_DEFERRED_SLOW_MS = '250'; + expect(deferredCallFileSlowMs()).toBe(250); + }); + + describe('logDeferredProfile dropped-line counter (U4)', () => { + // Background: `logger` (gitnexus/src/core/logger.ts) is a Proxy with a lazy + // `get` trap and no `set` trap, so vi.spyOn on `logger.info` fails with + // "property is not defined on the object" — the inner pino method isn't a + // stable own-property to wrap. These tests exercise the helper API and the + // happy path; the catch arm is pinned by source-shape assertions below. + + it('counter is zero at module entry (after reset in afterEach)', () => { + expect(getDeferredProfileDroppedCount()).toBe(0); + }); + + it('does not increment when logger.info succeeds', () => { + const cap = _captureLogger(); + try { + logDeferredProfile('normal message'); + expect(getDeferredProfileDroppedCount()).toBe(0); + } finally { + cap.restore(); + } + }); + + it('multiple successful calls keep the counter at zero', () => { + const cap = _captureLogger(); + try { + logDeferredProfile('m1'); + logDeferredProfile('m2'); + logDeferredProfile('m3'); + expect(getDeferredProfileDroppedCount()).toBe(0); + } finally { + cap.restore(); + } + }); + + it('resetDeferredProfileDroppedCount returns the counter to zero', () => { + // Drive the counter via a stub since we can't spy on the Proxy. + // Mutate the counter through the public API: simulate a dropped line + // by calling logDeferredProfile inside a forced-throw context. + // Without a way to force logger.info to throw, the most we can test + // here is that reset() is idempotent on an already-zero counter and + // that the getter reads what reset wrote. + resetDeferredProfileDroppedCount(); + expect(getDeferredProfileDroppedCount()).toBe(0); + resetDeferredProfileDroppedCount(); + expect(getDeferredProfileDroppedCount()).toBe(0); + }); + + it('source defines a try/catch around the logger.info call', () => { + // Pin the catch arm via source shape — see logger Proxy note above. + const fs = require('node:fs') as typeof import('node:fs'); + const path = require('node:path') as typeof import('node:path'); + const url = require('node:url') as typeof import('node:url'); + const here = path.dirname(url.fileURLToPath(import.meta.url)); + const sourcePath = path.resolve( + here, + '../../src/core/ingestion/utils/deferred-resolution-profile.ts', + ); + const source = fs.readFileSync(sourcePath, 'utf-8'); + + expect( + /export const logDeferredProfile[\s\S]*?try \{\s*logger\.info\(`\[deferred-profile\] \$\{message\}`\);\s*\} catch[\s\S]*?droppedLogLines\+\+/.test( + source, + ), + ).toBe(true); + }); + }); + + describe('endTimer (U3 formatter exception safety)', () => { + it('emits the formatter output via [deferred-profile] when start is non-null', () => { + const cap = _captureLogger(); + try { + const start = startTimer(true); + endTimer(start, (ms) => `stage A: ${ms.toFixed(0)}ms`); + const messages = cap.records().map((r) => String(r.msg ?? '')); + expect(messages.some((m) => /\[deferred-profile\] stage A: \d+ms/.test(m))).toBe(true); + } finally { + cap.restore(); + } + }); + + it('is a no-op when start is null (profiling disabled), even if formatter would throw', () => { + const cap = _captureLogger(); + try { + const formatter = vi.fn(() => { + throw new Error('should never run'); + }); + endTimer(null, formatter); + expect(formatter).not.toHaveBeenCalled(); + expect(cap.records()).toEqual([]); + } finally { + cap.restore(); + } + }); + + it('catches a throwing formatter and emits one formatter-error line', () => { + const cap = _captureLogger(); + try { + const start = startTimer(true); + expect(() => + endTimer(start, () => { + throw new Error('boom'); + }), + ).not.toThrow(); + + const messages = cap.records().map((r) => String(r.msg ?? '')); + const errLines = messages.filter((m) => + m.includes('[deferred-profile] formatter error: boom'), + ); + expect(errLines.length).toBe(1); + } finally { + cap.restore(); + } + }); + + it('coerces non-Error throws (string, plain object) via String() in the error message', () => { + const cap = _captureLogger(); + try { + const start = startTimer(true); + endTimer(start, () => { + throw 'plain string'; + }); + const messages = cap.records().map((r) => String(r.msg ?? '')); + expect( + messages.some((m) => m.includes('[deferred-profile] formatter error: plain string')), + ).toBe(true); + } finally { + cap.restore(); + } + }); + }); + + it('profileElapsedMs converts hrtime deltas to ms with exact arithmetic', () => { + const spy = vi.spyOn(process.hrtime, 'bigint'); + try { + spy.mockReturnValueOnce(1_000_000_000n); + const start = profileNow(); + spy.mockReturnValueOnce(1_002_500_000n); + expect(profileElapsedMs(start)).toBe(2.5); + + spy.mockReturnValueOnce(5_000_000_000n); + const startZero = profileNow(); + spy.mockReturnValueOnce(5_000_000_000n); + expect(profileElapsedMs(startZero)).toBe(0); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/gitnexus/test/unit/env.test.ts b/gitnexus/test/unit/env.test.ts new file mode 100644 index 000000000..6b5e767f3 --- /dev/null +++ b/gitnexus/test/unit/env.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { parseTruthyEnv } from '../../src/core/ingestion/utils/env.js'; + +describe('parseTruthyEnv', () => { + it.each([ + ['1', true], + ['true', true], + ['yes', true], + ['TRUE', true], + ['Yes', true], + ['YES', true], + [' 1 ', true], + [' true ', true], + ['\tyes\n', true], + ])('accepts %j as truthy', (raw, expected) => { + expect(parseTruthyEnv(raw)).toBe(expected); + }); + + it.each([ + ['0', false], + ['false', false], + ['no', false], + ['off', false], + ['', false], + [' ', false], + ['maybe', false], + ['2', false], + ['truthy', false], + ['1.0', false], + ['yes please', false], + ])('rejects %j as falsy', (raw, expected) => { + expect(parseTruthyEnv(raw)).toBe(expected); + }); + + it('returns false for undefined', () => { + expect(parseTruthyEnv(undefined)).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/parse-impl-e1-emission-shape.test.ts b/gitnexus/test/unit/parse-impl-e1-emission-shape.test.ts new file mode 100644 index 000000000..55b3a19ca --- /dev/null +++ b/gitnexus/test/unit/parse-impl-e1-emission-shape.test.ts @@ -0,0 +1,63 @@ +/** + * U2 (A2 from PR #1773 review) — regression guard for the E1 enrichment + * log dual-emission shape. + * + * The E1 line at the top of `runChunkedParseAndResolve`'s post-chunk band + * has two independent emission targets: + * - `logger.info('🔗 E1: Seeded …')` for the `isDev` path (dev-mode log + * scrapers still match the original emoji marker). + * - `logDeferredProfile('E1: seeded …')` for the `GITNEXUS_PROFILE_DEFERRED` + * path (operators grepping the [deferred-profile] prefix see no gap + * between wildcard-synth and heritage timings). + * + * When both flags are set, BOTH lines must fire. The original code used + * `if (isDev) { ... } else if (deferredProfile) { ... }` which is mutually + * exclusive and silently swallowed the [deferred-profile] line on combined- + * flag runs. This pin guards against the regression returning. + * + * Driving the four-case truth table via the real pipeline requires the + * worker path (`deferredWorkerCalls` only populates from chunk-worker + * extraction), which is slow and harness-dependent. A source-shape pin is + * the right test scope for a purely structural change — and is exactly + * how downstream readers grep for the regression anyway. + */ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PARSE_IMPL_PATH = path.resolve( + __dirname, + '../../src/core/ingestion/pipeline-phases/parse-impl.ts', +); + +describe('parse-impl E1 dual-emission shape (U2)', () => { + const source = fs.readFileSync(PARSE_IMPL_PATH, 'utf-8'); + + it('has a standalone `if (isDev)` branch emitting the original emoji line', () => { + expect(/if \(isDev\) \{\s*logger\.info\(`🔗 E1: Seeded \$\{enrichedCount\}/.test(source)).toBe( + true, + ); + }); + + it('has a standalone `if (deferredProfile)` branch emitting the [deferred-profile] line', () => { + expect( + /if \(deferredProfile\) \{\s*logDeferredProfile\(`E1: seeded \$\{enrichedCount\}/.test( + source, + ), + ).toBe(true); + }); + + it('does not chain the E1 branches via `else if`', () => { + // Tight regex anchored to the closing `}` of the isDev branch — confirms + // the very next token is `if` (independent branch) not `else if` (mutually + // exclusive). Unrelated `else if (deferredProfile)` later in the file + // (e.g., the buildHeritageMap-skipped log) is outside this window. + expect( + /if \(isDev\) \{\s*logger\.info\(`🔗 E1: Seeded[^`]+`\);\s*\}\s*if \(deferredProfile\)/.test( + source, + ), + ).toBe(true); + }); +}); From 060fe75715cd190b62d8f6c38bf502a05971be02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Fri, 22 May 2026 12:44:44 +0100 Subject: [PATCH 14/16] docs(lang-kotlin): refresh scope-resolver JSDoc after #1758-#1763 landed (#1781) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scope-resolver header comment claimed forced-mode passed 154/175 (88%) and listed smart casts, cross-file iterables, method chains, overload selection, virtual dispatch, and interface defaults as "remaining gaps". All six landed in PRs #1774-#1779. Forced mode now passes 175/175 (verified post-merge against `main`). Update the header to: - state the current forced-mode result accurately, - enumerate the closed sub-issues so future readers can trace each capability back to its PR, - and explicitly name the remaining flip blockers (#1755, #1756, #1757) so the next maintainer to look at this file knows exactly what's required before adding `Kotlin` to `MIGRATED_LANGUAGES`. Docs-only — no behavioral changes. Refs #1746. Co-authored-by: Test --- .../languages/kotlin/scope-resolver.ts | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts index 5f69a2713..591e79bfc 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts @@ -20,14 +20,26 @@ import { * Kotlin is intentionally registered but not yet listed in * `MIGRATED_LANGUAGES`, matching the Java migration pattern from #1482: * the resolver can run in shadow/forced mode, while production default - * stays on the legacy DAG until registry-primary parity reaches the - * RFC threshold. Forced mode currently passes 154/175 fixtures (88%), - * including core import, receiver, companion, default-param, vararg, - * constructor, local assignment-chain, and collection-iteration fixtures. - * Remaining gaps are advanced TypeEnv behaviors such as smart casts, - * cross-file iterable return propagation, method-chain fixpoint cases, - * overload target-id selection, virtual dispatch, and interface default - * method dispatch. + * stays on the legacy DAG until the RFC flip criteria in #1746 are met. + * + * **Forced-mode parity (`REGISTRY_PRIMARY_KOTLIN=1`):** 175/175 fixtures + * after the migration sub-issues #1758–#1763 closed. Covers core + * import, receiver, companion, default-param, vararg, constructor, + * local assignment-chain, collection-iteration, smart casts + * (`when (x) { is T -> … }` and `if (x is T)` — #1758), cross-file + * iterable return propagation (#1759), single-level method-chain + * fixpoint receiver types (#1760), parameter-type-narrowed overload + * target-id selection (#1761), virtual dispatch via constructor RHS + * (`val x: Animal = Dog()` — #1762), and interface default-method + * dispatch via implements-split MRO (#1763). + * + * **Remaining pre-flip blockers (#1746):** #1755 (forced-mode preview + * CI workflow — obviated once Kotlin lands in `MIGRATED_LANGUAGES` + * because the existing scope-parity matrix auto-discovers it), #1756 + * (companion vs instance member dispatch), and #1757 (lambda scopes + * and lambda-parameter bindings). The flip PR adds + * `SupportedLanguages.Kotlin` to `MIGRATED_LANGUAGES` after the named + * blockers close. */ export const kotlinScopeResolver: ScopeResolver = { language: SupportedLanguages.Kotlin, From 952ada70c56a233d3649f71f55c28562c019e86c Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Fri, 22 May 2026 13:31:06 +0100 Subject: [PATCH 15/16] feat(cpp): Resolve overloaded operator calls (#1754) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cpp): resolve overloaded operator calls * fix(cpp): tighten overloaded operator resolution --------- Co-authored-by: Gergő Magyar --- .../call-extractors/configs/c-cpp.ts | 165 ++++++++++++++++++ .../src/core/ingestion/languages/c-cpp.ts | 8 +- .../core/ingestion/languages/cpp/captures.ts | 107 +++++++++++- .../src/core/ingestion/languages/cpp/query.ts | 69 ++++++++ .../src/core/ingestion/scope-extractor.ts | 1 + .../src/core/ingestion/tree-sitter-queries.ts | 12 +- .../cpp-overloaded-operators/lib.cpp | 26 +++ .../cpp-overloaded-operators/lib.h | 23 +++ .../test/integration/resolvers/cpp.test.ts | 59 +++++++ 9 files changed, 460 insertions(+), 10 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.h diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts b/gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts index 02a6ed60f..7e7d4d852 100644 --- a/gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts +++ b/gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts @@ -2,6 +2,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import type { CallExtractionConfig } from '../../call-types.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; export const cCallConfig: CallExtractionConfig = { language: SupportedLanguages.C, @@ -9,4 +10,168 @@ export const cCallConfig: CallExtractionConfig = { export const cppCallConfig: CallExtractionConfig = { language: SupportedLanguages.CPlusPlus, + extractLanguageCallSite(callNode) { + return extractCppOperatorCallSite(callNode); + }, }; + +function extractCppOperatorCallSite(callNode: SyntaxNode) { + if (callNode.type !== 'binary_expression') return null; + if (isPrimitiveOnlyBinaryOperatorCall(callNode)) return null; + + const operator = callNode.childForFieldName('operator')?.text.trim(); + // Keep the legacy DAG conservative: only simple identifier operands are + // modeled here. Complex expressions stay unresolved instead of guessed. + if (operator === '+') { + const left = callNode.childForFieldName('left'); + const right = callNode.childForFieldName('right'); + if (left?.type !== 'identifier' || right?.type !== 'identifier') return null; + return { + calledName: 'operator+', + callForm: 'member' as const, + receiverName: left.text, + argCount: 1, + }; + } + + if (operator === '<<') { + const right = callNode.childForFieldName('right'); + if (right?.type !== 'identifier') return null; + return { + calledName: 'operator<<', + callForm: 'free' as const, + argCount: 2, + }; + } + + return null; +} + +function isPrimitiveOnlyBinaryOperatorCall(callNode: SyntaxNode): boolean { + const left = callNode.childForFieldName('left'); + const right = callNode.childForFieldName('right'); + if (left === null || right === null) return false; + return isBuiltinOperatorOperand(left) && isBuiltinOperatorOperand(right); +} + +function isBuiltinOperatorOperand(node: SyntaxNode): boolean { + return isBuiltinOperatorType(inferCppOperatorOperandType(node)); +} + +function inferCppOperatorOperandType(node: SyntaxNode): string { + const literalType = inferCppLiteralType(node); + if (literalType !== '') return literalType; + if (node.type === 'identifier') return lookupCppIdentifierType(node); + return ''; +} + +function inferCppLiteralType(node: SyntaxNode): string { + if (node.type === 'number_literal') return node.text.includes('.') ? 'double' : 'int'; + if (node.type === 'char_literal') return 'char'; + if (node.type === 'true' || node.type === 'false') return 'bool'; + return ''; +} + +function lookupCppIdentifierType(identNode: SyntaxNode): string { + const varName = identNode.text; + let scope: SyntaxNode | null = identNode.parent; + while ( + scope !== null && + scope.type !== 'compound_statement' && + scope.type !== 'translation_unit' + ) { + scope = scope.parent; + } + if (scope === null) return ''; + + const parameterType = lookupCppFunctionParameterType(scope, varName); + if (parameterType !== '') return parameterType; + + for (let i = 0; i < scope.childCount; i++) { + const stmt = scope.child(i); + if (stmt === null || stmt.type !== 'declaration') continue; + const typeNode = stmt.childForFieldName('type'); + const declarator = stmt.childForFieldName('declarator'); + if (typeNode === null || declarator === null) continue; + if (extractDeclaratorLeafName(declarator) === varName) + return normalizeCppTypeText(typeNode.text); + } + return ''; +} + +function lookupCppFunctionParameterType(scope: SyntaxNode, varName: string): string { + let node: SyntaxNode | null = scope.parent; + while (node !== null) { + if (node.type === 'function_definition' || node.type === 'function_declarator') { + const fnDecl = + node.type === 'function_declarator' + ? node + : findFirstDescendantOfType(node, 'function_declarator'); + const params = fnDecl?.childForFieldName('parameters') ?? null; + if (params === null) return ''; + for (let i = 0; i < params.namedChildCount; i++) { + const param = params.namedChild(i); + if (param === null || param.type !== 'parameter_declaration') continue; + const declarator = param.childForFieldName('declarator'); + const typeNode = param.childForFieldName('type'); + if ( + declarator !== null && + typeNode !== null && + extractDeclaratorLeafName(declarator) === varName + ) { + return normalizeCppTypeText(typeNode.text); + } + } + return ''; + } + node = node.parent; + } + return ''; +} + +function findFirstDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | null { + if (node.type === type) return node; + for (let i = 0; i < node.namedChildCount; i++) { + const found = findFirstDescendantOfType(node.namedChild(i)!, type); + if (found !== null) return found; + } + return null; +} + +function extractDeclaratorLeafName(node: SyntaxNode): string { + if ( + node.type === 'identifier' || + node.type === 'field_identifier' || + node.type === 'operator_name' + ) { + return node.text; + } + + const named = node.namedChildren; + for (let i = named.length - 1; i >= 0; i--) { + const name = extractDeclaratorLeafName(named[i]!); + if (name !== '') return name; + } + return ''; +} + +function normalizeCppTypeText(text: string): string { + return text + .replace(/\b(const|volatile|static|extern|register|mutable|inline|constexpr)\b/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function isBuiltinOperatorType(type: string): boolean { + return ( + type === 'bool' || + type === 'char' || + type === 'double' || + type === 'float' || + type === 'int' || + type === 'long' || + type === 'short' || + type === 'signed' || + type === 'unsigned' + ); +} diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 453baca20..874c98b89 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -231,6 +231,7 @@ const cCppExtractFunctionName = ( c?.type === 'qualified_identifier' || c?.type === 'identifier' || c?.type === 'field_identifier' || + c?.type === 'operator_name' || c?.type === 'parenthesized_declarator' ) { innerDeclarator = c; @@ -244,7 +245,7 @@ const cCppExtractFunctionName = ( if (!nameNode) { for (let i = 0; i < innerDeclarator.childCount; i++) { const c = innerDeclarator.child(i); - if (c?.type === 'identifier') { + if (c?.type === 'identifier' || c?.type === 'operator_name') { nameNode = c; break; } @@ -256,7 +257,8 @@ const cCppExtractFunctionName = ( } } else if ( innerDeclarator?.type === 'identifier' || - innerDeclarator?.type === 'field_identifier' + innerDeclarator?.type === 'field_identifier' || + innerDeclarator?.type === 'operator_name' ) { // field_identifier is used for method names inside C++ class bodies funcName = innerDeclarator.text; @@ -275,7 +277,7 @@ const cCppExtractFunctionName = ( if (!nameNode) { for (let i = 0; i < nestedId.childCount; i++) { const c = nestedId.child(i); - if (c?.type === 'identifier') { + if (c?.type === 'identifier' || c?.type === 'operator_name') { nameNode = c; break; } diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index 4e4cb4dcf..de8cd058e 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -182,17 +182,41 @@ export function emitCppScopeCaptures( grouped['@reference.call.free'] ?? grouped['@reference.call.member'] ?? grouped['@reference.call.qualified']; + const operatorAnchor = grouped['@reference.operator']; + if (operatorAnchor !== undefined) { + const operatorNode = + callAnchor !== undefined + ? findNodeAtRange(tree.rootNode, callAnchor.range, 'binary_expression') + : null; + if (operatorNode !== null && isPrimitiveOnlyBinaryOperator(operatorNode)) continue; + } if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) { - const callNode = findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression'); - if (callNode !== null) { + const callNode = + findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ?? + findNodeAtRange(tree.rootNode, callAnchor.range, 'binary_expression'); + if (callNode?.type === 'call_expression') { grouped['@reference.arity'] = syntheticCapture( '@reference.arity', callNode, String(computeCppCallArity(callNode)), ); + } else if (callNode?.type === 'binary_expression') { + grouped['@reference.arity'] = syntheticCapture( + '@reference.arity', + callNode, + grouped['@reference.call.member'] !== undefined ? '1' : '2', + ); } } + if (operatorAnchor !== undefined && grouped['@reference.name'] === undefined) { + grouped['@reference.name'] = syntheticCapture( + '@reference.name', + findNodeAtRange(tree.rootNode, operatorAnchor.range, operatorAnchor.text) ?? tree.rootNode, + `operator${operatorAnchor.text}`, + ); + } + // ── Enrich constructor calls (new Foo()) with arity ───────────── const ctorCallAnchor = grouped['@reference.call.constructor']; if (ctorCallAnchor !== undefined && grouped['@reference.arity'] === undefined) { @@ -211,9 +235,13 @@ export function emitCppScopeCaptures( if (anyCallAnchor !== undefined && grouped['@reference.parameter-types'] === undefined) { const cNode = findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'call_expression') ?? - findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'new_expression'); + findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'new_expression') ?? + findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'binary_expression'); if (cNode !== null) { - const argTypes = inferCppCallArgTypes(cNode); + const argTypes = + cNode.type === 'binary_expression' + ? inferCppBinaryOperatorArgTypes(cNode, grouped['@reference.call.free'] !== undefined) + : inferCppCallArgTypes(cNode); if (argTypes !== undefined && argTypes.length > 0) { grouped['@reference.parameter-types'] = syntheticCapture( '@reference.parameter-types', @@ -221,7 +249,13 @@ export function emitCppScopeCaptures( JSON.stringify(argTypes), ); } - const argTypeClasses = inferCppCallArgTypeClasses(cNode); + const argTypeClasses = + cNode.type === 'binary_expression' + ? inferCppBinaryOperatorArgTypeClasses( + cNode, + grouped['@reference.call.free'] !== undefined, + ) + : inferCppCallArgTypeClasses(cNode); if (argTypeClasses !== undefined && argTypeClasses.length > 0) { grouped['@reference.parameter-type-classes'] = syntheticCapture( '@reference.parameter-type-classes', @@ -716,6 +750,69 @@ function inferCppCallArgTypeClasses(node: SyntaxNode): ParameterTypeClass[] | un return classes.length > 0 ? classes : undefined; } +function inferCppBinaryOperatorArgTypes( + node: SyntaxNode, + includeLeftOperand: boolean, +): string[] | undefined { + const operands = binaryOperatorOperands(node, includeLeftOperand); + if (operands.length === 0) return undefined; + const types = operands.map(inferCppExpressionType); + return types.length > 0 ? types : undefined; +} + +function inferCppBinaryOperatorArgTypeClasses( + node: SyntaxNode, + includeLeftOperand: boolean, +): ParameterTypeClass[] | undefined { + const operands = binaryOperatorOperands(node, includeLeftOperand); + if (operands.length === 0) return undefined; + const classes = operands.map(inferCppExpressionTypeClass); + return classes.length > 0 ? classes : undefined; +} + +function binaryOperatorOperands(node: SyntaxNode, includeLeftOperand: boolean): SyntaxNode[] { + const operands: SyntaxNode[] = []; + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (includeLeftOperand && left !== null) operands.push(left); + if (right !== null) operands.push(right); + return operands; +} + +function isPrimitiveOnlyBinaryOperator(node: SyntaxNode): boolean { + const operands = binaryOperatorOperands(node, true); + return operands.length > 0 && operands.every((operand) => isBuiltinOperatorType(operand)); +} + +function isBuiltinOperatorType(node: SyntaxNode): boolean { + const type = inferCppExpressionType(node); + return ( + type === 'bool' || + type === 'char' || + type === 'double' || + type === 'float' || + type === 'int' || + type === 'long' || + type === 'short' || + type === 'signed' || + type === 'unsigned' + ); +} + +function inferCppExpressionType(node: SyntaxNode): string { + const litType = inferCppLiteralType(node); + if (litType !== '') return litType; + if (node.type === 'identifier') return lookupDeclaredTypeForIdentifier(node); + return ''; +} + +function inferCppExpressionTypeClass(node: SyntaxNode): ParameterTypeClass { + const litType = inferCppLiteralType(node); + if (litType !== '') return valueTypeClass(litType); + if (node.type === 'identifier') return lookupDeclaredTypeClassForIdentifier(node); + return unknownTypeClass('unknown'); +} + function valueTypeClass(base: string): ParameterTypeClass { return { base, cv: 'none', indirection: 'value', pointerDepth: 0 }; } diff --git a/gitnexus/src/core/ingestion/languages/cpp/query.ts b/gitnexus/src/core/ingestion/languages/cpp/query.ts index 70d544e3d..d42b586ce 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/query.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/query.ts @@ -98,6 +98,12 @@ const CPP_SCOPE_QUERY = ` declarator: (qualified_identifier name: (identifier) @declaration.name))) @declaration.method +;; Out-of-class operator method: Point::operator+(...) +(function_definition + declarator: (function_declarator + declarator: (qualified_identifier + name: (operator_name) @declaration.name))) @declaration.method + ;; ─── Declarations — out-of-class method with pointer return ───────── (function_definition declarator: (pointer_declarator @@ -130,6 +136,11 @@ const CPP_SCOPE_QUERY = ` declarator: (function_declarator declarator: (field_identifier) @declaration.name)) @declaration.method +;; Inline operator method in class body: Point operator+(Point) const { ... } +(function_definition + declarator: (function_declarator + declarator: (operator_name) @declaration.name)) @declaration.method + ;; ─── Declarations — inline method with pointer return (field_identifier) ── ;; Covers: User* lookup(int id) { ... } inside a class body ;; AST: function_definition > pointer_declarator > function_declarator > field_identifier @@ -145,17 +156,49 @@ const CPP_SCOPE_QUERY = ` (function_declarator declarator: (field_identifier) @declaration.name))) @declaration.method +;; Inline operator method with reference return: Point& operator+=(Point) { ... } +(field_declaration_list + (function_definition + declarator: (reference_declarator + (function_declarator + declarator: (operator_name) @declaration.name))) @declaration.method) + +;; Free operator definition with reference return: std::ostream& operator<<(...) { ... } +(translation_unit + (function_definition + declarator: (reference_declarator + (function_declarator + declarator: (operator_name) @declaration.name))) @declaration.function) + +(namespace_definition + body: (declaration_list + (function_definition + declarator: (reference_declarator + (function_declarator + declarator: (operator_name) @declaration.name))) @declaration.function)) + ;; ─── Declarations — function prototype (forward declaration) ──────── (declaration declarator: (function_declarator declarator: (identifier) @declaration.name)) @declaration.function +;; Free operator prototype: std::ostream& operator<<(std::ostream&, T) +(declaration + declarator: (function_declarator + declarator: (operator_name) @declaration.name)) @declaration.function + ;; ─── Declarations — function prototype with pointer return ────────── (declaration declarator: (pointer_declarator declarator: (function_declarator declarator: (identifier) @declaration.name))) @declaration.function +;; Free operator prototype with reference return. +(declaration + declarator: (reference_declarator + (function_declarator + declarator: (operator_name) @declaration.name))) @declaration.function + ;; ─── Declarations — typedef ───────────────────────────────────────── (type_definition declarator: (type_identifier) @declaration.name) @declaration.typedef @@ -171,6 +214,11 @@ const CPP_SCOPE_QUERY = ` declarator: (function_declarator declarator: (field_identifier) @declaration.name)) @declaration.method +;; Operator method prototype in class body: Point operator+(Point) const; +(field_declaration + declarator: (function_declarator + declarator: (operator_name) @declaration.name)) @declaration.method + ;; Method prototype with pointer return: User* lookup(int id); (field_declaration declarator: (pointer_declarator @@ -183,6 +231,11 @@ const CPP_SCOPE_QUERY = ` (function_declarator declarator: (field_identifier) @declaration.name))) @declaration.method +(field_declaration + declarator: (reference_declarator + (function_declarator + declarator: (operator_name) @declaration.name))) @declaration.method + ;; ─── Declarations — fields ────────────────────────────────────────── (field_declaration declarator: (field_identifier) @declaration.name) @declaration.field @@ -473,6 +526,22 @@ const CPP_SCOPE_QUERY = ` argument: (_) @reference.receiver field: (field_identifier) @reference.name)) @reference.call.member +;; Conservative operator-call support (#1636): model a + b as a +;; member-style operator+ lookup, and lhs << rhs as a free +;; operator<< lookup. Free operator+(T,T), member operator<<, and +;; complex operand expressions remain false negatives for now. +;; Built-in operators remain unresolved because no user-defined +;; operator target exists. +(binary_expression + left: (_) @reference.receiver + operator: "+" @reference.operator + right: (_)) @reference.call.member + +(binary_expression + left: (_) + operator: "<<" @reference.operator + right: (_)) @reference.call.free + ;; ─── References — template calls (func()) ──────────────────────── (call_expression function: (template_function diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 09080d2c6..a737214b3 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -1048,6 +1048,7 @@ const KNOWN_SUB_TAGS: ReadonlySet = new Set([ '@type-binding.type', '@reference.name', '@reference.receiver', + '@reference.operator', '@reference.arity', '@reference.parameter-types', '@reference.parameter-type-classes', diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index f02ae2cb2..f67823c8c 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -702,7 +702,9 @@ export const CPP_QUERIES = ` ; Functions & Methods (direct declarator) (function_definition declarator: (function_declarator declarator: (identifier) @name)) @definition.function +(function_definition declarator: (function_declarator declarator: (operator_name) @name)) @definition.function (function_definition declarator: (function_declarator declarator: (qualified_identifier name: (identifier) @name))) @definition.method +(function_definition declarator: (function_declarator declarator: (qualified_identifier name: (operator_name) @name))) @definition.method ; Functions/methods returning pointers (pointer_declarator wraps function_declarator) (function_definition declarator: (pointer_declarator declarator: (function_declarator declarator: (identifier) @name))) @definition.function @@ -714,14 +716,18 @@ export const CPP_QUERIES = ` ; Functions/methods returning references (reference_declarator wraps function_declarator) (function_definition declarator: (reference_declarator (function_declarator declarator: (identifier) @name))) @definition.function +(function_definition declarator: (reference_declarator (function_declarator declarator: (operator_name) @name))) @definition.function (function_definition declarator: (reference_declarator (function_declarator declarator: (qualified_identifier name: (identifier) @name)))) @definition.method +(function_definition declarator: (reference_declarator (function_declarator declarator: (qualified_identifier name: (operator_name) @name)))) @definition.method ; Destructors (destructor_name is distinct from identifier in tree-sitter-cpp) (function_definition declarator: (function_declarator declarator: (qualified_identifier name: (destructor_name) @name))) @definition.method ; Function declarations / prototypes (common in headers) (declaration declarator: (function_declarator declarator: (identifier) @name)) @definition.function +(declaration declarator: (function_declarator declarator: (operator_name) @name)) @definition.function (declaration declarator: (pointer_declarator declarator: (function_declarator declarator: (identifier) @name))) @definition.function +(declaration declarator: (reference_declarator (function_declarator declarator: (operator_name) @name))) @definition.function ; Class/struct data member fields (Address address; int count;) ; Uses field_identifier to exclude method declarations (which use function_declarator) @@ -740,13 +746,13 @@ export const CPP_QUERIES = ` ; Inline class method declarations (inside class body, no body: void save();) ; tree-sitter-cpp uses field_identifier (not identifier) for names inside class bodies -(field_declaration declarator: (function_declarator declarator: [(field_identifier) (identifier)] @name)) @definition.method +(field_declaration declarator: (function_declarator declarator: [(field_identifier) (identifier) (operator_name)] @name)) @definition.method ; Inline class method declarations returning a pointer (User* lookup();) (field_declaration declarator: (pointer_declarator declarator: (function_declarator declarator: [(field_identifier) (identifier)] @name))) @definition.method ; Inline class method declarations returning a reference (User& lookup();) -(field_declaration declarator: (reference_declarator (function_declarator declarator: [(field_identifier) (identifier)] @name))) @definition.method +(field_declaration declarator: (reference_declarator (function_declarator declarator: [(field_identifier) (identifier) (operator_name)] @name))) @definition.method ; Inline class method definitions (inside class body, with body: void Foo() { ... }) (field_declaration_list @@ -785,6 +791,8 @@ export const CPP_QUERIES = ` (call_expression function: (field_expression field: (field_identifier) @call.name)) @call (call_expression function: (qualified_identifier name: (identifier) @call.name)) @call (call_expression function: (template_function name: (identifier) @call.name)) @call +(binary_expression operator: "+" @call.name) @call +(binary_expression operator: "<<" @call.name) @call ; Constructor calls: new User() (new_expression type: (type_identifier) @call.name) @call diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.cpp new file mode 100644 index 000000000..46a0eef98 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.cpp @@ -0,0 +1,26 @@ +#include "lib.h" + +namespace std { +ostream cout; +} + +std::ostream& operator<<(std::ostream& os, const Point& p) { + return os; +} + +void runMember(Point a, Point b) { + Point c = a + b; +} + +void runFree(Point p) { + std::cout << p; +} + +void runBuiltin() { + int x = 1 + 2; +} + +void runBuiltinVariables(int a, int b) { + int x = a + b; + int y = a << b; +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.h new file mode 100644 index 000000000..6966a680c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.h @@ -0,0 +1,23 @@ +#pragma once + +namespace std { +class ostream {}; +extern ostream cout; +} + +struct Point { + Point operator+(Point rhs) const { + return rhs; + } + + Point& operator+=(Point rhs) { + return *this; + } +}; + +std::ostream& operator<<(std::ostream& os, const Point& p); + +void runMember(Point a, Point b); +void runFree(Point p); +void runBuiltin(); +void runBuiltinVariables(int a, int b); diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index e1376938e..90bc75117 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -17,6 +17,65 @@ import { const it = createResolverParityIt('cpp'); +// --------------------------------------------------------------------------- +// C++ overloaded operators (#1636) +// --------------------------------------------------------------------------- + +describe('C++ overloaded operator call resolution (#1636)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-overloaded-operators'), () => {}); + }, 60000); + + it('resolves member operator+ for user-defined operands', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'runMember' && c.target === 'operator+', + ); + + expect(calls).toHaveLength(1); + expect(calls[0]?.targetLabel).toBe('Method'); + expect(calls[0]?.targetFilePath).toBe('lib.h'); + }); + + it('resolves free operator<< for user-defined operands', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'runFree' && c.target === 'operator<<', + ); + + expect(calls).toHaveLength(1); + expect(calls[0]?.targetLabel).toBe('Function'); + expect(calls[0]?.targetFilePath).toBe('lib.cpp'); + }); + + it('does not synthesize an operator edge for built-in int + int', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'runBuiltin' && c.target.startsWith('operator'), + ); + + expect(calls).toHaveLength(0); + }); + + it('does not synthesize operator edges for built-in int variables', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'runBuiltinVariables' && c.target.startsWith('operator'), + ); + + expect(calls).toHaveLength(0); + }); + + it('classifies reference-return inline operators as methods', () => { + const methods = getNodesByLabelFull(result, 'Method').filter((m) => m.name === 'operator+='); + const functions = getNodesByLabelFull(result, 'Function').filter( + (f) => f.name === 'operator+=', + ); + + expect(methods).toHaveLength(1); + expect(methods[0]?.properties.filePath).toBe('lib.h'); + expect(functions).toHaveLength(0); + }); +}); + // --------------------------------------------------------------------------- // Heritage: diamond inheritance + include-based imports // --------------------------------------------------------------------------- From 87b91c821e412f4b80d91137225c2d6f0dbb3ce5 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 14:46:49 +0100 Subject: [PATCH 16/16] fix(lbug): add WAL checkpoint-threshold control (#1772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * fix(analyze): add WAL auto-checkpoint CLI control and default-off behavior * test(analyze): share lbug auto-checkpoint parsing and align validation * fix(analyze): always enable lbug auto-checkpoint and expose threshold control * refactor(lbug): inline always-on auto-checkpoint constructor arg * fix(analyze): guide checkpoint-threshold on Ladybug WAL checkpoint IO failures * test(analyze): cover checkpoint IO guidance and add integration guard * fix(analyze): tighten checkpoint IO detection and remove test hook * fix(analyze): remove checkpoint test hook and tighten error matching * fix(analyze): rename to wal-checkpoint-threshold, raise default, add manual checkpoint driver with retry Address review feedback on PR #1772: - Rename CLI flag, env var, AnalyzeOptions field, recovery-hint tag, and parser/constants from lbug-* to engine-neutral wal-* (matches the existing WAL_RECOVERY_SUGGESTION / isWalCorruptionError convention). - Raise default threshold from -1 (Ladybug stock ~16 MiB) to 64 MiB so users on the default config no longer hit the original rename/remove race. - Align both READMEs to publish 67108864 (64 MiB) instead of 65536 (which would have made the crash more frequent). - Add wal-checkpoint-driver.ts: a periodic manual CHECKPOINT driver wrapped in a 3-attempt jittered retry (50/200/500 ms), driven from runFullAnalysis. Opt-out via GITNEXUS_WAL_MANUAL_CHECKPOINT=0. Moves the race window into a JS-controllable retry surface while keeping native auto-checkpoint on. - Move LBUG_CHECKPOINT_RENAME_RE / REMOVE_RE plus the predicate (renamed to isLbugCheckpointIoError) into lbug-config.ts alongside isWalCorruptionError. Predicate is now exported. Add a permissive fallback matcher and pin the matched Ladybug version in comments. - Warn instead of silently defaulting when GITNEXUS_WAL_CHECKPOINT_THRESHOLD is set to a non-empty unparseable value (closes the CLI-vs-env asymmetry). - Add a typed RecoveryHint string-literal union in cli-message.ts so future hint tags can't drift. - Add a real integration test under test/integration/ that triggers a Ladybug checkpoint IO failure via a pre-existing directory at the rename target (portable across platforms; no test-only injection hook). - Add small-disk / CI caveat (32 MiB secondary suggestion) to the recovery hint and README env-var rows. - Document CLI/env precedence in the analyze --help block. - Help placeholder: -> . - Rename analyze-lbug-auto-checkpoint.test.ts to use the new wal-* token. * chore(lbug): remove dead jitteredDelay helper and apply prettier - Drop unused `jitteredDelay` function flagged by CodeQL in PR #1772; the retry loop already inlines the same calculation with the injectable `randomImpl` so the helper was dead. Move the non-cryptographic-by-design comment next to the actual jitter site. - Apply `prettier --write` to wal-checkpoint-driver.ts and the new integration test to absorb the PR autofix bot's formatting findings. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Gergő Magyar Co-authored-by: Test --- README.md | 2 + gitnexus/README.md | 2 + gitnexus/src/cli/analyze.ts | 44 +++- gitnexus/src/cli/cli-message.ts | 38 ++- gitnexus/src/cli/index.ts | 7 + gitnexus/src/core/lbug/lbug-adapter.ts | 21 ++ gitnexus/src/core/lbug/lbug-config.ts | 87 ++++++- .../src/core/lbug/wal-checkpoint-driver.ts | 232 ++++++++++++++++++ gitnexus/src/core/run-analyze.ts | 25 +- .../analyze-wal-checkpoint-failure.test.ts | 133 ++++++++++ .../analyze-lbug-checkpoint-threshold.test.ts | 99 ++++++++ gitnexus/test/unit/analyze-wal-error.test.ts | 78 ++++++ gitnexus/test/unit/lbug-checkpoint.test.ts | 14 +- gitnexus/test/unit/lbug-config-wal.test.ts | 140 ++++++++++- .../test/unit/wal-checkpoint-driver.test.ts | 159 ++++++++++++ 15 files changed, 1070 insertions(+), 11 deletions(-) create mode 100644 gitnexus/src/core/lbug/wal-checkpoint-driver.ts create mode 100644 gitnexus/test/integration/analyze-wal-checkpoint-failure.test.ts create mode 100644 gitnexus/test/unit/analyze-lbug-checkpoint-threshold.test.ts create mode 100644 gitnexus/test/unit/wal-checkpoint-driver.test.ts diff --git a/README.md b/README.md index d55e8ef94..44f56edd0 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,7 @@ gitnexus analyze --skip-git # Index folders that are not Git repositories gitnexus analyze --embeddings # Enable embedding generation (slower, better search) gitnexus analyze --verbose # Log skipped files when parsers are unavailable gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses +gitnexus analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB) gitnexus analyze --workers # Parse worker pool size (default: cores-1, capped at 16; 0 = sequential) gitnexus mcp # Start MCP server (stdio) — serves all indexed repos gitnexus serve # Start local HTTP server (multi-repo) for web UI connection @@ -241,6 +242,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max | `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. | | `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size `. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. | | `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout ` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. | +| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold `. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. | | `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. | | `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per worker slot before the slot is dropped from the active rotation. Bounds respawn loops on a chronically-crashing slot. | Hosts where a flaky worker should retry more (raise) or fail-fast (lower) before the slot is dropped. | | `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Combined with `timeoutBackoffFactor`, prevents exponentially-growing retries from stalling for hours. | Slow files that legitimately need long total retry windows; lower to fail-fast on stalls. | diff --git a/gitnexus/README.md b/gitnexus/README.md index 61ae131ac..9b18e1dbb 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -158,6 +158,7 @@ gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexu gitnexus analyze --verbose # Log skipped files when parsers are unavailable gitnexus analyze --max-file-size 1024 # Skip files larger than N KB (default: 512, cap: 32768) gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses +gitnexus analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB) gitnexus mcp # Start MCP server (stdio) — serves all indexed repos gitnexus serve # Start local HTTP server (multi-repo) for web UI gitnexus index # Register an existing .gitnexus/ folder into the global registry @@ -307,6 +308,7 @@ Configure the behavior with two environment variables: |----------|--------|---------|--------| | `GITNEXUS_LBUG_EXTENSION_INSTALL` | `auto`, `load-only`, `never` | `auto` | `auto` runs one bounded INSTALL if LOAD fails. `load-only` only uses already-installed extensions (recommended for offline / firewalled environments). `never` skips optional extensions entirely. | | `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process `INSTALL` child before it is killed. | +| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | ```bash # Offline/airgapped: never reach the network for extensions diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 55995b76b..32ceaca62 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -13,7 +13,12 @@ import { spawn } from 'child_process'; import v8 from 'v8'; import cliProgress from 'cli-progress'; import { closeLbug } from '../core/lbug/lbug-adapter.js'; -import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../core/lbug/lbug-config.js'; +import { + isLbugCheckpointIoError, + isWalCorruptionError, + parseWalCheckpointThreshold, + WAL_RECOVERY_SUGGESTION, +} from '../core/lbug/lbug-config.js'; import { getStoragePaths, getGlobalRegistryPath, @@ -415,6 +420,15 @@ const forceHeapOOMForTestIfEnabled = (): void => { for (;;) chunks.push('x'.repeat(1024 * 1024)); }; +// 64 MiB keeps auto-checkpoint enabled but triggers less frequently than +// Ladybug's stock ~16 MiB threshold, reducing rename/remove churn on large +// runs. Also matches the GitNexus default in `lbug-config.ts`. +// +// IMPORTANT: keep README examples (`README.md`, `gitnexus/README.md`) and +// the `DEFAULT_WAL_CHECKPOINT_THRESHOLD` constant in +// `gitnexus/src/core/lbug/lbug-config.ts` in sync with this value. +const RECOMMENDED_WAL_CHECKPOINT_THRESHOLD = 64 * 1024 * 1024; + /** Re-exec the process with a 16GB heap and larger stack if we're currently below that. */ async function ensureHeap(): Promise { const nodeOpts = process.env.NODE_OPTIONS || ''; @@ -477,6 +491,8 @@ const ANALYZE_CLI_ENV_KEYS = [ 'GITNEXUS_PROFILE_DEFERRED_SLOW_MS', 'GITNEXUS_MAX_FILE_SIZE', 'GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS', + 'GITNEXUS_WAL_CHECKPOINT_THRESHOLD', + 'GITNEXUS_WAL_MANUAL_CHECKPOINT', 'GITNEXUS_EMBEDDING_THREADS', 'GITNEXUS_EMBEDDING_BATCH_SIZE', 'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE', @@ -562,6 +578,8 @@ export interface AnalyzeOptions { maxFileSize?: string; /** Override worker sub-batch idle timeout in seconds. */ workerTimeout?: string; + /** Control LadybugDB WAL auto-checkpoint threshold during analyze. */ + walCheckpointThreshold?: string; /** Parse worker pool size; 0 disables workers (sequential fallback). */ workers?: string; embeddingThreads?: string; @@ -633,6 +651,16 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions): ); } + if (options?.walCheckpointThreshold !== undefined) { + const parsed = parseWalCheckpointThreshold(options.walCheckpointThreshold); + if (parsed === undefined) { + cliError(' --wal-checkpoint-threshold must be an integer >= -1.\n'); + process.exitCode = 1; + return; + } + process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD = String(parsed); + } + // `--workers` is threaded through `runFullAnalysis` options → PipelineOptions // → createWorkerPool, intentionally bypassing the GITNEXUS_WORKER_POOL_SIZE // env channel so this CLI surface never mutates `process.env` for pool size. @@ -1130,6 +1158,20 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions): return; } + if (isLbugCheckpointIoError(err)) { + cliError( + ` LadybugDB failed while rotating/removing WAL checkpoint files.\n` + + ` This can happen when auto-checkpoint runs at the default threshold (~16MB).\n` + + ` Retry with a larger checkpoint threshold to reduce checkpoint frequency:\n` + + ` gitnexus analyze --wal-checkpoint-threshold ${RECOMMENDED_WAL_CHECKPOINT_THRESHOLD}\n` + + ` (or set GITNEXUS_WAL_CHECKPOINT_THRESHOLD=${RECOMMENDED_WAL_CHECKPOINT_THRESHOLD})\n` + + ` (Try 33554432 = 32 MiB on small-disk / CI runners.)\n`, + { recoveryHint: 'wal-checkpoint-threshold' }, + ); + process.exitCode = 1; + return; + } + // HF download failure — show clean guidance without the raw stack trace. // Checked before writeFatalToStderr so the user sees one focused message // rather than a stack-trace dump followed by a second remediation block. diff --git a/gitnexus/src/cli/cli-message.ts b/gitnexus/src/cli/cli-message.ts index db5f51fab..1f1eb9521 100644 --- a/gitnexus/src/cli/cli-message.ts +++ b/gitnexus/src/cli/cli-message.ts @@ -29,6 +29,38 @@ */ import { logger } from '../core/logger.js'; +/** + * String-literal union of all `recoveryHint` tags emitted by the CLI. + * + * Centralized so a new recovery branch added in `analyze.ts` cannot land + * without updating this union — TypeScript will reject the unknown literal + * passed via `cliError({ recoveryHint: '...' })`. To add a new hint: + * 1. Add the tag string to this union. + * 2. Pass it as the `recoveryHint` field at the relevant `cliError` + * call site. + * + * Consumers can import this type to narrow log-record `recoveryHint` + * fields without restating the literal list. + */ +export type RecoveryHint = + | 'wal-corruption' + | 'wal-checkpoint-threshold' + | 'heap-oom-respawn' + | 'native-worker-abort' + | 'hf-endpoint-unreachable' + | 'large-repo' + | 'npm-resolution' + | 'module-not-found'; + +/** + * Common shape for the optional structured-field bag passed to + * `cliError`/`cliWarn`/`cliInfo`. Typed so the `recoveryHint` slot is + * checked against the {@link RecoveryHint} union. + */ +export interface CliMessageFields extends Record { + recoveryHint?: RecoveryHint; +} + function writeStderr(msg: string): void { // Direct write — bypassing `console.*` so it cannot be intercepted by // progress-bar redirection (see `cli/analyze.ts:barLog`) or other @@ -41,7 +73,7 @@ function writeStderr(msg: string): void { * User-facing informational message. Use for banners, listening URLs, * and any message the user expects to read in plain text. */ -export function cliInfo(msg: string, fields?: Record): void { +export function cliInfo(msg: string, fields?: CliMessageFields): void { writeStderr(msg); logger.info(fields ?? {}, msg); } @@ -50,7 +82,7 @@ export function cliInfo(msg: string, fields?: Record): void { * User-facing warning. Operator-actionable but non-fatal — `cliWarn` * indicates the command can still proceed in some form. */ -export function cliWarn(msg: string, fields?: Record): void { +export function cliWarn(msg: string, fields?: CliMessageFields): void { writeStderr(msg); logger.warn(fields ?? {}, msg); } @@ -59,7 +91,7 @@ export function cliWarn(msg: string, fields?: Record): void { * User-facing error. Indicates the command cannot proceed; usually * paired with a non-zero exit code at the call site. */ -export function cliError(msg: string, fields?: Record): void { +export function cliError(msg: string, fields?: CliMessageFields): void { writeStderr(msg); logger.error(fields ?? {}, msg); } diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 31446cf6f..17fab2068 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -71,6 +71,11 @@ program '--worker-timeout ', 'Worker sub-batch idle timeout before retry/fallback. Default: 30.', ) + .option( + '--wal-checkpoint-threshold ', + 'LadybugDB WAL auto-checkpoint threshold in bytes during analyze ' + + '(integer >= -1; default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).', + ) .option( '--workers ', 'Parse worker pool size. Default: cores-1 capped at 16. Pass 0 to disable workers (sequential).', @@ -85,6 +90,7 @@ program ' GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n' + ' GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n' + ' GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n' + + ' GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n' + ' GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n' + ' GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n' + ' GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n' + @@ -93,6 +99,7 @@ program ' GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n' + ' GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n' + ' GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n' + + '\nFlags override the corresponding env vars when both are provided.\n' + '\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n' + ' `!__tests__/` to index a directory that is auto-filtered by default (#771).', ) diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index da9809dba..5e2a34601 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -1527,6 +1527,27 @@ export const flushWAL = async (): Promise => { } }; +/** + * Issue a manual `CHECKPOINT` against the current connection and surface + * any engine error to the caller. Unlike {@link flushWAL}, this variant + * does NOT swallow Ladybug rename/remove IO failures — the manual + * checkpoint driver (`wal-checkpoint-driver.ts`) relies on the rejection + * to drive its bounded retry loop. Returns `false` when no connection is + * open (the caller treats this as a no-op success — there is no WAL to + * flush). Returns `true` after a successful CHECKPOINT + drain. + * + * The split from `flushWAL` is deliberate: every other CHECKPOINT site + * (server flush, safeClose) is best-effort and prefers a silent skip; + * the manual driver, by contrast, must observe failures to decide + * whether to retry. + */ +export const tryFlushWAL = async (): Promise => { + if (!conn) return false; + const checkpointResult = await conn.query('CHECKPOINT'); + await drainQueryResult(checkpointResult); + return true; +}; + /** * Flush the WAL and close the connection and database handles. * diff --git a/gitnexus/src/core/lbug/lbug-config.ts b/gitnexus/src/core/lbug/lbug-config.ts index 22f2d3b18..a71b25930 100644 --- a/gitnexus/src/core/lbug/lbug-config.ts +++ b/gitnexus/src/core/lbug/lbug-config.ts @@ -2,6 +2,7 @@ import fs from 'fs/promises'; import os from 'os'; import path from 'path'; import type lbug from '@ladybugdb/core'; +import { logger } from '../logger.js'; /** * Shared configuration for `@ladybugdb/core` `Database` construction. @@ -45,6 +46,44 @@ export const LBUG_MAX_DB_SIZE: number = (() => { return 16 * 1024 * 1024 * 1024; })(); +export const parseWalCheckpointThreshold = (raw: string | undefined): number | undefined => { + if (raw === undefined) return undefined; + const normalized = raw.trim(); + if (normalized.length === 0) return undefined; + const parsed = Number(normalized); + if (!Number.isInteger(parsed) || parsed < -1) return undefined; + return parsed; +}; + +/** + * Default GitNexus WAL auto-checkpoint threshold in bytes (64 MiB). + * + * Larger than Ladybug's stock ~16 MiB to reduce checkpoint rename/remove + * churn under heavy analyze write load — the original race that motivated + * issue #1741 triggered at the stock threshold. README examples in + * `README.md` and `gitnexus/README.md` and the recovery hint in + * `analyze.ts` MUST stay in sync with this value. + */ +const DEFAULT_WAL_CHECKPOINT_THRESHOLD = 64 * 1024 * 1024; + +const resolveCheckpointThreshold = (): number => { + const raw = process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD; + if (raw === undefined) return DEFAULT_WAL_CHECKPOINT_THRESHOLD; + const parsed = parseWalCheckpointThreshold(raw); + if (parsed !== undefined) return parsed; + // Non-empty but unparseable input: warn the operator and fall back. Mirrors + // the CLI's `--wal-checkpoint-threshold` validation (which hard-errors) + // but the env-var path stays soft to preserve "set once in your shell" + // ergonomics across mixed-version invocations. + if (raw.trim().length > 0) { + logger.warn( + { rawValue: raw, fallback: DEFAULT_WAL_CHECKPOINT_THRESHOLD }, + `Ignoring invalid GITNEXUS_WAL_CHECKPOINT_THRESHOLD=${raw}; expected integer >= -1; falling back to default (${DEFAULT_WAL_CHECKPOINT_THRESHOLD}).`, + ); + } + return DEFAULT_WAL_CHECKPOINT_THRESHOLD; +}; + /** Matches WAL corruption errors from the LadybugDB engine. */ const WAL_CORRUPTION_RE = /corrupt(ed)?\s+wal|invalid\s+wal\s+record|wal.*corrupt|checksum.*wal/i; @@ -57,6 +96,50 @@ export function isWalCorruptionError(err: unknown): boolean { return WAL_CORRUPTION_RE.test(msg); } +// ─── Ladybug WAL checkpoint IO error matchers ─────────────────────────────── +// +// Matched against LadybugDB v0.16.1 (see `gitnexus/package.json` +// @ladybugdb/core). Strict regexes encode local_file_system.cpp wording +// verified at that version. Two-tier strategy: strict matchers first so we +// only fire on real checkpoint-rotation shapes; a permissive fallback +// catches future Ladybug message drift so the recovery hint keeps surfacing +// even if upstream wording changes. +// +// From Ladybug native LocalFileSystem exceptions (`local_file_system.cpp`), +// surfaced in Node as: +// "Runtime exception: IO exception: Error renaming file ..." +// "Runtime exception: IO exception: Error removing directory or file ..." +// We only match checkpoint-rotation shapes: +// - ".wal -> .wal.checkpoint" rename failures +// - ".wal.checkpoint" remove failures +// Example matches: +// "Runtime exception: IO exception: Error renaming file /x/lbug.wal to /x/lbug.wal.checkpoint. ErrorMessage: Permission denied" +// "Runtime exception: IO exception: Error removing directory or file /x/lbug.wal.checkpoint. Error Message: Permission denied" +// Matching is case-insensitive to remain robust across wrappers/platforms. +const LBUG_CHECKPOINT_RENAME_RE = + /^runtime exception: io exception:\s*error renaming file\s+.+?\.wal\s+to\s+.+?\.wal\.checkpoint(?:\.|\s|$)/i; +const LBUG_CHECKPOINT_REMOVE_RE = + /^runtime exception: io exception:\s*error removing directory or file\s+.+?\.wal\.checkpoint(?:\.|\s|$)/i; +/** + * Permissive fallback: any IO-exception-shaped message that mentions a + * `.wal.checkpoint` path. Catches future Ladybug message drift (different + * verb, additional preamble, locale variation) so the recovery hint keeps + * surfacing even if the strict regexes go stale. + */ +const LBUG_CHECKPOINT_PERMISSIVE_RE = /io exception.*\.wal\.checkpoint/i; + +/** + * True when `err` looks like a Ladybug WAL-checkpoint rotation/remove IO + * failure. Tries strict matchers first (renames + removes), then falls + * back to the permissive matcher. + */ +export const isLbugCheckpointIoError = (err: unknown): boolean => { + if (!err) return false; + const msg = err instanceof Error ? err.message : String(err); + if (LBUG_CHECKPOINT_RENAME_RE.test(msg) || LBUG_CHECKPOINT_REMOVE_RE.test(msg)) return true; + return LBUG_CHECKPOINT_PERMISSIVE_RE.test(msg); +}; + type LbugModule = typeof lbug; export interface LbugDatabaseOptions { @@ -103,8 +186,8 @@ export function createLbugDatabase( false, // enableCompression (pinned for v0.16.0) options.readOnly ?? false, LBUG_MAX_DB_SIZE, - true, // autoCheckpoint - -1, // checkpointThreshold + true, // autoCheckpoint (always on) + resolveCheckpointThreshold(), // checkpointThreshold (default 64 MiB; override with GITNEXUS_WAL_CHECKPOINT_THRESHOLD; -1 keeps Ladybug stock ~16 MiB) options.throwOnWalReplayFailure ?? true, true, // enableChecksums ) as lbug.Database; diff --git a/gitnexus/src/core/lbug/wal-checkpoint-driver.ts b/gitnexus/src/core/lbug/wal-checkpoint-driver.ts new file mode 100644 index 000000000..57dc6b2d6 --- /dev/null +++ b/gitnexus/src/core/lbug/wal-checkpoint-driver.ts @@ -0,0 +1,232 @@ +/** + * Manual WAL checkpoint driver with bounded retry (#1741 follow-up). + * + * Background + * ---------- + * LadybugDB's native auto-checkpoint runs from inside the C++ engine on a + * background path that has no JS-side hook for mid-write rotation. When + * the rename of `.wal` → `.wal.checkpoint` races a transient file + * lock (Windows Defender, AV scanner, NTFS shadow copy) the engine raises + * a `Runtime exception: IO exception: Error renaming file …` that aborts + * the in-flight write. There is no engine-level retry. + * + * The auto-checkpoint cannot be made retryable from JS, but a *manual* + * `CHECKPOINT` query that the JS layer issues itself CAN be wrapped in a + * bounded retry. By draining the WAL on a tight cadence — more often than + * the native threshold — the auto-checkpoint almost never has work left + * to do, so the un-retriable native rename race is moved into the + * JS-controlled path where this module's retry absorbs it. + * + * Design contract + * --------------- + * - `autoCheckpoint` stays on (maintainer requirement). This driver is + * additive: it preempts the native checkpoint, it does not replace it. + * - The driver runs ONLY during analyze (callers opt-in explicitly). MCP + * and other long-lived flows continue to rely on the close-time + * CHECKPOINT in `safeClose`. + * - Opt-out is via `GITNEXUS_WAL_MANUAL_CHECKPOINT=0`. Default is on. + * - Retries only fire on `isLbugCheckpointIoError` — every other error + * surfaces immediately. The retry budget is small (3 attempts) with + * jittered backoff so a chronic rename failure escalates fast. + * - Retry attempts log at `debug`; only the final, exhausted failure + * surfaces to the caller (and is logged at `warn` here for operators). + */ + +import { logger } from '../logger.js'; +import { tryFlushWAL } from './lbug-adapter.js'; +import { isLbugCheckpointIoError } from './lbug-config.js'; + +/** + * Bounded retry budget. Total worst-case wall time is dominated by the + * three sleeps below (~750 ms before jitter) plus three CHECKPOINT round + * trips — small enough to stay invisible during a large analyze, large + * enough to ride out a single AV scanner sweep on Windows. + */ +const CHECKPOINT_RETRY_ATTEMPTS = 3; + +/** + * Base back-off in ms. Each attempt waits `BASE_DELAYS[attempt-1]` + * milliseconds before the next try, plus a small jitter to avoid + * synchronized retries when multiple analyzers ever share a host. + */ +const BASE_DELAYS_MS: readonly number[] = [50, 200, 500]; + +/** Maximum jitter added on top of each base delay. */ +const JITTER_MAX_MS = 50; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Run a single CHECKPOINT with bounded retry on + * `isLbugCheckpointIoError`. Returns the number of attempts actually + * spent (1-`CHECKPOINT_RETRY_ATTEMPTS`) on success, or rethrows the last + * checkpoint error after exhausting the budget. Non-checkpoint errors + * (e.g. WAL corruption, lock-busy) propagate immediately on the first + * attempt — those are not what this retry is designed to absorb. + * + * The split from `flushWAL` is deliberate: `flushWAL` is the swallow-and- + * log helper used by `safeClose` and the server's best-effort flush, + * which by contract cannot fail the surrounding operation. The manual + * driver MUST observe failures to decide whether to retry, and that is + * the role of `tryFlushWAL`. + * + * Exported for direct unit testing — production callers use + * {@link startWalCheckpointDriver} or {@link checkpointOnce}. + */ +export const runCheckpointWithRetry = async ( + options: { + /** Override the sleep implementation for tests. */ + sleepFn?: (ms: number) => Promise; + /** Override the CHECKPOINT call for tests. */ + checkpointFn?: () => Promise; + /** Override the jitter source for tests. Returns a value in [0, 1). */ + randomFn?: () => number; + } = {}, +): Promise<{ attempts: number; flushed: boolean }> => { + const sleepImpl = options.sleepFn ?? sleep; + const checkpointImpl = options.checkpointFn ?? tryFlushWAL; + const randomImpl = options.randomFn ?? Math.random; + + let lastError: unknown; + for (let attempt = 1; attempt <= CHECKPOINT_RETRY_ATTEMPTS; attempt++) { + try { + const flushed = await checkpointImpl(); + return { attempts: attempt, flushed }; + } catch (err) { + lastError = err; + if (!isLbugCheckpointIoError(err)) { + // Non-checkpoint error — propagate immediately. Examples: + // WAL corruption, missing connection, query syntax failure. + // Retrying these would only mask the real signal. + throw err; + } + if (attempt === CHECKPOINT_RETRY_ATTEMPTS) break; + const base = BASE_DELAYS_MS[Math.min(attempt - 1, BASE_DELAYS_MS.length - 1)] ?? 500; + // randomImpl defaults to Math.random — non-cryptographic by design; jitter only avoids + // synchronized retries between concurrent analyzers. + const delayMs = base + Math.floor(randomImpl() * JITTER_MAX_MS); + logger.debug( + { attempt, totalAttempts: CHECKPOINT_RETRY_ATTEMPTS, delayMs }, + 'GitNexus: WAL checkpoint IO error — retrying', + ); + await sleepImpl(delayMs); + } + } + + logger.warn( + { attempts: CHECKPOINT_RETRY_ATTEMPTS }, + 'GitNexus: manual WAL checkpoint exhausted retry budget — surfacing IO error to caller', + ); + throw lastError; +}; + +/** + * Single-shot manual checkpoint. Use this when the caller drives the + * cadence itself (e.g. a phase boundary in `runFullAnalysis`). + * + * Honors the `GITNEXUS_WAL_MANUAL_CHECKPOINT=0` opt-out so operators can + * disable the manual path if it ever interacts badly with a future + * Ladybug release. + */ +export const checkpointOnce = async (): Promise => { + if (!isManualCheckpointEnabled()) return; + await runCheckpointWithRetry(); +}; + +/** Default cadence (ms) for the periodic driver. */ +const DEFAULT_PERIOD_MS = 5_000; + +/** + * Start a periodic manual checkpoint driver. The returned handle has a + * `stop()` method that resolves once the in-flight checkpoint (if any) + * settles, so callers can `await driver.stop()` before close-time + * `safeClose` and avoid racing the final flush. + * + * The first checkpoint fires after `periodMs` (not immediately) so a + * cold analyze does not pay a CHECKPOINT round trip before any writes + * have happened. + */ +export interface WalCheckpointDriver { + /** Stop the driver and await any in-flight checkpoint. Idempotent. */ + stop(): Promise; +} + +export const startWalCheckpointDriver = ( + options: { periodMs?: number } = {}, +): WalCheckpointDriver => { + if (!isManualCheckpointEnabled()) { + return { stop: async () => undefined }; + } + + const periodMs = options.periodMs ?? DEFAULT_PERIOD_MS; + let stopped = false; + let inflight: Promise | null = null; + + const tick = async (): Promise => { + if (stopped) return; + inflight = runCheckpointWithRetry() + .then(() => undefined) + .catch((err) => { + // The retry budget exhausted. The caller's surrounding write + // will see the same engine error on its next operation and the + // `analyzeCommand` catch block will emit the recovery hint. + // Logging here keeps the operator-visible trail without + // double-logging the user-facing message. + logger.warn( + { err: err instanceof Error ? err.message : String(err) }, + 'GitNexus: manual WAL checkpoint failed after retries', + ); + }); + try { + await inflight; + } finally { + inflight = null; + } + }; + + const handle = setInterval(() => { + // Fire-and-forget: setInterval cannot await directly. The next tick + // is guarded by `stopped` and the `inflight` reference. + void tick(); + }, periodMs); + // `setInterval` returned by Node is a `Timeout` object with `.unref()` + // so a hung driver never prevents process exit. + if (typeof (handle as NodeJS.Timeout).unref === 'function') { + (handle as NodeJS.Timeout).unref(); + } + + return { + stop: async () => { + if (stopped) { + if (inflight) await inflight; + return; + } + stopped = true; + clearInterval(handle); + if (inflight) { + try { + await inflight; + } catch { + /* swallowed in tick() — surface path is the surrounding write */ + } + } + }, + }; +}; + +/** + * Reading `GITNEXUS_WAL_MANUAL_CHECKPOINT` at every call site (rather + * than caching at module load) keeps `analyzeCommand` env restoration + * honest: tests that toggle the flag between invocations see the live + * value, matching the `ANALYZE_CLI_ENV_KEYS` snapshot/restore contract + * in `analyze.ts`. + * + * Accepted opt-out values: '0', 'false', 'off', 'no' (case-insensitive). + * Anything else — including undefined — leaves the driver enabled. + */ +export const isManualCheckpointEnabled = (): boolean => { + const raw = process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT; + if (raw === undefined) return true; + const normalized = raw.trim().toLowerCase(); + return !['0', 'false', 'off', 'no'].includes(normalized); +}; diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 79601a9a2..c28504c4c 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -26,6 +26,10 @@ import { queryImporters, } from './lbug/lbug-adapter.js'; import { createSearchFTSIndexes, verifySearchFTSIndexes } from './search/fts-indexes.js'; +import { + startWalCheckpointDriver, + type WalCheckpointDriver, +} from './lbug/wal-checkpoint-driver.js'; import { getStoragePaths, saveMeta, @@ -521,6 +525,16 @@ export async function runFullAnalysis( } await initLbug(lbugPath); + + // Manual WAL checkpoint driver (#1741): periodically drain the WAL + // from JS so the un-retriable native auto-checkpoint almost never + // has work left to do. Failures of the manual CHECKPOINT are absorbed + // by the driver's bounded retry; the final un-recoverable error still + // surfaces via the surrounding write that follows the failed flush. + // Opt-out via `GITNEXUS_WAL_MANUAL_CHECKPOINT=0` (the driver itself + // returns a no-op handle when disabled). Analyze-only: MCP and serve + // paths continue to rely on the close-time CHECKPOINT in `safeClose`. + const walCheckpointDriver: WalCheckpointDriver = startWalCheckpointDriver(); try { // All work after initLbug is wrapped in try/finally to ensure closeLbug() // is called even if an error occurs — the module-level singleton DB handle @@ -961,6 +975,9 @@ export async function runFullAnalysis( } // ── Close LadybugDB ────────────────────────────────────────────── + // Stop the manual checkpoint driver before closeLbug so its + // in-flight CHECKPOINT cannot race the `safeClose` CHECKPOINT. + await walCheckpointDriver.stop(); await closeLbug(); progress('done', 100, 'Done'); @@ -972,7 +989,13 @@ export async function runFullAnalysis( pipelineResult, }; } catch (err) { - // Ensure LadybugDB is closed even on error + // Ensure LadybugDB is closed even on error. Stop the driver first + // so its retry loop cannot extend an already-failing analyze. + try { + await walCheckpointDriver.stop(); + } catch { + /* swallow — surface path is the rethrow below */ + } try { await closeLbug(); } catch { diff --git a/gitnexus/test/integration/analyze-wal-checkpoint-failure.test.ts b/gitnexus/test/integration/analyze-wal-checkpoint-failure.test.ts new file mode 100644 index 000000000..c517263df --- /dev/null +++ b/gitnexus/test/integration/analyze-wal-checkpoint-failure.test.ts @@ -0,0 +1,133 @@ +/** + * Integration test: WAL auto-checkpoint rename failure (#1741 / #1772). + * + * Drives the real `analyzeCommand` against a real LadybugDB instance and + * provokes a genuine Ladybug-engine `IO exception: Error renaming file + * .wal to .wal.checkpoint` by pre-planting a *directory* at the + * `.wal.checkpoint` rename target. `fs.rename` (which Ladybug's native + * `LocalFileSystem` ultimately invokes) cannot overwrite a non-empty + * directory with a file on either POSIX or Windows, and Ladybug's + * `doInitLbug` orphan-cleanup uses `fs.unlink` which fails on a directory + * — so the blocker survives initialization and the next auto-checkpoint + * fires the natural rename failure that motivated PR #1772. + * + * No test-only hooks, no env-var fault toggles in production code: we use + * the same `GITNEXUS_WAL_CHECKPOINT_THRESHOLD=1` knob that real users have + * available to force checkpointing on every write, then arrange a real + * filesystem state that makes the rename impossible. + * + * Verifies that: + * 1. The CLI exits non-zero. + * 2. stderr contains the actionable recovery hint pointing at + * `--wal-checkpoint-threshold 67108864` (the + * `RECOMMENDED_WAL_CHECKPOINT_THRESHOLD` constant in `analyze.ts`). + * 3. The recovery message references the + * `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` env var as a parallel route. + * + * Empirically confirmed portable on Windows; the same mechanism is + * expected to work on POSIX (`rename(2)` fails with `EISDIR`/`ENOTEMPTY` + * when the target is a non-empty directory). If a future Ladybug release + * changes the rename ordering, the loose match on the recovery hint + * (rather than the exact engine error wording) keeps this test stable. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'child_process'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import { createRequire } from 'module'; +import { fileURLToPath, pathToFileURL } from 'url'; +import { cleanupTempDirSync } from '../helpers/test-db.js'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(testDir, '../..'); +const cliEntry = path.join(repoRoot, 'src/cli/index.ts'); +const FIXTURE_SRC = path.resolve(testDir, '..', 'fixtures', 'mini-repo'); + +const _require = createRequire(import.meta.url); +const tsxPkgDir = path.dirname(_require.resolve('tsx/package.json')); +const tsxImportUrl = pathToFileURL(path.join(tsxPkgDir, 'dist', 'loader.mjs')).href; + +let tmpParent: string; +let suiteGitnexusHome: string; +let repoPath: string; + +beforeAll(() => { + tmpParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-wal-checkpoint-e2e-')); + suiteGitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-wal-checkpoint-home-')); + repoPath = path.join(tmpParent, 'mini-repo'); + fs.cpSync(FIXTURE_SRC, repoPath, { recursive: true }); + + spawnSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + spawnSync('git', ['add', '-A'], { cwd: repoPath, stdio: 'pipe' }); + spawnSync('git', ['commit', '-m', 'initial commit'], { + cwd: repoPath, + stdio: 'pipe', + env: { + ...process.env, + GIT_AUTHOR_NAME: 'test', + GIT_AUTHOR_EMAIL: 'test@test', + GIT_COMMITTER_NAME: 'test', + GIT_COMMITTER_EMAIL: 'test@test', + }, + }); +}); + +afterAll(() => { + if (tmpParent) cleanupTempDirSync(tmpParent); + if (suiteGitnexusHome) cleanupTempDirSync(suiteGitnexusHome); +}); + +describe('analyze WAL auto-checkpoint rename failure (real lbug, no mocks)', () => { + it('surfaces the --wal-checkpoint-threshold recovery hint when the rename target is blocked', () => { + // Plant a non-empty directory at the path Ladybug's auto-checkpoint + // will try to rename `.wal` over. `fs.rename` cannot overwrite a + // non-empty directory, and the adapter's orphan-sidecar cleanup uses + // `fs.unlink` (which fails on directories) — so the blocker persists + // through `doInitLbug` and trips the very first auto-checkpoint that + // a `GITNEXUS_WAL_CHECKPOINT_THRESHOLD=1` setting forces. + const storageDir = path.join(repoPath, '.gitnexus'); + fs.mkdirSync(storageDir, { recursive: true }); + const blockerDir = path.join(storageDir, 'lbug.wal.checkpoint'); + fs.mkdirSync(blockerDir, { recursive: true }); + fs.writeFileSync(path.join(blockerDir, 'blocker'), 'cannot-be-renamed-over'); + + const result = spawnSync( + process.execPath, + ['--import', tsxImportUrl, cliEntry, 'analyze', '--skip-skills'], + { + cwd: repoPath, + encoding: 'utf8', + // Generous timeout: the test does real CSV/COPY work before the + // first failing checkpoint, and CI runners are slow. + timeout: process.env.CI ? 120_000 : 60_000, + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + GITNEXUS_HOME: suiteGitnexusHome, + // Skip ensureHeap re-exec (which drops the tsx loader). + NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), + // Tiny threshold forces auto-checkpoint on every write so the + // first write into the WAL trips the planted rename blocker. + GITNEXUS_WAL_CHECKPOINT_THRESHOLD: '1', + CI: '1', + }, + }, + ); + + const combined = `${result.stderr}\n${result.stdout}`; + + // The CLI must exit non-zero. status === null means the timeout fired + // without a clean exit — also a failure for this assertion. + expect(result.status === null ? 'timeout' : result.status).not.toBe(0); + + // Recovery hint must reference the CLI flag and the recommended + // 64 MiB threshold (67_108_864 bytes). Both come from the + // RECOMMENDED_WAL_CHECKPOINT_THRESHOLD constant in analyze.ts; keep + // those values in sync with this assertion if the constant changes. + expect(combined).toContain('gitnexus analyze --wal-checkpoint-threshold'); + expect(combined).toContain('67108864'); + // The env-var route should be advertised alongside the flag. + expect(combined).toContain('GITNEXUS_WAL_CHECKPOINT_THRESHOLD'); + }, 180_000); +}); diff --git a/gitnexus/test/unit/analyze-lbug-checkpoint-threshold.test.ts b/gitnexus/test/unit/analyze-lbug-checkpoint-threshold.test.ts new file mode 100644 index 000000000..de55d4e71 --- /dev/null +++ b/gitnexus/test/unit/analyze-lbug-checkpoint-threshold.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const runFullAnalysisMock = vi.fn(); + +vi.mock('../../src/core/run-analyze.js', () => ({ + runFullAnalysis: runFullAnalysisMock, +})); + +vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ + closeLbug: vi.fn(async () => undefined), +})); + +vi.mock('../../src/storage/repo-manager.js', () => ({ + getStoragePaths: vi.fn(() => ({ storagePath: '.gitnexus', lbugPath: '.gitnexus/lbug' })), + getGlobalRegistryPath: vi.fn(() => 'registry.json'), + RegistryNameCollisionError: class RegistryNameCollisionError extends Error {}, + AnalysisNotFinalizedError: class AnalysisNotFinalizedError extends Error {}, + assertAnalysisFinalized: vi.fn(async () => undefined), +})); + +vi.mock('../../src/storage/git.js', () => ({ + getGitRoot: vi.fn(() => '/repo'), + hasGitDir: vi.fn(() => true), +})); + +vi.mock('../../src/core/ingestion/utils/max-file-size.js', () => ({ + getMaxFileSizeBannerMessage: vi.fn(() => null), +})); + +describe('analyzeCommand --wal-checkpoint-threshold parsing', () => { + const ORIGINAL_NODE_OPTIONS = process.env.NODE_OPTIONS; + const ORIGINAL_THRESHOLD = process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD; + + beforeEach(() => { + vi.resetModules(); + runFullAnalysisMock.mockReset(); + process.exitCode = undefined; + process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim(); + }); + + afterEach(() => { + if (ORIGINAL_NODE_OPTIONS === undefined) { + delete process.env.NODE_OPTIONS; + } else { + process.env.NODE_OPTIONS = ORIGINAL_NODE_OPTIONS; + } + if (ORIGINAL_THRESHOLD === undefined) { + delete process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD; + } else { + process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD = ORIGINAL_THRESHOLD; + } + }); + + it.each(['maybe', '-2', '1.5', ''])( + 'rejects invalid --wal-checkpoint-threshold value %s before analysis starts', + async (walCheckpointThreshold) => { + const { _captureLogger } = await import('../../src/core/logger.js'); + const cap = _captureLogger(); + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, { walCheckpointThreshold }); + + expect(process.exitCode).toBe(1); + expect(runFullAnalysisMock).not.toHaveBeenCalled(); + expect( + cap + .records() + .some((r) => r.msg === ' --wal-checkpoint-threshold must be an integer >= -1.\n'), + ).toBe(true); + cap.restore(); + }, + ); + + it.each([ + ['-1', '-1'], + ['0', '0'], + ['1024', '1024'], + ])( + 'sets GITNEXUS_WAL_CHECKPOINT_THRESHOLD=%s during runFullAnalysis and restores afterwards', + async (cliValue, expectedEnv) => { + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + let envAtCallTime: string | undefined; + runFullAnalysisMock.mockImplementation(async () => { + envAtCallTime = process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD; + return { + repoName: 'repo', + repoPath: '/repo', + stats: {}, + alreadyUpToDate: true, + }; + }); + + await analyzeCommand(undefined, { walCheckpointThreshold: cliValue }); + + expect(envAtCallTime).toBe(expectedEnv); + expect(process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD).toBe(ORIGINAL_THRESHOLD); + }, + ); +}); diff --git a/gitnexus/test/unit/analyze-wal-error.test.ts b/gitnexus/test/unit/analyze-wal-error.test.ts index 1b4ed5101..5264dfd3a 100644 --- a/gitnexus/test/unit/analyze-wal-error.test.ts +++ b/gitnexus/test/unit/analyze-wal-error.test.ts @@ -134,4 +134,82 @@ describe('analyzeCommand WAL corruption error handling', () => { cap.restore(); }); + + it('recommends --wal-checkpoint-threshold on Ladybug checkpoint I/O failures', async () => { + runFullAnalysisMock.mockRejectedValue( + new Error( + 'Runtime exception: IO exception: Error renaming file /repo/.gitnexus/lbug.wal to /repo/.gitnexus/lbug.wal.checkpoint. ErrorMessage: Permission denied', + ), + ); + + const { _captureLogger } = await import('../../src/core/logger.js'); + const cap = _captureLogger(); + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, {}); + + expect(process.exitCode).toBe(1); + const records = cap.records(); + expect( + records.some( + (r) => + typeof r.msg === 'string' && + r.msg.includes('gitnexus analyze --wal-checkpoint-threshold'), + ), + ).toBe(true); + + cap.restore(); + }); + + it('also recommends threshold on .wal.checkpoint remove failures', async () => { + runFullAnalysisMock.mockRejectedValue( + new Error( + 'Runtime exception: IO exception: Error removing directory or file /repo/.gitnexus/lbug.wal.checkpoint. Error Message: Permission denied', + ), + ); + + const { _captureLogger } = await import('../../src/core/logger.js'); + const cap = _captureLogger(); + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, {}); + + expect(process.exitCode).toBe(1); + const records = cap.records(); + expect( + records.some( + (r) => + typeof r.msg === 'string' && + r.msg.includes('gitnexus analyze --wal-checkpoint-threshold'), + ), + ).toBe(true); + + cap.restore(); + }); + + it('does not recommend threshold for non-checkpoint IO exceptions', async () => { + runFullAnalysisMock.mockRejectedValue( + new Error( + 'Runtime exception: IO exception: Error renaming file /repo/.gitnexus/data.tmp to /repo/.gitnexus/data.tmp.bak. ErrorMessage: Permission denied', + ), + ); + + const { _captureLogger } = await import('../../src/core/logger.js'); + const cap = _captureLogger(); + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, {}); + + expect(process.exitCode).toBe(1); + const records = cap.records(); + expect( + records.some( + (r) => + typeof r.msg === 'string' && + r.msg.includes('gitnexus analyze --wal-checkpoint-threshold'), + ), + ).toBe(false); + + cap.restore(); + }); }); diff --git a/gitnexus/test/unit/lbug-checkpoint.test.ts b/gitnexus/test/unit/lbug-checkpoint.test.ts index 5b9603997..6eac7e73a 100644 --- a/gitnexus/test/unit/lbug-checkpoint.test.ts +++ b/gitnexus/test/unit/lbug-checkpoint.test.ts @@ -53,9 +53,19 @@ describe('flushWAL / safeClose — consolidation guard (#1376)', () => { expect(closeLbugBlock).not.toMatch(/db\.close\(\)/); }); - it('flushWAL is the only place that issues conn.query(CHECKPOINT)', () => { + it('CHECKPOINT is issued only by flushWAL (best-effort) and tryFlushWAL (rethrows for the retry driver)', () => { const matches = adapterSource.match(/conn\.query\('CHECKPOINT'\)/g) ?? []; - expect(matches.length).toBe(1); + // Two authorized sites: `flushWAL` (swallows errors — used by + // `safeClose` and the server's best-effort flush) and `tryFlushWAL` + // (rethrows so the manual checkpoint driver in `wal-checkpoint-driver.ts` + // can apply its bounded retry). Any third occurrence is a regression — + // a CHECKPOINT outside these two helpers will be invisible to the + // retry/error policy. + expect(matches.length).toBe(2); + }); + + it('exports tryFlushWAL (CHECKPOINT-with-rethrow for the manual retry driver)', () => { + expect(adapterSource).toMatch(/export const tryFlushWAL/); }); it('flushWAL drains and closes the CHECKPOINT result before returning', () => { diff --git a/gitnexus/test/unit/lbug-config-wal.test.ts b/gitnexus/test/unit/lbug-config-wal.test.ts index 6baea3621..626e5d235 100644 --- a/gitnexus/test/unit/lbug-config-wal.test.ts +++ b/gitnexus/test/unit/lbug-config-wal.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; -import { createLbugDatabase, isWalCorruptionError } from '../../src/core/lbug/lbug-config.js'; +import { + createLbugDatabase, + isLbugCheckpointIoError, + isWalCorruptionError, +} from '../../src/core/lbug/lbug-config.js'; +import { _captureLogger } from '../../src/core/logger.js'; + +const DEFAULT_THRESHOLD = 64 * 1024 * 1024; describe('isWalCorruptionError', () => { it.each([ @@ -35,6 +42,101 @@ describe('isWalCorruptionError', () => { }); describe('createLbugDatabase WAL replay option', () => { + it('enables auto-checkpoint by default and uses default threshold (64 MiB)', () => { + const Database = vi.fn(function (this: any) {}); + const lbugModule = { Database } as any; + + createLbugDatabase(lbugModule, '/tmp/lbug-default'); + + expect(Database).toHaveBeenCalledWith( + '/tmp/lbug-default', + 0, + false, + false, + expect.any(Number), + true, + DEFAULT_THRESHOLD, + true, + true, + ); + }); + + it.each([ + ['0', 0], + ['1024', 1024], + ['-1', -1], + ['invalid', DEFAULT_THRESHOLD], + ['', DEFAULT_THRESHOLD], + ])('respects GITNEXUS_WAL_CHECKPOINT_THRESHOLD=%s', (raw, expectedCheckpointThreshold) => { + try { + vi.stubEnv('GITNEXUS_WAL_CHECKPOINT_THRESHOLD', raw); + const Database = vi.fn(function (this: any) {}); + const lbugModule = { Database } as any; + + createLbugDatabase(lbugModule, '/tmp/lbug-env'); + + expect(Database).toHaveBeenCalledWith( + '/tmp/lbug-env', + 0, + false, + false, + expect.any(Number), + true, + expectedCheckpointThreshold, + true, + true, + ); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('warns and falls back to default when GITNEXUS_WAL_CHECKPOINT_THRESHOLD is invalid', () => { + const cap = _captureLogger(); + try { + vi.stubEnv('GITNEXUS_WAL_CHECKPOINT_THRESHOLD', 'invalid'); + const Database = vi.fn(function (this: any) {}); + const lbugModule = { Database } as any; + + createLbugDatabase(lbugModule, '/tmp/lbug-invalid'); + + const warn = cap + .records() + .find( + (r) => + typeof r.msg === 'string' && + r.msg.includes('Ignoring invalid GITNEXUS_WAL_CHECKPOINT_THRESHOLD'), + ); + expect(warn).toBeDefined(); + } finally { + vi.unstubAllEnvs(); + cap.restore(); + } + }); + + it('does NOT warn when GITNEXUS_WAL_CHECKPOINT_THRESHOLD is empty (treated as unset)', () => { + const cap = _captureLogger(); + try { + vi.stubEnv('GITNEXUS_WAL_CHECKPOINT_THRESHOLD', ''); + const Database = vi.fn(function (this: any) {}); + const lbugModule = { Database } as any; + + createLbugDatabase(lbugModule, '/tmp/lbug-empty'); + + const warn = cap + .records() + .find( + (r) => + typeof r.msg === 'string' && + r.msg.includes('Ignoring invalid GITNEXUS_WAL_CHECKPOINT_THRESHOLD'), + ); + expect(warn).toBeUndefined(); + } finally { + vi.unstubAllEnvs(); + cap.restore(); + } + }); + it('passes throwOnWalReplayFailure and checksum constructor args explicitly', () => { const Database = vi.fn(function (this: any) {}); const lbugModule = { Database } as any; @@ -51,9 +153,43 @@ describe('createLbugDatabase WAL replay option', () => { true, expect.any(Number), true, - -1, + DEFAULT_THRESHOLD, false, true, ); }); }); + +// ─── Finding 8: strict + permissive checkpoint IO matchers ───────────────── +describe('isLbugCheckpointIoError', () => { + it.each([ + [ + 'native rename failure (v0.16.x exact)', + 'Runtime exception: IO exception: Error renaming file /repo/.gitnexus/lbug.wal to /repo/.gitnexus/lbug.wal.checkpoint. ErrorMessage: Permission denied', + ], + [ + 'native remove failure (v0.16.x exact)', + 'Runtime exception: IO exception: Error removing directory or file /repo/.gitnexus/lbug.wal.checkpoint. Error Message: Permission denied', + ], + ])('matches strict %s', (_label, msg) => { + expect(isLbugCheckpointIoError(msg)).toBe(true); + expect(isLbugCheckpointIoError(new Error(msg))).toBe(true); + }); + + it('matches permissive fallback for hypothetical message drift', () => { + // Permissive matcher accepts any IO-exception-shaped message mentioning .wal.checkpoint. + const drift = + 'Some new wrapper preamble :: IO exception when finalizing /repo/.gitnexus/lbug.wal.checkpoint'; + expect(isLbugCheckpointIoError(drift)).toBe(true); + }); + + it('does NOT match unrelated IO errors', () => { + expect( + isLbugCheckpointIoError( + 'Runtime exception: IO exception: Error renaming file /repo/data.tmp to /repo/data.tmp.bak', + ), + ).toBe(false); + expect(isLbugCheckpointIoError('Some other error')).toBe(false); + expect(isLbugCheckpointIoError(undefined)).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/wal-checkpoint-driver.test.ts b/gitnexus/test/unit/wal-checkpoint-driver.test.ts new file mode 100644 index 000000000..2079c74dc --- /dev/null +++ b/gitnexus/test/unit/wal-checkpoint-driver.test.ts @@ -0,0 +1,159 @@ +/** + * Unit tests for the manual WAL checkpoint driver (#1741 follow-up). + * + * The driver wraps a CHECKPOINT call in a bounded retry that fires only + * on `isLbugCheckpointIoError` shapes. These tests inject a fake + * `checkpointFn`, fake `sleepFn`, and fake `randomFn` to exercise the + * retry policy deterministically without touching a real LadybugDB. + * + * Integration-level coverage that the driver actually runs against a + * native engine lives in `test/integration/analyze-wal-checkpoint-failure.test.ts`. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + isManualCheckpointEnabled, + runCheckpointWithRetry, + startWalCheckpointDriver, +} from '../../src/core/lbug/wal-checkpoint-driver.js'; + +const makeCheckpointError = () => + // Matches the strict rename matcher in lbug-config.ts. + new Error( + 'Runtime exception: IO exception: Error renaming file /tmp/lbug.wal to /tmp/lbug.wal.checkpoint. ErrorMessage: Permission denied', + ); + +describe('runCheckpointWithRetry — retry policy', () => { + it('returns on first success with attempts=1 and no sleeps', async () => { + const checkpointFn = vi.fn().mockResolvedValue(true); + const sleepFn = vi.fn().mockResolvedValue(undefined); + const randomFn = vi.fn().mockReturnValue(0); + + const result = await runCheckpointWithRetry({ checkpointFn, sleepFn, randomFn }); + + expect(result.attempts).toBe(1); + expect(result.flushed).toBe(true); + expect(checkpointFn).toHaveBeenCalledTimes(1); + expect(sleepFn).toHaveBeenCalledTimes(0); + }); + + it('retries up to 3 times on checkpoint IO errors and succeeds on the final attempt', async () => { + const checkpointFn = vi + .fn() + .mockRejectedValueOnce(makeCheckpointError()) + .mockRejectedValueOnce(makeCheckpointError()) + .mockResolvedValueOnce(true); + const sleepFn = vi.fn().mockResolvedValue(undefined); + // Fixed random returns 0, so jitter contributes 0 ms and we can + // assert exact delays against BASE_DELAYS_MS = [50, 200, 500]. + const randomFn = vi.fn().mockReturnValue(0); + + const result = await runCheckpointWithRetry({ checkpointFn, sleepFn, randomFn }); + + expect(result.attempts).toBe(3); + expect(result.flushed).toBe(true); + expect(checkpointFn).toHaveBeenCalledTimes(3); + // Sleeps happen between attempts: after attempt 1 (50 ms) and after + // attempt 2 (200 ms). No sleep after the final attempt. + expect(sleepFn).toHaveBeenCalledTimes(2); + expect(sleepFn).toHaveBeenNthCalledWith(1, 50); + expect(sleepFn).toHaveBeenNthCalledWith(2, 200); + }); + + it('rethrows the last error after exhausting all retries on persistent IO failures', async () => { + const persistent = makeCheckpointError(); + const checkpointFn = vi.fn().mockRejectedValue(persistent); + const sleepFn = vi.fn().mockResolvedValue(undefined); + const randomFn = vi.fn().mockReturnValue(0); + + await expect(runCheckpointWithRetry({ checkpointFn, sleepFn, randomFn })).rejects.toBe( + persistent, + ); + + expect(checkpointFn).toHaveBeenCalledTimes(3); + // Two backoffs (50 ms, 200 ms) but no sleep after the final attempt. + expect(sleepFn).toHaveBeenCalledTimes(2); + expect(sleepFn).toHaveBeenNthCalledWith(1, 50); + expect(sleepFn).toHaveBeenNthCalledWith(2, 200); + }); + + it('does NOT retry non-checkpoint errors (e.g. WAL corruption surfaces immediately)', async () => { + const corruption = new Error('Runtime exception: Corrupted wal file.'); + const checkpointFn = vi.fn().mockRejectedValue(corruption); + const sleepFn = vi.fn().mockResolvedValue(undefined); + + await expect(runCheckpointWithRetry({ checkpointFn, sleepFn })).rejects.toBe(corruption); + + expect(checkpointFn).toHaveBeenCalledTimes(1); + expect(sleepFn).toHaveBeenCalledTimes(0); + }); + + it('jitter is bounded: 0 <= jitter < 50 ms regardless of random source', async () => { + const checkpointFn = vi + .fn() + .mockRejectedValueOnce(makeCheckpointError()) + .mockResolvedValueOnce(true); + const sleepFn = vi.fn().mockResolvedValue(undefined); + // Random returns 0.999... — jitter should still be <50 ms (floor). + const randomFn = vi.fn().mockReturnValue(0.9999); + + await runCheckpointWithRetry({ checkpointFn, sleepFn, randomFn }); + + expect(sleepFn).toHaveBeenCalledTimes(1); + const delay = sleepFn.mock.calls[0][0] as number; + expect(delay).toBe(50 + Math.floor(0.9999 * 50)); // == 99 + }); +}); + +describe('isManualCheckpointEnabled — env var parsing', () => { + let originalEnv: string | undefined; + beforeEach(() => { + originalEnv = process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT; + }); + afterEach(() => { + if (originalEnv === undefined) delete process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT; + else process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT = originalEnv; + }); + + it('defaults to enabled when the env var is unset', () => { + delete process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT; + expect(isManualCheckpointEnabled()).toBe(true); + }); + + it.each(['0', 'false', 'FALSE', 'off', 'no', ' 0 '])( + 'returns false for opt-out value %s', + (value) => { + process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT = value; + expect(isManualCheckpointEnabled()).toBe(false); + }, + ); + + it.each(['1', 'true', 'on', 'yes', ''])('returns true for non-opt-out value %s', (value) => { + process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT = value; + expect(isManualCheckpointEnabled()).toBe(true); + }); +}); + +describe('startWalCheckpointDriver — lifecycle', () => { + let originalEnv: string | undefined; + beforeEach(() => { + originalEnv = process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT; + }); + afterEach(() => { + if (originalEnv === undefined) delete process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT; + else process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT = originalEnv; + }); + + it('returns a no-op handle when manual checkpoint is disabled', async () => { + process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT = '0'; + const driver = startWalCheckpointDriver({ periodMs: 10 }); + // stop() must resolve cleanly even when no interval was scheduled. + await expect(driver.stop()).resolves.toBeUndefined(); + }); + + it('stop() is idempotent (second call resolves without throwing)', async () => { + process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT = '0'; + const driver = startWalCheckpointDriver({ periodMs: 10 }); + await driver.stop(); + await expect(driver.stop()).resolves.toBeUndefined(); + }); +});