From 4d010ff3f0cf313effa56b8d89f69192e117536a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:37:14 +0100 Subject: [PATCH 1/8] chore(deps)(deps-dev): bump @vitest/coverage-v8 in /gitnexus (#2042) Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 4.1.7 to 4.1.8. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.8/packages/coverage-v8) --- updated-dependencies: - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.8 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus/package-lock.json | 250 ++++++++++++++++++------------------- 1 file changed, 125 insertions(+), 125 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 810923e40..555c1ac21 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1307,9 +1307,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.132.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", - "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", "funding": { @@ -1387,9 +1387,9 @@ "license": "BSD-3-Clause" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", - "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -1404,9 +1404,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -1421,9 +1421,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", - "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -1438,9 +1438,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", - "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -1455,9 +1455,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", - "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -1472,9 +1472,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", - "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], @@ -1489,9 +1489,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", - "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], @@ -1506,9 +1506,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", - "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], @@ -1523,9 +1523,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", - "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], @@ -1540,9 +1540,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", - "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], @@ -1557,9 +1557,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", - "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], @@ -1574,9 +1574,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", - "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -1591,9 +1591,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", - "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ "wasm32" ], @@ -1621,9 +1621,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", - "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -1638,9 +1638,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", - "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -1847,14 +1847,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", - "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", + "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -1868,8 +1868,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.7", - "vitest": "4.1.7" + "@vitest/browser": "4.1.8", + "vitest": "4.1.8" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1878,16 +1878,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", - "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1896,13 +1896,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", - "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.7", + "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1923,9 +1923,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", - "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", "dev": true, "license": "MIT", "dependencies": { @@ -1936,13 +1936,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", - "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.8", "pathe": "^2.0.3" }, "funding": { @@ -1950,14 +1950,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", - "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1966,9 +1966,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", - "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", "dev": true, "license": "MIT", "funding": { @@ -1976,13 +1976,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", - "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", + "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4291,13 +4291,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", - "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.132.0", + "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -4307,21 +4307,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.2", - "@rolldown/binding-darwin-arm64": "1.0.2", - "@rolldown/binding-darwin-x64": "1.0.2", - "@rolldown/binding-freebsd-x64": "1.0.2", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", - "@rolldown/binding-linux-arm64-gnu": "1.0.2", - "@rolldown/binding-linux-arm64-musl": "1.0.2", - "@rolldown/binding-linux-ppc64-gnu": "1.0.2", - "@rolldown/binding-linux-s390x-gnu": "1.0.2", - "@rolldown/binding-linux-x64-gnu": "1.0.2", - "@rolldown/binding-linux-x64-musl": "1.0.2", - "@rolldown/binding-openharmony-arm64": "1.0.2", - "@rolldown/binding-wasm32-wasi": "1.0.2", - "@rolldown/binding-win32-arm64-msvc": "1.0.2", - "@rolldown/binding-win32-x64-msvc": "1.0.2" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/router": { @@ -4748,9 +4748,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -5167,17 +5167,17 @@ } }, "node_modules/vite": { - "version": "8.0.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", - "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", - "rolldown": "1.0.2", - "tinyglobby": "^0.2.16" + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -5245,19 +5245,19 @@ } }, "node_modules/vitest": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", - "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.7", - "@vitest/mocker": "4.1.7", - "@vitest/pretty-format": "4.1.7", - "@vitest/runner": "4.1.7", - "@vitest/snapshot": "4.1.7", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -5285,12 +5285,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.7", - "@vitest/browser-preview": "4.1.7", - "@vitest/browser-webdriverio": "4.1.7", - "@vitest/coverage-istanbul": "4.1.7", - "@vitest/coverage-v8": "4.1.7", - "@vitest/ui": "4.1.7", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" From 1812fcd1650e4157743e9319745161cab8433e6e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:37:24 +0100 Subject: [PATCH 2/8] chore(deps)(deps-dev): bump vitest from 4.1.7 to 4.1.8 in /gitnexus (#2043) Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.7 to 4.1.8. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.8/packages/vitest) --- updated-dependencies: - dependency-name: vitest dependency-version: 4.1.8 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From 54ea21a0bcc59ace341eef65f69e798ad079bc16 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:37:34 +0100 Subject: [PATCH 3/8] chore(deps)(deps): bump hono from 4.12.18 to 4.12.23 in /gitnexus (#2044) Bumps [hono](https://github.com/honojs/hono) from 4.12.18 to 4.12.23. - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.18...v4.12.23) --- updated-dependencies: - dependency-name: hono dependency-version: 4.12.23 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 555c1ac21..280d5b366 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -3106,9 +3106,9 @@ "license": "MIT" }, "node_modules/hono": { - "version": "4.12.18", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", - "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", "license": "MIT", "engines": { "node": ">=16.9.0" From 93e04b46d61d23594743e3628714f2c32cfed096 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Fri, 5 Jun 2026 04:23:21 +0100 Subject: [PATCH 4/8] fix(lbug): load FTS in Windows read pool (#2040) --- gitnexus/scripts/cross-platform-tests.ts | 2 +- gitnexus/src/core/lbug/pool-adapter.ts | 69 +-------- gitnexus/test/unit/lbug-pool-fts-load.test.ts | 62 ++++++++ .../test/unit/lbug-pool-win-fts-probe.test.ts | 133 ------------------ 4 files changed, 65 insertions(+), 201 deletions(-) create mode 100644 gitnexus/test/unit/lbug-pool-fts-load.test.ts delete mode 100644 gitnexus/test/unit/lbug-pool-win-fts-probe.test.ts diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index 364eac683..d332ba1d6 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -33,7 +33,7 @@ const PLATFORM_LOGIC = [ 'test/unit/resolve-invocation.test.ts', 'test/unit/platform-capabilities.test.ts', 'test/unit/worker-pool-windows-quarantine.test.ts', - 'test/unit/lbug-pool-win-fts-probe.test.ts', + 'test/unit/lbug-pool-fts-load.test.ts', 'test/unit/repo-manager.test.ts', 'test/unit/repo-manager-finalize-invariant.test.ts', 'test/unit/hooks.test.ts', diff --git a/gitnexus/src/core/lbug/pool-adapter.ts b/gitnexus/src/core/lbug/pool-adapter.ts index de478bb00..1b868d8c1 100644 --- a/gitnexus/src/core/lbug/pool-adapter.ts +++ b/gitnexus/src/core/lbug/pool-adapter.ts @@ -16,8 +16,6 @@ */ import fs from 'fs/promises'; -import os from 'os'; -import path from 'path'; import lbug from '@ladybugdb/core'; import { isReadOnlyDbError, loadFTSExtension } from './lbug-adapter.js'; import { @@ -36,42 +34,6 @@ import { statIfExists, } from './sidecar-recovery.js'; -/** - * Probe whether a Windows FTS extension binary is locally installed under - * ~/.lbdb/extension//win_amd64/fts/. Returns true on the first - * version dir whose libfts.lbug_extension exists on disk; false if the - * extension root is missing or contains no FTS binary. - * - * Gates the Windows skip-FTS-load guard below so we only skip the load - * when no extension binary is present. When at least one binary exists, - * loadFTSExtension is called with policy: 'load-only' — LadybugDB resolves - * LOAD EXTENSION fts to its version-specific path internally, and the - * ExtensionManager's tryLoad try/catch handles version-mismatch errors - * cleanly without ever attempting dlopen of a stale binary. The install - * path that the #1199/#1217 SIGSEGV documented is never exercised at - * query time. - * - * Exported so unit tests can exercise the probe directly against a - * temp-dir plus spied `os.homedir()` — see lbug-pool-win-fts-probe.test.ts. - */ -export async function hasLocalWinFtsExtension(): Promise { - try { - const extRoot = path.join(os.homedir(), '.lbdb', 'extension'); - const versions = await fs.readdir(extRoot); - for (const v of versions) { - try { - await fs.stat(path.join(extRoot, v, 'win_amd64', 'fts', 'libfts.lbug_extension')); - return true; - } catch { - /* missing for this version, keep looking */ - } - } - } catch { - /* no .lbdb/extension dir */ - } - return false; -} - /** Per-repo pool: one Database, many Connections */ interface PoolEntry { db: lbug.Database; @@ -612,27 +574,7 @@ async function doInitLbug(repoId: string, dbPath: string): Promise { // install; analyze owns extension installation. If LOAD fails, search // features degrade gracefully and the user-facing query path proceeds. if (!shared.ftsLoaded) { - // Windows guard: LOAD EXTENSION fts crashes with SIGSEGV on Windows during - // *install* — the @ladybugdb/core out-of-process installer hits an unhandled - // error path that signals SIGSEGV instead of throwing (see #1199, #1217). - // The previous unconditional skip was over-broad: it also disabled FTS on - // hosts where the binary was already on disk and only needed LOAD, leaving - // BM25 silently degraded with no error path (see #1690). - // - // Probe ~/.lbdb/extension/*/win_amd64/fts/ first. If any binary is on disk - // we run loadFTSExtension(..., 'load-only'); the install path is never - // exercised, and LadybugDB's version-specific resolution + ExtensionManager - // try/catch handle stale/zero-byte siblings cleanly (verified empirically - // on Win10 + Node 22.19 + gitnexus 1.6.5 + @ladybugdb/core 0.16.1). With - // no binary at all, we fall back to the upstream skip so install-time - // SIGSEGV continues to be avoided. - if (process.platform === 'win32') { - shared.ftsLoaded = (await hasLocalWinFtsExtension()) - ? await loadFTSExtension(available[0], { policy: 'load-only' }) - : true; - } else { - shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' }); - } + shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' }); } // Register pool entry only after all connections are pre-warmed and FTS is @@ -696,15 +638,8 @@ export async function initLbugWithDb( // Load FTS extension if not already loaded on this Database. // policy: 'load-only' — same contract as initLbug above; the read pool // must not block on a network install during query execution. - // Windows guard: same probe-then-load policy as doInitLbug above. if (!shared.ftsLoaded) { - if (process.platform === 'win32') { - shared.ftsLoaded = (await hasLocalWinFtsExtension()) - ? await loadFTSExtension(available[0], { policy: 'load-only' }) - : true; - } else { - shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' }); - } + shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' }); } pool.set(repoId, { diff --git a/gitnexus/test/unit/lbug-pool-fts-load.test.ts b/gitnexus/test/unit/lbug-pool-fts-load.test.ts new file mode 100644 index 000000000..d62735c27 --- /dev/null +++ b/gitnexus/test/unit/lbug-pool-fts-load.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { loadFTSExtensionMock } = vi.hoisted(() => ({ + loadFTSExtensionMock: vi.fn(), +})); + +vi.mock('@ladybugdb/core', () => ({ + default: { + Database: vi.fn(), + Connection: vi.fn(function (this: any) { + this.close = vi.fn().mockResolvedValue(undefined); + }), + }, +})); + +vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ + isReadOnlyDbError: vi.fn(() => false), + loadFTSExtension: loadFTSExtensionMock, +})); + +vi.mock('../../src/core/lbug/lbug-config.js', () => ({ + createLbugDatabase: vi.fn(), + toNativeSafePath: vi.fn((p: string) => p), + isWalCorruptionError: vi.fn(() => false), + WAL_RECOVERY_SUGGESTION: '', +})); + +const { closeLbug, initLbugWithDb } = await import('../../src/core/lbug/pool-adapter.js'); + +describe('read-pool FTS loading', () => { + afterEach(async () => { + await closeLbug().catch(() => {}); + loadFTSExtensionMock.mockReset(); + }); + + it('loads FTS with load-only policy and caches a successful load', async () => { + loadFTSExtensionMock.mockResolvedValue(true); + const db = {} as any; + + await initLbugWithDb('repo-a', db, '/tmp/shared-fts-db'); + await initLbugWithDb('repo-b', db, '/tmp/shared-fts-db'); + + expect(loadFTSExtensionMock).toHaveBeenCalledTimes(1); + expect(loadFTSExtensionMock).toHaveBeenCalledWith(expect.anything(), { policy: 'load-only' }); + }); + + it('does not fake a successful load when FTS is unavailable', async () => { + loadFTSExtensionMock.mockResolvedValue(false); + const db = {} as any; + + await initLbugWithDb('repo-a', db, '/tmp/shared-fts-db'); + await initLbugWithDb('repo-b', db, '/tmp/shared-fts-db'); + + expect(loadFTSExtensionMock).toHaveBeenCalledTimes(2); + expect(loadFTSExtensionMock).toHaveBeenNthCalledWith(1, expect.anything(), { + policy: 'load-only', + }); + expect(loadFTSExtensionMock).toHaveBeenNthCalledWith(2, expect.anything(), { + policy: 'load-only', + }); + }); +}); diff --git a/gitnexus/test/unit/lbug-pool-win-fts-probe.test.ts b/gitnexus/test/unit/lbug-pool-win-fts-probe.test.ts deleted file mode 100644 index 4a164307f..000000000 --- a/gitnexus/test/unit/lbug-pool-win-fts-probe.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Unit tests for the Windows FTS probe in pool-adapter.ts. - * - * Covers `hasLocalWinFtsExtension()` — the helper that gates the - * Windows-only skip of `loadFTSExtension` in `doInitLbug` and - * `initLbugWithDb`. Issue #1690 / PR #1692. - * - * The probe is exercised against a real temp filesystem with - * `os.homedir()` spied to point at the tempdir. This tests the - * actual fs surface (readdir/stat semantics, missing-dir behavior, - * zero-byte file handling) rather than mocking fs internals. - * - * The Windows-branch conditional in `doInitLbug` / `initLbugWithDb` - * is intentionally not unit-tested in isolation: those functions - * require a fully constructed `lbug.Database` + `Connection` pool - * and are exercised end-to-end by `test/integration/lbug-pool*.test.ts` - * on the `windows-latest` matrix. The conditional itself is a single - * expression — `(await hasLocalWinFtsExtension()) ? load : true` — - * whose correctness reduces to the probe being correctly tested here. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import os from 'os'; -import path from 'path'; -import fs from 'fs/promises'; - -// Stub out the LadybugDB native loader and its transitive importers so that -// importing pool-adapter.ts in this unit test does not pull in the .node binary -// (which is built by the postinstall script and is not always present in the -// dev install used for unit tests). -vi.mock('@ladybugdb/core', () => ({ - default: { Database: vi.fn(), Connection: vi.fn() }, -})); -vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ - isReadOnlyDbError: vi.fn(() => false), - loadFTSExtension: vi.fn(), -})); -vi.mock('../../src/core/lbug/lbug-config.js', () => ({ - createLbugDatabase: vi.fn(), - toNativeSafePath: vi.fn((p: string) => p), - isWalCorruptionError: vi.fn(() => false), - WAL_RECOVERY_SUGGESTION: '', -})); - -import { hasLocalWinFtsExtension } from '../../src/core/lbug/pool-adapter.js'; - -describe('hasLocalWinFtsExtension', () => { - let tmpHome: string; - - beforeEach(async () => { - tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-fts-probe-')); - vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); - }); - - afterEach(async () => { - vi.restoreAllMocks(); - await fs.rm(tmpHome, { recursive: true, force: true }); - }); - - it('returns false when ~/.lbdb/extension does not exist', async () => { - // tmpHome is empty; the probe should swallow the readdir ENOENT and return false. - await expect(hasLocalWinFtsExtension()).resolves.toBe(false); - }); - - it('returns false when ~/.lbdb/extension exists but has no version dirs', async () => { - await fs.mkdir(path.join(tmpHome, '.lbdb', 'extension'), { recursive: true }); - await expect(hasLocalWinFtsExtension()).resolves.toBe(false); - }); - - it('returns true when a single version dir contains the FTS binary', async () => { - const ftsDir = path.join(tmpHome, '.lbdb', 'extension', '0.16.0', 'win_amd64', 'fts'); - await fs.mkdir(ftsDir, { recursive: true }); - await fs.writeFile(path.join(ftsDir, 'libfts.lbug_extension'), Buffer.from('mock-binary')); - await expect(hasLocalWinFtsExtension()).resolves.toBe(true); - }); - - it('returns true when the binary is a zero-byte stub (LOAD failure handled downstream)', async () => { - // Empirically verified in #1690 thread: LadybugDB resolves LOAD EXTENSION fts to a - // version-specific path internally and the ExtensionManager's tryLoad try/catch - // catches the resulting load error cleanly. Probe is intentionally generous here; - // safety lives in the loader, not the probe. - const ftsDir = path.join(tmpHome, '.lbdb', 'extension', '0.16.0', 'win_amd64', 'fts'); - await fs.mkdir(ftsDir, { recursive: true }); - await fs.writeFile(path.join(ftsDir, 'libfts.lbug_extension'), ''); - await expect(hasLocalWinFtsExtension()).resolves.toBe(true); - }); - - it('returns true when multiple version dirs exist and only one carries the binary', async () => { - const versions = ['0.15.0', '0.16.0', '0.17.0']; - for (const v of versions) { - await fs.mkdir(path.join(tmpHome, '.lbdb', 'extension', v, 'win_amd64', 'fts'), { - recursive: true, - }); - } - // Only 0.16.0 has the binary; the probe should keep iterating past empty siblings. - await fs.writeFile( - path.join( - tmpHome, - '.lbdb', - 'extension', - '0.16.0', - 'win_amd64', - 'fts', - 'libfts.lbug_extension', - ), - Buffer.from('mock-binary'), - ); - await expect(hasLocalWinFtsExtension()).resolves.toBe(true); - }); - - it('returns false when version dirs exist but none contain the binary', async () => { - // Adversarial topology raised by #1690 review: tree exists (Nix store, Bazel - // sandbox seeding, corporate MDM-prepopulated user dirs) but the actual - // libfts.lbug_extension file is absent. Probe must distinguish file from dir. - const versions = ['0.15.0', '0.16.0', '0.17.0']; - for (const v of versions) { - await fs.mkdir(path.join(tmpHome, '.lbdb', 'extension', v, 'win_amd64', 'fts'), { - recursive: true, - }); - } - await expect(hasLocalWinFtsExtension()).resolves.toBe(false); - }); - - it('returns false when fs.readdir throws (e.g. permission denied on the extension root)', async () => { - // Cover the outer try/catch — any fs error walking the extension root is - // treated as "no binary present", matching the upstream skip-guard intent. - const eaccess = Object.assign(new Error('EACCES: permission denied'), { - code: 'EACCES', - }) as NodeJS.ErrnoException; - vi.spyOn(fs, 'readdir').mockRejectedValue(eaccess); - await expect(hasLocalWinFtsExtension()).resolves.toBe(false); - }); -}); From 281ce2600c4c13ebdf43b697c90e2f0481d0ee76 Mon Sep 17 00:00:00 2001 From: Abhinav Pandey Date: Fri, 5 Jun 2026 11:09:12 +0530 Subject: [PATCH 5/8] fix(java): close parsing-layer coverage gaps F35/F38/F41 (#1928) (#2045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(java): close parsing-layer coverage gaps F35/F38/F41 (#1928) Registry-primary scope-resolution path (the live one post-#942/#943): - F35 [HIGH]: qualified / qualified-generic constructor calls. `new pkg.Foo()` parses as a `scoped_type_identifier` that the query bound only as `@reference.call.constructor.qualified` with no `@reference.name`, so the scope extractor fell back to the whole-expression anchor and the reference name became the raw `new pkg.Foo()` text (never resolved). Bind the simple -name tail (end-anchored last child) and add an arm for the previously uncaptured `new pkg.Box()` (qualified + generic) shape. - F38 [MEDIUM]: `super(...)` / `this(...)` explicit constructor invocations, modeled as `explicit_constructor_invocation` and never matched by the scope query, dropped the chained-constructor CALLS edges. Synthesize them with the target resolved structurally (this -> enclosing type name; super -> superclass tail via the shared javaBaseLookupNameNode, skipping implicit Object) plus arity for overload disambiguation. - F41 [LOW]: interpretJavaTypeBinding stripped the qualifier before generics, so a qualified generic type arg (`Map`) was cut inside the generic into `User>`. Strip generics first, then the qualifier; make the erasure fallback qualifier-tolerant. F36/F37 already landed upstream (#1940/#1956); F39/F40 are legacy-bank remnants that are no longer consumed (legacy @import skipped in parse-worker; legacy @call never read in parse-impl) so they are intentionally left untouched. Tests: low-level capture unit tests (constructor shapes incl. double-match guard; super/this/enum/implicit-Object), interpretJavaTypeBinding unit tests (qualified generic args + the corruption case), and end-to-end resolver tests with new fixtures asserting the CALLS edges resolve to the correct constructors. Co-authored-by: Cursor * fix(scope-resolution): register Constructor overload keys so this()/super() chains don't self-loop (#1928 F38 review) Review of #2045 caught two gaps; both confirmed by reproduction. P2 — F38 this() emitted a self-loop. On the java-explicit-constructor fixture, Child(int){ this(); } produced CALLS Child()#0 -> Child()#0 instead of Child(int)#1 -> Child()#0. Root cause is the language-agnostic graph-bridge: the parse phase mints distinct Constructor nodes (Child#0, Child#1) carrying parameterTypes, but node-lookup.ts registered the parameter-types / shape overload keys only for Function/Method, never Constructor, so both ctors collapsed onto the first-wins qualified/simple key and the caller Child(int) resolved to Child#0 (the this() target). Extend the overload keys to Constructor in both node-lookup.ts (registration) and ids.ts (lookup) via a shared isOverloadableCallable predicate. Verified the edge now connects distinct nodes (Child#1 -> Child#0); super(1)->Base#1 still correct. No cross-language regressions (the 9 worker-path failures reproduce identically on clean HEAD). Also harden the integration test: it matched the this() edge on name only, which a self-loop satisfies; now assert the endpoints are DISTINCT constructors. P3 — F41 order-regression guard was inert (List> normalizes to List under both strip orders). Add List> -> List, which is corrupted to Foo> under the old order and only correct generics-first. Co-authored-by: Cursor * fix(java): update fingerprint and add notes for constructor query captures in baselines.json Updated the fingerprint for the Java section and added detailed notes regarding the enhancements in constructor query captures, including qualified and qualified-generic constructor queries. This change reflects ongoing improvements in the parsing layer coverage and fixture updates. --------- Co-authored-by: Cursor Co-authored-by: Gergő Magyar --- gitnexus/bench/scope-capture/baselines.json | 5 +- .../core/ingestion/languages/java/captures.ts | 99 ++++++++++++++- .../ingestion/languages/java/interpret.ts | 18 ++- .../core/ingestion/languages/java/query.ts | 17 ++- .../scope-resolution/graph-bridge/ids.ts | 19 ++- .../graph-bridge/node-lookup.ts | 20 ++- .../models/Base.java | 5 + .../models/Child.java | 11 ++ .../java-qualified-constructor/App.java | 6 + .../java-qualified-constructor/pkg/Box.java | 5 + .../java-qualified-constructor/pkg/Foo.java | 7 ++ .../integration/resolvers/java-1928.test.ts | 76 +++++++++++ .../java/java-captures.test.ts | 119 ++++++++++++++++++ .../java/java-interpret.test.ts | 86 +++++++++++++ 14 files changed, 474 insertions(+), 19 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/java-explicit-constructor/models/Base.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-explicit-constructor/models/Child.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-qualified-constructor/App.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-qualified-constructor/pkg/Box.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-qualified-constructor/pkg/Foo.java create mode 100644 gitnexus/test/integration/resolvers/java-1928.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/java/java-captures.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/java/java-interpret.test.ts diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 85b2efd58..e896c2383 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -57,9 +57,10 @@ "_rebaselined": "#1970 review + tri-review follow-ups: constructor-call retag, cascade calls, built-in suppression, enum scope, #1926 F24/F25, named-ctor dedup (crash fix), container-name binding suppression; heritage file-affinity resolution. Fixtures: member-call-contexts, constructor-body, named-constructor-body, heritage-name-collision, construct-cascade." }, "java": { - "fingerprint": "d5cf68e9faf92fffd928c1ee6e584c72cc65918d1f1b5078abb3bfe09ac699bf", + "fingerprint": "9b29cafe32873b4902bda311bd089ffc04efe08f13557b966d29544be514080a", "scaling_budget": 1.5, - "_rebaselined": "#1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged." + "_rebaselined": "#1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", + "_note": "#1928 / #2045: F35 adds qualified + qualified-generic constructor query captures (`new pkg.Foo()`, `new a.b.Foo()`, `new pkg.Box()`); F38 synthesizes `@reference.call.constructor` on `super(...)`/`this(...)` explicit_constructor_invocation nodes; F41 generic-aware stripQualifier in interpret (type-binding normalization). + java-qualified-constructor and java-explicit-constructor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.06)." }, "typescript": { "fingerprint": "3f44a4a6892698df2d145c8ff2812c3b318807648983c88aca28fbd694f172f9", diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index 85c5a9106..cd4463459 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -216,7 +216,104 @@ export function emitJavaScopeCaptures( out.push(grouped); } - return [...resolveVarTypeBindings(out), ...synthesizeJavaInheritanceReferences(tree.rootNode)]; + return [ + ...resolveVarTypeBindings(out), + ...synthesizeJavaInheritanceReferences(tree.rootNode), + ...synthesizeJavaExplicitConstructorReferences(tree.rootNode), + ]; +} + +/** + * Synthesize `@reference.call.constructor` captures for explicit constructor + * invocations — `super(...)` and `this(...)` (F38 #1928). tree-sitter-java + * models these as `explicit_constructor_invocation` nodes, which the scope + * query does not match, so the chained-constructor CALLS edges (subclass ctor → + * superclass ctor; ctor → sibling overload) were silently dropped. + * + * The grammar gives no constructor *name* at the call site (the child is a bare + * `(super)` / `(this)` token), so the target name is resolved structurally: + * - `this(...)` → the enclosing type's own simple name (constructor symbols + * are keyed by the declaring class name). + * - `super(...)` → the enclosing class's superclass simple-name tail (reusing + * `javaBaseLookupNameNode` so qualified/generic supers reduce + * to the bare class name, matching the EXTENDS synth). An + * implicit `Object` super (no `superclass` field) has no + * in-graph symbol, so it is skipped rather than emitting a + * dangling reference. + * Arity is attached so overloaded constructors disambiguate downstream, mirroring + * the call-site arity synthesized for `new X(...)`. + */ +function synthesizeJavaExplicitConstructorReferences(root: SyntaxNode): CaptureMatch[] { + const out: CaptureMatch[] = []; + const stack: SyntaxNode[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.type === 'explicit_constructor_invocation') { + emitJavaExplicitConstructorRef(out, node); + } + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child !== null) stack.push(child); + } + } + return out; +} + +const TYPE_DECL_NODE_TYPES = new Set([ + 'class_declaration', + 'enum_declaration', + 'record_declaration', +]); + +function emitJavaExplicitConstructorRef(out: CaptureMatch[], node: SyntaxNode): void { + const ctor = node.childForFieldName('constructor'); + if (ctor === null) return; + + const enclosingType = findEnclosingTypeDeclaration(node); + if (enclosingType === null) return; + + let targetNameNode: SyntaxNode | null = null; + if (ctor.type === 'this') { + targetNameNode = enclosingType.childForFieldName('name'); + } else if (ctor.type === 'super') { + // Only class_declaration carries a `superclass` field; enum/record cannot + // declare an explicit superclass, so `super(...)` there has no resolvable + // target symbol. + const superclass = enclosingType.childForFieldName('superclass'); + if (superclass === null) return; + for (const base of superclass.namedChildren) { + if (base === null) continue; + const nameNode = javaBaseLookupNameNode(base); + if (nameNode !== null) { + targetNameNode = nameNode; + break; + } + } + } + if (targetNameNode === null) return; + + const argList = node.childForFieldName('arguments'); + const args = + argList === null + ? [] + : argList.namedChildren.filter( + (c) => c !== null && c.type !== 'block_comment' && c.type !== 'line_comment', + ); + + out.push({ + '@reference.call.constructor': nodeToCapture('@reference.call.constructor', node), + '@reference.name': nodeToCapture('@reference.name', targetNameNode), + '@reference.arity': syntheticCapture('@reference.arity', node, String(args.length)), + }); +} + +function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null { + let cur: SyntaxNode | null = node.parent; + while (cur !== null) { + if (TYPE_DECL_NODE_TYPES.has(cur.type)) return cur; + cur = cur.parent; + } + return null; } /** diff --git a/gitnexus/src/core/ingestion/languages/java/interpret.ts b/gitnexus/src/core/ingestion/languages/java/interpret.ts index 87da580b6..09c911aa9 100644 --- a/gitnexus/src/core/ingestion/languages/java/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/java/interpret.ts @@ -74,10 +74,14 @@ export function interpretJavaTypeBinding(captures: CaptureMatch): ParsedTypeBind const typeCap = captures['@type-binding.type']; if (nameCap === undefined || typeCap === undefined) return null; - // Strip qualifier first so that `com.example.BaseModel` becomes - // `BaseModel` before stripGeneric — the JVM-erasure fallback pattern - // requires an unqualified identifier at the start of the string. - const rawType = stripGeneric(stripQualifier(typeCap.text.trim())); + // Strip generics BEFORE the qualifier (F41 #1928). Stripping the qualifier + // first uses `lastIndexOf('.')`, which for a qualified *type argument* + // (`Map`) cuts inside the generic and yields a + // corrupted `User>`. Unwrapping generics first reduces the string to a single + // (possibly qualified) class name, then the qualifier strip leaves the bare + // simple name. `stripGeneric`'s erasure fallback is qualifier-tolerant so a + // qualified generic base (`com.example.BaseModel`) still reduces correctly. + const rawType = stripQualifier(stripGeneric(typeCap.text.trim())); // Skip `var` — tree-sitter-java parses `var` as type_identifier with // text "var". When used without a constructor initializer, there's no @@ -127,8 +131,10 @@ function stripGeneric(text: string): string { // `BaseModel` → `BaseModel`, `Builder` → `Builder`. // This mirrors JVM type erasure — the raw class name is the resolvable symbol. // The pattern matches up to the first `<` to handle nested generics safely - // (e.g. `BaseModel>` → `BaseModel`). - const fallback = text.match(/^([A-Za-z_$][A-Za-z0-9_$]*)<.+>$/s); + // (e.g. `BaseModel>` → `BaseModel`). The base is allowed to be + // qualified (`com.example.BaseModel` → `com.example.BaseModel`) since the + // caller strips the qualifier afterwards (F41 #1928). + const fallback = text.match(/^((?:[A-Za-z_$][A-Za-z0-9_$]*\.)*[A-Za-z_$][A-Za-z0-9_$]*)<.+>$/s); if (fallback !== null) return fallback[1].trim(); return text; diff --git a/gitnexus/src/core/ingestion/languages/java/query.ts b/gitnexus/src/core/ingestion/languages/java/query.ts index e1e581ad5..4821e9703 100644 --- a/gitnexus/src/core/ingestion/languages/java/query.ts +++ b/gitnexus/src/core/ingestion/languages/java/query.ts @@ -214,8 +214,23 @@ const JAVA_SCOPE_QUERY = ` type: (generic_type (type_identifier) @reference.name)) @reference.call.constructor +;; References — qualified constructor calls: new pkg.Foo(), new a.b.Foo() (F35 #1928) +;; tree-sitter-java parses \`pkg.Foo\` as a scoped_type_identifier whose final +;; child is the simple type. Bind that tail as @reference.name (trailing \`.\` +;; anchor = last child) so resolution targets \`Foo\`, not the raw \`pkg.Foo\` text. +;; Mirrors the TS/JS new-expression qualified-constructor capture. (object_creation_expression - type: (scoped_type_identifier) @reference.call.constructor.qualified) @reference.call.constructor + type: (scoped_type_identifier + (type_identifier) @reference.name .) @reference.call.constructor.qualified) @reference.call.constructor + +;; References — qualified + generic constructor calls: new pkg.Box() (F35 #1928) +;; The base is a generic_type whose first child is a scoped_type_identifier, so +;; neither the simple-generic nor the plain-scoped arm above matches it. Bind the +;; scoped tail as @reference.name. +(object_creation_expression + type: (generic_type + (scoped_type_identifier + (type_identifier) @reference.name .) @reference.call.constructor.qualified)) @reference.call.constructor ;; References — method references: User::getName, obj::method (method_reference diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts index 92f3f2d8b..441bb3e4b 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -54,6 +54,16 @@ function isCallerAnchorLabel(label: NodeLabel): boolean { ); } +/** + * Callables whose same-name overloads occupy distinct graph nodes keyed by + * parameter types / shape. Must mirror `isOverloadableCallable` in + * `node-lookup.ts` so registration and lookup agree (Constructor included — + * #1928 F38). + */ +function isOverloadableCallable(label: NodeLabel | undefined): boolean { + return label === 'Function' || label === 'Method' || label === 'Constructor'; +} + /** * Look up a `SymbolDefinition` in the graph node lookup. * @@ -107,7 +117,7 @@ export function resolveDefGraphId( if (cHit !== undefined) return cHit; } if ( - (def.type === 'Function' || def.type === 'Method') && + isOverloadableCallable(def.type) && def.parameterTypes !== undefined && def.parameterTypeClasses !== undefined ) { @@ -120,9 +130,12 @@ export function resolveDefGraphId( } // Overload disambiguation: when the def carries parameter types, // try the parameter-typed key first so same-name same-arity - // overloads route to their distinct graph nodes. + // overloads route to their distinct graph nodes. Constructors are + // included so a `this(int)`/`super(int)` chain or `new Foo(int)` + // resolves to the matching ctor overload instead of first-wins + // collapsing onto another `Foo` ctor (a self-loop) — #1928 F38. if ( - (def.type === 'Function' || def.type === 'Method') && + isOverloadableCallable(def.type) && def.parameterTypes !== undefined && def.parameterTypes.length > 0 ) { diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts index 80bc5dedd..d7da8a56f 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts @@ -99,11 +99,7 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { // a parameter-types-suffixed key so resolveDefGraphId can find // the right overload by matching its def's parameterTypes. const pTypes = (props as { parameterTypes?: readonly string[] }).parameterTypes; - if ( - pTypes !== undefined && - pTypes.length > 0 && - (node.label === 'Function' || node.label === 'Method') - ) { + if (pTypes !== undefined && pTypes.length > 0 && isOverloadableCallable(node.label)) { const pKey = qualifiedKey( props.filePath, node.label, @@ -115,7 +111,7 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { const pClasses = (props as { parameterTypeClasses?: readonly ParameterTypeClass[] }) .parameterTypeClasses; const shapeTag = parameterShapeIdTag(pTypes, pClasses); - if (shapeTag !== '' && (node.label === 'Function' || node.label === 'Method')) { + if (shapeTag !== '' && isOverloadableCallable(node.label)) { const shapeKey = qualifiedKey(props.filePath, node.label, `${keyQualified}${shapeTag}`); if (!lookup.has(shapeKey)) lookup.set(shapeKey, node.id); } @@ -162,6 +158,18 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { return lookup; } +/** + * Callables whose same-name overloads must route to distinct graph nodes via + * the parameter-types / shape key. Constructors belong here too: a class with + * `Foo()` and `Foo(int)` mints distinct `#0`/`#1` Constructor nodes, and a + * `this(...)`/`super(...)` edge (or any `new Foo(args)`) must reach the right + * one. Without the overload key both ctor nodes collapse onto the first-wins + * qualified/simple key, turning a `this()` chain into a self-loop (#1928 F38). + */ +function isOverloadableCallable(label: NodeLabel): boolean { + return label === 'Function' || label === 'Method' || label === 'Constructor'; +} + export function isLinkableLabel(label: NodeLabel): boolean { return ( label === 'Function' || diff --git a/gitnexus/test/fixtures/lang-resolution/java-explicit-constructor/models/Base.java b/gitnexus/test/fixtures/lang-resolution/java-explicit-constructor/models/Base.java new file mode 100644 index 000000000..2c9334f2e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-explicit-constructor/models/Base.java @@ -0,0 +1,5 @@ +package models; + +public class Base { + public Base(int x) {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-explicit-constructor/models/Child.java b/gitnexus/test/fixtures/lang-resolution/java-explicit-constructor/models/Child.java new file mode 100644 index 000000000..14de7ea12 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-explicit-constructor/models/Child.java @@ -0,0 +1,11 @@ +package models; + +public class Child extends Base { + public Child() { + super(1); + } + + public Child(int x) { + this(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-qualified-constructor/App.java b/gitnexus/test/fixtures/lang-resolution/java-qualified-constructor/App.java new file mode 100644 index 000000000..c6147a221 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-qualified-constructor/App.java @@ -0,0 +1,6 @@ +public class App { + public void make() { + pkg.Foo f = new pkg.Foo(); + new pkg.Box(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-qualified-constructor/pkg/Box.java b/gitnexus/test/fixtures/lang-resolution/java-qualified-constructor/pkg/Box.java new file mode 100644 index 000000000..05c940e71 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-qualified-constructor/pkg/Box.java @@ -0,0 +1,5 @@ +package pkg; + +public class Box { + public Box() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-qualified-constructor/pkg/Foo.java b/gitnexus/test/fixtures/lang-resolution/java-qualified-constructor/pkg/Foo.java new file mode 100644 index 000000000..87bcc7cd0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-qualified-constructor/pkg/Foo.java @@ -0,0 +1,7 @@ +package pkg; + +public class Foo { + public Foo() {} + + public void run() {} +} diff --git a/gitnexus/test/integration/resolvers/java-1928.test.ts b/gitnexus/test/integration/resolvers/java-1928.test.ts new file mode 100644 index 000000000..ce739cc42 --- /dev/null +++ b/gitnexus/test/integration/resolvers/java-1928.test.ts @@ -0,0 +1,76 @@ +/** + * Java parsing-layer coverage gaps (#1928) — end-to-end resolution. + * + * - F35: qualified / qualified-generic constructor calls (`new pkg.Foo()`, + * `new pkg.Box()`) resolve to the target constructor instead of + * dropping the edge on a corrupted `pkg.Foo` reference name. + * - F38: `super(...)` / `this(...)` explicit constructor invocations emit CALLS + * edges to the superclass / sibling constructor. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js'; + +describe('Java qualified constructor resolution (F35 #1928)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'java-qualified-constructor'), () => {}); + }, 60000); + + it('resolves `new pkg.Foo()` to the Foo constructor', () => { + const calls = getRelationships(result, 'CALLS'); + const fooCtor = calls.find((c) => c.target === 'Foo' && c.source === 'make'); + expect(fooCtor).toBeDefined(); + expect(fooCtor!.targetLabel).toBe('Constructor'); + expect(fooCtor!.targetFilePath).toBe('pkg/Foo.java'); + }); + + it('resolves `new pkg.Box()` to the Box constructor', () => { + const calls = getRelationships(result, 'CALLS'); + const boxCtor = calls.find((c) => c.target === 'Box' && c.source === 'make'); + expect(boxCtor).toBeDefined(); + expect(boxCtor!.targetLabel).toBe('Constructor'); + expect(boxCtor!.targetFilePath).toBe('pkg/Box.java'); + }); + + it('does not emit a CALLS edge to a corrupted `pkg.Foo` / `pkg.Box` name', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.some((c) => c.target === 'pkg.Foo' || c.target === 'pkg.Box')).toBe(false); + }); +}); + +describe('Java explicit constructor invocation resolution (F38 #1928)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'java-explicit-constructor'), () => {}); + }, 60000); + + it('resolves `super(1)` in Child() to the Base constructor', () => { + const calls = getRelationships(result, 'CALLS'); + const superCall = calls.find((c) => c.target === 'Base' && c.targetLabel === 'Constructor'); + expect(superCall).toBeDefined(); + expect(superCall!.source).toBe('Child'); + expect(superCall!.targetFilePath).toBe('models/Base.java'); + // Source is the arity-0 `Child()`, where `super(1)` lives. + expect(superCall!.rel.sourceId).toContain('Child.Child#0'); + expect(superCall!.rel.targetId).toContain('Base.Base#1'); + }); + + it('resolves `this()` in Child(int) to a DISTINCT Child constructor (no self-loop)', () => { + const calls = getRelationships(result, 'CALLS'); + const thisCall = calls.find( + (c) => c.target === 'Child' && c.targetLabel === 'Constructor' && c.source === 'Child', + ); + expect(thisCall).toBeDefined(); + expect(thisCall!.targetFilePath).toBe('models/Child.java'); + // The edge must connect DISTINCT constructors: the caller `Child(int)` (#1) + // chains to `Child()` (#0). A self-loop (`#0 → #0`) — the bug this PR's + // review caught (#1928 F38: ctor overload keys missing in node-lookup) — + // satisfies the name-only match above but must NOT pass here. + expect(thisCall!.rel.sourceId).not.toBe(thisCall!.rel.targetId); + expect(thisCall!.rel.sourceId).toContain('Child.Child#1'); + expect(thisCall!.rel.targetId).toContain('Child.Child#0'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts b/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts new file mode 100644 index 000000000..f4d5d0251 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts @@ -0,0 +1,119 @@ +/** + * Low-level coverage for the Java scope-captures orchestrator + * (`emitJavaScopeCaptures`), focused on the #1928 parsing-layer fixes: + * + * - F35: qualified / qualified-generic constructor calls bind the simple-name + * tail as @reference.name (not the raw `pkg.Foo` text). + * - F38: `super(...)` / `this(...)` explicit constructor invocations are + * captured as @reference.call.constructor references with arity. + * + * Runs against the installed tree-sitter-java grammar so it catches grammar + * drift before the integration parity gate. + */ + +import { describe, it, expect } from 'vitest'; +import { emitJavaScopeCaptures } from '../../../../src/core/ingestion/languages/java/captures.js'; + +function wrapExpr(expr: string): string { + return `class C { void m() { ${expr}; } }`; +} + +/** All constructor-call matches in `src`, as `{ name, qualified, arity }`. */ +function ctorRefs(src: string) { + return emitJavaScopeCaptures(src, 'C.java') + .filter((m) => m['@reference.call.constructor'] !== undefined) + .map((m) => ({ + name: m['@reference.name']?.text, + qualified: m['@reference.call.constructor.qualified']?.text, + arity: m['@reference.arity']?.text, + })); +} + +describe('emitJavaScopeCaptures — constructor reference names (F35 #1928)', () => { + it('binds the simple name for an unqualified `new User()`', () => { + const refs = ctorRefs(wrapExpr('new User()')); + expect(refs).toContainEqual({ name: 'User', qualified: undefined, arity: '0' }); + }); + + it('binds the simple-name tail for a qualified `new pkg.Foo()`', () => { + const refs = ctorRefs(wrapExpr('new pkg.Foo()')); + const foo = refs.find((r) => r.name === 'Foo'); + expect(foo).toBeDefined(); + expect(foo!.qualified).toBe('pkg.Foo'); + // The name must be the bare tail, never the raw scoped text. + expect(refs.some((r) => r.name === 'pkg.Foo')).toBe(false); + }); + + it('binds the simple-name tail for a deeply-nested `new a.b.Foo()`', () => { + const refs = ctorRefs(wrapExpr('new a.b.Foo()')); + const foo = refs.find((r) => r.name === 'Foo'); + expect(foo).toBeDefined(); + expect(foo!.qualified).toBe('a.b.Foo'); + expect(refs.some((r) => r.name === 'a' || r.name === 'b')).toBe(false); + }); + + it('binds the simple name for a simple-generic `new Box()`', () => { + const refs = ctorRefs(wrapExpr('new Box()')); + const box = refs.find((r) => r.name === 'Box'); + expect(box).toBeDefined(); + expect(box!.qualified).toBeUndefined(); + }); + + it('binds the simple-name tail for a qualified-generic `new pkg.Box()`', () => { + const refs = ctorRefs(wrapExpr('new pkg.Box()')); + const box = refs.find((r) => r.name === 'Box'); + expect(box).toBeDefined(); + expect(box!.qualified).toBe('pkg.Box'); + expect(refs.some((r) => r.name === 'pkg.Box' || r.name === 'String')).toBe(false); + }); + + it('carries the argument arity on a qualified constructor call', () => { + const refs = ctorRefs(wrapExpr('new pkg.Foo(1, 2, 3)')); + const foo = refs.find((r) => r.name === 'Foo'); + expect(foo!.arity).toBe('3'); + }); + + it('emits exactly one constructor reference per `new` expression', () => { + // Regression guard: the qualified + qualified-generic arms must not + // double-match the plain/generic arms. + expect(ctorRefs(wrapExpr('new pkg.Foo()')).length).toBe(1); + expect(ctorRefs(wrapExpr('new pkg.Box()')).length).toBe(1); + expect(ctorRefs(wrapExpr('new a.b.Foo()')).length).toBe(1); + }); +}); + +describe('emitJavaScopeCaptures — explicit constructor invocations (F38 #1928)', () => { + it('captures `super(...)` as a constructor ref to the superclass simple name', () => { + const src = 'class C extends pkg.Base { C() { super(1, 2); } }'; + const refs = ctorRefs(src); + const sup = refs.find((r) => r.name === 'Base'); + expect(sup).toBeDefined(); + expect(sup!.arity).toBe('2'); + }); + + it('reduces a generic superclass `super(...)` target to the bare name', () => { + const src = 'class C extends Box { C() { super(); } }'; + const refs = ctorRefs(src); + expect(refs.some((r) => r.name === 'Box' && r.arity === '0')).toBe(true); + }); + + it('captures `this(...)` as a constructor ref to the enclosing class name', () => { + const src = 'class C { C() { this(1); } C(int x) {} }'; + const refs = ctorRefs(src); + const self = refs.find((r) => r.name === 'C' && r.arity === '1'); + expect(self).toBeDefined(); + }); + + it('does NOT synthesize a super ref when there is no explicit superclass', () => { + // Implicit `Object` super — no in-graph symbol, so no reference is emitted. + const src = 'class C { C() { super(); } }'; + const refs = ctorRefs(src); + expect(refs.length).toBe(0); + }); + + it('captures `this(...)` inside an enum constructor', () => { + const src = 'enum E { A; E() { this(1); } E(int x) {} }'; + const refs = ctorRefs(src); + expect(refs.some((r) => r.name === 'E' && r.arity === '1')).toBe(true); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/java/java-interpret.test.ts b/gitnexus/test/unit/scope-resolution/java/java-interpret.test.ts new file mode 100644 index 000000000..989a94edd --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/java/java-interpret.test.ts @@ -0,0 +1,86 @@ +/** + * Coverage for `interpretJavaTypeBinding` type-name normalization, focused on + * F41 (#1928): generics must be stripped BEFORE the qualifier so a qualified + * generic *type argument* (`Map`) is not corrupted + * into `User>` by an early `lastIndexOf('.')` cut. + */ + +import { describe, it, expect } from 'vitest'; +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { interpretJavaTypeBinding } from '../../../../src/core/ingestion/languages/java/interpret.js'; + +const ZERO_RANGE = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 } as const; +const cap = (name: string, text: string): Capture => ({ name, text, range: ZERO_RANGE }); + +/** Build an annotation-source type binding (`Type name;`). */ +function binding(typeText: string, sourceTag = '@type-binding.annotation'): CaptureMatch { + return { + '@type-binding.name': cap('@type-binding.name', 'x'), + '@type-binding.type': cap('@type-binding.type', typeText), + [sourceTag]: cap(sourceTag, typeText), + }; +} + +/** The normalized `rawTypeName` for a given raw type string. */ +function raw(typeText: string): string | undefined { + return interpretJavaTypeBinding(binding(typeText))?.rawTypeName; +} + +describe('interpretJavaTypeBinding — type normalization (F41 #1928)', () => { + it('strips a qualifier from a plain qualified type', () => { + expect(raw('com.example.User')).toBe('User'); + }); + + it('strips generics from an unqualified generic base', () => { + expect(raw('BaseModel')).toBe('BaseModel'); + }); + + it('strips generics AND qualifier from a qualified generic base', () => { + expect(raw('com.example.BaseModel')).toBe('BaseModel'); + }); + + it('does not corrupt a qualified generic TYPE ARGUMENT (the F41 bug)', () => { + // Before the fix: stripQualifier ran first → `User>` (trailing bracket). + expect(raw('Map')).toBe('User'); + }); + + it('extracts the element type from a single-arg container with qualified arg', () => { + expect(raw('List')).toBe('User'); + }); + + it('extracts the element type from a qualified container', () => { + expect(raw('java.util.List')).toBe('User'); + }); + + it('extracts the element type from a simple container', () => { + expect(raw('List')).toBe('User'); + expect(raw('Optional')).toBe('User'); + }); + + it('extracts the value type from a two-arg map', () => { + expect(raw('Map')).toBe('User'); + }); + + it('passes through a plain simple type', () => { + expect(raw('User')).toBe('User'); + }); + + it('falls back to the raw class name for an unrecognized nested generic', () => { + // Nested generic args defeat the single/two-arg element extraction; the + // erasure fallback keeps the outer class name (pre-existing behavior). + expect(raw('List>')).toBe('List'); + }); + + it('keeps the outer class for a QUALIFIED nested generic element (guards the strip order)', () => { + // Unlike `List>` (no dot inside the args, so it yields + // `List` under both strip orders), this input has a qualified nested + // element: the OLD order (stripQualifier first) cut inside the generic and + // produced a corrupted `Foo>`; only generics-first yields `List`. + // This is the case that actually fails if the F41 reorder regresses. + expect(raw('List>')).toBe('List'); + }); + + it('returns null for a bare `var` with no concrete type', () => { + expect(interpretJavaTypeBinding(binding('var'))).toBeNull(); + }); +}); From 89b02286ad4dfc7d6391e2b9a19ad667968c63f2 Mon Sep 17 00:00:00 2001 From: Abhinav Pandey Date: Fri, 5 Jun 2026 11:34:57 +0530 Subject: [PATCH 6/8] fix(csharp): qualified/alias constructor names, : base/: this initializers, generic type-arg strip (#2046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(csharp): bind qualified constructor names, capture : base/: this, fix generic strip Mirrors the Java #1928 parsing-layer fixes for the C# scope-resolution path — the same three defect classes exist verbatim in C#: - Qualified / qualified-generic / alias-qualified constructor calls (`new Ns.Foo()`, `new A.B.Foo()`, `new Ns.Box()`, `new MyAlias::Foo()`, `new global::Foo()`) bound only `@reference.call.constructor.qualified` with no `@reference.name`, so the central extractor fell back to the whole-expression anchor and the reference name became the raw `new Ns.Foo()` text (never resolved). Derive the simple-name tail via the existing `terminalTypeNameNode` helper (handles qualified_name, generic tail, and alias_qualified_name), and add a query arm for the top-level `alias_qualified_name` shape that was not captured at all. - `: base(...)` / `: this(...)` explicit constructor initializers, modeled by tree-sitter as `constructor_initializer` and never matched by the scope query, dropped the chained-constructor CALLS edges. Synthesize them: `this` → enclosing type name; `base` → the base type's bare name (first base-list entry, which C# requires to be the base class). Arity attached for overload disambiguation. - `interpretCsharpTypeBinding`'s qualifier strip used `lastIndexOf('.')` over the whole string, cutting inside a qualified generic type ARGUMENT (`Dictionary` → `User>`). Make stripQualifier generic-aware: reduce only the segment before the first `<`, re-attaching the generic suffix — multi-arg generics stay intact so the `.Values`/`.Keys` collection-accessor unwrap keeps working. Tests: capture-level unit tests for every constructor shape (incl. alias-qualified, double-match guard) and `: base`/`: this` (incl. struct/record/mixed-base); interpretCsharpTypeBinding unit tests (the corruption case + nullable/nested/ unknown-generic edges); end-to-end resolver tests with new fixtures. The csharp-captures golden was regenerated — drift is purely additive (only the new fixtures; zero existing-fixture digests changed). Co-authored-by: Cursor * fix(csharp): enhance constructor resolution and namespace qualification - Implemented qualified constructor name binding to resolve collisions between types in different namespaces. - Added support for `: base(...)` and `: this(...)` constructor initializers to ensure correct edge emission in the scope resolution. - Improved generic argument stripping to prevent incorrect parsing of qualified types. - Introduced tests for new features, including handling of interface-only base classes and qualified constructor calls. This update addresses issues related to constructor resolution and namespace qualification, ensuring accurate type references in C# code. Tests have been added to validate these changes. * fix(csharp): implement namespace prefix tagging for file-level type definitions - Updated the C# ingestion process to tag file-level type definitions with their enclosing namespace path using a new `namespacePrefix` field, without altering the `qualifiedName`. - Enhanced the scope resolver to utilize the `namespacePrefix` for resolving same-tail collisions in constructor calls, improving accuracy in type resolution. - Added unit tests to validate the new functionality, ensuring that namespace prefixes are correctly applied to both block-scoped and file-scoped types, while leaving namespace-free types untagged. This change addresses issues related to namespace qualification and constructor resolution in C# code, facilitating better handling of type references. * refactor(scope-resolution): share isOverloadableCallable via util Extract the ctor/function/method overload predicate into callable-labels.ts so graph-bridge registration and lookup stay aligned without duplicated private copies in ids.ts and node-lookup.ts. Co-authored-by: Cursor --------- Co-authored-by: Cursor Co-authored-by: Gergő Magyar --- gitnexus/bench/scope-capture/baselines.json | 5 +- .../ingestion/languages/csharp/captures.ts | 116 ++++ .../ingestion/languages/csharp/interpret.ts | 17 +- .../languages/csharp/qualified-type-names.ts | 66 ++ .../core/ingestion/languages/csharp/query.ts | 13 + .../languages/csharp/scope-resolver.ts | 9 +- .../scope-resolution/graph-bridge/edges.ts | 4 +- .../scope-resolution/graph-bridge/ids.ts | 57 +- .../graph-bridge/node-lookup.ts | 13 +- .../passes/free-call-fallback.ts | 36 +- .../scope-resolution/scope/walkers.ts | 51 +- .../core/ingestion/utils/callable-labels.ts | 16 + .../core/ingestion/utils/qualified-name.ts | 25 + .../expected-captures.json | 584 ++++++++++-------- .../csharp-explicit-ctor-init/Models/Base.cs | 6 + .../csharp-explicit-ctor-init/Models/Child.cs | 8 + .../csharp-interface-only-base/C.cs | 6 + .../csharp-namespace-qualified-ctor/A/App.cs | 9 + .../csharp-namespace-qualified-ctor/A/Foo.cs | 6 + .../csharp-namespace-qualified-ctor/B/Foo.cs | 6 + .../csharp-qualified-constructor/App.cs | 8 + .../Models/Box.cs | 6 + .../Models/Widget.cs | 8 + .../integration/resolvers/csharp-1924.test.ts | 100 +++ .../ingestion/qualified-name-utils.test.ts | 20 + .../csharp/csharp-captures.test.ts | 584 +++--------------- .../csharp/csharp-interpret.test.ts | 82 +++ .../csharp-qualified-type-names.test.ts | 40 ++ 28 files changed, 1092 insertions(+), 809 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/csharp/qualified-type-names.ts create mode 100644 gitnexus/src/core/ingestion/utils/callable-labels.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-explicit-ctor-init/Models/Base.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-explicit-ctor-init/Models/Child.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-interface-only-base/C.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-namespace-qualified-ctor/A/App.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-namespace-qualified-ctor/A/Foo.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-namespace-qualified-ctor/B/Foo.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-qualified-constructor/App.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-qualified-constructor/Models/Box.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-qualified-constructor/Models/Widget.cs create mode 100644 gitnexus/test/integration/resolvers/csharp-1924.test.ts create mode 100644 gitnexus/test/unit/ingestion/qualified-name-utils.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/csharp/csharp-interpret.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/csharp/csharp-qualified-type-names.test.ts diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index e896c2383..225238dd8 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -24,8 +24,9 @@ }, "csharp": { "_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.", - "fingerprint": "701b4274643a9a5ee03a71e9fc0dede28a89524eedb976affc48df72bdd4adcc", - "scaling_budget": 1.5 + "fingerprint": "2bb5bc8c19cb8eb08c9590545ad8a1968a7152951f7e12746e2d7901d542fed9", + "scaling_budget": 1.5, + "_note": "#2046: F35 qualified-constructor captures now emit @reference.qualified-name + a simple-name @reference.name on `new Ns.Foo()`/`new A.B.Foo()`; namespace_declaration/file_scoped_namespace_declaration now emit @declaration.namespace name captures (feeding the non-destructive namespacePrefix sidecar for `new B.Foo()` same-tail disambiguation). + csharp-interface-only-base and csharp-namespace-qualified-ctor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.11)." }, "rust": { "fingerprint": "ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82", diff --git a/gitnexus/src/core/ingestion/languages/csharp/captures.ts b/gitnexus/src/core/ingestion/languages/csharp/captures.ts index d5914780d..b3b720587 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/captures.ts @@ -196,6 +196,33 @@ export function emitCsharpScopeCaptures( } } + // Qualified constructor calls — `new Ns.Foo()`, `new A.B.Foo()`, + // `new Ns.Box()` — bind only `@reference.call.constructor.qualified` + // with NO `@reference.name`, so the central extractor falls back to the + // whole-expression anchor and the reference name becomes the raw + // `new Ns.Foo()` text (never resolves). Derive the bare simple-name tail via + // the same `terminalTypeNameNode` helper the inheritance synth uses — it + // handles qualified_name, a generic tail (`Ns.Box` → `Box`), and + // alias_qualified_name (`global::Ns.Foo`). Mirrors Java F35 (#1928). + if ( + grouped['@reference.call.constructor.qualified'] !== undefined && + grouped['@reference.name'] === undefined + ) { + const qNode = nodeMap['@reference.call.constructor.qualified']; + const nameNode = qNode === undefined ? null : terminalTypeNameNode(qNode); + if (nameNode !== null) { + grouped['@reference.name'] = nodeToCapture('@reference.name', nameNode); + const qText = qNode.text.trim(); + if (qText.length > 0 && qText !== nameNode.text) { + grouped['@reference.qualified-name'] = syntheticCapture( + '@reference.qualified-name', + qNode, + qText, + ); + } + } + } + // Synthesize `@reference.arity` on every callsite so the // registry's arity filter can narrow overloads. Count the // `argument` named children of the backing `argument_list`. @@ -263,10 +290,99 @@ export function emitCsharpScopeCaptures( out.push(...synthesizeGenericTypeArgumentReferences(tree.rootNode)); out.push(...synthesizeCsharpInheritanceReferences(tree.rootNode)); + out.push(...synthesizeCsharpConstructorInitializerReferences(tree.rootNode)); return out; } +/** + * Synthesize `@reference.call.constructor` captures for C# constructor + * initializers — `: base(...)` and `: this(...)` (F38 analog of Java #1928). + * tree-sitter-c-sharp models these as `constructor_initializer` nodes the scope + * query never matched, so the chained-constructor CALLS edges (derived ctor → + * base ctor; ctor → sibling overload) were silently dropped. + * + * The initializer carries no constructor name (the `base`/`this` child is a bare + * keyword token), so the target is resolved structurally: + * - `this(...)` → the enclosing type's own simple name. + * - `base(...)` → the enclosing class/record's base type, reduced to its bare + * simple name via `terminalTypeNameNode`. C# requires the base class first in + * a mixed list (`class C : Base, IFoo`); interface-only lists (`class C : IFoo`) + * imply implicit `System.Object` — no `@reference` is emitted when the first + * non-builtin base would be an interface-only target (resolution also drops + * Interface-typed constructor targets in `free-call-fallback`). + * Arity is attached for overload disambiguation, mirroring `new X(...)`. + */ +function synthesizeCsharpConstructorInitializerReferences(root: SyntaxNode): CaptureMatch[] { + const out: CaptureMatch[] = []; + walkNamedTree(root, (node) => { + if (node.type !== 'constructor_initializer') return; + + let kind: 'base' | 'this' | null = null; + for (let i = 0; i < node.childCount; i++) { + const t = node.child(i)?.type; + if (t === 'base' || t === 'this') { + kind = t; + break; + } + } + if (kind === null) return; + + const enclosingType = findEnclosingTypeDeclaration(node); + if (enclosingType === null) return; + + let targetNameNode: SyntaxNode | null = null; + if (kind === 'this') { + targetNameNode = enclosingType.childForFieldName('name'); + } else { + const baseList = findNamedChild(enclosingType, 'base_list'); + if (baseList === null) return; + // Prefer the first non-builtin entry (idiomatically the base class). When + // the list is interface-only (`class C : IFoo`), do not synthesize a + // `base(...)` ref — valid C# chains to implicit Object, not IFoo (#2046). + let sawNonBuiltin = false; + for (const base of baseList.namedChildren) { + if (base === null) continue; + const n = terminalTypeNameNode(base); + if (n === null || BUILTIN_TYPE_NAMES.has(n.text)) continue; + sawNonBuiltin = true; + targetNameNode = n; + break; + } + if (!sawNonBuiltin) return; + } + if (targetNameNode === null) return; + + const argList = findNamedChild(node, 'argument_list'); + const arity = + argList === null + ? 0 + : argList.namedChildren.filter((c) => c !== null && c.type === 'argument').length; + + out.push({ + '@reference.call.constructor': nodeToCapture('@reference.call.constructor', node), + '@reference.name': nodeToCapture('@reference.name', targetNameNode), + '@reference.arity': syntheticCapture('@reference.arity', node, String(arity)), + }); + }); + return out; +} + +function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null { + let cur: SyntaxNode | null = node.parent; + while (cur !== null) { + if ( + cur.type === 'class_declaration' || + cur.type === 'struct_declaration' || + cur.type === 'record_declaration' + ) { + return cur; + } + cur = cur.parent; + } + return null; +} + /** * Synthesize `@reference.inherits` captures from C# base lists so the * registry-primary scope-resolution path emits EXTENDS / IMPLEMENTS edges diff --git a/gitnexus/src/core/ingestion/languages/csharp/interpret.ts b/gitnexus/src/core/ingestion/languages/csharp/interpret.ts index beb2594a9..ffb38a257 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/interpret.ts @@ -129,9 +129,20 @@ function stripGeneric(text: string): string { * receiver's generic type based on the suffix — `data.Values` → * element type of `data`'s Dictionary. */ function stripQualifier(text: string): string { - const lastDot = text.lastIndexOf('.'); - if (lastDot === -1) return text; - const tail = text.slice(lastDot + 1); + // Strip only the outermost qualifier: the last `.` at generic nesting depth 0. + // This preserves F41 (never cut inside `Dictionary`) AND + // nested types through a generic outer (`Ns.Outer.Inner` → `Inner`, not + // `Outer.Inner` — #2046 P3). + let depth = 0; + let lastDotAtDepth0 = -1; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (c === '<') depth++; + else if (c === '>') depth = Math.max(0, depth - 1); + else if (c === '.' && depth === 0) lastDotAtDepth0 = i; + } + if (lastDotAtDepth0 === -1) return text; + const tail = text.slice(lastDotAtDepth0 + 1); if (COLLECTION_ACCESSOR_SUFFIXES.has(tail)) return text; return tail; } diff --git a/gitnexus/src/core/ingestion/languages/csharp/qualified-type-names.ts b/gitnexus/src/core/ingestion/languages/csharp/qualified-type-names.ts new file mode 100644 index 000000000..a4b72e6b9 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/qualified-type-names.ts @@ -0,0 +1,66 @@ +/** + * Tag C# file-level type defs with their enclosing-namespace path on the + * sidecar `namespacePrefix` field — WITHOUT touching `qualifiedName` (mutating + * it corrupts simple-name heritage / base resolution; #2046 regression). + * + * `tagNamespacePrefixes` (shared) only reaches defs whose scope chain includes + * a Namespace scope. C# file-scoped `namespace X;` gives the Namespace scope a + * 1-line range, so top-level types land under the Module scope and are missed. + * This pass covers both block- and file-scoped namespaces so the qualified + * constructor resolver can break a same-tail collision (`new B.Foo()` with both + * `A.Foo` and `B.Foo`) by matching the explicit qualifier against the sidecar. + */ +import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import { isClassLike } from '../../scope-resolution/scope/walkers.js'; + +function isTypeDef(def: SymbolDefinition): boolean { + return isClassLike(def.type) || def.type === 'Enum'; +} + +export function populateCsharpNamespacePrefixes(parsed: ParsedFile): void { + const scopesById = new Map(); + for (const scope of parsed.scopes) scopesById.set(scope.id, scope); + + // The file's declared namespace (file-scoped `namespace X;`). First Namespace + // scope's own def qualifiedName; undefined when the file is namespace-free. + const fileNamespace = ((): string | undefined => { + for (const scope of parsed.scopes) { + if (scope.kind !== 'Namespace') continue; + const nsDef = scope.ownedDefs.find((d) => d.type === 'Namespace'); + const q = nsDef?.qualifiedName; + if (q !== undefined && q.length > 0) return q; + } + return undefined; + })(); + + // Enclosing namespace path for a scope: nearest ancestor Namespace scope's + // full qualifiedName, else the file-scoped namespace (Module-parented types). + const namespaceOf = (scope: ParsedFile['scopes'][number]): string | undefined => { + let parentId = scope.parent; + while (parentId !== null) { + const parent = scopesById.get(parentId); + if (parent === undefined) break; + if (parent.kind === 'Namespace') { + const nsDef = parent.ownedDefs.find((d) => d.type === 'Namespace'); + const q = nsDef?.qualifiedName; + if (q !== undefined && q.length > 0) return q; + } + if (parent.kind === 'Module') return fileNamespace; + parentId = parent.parent; + } + return fileNamespace; + }; + + for (const scope of parsed.scopes) { + if (scope.kind !== 'Class') continue; + const prefix = namespaceOf(scope); + if (prefix === undefined || prefix.length === 0) continue; + for (const def of scope.ownedDefs) { + if (!isTypeDef(def)) continue; + if (def.namespacePrefix !== undefined) continue; + const q = def.qualifiedName; + if (q === prefix || (q !== undefined && q.startsWith(`${prefix}.`))) continue; + def.namespacePrefix = prefix; + } + } +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/query.ts b/gitnexus/src/core/ingestion/languages/csharp/query.ts index f299aaafa..0a0bb65ca 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/query.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/query.ts @@ -41,6 +41,12 @@ const CSHARP_SCOPE_QUERY = ` (namespace_declaration) @scope.namespace (file_scoped_namespace_declaration) @scope.namespace +(namespace_declaration + name: (_) @declaration.name) @declaration.namespace + +(file_scoped_namespace_declaration + name: (_) @declaration.name) @declaration.namespace + (class_declaration) @scope.class (interface_declaration) @scope.class (struct_declaration) @scope.class @@ -482,6 +488,13 @@ const CSHARP_SCOPE_QUERY = ` (object_creation_expression type: (qualified_name) @reference.call.constructor.qualified) @reference.call.constructor +;; Alias-qualified constructor: \`new MyAlias::Foo()\`, \`new global::Foo()\`. The +;; top-level type is an alias_qualified_name (a \`global::Ns.Foo\` qualifier nests +;; under qualified_name instead, covered above). No @reference.name here — +;; captures.ts derives the simple-name tail via terminalTypeNameNode. +(object_creation_expression + type: (alias_qualified_name) @reference.call.constructor.qualified) @reference.call.constructor + ;; References — field/property writes: \`obj.Name = "x"\` emits a write ;; ACCESSES edge from the enclosing method to the field/property on ;; obj's class. diff --git a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts index 0642b6f91..19e4ae8e2 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts @@ -10,6 +10,7 @@ import type { ParsedFile } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; +import { populateCsharpNamespacePrefixes } from './qualified-type-names.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; import { csharpProvider } from '../csharp.js'; import { @@ -57,7 +58,13 @@ const csharpScopeResolver: ScopeResolver = { buildMro: (graph, parsedFiles, nodeLookup) => buildMro(graph, parsedFiles, nodeLookup, defaultLinearize), - populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed), + populateOwners: (parsed: ParsedFile) => { + populateClassOwnedMembers(parsed); + // Sidecar-only namespace tagging (does NOT touch qualifiedName) so the + // qualified constructor resolver can break same-tail collisions like + // `new B.Foo()` by matching the explicit qualifier (#2046). + populateCsharpNamespacePrefixes(parsed); + }, // C# uses `base` for super-class dispatch, not `super`. Match as a // plain identifier (no `()` call like Python's `super(...)`) — `base` diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts index 5c7120495..ce7b52844 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts @@ -78,7 +78,7 @@ export function tryEmitEdge( // Inheritance edges are emitted directly by `preEmitInheritanceEdges` (which // owns the enclosing-class caller and the EXTENDS-vs-IMPLEMENTS type), so this // generic bridge derives caller + edge type purely from the site. - const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup); + const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup, site.atRange); const targetGraphId = resolveDefGraphId(targetDef.filePath, targetDef, nodeLookup); const edgeType = mapReferenceKindToEdgeType(site.kind as Reference['kind']); if (callerGraphId === undefined) return false; @@ -135,7 +135,7 @@ export function tryEmitEdgeWithExplicitTargetId( confidence = 0.85, collapseByCallerTarget = false, ): boolean { - const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup); + const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup, site.atRange); const edgeType = mapReferenceKindToEdgeType(site.kind as Reference['kind']); if (callerGraphId === undefined) return false; if (edgeType === undefined) return false; diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts index 441bb3e4b..5da650393 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -21,6 +21,7 @@ import type { NodeLabel, ParameterTypeClass, ScopeId, SymbolDefinition } from 'g import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { generateId } from '../../../../lib/utils.js'; import { qualifiedKey, simpleKey, type GraphNodeLookup } from '../graph-bridge/node-lookup.js'; +import { isOverloadableCallable } from '../../utils/callable-labels.js'; import { templateConstraintsIdTag } from '../../utils/template-arguments.js'; import { parameterShapeIdTag } from '../../utils/method-props.js'; /** @@ -54,14 +55,40 @@ function isCallerAnchorLabel(label: NodeLabel): boolean { ); } -/** - * Callables whose same-name overloads occupy distinct graph nodes keyed by - * parameter types / shape. Must mirror `isOverloadableCallable` in - * `node-lookup.ts` so registration and lookup agree (Constructor included — - * #1928 F38). - */ -function isOverloadableCallable(label: NodeLabel | undefined): boolean { - return label === 'Function' || label === 'Method' || label === 'Constructor'; +function rangeContainsPoint( + range: { startLine: number; startCol: number; endLine: number; endCol: number }, + at: { startLine: number; startCol: number }, +): boolean { + if (at.startLine < range.startLine || at.startLine > range.endLine) return false; + if (at.startLine === range.startLine && at.startCol < range.startCol) return false; + if (at.startLine === range.endLine && at.startCol > range.endCol) return false; + return true; +} + +/** Pick the callable that owns `atRange` when multiple overloads share a class scope. */ +function pickCallerCallableDef( + scope: { + readonly id: ScopeId; + readonly range: { startLine: number; startCol: number; endLine: number; endCol: number }; + readonly ownedDefs: readonly SymbolDefinition[]; + }, + scopes: ScopeResolutionIndexes, + atRange?: { startLine: number; startCol: number }, +): SymbolDefinition | undefined { + if (atRange !== undefined) { + for (const childId of scopes.scopeTree.getChildren(scope.id)) { + const child = scopes.scopeTree.getScope(childId); + if (child === undefined || child.kind !== 'Function') continue; + if (!rangeContainsPoint(child.range, atRange)) continue; + const childCallable = child.ownedDefs.find( + (d) => d.type === 'Function' || d.type === 'Method' || d.type === 'Constructor', + ); + if (childCallable !== undefined) return childCallable; + } + } + return scope.ownedDefs.find( + (d) => d.type === 'Function' || d.type === 'Method' || d.type === 'Constructor', + ); } /** @@ -131,9 +158,10 @@ export function resolveDefGraphId( // Overload disambiguation: when the def carries parameter types, // try the parameter-typed key first so same-name same-arity // overloads route to their distinct graph nodes. Constructors are - // included so a `this(int)`/`super(int)` chain or `new Foo(int)` - // resolves to the matching ctor overload instead of first-wins - // collapsing onto another `Foo` ctor (a self-loop) — #1928 F38. + // included so a C# `: this(int)` / `: base(int)` chain, a Java + // `this(int)`/`super(int)` chain, or `new Foo(int)` resolves to the + // matching ctor overload instead of first-wins collapsing onto + // another `Foo` ctor (a self-loop) — #1928 F38 / #2046. if ( isOverloadableCallable(def.type) && def.parameterTypes !== undefined && @@ -197,6 +225,7 @@ export function resolveCallerGraphId( startScope: ScopeId, scopes: ScopeResolutionIndexes, nodeLookup: GraphNodeLookup, + atRange?: { startLine: number; startCol: number }, ): string | undefined { let current: ScopeId | null = startScope; const visited = new Set(); @@ -211,11 +240,9 @@ export function resolveCallerGraphId( // Prefer Function/Method/Constructor anchors; fall back to // Class/Interface/Struct/Enum. Variable/Property are NOT valid // caller anchors — see `isCallerAnchorLabel` for why. - const fnDef = scope.ownedDefs.find( - (d) => d.type === 'Function' || d.type === 'Method' || d.type === 'Constructor', - ); + const fnDef = pickCallerCallableDef(scope, scopes, atRange); if (fnDef !== undefined) { - const id = resolveDefGraphId(scope.filePath, fnDef, nodeLookup); + const id = resolveDefGraphId(fnDef.filePath, fnDef, nodeLookup); if (id !== undefined) return id; } const classDef = scope.ownedDefs.find((d) => isCallerAnchorLabel(d.type)); diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts index d7da8a56f..e39ddc4b7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts @@ -20,6 +20,7 @@ import type { NodeLabel, ParameterTypeClass } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../../graph/types.js'; +import { isOverloadableCallable } from '../../utils/callable-labels.js'; import { templateConstraintsIdTag } from '../../utils/template-arguments.js'; import { parameterShapeIdTag } from '../../utils/method-props.js'; @@ -158,18 +159,6 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { return lookup; } -/** - * Callables whose same-name overloads must route to distinct graph nodes via - * the parameter-types / shape key. Constructors belong here too: a class with - * `Foo()` and `Foo(int)` mints distinct `#0`/`#1` Constructor nodes, and a - * `this(...)`/`super(...)` edge (or any `new Foo(args)`) must reach the right - * one. Without the overload key both ctor nodes collapse onto the first-wins - * qualified/simple key, turning a `this()` chain into a self-loop (#1928 F38). - */ -function isOverloadableCallable(label: NodeLabel): boolean { - return label === 'Function' || label === 'Method' || label === 'Constructor'; -} - export function isLinkableLabel(label: NodeLabel): boolean { return ( label === 'Function' || diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index 7fe68f6fd..ec40991ec 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -39,7 +39,7 @@ import { findAllCallableBindingsInScope, findCallableBindingInScope, findCallableBindingsAndAdlBlocker, - findClassBindingInScope, + resolveInheritanceBaseInScope, } from '../scope/walkers.js'; import { isOverloadAmbiguousAfterNormalization, @@ -114,8 +114,13 @@ export function emitFreeCallFallback( // the same two targets; see test expectations. let fnDef: SymbolDefinition | undefined; if (site.callForm === 'constructor') { - const classDef = findClassBindingInScope(site.inScope, site.name, scopes); - if (classDef !== undefined) { + const classDef = resolveInheritanceBaseInScope( + site.inScope, + site.name, + scopes, + site.rawQualifiedName, + ); + if (classDef !== undefined && classDef.type !== 'Interface') { // Most languages link `Type(...)` to the explicit Constructor def // when one exists (else the Class). Languages that model the call // as a reference to the type itself opt into @@ -123,7 +128,7 @@ export function emitFreeCallFallback( fnDef = options.constructorCallTargetsClass === true ? classDef - : pickConstructorOrClass(classDef, workspaceIndex, scopes); + : pickConstructorOrClass(classDef, workspaceIndex, scopes, site.arity); } else if (options.allowGlobalFallback === true) { // The constructed type may live in a sibling/imported file that is // not in the call-site's lexical scope-chain bindings. Fall back to @@ -133,9 +138,11 @@ export function emitFreeCallFallback( const globalClass = pickUniqueGlobalClass(site.name, globalClassesBySimpleName); if (globalClass !== undefined) { fnDef = - options.constructorCallTargetsClass === true - ? globalClass - : pickConstructorOrClass(globalClass, workspaceIndex, scopes); + globalClass.type === 'Interface' + ? undefined + : options.constructorCallTargetsClass === true + ? globalClass + : pickConstructorOrClass(globalClass, workspaceIndex, scopes, site.arity); } } } @@ -379,7 +386,7 @@ export function emitFreeCallFallback( handledSites.add(siteKey(parsed.filePath, site)); continue; } - const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup); + const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup, site.atRange); if (callerGraphId === undefined) continue; const tgtGraphId = resolveDefGraphId(fnDef.filePath, fnDef, nodeLookup); if (tgtGraphId === undefined) continue; @@ -662,22 +669,29 @@ function pickConstructorOrClass( classDef: SymbolDefinition, workspaceIndex: WorkspaceResolutionIndex, scopes?: ScopeResolutionIndexes, + callArity?: number, ): SymbolDefinition { const classScope = workspaceIndex.classScopeByDefId.get(classDef.nodeId); if (classScope === undefined) return classDef; + const ctors: SymbolDefinition[] = []; for (const def of classScope.ownedDefs) { - if (def.type === 'Constructor') return def; + if (def.type === 'Constructor') ctors.push(def); } if (scopes !== undefined) { for (const childId of scopes.scopeTree.getChildren(classScope.id)) { const childScope = scopes.scopeTree.getScope(childId); if (childScope === undefined || childScope.kind === 'Class') continue; for (const def of childScope.ownedDefs) { - if (def.type === 'Constructor') return def; + if (def.type === 'Constructor') ctors.push(def); } } } - return classDef; + if (ctors.length === 0) return classDef; + if (callArity !== undefined) { + const narrowed = narrowByArity(ctors, callArity); + if (narrowed !== undefined) return narrowed; + } + return ctors[0]!; } /** Find a unique workspace-wide class-like def by simple name, for a diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 23cc0aa8e..3d487bfc9 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -24,7 +24,11 @@ import type { BindingRef, ParsedFile, ScopeId, SymbolDefinition, TypeRef } from import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import type { SemanticModel } from '../../model/semantic-model.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; -import { normalizeQualifiedName } from '../../utils/qualified-name.js'; +import { + normalizeQualifiedName, + splitQualifiedName, + stripTrailingTypeArguments, +} from '../../utils/qualified-name.js'; const EMPTY_BINDINGS: readonly BindingRef[] = Object.freeze([]); @@ -353,7 +357,7 @@ function resolveQualifiedInheritanceBase( scopes: ScopeResolutionIndexes, enclosingClassDef?: SymbolDefinition, ): SymbolDefinition | undefined { - const normalized = normalizeQualifiedName(rawQualifiedName); + const normalized = stripTrailingTypeArguments(normalizeQualifiedName(rawQualifiedName)); // No qualifier after normalization → nothing the simple-tail walk doesn't do. if (normalized.length === 0 || !normalized.includes('.')) return undefined; @@ -365,12 +369,26 @@ function resolveQualifiedInheritanceBase( const enclosing = isRootAnchored ? [] : enclosingScopeSegments(startScope, scopes, enclosingClassDef); - // Candidate keys: longest enclosing prefix first, then the root-anchored form. + // Candidate keys: longest enclosing prefix first for *relative* qualified + // bases (`Outer.Inner` inside `NS.Outer.Derived` → `NS.Outer.Inner`). When the + // qualifier names a *different* namespace than the enclosing scope (`new B.Foo()` + // inside `namespace A` → `B.Foo`, not `A.Foo`), try the raw normalized key + // FIRST so same-tail local bindings don't win (#2046 / #1991). + const normParts = splitQualifiedName(normalized); + const isRelativeToEnclosing = + enclosing.length > 0 && + normParts.length > 0 && + normParts[0] === enclosing[enclosing.length - 1]; const keys: string[] = []; + if (!isRelativeToEnclosing) { + keys.push(normalized); + } for (let i = enclosing.length; i >= 1; i--) { keys.push([...enclosing.slice(0, i), normalized].join('.')); } - keys.push(normalized); + if (!keys.includes(normalized)) { + keys.push(normalized); + } for (const key of keys) { const ids = scopes.qualifiedNames.get(key); @@ -407,6 +425,31 @@ function resolveQualifiedInheritanceBase( return undefined; // genuine tie → refuse, don't guess } } + + // Qualifier-vs-sidecar fallback (#2046). Languages whose class `qualifiedName` + // is the SIMPLE name (C#) never populate a qualified key in the index, so the + // keyed loop above can't see `B.Foo`. Resolve the simple TAIL and break the + // same-tail collision by matching the explicit qualifier (`B`) against each + // candidate's `namespacePrefix` sidecar. Commit only on a unique match — a + // still-ambiguous qualifier refuses (never guesses a wrong EXTENDS/CALLS edge). + const tail = normParts[normParts.length - 1]; + const qualifier = normParts.slice(0, -1).join('.'); + if (tail !== undefined && qualifier.length > 0) { + const tailIds = scopes.qualifiedNames.get(tail); + let qUnique: SymbolDefinition | undefined; + let qCount = 0; + for (const id of tailIds) { + const def = scopes.defs.get(id); + if (def === undefined || !isClassLike(def.type)) continue; + const np = def.namespacePrefix; + if (np === undefined || np.length === 0) continue; + if (np === qualifier || np.endsWith(`.${qualifier}`)) { + qUnique = def; + qCount++; + } + } + if (qCount === 1) return qUnique; + } return undefined; } diff --git a/gitnexus/src/core/ingestion/utils/callable-labels.ts b/gitnexus/src/core/ingestion/utils/callable-labels.ts new file mode 100644 index 000000000..a4b994f43 --- /dev/null +++ b/gitnexus/src/core/ingestion/utils/callable-labels.ts @@ -0,0 +1,16 @@ +import type { NodeLabel } from 'gitnexus-shared'; + +/** + * Callables whose same-name overloads occupy distinct graph nodes keyed by + * parameter types / shape. Shared by graph-bridge registration (`node-lookup.ts`) + * and resolution (`ids.ts`) so overload keys stay aligned. + * + * Constructors belong here: a class with `Foo()` and `Foo(int)` mints distinct + * `#0`/`#1` Constructor nodes, and a `: this(...)` / `: base(...)` (C#), + * `this(...)`/`super(...)` (Java), or `new Foo(args)` edge must reach the + * right overload — otherwise both ctor nodes collapse onto the first-wins + * qualified/simple key and a ctor chain becomes a self-loop (#1928 F38 / #2046). + */ +export function isOverloadableCallable(label: NodeLabel | undefined): boolean { + return label === 'Function' || label === 'Method' || label === 'Constructor'; +} diff --git a/gitnexus/src/core/ingestion/utils/qualified-name.ts b/gitnexus/src/core/ingestion/utils/qualified-name.ts index dcd3d5ffb..cf266b154 100644 --- a/gitnexus/src/core/ingestion/utils/qualified-name.ts +++ b/gitnexus/src/core/ingestion/utils/qualified-name.ts @@ -41,3 +41,28 @@ export const splitQualifiedName = (value: string): string[] => { const normalized = normalizeQualifiedName(value); return normalized ? normalized.split('.').filter(Boolean) : []; }; + +/** + * Strip a trailing generic argument list at angle-bracket depth 0 + * (`Models.Box` → `Models.Box`). Constructor and inheritance sites + * often carry type arguments that are not part of the indexed def key. + */ +export function stripTrailingTypeArguments(value: string): string { + let depth = 0; + let ltAt0 = -1; + for (let i = 0; i < value.length; i++) { + const c = value[i]; + if (c === '<') { + if (depth === 0) ltAt0 = i; + depth++; + } else if (c === '>') { + depth = Math.max(0, depth - 1); + if (depth === 0 && ltAt0 >= 0) { + const after = value.slice(i + 1).trim(); + if (after.length === 0) return value.slice(0, ltAt0); + ltAt0 = -1; + } + } + } + return value; +} diff --git a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json index 37ab734d6..0f6fd1f07 100644 --- a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json @@ -1,131 +1,131 @@ { "csharp-alias-imports/Models/Repo.cs": { - "captureGroups": 12, - "digest": "f46b3ec22a53f7aa3409d001e6c845dd9b17d3634f0f791f6b014bd599884309" + "captureGroups": 13, + "digest": "89499f7c26b495994d869b802deaeecc1de68c7d1e1b41629b57f9baaff4c240" }, "csharp-alias-imports/Models/User.cs": { - "captureGroups": 12, - "digest": "065ae045a857e4c881c62ea7234a846332b9d8489f539fa4cfad045707eab039" + "captureGroups": 13, + "digest": "d6f73d9a16c5d07f7b7c3c85fe2464426ad6fec89b05312ebccdf3391b6e52e5" }, "csharp-alias-imports/Services/Main.cs": { - "captureGroups": 17, - "digest": "24ce33ab9016af2cb4da1384f4478a3ccde4d856e0c6839222d50e8946a0d9a7" + "captureGroups": 18, + "digest": "8b570aa6e9dec38c2ec1d65800a7647cb9a92ea0c1cd6d290df47da1f945b1b5" }, "csharp-ambiguous/Models/Handler.cs": { - "captureGroups": 7, - "digest": "520efd0a0ac67137dbae921eb0d27d4b0f841fc0582bfe01fb664d54bce2fd06" + "captureGroups": 8, + "digest": "393b6b4b531e1df7e1d365928ccb5c2c2e7959f5400743a458640845a2d823d4" }, "csharp-ambiguous/Models/IProcessor.cs": { - "captureGroups": 6, - "digest": "d38477ce3dd97a87f38359da966cb7445588fb5c17bdaa7a25bc6f23e8d9d1ec" + "captureGroups": 7, + "digest": "11500446a619a070bfb05d02c4e3b9dbf865b4d596d537f27033f22ec7c4fe9e" }, "csharp-ambiguous/Other/Handler.cs": { - "captureGroups": 7, - "digest": "119e87ba0c830530fdf6fb04d592501c6c552e1051613ebc65c1c1fb0da1ce47" + "captureGroups": 8, + "digest": "4e249bed58aaeb759453ec49f8501d3859ad473563cc798422edef2f7b716b2e" }, "csharp-ambiguous/Other/IProcessor.cs": { - "captureGroups": 6, - "digest": "ceae1be5aae89de4eec68c5a64195728a7ae434a9b2cab2eaab617b912c3433d" + "captureGroups": 7, + "digest": "4df3ab36b847dd22fd8334722f0f6935219a0fc6cfcc59bf0952fc47b71a4b17" }, "csharp-ambiguous/Services/UserHandler.cs": { - "captureGroups": 11, - "digest": "77fbdaffa5e1f6ab94b14d339af380c83466183162a57ea1b34f5d9264c2ed4a" + "captureGroups": 12, + "digest": "c48d0db5417ad57755d0bfe5e275e0bdaf8a765692f80603ecfd64e396314b28" }, "csharp-assignment-chain/Models/Repo.cs": { - "captureGroups": 7, - "digest": "65fc6c6688e7f7badead653bbef85fca225b6910eeaacb9f30d03cb28aeb1e26" + "captureGroups": 8, + "digest": "81e43aed743577f4e7e1ece1aea61bec9d4933b2e63207d44929bfdcf3d40342" }, "csharp-assignment-chain/Models/User.cs": { - "captureGroups": 7, - "digest": "4f0c929c82e56f41e5b3b4c0aefee2121d7a307ef24535ac998fd7041d0e025b" + "captureGroups": 8, + "digest": "064640e172244bee5078073d4eed69df177681cba8c0a8105a391a65864612f9" }, "csharp-assignment-chain/Program.cs": { - "captureGroups": 29, - "digest": "81757e8ea3909070375dfea988987f2ae759d9a607498f02ca929112f05533d1" + "captureGroups": 30, + "digest": "a3eb329f0ca262da99fa1000b49f7b10e7e5c3fbc157f0d95825e866cdd4c190" }, "csharp-async-binding/Order.cs": { - "captureGroups": 8, - "digest": "20a7e738e7e3248ef9fcdc34b460b899fe1dc882dc1577cabacee49c2617af2b" + "captureGroups": 9, + "digest": "c2d5ef9688ff2fce345fe8d8148ee0d8281032b55083f70ac925e5407b3c0d0c" }, "csharp-async-binding/OrderService.cs": { - "captureGroups": 11, - "digest": "8aefaf0ef949e72fd3dff12bbb9fe7b97f1abe7f53cf3f13bb8ba6691da566e9" + "captureGroups": 12, + "digest": "9ea84a606a4bbee462b5cbc8becff91aa07847187b40f1af6d7c429c2876df4a" }, "csharp-async-binding/Program.cs": { - "captureGroups": 31, - "digest": "7598dd29b2653f598be6668b0695de630545b90726cbfa8c9fcbe352d0cb20d5" + "captureGroups": 32, + "digest": "a90d5d0f8b41f8752b5b629aa4d1bf58b8dcda6181c41fbe9453eb758c4bbfa3" }, "csharp-async-binding/User.cs": { - "captureGroups": 8, - "digest": "2e80c42ee70d1c96bb4934fecd04ee696c131ec5984afc6f750f8ccf2be16f03" + "captureGroups": 9, + "digest": "d771395595ea17b22e3f7b448bb10463086aa89fe49f54fe24ee514e2392a880" }, "csharp-async-binding/UserService.cs": { - "captureGroups": 11, - "digest": "d51bfff25e0f3a62962dc61a57e8a642529d47691a5ed75bceaac6b37c48848e" + "captureGroups": 12, + "digest": "08c1546fd0a591d45041f4840725aa16cb832d6cd848563db45a83868352afa7" }, "csharp-call-result-binding/App.cs": { "captureGroups": 24, "digest": "da0e6286460e3eb719452574c2c627f3d242394b165525e026926c08b26cb88c" }, "csharp-calls/Services/UserService.cs": { - "captureGroups": 10, - "digest": "bea2e451e3d28fd3eb172c6ba99f1fb4827929733edef6aaf49545a830caaa54" + "captureGroups": 11, + "digest": "d527d47a183733c34d56227c383267f25acc951413639f1e6cc6cb7eabd2440a" }, "csharp-calls/Utils/OneArg.cs": { - "captureGroups": 6, - "digest": "4ca5be18e4c5b9e5cebfecc2424e69d4adf8160a857ded2efc28dc4676965a3d" + "captureGroups": 7, + "digest": "8d9ac2bdedab0c1202d99233bbf03364d17e67fbfe9ecdd0ecb924198e1cd393" }, "csharp-calls/Utils/ZeroArg.cs": { - "captureGroups": 6, - "digest": "02e3a191df1c53fe29d9a463bb65eee0e98ef9d19288678e1fee7ae6c692809d" + "captureGroups": 7, + "digest": "d6c3365cad3d69be7d0cefbb8a237b8c719ee2e0cdd0be76968db058c93dab9d" }, "csharp-chain-call/Models/Repo.cs": { - "captureGroups": 7, - "digest": "8bab68d412d077c3c4d48d9f95f51847f06d11391cda73e49d528ce58b494b88" + "captureGroups": 8, + "digest": "605d2a7cd86022789ea5282b210a58aef814608a398d4ba4b62b85a206ad530d" }, "csharp-chain-call/Models/User.cs": { - "captureGroups": 7, - "digest": "1bce064128c35d0799914587b2f4afe7ff69f36a3ab6c6bcf0ead801a7171cd0" + "captureGroups": 8, + "digest": "d200eaad13e2186707a5702ad15384176f7a0e834a0b1ce876d98e229035a3ee" }, "csharp-chain-call/Program.cs": { "captureGroups": 12, "digest": "db5c68b0370c594cd006cabb3c836c7a0c5d9ab3a9be8f3cb4420ec76bd31d8a" }, "csharp-chain-call/Services/UserService.cs": { - "captureGroups": 10, - "digest": "89b0d36671d7a0392a2b2a719efa6c337713f60e4ed7edb6ef83d27c58fbf588" + "captureGroups": 11, + "digest": "9833795eeb79a08ef9c8afb66a58b789ab314e48c1ae3193fe87fec279c55374" }, "csharp-child-extends-parent/src/App.cs": { - "captureGroups": 12, - "digest": "817c2f4caf130d8af6ca4bbbc44d25c3186b59e1bddf6cb06b93cd99b98abd0c" + "captureGroups": 13, + "digest": "5d421e95d77f601207bf0f1f10f6e70267d02069c3e0a8ebe2ad574e13be2026" }, "csharp-child-extends-parent/src/Child.cs": { - "captureGroups": 5, - "digest": "06860696436195a2e4bdac8954307bd97d28e8fb9ef27e1adbb31aa0f4939012" + "captureGroups": 6, + "digest": "a520ca4ca280c444a833d012b6fa03fbbd427a0de2311e68b9bdac6771c70a93" }, "csharp-child-extends-parent/src/Parent.cs": { - "captureGroups": 7, - "digest": "0d8085667a8b8d406588ce115772ada953373dbe8a7c334f5ae17acd0ac01a64" + "captureGroups": 8, + "digest": "e6b7f74cb4f569001c338a16de42202ea223575cca173da42d381ede7110cd82" }, "csharp-class-static-field-access/src/Counters.cs": { - "captureGroups": 13, - "digest": "a01b1b506018b475500beac2bc0662c1f77ac04086eb1b075714ed6360cae9d0" + "captureGroups": 14, + "digest": "c419459dc554b60d0cf6be39f2dcf79e094d155c3f0c9f171dd74a2a8b8b34b1" }, "csharp-collection-accessor/Models/Widget.cs": { - "captureGroups": 7, - "digest": "1289983262ca7a29ad77abb559243eb4ab7dcb7e036563a09989fd4e4898b49c" + "captureGroups": 8, + "digest": "0ccea7f5ce3ad101ba8085cc1dd8d638b11817afae63a200e16fd2a3cb50143b" }, "csharp-collection-accessor/Services/Renderer.cs": { - "captureGroups": 15, - "digest": "21a4db0b832bd83307b0fbacd1540854157457c95a81c2e351653ff2009a972d" + "captureGroups": 16, + "digest": "b116c898071aab598ab151b9619ec8517fed67ad96b5bfd575138f62c5369f82" }, "csharp-deep-field-chain/Models.cs": { - "captureGroups": 24, - "digest": "c14f06f17ae5bad8aaf7ed6c5691a30b119aceeea35016e765c12cf60ba22f13" + "captureGroups": 25, + "digest": "37c48de332491deb8c31c5f1f8184e301900f87258eac47321ebf12065991cad" }, "csharp-deep-field-chain/Service.cs": { - "captureGroups": 12, - "digest": "0a5a6dfb0a872ebda19856f7e7cf805e1e72000e96ac1db5939c4df0234acc86" + "captureGroups": 13, + "digest": "a10037598e4ee49a9e2ce2c4bf397bee1ca44faec8e7abda852efe665f7dfbfa" }, "csharp-dictionary-keys-values/App.cs": { "captureGroups": 19, @@ -139,85 +139,93 @@ "captureGroups": 7, "digest": "49ec4930562a8e12040010b12535a1620aa5ab86e2aab3cad2623e8aec106518" }, - "csharp-field-types/Models.cs": { + "csharp-explicit-ctor-init/Models/Base.cs": { + "captureGroups": 8, + "digest": "a96546d27fbb8b2ddf8571f0253bb145565223e52f45d8ff0cf84c6ff5cb1ee1" + }, + "csharp-explicit-ctor-init/Models/Child.cs": { "captureGroups": 16, - "digest": "c66da699df823e98eba7ee05170001ed51a7f4ed464ece95c8bb8aa92c2cb5bc" + "digest": "fe50206ccb6df1b631bb5c22892e1e3f668181e9ba9f3b55981e9df97ff10ff2" + }, + "csharp-field-types/Models.cs": { + "captureGroups": 17, + "digest": "2e698fecbe4099c8aa5c63ba5ec7adad0dbb64912163685507a96fb681545d03" }, "csharp-field-types/Service.cs": { - "captureGroups": 9, - "digest": "c39485169d15a2b74cc3b67417aab92edf7b6c3a2d64e8e4311b14fb7f5a882e" + "captureGroups": 10, + "digest": "4cf80db658b9491052b4f629fce5f8397d1e8d537753ff3e729f2ab9c446f367" }, "csharp-foreach/Models/Repo.cs": { - "captureGroups": 7, - "digest": "65fc6c6688e7f7badead653bbef85fca225b6910eeaacb9f30d03cb28aeb1e26" + "captureGroups": 8, + "digest": "81e43aed743577f4e7e1ece1aea61bec9d4933b2e63207d44929bfdcf3d40342" }, "csharp-foreach/Models/User.cs": { - "captureGroups": 7, - "digest": "4f0c929c82e56f41e5b3b4c0aefee2121d7a307ef24535ac998fd7041d0e025b" + "captureGroups": 8, + "digest": "064640e172244bee5078073d4eed69df177681cba8c0a8105a391a65864612f9" }, "csharp-foreach/Program.cs": { - "captureGroups": 17, - "digest": "44b01246c74a987b8e0938eeda7609b2b3e068b92f1217e33151c20637eb99d7" + "captureGroups": 18, + "digest": "be6192b93b692af37eb8564714faa1486c9463c8fac4c41d4c17c47e3306474d" }, "csharp-frozen-binding-collision/App/Program.cs": { - "captureGroups": 17, - "digest": "9e53c57dcddc2ce6d33556c0ab3240cddd7f28647a8d03992c4b082db0f5e5c8" + "captureGroups": 18, + "digest": "d24df73c3f38999271b8bf0a5ea262d7e9a134b28d3aa5aa3efb6d90ef9b00a3" }, "csharp-frozen-binding-collision/Models/User.cs": { - "captureGroups": 7, - "digest": "d83de96ab701ee20bfac480d507c2d6f1c1e973fa466a9015f2cdf0d9be0803a" + "captureGroups": 8, + "digest": "2f15da33d48895b704e654f7d4324c9606983c06c955943701a1067d54c37ac5" }, "csharp-generic-parent-resolution/src/Models/BaseModel.cs": { - "captureGroups": 7, - "digest": "7773394d9ee9598dc3a9c0992c8ccf411071edde9418ab73d1c21eb5037a108a" + "captureGroups": 8, + "digest": "af08cdad747c53a0be8e32344d7af5bae07e215ce5be51685b6dd233c9e1cb6d" }, "csharp-generic-parent-resolution/src/Models/Repo.cs": { - "captureGroups": 7, - "digest": "03009e02efb0bb1fb33dae6cd15b99c46f2e11a30da79fb8696ec3066c203db0" + "captureGroups": 8, + "digest": "bc3ded7bee753993b7bb10a33f3fe4e4499cca0489fcfd58d8f80c811375a4a9" }, "csharp-generic-parent-resolution/src/Models/User.cs": { - "captureGroups": 10, - "digest": "fddcbe19d2a797949e35f8a9f04546c9f483f92c6055685651b96e4dd6e87577" + "captureGroups": 11, + "digest": "2f193c298218e84b404be421c513c2f0ee2f21c05e266b0c523b293b17529aeb" }, "csharp-generic-type-refs/Program.cs": { - "captureGroups": 22, - "digest": "d4181ce42bba3ee454f4a3b9870ea88944a0df9cd5c9147d42d930dac63b8b33" + "captureGroups": 23, + "digest": "97ce65fba71e65ad7dfebb630d9f38b0ad598d45cd461b5e1275ec80168df27b" }, "csharp-grandparent-resolution/Models/A.cs": { - "captureGroups": 9, - "digest": "2687b8e8f0b869d1ea8bdb9cf04dba3845f25ec1699b518114c57c4851b3528d" + "captureGroups": 10, + "digest": "3cd545b2cbec5fee82e9e3d09f2d2ff7ff940e3bf4b597d7c9080fcd8b526675" }, "csharp-grandparent-resolution/Models/B.cs": { - "captureGroups": 5, - "digest": "7b5140fffae831750f64f832cb19449d88cf421089ddbeb8790d58386a821b8b" + "captureGroups": 6, + "digest": "aec4240d6bd3c9bbcb97e3dc4b3b908bbac14cda0e4242009ab37cbf39f9d556" }, "csharp-grandparent-resolution/Models/C.cs": { - "captureGroups": 5, - "digest": "e18b37eacce974faa20912b3ecf0588e5c3130b14624e672973bfff718aa0db9" + "captureGroups": 6, + "digest": "9e70e0bda214975a13d2ad97c0bf7977df7019f6ababca2fe4fb8e27e412ab5a" }, "csharp-grandparent-resolution/Models/Greeting.cs": { - "captureGroups": 7, - "digest": "fa105c430ad5ff6dbf73cd93f219ba42464cab335e8bb3b29a06326e8def17be" + "captureGroups": 8, + "digest": "eccb8f6c92e45c6318989e5c9ef97127c623fc55734fa28b51f7d2fd45abe080" }, "csharp-grandparent-resolution/Services/App.cs": { - "captureGroups": 13, - "digest": "7e386e4731095480c515b8f4a538ea6eb19800345ce4c0e51d6d9307971f2a97" + "captureGroups": 14, + "digest": "ec4326a3db72676fb5e17b9fad3de503989a7f34b1b7159fe86093728fbf1737" }, "csharp-hello/Hello.cs": { - "captureGroups": 19, - "digest": "a6e6e37315c555af53aed63767a341bb3b1a4086e64d99b4b073037a4bc0284b" + "captureGroups": 20, + "digest": "140b0d111483cecb5a875d42a081fb0e19e378cd98802890210c23773350e6dd" }, "csharp-interface-default-method/App.cs": { - "captureGroups": 11, - "digest": "3f64ed5bfd0eb0bc62d73b908eebac7025f9e32639aeebc8db9160f3523b521a" + "captureGroups": 12, + "digest": "1df65065f20b8d316238bd3f7c43f95cc0e2527c8f7cca88e95a872016a04ed2" }, "csharp-interface-default-method/User.cs": { - "captureGroups": 11, - "digest": "40a8824497cc733891ef51ea960b3e44bb4ed23db9934884f78bb36d4dde78be" + "captureGroups": 12, + "digest": "647035b25d003e3ddf4763e36311b638391b0147e762865618a83285fab50f36" }, "csharp-interface-default-method/Validator.cs": { - "captureGroups": 7, - "digest": "27f5051d339f63be01534b002271db6efe647955501129b90d6fd18fb3dac18c" + "captureGroups": 8, + "digest": "15211636afe19876d03bf8e775f15bb42a73faae560d9084abf8a5d8651be2ca" }, "csharp-interface-dispatch/App.cs": { "captureGroups": 11, @@ -232,72 +240,76 @@ "digest": "65bd6fd311969af60313ea33345188894fa236ca26020b3e681fc8c3144898fb" }, "csharp-interface-heritage/src/IAuditableService.cs": { - "captureGroups": 7, - "digest": "bc413b880556ec2798ec501b020d993e7a53d1d9be1e0e0bee4d885990330fad" + "captureGroups": 8, + "digest": "de19d6c17c5b619413f9287c0a495c41c32bb9583404b93cfa32320c385360bc" }, "csharp-interface-heritage/src/IBarService.cs": { - "captureGroups": 6, - "digest": "ebd71b8cd792309f85be2bdccb2c99c8ee55a818ffb92791b8f72fbe6a04e791" + "captureGroups": 7, + "digest": "2d9ca7beacba907cb63d3d07e824c6176b28062f707ba4479e1cddbcb3528735" }, "csharp-interface-heritage/src/IBaseInterface.cs": { - "captureGroups": 6, - "digest": "cfbc24950b74dd10ddb59c25f8aa4d4b83c5e72502c4227f3f6e311e9e06401f" + "captureGroups": 7, + "digest": "9125ab3f10758f0adbffb82f67030c04e0c5d381b490c45baa8af68da075f91f" }, "csharp-interface-heritage/src/IFooService.cs": { - "captureGroups": 7, - "digest": "0d00dcf860e772b8ec539064ed97941aadb64c99fc91783d3ff657c93d00b67e" + "captureGroups": 8, + "digest": "9585eab103e57f316db36472657d5eb680cc5137abebf8c8bb0914222d281df9" }, "csharp-interface-heritage/src/MyService.cs": { - "captureGroups": 19, - "digest": "b39d4f1b8860dc16be70431b86e5bfaf0a6fb317a1e586d7e08bbdfd6682aee2" + "captureGroups": 20, + "digest": "ac1c0f71c455e55204ceb9c405a8bcc74991c2c380a184aa6d04f3282bd946bc" + }, + "csharp-interface-only-base/C.cs": { + "captureGroups": 11, + "digest": "c5a6484f74d530463f3028036f401323d84ec2a825fd52450326d95c8088ccb2" }, "csharp-interface-receiver-static/src/ILogger.cs": { - "captureGroups": 6, - "digest": "846b1d46ba9e45334e2e495f42e9d54ce7b86f36e955877b644f9f52bb5f654b" + "captureGroups": 7, + "digest": "8bfccfdf1696c38539df95c001029d4d40f09d2f84ed273e5e4ddf860dad7d10" }, "csharp-interface-receiver-static/src/Runner.cs": { - "captureGroups": 8, - "digest": "9ab5f0dd02a660099b28b88e289ac2ab724ec7eea662ba535263bd76d17d9bd4" + "captureGroups": 9, + "digest": "64c9ae1061a08798b11c48a89631d684d148fe3dcac2c9c3faefc55754bf7f41" }, "csharp-is-pattern/models/Repo.cs": { - "captureGroups": 7, - "digest": "bdbacca40f98981bacda770bc3b594f7346956ec512b017981e5dff2528428fa" + "captureGroups": 8, + "digest": "71b55515db9e3680e0cf9f0b4142cc71b25353047bcec25776801cc38be0cf5e" }, "csharp-is-pattern/models/User.cs": { - "captureGroups": 7, - "digest": "1a3928a23d3787dd0dc3ff8f4508b06ca67b9cac6f2ff9735e61ff2968fa53f6" + "captureGroups": 8, + "digest": "2c97632b3beb8f5500e4d02b1150d2845fa6d6bc26ee1838e4e1118ec6a8e847" }, "csharp-is-pattern/services/App.cs": { - "captureGroups": 10, - "digest": "669b5e0cbb49ed97ebb0721135f45e2f1e796952c39820d6c03ab9d0d2017aae" + "captureGroups": 11, + "digest": "e125e671dfcf30a41b26c29bfd57fb9fe9b8e0d1a0dabedbdd3ac1451693672e" }, "csharp-large-cache-miss-resolution/Models/User.cs": { - "captureGroups": 7, - "digest": "8902fae6ec1e6e6b2bd81e669208dd1277caea531777c9b1e1fef7492b8536f4" + "captureGroups": 8, + "digest": "7755b0c63334b12c879749f43eb23d70a929383e3706bee56547d45df5f90096" }, "csharp-large-cache-miss-resolution/Other/Helper.cs": { - "captureGroups": 4, - "digest": "2b8a0df5cb14c0c7df3d1dac09e25e8536d3d9ef7ae107570f09f92df3e3f554" + "captureGroups": 5, + "digest": "e5a4b2a93eaa9d4528a5dd71455247db84bd7bb1eb941ea03da6ccf7a0837367" }, "csharp-large-cache-miss-resolution/Services/UserService.cs": { - "captureGroups": 15, - "digest": "711b8e8828d46e47c4ec58fb988f69d119b137610c87d64a0bb0b608855e06a7" + "captureGroups": 16, + "digest": "ac7dcf56199eb29174cfbfb775e956247f72abc476cc8e28751d94084d1b5c7a" }, "csharp-local-shadow/App/Main.cs": { - "captureGroups": 12, - "digest": "eabfcf7555db7d72f29e5a4680349ff8b3be3ad2ed4247f41c67814e1d03a93a" + "captureGroups": 13, + "digest": "e1ba476d7984abedd654e461d9a971e8e566116e6414871f1962feef75b91d50" }, "csharp-local-shadow/Utils/Logger.cs": { - "captureGroups": 8, - "digest": "1b3b9b6fa338689500910b63444c4460392781db52ce875d760d724d7e6d3953" + "captureGroups": 9, + "digest": "324feff6c194e3f07493e63efe46165832033c855978358f0a0fd3cd6d6d732a" }, "csharp-member-calls/Models/User.cs": { - "captureGroups": 7, - "digest": "4f0c929c82e56f41e5b3b4c0aefee2121d7a307ef24535ac998fd7041d0e025b" + "captureGroups": 8, + "digest": "064640e172244bee5078073d4eed69df177681cba8c0a8105a391a65864612f9" }, "csharp-member-calls/Services/UserService.cs": { - "captureGroups": 12, - "digest": "2bbba859b5b5840dd880fac07de97a289be88f6ea11e0eadca652d66cad0b20a" + "captureGroups": 13, + "digest": "662512b485c31efe8b5a37df38e31bccf130e7625c7ef7594ea44e27b0b79d2c" }, "csharp-method-chain-binding/App.cs": { "captureGroups": 53, @@ -324,12 +336,24 @@ "digest": "4f25b7d6327c0f9d1f4ca9e8d1af07ced3b6fedbfbb8e4996c73cbfb963ef24e" }, "csharp-namespace-as-root-no-trailing-newline/App/Program.cs": { - "captureGroups": 12, - "digest": "de7d96908b66cbabf13203aa6ffa613e7cb463ed98e1073d98efc0d10a26a11b" + "captureGroups": 13, + "digest": "573014b81ec482ac5136b8587ac8773cf9a363edd2752ad7f147d2f6ee9d2b7e" }, "csharp-namespace-as-root-no-trailing-newline/Models/User.cs": { - "captureGroups": 7, - "digest": "c024ea5f5d7dc3ecfe98be3d3b38d18bbe358974bfce69514c2474f5e44d6490" + "captureGroups": 8, + "digest": "788e9818bd944c60d6ddee345242fd9a108d5e264b6195fd662deadf0b00fb1c" + }, + "csharp-namespace-qualified-ctor/A/App.cs": { + "captureGroups": 11, + "digest": "53f8bf268d11568eaac588fb2c3098daef3fe0b8aae9a48d8cf25fee8fe68e36" + }, + "csharp-namespace-qualified-ctor/A/Foo.cs": { + "captureGroups": 8, + "digest": "ea990b34ad9fd75f434287bc60697a0af3507de2413eaefd7ae937727f9b6ec6" + }, + "csharp-namespace-qualified-ctor/B/Foo.cs": { + "captureGroups": 8, + "digest": "b7b606a85d0821a7fa6e840dac5d3ba3caa96af15279a4dab1685e8827e49f8e" }, "csharp-nested-member-foreach/App.cs": { "captureGroups": 19, @@ -344,36 +368,36 @@ "digest": "49ec4930562a8e12040010b12535a1620aa5ab86e2aab3cad2623e8aec106518" }, "csharp-no-csproj/Models/User.cs": { - "captureGroups": 10, - "digest": "861d7c13c4460ccce77d898d7720c7ae0d8eef49af4cee9c8fd09f1f1e59b36f" + "captureGroups": 11, + "digest": "a498ba55ee638fdbec275c693bcd18999ee2acfcb477a28c27eec8ff4e70a05d" }, "csharp-no-csproj/Services/UserService.cs": { - "captureGroups": 12, - "digest": "a9fae488a7a5ce393554f0c25ccf6d23ea02c78590ec9fcdb1a93e37515c3587" + "captureGroups": 13, + "digest": "6a8c51ea371c5c1a072819d9205b714c2754c3120cbae98b020c425e96713ae7" }, "csharp-null-check-narrowing/Models/Repo.cs": { - "captureGroups": 7, - "digest": "36f2a415f8e92380a6809045c632fad8185cae70f63d76433a7ffb6dc2f89e72" + "captureGroups": 8, + "digest": "6d84ab1cb8f02d8aafe864ed6b450afd0d24c32f9643ec8548009ed5c382b3a1" }, "csharp-null-check-narrowing/Models/User.cs": { - "captureGroups": 7, - "digest": "81148127c77341020f9841d665aca8b40868f1121eafdb27ddf0265690a6e31f" + "captureGroups": 8, + "digest": "d019925a0e3ab366b3fb8fbd1abac38f564b9f4877ea0dd0b802c181c07c8587" }, "csharp-null-check-narrowing/Services/App.cs": { - "captureGroups": 29, - "digest": "64c9dfa8df03023edb1728cecd320d8a0cea67ccb5701e26461a26f08f8c544a" + "captureGroups": 30, + "digest": "5c41de929accffe5f747a6891ca323081e529e877171d4545bd7e47d6400a8cd" }, "csharp-null-conditional/App.cs": { - "captureGroups": 16, - "digest": "4c6ac6c2d8e17d4f4b0921dd139947ccb7b4cb950b895e3d6ee939a873918cee" + "captureGroups": 17, + "digest": "13a4025826cabe6d5fb2272715790af2ae4fffb46275c560f3019388c36c2e69" }, "csharp-null-conditional/Models/Repo.cs": { - "captureGroups": 7, - "digest": "1bd630cb2c4b938d97ee7416ce1542feacd0b6d40163f76cdec7fcf3df59bd0b" + "captureGroups": 8, + "digest": "5936132d4411a2e74b40d8a5a117ed4d5af7091b2c85706ce265eb8e7c63faa2" }, "csharp-null-conditional/Models/User.cs": { - "captureGroups": 7, - "digest": "4f0c929c82e56f41e5b3b4c0aefee2121d7a307ef24535ac998fd7041d0e025b" + "captureGroups": 8, + "digest": "064640e172244bee5078073d4eed69df177681cba8c0a8105a391a65864612f9" }, "csharp-optional-params/Services/App.cs": { "captureGroups": 15, @@ -392,156 +416,168 @@ "digest": "8b67299c96e0331a44bfd56edf13bc71f957e39bfa95888108d3180f454050a3" }, "csharp-overload-interface/App/Caller.cs": { - "captureGroups": 13, - "digest": "67439018a3750918b66735e67dc4edcef8ffaacd37d97a9efcc3504c492a1a0a" + "captureGroups": 14, + "digest": "8b37ca49176cdc59f0ad0033b75b1936c80eb1c74db4475876923c098a52b496" }, "csharp-overload-interface/App/Logger.cs": { - "captureGroups": 10, - "digest": "1f78db08c2a7a8d3402cf3b0f092ec6aa5b6aa9fedc790d088eaffce8e4bd713" + "captureGroups": 11, + "digest": "6e363259f8427756f497269ca0a3f4838ce16ad7518be6fe76289ee766cbc8e0" }, "csharp-overload-interface/Greeting/EnGreeter.cs": { - "captureGroups": 9, - "digest": "fa1429dc0e98f268ad3360d9ed55534dd39aa49b933771dd628a7b517f36fef6" + "captureGroups": 10, + "digest": "f806c6e6d0e983e13a1ae2c469c5f3a873dc5758465339ab8003b225b203056a" }, "csharp-overload-interface/Greeting/FrGreeter.cs": { - "captureGroups": 9, - "digest": "3a3ec96334e17fa0c48e41d7766238eca3ae4d55d206b21b0f89c1d67d9c4f93" + "captureGroups": 10, + "digest": "3219bd62296932198db2ae787e58351c6775b3cb5b5e4572570c51fb96afbff2" }, "csharp-overload-interface/Greeting/IGreeter.cs": { - "captureGroups": 6, - "digest": "0de9dec9ee677f1f6ce37654af62bcfab81270c12713d806475397936957eb23" + "captureGroups": 7, + "digest": "4a42025fdcb4db0bf1b77e1e4fa024af5c2a4dc36fe6806ce397b980047790d0" }, "csharp-overload-param-types/Models/UserService.cs": { - "captureGroups": 27, - "digest": "84cd0161f46cf334a85ed210739df5c40ae8fc7e8d8c7a4cbc3e459d2927ee1c" + "captureGroups": 28, + "digest": "b934c80be640f7d5707d5f03b352c059ed225272d494f93337805ad9b6ece895" }, "csharp-parent-resolution/src/Models/BaseModel.cs": { - "captureGroups": 7, - "digest": "7eb6d696b2699b0b2ed65dbd77061dc3978a469dd412e6ddab2fc17ea273df1d" + "captureGroups": 8, + "digest": "a4b79a6032bf181f1f3dbc32ab618c2c04a568c07af5d1984fff33217606812c" }, "csharp-parent-resolution/src/Models/ISerializable.cs": { - "captureGroups": 6, - "digest": "93f2d7c639083aa077ba3c9d2dea678d0d6a2aa914e8078307c98f4c68530226" + "captureGroups": 7, + "digest": "0a3610dcb0707b1ce10d53d782a0cbc8bcd8be482d366f4c1b2d0796a7ca0b10" }, "csharp-parent-resolution/src/Models/User.cs": { - "captureGroups": 10, - "digest": "848b4ec5fc727efb001134164da1158db495776bf006300fd58391040f6f906f" + "captureGroups": 11, + "digest": "9ed898dbf8e3e9ae1959e9c5189f75c5a134f6421679fa1ff9f81add0e9ed00c" }, "csharp-pattern-matching/Models/Animal.cs": { - "captureGroups": 19, - "digest": "40f80b8588591728ed99f3a3566dcefc14df511f8350cdc6843401e0f943c8ee" + "captureGroups": 20, + "digest": "b724c5e2a0742901a09a8c85e0fd5504905471bd8096e6541b05c2d2a1d65527" }, "csharp-pattern-matching/Services/AnimalService.cs": { - "captureGroups": 11, - "digest": "5418fbf243683914fea8eeed02f2ef856b02ba6c7e361e001da4662eb1332359" + "captureGroups": 12, + "digest": "209cee0681fa4f035a081f44e38f9cd39eda3d56936471396f78f5265d745e4b" }, "csharp-primary-ctor-heritage/src/BaseEntity.cs": { - "captureGroups": 5, - "digest": "b1ed570e315646104e03429cee0ea982f8563bce337a31293b3718f63679c2b2" + "captureGroups": 6, + "digest": "6dd168d40d7a04e8ef5e15e615f6281d3eaf917d79918f30c2261117c2edad12" }, "csharp-primary-ctor-heritage/src/IFoo.cs": { - "captureGroups": 6, - "digest": "b19bf122c62247868382dd45b258757b653e526fd3fda8cabe080f7f83510891" + "captureGroups": 7, + "digest": "c9958c4abd7fd4ccf4b9c49e039999b47ca6c507384de7eb63d78765d39df604" }, "csharp-primary-ctor-heritage/src/Repo.cs": { - "captureGroups": 6, - "digest": "3b339a7ef549da1554aa28d83a2d0402d79c1a7c483538e0266f283ac6175307" + "captureGroups": 7, + "digest": "33d96df54df2f50a1f6a443d203625f3dd6c61e70ed10a71ba920597c6810035" }, "csharp-primary-ctor-heritage/src/Service.cs": { - "captureGroups": 5, - "digest": "9514716e0d445012f9ff62e9b53d8f48e633d1f74b05c9cf90d85f8393c2015f" + "captureGroups": 6, + "digest": "7ef5f84d5ab5764ee08c2de893f172af36fe9e3d264ed4b812ff67c8fa824453" }, "csharp-primary-ctor-heritage/src/User.cs": { - "captureGroups": 11, - "digest": "36d4762bfb9ff4083315384560ae6716d12edd0f2d13550506d55721573fd214" + "captureGroups": 12, + "digest": "d00fce2f19f4bd9138bbcf799dcd2c549ac014c202fafb35bfc3b03235a40852" }, "csharp-primary-ctors/App.cs": { "captureGroups": 14, "digest": "4c2e190ed675ac78244e0a4230d79e9e4c74f2ef794dd9b8126f6b57a4242f4c" }, "csharp-primary-ctors/Models/Person.cs": { - "captureGroups": 5, - "digest": "f11b11a31ad7a691a0369d61ce8c067c2d0755515fce4fee2657b6e444c7f21a" + "captureGroups": 6, + "digest": "07f166295f77ad0b1bb122971344e089dd76d3e277ed7994b927364e7880ba07" }, "csharp-primary-ctors/Models/User.cs": { - "captureGroups": 10, - "digest": "5233b368adda16fb7573e5aedbd3be0692f166eddff9ee987a0be93c7eb1a4e8" + "captureGroups": 11, + "digest": "01bb40f83fca48db34d1c1c970317cf0fece5af7eb7e02836ef9e64304154b3a" }, "csharp-proj/Interfaces/IRepository.cs": { - "captureGroups": 12, - "digest": "f3e98fc370c43743c84d82432ed7e5c7039acd0032ee1194d53453ba2c28f132" + "captureGroups": 13, + "digest": "12e7cea9ae7c97e97789f2ef83e689530002c084b06a904a17994ce43f8b6af4" }, "csharp-proj/Models/BaseEntity.cs": { - "captureGroups": 8, - "digest": "633570540177423d352b44b0f5230d7f440f101789d1b92192c0fc360e6c26d3" + "captureGroups": 9, + "digest": "d0fec96c964b34f5f920cccccfeb0ad4408bccc71ba822f5db1529a65d7edbc4" }, "csharp-proj/Models/User.cs": { - "captureGroups": 20, - "digest": "1bc7f070357cf54c14c4c1c7967ceb4caeb2a4a1aba0fa32e1e6c6c672a97ea4" + "captureGroups": 21, + "digest": "6c1b2e9adee1a7cf34ecd647be966c28aab7a8ac57c0fcb14373f2e562f3f456" }, "csharp-proj/Services/UserService.cs": { - "captureGroups": 19, - "digest": "42b79c274955abcd548ca842077e05b0b6b46e324dd29f7a297882ac790ce11f" + "captureGroups": 20, + "digest": "26ff7130ea2290fefd0160458c2b1fb7cd8572643f04545a634696a1f1773e5f" }, "csharp-qualified-base/src/Domain.cs": { - "captureGroups": 15, - "digest": "76cdfe6e0bb9ace81fda8e4c3e1b5bea536b3d906170ee92c68a8b4d5b918fbc" + "captureGroups": 16, + "digest": "44e6d4b1da7a703f71ed04436946bd2a2d7d5695b62e4ba247573be3a2ad1d7e" }, "csharp-qualified-base/src/Shapes.cs": { - "captureGroups": 38, - "digest": "eb485282a87a35dac553701e6560d26b4e01f585fa36ded4576bbce00c8c163a" + "captureGroups": 39, + "digest": "f0596aeaf7f703a890f34a425c09d0b551ffd126933ff9bb9f326381bf7f9314" + }, + "csharp-qualified-constructor/App.cs": { + "captureGroups": 12, + "digest": "fbddf8b88f21041edcd3cb1aa0b50e50e00346a18062380d09ddf46e7e06924d" + }, + "csharp-qualified-constructor/Models/Box.cs": { + "captureGroups": 8, + "digest": "be25e3fb2b928fda0b47cf2bccda6857fe76be0173fe540d34170d44b84ec35d" + }, + "csharp-qualified-constructor/Models/Widget.cs": { + "captureGroups": 11, + "digest": "c5cc4d62e4d820f275d3dafea1cc841cac8573b3f2975de7f9c9fb5423cc9cec" }, "csharp-qualified-types/Data/User.cs": { - "captureGroups": 7, - "digest": "16b056de69cc44d953e4cc702f22986b61d935193c4d6bd9cd9a1adcbe2b3e6c" + "captureGroups": 8, + "digest": "e363f2433ece7607a56e38b237dce54c825c786d1606967138c82e247b3866c0" }, "csharp-qualified-types/Services/User.cs": { - "captureGroups": 7, - "digest": "c3a275cbcdcdd71b6611df01710d0e7d201924aada837f35d8c956207a5a17c4" + "captureGroups": 8, + "digest": "1d010b1c4cd44330d64fb49d5e726c834ba310f9c79a3e2927d8e8d01882031e" }, "csharp-receiver-resolution/App.cs": { - "captureGroups": 18, - "digest": "41bbfa18dc9cce94ce04de96f01381f86e5fedb3d7c6acf3cbcb8de2b45110e8" + "captureGroups": 19, + "digest": "05af6cc0f7f1ed5da0c19010b918304c66571ed7f4c07b497afbd96a2eb1d844" }, "csharp-receiver-resolution/Models/Repo.cs": { - "captureGroups": 7, - "digest": "65fc6c6688e7f7badead653bbef85fca225b6910eeaacb9f30d03cb28aeb1e26" + "captureGroups": 8, + "digest": "81e43aed743577f4e7e1ece1aea61bec9d4933b2e63207d44929bfdcf3d40342" }, "csharp-receiver-resolution/Models/User.cs": { - "captureGroups": 7, - "digest": "4f0c929c82e56f41e5b3b4c0aefee2121d7a307ef24535ac998fd7041d0e025b" + "captureGroups": 8, + "digest": "064640e172244bee5078073d4eed69df177681cba8c0a8105a391a65864612f9" }, "csharp-record-base/src/Models/BaseEntity.cs": { - "captureGroups": 8, - "digest": "1ed96b53bfd3a0ed13a7931e82b0b47f4c4acfc23d23f04d4053c7ad994cf9f9" + "captureGroups": 9, + "digest": "1a4fc04784f1de00213fedbfdf103acd095c6f9a64437951a831c5182c45d073" }, "csharp-record-base/src/Models/UserRecord.cs": { - "captureGroups": 11, - "digest": "416980acf3f3df44c96c375de8cd683031e053b3d82cf1a0ea5ae66b056438b6" + "captureGroups": 12, + "digest": "bbb90a4b0e8854f12cf686174d28cc5af0e88603a7cdeedddb13c90b1f39dbf6" }, "csharp-recursive-pattern/Models/Repo.cs": { - "captureGroups": 8, - "digest": "089242f5a0e1d4338002f9f72788116e285ee24f719e702bc15a1858e7a04d97" + "captureGroups": 9, + "digest": "1cf495c2e668834c355f87f314983cc2a575ae8b039b404ae675e8ba6592f7e3" }, "csharp-recursive-pattern/Models/User.cs": { - "captureGroups": 8, - "digest": "a460c8057d4d5306947c2fec7ef92fd5b8e2e499d99b560f9f9a837075e43795" + "captureGroups": 9, + "digest": "80b9061d53bd0ceba23699c828100ebce5af9a7c006fff7e4a99d2f8e006b92e" }, "csharp-recursive-pattern/Program.cs": { - "captureGroups": 13, - "digest": "4bb642f82e6de5de638eb9ffb9f8fa79a50e32a8fb5b7f71ee92368b96a350a9" + "captureGroups": 14, + "digest": "c2fe0ca3b5bf9b26774ee896b7f2fbae1cdaaf3c27d1469897cde3547c297bae" }, "csharp-return-type/Models/Repo.cs": { - "captureGroups": 7, - "digest": "ab4a56185a33d8afcbcf9a4c60bb8c6c90be611bc9e61516a768a2c67f4616f5" + "captureGroups": 8, + "digest": "4953327fdde44fada4df0c55bd1dab9c2e96b28f42a1fbfb224c374f75ce0fbb" }, "csharp-return-type/Models/User.cs": { - "captureGroups": 19, - "digest": "5f0eebeef76cfcf10180cd431fc14443de0a7c6d501a55a24ae774a94bca2bb7" + "captureGroups": 20, + "digest": "7a41c40abf08b3fbc68b34e581636b51dcfb32c9e49dd195e5dfe29680d3cf2e" }, "csharp-return-type/Services/App.cs": { - "captureGroups": 15, - "digest": "d3634ba17d4d96216df5fd0d7e967309dd32793636598b2d160fd19a2eea3e25" + "captureGroups": 16, + "digest": "6c22fca1da52147fdbfbe48f1563e95f291ef0af8856b5c6e82ed04a110d064e" }, "csharp-same-arity-cross-file/App.cs": { "captureGroups": 47, @@ -560,92 +596,92 @@ "digest": "ee3c7f29ba0638d2917d364fa39cd80220655b4ee0825eda1d2640e7d9e8c500" }, "csharp-self-this-resolution/src/Models/Repo.cs": { - "captureGroups": 7, - "digest": "03009e02efb0bb1fb33dae6cd15b99c46f2e11a30da79fb8696ec3066c203db0" + "captureGroups": 8, + "digest": "bc3ded7bee753993b7bb10a33f3fe4e4499cca0489fcfd58d8f80c811375a4a9" }, "csharp-self-this-resolution/src/Models/User.cs": { - "captureGroups": 11, - "digest": "769120fcebccfa2088d22b3a5a1dcabf49ab134fae03688a26b5ca2dafe6aca9" + "captureGroups": 12, + "digest": "2c2f55fde8400c170c7cedae5a126fb2b1c2616042520a309f3109995a5f49ff" }, "csharp-spurious-edges-no-csproj/Legacy/System/Threading/Tasks.cs": { - "captureGroups": 7, - "digest": "41d7afa4d3c8cddaaba2dce5c725964a04cc20c7b3d766cbb163ec3e19ff0080" + "captureGroups": 8, + "digest": "587919015c220cc7d96e2cc894a9d8f700638020c3e276f3145ceb004b68eb94" }, "csharp-spurious-edges-no-csproj/Models/User.cs": { - "captureGroups": 5, - "digest": "b9d373709df7aef537bbdf28cf00d9a71b8ef8a1d217180e60c9c70c511d6c64" + "captureGroups": 6, + "digest": "98a71656fe2604bf0912b159ce4c98c0f0f6916f601dd894e70c001ac8092c12" }, "csharp-spurious-edges-no-csproj/Services/OrderService.cs": { - "captureGroups": 14, - "digest": "36e86f9b88e113c1195564ad7d5ded5cbba7250ef0d4b2dfb7187e2fa6b7f9e4" + "captureGroups": 15, + "digest": "2574c61dc312d531301a6d08c828ac743b1198e32946980c0f44bc115ec9bcdc" }, "csharp-spurious-edges/Legacy/Tasks.cs": { - "captureGroups": 7, - "digest": "6c7f8cd5275bb0d9754190762b6381424fbe86d0718739ab7e40757a0c0cd446" + "captureGroups": 8, + "digest": "90e15db1a68590cf57a8373c362328b7eefcc522327bca945d1f61a9c7d5f7e3" }, "csharp-spurious-edges/Models/User.cs": { - "captureGroups": 5, - "digest": "b9d373709df7aef537bbdf28cf00d9a71b8ef8a1d217180e60c9c70c511d6c64" + "captureGroups": 6, + "digest": "98a71656fe2604bf0912b159ce4c98c0f0f6916f601dd894e70c001ac8092c12" }, "csharp-spurious-edges/Services/OrderService.cs": { - "captureGroups": 14, - "digest": "36e86f9b88e113c1195564ad7d5ded5cbba7250ef0d4b2dfb7187e2fa6b7f9e4" + "captureGroups": 15, + "digest": "2574c61dc312d531301a6d08c828ac743b1198e32946980c0f44bc115ec9bcdc" }, "csharp-struct-overloads/src/Calc.cs": { - "captureGroups": 15, - "digest": "fabf22986700fb7bff0521b504e103583dd2b204b1765a3bbd7b5062d310f9a0" + "captureGroups": 16, + "digest": "03b9f3273cdd46364956b6cf15d29a849c6be70567d7d26ffd23023f60a24591" }, "csharp-super-resolution/src/Models/BaseModel.cs": { - "captureGroups": 7, - "digest": "3e5355115690e1b80947138c0bb1ef9f1a3a3073371bed6cfb354e7367c0d46e" + "captureGroups": 8, + "digest": "6042759bbc73b821292cbfaa01a0d39cd7cfd29897c81729806c6585ba8aa147" }, "csharp-super-resolution/src/Models/Repo.cs": { - "captureGroups": 7, - "digest": "03009e02efb0bb1fb33dae6cd15b99c46f2e11a30da79fb8696ec3066c203db0" + "captureGroups": 8, + "digest": "bc3ded7bee753993b7bb10a33f3fe4e4499cca0489fcfd58d8f80c811375a4a9" }, "csharp-super-resolution/src/Models/User.cs": { - "captureGroups": 10, - "digest": "7884f0582c5b433845bfc9a8e331e8939638ee4e2308e0d8d8767f8332209a4b" + "captureGroups": 11, + "digest": "cac9a6305ac80c58a31b874d52924f7d177cc4696a9eb74d152af0effbdb59b7" }, "csharp-switch-pattern/Models/Repo.cs": { - "captureGroups": 7, - "digest": "b539bad52d6d4ace3120ff489d2524717ba6c9cdd9df8f242e6dc2848a9c8731" + "captureGroups": 8, + "digest": "5e51e630a56363e7da611cf69e747636c493fb09d88a3e68b0c8eef193f82a41" }, "csharp-switch-pattern/Models/User.cs": { - "captureGroups": 7, - "digest": "a16151da571f84e36a531970767568ce684e09bf47a6ec5ce50410e7be675a09" + "captureGroups": 8, + "digest": "c51e7c7e0423087ddd3a439b99c95101a3bb8611bb6677d7bd1630a464a8cacb" }, "csharp-switch-pattern/Program.cs": { - "captureGroups": 12, - "digest": "49dbf7471b0bfbf26b37a82ab0794e74cf6ffcc2a04fed5dc3c9df3bece8e38c" + "captureGroups": 13, + "digest": "bee49e211ae6d5a6391a56a68044762500d600e2a8a5b9d78c1350e342dcab19" }, "csharp-using-static/App/Calculator.cs": { - "captureGroups": 9, - "digest": "9e6b557a1920d5f793e0bc6c6bbdf4dc9d74d7ff50e7b833961ec089e265551f" + "captureGroups": 10, + "digest": "9f1d4ed78f5653ce5da1604afb322ee10ad212fbc4f791612a77a52530a8fa07" }, "csharp-using-static/Helpers/MathUtils.cs": { - "captureGroups": 6, - "digest": "054eff66bd3582079779542135943e70f9d9f95b03329e45944eb85077290c08" + "captureGroups": 7, + "digest": "f6db029267c1562d1b5defe08089f66f9bd183509b0a7a9df4f638d9170b36f1" }, "csharp-var-foreach/Models/Repo.cs": { - "captureGroups": 7, - "digest": "b539bad52d6d4ace3120ff489d2524717ba6c9cdd9df8f242e6dc2848a9c8731" + "captureGroups": 8, + "digest": "5e51e630a56363e7da611cf69e747636c493fb09d88a3e68b0c8eef193f82a41" }, "csharp-var-foreach/Models/User.cs": { - "captureGroups": 7, - "digest": "a16151da571f84e36a531970767568ce684e09bf47a6ec5ce50410e7be675a09" + "captureGroups": 8, + "digest": "c51e7c7e0423087ddd3a439b99c95101a3bb8611bb6677d7bd1630a464a8cacb" }, "csharp-var-foreach/Program.cs": { - "captureGroups": 27, - "digest": "9b415bc4739d5ee3e4e538f225a96694391029be69b662de35d9edf47116f345" + "captureGroups": 28, + "digest": "f70572e67859f3e853b84a123d70836324509abb1e60e4e4179b354e3bb52988" }, "csharp-variadic-resolution/Services/App.cs": { - "captureGroups": 9, - "digest": "51b9c158776e89f6be4225849f31470889c442c6e064f5c38bc32806a691106b" + "captureGroups": 10, + "digest": "892a1570c699c625fa1be6d4c53216589622a1b7df5e297a625edffd2fc31e14" }, "csharp-variadic-resolution/Utils/Logger.cs": { - "captureGroups": 7, - "digest": "abc447c521d792d089fefc63ac281c5af0f94dd76dc284e83296f20617ab6b14" + "captureGroups": 8, + "digest": "835f4084b55107b725ac675350df638be92436685d39eac1518b234fd9f618e3" }, "csharp-write-access/Models.cs": { "captureGroups": 9, @@ -656,7 +692,7 @@ "digest": "1782eca84697c9ebd7fe802d1e0e0952d29cef8d8834e826b359388561a41c06" }, "synthetic:dao-20": { - "captureGroups": 222, - "digest": "058e8fd3360af32ce483e6be7887ee12cd1a4150904b43f88d1fa7bd9bb7577b" + "captureGroups": 223, + "digest": "05b2727c2e20430921fe536ad4d421764c9791e5f370fdce97c3fb0d07ada703" } } diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-explicit-ctor-init/Models/Base.cs b/gitnexus/test/fixtures/lang-resolution/csharp-explicit-ctor-init/Models/Base.cs new file mode 100644 index 000000000..1e4dee5bb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-explicit-ctor-init/Models/Base.cs @@ -0,0 +1,6 @@ +namespace Models; + +public class Base +{ + public Base(int x) {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-explicit-ctor-init/Models/Child.cs b/gitnexus/test/fixtures/lang-resolution/csharp-explicit-ctor-init/Models/Child.cs new file mode 100644 index 000000000..ffa0b2ee7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-explicit-ctor-init/Models/Child.cs @@ -0,0 +1,8 @@ +namespace Models; + +public class Child : Base +{ + public Child() : base(1) {} + + public Child(int x) : this() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-only-base/C.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-only-base/C.cs new file mode 100644 index 000000000..02d52ba5c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-only-base/C.cs @@ -0,0 +1,6 @@ +interface IFoo {} + +class C : IFoo +{ + public C() : base() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-namespace-qualified-ctor/A/App.cs b/gitnexus/test/fixtures/lang-resolution/csharp-namespace-qualified-ctor/A/App.cs new file mode 100644 index 000000000..e2d8facbe --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-namespace-qualified-ctor/A/App.cs @@ -0,0 +1,9 @@ +namespace A; + +public class App +{ + public void Make() + { + var x = new B.Foo(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-namespace-qualified-ctor/A/Foo.cs b/gitnexus/test/fixtures/lang-resolution/csharp-namespace-qualified-ctor/A/Foo.cs new file mode 100644 index 000000000..82a24b313 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-namespace-qualified-ctor/A/Foo.cs @@ -0,0 +1,6 @@ +namespace A; + +public class Foo +{ + public Foo() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-namespace-qualified-ctor/B/Foo.cs b/gitnexus/test/fixtures/lang-resolution/csharp-namespace-qualified-ctor/B/Foo.cs new file mode 100644 index 000000000..878379ce9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-namespace-qualified-ctor/B/Foo.cs @@ -0,0 +1,6 @@ +namespace B; + +public class Foo +{ + public Foo() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-qualified-constructor/App.cs b/gitnexus/test/fixtures/lang-resolution/csharp-qualified-constructor/App.cs new file mode 100644 index 000000000..49bb954f9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-qualified-constructor/App.cs @@ -0,0 +1,8 @@ +public class App +{ + public void Make() + { + var w = new Models.Widget(); + var b = new Models.Box(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-qualified-constructor/Models/Box.cs b/gitnexus/test/fixtures/lang-resolution/csharp-qualified-constructor/Models/Box.cs new file mode 100644 index 000000000..ac92013dc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-qualified-constructor/Models/Box.cs @@ -0,0 +1,6 @@ +namespace Models; + +public class Box +{ + public Box() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-qualified-constructor/Models/Widget.cs b/gitnexus/test/fixtures/lang-resolution/csharp-qualified-constructor/Models/Widget.cs new file mode 100644 index 000000000..715ff30c9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-qualified-constructor/Models/Widget.cs @@ -0,0 +1,8 @@ +namespace Models; + +public class Widget +{ + public Widget() {} + + public void Render() {} +} diff --git a/gitnexus/test/integration/resolvers/csharp-1924.test.ts b/gitnexus/test/integration/resolvers/csharp-1924.test.ts new file mode 100644 index 000000000..1ed96ab7a --- /dev/null +++ b/gitnexus/test/integration/resolvers/csharp-1924.test.ts @@ -0,0 +1,100 @@ +/** + * C# parsing-layer coverage gaps mirroring the Java #1928 findings — end-to-end. + * + * - F35: qualified / qualified-generic constructor calls (`new Ns.Foo()`, + * `new Ns.Box()`) resolve to the target constructor/class instead + * of dropping the edge on a corrupted `Ns.Foo` reference name. + * - F38: `: base(...)` / `: this(...)` constructor initializers emit CALLS + * edges to the base / sibling constructor. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js'; + +describe('C# qualified constructor resolution (F35, mirror of Java #1928)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-qualified-constructor'), + () => {}, + ); + }, 60000); + + it('resolves `new Models.Widget()` to the Widget type', () => { + const calls = getRelationships(result, 'CALLS'); + const widget = calls.find((c) => c.source === 'Make' && c.target === 'Widget'); + expect(widget).toBeDefined(); + expect(['Class', 'Constructor']).toContain(widget!.targetLabel); + }); + + it('resolves `new Models.Box()` to the Box type', () => { + const calls = getRelationships(result, 'CALLS'); + const box = calls.find((c) => c.source === 'Make' && c.target === 'Box'); + expect(box).toBeDefined(); + expect(['Class', 'Constructor']).toContain(box!.targetLabel); + }); + + it('never emits a CALLS edge to a corrupted qualified/raw name', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.some((c) => c.target.includes('.') || c.target.includes('new '))).toBe(false); + }); +}); + +describe('C# explicit constructor initializer resolution (F38, mirror of Java #1928)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-explicit-ctor-init'), () => {}); + }, 60000); + + it('resolves `: base(1)` to the Base type', () => { + const calls = getRelationships(result, 'CALLS'); + const baseCall = calls.find((c) => c.target === 'Base'); + expect(baseCall).toBeDefined(); + expect(baseCall!.source).toBe('Child'); + expect(['Class', 'Constructor']).toContain(baseCall!.targetLabel); + }); + + it('resolves `: this()` to a DISTINCT sibling Child constructor (no self-loop)', () => { + const calls = getRelationships(result, 'CALLS'); + const thisCall = calls.find((c) => c.target === 'Child' && c.source === 'Child'); + expect(thisCall).toBeDefined(); + expect(thisCall!.targetLabel).toBe('Constructor'); + expect(thisCall!.rel.sourceId).not.toBe(thisCall!.rel.targetId); + expect(thisCall!.rel.sourceId).toMatch(/Child\.Child#1/); + expect(thisCall!.rel.targetId).toMatch(/Child\.Child#0/); + }); +}); + +describe('C# interface-only `: base()` must not target an interface (#2046)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-interface-only-base'), () => {}); + }, 60000); + + it('emits no CALLS edge to IFoo from `: base()` on `class C : IFoo`', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.some((c) => c.target === 'IFoo')).toBe(false); + expect(calls.some((c) => c.targetLabel === 'Interface')).toBe(false); + }); +}); + +describe('C# qualified constructor resolves by qualifier, not same-tail local (#2046)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-namespace-qualified-ctor'), + () => {}, + ); + }, 60000); + + it('resolves `new B.Foo()` to the Foo in namespace B, not the colliding A.Foo', () => { + const calls = getRelationships(result, 'CALLS'); + const hit = calls.find((c) => c.source === 'Make' && c.target === 'Foo'); + expect(hit).toBeDefined(); + expect(hit!.targetFilePath).toBe('B/Foo.cs'); + }); +}); diff --git a/gitnexus/test/unit/ingestion/qualified-name-utils.test.ts b/gitnexus/test/unit/ingestion/qualified-name-utils.test.ts new file mode 100644 index 000000000..9c65b303c --- /dev/null +++ b/gitnexus/test/unit/ingestion/qualified-name-utils.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { + normalizeQualifiedName, + stripTrailingTypeArguments, +} from '../../../src/core/ingestion/utils/qualified-name.js'; + +describe('stripTrailingTypeArguments', () => { + it('strips a single generic arg list at depth 0', () => { + expect(stripTrailingTypeArguments('Models.Box')).toBe('Models.Box'); + }); + + it('preserves nested generic brackets in the type name', () => { + expect(stripTrailingTypeArguments('Ns.Outer.Inner')).toBe('Ns.Outer.Inner'); + }); + + it('composes with normalizeQualifiedName for qualified ctor sites', () => { + const key = stripTrailingTypeArguments(normalizeQualifiedName('Models.Box')); + expect(key).toBe('Models.Box'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts b/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts index 410452fb3..98e4a5db8 100644 --- a/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts +++ b/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts @@ -1,523 +1,137 @@ /** - * Unit 1 coverage for the C# scope query + captures orchestrator. + * Low-level coverage for `emitCsharpScopeCaptures`, focused on the C# analogs of + * the Java #1928 parsing-layer fixes: * - * Pins the capture-tag vocabulary + range shape for every construct - * the scope-resolution pipeline reads. Runs against tree-sitter-c-sharp - * so it catches grammar drift (node renames, field-name changes) - * before the integration parity gate does. + * - F35: qualified / qualified-generic constructor calls bind the simple-name + * tail as @reference.name (not the raw `Ns.Foo` text). + * - F38: `: base(...)` / `: this(...)` constructor initializers are captured as + * @reference.call.constructor references with arity. */ - import { describe, it, expect } from 'vitest'; import { emitCsharpScopeCaptures } from '../../../../src/core/ingestion/languages/csharp/captures.js'; -function tagsFor(src: string): string[][] { - const matches = emitCsharpScopeCaptures(src, 'test.cs'); - return matches.map((m) => Object.keys(m).sort()); +function ctorRefs(src: string) { + return emitCsharpScopeCaptures(src, 'C.cs') + .filter((m) => m['@reference.call.constructor'] !== undefined) + .map((m) => ({ + name: m['@reference.name']?.text, + qualified: m['@reference.call.constructor.qualified']?.text, + qualifiedName: m['@reference.qualified-name']?.text, + arity: m['@reference.arity']?.text, + })); } -function findMatch(src: string, predicate: (tags: string[]) => boolean) { - const matches = emitCsharpScopeCaptures(src, 'test.cs'); - return matches.find((m) => predicate(Object.keys(m))); -} +const wrap = (expr: string) => `class C { void M() { ${expr} } }`; -describe('emitCsharpScopeCaptures — scopes', () => { - it('captures the compilation unit as @scope.module', () => { - const all = tagsFor('class A { }'); - expect(all.some((t) => t.includes('@scope.module'))).toBe(true); +describe('emitCsharpScopeCaptures — qualified constructor names (F35)', () => { + it('binds the simple name for an unqualified `new Foo()`', () => { + expect(ctorRefs(wrap('var x = new Foo();'))).toContainEqual({ + name: 'Foo', + qualified: undefined, + arity: '0', + }); }); - it('parses large cache-miss files with the adaptive tree-sitter buffer', () => { - const padding = 'x'.repeat(600 * 1024); - const match = findMatch( - `namespace Large;\n// ${padding}\nclass Big { public void AfterPadding() { } }`, - (t) => t.includes('@declaration.method'), - ); - expect(match).toBeDefined(); - expect(match!['@declaration.name'].text).toBe('AfterPadding'); + it('binds the simple-name tail for a qualified `new Ns.Foo()`', () => { + const refs = ctorRefs(wrap('var x = new Ns.Foo();')); + const foo = refs.find((r) => r.name === 'Foo'); + expect(foo).toBeDefined(); + expect(foo!.qualified).toBe('Ns.Foo'); + expect(foo!.qualifiedName).toBe('Ns.Foo'); + expect(refs.some((r) => r.name === 'Ns.Foo' || r.name === 'Ns')).toBe(false); }); - it('parses UTF-8-heavy cache-miss files with a byte-sized buffer', () => { - const padding = '漢'.repeat(190_000); - const match = findMatch( - `namespace Large;\n// ${padding}\nclass Big { public void AfterPadding() { } }`, - (t) => t.includes('@declaration.method'), - ); - expect(match).toBeDefined(); - expect(match!['@declaration.name'].text).toBe('AfterPadding'); + it('binds the simple-name tail for a deeply-nested `new A.B.Foo()`', () => { + const refs = ctorRefs(wrap('var x = new A.B.Foo();')); + expect(refs.find((r) => r.name === 'Foo')!.qualified).toBe('A.B.Foo'); + expect(refs.some((r) => ['A', 'B', 'A.B.Foo'].includes(r.name as string))).toBe(false); }); - it('captures block-scoped namespaces as @scope.namespace', () => { - const all = tagsFor('namespace Foo.Bar { class A { } }'); - expect(all.some((t) => t.includes('@scope.namespace'))).toBe(true); + it('binds the simple-name tail for a qualified-generic `new Ns.Box()`', () => { + const refs = ctorRefs(wrap('var x = new Ns.Box();')); + const box = refs.find((r) => r.name === 'Box'); + expect(box).toBeDefined(); + expect(box!.qualified).toBe('Ns.Box'); + expect(refs.some((r) => r.name === 'Ns.Box' || r.name === 'int')).toBe(false); }); - it('captures file-scoped namespaces as @scope.namespace', () => { - const all = tagsFor('namespace Foo.Bar;\nclass A { }'); - expect(all.some((t) => t.includes('@scope.namespace'))).toBe(true); + it('binds the simple name for an unqualified generic `new Box()`', () => { + const refs = ctorRefs(wrap('var x = new Box();')); + expect(refs.find((r) => r.name === 'Box')).toBeDefined(); }); - it('captures classes, interfaces, structs, records, enums as @scope.class', () => { - // All four class-like kinds collapse to @scope.class at the scope - // layer because they share the same scope semantics (body is a - // member-holding scope). Declaration tags distinguish them. - const src = ` - class A { } - interface B { } - struct C { } - record D(int x); - enum E { V1, V2 } - `; - const all = tagsFor(src); - const scopeClassCount = all.filter((t) => t.includes('@scope.class')).length; - expect(scopeClassCount).toBe(5); + it('carries argument arity on a qualified constructor call', () => { + expect( + ctorRefs(wrap('var x = new Ns.Foo(1, 2, 3);')).find((r) => r.name === 'Foo')!.arity, + ).toBe('3'); }); - it('captures methods, constructors, destructors, local functions as @scope.function', () => { - const src = ` - class A { - public A() { } - ~A() { } - public void M() { - void Local() { } - } - } - `; - const all = tagsFor(src); - const scopeFnCount = all.filter((t) => t.includes('@scope.function')).length; - expect(scopeFnCount).toBe(4); + it('emits exactly one constructor reference per `new` expression', () => { + expect(ctorRefs(wrap('var x = new Ns.Foo();')).length).toBe(1); + expect(ctorRefs(wrap('var x = new Ns.Box();')).length).toBe(1); + expect(ctorRefs(wrap('var x = new A.B.Foo();')).length).toBe(1); + }); + + it('binds the tail for an alias-qualified `new MyAlias::Foo()`', () => { + const refs = ctorRefs(wrap('var x = new MyAlias::Foo();')); + expect(refs.find((r) => r.name === 'Foo')).toBeDefined(); + expect(refs.length).toBe(1); + }); + + it('binds the tail for `new global::Foo()`', () => { + expect( + ctorRefs(wrap('var x = new global::Foo();')).find((r) => r.name === 'Foo'), + ).toBeDefined(); + }); + + it('binds the tail for an alias-then-qualified `new global::Ns.Foo()`', () => { + const refs = ctorRefs(wrap('var x = new global::Ns.Foo();')); + expect(refs.find((r) => r.name === 'Foo')).toBeDefined(); + expect(refs.some((r) => r.name === 'Ns' || r.name === 'global')).toBe(false); }); }); -describe('emitCsharpScopeCaptures — declarations', () => { - it('captures class declarations with @declaration.class + @declaration.name', () => { - const m = findMatch('class User { }', (t) => t.includes('@declaration.class')); - expect(m).toBeDefined(); - expect(m!['@declaration.name'].text).toBe('User'); +describe('emitCsharpScopeCaptures — constructor initializers (F38)', () => { + it('captures `: base(...)` as a ref to the base type with arity', () => { + const refs = ctorRefs('class Child : Base { public Child() : base(1, 2) {} }'); + const baseRef = refs.find((r) => r.name === 'Base'); + expect(baseRef).toBeDefined(); + expect(baseRef!.arity).toBe('2'); }); - it('captures interface declarations distinctly from class declarations', () => { - const m = findMatch('interface IUser { }', (t) => t.includes('@declaration.interface')); - expect(m).toBeDefined(); - expect(m!['@declaration.name'].text).toBe('IUser'); + it('reduces a generic / qualified base `: base(...)` target to the bare name', () => { + expect( + ctorRefs('class Child : Pkg.Base { public Child() : base() {} }').some( + (r) => r.name === 'Base' && r.arity === '0', + ), + ).toBe(true); }); - it('captures struct, record, enum with their own declaration tags', () => { - expect(findMatch('struct Point { }', (t) => t.includes('@declaration.struct'))).toBeDefined(); - expect(findMatch('record R(int x);', (t) => t.includes('@declaration.record'))).toBeDefined(); - expect(findMatch('enum E { V }', (t) => t.includes('@declaration.enum'))).toBeDefined(); + it('captures `: this(...)` as a ref to the enclosing type', () => { + const refs = ctorRefs('class C { public C() : this(1) {} public C(int x) {} }'); + expect(refs.find((r) => r.name === 'C' && r.arity === '1')).toBeDefined(); }); - it('captures method declarations with their name', () => { - const m = findMatch('class A { public void Save() { } }', (t) => - t.includes('@declaration.method'), - ); - expect(m).toBeDefined(); - expect(m!['@declaration.name'].text).toBe('Save'); + it('captures `: this(...)` inside a struct', () => { + const refs = ctorRefs('struct S { public S(int x) : this() {} public S() {} }'); + expect(refs.some((r) => r.name === 'S' && r.arity === '0')).toBe(true); }); - it('captures constructor declarations under @declaration.constructor', () => { - const m = findMatch('class A { public A() { } }', (t) => - t.includes('@declaration.constructor'), - ); - expect(m).toBeDefined(); - expect(m!['@declaration.name'].text).toBe('A'); + it('captures `: this(...)` inside a record', () => { + const refs = ctorRefs('record R { public R(int x) : this() {} public R() {} }'); + expect(refs.some((r) => r.name === 'R' && r.arity === '0')).toBe(true); }); - it('captures property declarations', () => { - const m = findMatch('class A { public int Age { get; set; } }', (t) => - t.includes('@declaration.property'), - ); - expect(m).toBeDefined(); - expect(m!['@declaration.name'].text).toBe('Age'); + it('targets the base CLASS (always first per C# rules) in a mixed base list', () => { + // `class C : Base, IFoo` — C# requires the base class first, before any + // interfaces, so the first base-list entry is the correct `base(...)` target. + const refs = ctorRefs('class C : Base, IFoo { public C() : base() {} }'); + expect(refs.some((r) => r.name === 'Base' && r.arity === '0')).toBe(true); + expect(refs.some((r) => r.name === 'IFoo')).toBe(false); }); - it('captures field declarations as @declaration.variable', () => { - const m = findMatch('class A { private int _x; }', (t) => t.includes('@declaration.variable')); - expect(m).toBeDefined(); - expect(m!['@declaration.name'].text).toBe('_x'); - }); - - it('captures operator declarations as @declaration.method with the operator token as name', () => { - // Caller attribution walks ownedDefs looking for method owners. - // Without this, calls inside `operator +` bodies get attributed to - // the enclosing class instead of the operator. - const m = findMatch( - 'class T { public static T operator +(T a, T b) { return a; } }', - (t) => t.includes('@declaration.method') && !t.includes('@scope.class'), - ); - expect(m).toBeDefined(); - expect(m!['@declaration.name'].text).toBe('+'); - }); - - it('captures conversion operator declarations with the target type as name', () => { - const m = findMatch('class T { public static explicit operator int(T x) { return 0; } }', (t) => - t.includes('@declaration.method'), - ); - expect(m).toBeDefined(); - expect(m!['@declaration.name'].text).toBe('int'); - }); - - it('captures operator + conversion-operator as @scope.function', () => { - const src = ` - class T { - public static T operator +(T a, T b) { return a; } - public static explicit operator int(T x) { return 0; } - } - `; - const all = tagsFor(src); - const fnScopes = all.filter((t) => t.includes('@scope.function')).length; - expect(fnScopes).toBe(2); - }); - - it('captures local function declarations', () => { - const m = findMatch('class A { void M() { void Local() { } } }', (t) => - t.includes('@declaration.function'), - ); - expect(m).toBeDefined(); - expect(m!['@declaration.name'].text).toBe('Local'); - }); -}); - -describe('emitCsharpScopeCaptures — imports', () => { - it('captures each `using` directive as @import.statement', () => { - const src = ` - using System; - using System.Collections.Generic; - using Dict = System.Collections.Generic.Dictionary; - using static System.Math; - `; - const all = tagsFor(src); - const importCount = all.filter((t) => t.includes('@import.statement')).length; - expect(importCount).toBe(4); - }); -}); - -describe('emitCsharpScopeCaptures — type bindings', () => { - it('captures parameter annotations (object types)', () => { - // `int id` does NOT fire (predefined_type is not identifier) — - // only object-type parameters do. That's intentional: receiver- - // bound dispatch doesn't need primitives. - const m = findMatch('class A { void M(User u) { } }', (t) => - t.includes('@type-binding.parameter'), - ); - expect(m).toBeDefined(); - expect(m!['@type-binding.name'].text).toBe('u'); - expect(m!['@type-binding.type'].text).toBe('User'); - }); - - it('captures local variable annotations', () => { - const m = findMatch('class A { void M() { User u; } }', (t) => - t.includes('@type-binding.annotation'), - ); - expect(m).toBeDefined(); - expect(m!['@type-binding.name'].text).toBe('u'); - expect(m!['@type-binding.type'].text).toBe('User'); - }); - - it('captures constructor-inferred `var u = new User();`', () => { - const m = findMatch('class A { void M() { var u = new User(); } }', (t) => - t.includes('@type-binding.constructor'), - ); - expect(m).toBeDefined(); - expect(m!['@type-binding.name'].text).toBe('u'); - expect(m!['@type-binding.type'].text).toBe('User'); - }); - - it('captures alias `var u = Factory();`', () => { - const m = findMatch('class A { void M() { var u = Factory(); } }', (t) => - t.includes('@type-binding.alias'), - ); - expect(m).toBeDefined(); - expect(m!['@type-binding.name'].text).toBe('u'); - expect(m!['@type-binding.type'].text).toBe('Factory'); - }); -}); - -describe('emitCsharpScopeCaptures — arity metadata synthesis', () => { - it('synthesizes parameter-count + required-parameter-count on method declarations', () => { - const m = findMatch( - 'class A { public void M(int a, int b = 1) { } }', - (t) => - t.includes('@declaration.method') && - t.includes('@declaration.parameter-count') && - t.includes('@declaration.required-parameter-count'), - ); - expect(m).toBeDefined(); - expect(m!['@declaration.parameter-count'].text).toBe('2'); - expect(m!['@declaration.required-parameter-count'].text).toBe('1'); - }); - - it('synthesizes parameter-types on method declarations', () => { - const m = findMatch( - 'class A { public void M(User u, int n) { } }', - (t) => t.includes('@declaration.method') && t.includes('@declaration.parameter-types'), - ); - expect(m).toBeDefined(); - const types = JSON.parse(m!['@declaration.parameter-types'].text); - expect(types).toEqual(['User', 'int']); - }); - - it('leaves parameter-count undefined for `params` variadic methods', () => { - const m = findMatch('class A { public void M(params int[] xs) { } }', (t) => - t.includes('@declaration.method'), - ); - expect(m).toBeDefined(); - expect(m!['@declaration.parameter-count']).toBeUndefined(); - expect(m!['@declaration.required-parameter-count']).toBeUndefined(); - const types = JSON.parse(m!['@declaration.parameter-types'].text); - expect(types).toContain('params'); - }); - - it('synthesizes arity on constructor declarations', () => { - const m = findMatch('class A { public A(int a, int b) { } }', (t) => - t.includes('@declaration.constructor'), - ); - expect(m).toBeDefined(); - expect(m!['@declaration.parameter-count'].text).toBe('2'); - expect(m!['@declaration.required-parameter-count'].text).toBe('2'); - }); - - it('synthesizes arity on local function declarations', () => { - const m = findMatch('class A { void M() { void Local(int x) { } } }', (t) => - t.includes('@declaration.function'), - ); - expect(m).toBeDefined(); - expect(m!['@declaration.parameter-count'].text).toBe('1'); - }); -}); - -describe('emitCsharpScopeCaptures — receiver-binding synthesis (`this` / `base`)', () => { - it('emits `this` for an instance method inside a class', () => { - const m = findMatch('class User { public void M() { } }', (t) => - t.includes('@type-binding.self'), - ); - expect(m).toBeDefined(); - expect(m!['@type-binding.name'].text).toBe('this'); - expect(m!['@type-binding.type'].text).toBe('User'); - }); - - it('emits both `this` and `base` when the class has a base class', () => { - const matches = emitCsharpScopeCaptures( - 'class User : BaseModel { public void M() { base.Save(); } }', - 'test.cs', - ); - const receiverMatches = matches.filter((m) => '@type-binding.self' in m); - const names = receiverMatches.map((m) => m['@type-binding.name'].text).sort(); - expect(names).toEqual(['base', 'this']); - const baseMatch = receiverMatches.find((m) => m['@type-binding.name'].text === 'base'); - expect(baseMatch!['@type-binding.type'].text).toBe('BaseModel'); - }); - - it('excludes constructor arguments from record primary-constructor base bindings', () => { - const matches = emitCsharpScopeCaptures( - 'record User(int id) : BaseEntity(id) { public void M() { base.Save(); } }', - 'test.cs', - ); - const baseMatch = matches.find( - (m) => '@type-binding.self' in m && m['@type-binding.name'].text === 'base', - ); - expect(baseMatch?.['@type-binding.type'].text).toBe('BaseEntity'); - }); - - it('preserves a plain record base binding', () => { - const matches = emitCsharpScopeCaptures( - 'record User(int id) : BaseEntity { public void M() { base.Save(); } }', - 'test.cs', - ); - const baseMatch = matches.find( - (m) => '@type-binding.self' in m && m['@type-binding.name'].text === 'base', - ); - expect(baseMatch?.['@type-binding.type'].text).toBe('BaseEntity'); - }); - - it('excludes empty constructor arguments from record primary-constructor base bindings', () => { - const matches = emitCsharpScopeCaptures( - 'record User(int id) : BaseEntity() { public void M() { base.Save(); } }', - 'test.cs', - ); - const baseMatch = matches.find( - (m) => '@type-binding.self' in m && m['@type-binding.name'].text === 'base', - ); - expect(baseMatch?.['@type-binding.type'].text).toBe('BaseEntity'); - }); - - it('excludes nested constructor arguments from record primary-constructor base bindings', () => { - const matches = emitCsharpScopeCaptures( - 'record User(int id) : BaseEntity(Create(id, 2)) { public void M() { base.Save(); } }', - 'test.cs', - ); - const baseMatch = matches.find( - (m) => '@type-binding.self' in m && m['@type-binding.name'].text === 'base', - ); - expect(baseMatch?.['@type-binding.type'].text).toBe('BaseEntity'); - }); - - it('preserves qualified generic record primary-constructor base types without arguments', () => { - const matches = emitCsharpScopeCaptures( - 'record User(int id) : App.BaseEntity(id) { public void M() { base.Save(); } }', - 'test.cs', - ); - const baseMatch = matches.find( - (m) => '@type-binding.self' in m && m['@type-binding.name'].text === 'base', - ); - expect(baseMatch?.['@type-binding.type'].text).toBe('App.BaseEntity'); - }); - - it('does not emit `this` or `base` for static methods', () => { - const matches = emitCsharpScopeCaptures('class User { public static void M() { } }', 'test.cs'); - const receiverMatches = matches.filter((m) => '@type-binding.self' in m); - expect(receiverMatches).toHaveLength(0); - }); - - it('does not emit `base` for structs (they cannot inherit classes)', () => { - const matches = emitCsharpScopeCaptures('struct Point { public void M() { } }', 'test.cs'); - const names = matches - .filter((m) => '@type-binding.self' in m) - .map((m) => m['@type-binding.name'].text); - expect(names).toEqual(['this']); - }); - - it('does not emit `base` for interface methods', () => { - const matches = emitCsharpScopeCaptures('interface IFoo { void M() { } }', 'test.cs'); - const names = matches - .filter((m) => '@type-binding.self' in m) - .map((m) => m['@type-binding.name'].text); - expect(names).toEqual(['this']); - }); - - it('does not emit receiver bindings for free local functions (no enclosing type)', () => { - // Local functions inside a method still have `this` from the - // enclosing class — that's a normal method + local combination. - // Test the pure free case: a local function at namespace level is - // not legal C#, so we exercise the adjacent "top-level statement" - // variant: a method inside a class works fine, but the local - // function *inside* that method also sees `this` from the class. - // This test confirms synthesis doesn't produce duplicate bindings. - const matches = emitCsharpScopeCaptures( - 'class User { public void M() { void Local() { } } }', - 'test.cs', - ); - const thisMatches = matches.filter( - (m) => '@type-binding.self' in m && m['@type-binding.name'].text === 'this', - ); - // Expect two: one for M() and one for Local() — both see `this` - // from the enclosing User class. - expect(thisMatches).toHaveLength(2); - for (const tm of thisMatches) { - expect(tm['@type-binding.type'].text).toBe('User'); - } - }); - - it('emits `this` on constructors with the enclosing class name', () => { - const matches = emitCsharpScopeCaptures('class User { public User() { } }', 'test.cs'); - const thisMatch = matches.find( - (m) => '@type-binding.self' in m && m['@type-binding.name'].text === 'this', - ); - expect(thisMatch).toBeDefined(); - expect(thisMatch!['@type-binding.type'].text).toBe('User'); - }); - - it('emits `this` with innermost type for nested class methods', () => { - const matches = emitCsharpScopeCaptures( - 'class Outer { class Inner { public void M() { } } }', - 'test.cs', - ); - const thisMatches = matches.filter( - (m) => '@type-binding.self' in m && m['@type-binding.name'].text === 'this', - ); - // M is the only instance method; its `this` binds to Inner. - expect(thisMatches).toHaveLength(1); - expect(thisMatches[0]['@type-binding.type'].text).toBe('Inner'); - }); -}); - -describe('emitCsharpScopeCaptures — references', () => { - it('captures free call invocations', () => { - const m = findMatch('class A { void M() { Foo(); } }', (t) => - t.includes('@reference.call.free'), - ); - expect(m).toBeDefined(); - expect(m!['@reference.name'].text).toBe('Foo'); - }); - - it('captures member call invocations with receiver + name', () => { - const m = findMatch('class A { void M() { obj.Save(); } }', (t) => - t.includes('@reference.call.member'), - ); - expect(m).toBeDefined(); - expect(m!['@reference.receiver'].text).toBe('obj'); - expect(m!['@reference.name'].text).toBe('Save'); - }); - - it('captures null-conditional member calls `obj?.Save()` with a receiver', () => { - // Regression guard: without the receiver capture, receiver-bound - // resolution downgrades to free-call fallback and can mis-link to - // an imported `Save`. - const m = findMatch('class A { void M(User obj) { obj?.Save(); } }', (t) => - t.includes('@reference.call.member'), - ); - expect(m).toBeDefined(); - expect(m!['@reference.name'].text).toBe('Save'); - expect(m!['@reference.receiver'].text).toBe('obj'); - }); - - it('captures object-creation expressions as constructor calls', () => { - const m = findMatch('class A { void M() { var u = new User(); } }', (t) => - t.includes('@reference.call.constructor'), - ); - expect(m).toBeDefined(); - expect(m!['@reference.name'].text).toBe('User'); - }); - - it('captures member writes `obj.Name = "x"`', () => { - const m = findMatch('class A { void M(User obj) { obj.Name = "x"; } }', (t) => - t.includes('@reference.write.member'), - ); - expect(m).toBeDefined(); - expect(m!['@reference.receiver'].text).toBe('obj'); - expect(m!['@reference.name'].text).toBe('Name'); - }); - - it('captures member reads `obj.Name`', () => { - const m = findMatch('class A { void M(User obj) { var name = obj.Name; } }', (t) => - t.includes('@reference.read.member'), - ); - expect(m).toBeDefined(); - expect(m!['@reference.receiver'].text).toBe('obj'); - expect(m!['@reference.name'].text).toBe('Name'); - }); - - it('does not capture member calls as member reads', () => { - const matches = emitCsharpScopeCaptures( - 'class A { void M(User obj) { obj.Save(); } }', - 'test.cs', - ); - expect(matches.some((m) => '@reference.call.member' in m)).toBe(true); - expect(matches.some((m) => '@reference.read.member' in m)).toBe(false); - }); - - it('captures generic type arguments as type references', () => { - const matches = emitCsharpScopeCaptures( - 'class A : IEntityTypeConfiguration { public Task> Load(List users) => null!; }', - 'test.cs', - ); - const names = matches - .filter((m) => '@reference.type' in m) - .map((m) => m['@reference.name'].text); - - expect(names).toContain('USER_INFO'); - expect(names).not.toContain('string'); - }); - - it('captures call-site generic type arguments as type references', () => { - const matches = emitCsharpScopeCaptures( - 'class A { void M(IRepo repo) { repo.Get(); } }', - 'test.cs', - ); - const names = matches - .filter((m) => '@reference.type' in m) - .map((m) => m['@reference.name'].text); - - expect(names).toContain('USER_INFO'); + it('does NOT synthesize a base ref when the class has no base list', () => { + // (Not valid C#, but the synth must be defensive: no base_list → no target.) + expect(ctorRefs('class C { public C() {} }').length).toBe(0); }); }); diff --git a/gitnexus/test/unit/scope-resolution/csharp/csharp-interpret.test.ts b/gitnexus/test/unit/scope-resolution/csharp/csharp-interpret.test.ts new file mode 100644 index 000000000..548885e11 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/csharp/csharp-interpret.test.ts @@ -0,0 +1,82 @@ +/** + * Coverage for `interpretCsharpTypeBinding` type normalization, focused on the + * F41 analog (#1928): the qualifier strip must not reach into generic type + * ARGUMENTS. `Dictionary` was corrupted into `User>` by an + * unguarded `lastIndexOf('.')`. Multi-arg generics must stay intact so the + * collection-accessor (`.Values`/`.Keys`) unwrap keeps working. + */ +import { describe, it, expect } from 'vitest'; +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { interpretCsharpTypeBinding } from '../../../../src/core/ingestion/languages/csharp/interpret.js'; + +const ZERO = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 } as const; +const cap = (name: string, text: string): Capture => ({ name, text, range: ZERO }); + +function raw(typeText: string): string | undefined { + const m: CaptureMatch = { + '@type-binding.name': cap('@type-binding.name', 'x'), + '@type-binding.type': cap('@type-binding.type', typeText), + '@type-binding.annotation': cap('@type-binding.annotation', typeText), + }; + return interpretCsharpTypeBinding(m)?.rawTypeName; +} + +describe('interpretCsharpTypeBinding — type normalization (F41 analog #1928)', () => { + it('does not corrupt a qualified generic TYPE ARGUMENT (the bug)', () => { + // Was `User>` before the fix. + expect(raw('Dictionary')).toBe('Dictionary'); + }); + + it('leaves an unqualified multi-arg generic intact (collection-accessor unwrap)', () => { + expect(raw('Dictionary')).toBe('Dictionary'); + }); + + it('strips the OUTER qualifier of a generic while keeping the type args', () => { + expect(raw('Ns.Dictionary')).toBe('Dictionary'); + }); + + it('unwraps a single-arg known container to its (qualified) element type', () => { + expect(raw('List')).toBe('User'); + expect(raw('List')).toBe('User'); + expect(raw('Task')).toBe('User'); + }); + + it('strips a plain qualifier', () => { + expect(raw('Ns.User')).toBe('User'); + expect(raw('A.B.User')).toBe('User'); + }); + + it('strips a nullable suffix', () => { + expect(raw('User?')).toBe('User'); + }); + + it('unwraps a nullable single-arg generic (`List?` → `User`)', () => { + expect(raw('List?')).toBe('User'); + }); + + it('does not corrupt a nested generic — keeps it intact (no `>>` artifact)', () => { + expect(raw('List>')).toBe('List>'); + }); + + it('strips the outer qualifier of an unrecognized generic, keeping its args', () => { + // Accepted limitation: unknown generics are not erased to the bare base — + // only the OUTER qualifier is removed; the generic suffix is preserved. + expect(raw('Ns.Box')).toBe('Box'); + }); + + it('passes through a plain simple type', () => { + expect(raw('User')).toBe('User'); + }); + + it('preserves a collection-accessor suffix (`data.Values`)', () => { + expect(raw('data.Values')).toBe('data.Values'); + }); + + it('strips nested types through a generic outer (`Ns.Outer.Inner` → `Inner`)', () => { + // Prior generic-aware strip sliced at the first `<` and regressed to + // `Outer.Inner` (unresolvable). Last `.` at bracket depth 0 fixes both + // this shape and the F41 `Dictionary` case (#2046 P3). + expect(raw('Ns.Outer.Inner')).toBe('Inner'); + expect(raw('Outer.Inner')).toBe('Inner'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/csharp/csharp-qualified-type-names.test.ts b/gitnexus/test/unit/scope-resolution/csharp/csharp-qualified-type-names.test.ts new file mode 100644 index 000000000..89f8d787d --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/csharp/csharp-qualified-type-names.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import type { ParsedFile } from 'gitnexus-shared'; +import { extractParsedFile } from '../../../../src/core/ingestion/scope-extractor-bridge.js'; +import { csharpProvider } from '../../../../src/core/ingestion/languages/csharp.js'; +import { populateClassOwnedMembers } from '../../../../src/core/ingestion/scope-resolution/scope/walkers.js'; +import { populateCsharpNamespacePrefixes } from '../../../../src/core/ingestion/languages/csharp/qualified-type-names.js'; + +function parse(src: string, filePath: string): ParsedFile { + const parsed = extractParsedFile(csharpProvider, src, filePath); + if (parsed === undefined) throw new Error(`extractParsedFile returned undefined for ${filePath}`); + populateClassOwnedMembers(parsed); + populateCsharpNamespacePrefixes(parsed); + return parsed; +} + +describe('populateCsharpNamespacePrefixes', () => { + it('stamps a file-scoped namespace on the sidecar without touching qualifiedName', () => { + const parsed = parse(`namespace B;\npublic class Foo { public Foo() {} }`, 'B/Foo.cs'); + const foo = parsed.localDefs.find( + (d) => d.type === 'Class' && d.qualifiedName?.endsWith('Foo'), + ); + expect(foo?.namespacePrefix).toBe('B'); + expect(foo?.qualifiedName).toBe('Foo'); + }); + + it('stamps a block-scoped nested namespace path', () => { + const parsed = parse(`namespace A.B { public class Foo { public Foo() {} } }`, 'A/B/Foo.cs'); + const foo = parsed.localDefs.find( + (d) => d.type === 'Class' && d.qualifiedName?.endsWith('Foo'), + ); + expect(foo?.namespacePrefix).toBe('A.B'); + expect(foo?.qualifiedName).toBe('Foo'); + }); + + it('leaves a namespace-free type untagged', () => { + const parsed = parse(`public class Foo { public Foo() {} }`, 'Foo.cs'); + const foo = parsed.localDefs.find((d) => d.type === 'Class'); + expect(foo?.namespacePrefix).toBeUndefined(); + }); +}); From 22304cd4a4080a6835f0b57b9f1a6d12ac7e7db8 Mon Sep 17 00:00:00 2001 From: jwcrystal <121911854+jwcrystal@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:36:28 +0800 Subject: [PATCH 7/8] fix(mcp): prevent orphan processes by handling stdin close/end and startup race condition (#2049) * fix(mcp): prevent orphan processes by handling stdin close/end and startup race condition Three gaps in stdin EOF handling: 1. Startup race: parent can die before `process.stdin.on("end", ...)` is registered, so the event is missed entirely. 2. Missing "close" event: when pipe is forcibly closed (parent SIGKILL), "close" fires without "end" on some platforms. 3. Transport layer did not propagate stdin termination to its onclose callback. Fixes: - Check readableEnded/destroyed in start() before registering listeners. - Register stdin end+close listeners in CompatibleStdioServerTransport. - Add _closed guard for idempotent close(). - Throw if start() is called after close(). - Add process.stdin.on("close") in server.ts alongside existing handlers. - Add 5 regression tests. * fix(mcp): register stdin shutdown before server connect --- .../src/mcp/compatible-stdio-transport.ts | 26 ++++++ gitnexus/src/mcp/server.ts | 12 ++- .../unit/compatible-stdio-transport.test.ts | 67 +++++++++++++++ gitnexus/test/unit/server.test.ts | 86 +++++++++++++++++++ 4 files changed, 190 insertions(+), 1 deletion(-) diff --git a/gitnexus/src/mcp/compatible-stdio-transport.ts b/gitnexus/src/mcp/compatible-stdio-transport.ts index b5fc85630..fa8d9f4d2 100644 --- a/gitnexus/src/mcp/compatible-stdio-transport.ts +++ b/gitnexus/src/mcp/compatible-stdio-transport.ts @@ -48,12 +48,16 @@ const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10 MB — generous for JSON-RPC export class CompatibleStdioServerTransport implements Transport { private _readBuffer: Buffer | undefined; private _started = false; + private _closed = false; private _framing: StdioFraming | null = null; onmessage?: (message: JSONRPCMessage) => void; onerror?: (error: Error) => void; onclose?: () => void; + /** Stable bound reference so `on`/`off` always use the same function. */ + private readonly _boundClose = this.close.bind(this); + constructor( private readonly _stdin: NodeJS.ReadableStream = process.stdin, private readonly _stdout: NodeJS.WritableStream = process.stdout, @@ -77,10 +81,27 @@ export class CompatibleStdioServerTransport implements Transport { if (this._started) { throw new Error('CompatibleStdioServerTransport already started!'); } + if (this._closed) { + throw new Error('CompatibleStdioServerTransport has been permanently closed'); + } + + // If stdin is already closed (parent died before we registered listeners), + // the 'end'/'close' event was already emitted and will never fire again. + // Close the transport immediately to avoid orphaned processes. + if ( + (this._stdin as NodeJS.ReadStream).readableEnded || + (this._stdin as NodeJS.ReadStream).destroyed + ) { + this._started = true; + await this.close(); + return; + } this._started = true; this._stdin.on('data', this._ondata); this._stdin.on('error', this._onerror); + this._stdin.on('end', this._boundClose); + this._stdin.on('close', this._boundClose); } private detectFraming(): StdioFraming | null { @@ -201,8 +222,13 @@ export class CompatibleStdioServerTransport implements Transport { } async close() { + if (this._closed) return; + this._closed = true; + this._stdin.off('data', this._ondata); this._stdin.off('error', this._onerror); + this._stdin.off('end', this._boundClose); + this._stdin.off('close', this._boundClose); const remainingDataListeners = this._stdin.listenerCount('data'); if (remainingDataListeners === 0) { diff --git a/gitnexus/src/mcp/server.ts b/gitnexus/src/mcp/server.ts index 4696bbba6..b2bd5530f 100644 --- a/gitnexus/src/mcp/server.ts +++ b/gitnexus/src/mcp/server.ts @@ -337,7 +337,6 @@ export async function startMCPServer(backend: LocalBackend): Promise { }, }); const transport = new CompatibleStdioServerTransport(process.stdin, safeStdout); - await server.connect(transport); // Surface the redirect counter on shutdown so users see the volume of // stray writes even when individual payloads were truncated/suppressed. @@ -389,9 +388,20 @@ export async function startMCPServer(backend: LocalBackend): Promise { }); // Handle stdio errors — stdin close means the parent process is gone. + // Defense-in-depth: the transport also listens for stdin end/close and + // handles transport-level cleanup. These listeners handle process-level + // shutdown. Both paths are idempotent and safe to fire together. // Wrap so the event payload (e.g. an Error for 'error') can never reach // process.exit() as a non-numeric exit code, and void the returned promise. process.stdin.on('end', () => void shutdown(0)); + process.stdin.on('close', () => void shutdown(0)); process.stdin.on('error', () => void shutdown(0)); process.stdout.on('error', () => void shutdown(0)); + + if (process.stdin.readableEnded || process.stdin.destroyed) { + await shutdown(0); + return; + } + + await server.connect(transport); } diff --git a/gitnexus/test/unit/compatible-stdio-transport.test.ts b/gitnexus/test/unit/compatible-stdio-transport.test.ts index 4d2a148b4..d5686e316 100644 --- a/gitnexus/test/unit/compatible-stdio-transport.test.ts +++ b/gitnexus/test/unit/compatible-stdio-transport.test.ts @@ -9,6 +9,20 @@ function onceMessage(transport: CompatibleStdioServerTransport): Promise { }); } +function trackClose(transport: CompatibleStdioServerTransport): { + onclose: ReturnType; + closed: Promise; +} { + const onclose = vi.fn(); + const closed = new Promise((resolve) => { + transport.onclose = () => { + onclose(); + resolve(); + }; + }); + return { onclose, closed }; +} + describe('CompatibleStdioServerTransport', () => { let stdin: PassThrough; let stdout: PassThrough; @@ -258,4 +272,57 @@ describe('CompatibleStdioServerTransport', () => { expect(raw).toBe('{"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n'); }); + + // ─── stdin lifecycle behavior ────────────────────────────── + + it('stdin end closes transport and calls onclose exactly once', async () => { + const { onclose, closed } = trackClose(transport); + + await transport.start(); + stdin.push(null); + + await closed; + expect(onclose).toHaveBeenCalledTimes(1); + }); + + it('stdin close closes transport and calls onclose exactly once', async () => { + const { onclose, closed } = trackClose(transport); + + await transport.start(); + stdin.destroy(); + + await closed; + expect(onclose).toHaveBeenCalledTimes(1); + }); + + it('close() is idempotent: calling close twice calls onclose once', async () => { + const onclose = vi.fn(); + transport.onclose = onclose; + + await transport.start(); + await transport.close(); + await transport.close(); + + expect(onclose).toHaveBeenCalledTimes(1); + }); + + it('start() immediately closes if stdin is already ended/destroyed before listeners register', async () => { + const endedStdin = new PassThrough(); + endedStdin.push(null); + + const t = new CompatibleStdioServerTransport(endedStdin, new PassThrough()); + const { onclose, closed } = trackClose(t); + + await t.start(); + + await closed; + expect(onclose).toHaveBeenCalledTimes(1); + }); + + it('start() after close throws', async () => { + await transport.start(); + await transport.close(); + + await expect(transport.start()).rejects.toThrow(/close/i); + }); }); diff --git a/gitnexus/test/unit/server.test.ts b/gitnexus/test/unit/server.test.ts index d5ff9d730..f2ac995da 100644 --- a/gitnexus/test/unit/server.test.ts +++ b/gitnexus/test/unit/server.test.ts @@ -14,10 +14,12 @@ */ import { describe, it, expect, vi } from 'vitest'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { createMCPServer, installSignalShutdown, + startMCPServer, SHUTDOWN_EXIT_CODES, } from '../../src/mcp/server.js'; import { GITNEXUS_TOOLS } from '../../src/mcp/tools.js'; @@ -130,6 +132,90 @@ describe('prompt registration', () => { }); }); +// ─── startMCPServer ──────────────────────────────────────────────────── + +describe('startMCPServer', () => { + it('registers stdin shutdown listeners before awaiting Server.prototype.connect', async () => { + // P1 finding: startMCPServer must register process.stdin end/close/error + // shutdown listeners before calling server.connect(), so that a stdin + // closure during transport setup is safely handled. + // This test asserts the listeners are present *during* the connect call. + + // Capture baseline listener counts before startMCPServer adds anything + const closeBefore = process.stdin.listenerCount('close'); + const endBefore = process.stdin.listenerCount('end'); + const errorBefore = process.stdin.listenerCount('error'); + const beforeListeners = { + stdinEnd: new Set(process.stdin.listeners('end')), + stdinClose: new Set(process.stdin.listeners('close')), + stdinError: new Set(process.stdin.listeners('error')), + stdoutError: new Set(process.stdout.listeners('error')), + sigint: new Set(process.listeners('SIGINT')), + sigterm: new Set(process.listeners('SIGTERM')), + exit: new Set(process.listeners('exit')), + uncaughtException: new Set(process.listeners('uncaughtException')), + unhandledRejection: new Set(process.listeners('unhandledRejection')), + }; + + // Stub process.exit to prevent actual termination if shutdown fires + const exitStub = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + // Spy on Server.prototype.connect to inspect listener counts during the call + const connectSpy = vi.spyOn(Server.prototype, 'connect').mockImplementation(async function () { + expect(process.stdin.listenerCount('close')).toBeGreaterThan(closeBefore); + expect(process.stdin.listenerCount('end')).toBeGreaterThan(endBefore); + expect(process.stdin.listenerCount('error')).toBeGreaterThan(errorBefore); + }); + + const backend = createMockBackend(); + + try { + await startMCPServer(backend); + } finally { + connectSpy.mockRestore(); + exitStub.mockRestore(); + // Remove only listeners added by startMCPServer so no side effects leak. + for (const listener of process.stdin.listeners('end')) { + if (!beforeListeners.stdinEnd.has(listener)) process.stdin.removeListener('end', listener); + } + for (const listener of process.stdin.listeners('close')) { + if (!beforeListeners.stdinClose.has(listener)) { + process.stdin.removeListener('close', listener); + } + } + for (const listener of process.stdin.listeners('error')) { + if (!beforeListeners.stdinError.has(listener)) { + process.stdin.removeListener('error', listener); + } + } + for (const listener of process.stdout.listeners('error')) { + if (!beforeListeners.stdoutError.has(listener)) { + process.stdout.removeListener('error', listener); + } + } + for (const listener of process.listeners('SIGINT')) { + if (!beforeListeners.sigint.has(listener)) process.removeListener('SIGINT', listener); + } + for (const listener of process.listeners('SIGTERM')) { + if (!beforeListeners.sigterm.has(listener)) process.removeListener('SIGTERM', listener); + } + for (const listener of process.listeners('exit')) { + if (!beforeListeners.exit.has(listener)) process.removeListener('exit', listener); + } + for (const listener of process.listeners('uncaughtException')) { + if (!beforeListeners.uncaughtException.has(listener)) { + process.removeListener('uncaughtException', listener); + } + } + for (const listener of process.listeners('unhandledRejection')) { + if (!beforeListeners.unhandledRejection.has(listener)) { + process.removeListener('unhandledRejection', listener); + } + } + } + }); +}); + // ─── Graceful shutdown signal handling (#1132) ──────────────────────── describe('installSignalShutdown (#1132)', () => { From 782f70cc077b2f0dee6e55c941047dbb2c0fdfdb Mon Sep 17 00:00:00 2001 From: Anton Fedotov Date: Fri, 5 Jun 2026 11:24:00 +0300 Subject: [PATCH 8/8] feat(wiki): add opencode local provider (#2039) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(wiki): add opencode local provider * style(wiki): format local cli client * fix(wiki): harden opencode event parsing --------- Co-authored-by: Gergő Magyar --- gitnexus/src/cli/i18n/en.ts | 2 +- gitnexus/src/cli/i18n/zh-CN.ts | 2 +- gitnexus/src/cli/index.ts | 2 +- gitnexus/src/cli/wiki.ts | 30 +- gitnexus/src/core/wiki/generator.ts | 25 +- gitnexus/src/core/wiki/llm-client.ts | 12 +- gitnexus/src/core/wiki/local-cli-client.ts | 104 +++++- gitnexus/src/storage/repo-manager.ts | 11 +- gitnexus/test/unit/wiki-flags.test.ts | 375 ++++++++++++++++++++- 9 files changed, 538 insertions(+), 25 deletions(-) diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index e4778fb58..6b57c2aa6 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -189,7 +189,7 @@ export const en = { 'help.option.clean.lbugSidecars': 'Clean quarantined LadybugDB missing-shadow WAL sidecars', 'help.option.wiki.force': 'Force full regeneration even if up to date', 'help.option.wiki.provider': - 'LLM provider: openai, openrouter, azure, custom, cursor, claude, or codex (default: openai)', + 'LLM provider: openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: openai)', 'help.option.wiki.model': 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)', 'help.option.wiki.baseUrl': 'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index cd488de0a..0eec71c44 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -178,7 +178,7 @@ export const zhCN = { 'help.option.clean.lbugSidecars': '清理已隔离的 LadybugDB missing-shadow WAL sidecar', 'help.option.wiki.force': '即使已是最新也强制完整重新生成', 'help.option.wiki.provider': - 'LLM 提供商:openai、openrouter、azure、custom、cursor、claude 或 codex(默认:openai)', + 'LLM 提供商:openai、openrouter、azure、custom、cursor、claude、codex 或 opencode(默认:openai)', 'help.option.wiki.model': 'LLM 模型或 Azure deployment 名称(默认:minimax/minimax-m2.5)', 'help.option.wiki.baseUrl': 'LLM API base URL。Azure v1:https://{resource}.openai.azure.com/openai/v1', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index f64b3a24d..8ae99455d 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -155,7 +155,7 @@ program .option('-f, --force', 'Force full regeneration even if up to date') .option( '--provider ', - 'LLM provider: openai, openrouter, azure, custom, cursor, claude, or codex (default: openai)', + 'LLM provider: openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: openai)', ) .option('--model ', 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)') .option( diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index d10d10875..446b83d30 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -58,14 +58,21 @@ function parsePositiveIntegerOption( function isLocalProvider( provider: LLMProvider | undefined, -): provider is 'cursor' | 'claude' | 'codex' { - return provider === 'cursor' || provider === 'claude' || provider === 'codex'; +): provider is 'cursor' | 'claude' | 'codex' | 'opencode' { + return ( + provider === 'cursor' || + provider === 'claude' || + provider === 'codex' || + provider === 'opencode' + ); } -function localModelConfigKey(provider: 'cursor' | 'claude' | 'codex') { +function localModelConfigKey(provider: 'cursor' | 'claude' | 'codex' | 'opencode') { if (provider === 'cursor') return 'cursorModel'; if (provider === 'claude') return 'claudeModel'; - return 'codexModel'; + if (provider === 'codex') return 'codexModel'; + if (provider === 'opencode') return 'opencodeModel'; + throw new Error(`Unsupported local provider: ${provider satisfies never}`); } /** @@ -248,7 +255,7 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) if (!llmConfig.apiKey && !isLocalProvider(llmConfig.provider)) { console.log(' Error: No LLM API key found.'); console.log(' Set OPENAI_API_KEY or GITNEXUS_API_KEY environment variable,'); - console.log(' or pass --api-key , or use --provider cursor|claude|codex.\n'); + console.log(' or pass --api-key , or use --provider cursor|claude|codex|opencode.\n'); process.exitCode = 1; return; } @@ -256,16 +263,17 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) } else { console.log(" No LLM configured. Let's set it up.\n"); console.log( - ' Supports OpenAI, OpenRouter, Azure, any OpenAI-compatible API, Cursor CLI, Claude CLI, or Codex CLI.\n', + ' Supports OpenAI, OpenRouter, Azure, any OpenAI-compatible API, Cursor CLI, Claude CLI, Codex CLI, or OpenCode CLI.\n', ); // Check if local agent CLIs are available. const hasCursor = detectCursorCLI(); const hasClaude = detectLocalCLI('claude'); const hasCodex = detectLocalCLI('codex'); + const hasOpenCode = detectLocalCLI('opencode'); const localChoices: Array<{ choice: string; - provider: 'cursor' | 'claude' | 'codex'; + provider: 'cursor' | 'claude' | 'codex' | 'opencode'; }> = []; // Provider selection @@ -298,6 +306,14 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) }); console.log(` [${choice}] Codex CLI (local, uses your Codex login)`); } + if (hasOpenCode) { + const choice = String(nextChoice++); + localChoices.push({ + choice, + provider: 'opencode', + }); + console.log(` [${choice}] OpenCode CLI (local, uses your OpenCode login/config)`); + } console.log(''); const maxChoice = String(nextChoice - 1); diff --git a/gitnexus/src/core/wiki/generator.ts b/gitnexus/src/core/wiki/generator.ts index a86d60d74..11df0b6c2 100644 --- a/gitnexus/src/core/wiki/generator.ts +++ b/gitnexus/src/core/wiki/generator.ts @@ -39,7 +39,12 @@ import { } from './llm-client.js'; import { callCursorLLM, resolveCursorConfig } from './cursor-client.js'; -import { callClaudeLLM, callCodexLLM, resolveLocalCLIConfig } from './local-cli-client.js'; +import { + callClaudeLLM, + callCodexLLM, + callOpenCodeLLM, + resolveLocalCLIConfig, +} from './local-cli-client.js'; import { GROUPING_SYSTEM_PROMPT, @@ -219,15 +224,25 @@ export class WikiGenerator { }); return callCursorLLM(prompt, cursorConfig, systemPrompt, options); } - if (this.llmConfig.provider === 'claude' || this.llmConfig.provider === 'codex') { + if ( + this.llmConfig.provider === 'claude' || + this.llmConfig.provider === 'codex' || + this.llmConfig.provider === 'opencode' + ) { const localConfig = resolveLocalCLIConfig({ model: this.llmConfig.model, workingDirectory: this.repoPath, requestTimeoutMs: this.llmConfig.requestTimeoutMs, }); - return this.llmConfig.provider === 'claude' - ? callClaudeLLM(prompt, localConfig, systemPrompt, options) - : callCodexLLM(prompt, localConfig, systemPrompt, options); + if (this.llmConfig.provider === 'claude') { + return callClaudeLLM(prompt, localConfig, systemPrompt, options); + } + if (this.llmConfig.provider === 'codex') { + return callCodexLLM(prompt, localConfig, systemPrompt, options); + } + if (this.llmConfig.provider === 'opencode') { + return callOpenCodeLLM(prompt, localConfig, systemPrompt, options); + } } return callLLM(prompt, this.llmConfig, systemPrompt, options); } diff --git a/gitnexus/src/core/wiki/llm-client.ts b/gitnexus/src/core/wiki/llm-client.ts index 6e88f8e74..786fcbea5 100644 --- a/gitnexus/src/core/wiki/llm-client.ts +++ b/gitnexus/src/core/wiki/llm-client.ts @@ -16,7 +16,8 @@ export type LLMProvider = | 'custom' | 'cursor' | 'claude' - | 'codex'; + | 'codex' + | 'opencode'; export interface LLMConfig { apiKey: string; @@ -59,9 +60,14 @@ export async function resolveLLMConfig(overrides?: Partial): Promise< ? savedConfig.claudeModel : savedProvider === 'codex' ? savedConfig.codexModel - : undefined; + : savedProvider === 'opencode' + ? savedConfig.opencodeModel + : undefined; const localProvider = - savedProvider === 'cursor' || savedProvider === 'claude' || savedProvider === 'codex'; + savedProvider === 'cursor' || + savedProvider === 'claude' || + savedProvider === 'codex' || + savedProvider === 'opencode'; const apiKey = overrides?.apiKey || diff --git a/gitnexus/src/core/wiki/local-cli-client.ts b/gitnexus/src/core/wiki/local-cli-client.ts index cd3e36237..4edfe57ec 100644 --- a/gitnexus/src/core/wiki/local-cli-client.ts +++ b/gitnexus/src/core/wiki/local-cli-client.ts @@ -15,7 +15,7 @@ import type { LLMResponse, CallLLMOptions } from './llm-client.js'; import { logger } from '../logger.js'; -export type LocalAgentProvider = 'claude' | 'codex'; +export type LocalAgentProvider = 'claude' | 'codex' | 'opencode'; export interface LocalCLIConfig { model?: string; @@ -26,6 +26,7 @@ export interface LocalCLIConfig { const COMMANDS: Record = { claude: 'claude', codex: 'codex', + opencode: 'opencode', }; interface LocalCommand { @@ -162,6 +163,102 @@ export async function callCodexLLM( } } +interface OpenCodeEvent { + type?: string; + message?: string; + error?: { + message?: string; + name?: string; + data?: { + message?: string; + }; + }; + part?: { + type?: string; + text?: string; + }; +} + +function parseOpenCodeEventStream(output: string): string { + const lines = output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + const textParts: string[] = []; + + for (const line of lines) { + let event: OpenCodeEvent; + try { + event = JSON.parse(line) as OpenCodeEvent; + } catch { + continue; + } + + if (event.type === 'error') { + const message = + event.error?.data?.message || + event.error?.name || + event.message || + event.part?.text || + line; + throw new Error(`OpenCode CLI returned error event: ${message}`); + } + + if (event.type === 'text' && typeof event.part?.text === 'string') { + textParts.push(event.part.text); + } + } + + const content = textParts.join('').trim(); + if (!content) { + throw new Error('OpenCode CLI returned no text output'); + } + return content; +} + +function buildChildEnv(provider: LocalAgentProvider): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + ...process.env, + CI: '1', + }; + + if (provider === 'opencode') { + delete env.OPENCODE_SERVER_PASSWORD; + delete env.OPENCODE_SERVER_USERNAME; + } + + return env; +} + +export async function callOpenCodeLLM( + prompt: string, + config: LocalCLIConfig, + systemPrompt?: string, + options?: CallLLMOptions, +): Promise { + const commandInfo = getDetectedCommand('opencode'); + if (!commandInfo) { + throw new Error( + 'OpenCode CLI not found. Install OpenCode CLI and ensure `opencode` is on PATH.', + ); + } + + const workingDirectory = config.workingDirectory || process.cwd(); + const fullPrompt = systemPrompt ? `${systemPrompt}\n\n---\n\n${prompt}` : prompt; + // OpenCode does not expose a Codex-style read-only sandbox / no-tools flag, + // so we rely on its non-interactive permission model and tolerate any + // non-JSON stdout warnings in the parser. + const args = ['run', '--format', 'json', '--dir', workingDirectory]; + + if (config.model) { + args.push('--model', config.model); + } + + const response = await runLocalCLI('opencode', commandInfo, args, config, fullPrompt, options); + return { content: parseOpenCodeEventStream(response.content) }; +} + function runLocalCLI( provider: LocalAgentProvider, commandInfo: LocalCommand, @@ -191,10 +288,7 @@ function runLocalCLI( cwd: config.workingDirectory || process.cwd(), stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, - env: { - ...process.env, - CI: '1', - }, + env: buildChildEnv(provider), }); verboseLog(provider, 'Process spawned with PID:', child.pid); diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 72be42733..581bb9250 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -933,10 +933,19 @@ export interface CLIConfig { apiKey?: string; model?: string; baseUrl?: string; - provider?: 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor' | 'claude' | 'codex'; + provider?: + | 'openai' + | 'openrouter' + | 'azure' + | 'custom' + | 'cursor' + | 'claude' + | 'codex' + | 'opencode'; cursorModel?: string; claudeModel?: string; codexModel?: string; + opencodeModel?: string; /** Azure api-version query param (e.g. '2024-10-21'). Only used when provider is 'azure'. */ apiVersion?: string; /** Set true when the deployment is a reasoning model (o1, o3, o4-mini). Auto-detected for OpenAI; must be set for Azure deployments. */ diff --git a/gitnexus/test/unit/wiki-flags.test.ts b/gitnexus/test/unit/wiki-flags.test.ts index 47d929bc4..0d6c71413 100644 --- a/gitnexus/test/unit/wiki-flags.test.ts +++ b/gitnexus/test/unit/wiki-flags.test.ts @@ -1,5 +1,5 @@ /** - * Unit tests for wiki CLI flags: --provider cursor/claude/codex, --review, --verbose + * Unit tests for wiki CLI flags: --provider cursor/claude/codex/opencode, --review, --verbose * * Tests the new wiki provider infrastructure without requiring an actual * local agent CLI binary or LLM API key. All external dependencies are mocked. @@ -192,6 +192,36 @@ describe('resolveLLMConfig', () => { expect(config.model).toBe('gpt-5.4'); }); + it('uses opencodeModel when provider is opencode', async () => { + vi.doMock('../../src/storage/repo-manager.js', () => ({ + loadCLIConfig: vi.fn().mockResolvedValue({ + provider: 'opencode', + opencodeModel: 'openai/gpt-5.4-mini', + }), + })); + + const { resolveLLMConfig } = await import('../../src/core/wiki/llm-client.js'); + const config = await resolveLLMConfig({ provider: 'opencode' }); + + expect(config.provider).toBe('opencode'); + expect(config.model).toBe('openai/gpt-5.4-mini'); + }); + + it('does not inherit HTTP model defaults for OpenCode local provider', async () => { + vi.doMock('../../src/storage/repo-manager.js', () => ({ + loadCLIConfig: vi.fn().mockResolvedValue({ + provider: 'openai', + model: 'minimax/minimax-m2.5', + }), + })); + + const { resolveLLMConfig } = await import('../../src/core/wiki/llm-client.js'); + const config = await resolveLLMConfig({ provider: 'opencode' }); + + expect(config.provider).toBe('opencode'); + expect(config.model).toBe(''); + }); + it('does not inherit HTTP model defaults for local CLI providers', async () => { vi.doMock('../../src/storage/repo-manager.js', () => ({ loadCLIConfig: vi.fn().mockResolvedValue({ @@ -760,6 +790,16 @@ describe('CLI config round-trip with cursor provider', () => { expect(loaded.apiKey).toBeUndefined(); }); + it('saves and loads opencode provider config correctly', async () => { + const config = { provider: 'opencode', opencodeModel: 'openai/gpt-5.4-mini' }; + await fs.writeFile(configPath, JSON.stringify(config, null, 2)); + + const loaded = JSON.parse(await fs.readFile(configPath, 'utf-8')); + expect(loaded.provider).toBe('opencode'); + expect(loaded.opencodeModel).toBe('openai/gpt-5.4-mini'); + expect(loaded.apiKey).toBeUndefined(); + }); + it('saves openai provider config with model and apiKey', async () => { const config = { provider: 'openai', @@ -941,6 +981,55 @@ describe('WikiGenerator invokeLLM routing', () => { expect(result.content).toBe('codex response'); }); + it('routes to callOpenCodeLLM when provider is opencode', async () => { + const cursorClient = await import('../../src/core/wiki/cursor-client.js'); + const localClient = await import('../../src/core/wiki/local-cli-client.js'); + const llmClient = await import('../../src/core/wiki/llm-client.js'); + + const cursorSpy = vi + .spyOn(cursorClient, 'callCursorLLM') + .mockResolvedValue({ content: 'cursor response' }); + const claudeSpy = vi + .spyOn(localClient, 'callClaudeLLM') + .mockResolvedValue({ content: 'claude response' }); + const codexSpy = vi + .spyOn(localClient, 'callCodexLLM') + .mockResolvedValue({ content: 'codex response' }); + const opencodeSpy = vi + .spyOn(localClient, 'callOpenCodeLLM') + .mockResolvedValue({ content: 'opencode response' }); + const openaiSpy = vi + .spyOn(llmClient, 'callLLM') + .mockResolvedValue({ content: 'openai response' }); + + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const storagePath = path.join(tmpDir, 'storage'); + const wikiDir = path.join(storagePath, 'wiki'); + await fs.mkdir(wikiDir, { recursive: true }); + + const repoPath = path.join(tmpDir, 'repo'); + await fs.mkdir(repoPath, { recursive: true }); + + const generator = new WikiGenerator(repoPath, storagePath, path.join(storagePath, 'lbug'), { + apiKey: '', + baseUrl: '', + model: 'openai/gpt-5.4-mini', + maxTokens: 1000, + temperature: 0, + provider: 'opencode', + }); + + const result = await (generator as any).invokeLLM('test prompt', 'system prompt'); + + expect(opencodeSpy).toHaveBeenCalledTimes(1); + expect(codexSpy).not.toHaveBeenCalled(); + expect(claudeSpy).not.toHaveBeenCalled(); + expect(cursorSpy).not.toHaveBeenCalled(); + expect(openaiSpy).not.toHaveBeenCalled(); + expect(result.content).toBe('opencode response'); + }); + it('routes to callLLM when provider is openai', async () => { const cursorClient = await import('../../src/core/wiki/cursor-client.js'); const localClient = await import('../../src/core/wiki/local-cli-client.js'); @@ -1017,6 +1106,13 @@ describe('local agent CLI calls', () => { afterEach(() => { vi.restoreAllMocks(); + delete process.env.OPENCODE; + delete process.env.OPENCODE_PID; + delete process.env.OPENCODE_PROCESS_ROLE; + delete process.env.OPENCODE_RUN_ID; + delete process.env.OPENCODE_EXPERIMENTAL_WEBSOCKETS; + delete process.env.OPENCODE_SERVER_PASSWORD; + delete process.env.OPENCODE_SERVER_USERNAME; }); it('throws when Claude CLI is not in PATH', async () => { @@ -1045,6 +1141,283 @@ describe('local agent CLI calls', () => { await expect(callCodexLLM('hello', {})).rejects.toThrow('Codex CLI not found'); }); + it('throws when OpenCode CLI is not in PATH', async () => { + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockImplementation(() => { + throw new Error('not found'); + }), + spawn: vi.fn(), + })); + + const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await expect(callOpenCodeLLM('hello', {})).rejects.toThrow('OpenCode CLI not found'); + }); + + it('spawns OpenCode run, strips only credential env vars, and parses JSON text events', async () => { + process.env.OPENCODE = '1'; + process.env.OPENCODE_PID = '4242'; + process.env.OPENCODE_PROCESS_ROLE = 'worker'; + process.env.OPENCODE_RUN_ID = 'run-123'; + process.env.OPENCODE_EXPERIMENTAL_WEBSOCKETS = 'true'; + process.env.OPENCODE_SERVER_PASSWORD = 'secret'; + process.env.OPENCODE_SERVER_USERNAME = 'opencode'; + + const jsonOutput = [ + JSON.stringify({ type: 'step_start', part: { type: 'step-start' } }), + JSON.stringify({ type: 'text', part: { type: 'text', text: 'Hello' } }), + JSON.stringify({ type: 'text', part: { type: 'text', text: ' world' } }), + JSON.stringify({ type: 'step_finish', part: { type: 'step-finish', reason: 'stop' } }), + ].join('\n'); + + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.stdin.end = vi.fn((stdinText?: string) => { + queueMicrotask(() => { + child.stdout.emit('data', Buffer.from(jsonOutput)); + child.emit('close', 0); + }); + return stdinText; + }); + + const spawnSpy = vi.fn(() => child); + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'), + spawn: spawnSpy, + })); + + const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + const response = await callOpenCodeLLM( + 'hello', + { model: 'openai/gpt-5.4-mini', workingDirectory: process.cwd() }, + 'system prompt', + ); + + expect(response.content).toBe('Hello world'); + expect(child.stdin.end).toHaveBeenCalledWith('system prompt\n\n---\n\nhello'); + + const args = spawnSpy.mock.calls[0][1] as string[]; + expect(args).toContain('run'); + expect(args).toContain('--format'); + expect(args).toContain('json'); + expect(args).toContain('--dir'); + expect(args).toContain(process.cwd()); + expect(args).toContain('--model'); + expect(args).toContain('openai/gpt-5.4-mini'); + + const spawnOptions = spawnSpy.mock.calls[0][2] as { env: Record }; + expect(spawnOptions.env.OPENCODE).toBe('1'); + expect(spawnOptions.env.OPENCODE_PID).toBe('4242'); + expect(spawnOptions.env.OPENCODE_PROCESS_ROLE).toBe('worker'); + expect(spawnOptions.env.OPENCODE_RUN_ID).toBe('run-123'); + expect(spawnOptions.env.OPENCODE_EXPERIMENTAL_WEBSOCKETS).toBe('true'); + expect(spawnOptions.env.OPENCODE_SERVER_PASSWORD).toBeUndefined(); + expect(spawnOptions.env.OPENCODE_SERVER_USERNAME).toBeUndefined(); + }); + + it('omits --model when OpenCode config does not specify one', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.stdin.end = vi.fn(() => { + queueMicrotask(() => { + child.stdout.emit( + 'data', + Buffer.from(JSON.stringify({ type: 'text', part: { type: 'text', text: 'OK' } })), + ); + child.emit('close', 0); + }); + }); + + const spawnSpy = vi.fn(() => child); + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'), + spawn: spawnSpy, + })); + + const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + const response = await callOpenCodeLLM('hello', { workingDirectory: process.cwd() }); + + expect(response.content).toBe('OK'); + const args = spawnSpy.mock.calls[0][1] as string[]; + expect(args).not.toContain('--model'); + }); + + it('parses OpenCode text events even when part.type is omitted', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.stdin.end = vi.fn(() => { + queueMicrotask(() => { + child.stdout.emit( + 'data', + Buffer.from(JSON.stringify({ type: 'text', part: { text: 'fallback text' } })), + ); + child.emit('close', 0); + }); + }); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'), + spawn: vi.fn(() => child), + })); + + const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + const response = await callOpenCodeLLM('hello', { workingDirectory: process.cwd() }); + + expect(response.content).toBe('fallback text'); + }); + + it('ignores non-JSON stdout lines when OpenCode text events are present', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.stdin.end = vi.fn(() => { + queueMicrotask(() => { + child.stdout.emit( + 'data', + Buffer.from( + [ + 'permission requested: write access denied', + JSON.stringify({ type: 'text', part: { text: 'Hello from opencode' } }), + '~ https://opencode.ai/share/abc123', + ].join('\n'), + ), + ); + child.emit('close', 0); + }); + }); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'), + spawn: vi.fn(() => child), + })); + + const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + const response = await callOpenCodeLLM('hello', { workingDirectory: process.cwd() }); + + expect(response.content).toBe('Hello from opencode'); + }); + + it('fails with no text output when OpenCode only writes non-JSON stdout lines', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.stdin.end = vi.fn(() => { + queueMicrotask(() => { + child.stdout.emit('data', Buffer.from('not-json')); + child.emit('close', 0); + }); + }); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'), + spawn: vi.fn(() => child), + })); + + const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await expect(callOpenCodeLLM('hello', { workingDirectory: process.cwd() })).rejects.toThrow( + 'OpenCode CLI returned no text output', + ); + }); + + it('surfaces OpenCode error events', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.stdin.end = vi.fn(() => { + queueMicrotask(() => { + child.stdout.emit( + 'data', + Buffer.from( + JSON.stringify({ + type: 'error', + error: { name: 'PermissionDenied', data: { message: 'permission denied' } }, + }), + ), + ); + child.emit('close', 0); + }); + }); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'), + spawn: vi.fn(() => child), + })); + + const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await expect(callOpenCodeLLM('hello', { workingDirectory: process.cwd() })).rejects.toThrow( + 'OpenCode CLI returned error event: permission denied', + ); + }); + + it('fails when OpenCode returns no text events', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.stdin.end = vi.fn(() => { + queueMicrotask(() => { + child.stdout.emit( + 'data', + Buffer.from(JSON.stringify({ type: 'step_finish', part: { type: 'step-finish' } })), + ); + child.emit('close', 0); + }); + }); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'), + spawn: vi.fn(() => child), + })); + + const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await expect(callOpenCodeLLM('hello', { workingDirectory: process.cwd() })).rejects.toThrow( + 'OpenCode CLI returned no text output', + ); + }); + + it('falls back to the OpenCode error name when the nested message is missing', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.stdin.end = vi.fn(() => { + queueMicrotask(() => { + child.stdout.emit( + 'data', + Buffer.from(JSON.stringify({ type: 'error', error: { name: 'PermissionDenied' } })), + ); + child.emit('close', 0); + }); + }); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'), + spawn: vi.fn(() => child), + })); + + const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await expect(callOpenCodeLLM('hello', { workingDirectory: process.cwd() })).rejects.toThrow( + 'OpenCode CLI returned error event: PermissionDenied', + ); + }); + it('uses Codex config overrides instead of removed approval flags', async () => { const child = new EventEmitter() as any; child.stdout = new EventEmitter();