diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 85b2efd58..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", @@ -57,9 +58,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/package-lock.json b/gitnexus/package-lock.json index 810923e40..280d5b366 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" }, @@ -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" @@ -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" 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/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/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/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/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 92f3f2d8b..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,6 +55,42 @@ function isCallerAnchorLabel(label: NodeLabel): boolean { ); } +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', + ); +} + /** * Look up a `SymbolDefinition` in the graph node lookup. * @@ -107,7 +144,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 +157,13 @@ 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 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 ( - (def.type === 'Function' || def.type === 'Method') && + isOverloadableCallable(def.type) && def.parameterTypes !== undefined && def.parameterTypes.length > 0 ) { @@ -184,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(); @@ -198,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 80bc5dedd..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'; @@ -99,11 +100,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 +112,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); } 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/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/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/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/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/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/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/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/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/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/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/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); - }); -}); 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(); + }); +}); 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(); + }); +}); 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)', () => { 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();