From 5544350e3091775386d331e71e8627e2aea98178 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:26:50 +0100 Subject: [PATCH 01/46] chore(deps)(deps-dev): bump jsdom from 29.0.0 to 29.0.2 in /gitnexus-web (#863) --- gitnexus-web/package-lock.json | 319 ++++----------------------------- gitnexus-web/package.json | 2 +- 2 files changed, 40 insertions(+), 281 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 2190e27a6..189ada42c 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -57,7 +57,7 @@ "@vercel/node": "^5.5.16", "@vitejs/plugin-react": "^5.1.0", "@vitest/coverage-v8": "^3.2.4", - "jsdom": "^29.0.0", + "jsdom": "^29.0.2", "tree-sitter-wasms": "^0.1.13", "typescript": "^5.4.5", "vite": "^5.2.0", @@ -129,39 +129,49 @@ } }, "node_modules/@asamuzakjp/css-color": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", - "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.1.1", - "@csstools/css-color-parser": "^4.0.2", + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.2.6" + "@csstools/css-tokenizer": "^4.0.0" }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.3.tgz", - "integrity": "sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==", + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.10.tgz", + "integrity": "sha512-KyOb19eytNSELkmdqzZZUXWCU25byIlOld5qVFg0RYdS0T3tt7jeDByxk9hIAC73frclD8GKrHttr0SUjKCCdQ==", "dev": true, "license": "MIT", "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.7" + "is-potential-custom-element-name": "^1.0.1" }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/@asamuzakjp/nwsapi": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", @@ -628,9 +638,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", - "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", "dev": true, "funding": [ { @@ -652,9 +662,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", - "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", "dev": true, "funding": [ { @@ -669,7 +679,7 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.1.1" + "@csstools/css-calc": "^3.2.0" }, "engines": { "node": ">=20.19.0" @@ -2224,257 +2234,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@swc/core": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.8.tgz", - "integrity": "sha512-T8keoJjXaSUoVBCIjgL6wAnhADIb09GOELzKg10CjNg+vLX48P93SME6jTfte9MZIm5m+Il57H3rTSk/0kzDUw==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.25" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/swc" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.8", - "@swc/core-darwin-x64": "1.15.8", - "@swc/core-linux-arm-gnueabihf": "1.15.8", - "@swc/core-linux-arm64-gnu": "1.15.8", - "@swc/core-linux-arm64-musl": "1.15.8", - "@swc/core-linux-x64-gnu": "1.15.8", - "@swc/core-linux-x64-musl": "1.15.8", - "@swc/core-win32-arm64-msvc": "1.15.8", - "@swc/core-win32-ia32-msvc": "1.15.8", - "@swc/core-win32-x64-msvc": "1.15.8" - }, - "peerDependencies": { - "@swc/helpers": ">=0.5.17" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } - } - }, - "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.8.tgz", - "integrity": "sha512-M9cK5GwyWWRkRGwwCbREuj6r8jKdES/haCZ3Xckgkl8MUQJZA3XB7IXXK1IXRNeLjg6m7cnoMICpXv1v1hlJOg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-darwin-x64": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.8.tgz", - "integrity": "sha512-j47DasuOvXl80sKJHSi2X25l44CMc3VDhlJwA7oewC1nV1VsSzwX+KOwE5tLnfORvVJJyeiXgJORNYg4jeIjYQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.8.tgz", - "integrity": "sha512-siAzDENu2rUbwr9+fayWa26r5A9fol1iORG53HWxQL1J8ym4k7xt9eME0dMPXlYZDytK5r9sW8zEA10F2U3Xwg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.8.tgz", - "integrity": "sha512-o+1y5u6k2FfPYbTRUPvurwzNt5qd0NTumCTFscCNuBksycloXY16J8L+SMW5QRX59n4Hp9EmFa3vpvNHRVv1+Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.8.tgz", - "integrity": "sha512-koiCqL09EwOP1S2RShCI7NbsQuG6r2brTqUYE7pV7kZm9O17wZ0LSz22m6gVibpwEnw8jI3IE1yYsQTVpluALw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.8.tgz", - "integrity": "sha512-4p6lOMU3bC+Vd5ARtKJ/FxpIC5G8v3XLoPEZ5s7mLR8h7411HWC/LmTXDHcrSXRC55zvAVia1eldy6zDLz8iFQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.8.tgz", - "integrity": "sha512-z3XBnbrZAL+6xDGAhJoN4lOueIxC/8rGrJ9tg+fEaeqLEuAtHSW2QHDHxDwkxZMjuF/pZ6MUTjHjbp8wLbuRLA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.8.tgz", - "integrity": "sha512-djQPJ9Rh9vP8GTS/Df3hcc6XP6xnG5c8qsngWId/BLA9oX6C7UzCPAn74BG/wGb9a6j4w3RINuoaieJB3t+7iQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.8.tgz", - "integrity": "sha512-/wfAgxORg2VBaUoFdytcVBVCgf1isWZIEXB9MZEUty4wwK93M/PxAkjifOho9RN3WrM3inPLabICRCEgdHpKKQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.8.tgz", - "integrity": "sha512-GpMePrh9Sl4d61o4KAHOOv5is5+zt6BEXCOCgs/H0FLGeii7j9bWDE8ExvKFy2GRRZVNR1ugsnzaGWHKM6kuzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true - }, - "node_modules/@swc/types": { - "version": "0.1.25", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", - "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@swc/counter": "^0.1.3" - } - }, - "node_modules/@swc/wasm": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.15.8.tgz", - "integrity": "sha512-RG2BxGbbsjtddFCo1ghKH6A/BMXbY1eMBfpysV0lJMCpI4DZOjW1BNBnxvBt7YsYmlJtmy5UXIg9/4ekBTFFaQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true - }, "node_modules/@tailwindcss/node": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", @@ -6098,14 +5857,14 @@ "license": "MIT" }, "node_modules/jsdom": { - "version": "29.0.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.0.tgz", - "integrity": "sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ==", + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^5.0.1", - "@asamuzakjp/dom-selector": "^7.0.2", + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", @@ -6119,7 +5878,7 @@ "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", - "undici": "^7.24.3", + "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", @@ -6152,9 +5911,9 @@ } }, "node_modules/jsdom/node_modules/undici": { - "version": "7.24.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.3.tgz", - "integrity": "sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "dev": true, "license": "MIT", "engines": { diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 9616845d1..38e386fd4 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -67,7 +67,7 @@ "@vercel/node": "^5.5.16", "@vitejs/plugin-react": "^5.1.0", "@vitest/coverage-v8": "^3.2.4", - "jsdom": "^29.0.0", + "jsdom": "^29.0.2", "tree-sitter-wasms": "^0.1.13", "typescript": "^5.4.5", "vite": "^5.2.0", From 0a3b9120a0bfa355a9c399c72bbd54d9fb895f6c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:27:07 +0100 Subject: [PATCH 02/46] chore(deps)(deps): bump tailwindcss in /gitnexus-web (#861) --- gitnexus-web/package-lock.json | 20 ++++++++++++++++---- gitnexus-web/package.json | 2 +- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 189ada42c..e9d87fca3 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -39,7 +39,7 @@ "react-zoom-pan-pinch": "^3.7.0", "remark-gfm": "^4.0.1", "sigma": "^3.0.2", - "tailwindcss": "^4.1.18", + "tailwindcss": "^4.2.2", "uuid": "^13.0.0", "zod": "^3.25.76" }, @@ -2249,6 +2249,12 @@ "tailwindcss": "4.1.18" } }, + "node_modules/@tailwindcss/node/node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "license": "MIT" + }, "node_modules/@tailwindcss/oxide": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", @@ -2491,6 +2497,12 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tailwindcss/vite/node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "license": "MIT" + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -8765,9 +8777,9 @@ "license": "MIT" }, "node_modules/tailwindcss": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", - "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", "license": "MIT" }, "node_modules/tapable": { diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 38e386fd4..fa8a32ee8 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -49,7 +49,7 @@ "react-zoom-pan-pinch": "^3.7.0", "remark-gfm": "^4.0.1", "sigma": "^3.0.2", - "tailwindcss": "^4.1.18", + "tailwindcss": "^4.2.2", "uuid": "^13.0.0", "zod": "^3.25.76" }, From d5225e699f9bd5c6e7bf0e62f1a57c95c858b45a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:27:37 +0100 Subject: [PATCH 03/46] chore(deps)(deps): bump mermaid from 11.12.2 to 11.14.0 in /gitnexus-web (#860) --- gitnexus-web/package-lock.json | 163 ++++++++++++++++----------------- gitnexus-web/package.json | 2 +- 2 files changed, 80 insertions(+), 85 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index e9d87fca3..31c055b19 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -29,7 +29,7 @@ "langchain": "^1.2.10", "lru-cache": "^11.2.4", "lucide-react": "^0.562.0", - "mermaid": "^11.12.2", + "mermaid": "^11.14.0", "mnemonist": "^0.39.0", "pandemonium": "^2.4.0", "react": "^18.3.1", @@ -543,54 +543,40 @@ "license": "MIT" }, "node_modules/@chevrotain/cst-dts-gen": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", - "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz", + "integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/gast": "11.0.3", - "@chevrotain/types": "11.0.3", - "lodash-es": "4.17.21" + "@chevrotain/gast": "12.0.0", + "@chevrotain/types": "12.0.0" } }, - "node_modules/@chevrotain/cst-dts-gen/node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" - }, "node_modules/@chevrotain/gast": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz", - "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz", + "integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/types": "11.0.3", - "lodash-es": "4.17.21" + "@chevrotain/types": "12.0.0" } }, - "node_modules/@chevrotain/gast/node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" - }, "node_modules/@chevrotain/regexp-to-ast": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz", - "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz", + "integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==", "license": "Apache-2.0" }, "node_modules/@chevrotain/types": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", - "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz", + "integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==", "license": "Apache-2.0" }, "node_modules/@chevrotain/utils": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", - "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz", + "integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==", "license": "Apache-2.0" }, "node_modules/@cspotcode/source-map-support": { @@ -1774,12 +1760,12 @@ } }, "node_modules/@mermaid-js/parser": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz", - "integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz", + "integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==", "license": "MIT", "dependencies": { - "langium": "3.3.1" + "langium": "^4.0.0" } }, "node_modules/@napi-rs/wasm-runtime": { @@ -3129,6 +3115,16 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@vercel/build-utils": { "version": "13.2.11", "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.2.11.tgz", @@ -3900,37 +3896,33 @@ } }, "node_modules/chevrotain": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", - "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz", + "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/cst-dts-gen": "11.0.3", - "@chevrotain/gast": "11.0.3", - "@chevrotain/regexp-to-ast": "11.0.3", - "@chevrotain/types": "11.0.3", - "@chevrotain/utils": "11.0.3", - "lodash-es": "4.17.21" + "@chevrotain/cst-dts-gen": "12.0.0", + "@chevrotain/gast": "12.0.0", + "@chevrotain/regexp-to-ast": "12.0.0", + "@chevrotain/types": "12.0.0", + "@chevrotain/utils": "12.0.0" + }, + "engines": { + "node": ">=22.0.0" } }, "node_modules/chevrotain-allstar": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", - "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz", + "integrity": "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==", "license": "MIT", "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { - "chevrotain": "^11.0.0" + "chevrotain": "^12.0.0" } }, - "node_modules/chevrotain/node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" - }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -4601,9 +4593,9 @@ } }, "node_modules/dagre-d3-es": { - "version": "7.0.13", - "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz", - "integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==", + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", "license": "MIT", "dependencies": { "d3": "^7.9.0", @@ -6066,19 +6058,21 @@ } }, "node_modules/langium": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz", - "integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.2.tgz", + "integrity": "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==", "license": "MIT", "dependencies": { - "chevrotain": "~11.0.3", - "chevrotain-allstar": "~0.3.0", + "@chevrotain/regexp-to-ast": "~12.0.0", + "chevrotain": "~12.0.0", + "chevrotain-allstar": "~0.4.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", - "vscode-uri": "~3.0.8" + "vscode-uri": "~3.1.0" }, "engines": { - "node": ">=16.0.0" + "node": ">=20.10.0", + "npm": ">=10.2.3" } }, "node_modules/langsmith": { @@ -6391,9 +6385,9 @@ "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.22", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", - "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, "node_modules/longest-streak": { @@ -6843,27 +6837,28 @@ } }, "node_modules/mermaid": { - "version": "11.12.2", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.2.tgz", - "integrity": "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==", + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz", + "integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==", "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.1", - "@iconify/utils": "^3.0.1", - "@mermaid-js/parser": "^0.6.3", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.1.0", "@types/d3": "^7.4.3", - "cytoscape": "^3.29.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", - "dagre-d3-es": "7.0.13", - "dayjs": "^1.11.18", - "dompurify": "^3.2.5", - "katex": "^0.16.22", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "katex": "^0.16.25", "khroma": "^2.1.0", - "lodash-es": "^4.17.21", - "marked": "^16.2.1", + "lodash-es": "^4.17.23", + "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", @@ -10041,9 +10036,9 @@ "license": "MIT" }, "node_modules/vscode-uri": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", - "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "license": "MIT" }, "node_modules/w3c-xmlserializer": { diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index fa8a32ee8..b144e37d2 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -39,7 +39,7 @@ "langchain": "^1.2.10", "lru-cache": "^11.2.4", "lucide-react": "^0.562.0", - "mermaid": "^11.12.2", + "mermaid": "^11.14.0", "mnemonist": "^0.39.0", "pandemonium": "^2.4.0", "react": "^18.3.1", From 08b4505197498987252ed7aed7d471e9e93a54c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:28:34 +0100 Subject: [PATCH 04/46] chore(deps)(deps): bump @ladybugdb/core in /gitnexus (#873) --- gitnexus/package-lock.json | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index def88c93c..dfdbc54e0 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1160,9 +1160,9 @@ } }, "node_modules/@ladybugdb/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.15.2.tgz", - "integrity": "sha512-DpseEj9CM/QTV0z+rvBk6nB2mOoG4GVhnKKLiXChGTVddgpH6R/Pv2YiDZB7rUIDnFpJxVQNbQaYEkZ7i1h1KA==", + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.15.3.tgz", + "integrity": "sha512-Xa8VmWhMTvTCWmApnqm9FJtyxxV+CiMCokl1p9vEfXNuBz3SWXWGDmHlzKikswtQbUe9tTV3J9MxPdVFVE6/yg==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -1170,16 +1170,16 @@ "node-addon-api": "^6.0.0" }, "optionalDependencies": { - "@ladybugdb/core-darwin-arm64": "0.15.2", - "@ladybugdb/core-linux-arm64": "0.15.2", - "@ladybugdb/core-linux-x64": "0.15.2", - "@ladybugdb/core-win32-x64": "0.15.2" + "@ladybugdb/core-darwin-arm64": "0.15.3", + "@ladybugdb/core-linux-arm64": "0.15.3", + "@ladybugdb/core-linux-x64": "0.15.3", + "@ladybugdb/core-win32-x64": "0.15.3" } }, "node_modules/@ladybugdb/core-darwin-arm64": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.15.2.tgz", - "integrity": "sha512-ifLyUTPzlh2zR1IqkUT5AfldX+X4zfWBzwakmGTgMPxyrEiRNDwUKfnNxHeLQ/TJTOS/nfzYxxLLt5CZf2/FhA==", + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.15.3.tgz", + "integrity": "sha512-+bqAb3wbbmxPSeNQjbVd6Ek5K8GbHr1KlDr09YkNqZ7XWhKqWxbs097xAG9bynLcZh9oxok2PGCoK4w5YHs11w==", "cpu": [ "arm64" ], @@ -1190,9 +1190,9 @@ ] }, "node_modules/@ladybugdb/core-linux-arm64": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.15.2.tgz", - "integrity": "sha512-9537UbHOiuSr/BaTfjcoBsHxEKF4uEXWyXEjm/AQCGXQFocX3nQDVNDYJzuDYjKZ51oJRJ0oSuesAStOCwjolA==", + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.15.3.tgz", + "integrity": "sha512-Z8Ur6YbC5y6pgtKh/7b1/xdeRHy69sGhsoVJm1tc9xp9Zrar6G2A71bEdjOdDJ/mDRt6RtY0zdhUgIgQXYQtbQ==", "cpu": [ "arm64" ], @@ -1203,9 +1203,9 @@ ] }, "node_modules/@ladybugdb/core-linux-x64": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.15.2.tgz", - "integrity": "sha512-1+xLoapjbMQzDHxcPpMPt8Suuvms3nhOIZFNGPDcWz90NwEmLAjWNFQZZHeg8DRz0vG2j8UY292bvGORVcxs8g==", + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.15.3.tgz", + "integrity": "sha512-DT9xBc91tuxzjRu1dJ3xGt/K/uR1Q8bX5+8tCtj66UbVIVvp1RWAAE9phq7eahcF/3zBuFRonkxW/tTyQdQIlQ==", "cpu": [ "x64" ], @@ -1216,9 +1216,9 @@ ] }, "node_modules/@ladybugdb/core-win32-x64": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.15.2.tgz", - "integrity": "sha512-+LIJVKBNSrf2bGruJO4l0ihrLKZkv5+lNitK8xc3T7gC1bcc+FaYtRMvlgZP6Qh2rEHAjqfbaSKVrdw0M2EXTw==", + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.15.3.tgz", + "integrity": "sha512-ymHC8nHGIT7M9aditBQFIystxW+WoqvI3xklz22BHaFpU9CrTNtdU20K6cuRZvqEA2//Edu7kMoP9OwLkIleCg==", "cpu": [ "x64" ], From 925460ab5becfc65498e4514048f049e3464970d Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Sat, 18 Apr 2026 07:10:44 +0100 Subject: [PATCH 05/46] refactor(cli): trim duplicated ai-context CLAUDE.md block (#904) --- gitnexus/src/cli/ai-context.ts | 58 --------------------------- gitnexus/test/unit/ai-context.test.ts | 57 ++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 58 deletions(-) diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index ae7f984fb..984d1432a 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -101,19 +101,6 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s - When exploring unfamiliar code, use \`gitnexus_query({query: "concept"})\` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. - When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use \`gitnexus_context({name: "symbolName"})\`. -## When Debugging - -1. \`gitnexus_query({query: ""})\` — find execution flows related to the issue -2. \`gitnexus_context({name: ""})\` — see all callers, callees, and process participation -3. \`READ gitnexus://repo/${projectName}/process/{processName}\` — trace the full execution flow step by step -4. For regressions: \`gitnexus_detect_changes({scope: "compare", base_ref: "main"})\` — see what your branch changed - -## When Refactoring - -- **Renaming**: MUST use \`gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})\` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with \`dry_run: false\`. -- **Extracting/Splitting**: MUST run \`gitnexus_context({name: "target"})\` to see all incoming/outgoing refs, then \`gitnexus_impact({target: "target", direction: "upstream"})\` to find all external callers before moving code. -- After any refactor: run \`gitnexus_detect_changes({scope: "all"})\` to verify only expected files changed. - ## Never Do - NEVER edit a function, class, or method without first running \`gitnexus_impact\` on it. @@ -121,25 +108,6 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s - NEVER rename symbols with find-and-replace — use \`gitnexus_rename\` which understands the call graph. - NEVER commit changes without running \`gitnexus_detect_changes()\` to check affected scope. -## Tools Quick Reference - -| Tool | When to use | Command | -|------|-------------|---------| -| \`query\` | Find code by concept | \`gitnexus_query({query: "auth validation"})\` | -| \`context\` | 360-degree view of one symbol | \`gitnexus_context({name: "validateUser"})\` | -| \`impact\` | Blast radius before editing | \`gitnexus_impact({target: "X", direction: "upstream"})\` | -| \`detect_changes\` | Pre-commit scope check | \`gitnexus_detect_changes({scope: "staged"})\` | -| \`rename\` | Safe multi-file rename | \`gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})\` | -| \`cypher\` | Custom graph queries | \`gitnexus_cypher({query: "MATCH ..."})\` | - -## Impact Risk Levels - -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | - ## Resources | Resource | Use for | @@ -149,32 +117,6 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s | \`gitnexus://repo/${projectName}/processes\` | All execution flows | | \`gitnexus://repo/${projectName}/process/{name}\` | Step-by-step execution trace | -## Self-Check Before Finishing - -Before completing any code modification task, verify: -1. \`gitnexus_impact\` was run for all modified symbols -2. No HIGH/CRITICAL risk warnings were ignored -3. \`gitnexus_detect_changes()\` confirms changes match expected scope -4. All d=1 (WILL BREAK) dependents were updated - -## Keeping the Index Fresh - -After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: - -\`\`\`bash -npx gitnexus analyze -\`\`\` - -If the index previously included embeddings, preserve them by adding \`--embeddings\`: - -\`\`\`bash -npx gitnexus analyze --embeddings -\`\`\` - -To check whether embeddings exist, inspect \`.gitnexus/meta.json\` — the \`stats.embeddings\` field shows the count (0 means no embeddings). **Running analyze without \`--embeddings\` will delete any previously generated embeddings.** - -> Claude Code users: A PostToolUse hook handles this automatically after \`git commit\` and \`git merge\`. - ${ groupNames && groupNames.length > 0 ? `## Cross-Repo Groups diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index 0a9f52a68..7a8ed0e9b 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -45,6 +45,63 @@ describe('generateAIContextFiles', () => { expect(content).toContain('TestProject'); }); + it('keeps the load-bearing repo-specific sections in the CLAUDE.md block (#856)', async () => { + // The trimmed block must still contain everything that is genuinely + // unique per repo or load-bearing for the agent: the freshness warning, + // the Always Do / Never Do imperative lists, the Resources URI table + // (projectName-interpolated), and the skills routing table that tells + // the agent which skill file to read for each task. + const stats = { nodes: 50, edges: 100, processes: 5 }; + await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats); + + const content = await fs.readFile(path.join(tmpDir, 'CLAUDE.md'), 'utf-8'); + + expect(content).toContain('If any GitNexus tool warns the index is stale'); + expect(content).toContain('## Always Do'); + expect(content).toContain('## Never Do'); + expect(content).toContain('## Resources'); + expect(content).toContain('gitnexus://repo/TestProject/context'); + expect(content).toContain('gitnexus-impact-analysis/SKILL.md'); + expect(content).toContain('gitnexus-refactoring/SKILL.md'); + expect(content).toContain('gitnexus-debugging/SKILL.md'); + expect(content).toContain('gitnexus-cli/SKILL.md'); + }); + + it('does not duplicate content that already lives in skill files (#856)', async () => { + // The six sections listed in issue #856 are redundant with the skill + // files shipped alongside the CLAUDE.md block (both are loaded into + // every Claude Code session). Their absence is the whole point of the + // trim — assert each header is gone so a future regression that pads + // the block back out fails here. + const stats = { nodes: 50, edges: 100, processes: 5 }; + await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats); + + const content = await fs.readFile(path.join(tmpDir, 'CLAUDE.md'), 'utf-8'); + + expect(content).not.toContain('## Tools Quick Reference'); + expect(content).not.toContain('## Impact Risk Levels'); + expect(content).not.toContain('## Self-Check Before Finishing'); + expect(content).not.toContain('## When Debugging'); + expect(content).not.toContain('## When Refactoring'); + expect(content).not.toContain('## Keeping the Index Fresh'); + }); + + it('keeps the CLAUDE.md GitNexus block under the token-cost budget (#856)', async () => { + // The pre-trim block was ~5465 chars. After #856 it's ~2580 — about a + // 52% reduction. 2700 is a soft ceiling that still leaves headroom for + // legitimate future additions but will fail loudly if the trim is + // reverted or someone pads the block back out toward the original size. + const stats = { nodes: 50, edges: 100, processes: 5 }; + await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats); + + const content = await fs.readFile(path.join(tmpDir, 'CLAUDE.md'), 'utf-8'); + const block = content.slice( + content.indexOf(''), + content.indexOf(''), + ); + expect(block.length).toBeLessThan(2700); + }); + it('handles empty stats', async () => { const stats = {}; const result = await generateAIContextFiles(tmpDir, storagePath, 'EmptyProject', stats); From 94cba48b6d07c8a21a5f83c12d5a63f55476b4a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 07:19:24 +0100 Subject: [PATCH 06/46] chore(deps)(deps): bump mnemonist from 0.39.8 to 0.40.3 in /gitnexus (#871) --- gitnexus/package-lock.json | 28 +++++++++++++++++++++++----- gitnexus/package.json | 2 +- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index dfdbc54e0..4c76199ce 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -25,7 +25,7 @@ "ignore": "^7.0.5", "js-yaml": "^4.1.1", "lru-cache": "^11.0.0", - "mnemonist": "^0.39.0", + "mnemonist": "^0.40.3", "onnxruntime-node": "^1.24.0", "pandemonium": "^2.4.0", "tree-sitter": "^0.21.1", @@ -3351,6 +3351,15 @@ "graphology-types": ">=0.20.0" } }, + "node_modules/graphology-indices/node_modules/mnemonist": { + "version": "0.39.8", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", + "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.1" + } + }, "node_modules/graphology-types": { "version": "0.24.8", "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", @@ -4083,12 +4092,12 @@ } }, "node_modules/mnemonist": { - "version": "0.39.8", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", - "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", + "version": "0.40.3", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz", + "integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==", "license": "MIT", "dependencies": { - "obliterator": "^2.0.1" + "obliterator": "^2.0.4" } }, "node_modules/ms": { @@ -4277,6 +4286,15 @@ "mnemonist": "^0.39.2" } }, + "node_modules/pandemonium/node_modules/mnemonist": { + "version": "0.39.8", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", + "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", diff --git a/gitnexus/package.json b/gitnexus/package.json index ad075041d..c213ebe81 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -66,7 +66,7 @@ "ignore": "^7.0.5", "js-yaml": "^4.1.1", "lru-cache": "^11.0.0", - "mnemonist": "^0.39.0", + "mnemonist": "^0.40.3", "onnxruntime-node": "^1.24.0", "pandemonium": "^2.4.0", "tree-sitter": "^0.21.1", From 7a98a01ad52d262a49cd3a74e28d3548b80be286 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 07:19:49 +0100 Subject: [PATCH 07/46] chore(deps)(deps): bump lru-cache from 11.2.7 to 11.3.5 in /gitnexus (#870) --- gitnexus/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 4c76199ce..bb22f367b 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -3919,9 +3919,9 @@ "license": "Apache-2.0" }, "node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" From 30292d7179aef4d7238925ab5bdcf3aa8c1db837 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 07:20:19 +0100 Subject: [PATCH 08/46] chore(deps)(deps): bump @modelcontextprotocol/sdk in /gitnexus (#866) --- gitnexus/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index bb22f367b..ccd762920 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1235,9 +1235,9 @@ "license": "MIT" }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz", - "integrity": "sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==", + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", From 0b5381695f59a084744da2b5eaa0637514bce54b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 07:21:02 +0100 Subject: [PATCH 09/46] chore(deps)(deps-dev): bump @vitest/coverage-v8 in /gitnexus (#864) --- gitnexus/package-lock.json | 311 ++++++++++++++++++++----------------- 1 file changed, 167 insertions(+), 144 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index ccd762920..fc4acb2f9 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -138,14 +138,14 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, @@ -160,9 +160,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -1537,26 +1537,28 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", "dev": true, "license": "MIT", "funding": { @@ -1628,9 +1630,9 @@ "license": "BSD-3-Clause" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", "cpu": [ "arm64" ], @@ -1645,9 +1647,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", "cpu": [ "arm64" ], @@ -1662,9 +1664,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", "cpu": [ "x64" ], @@ -1679,9 +1681,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", "cpu": [ "x64" ], @@ -1696,9 +1698,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", - "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", "cpu": [ "arm" ], @@ -1713,9 +1715,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", "cpu": [ "arm64" ], @@ -1730,9 +1732,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", "cpu": [ "arm64" ], @@ -1747,9 +1749,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", "cpu": [ "ppc64" ], @@ -1764,9 +1766,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", "cpu": [ "s390x" ], @@ -1781,9 +1783,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", "cpu": [ "x64" ], @@ -1798,9 +1800,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", "cpu": [ "x64" ], @@ -1815,9 +1817,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", "cpu": [ "arm64" ], @@ -1832,9 +1834,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", - "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", "cpu": [ "wasm32" ], @@ -1842,16 +1844,29 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" }, "engines": { "node": ">=14.0.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", "cpu": [ "arm64" ], @@ -1866,9 +1881,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", "cpu": [ "x64" ], @@ -1883,9 +1898,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", - "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", "dev": true, "license": "MIT" }, @@ -2091,14 +2106,14 @@ "license": "MIT" }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz", - "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz", + "integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.2", + "@vitest/utils": "4.1.4", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2112,8 +2127,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.2", - "vitest": "4.1.2" + "@vitest/browser": "4.1.4", + "vitest": "4.1.4" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2122,16 +2137,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", - "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2140,13 +2155,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", - "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.2", + "@vitest/spy": "4.1.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2167,9 +2182,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", - "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", "dev": true, "license": "MIT", "dependencies": { @@ -2180,13 +2195,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", - "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.2", + "@vitest/utils": "4.1.4", "pathe": "^2.0.3" }, "funding": { @@ -2194,14 +2209,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", - "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2210,9 +2225,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", - "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", "dev": true, "license": "MIT", "funding": { @@ -2220,13 +2235,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", - "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", + "@vitest/pretty-format": "4.1.4", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4378,9 +4393,9 @@ "license": "MIT" }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { @@ -4559,14 +4574,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", - "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.12" + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" @@ -4575,21 +4590,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-x64": "1.0.0-rc.12", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" } }, "node_modules/router": { @@ -5014,14 +5029,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -5516,16 +5531,16 @@ } }, "node_modules/vite": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", - "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.12", + "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "bin": { @@ -5543,7 +5558,7 @@ "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", @@ -5594,19 +5609,19 @@ } }, "node_modules/vitest": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", - "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.2", - "@vitest/mocker": "4.1.2", - "@vitest/pretty-format": "4.1.2", - "@vitest/runner": "4.1.2", - "@vitest/snapshot": "4.1.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -5634,10 +5649,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.2", - "@vitest/browser-preview": "4.1.2", - "@vitest/browser-webdriverio": "4.1.2", - "@vitest/ui": "4.1.2", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -5661,6 +5678,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, From ef953beca950a53319a3ada60fd7388541a58a9c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 07:22:34 +0100 Subject: [PATCH 10/46] chore(deps)(deps): bump @huggingface/transformers in /gitnexus (#869) --- gitnexus/package-lock.json | 43 ++++++++++++++++++++++---------------- gitnexus/package.json | 2 +- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index fc4acb2f9..3e4a7ea65 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { - "@huggingface/transformers": "^3.0.0", + "@huggingface/transformers": "^4.1.0", "@ladybugdb/core": "^0.15.2", "@modelcontextprotocol/sdk": "^1.0.0", "@scarf/scarf": "^1.4.0", @@ -633,16 +633,23 @@ "node": ">=18" } }, + "node_modules/@huggingface/tokenizers": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz", + "integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==", + "license": "Apache-2.0" + }, "node_modules/@huggingface/transformers": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.8.1.tgz", - "integrity": "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.1.0.tgz", + "integrity": "sha512-WiMf9eyvF6V2pj4gs12A7GQV3svyFIBtB/W+Hn5lT5E5DyqWUno1ZrWoAfJv69X1RNv/0GoOo6DFmL6NOYd+rg==", "license": "Apache-2.0", "dependencies": { - "@huggingface/jinja": "^0.5.3", - "onnxruntime-node": "1.21.0", - "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", - "sharp": "^0.34.1" + "@huggingface/jinja": "^0.5.6", + "@huggingface/tokenizers": "^0.1.3", + "onnxruntime-node": "1.24.3", + "onnxruntime-web": "1.26.0-dev.20260410-5e55544225", + "sharp": "^0.34.5" } }, "node_modules/@img/colour": { @@ -4267,23 +4274,23 @@ } }, "node_modules/onnxruntime-web": { - "version": "1.22.0-dev.20250409-89f8206ba4", - "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.22.0-dev.20250409-89f8206ba4.tgz", - "integrity": "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==", + "version": "1.26.0-dev.20260410-5e55544225", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260410-5e55544225.tgz", + "integrity": "sha512-hHd9n8DzIfGSAjM4Dvslesc8i6h9HEEcl8qt7X3LfhUxMgls6FBJ32j2xrDtJjKJFEehFeJmyB/pvad1I8KS8w==", "license": "MIT", "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", - "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", + "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { - "version": "1.22.0-dev.20250409-89f8206ba4", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.22.0-dev.20250409-89f8206ba4.tgz", - "integrity": "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==", + "version": "1.24.0-dev.20251116-b39e144322", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz", + "integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==", "license": "MIT" }, "node_modules/package-json-from-dist": { @@ -4422,9 +4429,9 @@ } }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", + "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { diff --git a/gitnexus/package.json b/gitnexus/package.json index c213ebe81..a4574545c 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -51,7 +51,7 @@ "prepack": "node scripts/build.js" }, "dependencies": { - "@huggingface/transformers": "^3.0.0", + "@huggingface/transformers": "^4.1.0", "@ladybugdb/core": "^0.15.2", "@modelcontextprotocol/sdk": "^1.0.0", "@scarf/scarf": "^1.4.0", From 4988feec949791936561b4a1105e254ab513a35a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 07:26:56 +0100 Subject: [PATCH 11/46] chore(deps)(deps-dev): bump wait-on from 8.0.5 to 9.0.5 in /gitnexus-web (#859) --- gitnexus-web/package-lock.json | 53 ++++++++++++++++++---------------- gitnexus-web/package.json | 2 +- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 31c055b19..b62d3eddc 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -62,7 +62,7 @@ "typescript": "^5.4.5", "vite": "^5.2.0", "vitest": "^3.2.4", - "wait-on": "^8.0.5" + "wait-on": "^9.0.5" }, "engines": { "node": ">=20.0.0" @@ -3596,14 +3596,14 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" } }, "node_modules/bail": { @@ -5827,9 +5827,9 @@ } }, "node_modules/joi": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz", - "integrity": "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==", + "version": "18.1.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.1.2.tgz", + "integrity": "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5839,7 +5839,7 @@ "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", - "@standard-schema/spec": "^1.0.0" + "@standard-schema/spec": "^1.1.0" }, "engines": { "node": ">= 20" @@ -6378,9 +6378,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, @@ -8083,10 +8083,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", @@ -10055,15 +10058,15 @@ } }, "node_modules/wait-on": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.5.tgz", - "integrity": "sha512-J3WlS0txVHkhLRb2FsmRg3dkMTCV1+M6Xra3Ho7HzZDHpE7DCOnoSoCJsZotrmW3uRMhvIJGSKUKrh/MeF4iag==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.5.tgz", + "integrity": "sha512-qgnbHDfDTRIp73ANEJNRW/7kn8CrDUcvZz18xotJQku/P4saTGkbIzvnMZebPmVvVNUiRq1qWAPyqCH+W4H8KA==", "dev": true, "license": "MIT", "dependencies": { - "axios": "^1.12.1", - "joi": "^18.0.1", - "lodash": "^4.17.21", + "axios": "^1.15.0", + "joi": "^18.1.2", + "lodash": "^4.18.1", "minimist": "^1.2.8", "rxjs": "^7.8.2" }, @@ -10071,7 +10074,7 @@ "wait-on": "bin/wait-on" }, "engines": { - "node": ">=12.0.0" + "node": ">=20.0.0" } }, "node_modules/webidl-conversions": { diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index b144e37d2..08b2eed7d 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -72,6 +72,6 @@ "typescript": "^5.4.5", "vite": "^5.2.0", "vitest": "^3.2.4", - "wait-on": "^8.0.5" + "wait-on": "^9.0.5" } } From 509185b8f9b3813a077e8fc186496324fc846c9b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 07:28:17 +0100 Subject: [PATCH 12/46] chore(deps)(deps): bump commander from 12.1.0 to 14.0.3 in /gitnexus (#868) --- gitnexus/package-lock.json | 10 +++++----- gitnexus/package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 3e4a7ea65..a3e537556 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -15,7 +15,7 @@ "@modelcontextprotocol/sdk": "^1.0.0", "@scarf/scarf": "^1.4.0", "cli-progress": "^3.12.0", - "commander": "^12.0.0", + "commander": "^14.0.3", "cors": "^2.8.5", "express": "^4.19.2", "glob": "^11.0.0", @@ -2576,12 +2576,12 @@ "license": "MIT" }, "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/content-disposition": { diff --git a/gitnexus/package.json b/gitnexus/package.json index a4574545c..d57c56d59 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -56,7 +56,7 @@ "@modelcontextprotocol/sdk": "^1.0.0", "@scarf/scarf": "^1.4.0", "cli-progress": "^3.12.0", - "commander": "^12.0.0", + "commander": "^14.0.3", "cors": "^2.8.5", "express": "^4.19.2", "glob": "^11.0.0", From 725ed3fe6669f4e3decad6c66eb410de10897e2f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 07:40:08 +0100 Subject: [PATCH 13/46] chore(deps)(deps): bump glob from 11.1.0 to 13.0.6 in /gitnexus (#867) --- gitnexus/package-lock.json | 81 ++++---------------------------------- gitnexus/package.json | 2 +- 2 files changed, 9 insertions(+), 74 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index a3e537556..f2605cb43 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -18,7 +18,7 @@ "commander": "^14.0.3", "cors": "^2.8.5", "express": "^4.19.2", - "glob": "^11.0.0", + "glob": "^13.0.6", "graphology": "^0.25.4", "graphology-indices": "^0.17.0", "graphology-utils": "^2.3.0", @@ -1117,15 +1117,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/cliui": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", - "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -3137,22 +3128,6 @@ "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", "license": "Apache-2.0" }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3273,24 +3248,17 @@ "link": true }, "node_modules/glob": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", - "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -3600,21 +3568,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", - "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^9.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/jose": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", @@ -4293,12 +4246,6 @@ "integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==", "license": "MIT" }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, "node_modules/pandemonium": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/pandemonium/-/pandemonium-2.4.1.tgz", @@ -4903,18 +4850,6 @@ "dev": true, "license": "ISC" }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/gitnexus/package.json b/gitnexus/package.json index d57c56d59..b269f7670 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -59,7 +59,7 @@ "commander": "^14.0.3", "cors": "^2.8.5", "express": "^4.19.2", - "glob": "^11.0.0", + "glob": "^13.0.6", "graphology": "^0.25.4", "graphology-indices": "^0.17.0", "graphology-utils": "^2.3.0", From 040bb7a4891fec57665e73cf5724f9ff8fc4356e Mon Sep 17 00:00:00 2001 From: Kritik Bangera Date: Sat, 18 Apr 2026 13:09:18 +0530 Subject: [PATCH 14/46] feat: add docker support (#848) * feat: add docker support * feat: move docker files to root * feat: add docker build and push workflow * fix: pin docker action SHAs to verified commits Made-with: Cursor * fix: remove redundant --platform=$TARGETPLATFORM from runtime stage Made-with: Cursor * fix: upgrade docker actions to Node.js 24-compatible versions Made-with: Cursor * docs: updated readme * fix: update docker references * fix(docker-server): reject null bytes in resolvePath Defensively harden the path traversal guard by returning null early when the URL contains a null byte, before normalization runs. Made-with: Cursor * fix(docker-server): handle createReadStream errors Attach an error listener before piping so mid-flight read errors (truncated file, permission change) cleanly destroy the response instead of being silently swallowed. Made-with: Cursor * fix(docker-server): replace existsSync with async stat Eliminates the TOCTOU race between the initial stat call and the subsequent existsSync check. Reuses the async stat pattern already in place and removes the now-unused existsSync import. Made-with: Cursor * test(docker-server): add integration tests; fix %00 null-byte bypass Decode the URL before the null-byte check so percent-encoded null bytes (%00) are also rejected with 400 instead of falling through to the SPA fallback. Adds 5 node:test integration tests covering valid assets, SPA fallback, path traversal, null bytes, and 404. Made-with: Cursor * style: fix prettier formatting in docker-server files Made-with: Cursor * fix(docker): wire tests into CI, fix resolvePath separator, correct image namespace - Add `node --test docker-server.test.mjs` step to ci-tests.yml so the path-traversal guard tests run in every CI pass instead of being silently skipped. - Fix resolvePath containment check: `startsWith(root)` would allow sibling directories like `/app/dist-evil/`; now guards with `root + sep` or exact match. - Update docker-compose.yaml default image from `abhigyanpatwari` namespace to `brainifii` to match what docker.yml publishes to GHCR. * fix(docker): update apt-get commands and set user permissions - Modify Dockerfile and Dockerfile.test to include options for apt-get to bypass validity checks during updates. - Set ownership of the /app directory to the 'node' user in the runtime stage for improved security and proper permission handling. * fix(docker): switch to Alpine base images for smaller footprint - Update Dockerfile to use Alpine-based Node.js images for both builder and runtime stages, reducing image size and improving performance. - Replace apt-get commands with apk for package installation in the runtime stage. * fix(docker): update Node.js version in Dockerfile - Change base image from node:20-alpine to node:22-alpine * fix(docker): update Node.js version in Dockerfile to 22-alpine for runtime --------- Co-authored-by: kritik.b --- .cursor/.gitignore | 1 + .dockerignore | 19 ++++++ .env.example | 3 + .github/workflows/ci-tests.yml | 3 + .github/workflows/docker.yml | 71 ++++++++++++++++++++++ .gitignore | 1 + Dockerfile | 36 +++++++++++ README.md | 36 +++++++++++ docker-compose.yaml | 13 ++++ docker-server.mjs | 81 +++++++++++++++++++++++++ docker-server.test.mjs | 107 +++++++++++++++++++++++++++++++++ gitnexus-web/vite.config.ts | 1 + gitnexus/Dockerfile.test | 2 +- 13 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 .cursor/.gitignore create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/workflows/docker.yml create mode 100644 Dockerfile create mode 100644 docker-compose.yaml create mode 100644 docker-server.mjs create mode 100644 docker-server.test.mjs diff --git a/.cursor/.gitignore b/.cursor/.gitignore new file mode 100644 index 000000000..8bf7cc27a --- /dev/null +++ b/.cursor/.gitignore @@ -0,0 +1 @@ +plans/ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..e09631a0a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +.gitignore +.DS_Store + +node_modules +**/node_modules + +dist +**/dist +coverage +**/coverage + +.env +.env.local +.env.*.local + +.gitnexus +gitnexus-web/playwright-report +gitnexus-web/test-results diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..b52d05ae1 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +IMAGE_NAME=ghcr.io/abhigyanpatwari/gitnexus:latest +CONTAINER_NAME=gitnexus +HOST_PORT=4173 diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 27eb75383..7010ee6f3 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -41,6 +41,9 @@ jobs: --outputFile=web-test-results.json working-directory: gitnexus-web + - name: Run docker-server integration tests + run: node --test docker-server.test.mjs + - name: Upload test reports if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 000000000..40a1a6eef --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,71 @@ +name: Docker Build & Push + +on: + push: + tags: + - 'v*' + branches: + - main + paths-ignore: ['**.md', 'docs/**', 'LICENSE'] + workflow_dispatch: + +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". +# Tag refs are unique per release — distinct tags run in parallel. +# Pushes to main serialize; cancel superseded runs. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref == 'refs/heads/main' }} + +jobs: + build-push: + name: Build & Push image + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # Required for multi-platform (linux/arm64) emulation. + - name: Set up QEMU + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Computes image tags and labels from Git metadata: + # v* tag → ghcr.io//: (e.g. 1.2.3, 1.2, 1) + # main push → ghcr.io//:latest + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable={{is_default_branch}} + type=sha,prefix=sha-,format=short + + - name: Build and push + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + BUILDPLATFORM=${{ runner.os == 'Linux' && 'linux/amd64' || 'linux/amd64' }} diff --git a/.gitignore b/.gitignore index e8d4077ec..95c9164e0 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ Thumbs.db .env .env.local .env.*.local +docker/.env # Logs *.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..347888c23 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +ARG BUILDPLATFORM +ARG TARGETPLATFORM + +FROM --platform=$BUILDPLATFORM node:22-alpine AS builder + +WORKDIR /app + +COPY gitnexus-shared/package.json gitnexus-shared/package-lock.json ./gitnexus-shared/ +RUN npm ci --prefix gitnexus-shared + +COPY gitnexus-shared ./gitnexus-shared +RUN npm run build --prefix gitnexus-shared + +COPY gitnexus/package.json ./gitnexus/package.json +COPY gitnexus-web/package.json gitnexus-web/package-lock.json ./gitnexus-web/ +RUN npm ci --prefix gitnexus-web + +COPY gitnexus-web ./gitnexus-web +RUN npm run build --prefix gitnexus-web + +FROM node:22-alpine AS runtime + +RUN apk add --no-cache curl + +WORKDIR /app + +COPY --from=builder /app/gitnexus-web/dist ./dist +COPY docker-server.mjs ./docker-server.mjs + +RUN chown -R node:node /app + +USER node + +EXPOSE 4173 + +CMD ["node", "docker-server.mjs"] diff --git a/README.md b/README.md index 65ac7d332..d61fc54d5 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,42 @@ cd ../gitnexus-web && npm install npm run dev ``` +## Docker + +```bash +docker run --rm \ + --name gitnexus \ + -p 4173:4173 \ + ghcr.io/abhigyanpatwari/gitnexus:latest +``` + +Or with Docker Compose: + +```bash +docker compose up -d +``` + +Optional env file: + +```bash +cp .env.example .env +set -a +source .env +set +a +``` + +Docker files: + +- [Dockerfile](Dockerfile) is the source for the published `gitnexus` image. It builds `gitnexus-shared` and `gitnexus-web`, then serves the production frontend. +- [docker-compose.yaml](docker-compose.yaml) starts the published image with Docker Compose. +- [.env.example](.env.example) sets the image name, container name, and exposed port for the example commands. + +Notes: + +- The published image serves the production frontend only. It does not start `gitnexus serve`. +- In backend mode, the app still defaults to `http://localhost:4747` unless you change the server URL in the UI. +- If you do not want an env file, the defaults are `ghcr.io/abhigyanpatwari/gitnexus:latest`, container name `gitnexus`, and port `4173`. + The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAssembly (Tree-sitter WASM, LadybugDB WASM, in-browser embeddings). It's great for quick exploration but limited by browser memory for larger repos. **Local Backend Mode:** Run `gitnexus serve` and open the web UI locally — it auto-detects the server and shows all your indexed repos, with full AI chat support. No need to re-upload or re-index. The agent's tools (Cypher queries, search, code navigation) route through the backend HTTP API automatically. diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 000000000..849a3ae14 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,13 @@ +services: + gitnexus: + image: ${IMAGE_NAME:-ghcr.io/brainifii/gitnexus:latest} + container_name: ${CONTAINER_NAME:-gitnexus} + ports: + - '${HOST_PORT:-4173}:4173' + restart: unless-stopped + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:4173/'] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/docker-server.mjs b/docker-server.mjs new file mode 100644 index 000000000..adf84a07b --- /dev/null +++ b/docker-server.mjs @@ -0,0 +1,81 @@ +import { createReadStream } from 'node:fs'; +import { stat } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { extname, join, normalize, sep } from 'node:path'; + +const host = '0.0.0.0'; +const port = Number(process.env.PORT || '4173'); +const root = join(process.cwd(), 'dist'); + +const contentTypes = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + '.woff': 'font/woff', + '.woff2': 'font/woff2', +}; + +function resolvePath(urlPath) { + let decoded; + try { + decoded = decodeURIComponent(urlPath); + } catch { + return null; + } + if (decoded.includes('\0')) return null; + const cleanPath = normalize(decoded.replace(/^\/+/, '')); + const candidate = join(root, cleanPath); + if (candidate !== root && !candidate.startsWith(root + sep)) return null; + return candidate; +} + +const server = createServer(async (req, res) => { + const requestPath = req.url?.split('?')[0] || '/'; + let filePath = resolvePath(requestPath); + + if (!filePath) { + res.writeHead(400); + res.end('Bad request'); + return; + } + + try { + const fileStat = await stat(filePath).catch(() => null); + if (fileStat?.isDirectory()) { + filePath = join(filePath, 'index.html'); + } else if (!fileStat?.isFile()) { + filePath = join(root, 'index.html'); + } + + const finalStat = await stat(filePath).catch(() => null); + if (!finalStat?.isFile()) { + res.writeHead(404); + res.end('Not found'); + return; + } + + res.writeHead(200, { + 'Cache-Control': filePath.includes('/assets/') + ? 'public, max-age=31536000, immutable' + : 'no-cache', + 'Content-Type': contentTypes[extname(filePath)] || 'application/octet-stream', + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + }); + const stream = createReadStream(filePath); + stream.on('error', () => res.destroy()); + stream.pipe(res); + } catch (error) { + res.writeHead(500); + res.end(error instanceof Error ? error.message : 'Internal server error'); + } +}); + +server.listen(port, host, () => { + console.log(`gitnexus-web listening on http://${host}:${port}`); +}); diff --git a/docker-server.test.mjs b/docker-server.test.mjs new file mode 100644 index 000000000..a1005b0e4 --- /dev/null +++ b/docker-server.test.mjs @@ -0,0 +1,107 @@ +import { mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'; +import http, { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { after, before, it } from 'node:test'; +import assert from 'node:assert/strict'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const serverScript = join(__dirname, 'docker-server.mjs'); + +function getFreePort() { + return new Promise((resolve) => { + const s = createServer(); + s.listen(0, '127.0.0.1', () => { + const { port } = s.address(); + s.close(() => resolve(port)); + }); + }); +} + +function rawGet(port, path) { + return new Promise((resolve, reject) => { + const req = http.request({ host: '127.0.0.1', port, path }, (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body })); + }); + req.on('error', reject); + req.end(); + }); +} + +async function waitForServer(port, retries = 30) { + for (let i = 0; i < retries; i++) { + try { + await rawGet(port, '/'); + return; + } catch { + await new Promise((r) => setTimeout(r, 100)); + } + } + throw new Error('Server did not start in time'); +} + +let tmpDir, serverPort, child; + +before(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gitnexus-docker-test-')); + const distDir = join(tmpDir, 'dist'); + const assetsDir = join(distDir, 'assets'); + await mkdir(assetsDir, { recursive: true }); + await writeFile(join(distDir, 'index.html'), 'spa'); + await writeFile(join(assetsDir, 'app.abc123.js'), 'console.log("app")'); + + serverPort = await getFreePort(); + child = spawn(process.execPath, [serverScript], { + cwd: tmpDir, + env: { ...process.env, PORT: String(serverPort) }, + stdio: 'pipe', + }); + child.on('error', (err) => { + throw err; + }); + + await waitForServer(serverPort); +}); + +after(async () => { + child?.kill(); + if (tmpDir) await rm(tmpDir, { recursive: true, force: true }); +}); + +it('serves a valid asset with immutable cache header', async () => { + const res = await rawGet(serverPort, '/assets/app.abc123.js'); + assert.equal(res.status, 200); + assert.match(res.headers['cache-control'], /immutable/); + assert.equal(res.headers['cross-origin-opener-policy'], 'same-origin'); + assert.equal(res.headers['cross-origin-embedder-policy'], 'require-corp'); +}); + +it('serves SPA fallback for unknown routes', async () => { + const res = await rawGet(serverPort, '/some/unknown/route'); + assert.equal(res.status, 200); + assert.match(res.body, /spa/); + assert.match(res.headers['cache-control'], /no-cache/); +}); + +it('rejects path traversal with 400', async () => { + const res = await rawGet(serverPort, '/../../../etc/passwd'); + assert.equal(res.status, 400); +}); + +it('rejects percent-encoded null bytes with 400', async () => { + const res = await rawGet(serverPort, '/foo%00bar'); + assert.equal(res.status, 400); +}); + +it('returns 404 when dist/index.html is missing', async () => { + await unlink(join(tmpDir, 'dist', 'index.html')); + const res = await rawGet(serverPort, '/nonexistent-page'); + assert.equal(res.status, 404); +}); diff --git a/gitnexus-web/vite.config.ts b/gitnexus-web/vite.config.ts index b177f6804..a62b9e586 100644 --- a/gitnexus-web/vite.config.ts +++ b/gitnexus-web/vite.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ alias: { '@': path.resolve(__dirname, './src'), '@shared': path.resolve(__dirname, '../shared'), + 'gitnexus-shared': path.resolve(__dirname, '../gitnexus-shared/src/index.ts'), // Fix for Rollup failing to resolve this deep import from @langchain/anthropic '@anthropic-ai/sdk/lib/transform-json-schema': path.resolve( __dirname, diff --git a/gitnexus/Dockerfile.test b/gitnexus/Dockerfile.test index b2d22384f..7cafbe2c1 100644 --- a/gitnexus/Dockerfile.test +++ b/gitnexus/Dockerfile.test @@ -1,6 +1,6 @@ FROM node:20-bookworm WORKDIR /app -RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* +RUN apt-get -o Acquire::Check-Valid-Until=false -o Acquire::Check-Date=false update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* COPY . . RUN npm ci --ignore-scripts \ && node scripts/patch-tree-sitter-swift.cjs \ From 018e0e6b1476437dcffe136a8549330be1c74dbd Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 10:15:28 +0100 Subject: [PATCH 15/46] test(web-e2e): raise status-ready timeout to 45s for parallel-worker stability (#908) * Initial plan * plan: stabilize web e2e tests timing out under parallel workers Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/49d09e67-5a8e-4eee-adcd-3d5416675a6b Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test(web-e2e): bump status-ready timeout to 45s for parallel-worker stability Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/49d09e67-5a8e-4eee-adcd-3d5416675a6b Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- gitnexus-web/e2e/multi-repo-scoping.spec.ts | 27 +++++++++++++++--- gitnexus-web/e2e/onboarding.spec.ts | 9 +++++- gitnexus-web/e2e/repo-switching.spec.ts | 31 +++++++++++++++++---- 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/gitnexus-web/e2e/multi-repo-scoping.spec.ts b/gitnexus-web/e2e/multi-repo-scoping.spec.ts index 67ee06b08..a60c847d2 100644 --- a/gitnexus-web/e2e/multi-repo-scoping.spec.ts +++ b/gitnexus-web/e2e/multi-repo-scoping.spec.ts @@ -61,13 +61,22 @@ test.beforeAll(async () => { } }); +// Auto-connect downloads the full graph from the backend; under parallel +// workers in CI the same backend serves multiple downloads concurrently, so +// reaching the "Ready" state can take noticeably longer than a single-worker +// run. Match the 45s budget used by waitForGraphLoaded() in +// server-connect.spec.ts which has been stable on the same backend. +const READY_TIMEOUT_MS = 45_000; + test.describe('Multi-Repo Scoping', () => { test('auto-connect via ?server= sets ?project= in URL', async ({ page }) => { // Navigate with ?server= param (the bookmarkable shortcut) await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); // Wait for graph to load - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ + timeout: READY_TIMEOUT_MS, + }); // URL should now contain ?project= with the repo name const url = new URL(page.url()); @@ -77,8 +86,14 @@ test.describe('Multi-Repo Scoping', () => { }); test('?server= is preserved in URL for F5 recovery', async ({ page }) => { + // Two sequential auto-connects (initial + reload), each up to READY_TIMEOUT_MS, + // can exceed the default 60s test timeout under parallel workers. + test.slow(); + await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ + timeout: READY_TIMEOUT_MS, + }); // URL should still have ?server= const url = new URL(page.url()); @@ -86,12 +101,16 @@ test.describe('Multi-Repo Scoping', () => { // F5 should reconnect (not show onboarding) await page.reload(); - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ + timeout: READY_TIMEOUT_MS, + }); }); test('node count in status bar matches backend data', async ({ page }) => { await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ + timeout: READY_TIMEOUT_MS, + }); // Fetch expected node count from backend const res = await fetch(`${BACKEND_URL}/api/repo?repo=${encodeURIComponent(firstRepoName)}`); diff --git a/gitnexus-web/e2e/onboarding.spec.ts b/gitnexus-web/e2e/onboarding.spec.ts index da92ffb70..5b70899c6 100644 --- a/gitnexus-web/e2e/onboarding.spec.ts +++ b/gitnexus-web/e2e/onboarding.spec.ts @@ -26,7 +26,10 @@ async function enterExploringView(page: import('@playwright/test').Page) { // Landing screen may not appear (e.g. ?server auto-connect) } - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + // Match the 45s budget used by waitForGraphLoaded() in + // server-connect.spec.ts; under parallel CI workers, downloading the full + // graph can occasionally exceed 30s. + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 45_000 }); } // ── Flow 1: Onboarding (no server running) ───────────────────────────────── @@ -244,6 +247,10 @@ test.describe('Flow 3: Analyze form', () => { test.describe('Flow 4: Repo dropdown in exploring view', () => { const SKIP_MSG = 'Requires running gitnexus server with indexed repos'; + // enterExploringView() can take up to ~45s under parallel CI workers; combined + // with the dropdown interactions this can exceed the default 60s test budget. + test.slow(); + test.beforeAll(async () => { if (process.env.E2E) return; try { diff --git a/gitnexus-web/e2e/repo-switching.spec.ts b/gitnexus-web/e2e/repo-switching.spec.ts index 4cf6bf8c8..802f44e78 100644 --- a/gitnexus-web/e2e/repo-switching.spec.ts +++ b/gitnexus-web/e2e/repo-switching.spec.ts @@ -84,11 +84,20 @@ test.describe('Hold-queue timeout error', () => { // ── 2. ?project= URL persistence ───────────────────────────────────────────── +// Auto-connect downloads the full graph from the backend; under parallel +// workers in CI the same backend serves multiple downloads concurrently, so +// reaching the "Ready" state can take noticeably longer than a single-worker +// run. Match the 45s budget used by waitForGraphLoaded() in +// server-connect.spec.ts which has been stable on the same backend. +const READY_TIMEOUT_MS = 45_000; + test.describe('?project= URL persistence', () => { test('?project= is set in URL after connecting via ?server=', async ({ page }) => { await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ + timeout: READY_TIMEOUT_MS, + }); const url = new URL(page.url()); const project = url.searchParams.get('project'); @@ -98,12 +107,20 @@ test.describe('?project= URL persistence', () => { }); test('?project= is still present after F5 reload', async ({ page }) => { + // Two sequential auto-connects (initial + reload), each up to READY_TIMEOUT_MS, + // can exceed the default 60s test timeout under parallel workers. + test.slow(); + await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ + timeout: READY_TIMEOUT_MS, + }); // After connect, URL has ?server=&project= — F5 re-uses both params await page.reload(); - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ + timeout: READY_TIMEOUT_MS, + }); const url = new URL(page.url()); expect(url.searchParams.get('project')).toBeTruthy(); @@ -122,7 +139,9 @@ test.describe('?project= auto-connect', () => { `/?server=${encodeURIComponent(BACKEND_URL)}&project=${encodeURIComponent(firstRepoName)}`, ); - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ + timeout: READY_TIMEOUT_MS, + }); // ?project= in URL should match what we passed in const url = new URL(page.url()); @@ -155,7 +174,9 @@ test.describe('Windows path normalization', () => { await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ + timeout: READY_TIMEOUT_MS, + }); // URL ?project= must be the short basename, NOT the full Windows path const url = new URL(page.url()); From 969b4623ca80ba7334a4d8a6e5f0930c4f844bcc Mon Sep 17 00:00:00 2001 From: Ryanba <92616678+Gujiassh@users.noreply.github.com> Date: Sat, 18 Apr 2026 18:09:15 +0800 Subject: [PATCH 16/46] fix: keep worker warnings non-terminal (#900) * fix: keep worker warnings non-terminal Treat parse-worker warning messages as informational so a warning can be surfaced without short-circuiting the worker result protocol. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus * style: apply prettier formatting --------- Co-authored-by: Sisyphus From b8875b9c80ba434bcb5779c8a4379ca7f40cb167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 12:16:51 +0100 Subject: [PATCH 17/46] chore(release): v1.6.2 (#952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps gitnexus to v1.6.2 and adds the matching CHANGELOG entry. Highlights since v1.6.1 (61 commits): - Docker support (#848) - Language-agnostic heritage / call / variable extractors (config+factory pattern, #877 #878 #890) - AST-aware embedding chunking (#889) - jQuery / axios HTTP consumer detection (#887) - SemanticModel wired as first-class resolution input, SM-20 (#885) - ImportSemantics split into per-strategy hooks (#886) - Python dotted-import fix (#899); worker warnings non-terminal (#900 / #261); global-install ENOTEMPTY fixes (#843 #846); embeddings staleness fix (#831) See gitnexus/CHANGELOG.md for the full list. After merge, tag `v1.6.2` triggers publish.yml which runs CI, verifies tag↔package.json match, publishes to npm with provenance, and creates the GitHub Release using the extracted CHANGELOG body. --- gitnexus/CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++++++++++ gitnexus/package.json | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/gitnexus/CHANGELOG.md b/gitnexus/CHANGELOG.md index 80096345d..203089d93 100644 --- a/gitnexus/CHANGELOG.md +++ b/gitnexus/CHANGELOG.md @@ -2,6 +2,46 @@ All notable changes to GitNexus will be documented in this file. +## [1.6.2] - 2026-04-18 + +### Added + +- **Docker support** — containerized ingestion and MCP serving for reproducible runs on CI and container platforms (#848) +- **Language-agnostic heritage extractor** — config+factory pattern for class-heritage extraction (EXTENDS / IMPLEMENTS), completing the extractor refactor alongside method/field/call/variable (#890) +- **Language-agnostic call extractor** — config+factory pattern that collapses ~225 lines of inline parse-worker logic into declarative per-language configs (#877) +- **Language-agnostic variable extractor** — structured metadata for `Const` / `Static` / `Variable` nodes via config+factory pattern (#878) +- **AST-aware embedding chunking** — offset-based splitting preserves symbol boundaries, improving semantic search precision on large files (#889) +- **HTTP consumer detection for jQuery and axios object-form** — `$.ajax` / `$.get` / `$.post` and `axios({ url, method })` now recognized as HTTP call sites (#887) + +### Fixed + +- **Python external dotted imports** — avoid spurious same-file matches when an import path like `foo.bar.baz` refers to a third-party module (#899) +- **Worker warnings no longer terminate ingestion** — non-fatal parser warnings keep the pipeline running instead of aborting the run (#900, #261) +- **Global-install upgrade `ENOTEMPTY`** — devendored `tree-sitter-proto` install lifecycle + preinstall cleanup so `npm i -g gitnexus@latest` succeeds on top of an older install (#843, #846) +- **`env.cacheDir`** now defaults to a user-writable location, unblocking ingestion on systems where the install directory is read-only (#845) +- **Content-hash staleness detection for embeddings** — zero-node rebuilds no longer skip vector-index creation, fixing semantic search after selective re-analysis (#831) +- **`tree-sitter-c-sharp` version pin** — locked to 0.23.1 to avoid a breaking change in a transitive prerelease (#834) +- **`release-drafter` v7 CI** — replaced the removed `disable-releaser` flag with `dry-run` so release-note drafts still work +- **`npm arborist` crash from `tree-sitter-dart`** — switched the dependency URL format so `npm install` no longer crashes on clean installs +- **Service-group `ManifestExtractor`** — `config.links` now wires the manifest extractor properly, restoring cross-link discovery that had silently dropped to zero + +### Changed + +- **SemanticModel wired as a first-class resolution input (SM-20)** — `call-processor`, `resolution-context`, `type-env`, and `heritage-map` now consult `table.model.*` directly; 37 internal call sites migrated off the SymbolTable wrapper (#885) +- **Per-strategy `ImportSemantics` hooks** — `named` / `wildcard-transitive` / `wildcard-leaf` / `namespace` strategies split into composable hooks, replacing the monolithic conditional (Strategies 1–4 of #886) +- **Class extraction configs moved to `configs/` subdirectory** — per-language class configs now co-locate with the other extractor configs, completing the extractor layer's directory convention (#879) +- **CLI AI-context trimmed** — duplicated CLAUDE.md block removed from the shipped context, reducing token usage in LLM-consuming workflows (#904) +- **LLM context files optimized** — AI-consumed documentation tuned for accuracy and token efficiency (#857) +- **Workflow concurrency standardized** — all CI workflows adopt the consistent concurrency key pattern documented in CONTRIBUTING.md; release-note labeling automated (#837) +- **E2E status-ready timeout raised** — 45s accommodates parallel-worker startup variance on CI (#908) + +### Chore / Dependencies + +- **tree-sitter 0.25 upgrade readiness** — daily Dependabot monitor for the upcoming major-version bump (#847) +- Dependency bumps: `glob` 11.1.0 → 13.0.6 (#867), `commander` 12.1.0 → 14.0.3 (#868), `@huggingface/transformers` (#869), `@modelcontextprotocol/sdk` (#866), `lru-cache` 11.2.7 → 11.3.5 (#870), `mnemonist` 0.39.8 → 0.40.3 (#871), `@ladybugdb/core` (#873) +- gitnexus-web dependency bumps: `mermaid` 11.12.2 → 11.14.0 (#860), `tailwindcss` (#861), `jsdom` 29.0.0 → 29.0.2 (#863), `wait-on` 8.0.5 → 9.0.5 (#859), `@vitest/coverage-v8` (#864) +- GitHub Actions bumps: `actions/checkout` 4.3.1 → 6.0.2 (#842), `actions/upload-artifact` 4.6.2 → 7.0.1 (#838), `actions/setup-node` 4.4.0 → 6.3.0 (#841), `actions/cache` 5.0.4 → 5.0.5 (#840), `actions/github-script` 7.0.1 → 9.0.0 (#850), `dorny/paths-filter` 3.0.2 → 4.0.1 (#839), `amannn/action-semantic-pull-request` 6.1.1 (#853), `release-drafter/release-drafter` 6.0.0 → 7.2.0 (#852), `marocchino/sticky-pull-request-comment` 3.0.4 (#851), `softprops/action-gh-release` 2.5.0 → 3.0.0 (#849) + ## [1.6.1] - 2026-04-13 ### Added diff --git a/gitnexus/package.json b/gitnexus/package.json index b269f7670..d8f2c126a 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -1,6 +1,6 @@ { "name": "gitnexus", - "version": "1.6.1", + "version": "1.6.2", "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.", "author": "Abhigyan Patwari", "license": "PolyForm-Noncommercial-1.0.0", From 131d411ae499ef8bfed14f326123b2881313ecb8 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Sat, 18 Apr 2026 12:52:42 +0100 Subject: [PATCH 18/46] feat(mcp): rank context/impact disambiguation candidates and expose kind/file_path hints (#888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp): rank context/impact disambiguation candidates and expose kind/file_path hints The `context` MCP tool already returned `{ status: 'ambiguous', candidates }` when a name hit multiple symbols, but the candidates were returned in arbitrary DB order and the only hint it accepted was file_path. The `impact` tool was worse: when its name resolver found multiple viable matches it silently picked the first one from a priority UNION, with no signal back to the caller that a different symbol might have been intended. Both failure modes were flagged in issue #470 and reconfirmed in the comments by a second user who described impact as returning "incorrect parsing results and meaningless tool calls" in the multi-match case. Changes: * Add `resolveSymbolCandidates(repo, query, hints)` private helper on LocalBackend. Single place that: - Short-circuits on direct uid (zero-ambiguity) - Runs the same name-or-qualified-id match as before, with LIMIT 20 (was 10) so the ranker has headroom instead of arbitrary truncation - Preserves the #480 Class/Constructor preference -- when the only ambiguity is a Class and its own Constructor, the Class wins silently - Scores each candidate (pure TS, no extra DB round-trip): base 0.50, +0.40 for file_path match, +0.20 for kind match, plus a small kind-priority tiebreaker (Class > Interface > Function > Method > Constructor) when no explicit kind hint is given - Sorts desc by score with stable tiebreakers (shorter filePath, then lex uid) - Promotes to a single confident resolve when the top score is >= 0.95 AND beats the runner-up by >= 0.10 -- lets a strong hint cut through without forcing the caller through a disambiguation round-trip * Rewire `context()` to use the shared helper. Response shape is a strict superset of today's: candidates gain a `score` field, the existing `{ uid, name, kind, filePath, line }` keys are preserved so every downstream consumer (rename, eval-server formatter, etc.) keeps working. New `kind` input hint accepted. * Rewire `impact()` to use the shared helper. Now emits the same `{ status: 'ambiguous', candidates, impactedCount: 0, risk: 'UNKNOWN' }` shape instead of silent first-pick. New inputs accepted: `target_uid`, `file_path`, `kind`. * Update tool schemas in mcp/tools.ts to advertise the new inputs and describe ranked disambiguation. Backward compatibility: The #480 Class/Constructor collapse is preserved and covered by the existing java-class-impact integration test (still green). The ambiguous response shape is a strict superset -- `eval-formatters` unit test that parses the old shape is unchanged and still passes. `impact` going from silent-first-pick to structured ambiguous is a semantic improvement that is the entire point of the issue; callers relying on silent first-pick now get an actionable response. Scope declined for v1: module/community hint -- the issue lists it as one of several hints, but kind + file_path cover the vast majority of disambiguation needs in practice, and a community-label filter requires an extra graph query per candidate. Natural v2 follow-up. Tests: calltool-dispatch.test.ts gains 5 new cases covering file_path boost, kind hint boost, impact ambiguous shape, impact target_uid short-circuit, and score field presence on the existing ambiguous test. Plus the extended assertions on the existing `context tool returns disambiguation for multiple matches`. Verification: npx vitest run test/unit/calltool-dispatch.test.ts -> 64 pass npx vitest run test/integration/java-class-impact.test.ts -> pass npm run test:unit -> 3642 pass (4 pre-existing env failures unchanged: skip-git-cli needs built dist/, git-utils tmpdir on Windows worktree -- same on main) npx tsc --noEmit -> clean Closes #470 * fix(mcp): enrich labels from UNION when labels(n)[0] is empty; address review findings CI on PR #888 caught 13 integration-test failures I did not cover locally: my resolver refactor collected candidates via `labels(n)[0] AS type`, but LadybugDB returns an empty string for that projection on certain node types (most importantly Class). With an empty `type`, impact's downstream `_runImpactBFS` no longer recognised `symType === 'Class' | 'Interface'` and stopped seeding Constructor + File nodes into the frontier, so the "impact(upstream) surfaces the file importer" assertion broke across 11 language fixtures plus 2 OVERRIDES filter tests. The original impact resolver worked around this by running a prioritised UNION across Class/Interface/Function/Method/Constructor and picking the first hit. My refactor dropped that. Fix: keep the simple candidate MATCH but enrich types afterward via a single scoped UNION query, so every candidate carries an accurate label for both scoring and downstream BFS seeding. The UID direct-lookup path is patched the same way. Also addresses the findings from the senior reviewer on PR #888: * MIGRATION.md: document the `impact` behavioural change (silent first- pick → structured `{ status: 'ambiguous', candidates }`) so downstream callers know to branch on `result.status` before reading byDepth/ summary. `context` is unchanged shape-wise (strict superset). * New test: `context tool promotes top candidate via scoring when multiple rows survive DB pre-filter`. The review flagged that the existing file_path test works only because the mock ignores WHERE parameters -- the scored-promotion path (top ≥ 0.95 AND gap > 0.09) wasn't directly exercised. The new test uses two candidates both in App.tsx-containing paths plus a kind hint so promotion is decided by scoring, not DB pre-filtering. Also tightened the comment on the earlier file_path test to describe the mock vs production divergence honestly. * NIT: added a paragraph explaining why `scored.length >= 2` is kept as a defensive guard even though the `normalized.length === 1` early return already covers the single-candidate path. * Integration: two tests in `local-backend-calltool.test.ts` targeted `'authenticate'`, which now correctly resolves as ambiguous (two Method nodes: AuthService.authenticate and BaseService.authenticate). Updated both to pass `file_path: 'src/auth.ts'` so they exercise the new disambiguation API and still assert the METHOD_OVERRIDES filtering they were originally about. Edge case fix in the promotion gap check: IEEE754 makes 0.50 + 0.40 + 0.20 - 0.90 = 0.09999999999999998 instead of exactly 0.10, which would otherwise break the "winner clearly dominates" intent for legitimate 1.00 vs 0.90 cases. Changed `>= 0.10` to `> 0.09`; same user-facing intent, no floating-point sensitivity. Verification (all from gitnexus/): npx vitest run test/integration/class-impact-all-languages.test.ts -> 52 pass (was 11 FAIL on CI before this fix) npx vitest run test/integration/local-backend-calltool.test.ts -> 18 pass (was 2 FAIL on CI before this fix) npx vitest run test/integration/java-class-impact.test.ts -> 10 pass (regression guard for #480 preserved) npx vitest run test/unit/calltool-dispatch.test.ts -> 65 pass (1 new test + 4 from original #470 PR) npm run test:unit -> 3626 pass, 4 pre-existing env failures unchanged npx tsc --noEmit -> clean --- MIGRATION.md | 44 ++ gitnexus/src/mcp/local/local-backend.ts | 512 ++++++++++++------ gitnexus/src/mcp/tools.ts | 23 +- .../local-backend-calltool.test.ts | 11 + gitnexus/test/unit/calltool-dispatch.test.ts | 209 +++++++ 5 files changed, 646 insertions(+), 153 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index ec9fdabc2..88488b0ae 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,49 @@ # Migration Guide +## `impact` tool may now return `{ status: 'ambiguous' }` (PR #888, issue #470) + +Before this change the `impact` MCP tool silently picked the first match +when the `target` name hit multiple symbols (Class → Interface → Function +→ Method → Constructor priority UNION). This often produced analysis for +the wrong symbol with no signal back to the caller. + +After this change, when the resolver finds more than one viable match +and the caller supplied none of `target_uid` / `file_path` / `kind`, +`impact` returns a disambiguation response shaped like: + +```json +{ + "status": "ambiguous", + "message": "Found N symbols matching ''. Use target_uid, file_path, or kind to disambiguate.", + "target": { "name": "" }, + "direction": "upstream", + "impactedCount": 0, + "risk": "UNKNOWN", + "candidates": [ + { "uid": "...", "name": "...", "kind": "Function", "filePath": "...", "line": 42, "score": 0.76 } + ] +} +``` + +### Do I need to migrate? + +**Probably not, but check for assumptions.** Callers that unconditionally +read `result.byDepth` / `result.summary` / `result.affected_processes` +without first checking `result.status` will now see `undefined` in the +ambiguous case. The fix is to branch on `result.status === 'ambiguous'` +first and follow up with `target_uid` (preferred) or `file_path` / `kind`. + +The `context` tool's ambiguous response is a strict superset of the +existing shape — every candidate gains a `score` field, no existing field +has changed. No migration required for `context` callers. + +### What happens on re-index? + +Nothing — this is an MCP-surface change only. The graph schema, indexer, +and stored data are untouched. + +--- + ## OVERRIDES → METHOD_OVERRIDES (PR #642) The `OVERRIDES` relationship type has been renamed to `METHOD_OVERRIDES` for diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index ea0529540..fe63ee1a2 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -1079,9 +1079,296 @@ export class LocalBackend { return result; } + /** + * Patch the `type` field on candidates whose `labels(n)[0]` projection + * came back empty — a known LadybugDB behaviour for several node types. + * + * Uses one scoped UNION query across the five priority labels rather + * than per-candidate round-trips, so cost is a single DB call regardless + * of how many candidates need enrichment. No-op when every candidate + * already has a non-empty type. + * + * Failures are swallowed: label enrichment is an optimisation for + * downstream scoring and #480 Class/Interface BFS seeding; if it fails + * the symbol still resolves, just without the kind-priority bonus. + */ + private async enrichCandidateLabels( + repo: RepoHandle, + candidates: Array<{ id: string; type: string }>, + ): Promise { + const ids = candidates.filter((c) => c.type === '' && c.id).map((c) => c.id); + if (ids.length === 0) return; + try { + const rows = await executeParameterized( + repo.id, + ` + MATCH (n:\`Class\`) WHERE n.id IN $ids RETURN n.id AS id, 'Class' AS label + UNION ALL + MATCH (n:\`Interface\`) WHERE n.id IN $ids RETURN n.id AS id, 'Interface' AS label + UNION ALL + MATCH (n:\`Function\`) WHERE n.id IN $ids RETURN n.id AS id, 'Function' AS label + UNION ALL + MATCH (n:\`Method\`) WHERE n.id IN $ids RETURN n.id AS id, 'Method' AS label + UNION ALL + MATCH (n:\`Constructor\`) WHERE n.id IN $ids RETURN n.id AS id, 'Constructor' AS label + `, + { ids }, + ); + const labelById = new Map(); + for (const r of rows as any[]) { + const id = (r.id ?? r[0]) as string; + const label = (r.label ?? r[1]) as string; + if (id && label && !labelById.has(id)) labelById.set(id, label); + } + for (const c of candidates) { + if (c.type === '' && labelById.has(c.id)) c.type = labelById.get(c.id) as string; + } + } catch { + /* best-effort — downstream resolvers still work without the label */ + } + } + + /** + * Score a symbol candidate for disambiguation ranking. + * + * Deterministic, no DB round-trip: + * - base 0.50 + * - +0.40 when file_path hint matches (substring, case-insensitive) + * - +0.20 when kind hint exactly matches the candidate's kind + * - when no kind hint, a small priority bonus (Class > Interface > + * Function > Method > Constructor) to preserve the intuition that + * class-level names are usually what the user wanted. + * + * Capped at 1.0. Intentionally simple and inspectable — a future v2 can + * plug in BM25/embedding signals here without changing the surrounding + * resolver shape. + */ + private scoreCandidate( + c: { kind: string; filePath: string }, + hints: { file_path?: string; kind?: string }, + ): number { + let s = 0.5; + if (hints.file_path && c.filePath && typeof c.filePath === 'string') { + if (c.filePath.toLowerCase().includes(hints.file_path.toLowerCase())) { + s += 0.4; + } + } + if (hints.kind && c.kind === hints.kind) { + s += 0.2; + } + if (!hints.kind) { + const priority: Record = { + Class: 5, + Interface: 4, + Function: 3, + Method: 2, + Constructor: 1, + }; + s += (priority[c.kind] ?? 0) * 0.02; + } + return Math.min(1.0, s); + } + + /** + * Shared symbol resolver used by `context` and `impact`. + * + * Returns one of: + * - `{ kind: 'ok', symbol, resolvedLabel }` — single confident match + * (either direct UID, only one candidate after filtering, Class/ + * Constructor collapse, or a top-scoring candidate with a clear gap + * to the runner-up). + * - `{ kind: 'ambiguous', candidates }` — multiple viable matches, + * sorted by score desc. Each candidate carries a relevance score. + * - `{ kind: 'not_found' }` — no matches at all. + * + * Preserves the #480 Class/Constructor preference: when the only + * ambiguity is between a Class and its own Constructor (same name, + * same filePath), the Class wins silently. + */ + private async resolveSymbolCandidates( + repo: RepoHandle, + query: { uid?: string; name?: string; include_content?: boolean }, + hints: { file_path?: string; kind?: string }, + ): Promise< + | { + kind: 'ok'; + symbol: { + id: string; + name: string; + type: string; + filePath: string; + startLine: number; + endLine: number; + content?: string; + }; + resolvedLabel: string; + } + | { + kind: 'ambiguous'; + candidates: Array<{ + id: string; + name: string; + type: string; + filePath: string; + startLine: number; + endLine: number; + score: number; + }>; + } + | { kind: 'not_found' } + > { + const { uid, name, include_content } = query; + const selectClause = `n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}`; + + // Direct UID — zero-ambiguity path. + if (uid) { + const rows = await executeParameterized( + repo.id, + `MATCH (n {id: $uid}) RETURN ${selectClause} LIMIT 1`, + { uid }, + ); + if (rows.length === 0) return { kind: 'not_found' }; + const r = rows[0] as any; + const symbol = { + id: (r.id ?? r[0]) as string, + name: (r.name ?? r[1]) as string, + type: (r.type ?? r[2] ?? '') as string, + filePath: (r.filePath ?? r[3]) as string, + startLine: (r.startLine ?? r[4]) as number, + endLine: (r.endLine ?? r[5]) as number, + ...(include_content ? { content: (r.content ?? r[6]) as string | undefined } : {}), + }; + // Same LadybugDB label-enrichment as the name-based path: a UID + // pointing at a Class must still surface `type: 'Class'` so impact's + // Class/Interface BFS seed fires. No-op when type is already set. + await this.enrichCandidateLabels(repo, [symbol]); + return { kind: 'ok', symbol, resolvedLabel: symbol.type }; + } + + if (!name) return { kind: 'not_found' }; + + const isQualified = name.includes('/') || name.includes(':'); + let whereClause: string; + const queryParams: Record = { symName: name }; + if (hints.file_path) { + whereClause = `WHERE n.name = $symName AND n.filePath CONTAINS $filePath`; + queryParams.filePath = hints.file_path; + } else if (isQualified) { + whereClause = `WHERE n.id = $symName OR n.name = $symName`; + } else { + whereClause = `WHERE n.name = $symName`; + } + + // LIMIT 20 (was 10) — scoring is the point now, so give the ranker + // headroom instead of arbitrary truncation. + const rows = await executeParameterized( + repo.id, + `MATCH (n) ${whereClause} RETURN ${selectClause} LIMIT 20`, + queryParams, + ); + + if (rows.length === 0) return { kind: 'not_found' }; + + // Normalise row shape across object / tuple returns from LadybugDB. + const normalized = rows.map((r: any) => ({ + id: (r.id ?? r[0]) as string, + name: (r.name ?? r[1]) as string, + type: (r.type ?? r[2] ?? '') as string, + filePath: (r.filePath ?? r[3]) as string, + startLine: (r.startLine ?? r[4]) as number, + endLine: (r.endLine ?? r[5]) as number, + ...(include_content ? { content: (r.content ?? r[6]) as string | undefined } : {}), + })); + + // Enrich labels for any candidates where `labels(n)[0]` came back empty. + // LadybugDB returns an empty string for that projection on certain node + // types (notably Class), which left downstream consumers (impact's + // Class/Interface BFS seed, the kind-priority scoring bonus) unable to + // distinguish a Class target from "unknown kind". One scoped UNION + // across the five priority labels patches the type in-place without + // per-candidate round-trips. + await this.enrichCandidateLabels(repo, normalized); + + // Preserve #480 Class/Constructor collapse: if we have exactly one + // Class (or Interface) candidate and one Constructor sharing name + + // filePath, fold into the Class. This used to require a follow-up + // label query because LadybugDB sometimes returns an empty labels()[0] + // for Class nodes — enrichment above handles the empty-type case, but + // the `type === 'Constructor'` gate still correctly triggers when a + // Class and its Constructor share the name. + if (!hints.kind && normalized.length > 1) { + const ambiguousType = normalized.some((s) => s.type === '' || s.type === 'Constructor'); + if (ambiguousType) { + const candidateIds = normalized.map((s) => s.id).filter(Boolean); + for (const label of ['Class', 'Interface']) { + const labelRows = await executeParameterized( + repo.id, + `MATCH (n:\`${label}\`) WHERE n.id IN $candidateIds RETURN n.id AS id LIMIT 1`, + { candidateIds }, + ).catch(() => []); + if (labelRows.length > 0) { + const preferredId = (labelRows[0] as any).id ?? (labelRows[0] as any)[0]; + const preferred = normalized.find((s) => s.id === preferredId); + if (preferred) { + return { + kind: 'ok', + symbol: preferred, + resolvedLabel: label, + }; + } + } + } + } + } + + if (normalized.length === 1) { + return { + kind: 'ok', + symbol: normalized[0], + resolvedLabel: '', + }; + } + + // Score, sort desc, stable tiebreak on shorter filePath then lex uid. + const scored = normalized.map((s) => ({ + ...s, + score: this.scoreCandidate({ kind: s.type, filePath: s.filePath || '' }, hints), + })); + scored.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + const fpA = (a.filePath || '').length; + const fpB = (b.filePath || '').length; + if (fpA !== fpB) return fpA - fpB; + return String(a.id).localeCompare(String(b.id)); + }); + + // Confident single-result: top score ≥ 0.95 AND beats runner-up by a + // clear margin. This lets a very strong file_path/kind hint resolve + // cleanly instead of forcing the caller through a disambiguation + // round-trip. + // + // The gap threshold uses `> 0.09` rather than `>= 0.10` on purpose: + // IEEE754 addition of the scoring terms (0.50 + 0.40 + 0.20 - 0.90 + // yields 0.09999999999999998, not exactly 0.10) would otherwise break + // the comparison for legitimate "top is 1.00, runner is 0.90" cases. + // The intent is a clearly-dominant winner; 0.09 is a large enough + // margin to mean that unambiguously. + // + // The `scored.length >= 2` guard is defensive. The `normalized.length === 1` + // early return above already handles the single-candidate path, so in + // practice `scored` always has at least two elements by the time we get + // here — keeping the guard means changes to the upstream early-return + // logic cannot accidentally index out of bounds at `scored[1]`. + if (scored.length >= 2 && scored[0].score >= 0.95 && scored[0].score - scored[1].score > 0.09) { + return { kind: 'ok', symbol: scored[0], resolvedLabel: scored[0].type }; + } + + return { kind: 'ambiguous', candidates: scored }; + } + /** * Context tool — 360-degree symbol view with categorized refs. - * Disambiguation when multiple symbols share a name. + * Disambiguation (ranked) when multiple symbols share a name. * UID-based direct lookup. No cluster in output. */ private async context( @@ -1090,124 +1377,47 @@ export class LocalBackend { name?: string; uid?: string; file_path?: string; + kind?: string; include_content?: boolean; }, ): Promise { await this.ensureInitialized(repo.id); - const { name, uid, file_path, include_content } = params; + const { name, uid, file_path, kind, include_content } = params; if (!name && !uid) { return { error: 'Either "name" or "uid" parameter is required.' }; } - // Step 1: Find the symbol - let symbols: any[]; + const outcome = await this.resolveSymbolCandidates( + repo, + { uid, name, include_content }, + { file_path, kind }, + ); - if (uid) { - symbols = await executeParameterized( - repo.id, - ` - MATCH (n {id: $uid}) - RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''} - LIMIT 1 - `, - { uid }, - ); - } else { - const isQualified = name!.includes('/') || name!.includes(':'); - - let whereClause: string; - let queryParams: Record; - if (file_path) { - whereClause = `WHERE n.name = $symName AND n.filePath CONTAINS $filePath`; - queryParams = { symName: name!, filePath: file_path }; - } else if (isQualified) { - whereClause = `WHERE n.id = $symName OR n.name = $symName`; - queryParams = { symName: name! }; - } else { - whereClause = `WHERE n.name = $symName`; - queryParams = { symName: name! }; - } - - symbols = await executeParameterized( - repo.id, - ` - MATCH (n) ${whereClause} - RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''} - LIMIT 10 - `, - queryParams, - ); - } - - if (symbols.length === 0) { + if (outcome.kind === 'not_found') { return { error: `Symbol '${name || uid}' not found` }; } - // Step 2: Disambiguation - // When multiple nodes share the same name (e.g. a Java Class and its - // Constructor both named 'SessionTracker'), prefer the Class node so - // context() returns the semantically meaningful result rather than - // triggering ambiguous disambiguation (#480). - // labels(n)[0] returns empty string in LadybugDB, so we resolve the - // preferred node by re-querying with explicit label filters, scoped to - // the candidate IDs already in symbols. - // - // Guard: only attempt Class-preference when at least one candidate has an - // empty/unknown type (LadybugDB limitation) or is a Constructor — meaning - // the ambiguity may be a Class/Constructor name collision rather than two - // genuinely distinct symbols (e.g. two Functions in different files). - // - // resolvedLabel is set here and threaded to Step 3 to avoid a redundant - // classCheck round-trip later. - let resolvedLabel = ''; - if (symbols.length > 1 && !uid) { - const hasAmbiguousType = symbols.some((s: any) => { - const t = s.type || s[2] || ''; - return t === '' || t === 'Constructor'; - }); - if (hasAmbiguousType) { - const candidateIds = symbols.map((s: any) => s.id || s[0]).filter(Boolean); - const PREFER_LABELS = ['Class', 'Interface']; - let preferred: any = null; - for (const label of PREFER_LABELS) { - const match = await executeParameterized( - repo.id, - ` - MATCH (n:\`${label}\`) WHERE n.id IN $candidateIds RETURN n.id AS id LIMIT 1 - `, - { candidateIds }, - ).catch(() => []); - if (match.length > 0) { - preferred = symbols.find((s: any) => (s.id || s[0]) === (match[0].id || match[0][0])); - if (preferred) { - resolvedLabel = label; - break; - } - } - } - if (preferred) symbols = [preferred]; - } - } - - if (symbols.length > 1 && !uid) { + if (outcome.kind === 'ambiguous') { return { status: 'ambiguous', - message: `Found ${symbols.length} symbols matching '${name}'. Use uid or file_path to disambiguate.`, - candidates: symbols.map((s: any) => ({ - uid: s.id || s[0], - name: s.name || s[1], - kind: s.type || s[2], - filePath: s.filePath || s[3], - line: s.startLine || s[4], + message: `Found ${outcome.candidates.length} symbols matching '${name}'. Use uid, file_path, or kind to disambiguate.`, + candidates: outcome.candidates.map((c) => ({ + uid: c.id, + name: c.name, + kind: c.type, + filePath: c.filePath, + line: c.startLine, + score: Number(c.score.toFixed(2)), })), }; } // Step 3: Build full context - const sym = symbols[0]; - const symId = sym.id || sym[0]; + const sym = outcome.symbol; + const resolvedLabel = outcome.resolvedLabel; + const symId = sym.id; // Categorized incoming refs const incomingRows = await executeParameterized( @@ -1901,6 +2111,9 @@ export class LocalBackend { repo: RepoHandle, params: { target: string; + target_uid?: string; + file_path?: string; + kind?: string; direction: 'upstream' | 'downstream'; maxDepth?: number; relationTypes?: string[]; @@ -1927,6 +2140,9 @@ export class LocalBackend { repo: RepoHandle, params: { target: string; + target_uid?: string; + file_path?: string; + kind?: string; direction: 'upstream' | 'downstream'; maxDepth?: number; relationTypes?: string[]; @@ -1969,65 +2185,57 @@ export class LocalBackend { const includeTests = params.includeTests ?? false; const minConfidence = params.minConfidence ?? 0; - // Resolve target by name, preferring Class/Interface over Constructor - // (fix #480: Java class and constructor share the same name). - // labels(n)[0] returns empty string in LadybugDB, so we use explicit - // label-typed sub-queries in a single UNION ordered by priority to avoid - // up to 6 serial round-trips for non-Class targets. - let sym: any = null; - let symType = ''; + // Resolve target via the shared symbol resolver. When the caller passes + // target_uid we skip the name lookup entirely (zero-ambiguity). Otherwise + // we rank candidates (#470) and either proceed with a confident single + // match, or return a structured ambiguous response instead of silently + // picking the wrong symbol. + // + // The resolver preserves the #480 Class/Constructor preference heuristic: + // when a Class and its Constructor share name + filePath, the Class is + // selected silently. + const outcome = await this.resolveSymbolCandidates( + repo, + { uid: params.target_uid, name: target }, + { file_path: params.file_path, kind: params.kind }, + ); - try { - const rows = await executeParameterized( - repo.id, - ` - MATCH (n:\`Class\`) WHERE n.name = $targetName - RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 0 AS priority LIMIT 1 - UNION ALL - MATCH (n:\`Interface\`) WHERE n.name = $targetName - RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 1 AS priority LIMIT 1 - UNION ALL - MATCH (n:\`Function\`) WHERE n.name = $targetName - RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 2 AS priority LIMIT 1 - UNION ALL - MATCH (n:\`Method\`) WHERE n.name = $targetName - RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 3 AS priority LIMIT 1 - UNION ALL - MATCH (n:\`Constructor\`) WHERE n.name = $targetName - RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 4 AS priority LIMIT 1 - `, - { targetName: target }, - ).catch(() => []); - - if (rows.length > 0) { - // Pick the row with the lowest priority value (Class wins over Constructor) - const best = rows.reduce((a: any, b: any) => - (a.priority ?? a[3] ?? 99) <= (b.priority ?? b[3] ?? 99) ? a : b, - ); - sym = best; - const priorityToLabel = ['Class', 'Interface', 'Function', 'Method', 'Constructor']; - symType = priorityToLabel[best.priority ?? best[3]] ?? ''; - } - } catch { - /* fall through to unlabeled match */ + if (outcome.kind === 'not_found') { + const missing = params.target_uid ?? target; + return { + error: `Target '${missing}' not found`, + target: { name: target }, + direction, + impactedCount: 0, + risk: 'UNKNOWN', + }; } - // Fall back to unlabeled match for any other node type - if (!sym) { - const rows = await executeParameterized( - repo.id, - ` - MATCH (n) - WHERE n.name = $targetName - RETURN n.id AS id, n.name AS name, n.filePath AS filePath - LIMIT 1 - `, - { targetName: target }, - ); - if (rows.length > 0) sym = rows[0]; + if (outcome.kind === 'ambiguous') { + return { + status: 'ambiguous', + message: `Found ${outcome.candidates.length} symbols matching '${target}'. Use target_uid, file_path, or kind to disambiguate.`, + target: { name: target }, + direction, + impactedCount: 0, + risk: 'UNKNOWN', + candidates: outcome.candidates.map((c) => ({ + uid: c.id, + name: c.name, + kind: c.type, + filePath: c.filePath, + line: c.startLine, + score: Number(c.score.toFixed(2)), + })), + }; } - if (!sym) return { error: `Target '${target}' not found` }; + const sym = { + id: outcome.symbol.id, + name: outcome.symbol.name, + filePath: outcome.symbol.filePath, + }; + const symType = outcome.resolvedLabel || outcome.symbol.type || ''; return this._runImpactBFS(repo, sym, symType, direction, { maxDepth, diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 2c884d10b..7beb2e970 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -154,7 +154,7 @@ Shows categorized incoming/outgoing references (calls, imports, extends, impleme WHEN TO USE: After query() to understand a specific symbol in depth. When you need to know all callers, callees, and what execution flows a symbol participates in. AFTER THIS: Use impact() if planning changes, or READ gitnexus://repo/{name}/process/{processName} for full execution trace. -Handles disambiguation: if multiple symbols share the same name, returns candidates for you to pick from. Use uid param for zero-ambiguity lookup from prior results. +Handles disambiguation: if multiple symbols share the same name, returns ranked candidates (each with a relevance score) for you to pick from. Use uid for zero-ambiguity lookup, or narrow the search with file_path and/or kind hints. NOTE: ACCESSES edges (field read/write tracking) are included in context results with reason 'read' or 'write'. CALLS edges resolve through field access chains and method-call chains (e.g., user.address.getCity().save() produces CALLS edges at each step).`, inputSchema: { @@ -166,6 +166,11 @@ NOTE: ACCESSES edges (field read/write tracking) are included in context results description: 'Direct symbol UID from prior tool results (zero-ambiguity lookup)', }, file_path: { type: 'string', description: 'File path to disambiguate common names' }, + kind: { + type: 'string', + description: + "Kind filter to disambiguate common names (e.g. 'Function', 'Class', 'Method', 'Interface', 'Constructor')", + }, include_content: { type: 'boolean', description: 'Include full symbol source code (default: false)', @@ -265,16 +270,32 @@ Depth groups: TIP: Default traversal uses CALLS/IMPORTS/EXTENDS/IMPLEMENTS. For class members, include HAS_METHOD and HAS_PROPERTY in relationTypes. For field access analysis, include ACCESSES in relationTypes. +Handles disambiguation: when multiple symbols share the target name, returns ranked candidates (each with a relevance score) instead of silently picking one. Use target_uid for zero-ambiguity lookup, or narrow with file_path and/or kind hints. + EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, METHOD_OVERRIDES, METHOD_IMPLEMENTS, ACCESSES Confidence: 1.0 = certain, <0.8 = fuzzy match`, inputSchema: { type: 'object', properties: { target: { type: 'string', description: 'Name of function, class, or file to analyze' }, + target_uid: { + type: 'string', + description: + 'Direct symbol UID from prior tool results (zero-ambiguity lookup, skips target resolution)', + }, direction: { type: 'string', description: 'upstream (what depends on this) or downstream (what this depends on)', }, + file_path: { + type: 'string', + description: 'File path hint to disambiguate common names', + }, + kind: { + type: 'string', + description: + "Kind filter to disambiguate common names (e.g. 'Function', 'Class', 'Method', 'Interface', 'Constructor')", + }, maxDepth: { type: 'number', description: 'Max relationship depth (default: 3)', diff --git a/gitnexus/test/integration/local-backend-calltool.test.ts b/gitnexus/test/integration/local-backend-calltool.test.ts index 55e4110f3..52f9b2487 100644 --- a/gitnexus/test/integration/local-backend-calltool.test.ts +++ b/gitnexus/test/integration/local-backend-calltool.test.ts @@ -141,12 +141,20 @@ withTestLbugDB( }); it('filters by OVERRIDES only', async () => { + // The seed has two Method nodes named 'authenticate' (AuthService's + // override and BaseService's base). Per #470, `impact` now returns + // a ranked-ambiguous response when the target name hits multiple + // symbols, so we must disambiguate with file_path to get the + // AuthService override (the one with the outgoing METHOD_OVERRIDES + // edge we want to follow downstream). const result = await backend.callTool('impact', { target: 'authenticate', + file_path: 'src/auth.ts', direction: 'downstream', relationTypes: ['METHOD_OVERRIDES'], }); expect(result).not.toHaveProperty('error'); + expect(result.status).not.toBe('ambiguous'); // AuthService.authenticate overrides BaseService.authenticate expect(result.impactedCount).toBeGreaterThanOrEqual(1); const d1 = result.byDepth[1] || result.byDepth['1'] || []; @@ -158,12 +166,15 @@ withTestLbugDB( // Pass the LEGACY alias 'OVERRIDES' — impactByUid should flatMap-expand // it to ['OVERRIDES', 'METHOD_OVERRIDES'] so the METHOD_OVERRIDES edge // between BaseService.authenticate and AuthService.authenticate is found. + // file_path hint disambiguates the two 'authenticate' methods per #470. const result = await backend.callTool('impact', { target: 'authenticate', + file_path: 'src/auth.ts', direction: 'downstream', relationTypes: ['OVERRIDES'], }); expect(result).not.toHaveProperty('error'); + expect(result.status).not.toBe('ambiguous'); expect(result.impactedCount).toBeGreaterThanOrEqual(1); const d1 = result.byDepth[1] || result.byDepth['1'] || []; const names = d1.map((d: any) => d.name); diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index ebccb42a2..9a90b7030 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -248,6 +248,215 @@ describe('LocalBackend.callTool', () => { const result = await backend.callTool('context', { name: 'main' }); expect(result.status).toBe('ambiguous'); expect(result.candidates).toHaveLength(2); + + // #470: every candidate carries a relevance score in [0, 1] and the list + // is sorted descending by score (with deterministic tiebreakers). + for (const c of result.candidates) { + expect(typeof c.score).toBe('number'); + expect(c.score).toBeGreaterThanOrEqual(0); + expect(c.score).toBeLessThanOrEqual(1); + } + expect(result.candidates[0].score).toBeGreaterThanOrEqual(result.candidates[1].score); + }); + + it('context tool ranks file_path match higher than non-match (#470)', async () => { + (executeParameterized as any).mockResolvedValue([ + { + id: 'func:handleConnect:1', + name: 'handleConnect', + type: 'Function', + filePath: 'src/lib/socket.ts', + startLine: 10, + endLine: 20, + }, + { + id: 'func:handleConnect:2', + name: 'handleConnect', + type: 'Function', + filePath: 'src/App.tsx', + startLine: 42, + endLine: 60, + }, + ]); + const result = await backend.callTool('context', { + name: 'handleConnect', + file_path: 'App.tsx', + }); + // In production, `WHERE n.filePath CONTAINS $filePath` would pre-filter + // at the DB layer and only `src/App.tsx` would come back — resolving + // via the single-candidate early return rather than via scoring. The + // `executeParameterized` mock here returns both rows regardless of the + // WHERE clause parameters, so this asserts that the resolver ends up + // picking the App.tsx candidate in either case (via mock-relaxed DB + // pre-filter or via scoring promotion). The dedicated scoring-promotion + // path is covered by the next `it()` block below. + expect(result.status).toBe('found'); + expect(result.symbol.filePath).toBe('src/App.tsx'); + }); + + it('context tool promotes top candidate via scoring when multiple rows survive DB pre-filter (#470)', async () => { + // This test explicitly exercises the scored-promotion path (#470 + // review): both candidates satisfy the file_path hint (so DB + // pre-filter would return both in production), and promotion is + // determined purely by the combined file_path + kind score. + (executeParameterized as any).mockResolvedValue([ + { + id: 'fn:App:1', + name: 'render', + type: 'Function', + filePath: 'src/components/App.tsx', + startLine: 10, + endLine: 20, + }, + { + id: 'method:App:1', + name: 'render', + type: 'Method', + filePath: 'src/pages/App.tsx', + startLine: 5, + endLine: 15, + }, + ]); + const result = await backend.callTool('context', { + name: 'render', + file_path: 'App.tsx', + kind: 'Function', + }); + // Expected scoring: + // Function candidate: 0.50 base + 0.40 file_path + 0.20 kind = 1.10 → cap 1.00 + // Method candidate: 0.50 base + 0.40 file_path + 0.00 kind = 0.90 + // Top score ≥ 0.95 and beats runner-up by 0.10 → confident promotion + // to `{ status: 'found' }` with the Function. + expect(result.status).toBe('found'); + expect(result.symbol.filePath).toBe('src/components/App.tsx'); + expect(result.symbol.kind).toBe('Function'); + }); + + it('context tool returns ranked candidates when file_path only partially narrows (#470)', async () => { + (executeParameterized as any).mockResolvedValue([ + { + id: 'func:foo:1', + name: 'foo', + type: 'Function', + filePath: 'src/a.ts', + startLine: 1, + endLine: 5, + }, + { + id: 'func:foo:2', + name: 'foo', + type: 'Function', + filePath: 'src/b.ts', + startLine: 1, + endLine: 5, + }, + ]); + // No hints → both candidates score 0.56 (0.50 base + 0.06 Function + // priority). Tied scores fall back to deterministic tiebreakers. + const result = await backend.callTool('context', { name: 'foo' }); + expect(result.status).toBe('ambiguous'); + expect(result.candidates).toHaveLength(2); + expect(result.candidates[0].score).toBeCloseTo(0.56, 2); + expect(result.candidates[1].score).toBeCloseTo(0.56, 2); + }); + + it('context tool boosts the candidate whose kind matches the hint (#470)', async () => { + (executeParameterized as any).mockResolvedValue([ + { + id: 'method:save:1', + name: 'save', + type: 'Method', + filePath: 'src/service.ts', + startLine: 10, + endLine: 20, + }, + { + id: 'func:save:1', + name: 'save', + type: 'Function', + filePath: 'src/util.ts', + startLine: 5, + endLine: 15, + }, + ]); + const result = await backend.callTool('context', { name: 'save', kind: 'Function' }); + // When kind hint is given, kind-priority bonus is suppressed and +0.20 + // kind-match bonus applies instead. Function becomes the top candidate. + expect(result.status).toBe('ambiguous'); + expect(result.candidates[0].kind).toBe('Function'); + expect(result.candidates[0].score).toBeGreaterThan(result.candidates[1].score); + }); + + it('impact tool returns ambiguous shape with ranked candidates when target has multiple matches (#470)', async () => { + // resolveSymbolCandidates issues a single name query; mock it to return + // two Function rows in different files with no hints. + (executeParameterized as any).mockResolvedValue([ + { + id: 'func:login:1', + name: 'login', + type: 'Function', + filePath: 'src/auth.ts', + startLine: 5, + endLine: 15, + }, + { + id: 'func:login:2', + name: 'login', + type: 'Function', + filePath: 'src/admin/login.ts', + startLine: 8, + endLine: 20, + }, + ]); + + const result = await backend.callTool('impact', { target: 'login', direction: 'upstream' }); + + expect(result.status).toBe('ambiguous'); + expect(result.candidates).toHaveLength(2); + expect(result.impactedCount).toBe(0); + expect(result.risk).toBe('UNKNOWN'); + expect(result.target.name).toBe('login'); + for (const c of result.candidates) { + expect(typeof c.score).toBe('number'); + expect(c.uid).toBeDefined(); + expect(c.kind).toBe('Function'); + } + }); + + it('impact tool resolves via target_uid without running the name-based resolver (#470)', async () => { + // UID path: exactly one executeParameterized call for the lookup, then + // the BFS issues executeQuery calls (which we mock empty). Crucially, + // no `WHERE n.name =` query fires. + (executeParameterized as any).mockResolvedValue([ + { + id: 'uid:1234', + name: 'pickedByUid', + type: 'Function', + filePath: 'src/pick.ts', + startLine: 1, + endLine: 10, + }, + ]); + (executeQuery as any).mockResolvedValue([]); + + const result = await backend.callTool('impact', { + target: 'ignoredName', + target_uid: 'uid:1234', + direction: 'upstream', + }); + + // No ambiguous shape and no name-lookup error — the uid short-circuit won. + expect(result.status).not.toBe('ambiguous'); + expect(result.target).toBeDefined(); + + // All executeParameterized calls this test dispatched must have been + // uid-keyed, never name-keyed. That proves the name resolver was skipped. + const calls = (executeParameterized as any).mock.calls as Array< + [string, string, Record] + >; + for (const [, cypher] of calls) { + expect(cypher).not.toMatch(/WHERE n\.name = \$symName/); + } }); it('dispatches impact tool', async () => { From d9da7d6692f868639be1e4f5686be8ddca180fae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 12:54:59 +0100 Subject: [PATCH 19/46] fix(test): isolate cli-e2e from shared mini-repo fixture (#954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deterministic fix for the Windows-flaky pipeline-graph-golden test. Root cause cli-e2e.test.ts wrote into the SHARED fixture directory (test/fixtures/mini-repo/) — git init, analyze run that creates AGENTS.md, CLAUDE.md, .claude/, .gitnexus/. When pipeline-graph-golden ran in parallel, its `cpSync` of the source directory could capture the mid-flight pollution before cli-e2e's afterAll cleanup fired. macOS/Ubuntu won the race often enough that the flake presented as Windows-only. Fix cli-e2e now copies mini-repo into a fresh `mkdtemp`'d parent whose basename is `mini-repo` (preserving `--repo mini-repo` CLI lookup by basename), runs git-init there, and rm's the whole tmpdir in afterAll. The shared fixture source is never touched. Fallout from the cwd change: bare `--import tsx` specifiers (2 spawnSync + 1 spawn) can't resolve `tsx` from an os.tmpdir cwd where there is no node_modules. Switched them to the already-existing `tsxImportUrl` (absolute file:// URL to the tsx loader), matching the `runCliOutsideProject` pattern that was already set up for this exact case. Updated the "MINI_REPO is inside the project tree" comment in the `status on non-indexed repo` test — MINI_REPO is now in os.tmpdir, so the rationale for using a separate throwaway tmp git repo is different (but still valid: previous tests in the suite create MINI_REPO/.gitnexus, which findRepo() would pick up). Also updated pipeline-graph-golden's comment explaining WHY it copies to tmp — it's now defense-in-depth rather than a necessity, so a future test that adds files to the source can't silently regress the golden. Verification - 5x consecutive `cli-e2e + pipeline-graph-golden` runs: 20/20 pass (deterministic) - 3x full suite including pipeline.test: 27/27 pass - test/fixtures/mini-repo/ post-run contents: only `src/` — zero pollution from any test - macOS/Ubuntu behavior unchanged (they were passing; tmpdir isolation is purely additive) --- gitnexus/test/integration/cli-e2e.test.ts | 80 +++++++++++-------- .../integration/pipeline-graph-golden.test.ts | 9 ++- 2 files changed, 54 insertions(+), 35 deletions(-) diff --git a/gitnexus/test/integration/cli-e2e.test.ts b/gitnexus/test/integration/cli-e2e.test.ts index 7ee6f7a4e..abdd436a4 100644 --- a/gitnexus/test/integration/cli-e2e.test.ts +++ b/gitnexus/test/integration/cli-e2e.test.ts @@ -20,7 +20,22 @@ import { createRequire } from 'module'; const testDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(testDir, '../..'); const cliEntry = path.join(repoRoot, 'src/cli/index.ts'); -const MINI_REPO = path.resolve(testDir, '..', 'fixtures', 'mini-repo'); +const FIXTURE_SRC = path.resolve(testDir, '..', 'fixtures', 'mini-repo'); + +// `MINI_REPO` is a *per-run temp copy* of the fixture, not the shared +// source. Writing into the shared source races with other suites that +// ingest it read-only (pipeline-graph-golden, pipeline.test) — those +// suites copy the source to their own tmp dir but the copy happens at +// `beforeAll`, so if this suite's analyze has already created AGENTS.md +// / CLAUDE.md / .claude/ in the source when the other suite's cpSync +// runs, the pollution is captured before the isolation kicks in. +// +// The deterministic fix: this suite never touches the shared source. +// `beforeAll` copies the fixture to a fresh mkdtemp'd directory whose +// basename is `mini-repo` (so `--repo mini-repo` lookup by basename +// still works), `afterAll` rms the parent tmpdir. +let MINI_REPO: string; +let tmpParent: string; // Absolute file:// URL to tsx loader — needed when spawning CLI with cwd // outside the project tree (bare 'tsx' specifier won't resolve there). @@ -31,40 +46,39 @@ const tsxPkgDir = path.dirname(_require.resolve('tsx/package.json')); const tsxImportUrl = pathToFileURL(path.join(tsxPkgDir, 'dist', 'loader.mjs')).href; beforeAll(() => { + // Copy the fixture into an isolated tmpdir named `mini-repo` so that the + // `--repo mini-repo` CLI arg (which matches by basename) still works. + tmpParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cli-e2e-')); + MINI_REPO = path.join(tmpParent, 'mini-repo'); + fs.cpSync(FIXTURE_SRC, MINI_REPO, { recursive: true }); + // Initialize mini-repo as a git repo so the CLI analyze command // can run the full pipeline (it requires a .git directory). - const gitDir = path.join(MINI_REPO, '.git'); - if (!fs.existsSync(gitDir)) { - spawnSync('git', ['init'], { cwd: MINI_REPO, stdio: 'pipe' }); - spawnSync('git', ['add', '-A'], { cwd: MINI_REPO, stdio: 'pipe' }); - spawnSync('git', ['commit', '-m', 'initial commit'], { - cwd: MINI_REPO, - stdio: 'pipe', - env: { - ...process.env, - GIT_AUTHOR_NAME: 'test', - GIT_AUTHOR_EMAIL: 'test@test', - GIT_COMMITTER_NAME: 'test', - GIT_COMMITTER_EMAIL: 'test@test', - }, - }); - } + spawnSync('git', ['init'], { cwd: MINI_REPO, stdio: 'pipe' }); + spawnSync('git', ['add', '-A'], { cwd: MINI_REPO, stdio: 'pipe' }); + spawnSync('git', ['commit', '-m', 'initial commit'], { + cwd: MINI_REPO, + stdio: 'pipe', + env: { + ...process.env, + GIT_AUTHOR_NAME: 'test', + GIT_AUTHOR_EMAIL: 'test@test', + GIT_COMMITTER_NAME: 'test', + GIT_COMMITTER_EMAIL: 'test@test', + }, + }); }); afterAll(() => { - // Clean up all files/dirs created by analyze (git init, .gitnexus output, - // AI context files, skill files, .gitignore) so parallel tests like - // pipeline-graph-golden see a pristine fixture. - for (const entry of ['.git', '.gitnexus', '.claude', 'AGENTS.md', 'CLAUDE.md', '.gitignore']) { - const fullPath = path.join(MINI_REPO, entry); - if (fs.existsSync(fullPath)) { - fs.rmSync(fullPath, { recursive: true, force: true }); - } + // Entire tmp copy goes away — no selective cleanup needed. The shared + // `test/fixtures/mini-repo/` source was never touched. + if (tmpParent) { + fs.rmSync(tmpParent, { recursive: true, force: true }); } }); function runCli(command: string, cwd: string, timeoutMs = 15000) { - return spawnSync(process.execPath, ['--import', 'tsx', cliEntry, command], { + return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, command], { cwd, encoding: 'utf8', timeout: timeoutMs, @@ -84,7 +98,7 @@ function runCli(command: string, cwd: string, timeoutMs = 15000) { * can pass flags (e.g. --help) or omit a command entirely. */ function runCliRaw(extraArgs: string[], cwd: string, timeoutMs = 15000) { - return spawnSync(process.execPath, ['--import', 'tsx', cliEntry, ...extraArgs], { + return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, ...extraArgs], { cwd, encoding: 'utf8', timeout: timeoutMs, @@ -190,9 +204,11 @@ describe('CLI end-to-end', () => { } it('status on non-indexed repo reports not indexed', () => { - // MINI_REPO is inside the project tree so findRepo() walks up and - // finds the parent project's .gitnexus. Use an isolated temp git - // repo to guarantee no .gitnexus exists anywhere in the path. + // Even though MINI_REPO is now in an isolated tmpdir, previous tests + // in this suite may have created MINI_REPO/.gitnexus via analyze, + // and findRepo() walks up so any `.gitnexus` along the path still + // counts. This test needs a GUARANTEED pristine repo to assert the + // "not indexed" output, so it mints its own throwaway tmp git repo. const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-noindex-')); try { spawnSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' }); @@ -410,7 +426,7 @@ describe('CLI end-to-end', () => { process.execPath, [ '--import', - 'tsx', + tsxImportUrl, cliEntry, 'cypher', 'MATCH (n) RETURN n LIMIT 500', @@ -469,7 +485,7 @@ describe('CLI end-to-end', () => { return new Promise((resolve, reject) => { const child = spawn( process.execPath, - ['--import', 'tsx', cliEntry, 'eval-server', '--port', '0', '--idle-timeout', '3'], + ['--import', tsxImportUrl, cliEntry, 'eval-server', '--port', '0', '--idle-timeout', '3'], { cwd: MINI_REPO, stdio: ['ignore', 'pipe', 'pipe'], diff --git a/gitnexus/test/integration/pipeline-graph-golden.test.ts b/gitnexus/test/integration/pipeline-graph-golden.test.ts index 1a8e9d238..abd6377e3 100644 --- a/gitnexus/test/integration/pipeline-graph-golden.test.ts +++ b/gitnexus/test/integration/pipeline-graph-golden.test.ts @@ -132,9 +132,12 @@ describe('pipeline graph golden', () => { let tmpDir: string; beforeAll(async () => { - // Copy the fixture to a temp directory so parallel tests (cli-e2e) - // that create AGENTS.md / CLAUDE.md / .claude/ in the shared fixture - // don't pollute the golden snapshot. + // Copy the fixture to a temp directory as defense-in-depth against + // parallel tests writing into the shared fixture source. cli-e2e + // was the historical offender — it now copies to its own tmpdir + // (see test/integration/cli-e2e.test.ts `beforeAll`) — but this + // cpSync stays as a belt-and-suspenders guarantee that any future + // test adding files to the source won't pollute the golden snapshot. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-golden-')); fs.cpSync(FIXTURE_SRC, tmpDir, { recursive: true }); From afc0a8b6c51e06ef81f7b35686c223984cf849b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 12:55:09 +0100 Subject: [PATCH 20/46] feat(shared): add scope-resolution types + constants (#910, RFC #909 Ring 1) (#949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the authoritative data model and constants for the pure scope-based resolution RFC (#909) as Ring 1, part 1. No runtime behavior changes — types + constants only. New in gitnexus-shared/src/scope-resolution/: - types.ts — Scope, ScopeKind, ScopeId, DefId, Range, Capture, BindingRef, ImportEdge, TypeRef, Resolution, ResolutionEvidence, Reference, ReferenceIndex, LookupParams, RegistryContributor - evidence-weights.ts — EvidenceWeights constant map + typeBindingWeightAtDepth (RFC Appendix A) - origin-priority.ts — ORIGIN_PRIORITY constant map for deterministic tie-breaks (RFC Appendix B) - language-classification.ts — LanguageClassification type + LanguageClassifications map (production × 14, experimental × 2 for vue/cobol; governs Ring 4 DAG-retirement gate) - symbol-definition.ts — SymbolDefinition moved from gitnexus/src/core/ingestion/model/symbol-table.ts so scope-resolution types can reference it from the shared package Consumer updates: - symbol-table.ts: removes local SymbolDefinition declaration; imports from gitnexus-shared - model/index.ts: drops SymbolDefinition from barrel re-export per "direct imports from gitnexus-shared" convention (see gitnexus-shared feedback in project memory) - 9 source files + 5 test files: import SymbolDefinition directly from 'gitnexus-shared' Verification: - gitnexus-shared builds clean (tsc) - gitnexus builds clean (scripts/build.js) - 131/132 unit test files pass; 3767 tests green - Zero behavior changes; SymbolDefinition shape unchanged Blocks: #911 (LanguageProvider hook interface extensions) and all of Ring 2 (#912-#925). Closes part of #909. --- gitnexus-shared/src/index.ts | 33 +++ .../src/scope-resolution/evidence-weights.ts | 90 ++++++ .../language-classification.ts | 49 ++++ .../src/scope-resolution/origin-priority.ts | 30 ++ .../src/scope-resolution/symbol-definition.ts | 35 +++ gitnexus-shared/src/scope-resolution/types.ts | 264 ++++++++++++++++++ gitnexus/src/core/ingestion/call-processor.ts | 8 +- .../core/ingestion/model/field-registry.ts | 2 +- gitnexus/src/core/ingestion/model/index.ts | 3 +- .../core/ingestion/model/method-registry.ts | 2 +- .../ingestion/model/registration-table.ts | 4 +- .../ingestion/model/resolution-context.ts | 3 +- gitnexus/src/core/ingestion/model/resolve.ts | 2 +- .../core/ingestion/model/semantic-model.ts | 8 +- .../src/core/ingestion/model/symbol-table.ts | 28 +- .../src/core/ingestion/model/type-registry.ts | 2 +- .../test/unit/model/field-registry.test.ts | 2 +- gitnexus/test/unit/model/helpers.ts | 2 +- .../unit/model/registration-table.test.ts | 2 +- .../test/unit/model/type-registry.test.ts | 2 +- gitnexus/test/unit/type-env.test.ts | 2 +- 21 files changed, 525 insertions(+), 48 deletions(-) create mode 100644 gitnexus-shared/src/scope-resolution/evidence-weights.ts create mode 100644 gitnexus-shared/src/scope-resolution/language-classification.ts create mode 100644 gitnexus-shared/src/scope-resolution/origin-priority.ts create mode 100644 gitnexus-shared/src/scope-resolution/symbol-definition.ts create mode 100644 gitnexus-shared/src/scope-resolution/types.ts diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 4024bf070..8f9bbdb3b 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -23,3 +23,36 @@ export type { MroStrategy } from './mro-strategy.js'; // Pipeline progress export type { PipelinePhase, PipelineProgress } from './pipeline.js'; + +// ─── Scope-based resolution — RFC #909 (Ring 1 #910) ──────────────────────── +// Data model (RFC §2) +export type { SymbolDefinition } from './scope-resolution/symbol-definition.js'; +export type { + ScopeId, + DefId, + ScopeKind, + Range, + Capture, + BindingRef, + ImportEdge, + TypeRef, + Scope, + ResolutionEvidence, + Resolution, + Reference, + ReferenceIndex, + LookupParams, + RegistryContributor, +} from './scope-resolution/types.js'; + +// Evidence + tie-break constants (RFC Appendix A, Appendix B) +export { EvidenceWeights, typeBindingWeightAtDepth } from './scope-resolution/evidence-weights.js'; +export { ORIGIN_PRIORITY } from './scope-resolution/origin-priority.js'; +export type { OriginForTieBreak } from './scope-resolution/origin-priority.js'; + +// Language classification (RFC §6.1 Ring 3/4 governance) +export { + LanguageClassifications, + isProductionLanguage, +} from './scope-resolution/language-classification.js'; +export type { LanguageClassification } from './scope-resolution/language-classification.js'; diff --git a/gitnexus-shared/src/scope-resolution/evidence-weights.ts b/gitnexus-shared/src/scope-resolution/evidence-weights.ts new file mode 100644 index 000000000..d1f46dabb --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/evidence-weights.ts @@ -0,0 +1,90 @@ +/** + * `EvidenceWeights` — RFC Appendix A (authoritative values). + * + * Starting calibration for scope-based resolution. Shadow-first rollout + * tunes these against legacy DAG parity. Every `ResolutionEvidence.weight` + * value in the codebase MUST reference this map; inline magic numbers are a + * lint violation. Extends issue #429 (centralize hardcoded confidence values). + * + * Evidence composes additively inside `composeEvidence`; the sum is capped + * at 1.0 in `Resolution.confidence`. + */ + +/** + * Authoritative weight map. Keys are a mix of `ResolutionEvidence.kind` + * values and special modifiers (scope-chain depth, MRO depth decay, + * unlinked-import multiplicative cap). + */ +export const EvidenceWeights = { + // ─── Where-found signals (visibility) ───────────────────────────────────── + /** `BindingRef.origin === 'local'` */ + local: 0.55, + /** `BindingRef.origin === 'import'` */ + import: 0.45, + /** `BindingRef.origin === 'reexport'` */ + reexport: 0.4, + /** `BindingRef.origin === 'namespace'` */ + namespace: 0.4, + /** `BindingRef.origin === 'wildcard'` */ + wildcard: 0.3, + + // ─── Scope-chain deduction (per-hop) ────────────────────────────────────── + /** Deducted per parent-hop taken (depth-0 = 0, depth-1 = −0.02, …). */ + scopeChainPerDepth: -0.02, + + // ─── Receiver-type-binding signal (decays by MRO depth) ─────────────────── + /** + * Weight applied when the receiver's type binding resolves to a class that + * declares the candidate as a method/field. Decays by MRO depth: direct + * class = index 0; 1 parent hop = index 1; etc. Falls back to the last + * value for depths beyond the table. + */ + typeBindingByMroDepth: [0.5, 0.42, 0.36, 0.32, 0.3] as const, + + // ─── Corroborating signals ──────────────────────────────────────────────── + /** `def.ownerId === resolvedReceiver.def.id` (exact owner match). */ + ownerMatch: 0.2, + /** Explanatory only — retained for debuggability. Never discriminates + * because surviving candidates already passed `acceptedKinds`. */ + kindMatch: 0.0, + + // ─── Arity compatibility (from `provider.arityCompatibility`) ───────────── + /** `provider.arityCompatibility(...) === 'compatible'` */ + arityMatchCompatible: 0.1, + /** `provider.arityCompatibility(...) === 'unknown'` */ + arityMatchUnknown: 0.0, + /** `provider.arityCompatibility(...) === 'incompatible'` — penalizes; + * candidates filtered only when a compatible candidate exists. */ + arityMatchIncompatible: -0.15, + + // ─── Global fallback (only when nothing lexically visible) ──────────────── + /** Hit via `QualifiedNameIndex.byQualifiedName`. */ + globalQualified: 0.35, + /** Fallback hit in a `byName` index (and nothing was lexically visible). */ + globalName: 0.1, + + // ─── Degraded signals ───────────────────────────────────────────────────── + /** Call/reference flowing through a `dynamic-unresolved` edge. */ + dynamicImportUnresolved: 0.02, + + // ─── Unresolved-import cap (multiplicative, applied per-signal) ─────────── + /** + * Multiplicative cap on the edge-derived evidence signal + * (`import`/`wildcard`/`reexport`/`namespace`) when + * `ImportEdge.linkStatus === 'unresolved'`. Independent corroborating + * signals on the same candidate (`owner-match`, `arity-match`, + * `type-binding`) are NOT penalized. + */ + unlinkedImportMultiplier: 0.5, +} as const; + +/** + * Look up the `type-binding` signal weight for a given MRO depth, falling + * back to the last tabulated value for depths beyond the table. + */ +export function typeBindingWeightAtDepth(mroDepth: number): number { + const table = EvidenceWeights.typeBindingByMroDepth; + if (mroDepth < 0) return table[0]; + if (mroDepth >= table.length) return table[table.length - 1]; + return table[mroDepth]; +} diff --git a/gitnexus-shared/src/scope-resolution/language-classification.ts b/gitnexus-shared/src/scope-resolution/language-classification.ts new file mode 100644 index 000000000..10c556cda --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/language-classification.ts @@ -0,0 +1,49 @@ +/** + * `LanguageClassification` — RFC §6.1 Ring 3 / Ring 4 governance. + * + * Classifies each `SupportedLanguages` member for the rollout. Ring 4 (DAG + * retirement) is gated on *all production languages* being registry-primary + * and stable for one release cycle; `experimental` and `quarantined` + * languages do not block. + * + * Initial classification (locked in Ring 1 #910): + * - production: javascript, typescript, python, java, c, cpp, csharp, go, + * ruby, rust, php, kotlin, swift, dart + * - experimental: vue (embedded-language / SFC complexity), + * cobol (regex-provider path) + * - quarantined: (none) + */ + +import { SupportedLanguages } from '../languages.js'; + +export type LanguageClassification = 'production' | 'experimental' | 'quarantined'; + +/** + * The canonical classification for each supported language. Governance + * changes (promote `experimental` → `production`, quarantine a language, …) + * update this map in a dedicated PR. + */ +export const LanguageClassifications: Readonly> = + { + [SupportedLanguages.JavaScript]: 'production', + [SupportedLanguages.TypeScript]: 'production', + [SupportedLanguages.Python]: 'production', + [SupportedLanguages.Java]: 'production', + [SupportedLanguages.C]: 'production', + [SupportedLanguages.CPlusPlus]: 'production', + [SupportedLanguages.CSharp]: 'production', + [SupportedLanguages.Go]: 'production', + [SupportedLanguages.Ruby]: 'production', + [SupportedLanguages.Rust]: 'production', + [SupportedLanguages.PHP]: 'production', + [SupportedLanguages.Kotlin]: 'production', + [SupportedLanguages.Swift]: 'production', + [SupportedLanguages.Dart]: 'production', + [SupportedLanguages.Vue]: 'experimental', + [SupportedLanguages.Cobol]: 'experimental', + }; + +/** Convenience predicate: is this language gating Ring 4 retirement? */ +export function isProductionLanguage(lang: SupportedLanguages): boolean { + return LanguageClassifications[lang] === 'production'; +} diff --git a/gitnexus-shared/src/scope-resolution/origin-priority.ts b/gitnexus-shared/src/scope-resolution/origin-priority.ts new file mode 100644 index 000000000..c5068c274 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/origin-priority.ts @@ -0,0 +1,30 @@ +/** + * `ORIGIN_PRIORITY` — RFC Appendix B (authoritative values). + * + * Tie-break ordering applied inside `Registry.lookup` Step 7 when + * `|Δconfidence| < 0.001` between two `Resolution` candidates. Lower number + * = stronger (wins the tie). + * + * Full tie-break order (§4.2 Step 7): + * confidence DESC → scope depth ASC → MRO depth ASC → ORIGIN_PRIORITY ASC + * → DefId.localeCompare + */ + +export type OriginForTieBreak = + | 'local' + | 'import' + | 'reexport' + | 'namespace' + | 'wildcard' + | 'global-qualified' + | 'global-name'; + +export const ORIGIN_PRIORITY: Readonly> = { + local: 0, + import: 1, + reexport: 2, + namespace: 3, + wildcard: 4, + 'global-qualified': 5, + 'global-name': 6, +}; diff --git a/gitnexus-shared/src/scope-resolution/symbol-definition.ts b/gitnexus-shared/src/scope-resolution/symbol-definition.ts new file mode 100644 index 000000000..d07dbf38b --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/symbol-definition.ts @@ -0,0 +1,35 @@ +/** + * `SymbolDefinition` — the canonical shape of an indexed symbol record. + * + * Historically defined in `gitnexus/src/core/ingestion/model/symbol-table.ts`; + * moved into `gitnexus-shared` as part of RFC #909 Ring 1 (#910) so the + * scope-resolution types that reference it can live in the shared package + * alongside their consumers (`gitnexus/` and `gitnexus-web/`). + * + * Shape is unchanged from the prior local definition. + */ + +import type { NodeLabel } from '../graph/types.js'; + +export interface SymbolDefinition { + nodeId: string; + filePath: string; + type: NodeLabel; + /** Canonical dot-separated qualified type name for class-like symbols + * (e.g. `App.Models.User`). Falls back to the simple symbol name when no + * package/namespace/module scope exists or no explicit qualified metadata is provided. */ + qualifiedName?: string; + parameterCount?: number; + /** Number of required (non-optional, non-default) parameters. + * Enables range-based arity filtering: argCount >= requiredParameterCount && argCount <= parameterCount. */ + requiredParameterCount?: number; + /** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']). + * Populated when parameter types are resolvable from AST (any typed language). */ + parameterTypes?: string[]; + /** Raw return type text extracted from AST (e.g. 'User', 'Promise') */ + returnType?: string; + /** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List') */ + declaredType?: string; + /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ + ownerId?: string; +} diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts new file mode 100644 index 000000000..3ee1763b4 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -0,0 +1,264 @@ +/** + * Scope-resolution type definitions — RFC §2 data model (authoritative source). + * + * See: https://www.notion.so/346dc50b6ed281cfaacbe480bf231d50 + * + * Anti-drift rule: every type, interface, and enum defined here is the single + * source of truth. Later code that references these names must import them + * from `gitnexus-shared`; it must not re-define them locally. + * + * Lifecycle contract (RFC §2.8): scopes are **constructed during extraction, + * linked during finalize, immutable after finalize**. All fields are + * `readonly` at the type level; `Object.freeze` is applied at runtime in dev + * builds. `ReferenceIndex` is the sole structure populated after freeze — by + * resolution, before emission. + */ + +import type { NodeLabel } from '../graph/types.js'; +import type { SymbolDefinition } from './symbol-definition.js'; + +// ─── §2.1 Type aliases ────────────────────────────────────────────────────── + +/** Stable per-(file, range, kind) scope identifier; interned for identity-fast equality. */ +export type ScopeId = string; + +/** Stable symbol-definition identifier (graph nodeId). */ +export type DefId = string; + +/** Kinds of lexical scope a `Scope` node can represent. */ +export type ScopeKind = + | 'Module' // file root + | 'Namespace' // C++ namespace, C# namespace, Kotlin package-object, Rust mod + | 'Class' // class/struct/trait/interface body + | 'Function' // function/method/closure/lambda body + | 'Block' // { ... }, if-body, for-body, with-body, match arms + | 'Expression'; // comprehensions, for-init, pattern bindings, lambda param lists + +// ─── Range + Capture (parser-agnostic) ────────────────────────────────────── + +/** Source-text range. 1-based `startLine`/`endLine`; 0-based `startCol`/`endCol`. */ +export interface Range { + readonly startLine: number; + readonly startCol: number; + readonly endLine: number; + readonly endCol: number; +} + +/** + * Tagged capture emitted by a LanguageProvider's `emitScopeCaptures` hook. + * + * Parser-agnostic: tree-sitter queries and COBOL's regex tagger both produce + * `Capture[]`. The central `ScopeExtractor` consumes captures without + * knowing which parser produced them. + */ +export interface Capture { + /** Capture name, including leading `@` (e.g., `'@scope.module'`, `'@declaration.class'`). */ + readonly name: string; + readonly range: Range; + /** The captured source text. */ + readonly text: string; +} + +// ─── §2.4 ImportEdge ──────────────────────────────────────────────────────── + +/** + * A cross-file import edge attached to a module/namespace scope. + * + * Raw (unlinked) edges are emitted during parse (Phase 1); `targetModuleScope` + * and `targetDefId` are filled in during finalize (Phase 2) via SCC-aware + * bounded-fixpoint linking (RFC §3.2). + */ +export interface ImportEdge { + /** How this scope sees the imported name (after alias). */ + readonly localName: string; + /** Exporting file; `null` only when `kind === 'dynamic-unresolved'`. */ + readonly targetFile: string | null; + /** The name under which the target exports this symbol. */ + readonly targetExportedName: string; + /** Pre-resolved at finalize: the module scope of the exporting file. */ + readonly targetModuleScope?: ScopeId; + /** Pre-resolved at finalize: the exported symbol's `DefId`. */ + readonly targetDefId?: DefId; + readonly kind: + | 'named' + | 'alias' + | 'namespace' + | 'wildcard-expanded' + | 'reexport' + | 'dynamic-unresolved'; + /** Re-export chain, for provenance (e.g., `['./y']` when re-exported via `./y`). */ + readonly transitiveVia?: readonly string[]; + /** Set to `'unresolved'` when the SCC fixpoint could not link this edge. */ + readonly linkStatus?: 'unresolved'; +} + +// ─── §2.3 BindingRef ──────────────────────────────────────────────────────── + +/** + * A name binding visible at a scope, with provenance. + * + * Provenance stays at the visibility layer — a name being visible because it + * is local vs imported vs wildcard-expanded vs re-exported is a property of + * the binding itself. This keeps evidence emission and `import-use` reference + * stamping first-class instead of reconstructing provenance from a side table. + */ +export interface BindingRef { + readonly def: SymbolDefinition; + readonly origin: 'local' | 'import' | 'namespace' | 'wildcard' | 'reexport'; + /** Non-null for non-local origins; carries the `ImportEdge` that brought the name into this scope. */ + readonly via?: ImportEdge; +} + +// ─── §2.5 TypeRef ─────────────────────────────────────────────────────────── + +/** + * A reference to a named type, anchored at its declaration site. + * + * Design choice: raw name + declaration-site scope, resolved at lookup time. + * Pre-resolution would invert the extraction/resolution wall. Deferred thunks + * add no capability. Structured type systems are months of work per language. + * This shape keeps V1 tractable while preserving correctness for aliases, + * re-exports, and nested modules. Generics deferred to V2 via `typeArgs`. + */ +export interface TypeRef { + /** The name as written in source (e.g., `'User'`, `'models.User'`, `'List'`). */ + readonly rawName: string; + /** Anchor for resolving `rawName` — the scope where the annotation/inference was written. */ + readonly declaredAtScope: ScopeId; + readonly source: + | 'annotation' + | 'parameter-annotation' + | 'return-annotation' + | 'self' + | 'assignment-inferred' + | 'constructor-inferred' + | 'receiver-propagated'; + /** Reserved for V2+: generic type arguments (`List` → `[TypeRef('User')]`). V1 ignores. */ + readonly typeArgs?: readonly TypeRef[]; +} + +// ─── §2.2 Scope ───────────────────────────────────────────────────────────── + +/** + * The canonical lexical-scope node. Forms the spine of the SemanticModel. + * + * ScopeId shape (RFC §2.2): `scope:{filePath}#{startLine}:{startCol}-{endLine}:{endCol}:{kind}` + * — deterministic, stable across reparses of the same source, interned. + */ +export interface Scope { + readonly id: ScopeId; + readonly parent: ScopeId | null; + readonly kind: ScopeKind; + readonly range: Range; + readonly filePath: string; + + /** Names visible from this scope. Provenance preserved via `BindingRef.origin`. */ + readonly bindings: ReadonlyMap; + + /** Defs structurally owned by this scope (e.g., methods owned by a class body scope). */ + readonly ownedDefs: readonly SymbolDefinition[]; + + /** Import edges attached to this scope. Mostly module/namespace scopes, but some + * languages allow local imports (Python `def f(): from x import Y`, Rust + * fn-local `use`, TS dynamic `import()`). */ + readonly imports: readonly ImportEdge[]; + + /** Local type facts visible from this scope (parameter annotations, `self` binding, etc.). */ + readonly typeBindings: ReadonlyMap; +} + +// ─── §2.6 Resolution + ResolutionEvidence ─────────────────────────────────── + +/** + * One piece of evidence for a `Resolution`. Multiple signals corroborate a + * single match; their weights compose additively to produce `confidence`. + * + * Weights come from `EvidenceWeights` (see `./evidence-weights.ts`). + */ +export interface ResolutionEvidence { + readonly kind: + | 'local' + | 'scope-chain' + | 'import' + | 'type-binding' + | 'owner-match' + | 'kind-match' + | 'arity-match' + | 'global-name' + | 'global-qualified' + | 'dynamic-import-unresolved'; + /** Signal weight, sourced from `EvidenceWeights`. Additive; sum capped at 1.0. */ + readonly weight: number; + /** Optional debug annotation (e.g., `'matched via self: User'`). */ + readonly note?: string; +} + +/** + * A ranked resolution candidate returned by `ClassRegistry.lookup` / + * `MethodRegistry.lookup` / `FieldRegistry.lookup`. Evidence composes + * additively; callers read `[0]` for the one-shot answer or inspect the + * evidence trace for debugging. + */ +export interface Resolution { + readonly def: SymbolDefinition; + /** Σ of `evidence[].weight`, capped at 1.0. */ + readonly confidence: number; + readonly evidence: readonly ResolutionEvidence[]; + /** Optional debug trace: scopes walked to reach `def`. */ + readonly path?: readonly ScopeId[]; +} + +// ─── §2.7 Reference + ReferenceIndex ──────────────────────────────────────── + +/** + * A post-resolution usage fact: some code at `atRange` inside `fromScope` + * references `toDef` with the given confidence/evidence. Materialized by the + * resolution phase; emitted as graph edges (`CALLS`/`READS`/`WRITES`/etc.) + * during the emit phase. + */ +export interface Reference { + /** Innermost lexical scope containing `atRange`. */ + readonly fromScope: ScopeId; + readonly toDef: DefId; + /** Location of the reference in source. */ + readonly atRange: Range; + readonly kind: 'call' | 'read' | 'write' | 'type-reference' | 'inherits' | 'import-use'; + readonly confidence: number; + readonly evidence: readonly ResolutionEvidence[]; +} + +/** + * Two-way index over `Reference` records, populated during the resolution + * phase. Scopes stay immutable after finalize; references accumulate here. + */ +export interface ReferenceIndex { + readonly bySourceScope: ReadonlyMap; + readonly byTargetDef: ReadonlyMap; +} + +// ─── §4.1 LookupParams ────────────────────────────────────────────────────── + +/** + * Opaque placeholder for the per-kind registry passed as the owner-scoped + * contributor. Typed concretely in Ring 2 SHARED (#917); kept as `unknown` + * here so Ring 1 can ship without pulling in the registry implementation. + */ +export type RegistryContributor = unknown; + +/** + * Parameters accepted by `Registry.lookup`. Three registries (Class/Method/ + * Field) run the same 7-step algorithm with different parameter tuples; see + * RFC §4.4 for per-registry specializations. + */ +export interface LookupParams { + readonly acceptedKinds: readonly NodeLabel[]; + /** Class lookups: false. Method/Field lookups: true. */ + readonly useReceiverTypeBinding: boolean; + readonly ownerScopedContributor: RegistryContributor | null; + /** Optional arity hint fed to `provider.arityCompatibility`. */ + readonly arityHint?: number; + /** Explicit receiver name (e.g., `'user'` in `user.save()`). When present, + * the receiver's type binding at the callsite scope is used; otherwise + * the enclosing method's implicit `self`/`this` is consulted. See §4.1. */ + readonly explicitReceiver?: { readonly name: string }; +} diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index c30d83848..7d8a9da27 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -1,11 +1,7 @@ import { KnowledgeGraph } from '../graph/types.js'; import { ASTCache } from './ast-cache.js'; -import type { - SymbolDefinition, - SymbolTableReader, - HeritageMap, - ExtractedHeritage, -} from './model/index.js'; +import type { SymbolDefinition } from 'gitnexus-shared'; +import type { SymbolTableReader, HeritageMap, ExtractedHeritage } from './model/index.js'; import { CLASS_TYPES, CALL_TARGET_TYPES, lookupMethodByOwnerWithMRO } from './model/index.js'; import type { DispatchDecision, ReceiverEnriched } from './call-types.js'; diff --git a/gitnexus/src/core/ingestion/model/field-registry.ts b/gitnexus/src/core/ingestion/model/field-registry.ts index 45fe5c86a..62376c889 100644 --- a/gitnexus/src/core/ingestion/model/field-registry.ts +++ b/gitnexus/src/core/ingestion/model/field-registry.ts @@ -5,7 +5,7 @@ * Stores Property symbols keyed by `ownerNodeId\0fieldName` for O(1) lookup. */ -import type { SymbolDefinition } from './symbol-table.js'; +import type { SymbolDefinition } from 'gitnexus-shared'; // --------------------------------------------------------------------------- // Public read-only interface diff --git a/gitnexus/src/core/ingestion/model/index.ts b/gitnexus/src/core/ingestion/model/index.ts index 49f200285..d5398e6b7 100644 --- a/gitnexus/src/core/ingestion/model/index.ts +++ b/gitnexus/src/core/ingestion/model/index.ts @@ -26,7 +26,6 @@ export { type SymbolTableReader, type SymbolTableWriter, createSymbolTable, - type SymbolDefinition, type AddMetadata, CLASS_TYPES, CLASS_TYPES_TUPLE, @@ -36,6 +35,8 @@ export { type FreeCallableLabel, CALL_TARGET_TYPES, } from './symbol-table.js'; +// `SymbolDefinition` moved to `gitnexus-shared` (RFC #909 Ring 1 #910). +// Consumers should import it directly from `gitnexus-shared`, not via this barrel. // Type registry (classes, structs, interfaces, enums, records, impls) export { diff --git a/gitnexus/src/core/ingestion/model/method-registry.ts b/gitnexus/src/core/ingestion/model/method-registry.ts index be28e5782..327f568e7 100644 --- a/gitnexus/src/core/ingestion/model/method-registry.ts +++ b/gitnexus/src/core/ingestion/model/method-registry.ts @@ -7,7 +7,7 @@ * (array values) and arity-based filtering. */ -import type { SymbolDefinition } from './symbol-table.js'; +import type { SymbolDefinition } from 'gitnexus-shared'; // --------------------------------------------------------------------------- // Public read-only interface diff --git a/gitnexus/src/core/ingestion/model/registration-table.ts b/gitnexus/src/core/ingestion/model/registration-table.ts index a5bb14f21..6e0e2646d 100644 --- a/gitnexus/src/core/ingestion/model/registration-table.ts +++ b/gitnexus/src/core/ingestion/model/registration-table.ts @@ -49,8 +49,8 @@ * `NodeLabel` is missing from all three sets. */ -import type { NodeLabel } from 'gitnexus-shared'; -import type { SymbolDefinition, ClassLikeLabel, FreeCallableLabel } from './symbol-table.js'; +import type { NodeLabel, SymbolDefinition } from 'gitnexus-shared'; +import type { ClassLikeLabel, FreeCallableLabel } from './symbol-table.js'; import { FREE_CALLABLE_TYPES } from './symbol-table.js'; import type { MutableTypeRegistry } from './type-registry.js'; import type { MutableMethodRegistry } from './method-registry.js'; diff --git a/gitnexus/src/core/ingestion/model/resolution-context.ts b/gitnexus/src/core/ingestion/model/resolution-context.ts index b56524b36..45f94d698 100644 --- a/gitnexus/src/core/ingestion/model/resolution-context.ts +++ b/gitnexus/src/core/ingestion/model/resolution-context.ts @@ -18,7 +18,8 @@ * (three O(1) index lookups with a narrow, type-specific result set). */ -import type { SymbolDefinition, SymbolTableReader } from './symbol-table.js'; +import type { SymbolDefinition } from 'gitnexus-shared'; +import type { SymbolTableReader } from './symbol-table.js'; import type { MutableSemanticModel } from './semantic-model.js'; import { createSemanticModel } from './semantic-model.js'; diff --git a/gitnexus/src/core/ingestion/model/resolve.ts b/gitnexus/src/core/ingestion/model/resolve.ts index c05945b2d..62653a762 100644 --- a/gitnexus/src/core/ingestion/model/resolve.ts +++ b/gitnexus/src/core/ingestion/model/resolve.ts @@ -6,7 +6,7 @@ * on resolution-context.ts (circular dependency risk). */ -import type { SymbolDefinition } from './symbol-table.js'; +import type { SymbolDefinition } from 'gitnexus-shared'; import type { SemanticModel } from './semantic-model.js'; import type { HeritageMap } from './heritage-map.js'; import type { MroStrategy } from 'gitnexus-shared'; diff --git a/gitnexus/src/core/ingestion/model/semantic-model.ts b/gitnexus/src/core/ingestion/model/semantic-model.ts index deee4ad93..35a2628a5 100644 --- a/gitnexus/src/core/ingestion/model/semantic-model.ts +++ b/gitnexus/src/core/ingestion/model/semantic-model.ts @@ -53,12 +53,8 @@ import type { FieldRegistry, MutableFieldRegistry } from './field-registry.js'; import { createTypeRegistry } from './type-registry.js'; import { createMethodRegistry } from './method-registry.js'; import { createFieldRegistry } from './field-registry.js'; -import type { - SymbolTableReader, - SymbolTableWriter, - SymbolDefinition, - AddMetadata, -} from './symbol-table.js'; +import type { SymbolDefinition } from 'gitnexus-shared'; +import type { SymbolTableReader, SymbolTableWriter, AddMetadata } from './symbol-table.js'; import { createSymbolTable } from './symbol-table.js'; import { createRegistrationTable } from './registration-table.js'; diff --git a/gitnexus/src/core/ingestion/model/symbol-table.ts b/gitnexus/src/core/ingestion/model/symbol-table.ts index 75abb9731..c22f63acb 100644 --- a/gitnexus/src/core/ingestion/model/symbol-table.ts +++ b/gitnexus/src/core/ingestion/model/symbol-table.ts @@ -34,7 +34,7 @@ * logic up the dependency chain instead. */ -import type { NodeLabel } from 'gitnexus-shared'; +import type { NodeLabel, SymbolDefinition } from 'gitnexus-shared'; /** * Class-like NodeLabels — used for qualifiedName fallback inside @@ -113,28 +113,10 @@ export const CALL_TARGET_TYPES: ReadonlySet = new Set([ 'Constructor', ]); -export interface SymbolDefinition { - nodeId: string; - filePath: string; - type: NodeLabel; - /** Canonical dot-separated qualified type name for class-like symbols - * (e.g. `App.Models.User`). Falls back to the simple symbol name when no - * package/namespace/module scope exists or no explicit qualified metadata is provided. */ - qualifiedName?: string; - parameterCount?: number; - /** Number of required (non-optional, non-default) parameters. - * Enables range-based arity filtering: argCount >= requiredParameterCount && argCount <= parameterCount. */ - requiredParameterCount?: number; - /** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']). - * Populated when parameter types are resolvable from AST (any typed language). */ - parameterTypes?: string[]; - /** Raw return type text extracted from AST (e.g. 'User', 'Promise') */ - returnType?: string; - /** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List') */ - declaredType?: string; - /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ - ownerId?: string; -} +// `SymbolDefinition` moved to `gitnexus-shared` as part of RFC #909 Ring 1 +// (see #910). It is imported at the top of this file from `gitnexus-shared` +// and re-used unchanged throughout. Consumers should import +// `SymbolDefinition` directly from `gitnexus-shared`, not via this file. /** * Optional metadata accepted by {@link SymbolTable.add}. Kept as a separate diff --git a/gitnexus/src/core/ingestion/model/type-registry.ts b/gitnexus/src/core/ingestion/model/type-registry.ts index 95d52dbc1..3f9fdc5f4 100644 --- a/gitnexus/src/core/ingestion/model/type-registry.ts +++ b/gitnexus/src/core/ingestion/model/type-registry.ts @@ -6,7 +6,7 @@ * Also includes a separate index for Rust Impl blocks. */ -import type { SymbolDefinition } from './symbol-table.js'; +import type { SymbolDefinition } from 'gitnexus-shared'; // --------------------------------------------------------------------------- // Public read-only interface diff --git a/gitnexus/test/unit/model/field-registry.test.ts b/gitnexus/test/unit/model/field-registry.test.ts index 4c6e093d7..ff1f749b1 100644 --- a/gitnexus/test/unit/model/field-registry.test.ts +++ b/gitnexus/test/unit/model/field-registry.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect } from 'vitest'; import { createFieldRegistry } from '../../../src/core/ingestion/model/field-registry.js'; -import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js'; +import type { SymbolDefinition } from 'gitnexus-shared'; import { makeDef as makeBaseDef } from './helpers.js'; const makeDef = (overrides: Partial = {}): SymbolDefinition => diff --git a/gitnexus/test/unit/model/helpers.ts b/gitnexus/test/unit/model/helpers.ts index 04856f8d3..00c5fef06 100644 --- a/gitnexus/test/unit/model/helpers.ts +++ b/gitnexus/test/unit/model/helpers.ts @@ -6,7 +6,7 @@ * test file that uses it. */ -import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js'; +import type { SymbolDefinition } from 'gitnexus-shared'; /** * Build a {@link SymbolDefinition} with sensible defaults. Every field diff --git a/gitnexus/test/unit/model/registration-table.test.ts b/gitnexus/test/unit/model/registration-table.test.ts index 067b7fc20..fe37d0bbb 100644 --- a/gitnexus/test/unit/model/registration-table.test.ts +++ b/gitnexus/test/unit/model/registration-table.test.ts @@ -9,7 +9,7 @@ import { createTypeRegistry } from '../../../src/core/ingestion/model/type-regis import { createMethodRegistry } from '../../../src/core/ingestion/model/method-registry.js'; import { createFieldRegistry } from '../../../src/core/ingestion/model/field-registry.js'; import { ALL_NODE_LABELS } from '../../../src/core/ingestion/model/index.js'; -import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js'; +import type { SymbolDefinition } from 'gitnexus-shared'; import { makeDef as makeBaseDef } from './helpers.js'; // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/model/type-registry.test.ts b/gitnexus/test/unit/model/type-registry.test.ts index 8f0eee838..9eaf61c4e 100644 --- a/gitnexus/test/unit/model/type-registry.test.ts +++ b/gitnexus/test/unit/model/type-registry.test.ts @@ -10,7 +10,7 @@ import { describe, it, expect } from 'vitest'; import { createTypeRegistry } from '../../../src/core/ingestion/model/type-registry.js'; -import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js'; +import type { SymbolDefinition } from 'gitnexus-shared'; import { makeDef as makeBaseDef } from './helpers.js'; const makeDef = (overrides: Partial = {}): SymbolDefinition => diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index 27f449f91..dffbc047e 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { buildTypeEnv, type TypeEnvironment } from '../../src/core/ingestion/type-env.js'; import { BindingAccumulator } from '../../src/core/ingestion/binding-accumulator.js'; -import { type SymbolDefinition } from '../../src/core/ingestion/model/symbol-table.js'; +import { type SymbolDefinition } from 'gitnexus-shared'; import { createSemanticModel, type SemanticModel, From af1d278a7e2d6698719a8615214df76125f26286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 14:54:51 +0100 Subject: [PATCH 21/46] feat(shared,ingestion): extend LanguageProvider with scope-resolution hooks (#911, RFC #909 Ring 1) (#950) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the 14 optional scope-resolution hooks from RFC #909 §5.2 to `LanguageProviderConfig` plus the supporting input/output types in `gitnexus-shared`. Contract-only; no runtime behavior changes. Review-driven refinements (addresses two non-blocking review comments on #950): 1. `ParsedImport` is now a 5-variant discriminated union, not a flat record. Each variant carries only its legal fields so invalid shapes are compile errors: - 'named', 'alias', 'namespace', 'reexport', 'dynamic-unresolved' 'wildcard-expanded' is deliberately excluded — finalize materializes that kind; a provider must never emit it at parse time. 'reexport' is a first-class parse-phase variant so syntactically- detectable re-exports (TS `export { X } from './y'`, Rust `pub use foo::bar`) keep their parse-time signal through to finalize rather than being re-derived by the SCC pass. `namespace` gains an `importedName` field so `import numpy as np` can carry both `localName: 'np'` and `importedName: 'numpy'`. `dynamic-unresolved.targetRaw` is `string | null` (was mandatory null) so providers can emit the unresolvable expression text for diagnostics when available. 2. `bindingScopeFor` and `importOwningScope` return type changed from `ScopeId` to `ScopeId | null`, aligning with the X | null convention used by the 12 sibling optional hooks (receiverBinding, resolveScopeKind, interpretTypeBinding, …). `null` = delegate to the central default. Enables partial overrides — a JS provider can return a hoisted scope for `var` and `null` for `let`/`const` without re-implementing the default lookup. Both hooks also gain a purity JSDoc contract: same inputs yield the same ScopeId (or null) across invocations; no closure over mutable state. Required to keep scope-tree construction deterministic. A richer callable-defaults pattern (typed BindingScopeDefaults / ImportOwningDefaults helper interfaces on a `defaults` parameter) was considered and deferred to Ring 2 PKG #919, where the concrete ScopeExtractor will exist to inform the helper shape. Designing that pattern before the first consumer would set cross-hook precedent based on a single motivating example. Supporting types added to gitnexus-shared/src/scope-resolution/types.ts: - CaptureMatch, ParsedImport, ParsedTypeBinding - WorkspaceIndex, ScopeTree (opaque placeholders until Ring 2) - Callsite 14 hooks added to LanguageProviderConfig (all optional): Parse phase: emitScopeCaptures, interpretImport, receiverBinding, interpretTypeBinding, resolveScopeKind, shouldCreateScope, bindingScopeFor Finalize phase: resolveImportTarget, expandsWildcardTo, importOwningScope, mergeBindings Reference-extraction phase: classifyCallForm Resolution phase: shouldShadow, arityCompatibility Verification: - gitnexus-shared builds clean (tsc) - gitnexus builds clean (scripts/build.js) - test/unit/model: 84/84 pass — no regressions - No provider needs updating (all hooks optional) - No BindingScopeDefaults/ImportOwningDefaults/defaults parameter introduced (deferred to #919) Stacked on #910 (merged as afc0a8b6); rebased on main. Tracking: #909 (meta). Unblocks Ring 2 PKG (#919 ScopeExtractor, #922 import adapters) and all Ring 3 per-language migrations. Plan: docs/plans/2026-04-18-001-refactor-911-senior-hooks-redesign-plan.md --- gitnexus-shared/src/index.ts | 6 + gitnexus-shared/src/scope-resolution/types.ts | 144 ++++++++++ .../src/core/ingestion/language-provider.ts | 245 +++++++++++++++++- 3 files changed, 394 insertions(+), 1 deletion(-) diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 8f9bbdb3b..5b1d67072 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -33,6 +33,7 @@ export type { ScopeKind, Range, Capture, + CaptureMatch, BindingRef, ImportEdge, TypeRef, @@ -43,6 +44,11 @@ export type { ReferenceIndex, LookupParams, RegistryContributor, + ParsedImport, + ParsedTypeBinding, + WorkspaceIndex, + ScopeTree, + Callsite, } from './scope-resolution/types.js'; // Evidence + tie-break constants (RFC Appendix A, Appendix B) diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index 3ee1763b4..84945dcd9 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -59,6 +59,150 @@ export interface Capture { readonly text: string; } +/** + * A grouping of `Capture`s that came from a single query match (e.g., one + * `@import.statement` match carries `@import.source`, `@import.name`, + * `@import.alias?` as child captures). Keyed by capture name for O(1) + * child access. + */ +export type CaptureMatch = Readonly>; + +// ─── Hook input/output types (RFC §5.2) ───────────────────────────────────── + +/** + * Provider-interpreted raw import, consumed by finalize (Phase 2) to produce + * linked `ImportEdge[]`. The provider's `interpretImport` hook turns a + * `CaptureMatch` for an `@import.statement` into one of these; the central + * finalize algorithm resolves `targetRaw` to a concrete file via + * `resolveImportTarget` and materializes the final `ImportEdge`. + * + * Discriminated union — each variant carries only the fields that make sense + * for its kind. Invalid shapes (e.g., a `namespace` import with an alias-like + * `importedName` mismatch) are compile errors, not latent bugs. `'wildcard- + * expanded'` is deliberately NOT a variant: that kind is finalize output only, + * produced when `expandsWildcardTo` materializes a wildcard against target + * exports — a provider must never emit it at parse time. + */ +export type ParsedImport = + /** + * Per-name import without rename. + * + * Examples: + * - Python `from foo import X` → `{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: 'foo' }` + * - TS `import { X } from './foo'` → `{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: './foo' }` + * - Java `import foo.bar.X` → `{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: 'foo.bar' }` + */ + | { + readonly kind: 'named'; + readonly localName: string; + readonly importedName: string; + readonly targetRaw: string; + } + /** + * Per-name import with rename. + * + * Examples: + * - Python `from foo import X as Y` → `{ kind: 'alias', localName: 'Y', importedName: 'X', alias: 'Y', targetRaw: 'foo' }` + * - TS `import { X as Y } from './foo'` → `{ kind: 'alias', localName: 'Y', importedName: 'X', alias: 'Y', targetRaw: './foo' }` + */ + | { + readonly kind: 'alias'; + readonly localName: string; + readonly importedName: string; + readonly alias: string; + readonly targetRaw: string; + } + /** + * Qualified module handle, with or without rename. `importedName` is the + * module being aliased; `localName` is the scope-visible handle (often the + * same unless renamed). + * + * Examples: + * - Python `import numpy` → `{ kind: 'namespace', localName: 'numpy', importedName: 'numpy', targetRaw: 'numpy' }` + * - Python `import numpy as np` → `{ kind: 'namespace', localName: 'np', importedName: 'numpy', targetRaw: 'numpy' }` + * - TS `import * as np from 'numpy'` → `{ kind: 'namespace', localName: 'np', importedName: 'numpy', targetRaw: 'numpy' }` + * - Go `import foo "pkg/bar"` → `{ kind: 'namespace', localName: 'foo', importedName: 'bar', targetRaw: 'pkg/bar' }` + */ + | { + readonly kind: 'namespace'; + /** Scope-visible handle (e.g. `np` in `import numpy as np`; `numpy` when unaliased). */ + readonly localName: string; + /** Module being aliased (e.g. `numpy` in `import numpy as np`). */ + readonly importedName: string; + readonly targetRaw: string; + } + /** + * Syntactically-detectable parse-time re-export. Finalize may still produce + * `ImportEdge { kind: 'reexport', transitiveVia }` when flattening chains; + * this variant preserves the *parse-time* signal so finalize doesn't have + * to re-derive it from scratch. + * + * Examples: + * - TS `export { X } from './y'` → `{ kind: 'reexport', localName: 'X', importedName: 'X', targetRaw: './y' }` + * - TS `export { X as Y } from './y'` → `{ kind: 'reexport', localName: 'Y', importedName: 'X', alias: 'Y', targetRaw: './y' }` + * - Rust `pub use foo::bar` → `{ kind: 'reexport', localName: 'bar', importedName: 'bar', targetRaw: 'foo' }` + */ + | { + readonly kind: 'reexport'; + /** Name as re-exported in the current module. */ + readonly localName: string; + /** Name in the source module. */ + readonly importedName: string; + readonly targetRaw: string; + /** Set when the re-export renames the symbol (e.g. `export { X as Y } from './y'`). */ + readonly alias?: string; + } + /** + * Runtime-computed target — the import path is not a static literal at + * parse time. Providers SHOULD emit the unresolvable expression's source + * text as `targetRaw` to aid diagnostics; `null` only when no string form + * exists. + * + * Examples: + * - JS `await import(expr)` → `{ kind: 'dynamic-unresolved', localName: '', targetRaw: 'expr' }` + * - Python `importlib.import_module(f'pkg.{name}')` → `{ kind: 'dynamic-unresolved', localName: '', targetRaw: "f'pkg.{name}'" }` + */ + | { + readonly kind: 'dynamic-unresolved'; + readonly localName: string; + /** Source text of the unresolved expression when available; `null` otherwise. */ + readonly targetRaw: string | null; + }; + +/** + * Provider-interpreted type binding. The provider's `interpretTypeBinding` + * hook turns a `CaptureMatch` (e.g., `@type-binding.parameter`) into one of + * these; the central extractor attaches the resulting `TypeRef` to the + * appropriate scope's `typeBindings` map. + */ +export interface ParsedTypeBinding { + /** The name being bound (parameter name, `self`, assignment LHS, …). */ + readonly boundName: string; + /** The raw type name as written in source (`'User'`, `'models.User'`, …). */ + readonly rawTypeName: string; + readonly source: TypeRef['source']; +} + +/** + * Cross-file workspace index consumed by finalize-phase hooks + * (`resolveImportTarget`, `expandsWildcardTo`). Opaque placeholder in Ring 1; + * concretely typed in Ring 2 SHARED (#915). + */ +export type WorkspaceIndex = unknown; + +/** + * Scope tree handle consumed by parse-phase hooks (`bindingScopeFor`, + * `importOwningScope`) to navigate the in-progress scope tree. Opaque + * placeholder in Ring 1; concretely typed in Ring 2 SHARED (#912). + */ +export type ScopeTree = unknown; + +/** Call-site description passed to `arityCompatibility`. */ +export interface Callsite { + /** Number of arguments at the call site. */ + readonly arity: number; +} + // ─── §2.4 ImportEdge ──────────────────────────────────────────────────────── /** diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 3960c6a8a..2777c991f 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -9,7 +9,23 @@ * so adding a language to the enum without creating a provider is a compiler error. */ -import type { SupportedLanguages, MroStrategy } from 'gitnexus-shared'; +import type { + SupportedLanguages, + MroStrategy, + Capture, + CaptureMatch, + BindingRef, + TypeRef, + Scope, + ScopeId, + ScopeKind, + ScopeTree, + ParsedImport, + ParsedTypeBinding, + SymbolDefinition, + Callsite, + WorkspaceIndex, +} from 'gitnexus-shared'; import type { LanguageTypeConfig } from './type-extractors/types.js'; import type { CallRouter } from './call-routing.js'; import type { @@ -272,6 +288,233 @@ interface LanguageProviderConfig { /** Built-in/stdlib names that should be filtered from the call graph for this language. * Default: undefined (no language-specific filtering). */ readonly builtInNames?: ReadonlySet; + + // ══════════════════════════════════════════════════════════════════════════ + // Scope-based resolution hooks (RFC #909 — Ring 1 #911) + // + // All hooks below are OPTIONAL with safe defaults so existing providers + // continue to compile unchanged. Ring 2 (#919–#925) wires these into the + // central `ScopeExtractor` + finalize pipeline; Ring 3 per-language + // tickets implement the ones each language needs. + // + // See: https://www.notion.so/346dc50b6ed281cfaacbe480bf231d50 §5.2 + // ══════════════════════════════════════════════════════════════════════════ + + // ── Parse phase (per-capture interpretation) ─────────────────────── + + /** + * Emit scope captures from raw source. Tree-sitter-based providers run a + * `scopes.scm` query; standalone providers (COBOL) emit captures from a + * regex tagger. The return shape is parser-agnostic: the central + * `ScopeExtractor` consumes `Capture[]` without knowing which parser + * produced them. + * + * Required for any provider participating in scope-based resolution. + * Providers that have not yet migrated continue to run through the legacy + * DAG path (feature-flagged per `REGISTRY_PRIMARY_`). + * + * Default: undefined (language continues to use legacy DAG). + */ + readonly emitScopeCaptures?: ( + sourceText: string, + filePath: string, + ) => Promise; + + /** + * Interpret a raw `@import.statement` capture group into a `ParsedImport`. + * The central finalize algorithm resolves `ParsedImport.targetRaw` to a + * concrete file via `resolveImportTarget` and materializes the final + * `ImportEdge` with `targetModuleScope` / `targetDefId` filled in. + * + * Required when `emitScopeCaptures` is implemented. + */ + readonly interpretImport?: (captures: CaptureMatch) => ParsedImport | null; + + /** + * What is the implicit receiver on a Function scope? For instance methods + * this is `self`/`this`; for standalone functions it is `null`. Consulted + * by `Registry.lookup` Step 2 via the `resolveTypeRef` helper. + * + * Required for any language with method dispatch (OO semantics). + * + * Default: undefined (treated as `null` — no implicit receiver). + */ + readonly receiverBinding?: (functionScope: Scope) => TypeRef | null; + + /** + * Interpret a raw type-binding capture (parameter annotation, `self`, + * assignment with constructor RHS, …) into a `ParsedTypeBinding`. The + * central extractor attaches the resulting `TypeRef` to the appropriate + * scope's `typeBindings` map. + * + * Default: undefined (falls back to `{ boundName: captures.name, rawTypeName: captures.type, source: 'annotation' }`). + */ + readonly interpretTypeBinding?: (captures: CaptureMatch) => ParsedTypeBinding | null; + + /** + * Override the `ScopeKind` assigned to a scope capture. Use when the + * capture name alone can't resolve the kind (e.g., tree-sitter captures + * a `block` that is semantically an `Expression` in this language). + * + * Default: undefined (the central extractor uses the capture name's + * suffix — `@scope.function` → `'Function'`, etc.). + */ + readonly resolveScopeKind?: (captures: CaptureMatch) => ScopeKind | null; + + /** + * Should this scope capture materialize as a real `Scope` node? Return + * `false` to skip scope creation while still emitting declarations that + * would have gone inside (they attach to the enclosing real scope). + * + * Example: Python `if`/`for`/`while` bodies capture as `@scope.block` but + * Python has no block scope — hook returns `false` and child declarations + * lift to the enclosing function/module. + * + * Default: undefined (treated as `true` — always create). + */ + readonly shouldCreateScope?: (captures: CaptureMatch) => boolean; + + /** + * Override where a declaration's name becomes visible. By default the name + * is bound in the innermost enclosing scope; return a different `ScopeId` + * to hoist it (JS `var` → enclosing function scope; Ruby `def` inside + * `begin` → enclosing class scope). + * + * Return `null` to delegate to the central default (innermost enclosing + * scope). This matches the `X | null` convention used by the other optional + * hooks and supports partial overrides — e.g., a JS provider can return a + * hoisted scope for `var` declarations and `null` for `let`/`const`, without + * re-implementing the default lookup. + * + * **Purity:** must be a pure function of its inputs — same parameters must + * yield the same `ScopeId` (or `null`) across invocations. No closure over + * mutable state. Required so scope-tree construction stays deterministic + * across re-parses. + * + * Default: undefined (the central extractor uses `innermostScope.id`). + */ + readonly bindingScopeFor?: ( + declCapture: CaptureMatch, + innermostScope: Scope, + scopeTree: ScopeTree, + ) => ScopeId | null; + + // ── Finalize phase (cross-file + materialization) ────────────────── + + /** + * Resolve a `ParsedImport.targetRaw` expression to a concrete file path in + * the workspace. Language-specific resolution: Python relative imports, + * JS package.json + node_modules, Go module paths, Java classpath, + * COBOL COPY paths. Ports today's per-language import resolver. + * + * Required when `emitScopeCaptures` is implemented. Ring 2 PKG #922 + * provides the adapter that bridges today's resolver shape to this hook. + */ + readonly resolveImportTarget?: ( + parsedImport: ParsedImport, + workspaceIndex: WorkspaceIndex, + ) => string | null; + + /** + * Enumerate the exported names of a file — used by the finalize algorithm + * to expand `import * from M` into individual `BindingRef`s with + * `origin: 'wildcard'`. + * + * Default: undefined (central finalize walks the target file's + * `ExportMap.keys()`). + */ + readonly expandsWildcardTo?: ( + targetFile: string, + workspaceIndex: WorkspaceIndex, + ) => readonly string[]; + + /** + * Decide the scope to which a `ParsedImport` attaches. Most languages + * attach imports to the nearest enclosing `Module`/`Namespace` scope + * (the default); some languages allow local imports (Python function-local + * `from x import Y`, Rust fn-local `use`, TS dynamic `import()`) — return + * a `Function`/`Block` scope id instead. + * + * Return `null` to delegate to the central default (nearest enclosing + * `Module`/`Namespace`). This matches the `X | null` convention used by + * the other optional hooks and supports partial overrides — a provider + * that handles only specific import forms non-standardly can `return null` + * for the common cases and let the central walk handle them. + * + * **Purity:** must be a pure function of its inputs — same parameters must + * yield the same `ScopeId` (or `null`) across invocations. No closure over + * mutable state. Required so scope-tree construction stays deterministic + * across re-parses. + * + * Default: undefined (central finalize walks to the nearest enclosing + * `Module` or `Namespace` scope). + */ + readonly importOwningScope?: ( + parsedImport: ParsedImport, + innermostScope: Scope, + scopeTree: ScopeTree, + ) => ScopeId | null; + + /** + * Merge local declarations and imported bindings for a single (scope, name) + * during finalize materialization of a scope's binding table. Language- + * specific precedence: Python local hides import; TypeScript namespace + * merging keeps both; Ruby constant resolution has its own rules. + * + * Default: undefined (central finalize uses local-first-then-imports, + * deduping by `DefId`). + */ + readonly mergeBindings?: (scope: Scope, bindings: readonly BindingRef[]) => readonly BindingRef[]; + + // ── Reference-extraction phase ───────────────────────────────────── + + /** + * Classify a `@reference.call` capture as free / member / constructor / + * index. Preferred path is declarative via capture sub-tags + * (`@reference.call.free`, etc.); this hook handles the languages where + * call form can't be decided statically (Ruby bare `foo(x)` is free-or- + * member until resolved). + * + * Default: undefined (central extractor reads capture sub-tag if present; + * else treats as `'free'`). + */ + readonly classifyCallForm?: ( + captures: CaptureMatch, + enclosingScope: Scope, + ) => 'free' | 'member' | 'constructor' | 'index'; + + // ── Resolution phase (RFC §4v2) ──────────────────────────────────── + + /** + * Does a binding at this scope shadow bindings of the same name in outer + * scopes? Default: any binding shadows (standard lexical scoping). Return + * `false` for transparent-scope edge cases (Python `from x import *` + * contexts, JS `var` hoisting quirks, COBOL PARAGRAPH transparency). + * + * Consulted by `Registry.lookup` Step 1 and by `resolveTypeRef` for + * shadowing decisions during the lexical chain walk. + * + * Default: undefined (treated as `true` — any binding shadows). + */ + readonly shouldShadow?: (scope: Scope, bindings: readonly BindingRef[]) => boolean; + + /** + * Is this callable definition compatible with the given call-site arity? + * Language-specific rules: Python `*args`/`**kwargs`/defaults, JS default + * params + rest, Kotlin vararg + defaults, Ruby optional/splat/block, Go + * straight counts, Rust no-variadic-no-defaults. + * + * `'incompatible'` is a soft penalty (−0.15 per EvidenceWeights) and is + * filtered only when at least one `'compatible'` candidate exists; + * otherwise the incompatible candidate is kept with the penalty so the + * call-site still links to a best-guess target. + * + * Default: undefined (treated as `'unknown'` — no signal either way). + */ + readonly arityCompatibility?: ( + def: SymbolDefinition, + callsite: Callsite, + ) => 'compatible' | 'unknown' | 'incompatible'; } /** Runtime type — same as LanguageProviderConfig but with defaults guaranteed present. */ From 22f0beb057d8109fcad410480f2c8c183f522489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 15:32:51 +0100 Subject: [PATCH 22/46] =?UTF-8?q?feat(shared):=20shadow-mode=20diff=20+=20?= =?UTF-8?q?aggregate=20=E2=80=94=20full=20implementation=20(#918,=20RFC=20?= =?UTF-8?q?#909=20Ring=202=20SHARED)=20(#951)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the scaffold stubs with working pure-logic implementations plus unit-test coverage for both functions. Unblocks Ring 2 PKG #923 (shadow harness) to consume a concrete library instead of throwing scaffolds. gitnexus-shared/src/scope-resolution/shadow/diff.ts `diffResolutions(callsite, legacy, newResult): ShadowDiff` - [0] on each side is the top match - both empty → 'both-empty', delta [] - legacy empty only → 'only-new', delta = new top evidence - new empty only → 'only-legacy', delta = legacy top evidence - same top nodeId → 'both-agree', delta [] - different nodeIds → 'both-disagree', delta = symmetric difference of evidence kinds (legacy-only first in input order, then new-only) Evidence identity is `ResolutionEvidence.kind` — weight/note differences for the same kind do NOT produce delta entries. Rationale: the aggregator wants to know which *signals* explain a disagreement, not fluctuations in calibration values. gitnexus-shared/src/scope-resolution/shadow/aggregate.ts `aggregateDiffs(diffs, now?): ShadowParityReport` - buckets by `SupportedLanguages` - tallies agreements, evidence-breakdown (divergences only — agree and empty rows do not contribute) - parity = bothAgree / (totalCalls - bothEmpty), yields 0 (not NaN) when the denominator is 0 - perLanguage sorted alphabetically by enum value for stable output - evidenceBreakdown internally sorted by kind for stable output - overall = column-wise sum across languages - `now` parameter makes generatedAt deterministic in tests gitnexus-shared/src/index.ts Re-exports the full shadow API: diffResolutions, aggregateDiffs, and all their types (ShadowAgreement, ShadowCallsite, ShadowDiff, LanguageParityRow, ShadowParityReport). gitnexus/test/unit/shadow/diff.test.ts (13 tests) - 5 agreement outcomes - symmetric-by-kind evidence delta (disjoint, overlapping, fully-overlapping) - weight-only differences produce no delta - top-match only (ignores indices beyond [0]) - callsite passthrough - delta ordering (legacy-only first, input order preserved) gitnexus/test/unit/shadow/aggregate.test.ts (9 tests) - empty input - single language, all agree / mixed / all empty - multi-language bucketing + overall sum - alphabetical language sort - evidence breakdown scope - determinism via injected `now` + JSON round-trip identity Verification: - gitnexus-shared + gitnexus build clean (tsc + scripts/build.js) - test/unit/shadow: 22/22 pass - test/unit/model + test/unit/shadow combined: 106/106 pass - No runtime behavior changes (shadow is invoked by #923, not yet wired) Stacked on main (af1d278a). Depends on types from #910 (merged). Unblocks: #923 (Ring 2 PKG — shadow harness wiring) — concrete library to consume instead of scaffold stubs. Plan: docs/plans/2026-04-18-001-refactor-911-senior-hooks-redesign-plan.md is about #911; #918's scope is the scaffold+fill-in described in the PR description of #951. --- gitnexus-shared/src/index.ts | 10 + .../src/scope-resolution/shadow/aggregate.ts | 185 ++++++++++++++ .../src/scope-resolution/shadow/diff.ts | 126 ++++++++++ gitnexus/test/unit/shadow/aggregate.test.ts | 234 ++++++++++++++++++ gitnexus/test/unit/shadow/diff.test.ts | 188 ++++++++++++++ 5 files changed, 743 insertions(+) create mode 100644 gitnexus-shared/src/scope-resolution/shadow/aggregate.ts create mode 100644 gitnexus-shared/src/scope-resolution/shadow/diff.ts create mode 100644 gitnexus/test/unit/shadow/aggregate.test.ts create mode 100644 gitnexus/test/unit/shadow/diff.test.ts diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 5b1d67072..4dc93bc8f 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -62,3 +62,13 @@ export { isProductionLanguage, } from './scope-resolution/language-classification.js'; export type { LanguageClassification } from './scope-resolution/language-classification.js'; + +// Shadow-mode diff + aggregation (RFC §6.3; Ring 2 SHARED #918) +export { diffResolutions } from './scope-resolution/shadow/diff.js'; +export type { + ShadowAgreement, + ShadowCallsite, + ShadowDiff, +} from './scope-resolution/shadow/diff.js'; +export { aggregateDiffs } from './scope-resolution/shadow/aggregate.js'; +export type { LanguageParityRow, ShadowParityReport } from './scope-resolution/shadow/aggregate.js'; diff --git a/gitnexus-shared/src/scope-resolution/shadow/aggregate.ts b/gitnexus-shared/src/scope-resolution/shadow/aggregate.ts new file mode 100644 index 000000000..08ff25322 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/shadow/aggregate.ts @@ -0,0 +1,185 @@ +/** + * Shadow-mode aggregation — per-language parity %, per-evidence-kind + * breakdown of divergences. Consumed by the parity dashboard (RING2-PKG-5). + * + * Pure functions; no I/O. The harness persists per-run JSON; the dashboard + * reads `.gitnexus/shadow-parity/latest.json` and renders. + * + * Part of RFC #909 Ring 2 SHARED — #918. + */ + +import type { SupportedLanguages } from '../../languages.js'; +import type { ResolutionEvidence } from '../types.js'; +import type { ShadowAgreement, ShadowDiff } from './diff.js'; + +// ─── Aggregated report shape ──────────────────────────────────────────────── + +export interface LanguageParityRow { + readonly language: SupportedLanguages; + readonly totalCalls: number; + readonly bothAgree: number; + readonly onlyLegacy: number; + readonly onlyNew: number; + readonly bothDisagree: number; + readonly bothEmpty: number; + /** + * Fraction in [0, 1]. Numerator = `bothAgree`; denominator = "calls where + * at least one side resolved" = `totalCalls - bothEmpty`. + * + * When the denominator is 0 (all calls for this language were + * `both-empty`), returns 0. Callers rendering the dashboard should treat + * a 0 parity alongside `totalCalls === bothEmpty` as "no signal" rather + * than "total disagreement". + */ + readonly parity: number; + /** + * Divergence signals broken down by `ResolutionEvidence.kind`. Sourced + * from `ShadowDiff.evidenceDelta` on non-agreeing rows only — `both-agree` + * and `both-empty` do not contribute. + */ + readonly evidenceBreakdown: ReadonlyMap; +} + +export interface ShadowParityReport { + readonly generatedAt: string; // ISO 8601 + readonly perLanguage: readonly LanguageParityRow[]; + readonly overall: Omit; +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +/** + * Aggregate a stream of `ShadowDiff` records into a `ShadowParityReport`, + * bucketed by language. Pure function. + * + * - `perLanguage` rows are sorted alphabetically by `SupportedLanguages` + * value for stable JSON output (the dashboard reads + * `.gitnexus/shadow-parity/latest.json` and diffing snapshots is useful). + * - `overall` is the column-wise sum across languages. + * - `generatedAt` is injected via the `now` parameter so tests stay + * deterministic; production callers let it default to `new Date()`. + */ +export function aggregateDiffs( + diffs: readonly { readonly language: SupportedLanguages; readonly diff: ShadowDiff }[], + now: Date = new Date(), +): ShadowParityReport { + const perLanguageMap = new Map(); + + for (const { language, diff } of diffs) { + let counts = perLanguageMap.get(language); + if (!counts) { + counts = makeEmptyCounts(); + perLanguageMap.set(language, counts); + } + tallyDiff(counts, diff); + } + + const perLanguage: LanguageParityRow[] = Array.from(perLanguageMap.entries()) + .map(([language, counts]) => buildRow(language, counts)) + .sort((a, b) => a.language.localeCompare(b.language)); + + const overall = buildOverallRow(perLanguage); + + return { + generatedAt: now.toISOString(), + perLanguage, + overall, + }; +} + +// ─── Internal helpers ─────────────────────────────────────────────────────── + +interface MutableCounts { + totalCalls: number; + bothAgree: number; + onlyLegacy: number; + onlyNew: number; + bothDisagree: number; + bothEmpty: number; + evidenceBreakdown: Map; +} + +function makeEmptyCounts(): MutableCounts { + return { + totalCalls: 0, + bothAgree: 0, + onlyLegacy: 0, + onlyNew: 0, + bothDisagree: 0, + bothEmpty: 0, + evidenceBreakdown: new Map(), + }; +} + +function tallyDiff(counts: MutableCounts, diff: ShadowDiff): void { + counts.totalCalls += 1; + incrementAgreement(counts, diff.agreement); + if (diff.agreement === 'both-agree' || diff.agreement === 'both-empty') return; + for (const ev of diff.evidenceDelta) { + counts.evidenceBreakdown.set(ev.kind, (counts.evidenceBreakdown.get(ev.kind) ?? 0) + 1); + } +} + +function incrementAgreement(counts: MutableCounts, agreement: ShadowAgreement): void { + switch (agreement) { + case 'both-agree': + counts.bothAgree += 1; + return; + case 'only-legacy': + counts.onlyLegacy += 1; + return; + case 'only-new': + counts.onlyNew += 1; + return; + case 'both-disagree': + counts.bothDisagree += 1; + return; + case 'both-empty': + counts.bothEmpty += 1; + return; + } +} + +function buildRow(language: SupportedLanguages, counts: MutableCounts): LanguageParityRow { + const resolved = counts.totalCalls - counts.bothEmpty; + const parity = resolved > 0 ? counts.bothAgree / resolved : 0; + return { + language, + totalCalls: counts.totalCalls, + bothAgree: counts.bothAgree, + onlyLegacy: counts.onlyLegacy, + onlyNew: counts.onlyNew, + bothDisagree: counts.bothDisagree, + bothEmpty: counts.bothEmpty, + parity, + // Freeze via `new Map` on a sorted-kind copy so downstream consumers + // can't mutate the aggregator's internal state. + evidenceBreakdown: new Map( + Array.from(counts.evidenceBreakdown.entries()).sort(([a], [b]) => a.localeCompare(b)), + ), + }; +} + +function buildOverallRow( + perLanguage: readonly LanguageParityRow[], +): Omit { + let totalCalls = 0; + let bothAgree = 0; + let onlyLegacy = 0; + let onlyNew = 0; + let bothDisagree = 0; + let bothEmpty = 0; + for (const row of perLanguage) { + totalCalls += row.totalCalls; + bothAgree += row.bothAgree; + onlyLegacy += row.onlyLegacy; + onlyNew += row.onlyNew; + bothDisagree += row.bothDisagree; + bothEmpty += row.bothEmpty; + } + const resolved = totalCalls - bothEmpty; + const parity = resolved > 0 ? bothAgree / resolved : 0; + return { totalCalls, bothAgree, onlyLegacy, onlyNew, bothDisagree, bothEmpty, parity }; +} + +export type { ShadowAgreement, ShadowDiff }; diff --git a/gitnexus-shared/src/scope-resolution/shadow/diff.ts b/gitnexus-shared/src/scope-resolution/shadow/diff.ts new file mode 100644 index 000000000..a1c8755c6 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/shadow/diff.ts @@ -0,0 +1,126 @@ +/** + * Shadow-mode diff logic — RFC §6.3. + * + * Pure comparison logic for shadow mode. Takes two `Resolution[]` (legacy + * DAG result + new scope-based registry result) and produces a structured + * diff record for the parity dashboard. + * + * Consumed by the Ring 2 PKG shadow harness (#923), which dual-runs each + * call through legacy + new paths, diffs results, and persists per-run JSON + * for the parity dashboard. + * + * Part of RFC #909 Ring 2 SHARED — #918. + */ + +import type { Resolution, ResolutionEvidence } from '../types.js'; + +// ─── Diff record shape ────────────────────────────────────────────────────── + +export type ShadowAgreement = + | 'both-agree' // top match identical (same DefId) + | 'only-legacy' // legacy resolved; new did not + | 'only-new' // new resolved; legacy did not + | 'both-disagree' // both resolved, but to different targets + | 'both-empty'; // both returned empty + +export interface ShadowDiff { + readonly callsite: ShadowCallsite; + readonly legacy: Resolution | null; + readonly newResult: Resolution | null; + readonly agreement: ShadowAgreement; + /** + * Symmetric difference of the two top resolutions' `evidence` arrays, + * keyed on `ResolutionEvidence.kind`. + * + * - For `'both-agree'` and `'both-empty'` agreements, always empty. + * - For `'both-disagree'`, contains evidence kinds present on exactly one + * side (not in both). + * - For `'only-legacy'`, contains all of legacy's top evidence. + * - For `'only-new'`, contains all of new's top evidence. + */ + readonly evidenceDelta: readonly ResolutionEvidence[]; +} + +export interface ShadowCallsite { + readonly filePath: string; + readonly line: number; + readonly col: number; + readonly calledName: string; +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +/** + * Compare two `Resolution[]` arrays (top matches at `[0]`) and produce a + * `ShadowDiff`. Pure function. + * + * Agreement rules: + * - both arrays empty → `'both-empty'`, `evidenceDelta: []` + * - legacy empty, new non-empty → `'only-new'`, `evidenceDelta` = new's top evidence + * - legacy non-empty, new empty → `'only-legacy'`, `evidenceDelta` = legacy's top evidence + * - both non-empty, same top `def.nodeId` → `'both-agree'`, `evidenceDelta: []` + * - both non-empty, different top `def.nodeId` → `'both-disagree'`, + * `evidenceDelta` = symmetric difference by `ResolutionEvidence.kind` + * (first occurrence of a kind-only-on-legacy then kind-only-on-new; order + * preserved from input arrays) + * + * Evidence-delta rationale: callers aggregating divergences want to know + * which signal kinds explain a disagreement. Keying on `kind` (not full + * equality over `weight`/`note`) avoids spurious deltas when the same + * signal fires with slightly different calibration weights on each side. + */ +export function diffResolutions( + callsite: ShadowCallsite, + legacy: readonly Resolution[], + newResult: readonly Resolution[], +): ShadowDiff { + const legacyTop: Resolution | null = legacy.length > 0 ? legacy[0] : null; + const newTop: Resolution | null = newResult.length > 0 ? newResult[0] : null; + + const agreement: ShadowAgreement = (() => { + if (legacyTop === null && newTop === null) return 'both-empty'; + if (legacyTop === null) return 'only-new'; + if (newTop === null) return 'only-legacy'; + return legacyTop.def.nodeId === newTop.def.nodeId ? 'both-agree' : 'both-disagree'; + })(); + + const evidenceDelta = computeEvidenceDelta(legacyTop, newTop, agreement); + + return { + callsite, + legacy: legacyTop, + newResult: newTop, + agreement, + evidenceDelta, + }; +} + +// ─── Internal helpers ─────────────────────────────────────────────────────── + +/** + * Symmetric difference of two evidence arrays, keyed on + * `ResolutionEvidence.kind`. Preserves input order: legacy-only signals + * first (in legacy's original order), then new-only signals (in new's order). + * + * For `'both-agree'` / `'both-empty'` the delta is empty by contract. For + * `'only-legacy'` / `'only-new'` one side's evidence is the delta (nothing to + * subtract against). + */ +function computeEvidenceDelta( + legacy: Resolution | null, + newResult: Resolution | null, + agreement: ShadowAgreement, +): readonly ResolutionEvidence[] { + if (agreement === 'both-agree' || agreement === 'both-empty') return []; + if (agreement === 'only-legacy') return legacy!.evidence; + if (agreement === 'only-new') return newResult!.evidence; + + // both-disagree: symmetric difference keyed on `kind` + const legacyKinds = new Set(legacy!.evidence.map((e) => e.kind)); + const newKinds = new Set(newResult!.evidence.map((e) => e.kind)); + + const onlyInLegacy = legacy!.evidence.filter((e) => !newKinds.has(e.kind)); + const onlyInNew = newResult!.evidence.filter((e) => !legacyKinds.has(e.kind)); + + return [...onlyInLegacy, ...onlyInNew]; +} diff --git a/gitnexus/test/unit/shadow/aggregate.test.ts b/gitnexus/test/unit/shadow/aggregate.test.ts new file mode 100644 index 000000000..00fb44652 --- /dev/null +++ b/gitnexus/test/unit/shadow/aggregate.test.ts @@ -0,0 +1,234 @@ +/** + * Unit tests for `aggregateDiffs` (RFC #909 Ring 2 SHARED #918). + * + * Covers bucketing by language, parity math (incl. zero-resolved edge), + * evidence-kind breakdown, and stable sort order on the output rows. + */ + +import { describe, it, expect } from 'vitest'; +import { + aggregateDiffs, + SupportedLanguages, + type LanguageParityRow, + type ResolutionEvidence, + type ShadowAgreement, + type ShadowDiff, +} from 'gitnexus-shared'; + +// ─── Fixtures ─────────────────────────────────────────────────────────────── + +const FIXED_NOW = new Date('2026-04-18T12:00:00.000Z'); + +const makeDiff = ( + agreement: ShadowAgreement, + evidenceKinds: readonly ResolutionEvidence['kind'][] = [], +): ShadowDiff => ({ + callsite: { filePath: 'src/x.ts', line: 1, col: 0, calledName: 'foo' }, + legacy: null, + newResult: null, + agreement, + evidenceDelta: evidenceKinds.map((kind) => ({ kind, weight: 0.3 })), +}); + +const entry = (language: SupportedLanguages, diff: ShadowDiff) => ({ language, diff }); + +const findRow = ( + rows: readonly LanguageParityRow[], + language: SupportedLanguages, +): LanguageParityRow => { + const row = rows.find((r) => r.language === language); + if (!row) throw new Error(`no row for ${language}`); + return row; +}; + +// ─── Empty input ──────────────────────────────────────────────────────────── + +describe('aggregateDiffs — empty input', () => { + it('returns empty perLanguage, zeroed overall, generatedAt populated', () => { + const report = aggregateDiffs([], FIXED_NOW); + expect(report.perLanguage).toEqual([]); + expect(report.overall).toEqual({ + totalCalls: 0, + bothAgree: 0, + onlyLegacy: 0, + onlyNew: 0, + bothDisagree: 0, + bothEmpty: 0, + parity: 0, + }); + expect(report.generatedAt).toBe('2026-04-18T12:00:00.000Z'); + }); +}); + +// ─── Single language, single outcome ──────────────────────────────────────── + +describe('aggregateDiffs — single language', () => { + it('all both-agree → parity = 1.0', () => { + const diffs = [ + entry(SupportedLanguages.Python, makeDiff('both-agree')), + entry(SupportedLanguages.Python, makeDiff('both-agree')), + entry(SupportedLanguages.Python, makeDiff('both-agree')), + ]; + const report = aggregateDiffs(diffs, FIXED_NOW); + expect(report.perLanguage).toHaveLength(1); + const row = findRow(report.perLanguage, SupportedLanguages.Python); + expect(row).toMatchObject({ + language: SupportedLanguages.Python, + totalCalls: 3, + bothAgree: 3, + onlyLegacy: 0, + onlyNew: 0, + bothDisagree: 0, + bothEmpty: 0, + parity: 1, + }); + }); + + it('mixed outcomes → parity excludes both-empty from denominator', () => { + const diffs = [ + entry(SupportedLanguages.TypeScript, makeDiff('both-agree')), + entry(SupportedLanguages.TypeScript, makeDiff('both-agree')), + entry(SupportedLanguages.TypeScript, makeDiff('only-legacy', ['global-name'])), + entry(SupportedLanguages.TypeScript, makeDiff('only-new', ['local'])), + entry(SupportedLanguages.TypeScript, makeDiff('both-disagree', ['import'])), + entry(SupportedLanguages.TypeScript, makeDiff('both-empty')), + entry(SupportedLanguages.TypeScript, makeDiff('both-empty')), + ]; + const report = aggregateDiffs(diffs, FIXED_NOW); + const row = findRow(report.perLanguage, SupportedLanguages.TypeScript); + expect(row.totalCalls).toBe(7); + expect(row.bothAgree).toBe(2); + expect(row.onlyLegacy).toBe(1); + expect(row.onlyNew).toBe(1); + expect(row.bothDisagree).toBe(1); + expect(row.bothEmpty).toBe(2); + // parity = bothAgree / (totalCalls - bothEmpty) = 2 / (7 - 2) = 0.4 + expect(row.parity).toBeCloseTo(0.4, 10); + }); + + it('all both-empty → parity = 0 (not NaN)', () => { + const diffs = [ + entry(SupportedLanguages.Java, makeDiff('both-empty')), + entry(SupportedLanguages.Java, makeDiff('both-empty')), + ]; + const report = aggregateDiffs(diffs, FIXED_NOW); + const row = findRow(report.perLanguage, SupportedLanguages.Java); + expect(row.totalCalls).toBe(2); + expect(row.bothEmpty).toBe(2); + expect(row.parity).toBe(0); + expect(Number.isNaN(row.parity)).toBe(false); + }); +}); + +// ─── Multi-language ───────────────────────────────────────────────────────── + +describe('aggregateDiffs — multiple languages', () => { + it('buckets rows by language and sums overall column-wise', () => { + const diffs = [ + entry(SupportedLanguages.Python, makeDiff('both-agree')), + entry(SupportedLanguages.Python, makeDiff('both-disagree', ['local'])), + entry(SupportedLanguages.Ruby, makeDiff('both-agree')), + entry(SupportedLanguages.Ruby, makeDiff('both-agree')), + entry(SupportedLanguages.Ruby, makeDiff('only-new', ['type-binding'])), + ]; + const report = aggregateDiffs(diffs, FIXED_NOW); + expect(report.perLanguage).toHaveLength(2); + + const python = findRow(report.perLanguage, SupportedLanguages.Python); + expect(python.totalCalls).toBe(2); + expect(python.bothAgree).toBe(1); + expect(python.bothDisagree).toBe(1); + expect(python.parity).toBe(0.5); + + const ruby = findRow(report.perLanguage, SupportedLanguages.Ruby); + expect(ruby.totalCalls).toBe(3); + expect(ruby.bothAgree).toBe(2); + expect(ruby.onlyNew).toBe(1); + expect(ruby.parity).toBeCloseTo(2 / 3, 10); + + expect(report.overall).toEqual({ + totalCalls: 5, + bothAgree: 3, + onlyLegacy: 0, + onlyNew: 1, + bothDisagree: 1, + bothEmpty: 0, + parity: 3 / 5, + }); + }); + + it('perLanguage rows are sorted alphabetically by language value for stable output', () => { + const diffs = [ + entry(SupportedLanguages.TypeScript, makeDiff('both-agree')), + entry(SupportedLanguages.C, makeDiff('both-agree')), + entry(SupportedLanguages.Python, makeDiff('both-agree')), + entry(SupportedLanguages.Java, makeDiff('both-agree')), + ]; + const report = aggregateDiffs(diffs, FIXED_NOW); + const ordered = report.perLanguage.map((r) => r.language); + // Alphabetical by enum VALUE: 'c' < 'java' < 'python' < 'typescript' + expect(ordered).toEqual([ + SupportedLanguages.C, + SupportedLanguages.Java, + SupportedLanguages.Python, + SupportedLanguages.TypeScript, + ]); + }); +}); + +// ─── Evidence breakdown ───────────────────────────────────────────────────── + +describe('aggregateDiffs — evidence breakdown', () => { + it('counts divergence evidence kinds across non-agreeing rows only', () => { + const diffs = [ + entry(SupportedLanguages.Go, makeDiff('both-disagree', ['import', 'owner-match'])), + entry(SupportedLanguages.Go, makeDiff('only-legacy', ['import', 'global-name'])), + entry(SupportedLanguages.Go, makeDiff('only-new', ['local'])), + // both-agree contributes 0 to evidence breakdown regardless of any attached evidence + entry(SupportedLanguages.Go, makeDiff('both-agree', ['import'])), + // both-empty also contributes 0 + entry(SupportedLanguages.Go, makeDiff('both-empty')), + ]; + const report = aggregateDiffs(diffs, FIXED_NOW); + const row = findRow(report.perLanguage, SupportedLanguages.Go); + expect(Array.from(row.evidenceBreakdown.entries())).toEqual([ + ['global-name', 1], + ['import', 2], + ['local', 1], + ['owner-match', 1], + ]); + }); + + it('emits empty evidenceBreakdown when all calls agree or are empty', () => { + const diffs = [ + entry(SupportedLanguages.Rust, makeDiff('both-agree')), + entry(SupportedLanguages.Rust, makeDiff('both-empty')), + ]; + const report = aggregateDiffs(diffs, FIXED_NOW); + const row = findRow(report.perLanguage, SupportedLanguages.Rust); + expect(row.evidenceBreakdown.size).toBe(0); + }); +}); + +// ─── Determinism ──────────────────────────────────────────────────────────── + +describe('aggregateDiffs — determinism', () => { + it('injected `now` is used verbatim for generatedAt', () => { + const t = new Date('2030-01-01T00:00:00.000Z'); + const report = aggregateDiffs([], t); + expect(report.generatedAt).toBe('2030-01-01T00:00:00.000Z'); + }); + + it('same input produces byte-identical JSON (stable keys + sort)', () => { + const diffs = [ + entry(SupportedLanguages.Python, makeDiff('both-disagree', ['local', 'import'])), + entry(SupportedLanguages.Java, makeDiff('both-agree')), + ]; + const a = aggregateDiffs(diffs, FIXED_NOW); + const b = aggregateDiffs(diffs, FIXED_NOW); + // Round-trip through JSON to drop Map identity and force structural comparison. + const toJson = (r: typeof a): string => + JSON.stringify(r, (_key, v: unknown) => (v instanceof Map ? Object.fromEntries(v) : v)); + expect(toJson(a)).toBe(toJson(b)); + }); +}); diff --git a/gitnexus/test/unit/shadow/diff.test.ts b/gitnexus/test/unit/shadow/diff.test.ts new file mode 100644 index 000000000..700f08b91 --- /dev/null +++ b/gitnexus/test/unit/shadow/diff.test.ts @@ -0,0 +1,188 @@ +/** + * Unit tests for `diffResolutions` (RFC #909 Ring 2 SHARED #918). + * + * Pins the 5 `ShadowAgreement` outcomes and the symmetric-by-kind evidence- + * delta contract. Inputs are pure data fixtures — no real pipeline state. + */ + +import { describe, it, expect } from 'vitest'; +import { + diffResolutions, + type Resolution, + type ResolutionEvidence, + type ShadowCallsite, + type SymbolDefinition, +} from 'gitnexus-shared'; + +// ─── Fixtures ─────────────────────────────────────────────────────────────── + +const callsite: ShadowCallsite = { + filePath: 'src/app.ts', + line: 42, + col: 8, + calledName: 'save', +}; + +const makeDef = (nodeId: string): SymbolDefinition => ({ + nodeId, + filePath: 'src/models.ts', + type: 'Method', +}); + +const makeEvidence = (kind: ResolutionEvidence['kind'], weight = 0.5): ResolutionEvidence => ({ + kind, + weight, +}); + +const makeResolution = ( + nodeId: string, + evidenceKinds: readonly ResolutionEvidence['kind'][], +): Resolution => ({ + def: makeDef(nodeId), + confidence: Math.min(1, evidenceKinds.length * 0.3), + evidence: evidenceKinds.map((k) => makeEvidence(k)), +}); + +// ─── Agreement outcomes ───────────────────────────────────────────────────── + +describe('diffResolutions — agreement outcomes', () => { + it("both arrays empty → 'both-empty' with no evidence delta", () => { + const result = diffResolutions(callsite, [], []); + expect(result.agreement).toBe('both-empty'); + expect(result.evidenceDelta).toEqual([]); + expect(result.legacy).toBeNull(); + expect(result.newResult).toBeNull(); + }); + + it("identical top DefIds → 'both-agree' with empty evidence delta", () => { + const legacy = [makeResolution('def:User.save', ['local', 'owner-match'])]; + const next = [makeResolution('def:User.save', ['local', 'kind-match'])]; + const result = diffResolutions(callsite, legacy, next); + expect(result.agreement).toBe('both-agree'); + expect(result.evidenceDelta).toEqual([]); + expect(result.legacy).toBe(legacy[0]); + expect(result.newResult).toBe(next[0]); + }); + + it("legacy empty, new non-empty → 'only-new' with new's evidence as delta", () => { + const next = [makeResolution('def:User.save', ['local', 'owner-match'])]; + const result = diffResolutions(callsite, [], next); + expect(result.agreement).toBe('only-new'); + expect(result.evidenceDelta).toEqual(next[0].evidence); + expect(result.legacy).toBeNull(); + expect(result.newResult).toBe(next[0]); + }); + + it("legacy non-empty, new empty → 'only-legacy' with legacy's evidence as delta", () => { + const legacy = [makeResolution('def:User.save', ['global-name'])]; + const result = diffResolutions(callsite, legacy, []); + expect(result.agreement).toBe('only-legacy'); + expect(result.evidenceDelta).toEqual(legacy[0].evidence); + expect(result.legacy).toBe(legacy[0]); + expect(result.newResult).toBeNull(); + }); + + it("different top DefIds → 'both-disagree'", () => { + const legacy = [makeResolution('def:ModelA.save', ['global-name'])]; + const next = [makeResolution('def:ModelB.save', ['local'])]; + const result = diffResolutions(callsite, legacy, next); + expect(result.agreement).toBe('both-disagree'); + expect(result.legacy).toBe(legacy[0]); + expect(result.newResult).toBe(next[0]); + }); +}); + +// ─── Evidence delta — symmetric difference by `kind` ──────────────────────── + +describe('diffResolutions — evidence delta (symmetric-by-kind)', () => { + it("'both-disagree' with disjoint evidence → delta contains both sides' kinds", () => { + const legacy = [makeResolution('def:A', ['global-name'])]; + const next = [makeResolution('def:B', ['local', 'owner-match'])]; + const result = diffResolutions(callsite, legacy, next); + expect(result.evidenceDelta.map((e) => e.kind)).toEqual([ + 'global-name', + 'local', + 'owner-match', + ]); + }); + + it("'both-disagree' with overlapping kinds → overlapping kinds removed from delta", () => { + const legacy = [makeResolution('def:A', ['local', 'scope-chain', 'global-name'])]; + const next = [makeResolution('def:B', ['local', 'import', 'owner-match'])]; + const result = diffResolutions(callsite, legacy, next); + // 'local' is on both sides → dropped + // Remaining: legacy-only ['scope-chain', 'global-name'], then new-only ['import', 'owner-match'] + expect(result.evidenceDelta.map((e) => e.kind)).toEqual([ + 'scope-chain', + 'global-name', + 'import', + 'owner-match', + ]); + }); + + it("'both-disagree' with fully overlapping kinds → empty evidence delta", () => { + const legacy = [makeResolution('def:A', ['local', 'owner-match'])]; + const next = [makeResolution('def:B', ['owner-match', 'local'])]; + const result = diffResolutions(callsite, legacy, next); + // Same kind set, different order → symmetric difference is empty + expect(result.evidenceDelta).toEqual([]); + expect(result.agreement).toBe('both-disagree'); // agreement still disagrees because nodeIds differ + }); + + it('differing weights on the same kind → NOT a delta (keyed on kind only)', () => { + const legacy = [ + { + def: makeDef('def:A'), + confidence: 0.9, + evidence: [{ kind: 'local' as const, weight: 0.55 }], + }, + ]; + const next = [ + { + def: makeDef('def:B'), + confidence: 0.1, + evidence: [{ kind: 'local' as const, weight: 0.25 }], + }, + ]; + const result = diffResolutions(callsite, legacy, next); + expect(result.agreement).toBe('both-disagree'); + expect(result.evidenceDelta).toEqual([]); + }); +}); + +// ─── Metadata + ordering ──────────────────────────────────────────────────── + +describe('diffResolutions — metadata + ordering', () => { + it('ignores resolutions beyond index 0 (top match only)', () => { + const legacy = [ + makeResolution('def:User.save', ['local']), + makeResolution('def:other', ['global-name']), + ]; + const next = [ + makeResolution('def:User.save', ['local']), + makeResolution('def:yet-another', ['wildcard']), + ]; + const result = diffResolutions(callsite, legacy, next); + expect(result.agreement).toBe('both-agree'); + }); + + it('preserves callsite verbatim', () => { + const result = diffResolutions(callsite, [], []); + expect(result.callsite).toBe(callsite); + }); + + it("'both-disagree' delta order: legacy-only first (input order), then new-only", () => { + const legacy = [makeResolution('def:A', ['owner-match', 'scope-chain', 'kind-match'])]; + const next = [makeResolution('def:B', ['import', 'owner-match', 'arity-match'])]; + const result = diffResolutions(callsite, legacy, next); + // 'owner-match' overlaps → dropped + // legacy-only in original order: ['scope-chain', 'kind-match'] + // then new-only in original order: ['import', 'arity-match'] + expect(result.evidenceDelta.map((e) => e.kind)).toEqual([ + 'scope-chain', + 'kind-match', + 'import', + 'arity-match', + ]); + }); +}); From f73389eac3eb8a12f41d0dd73643c158b29972a2 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 15:58:31 +0100 Subject: [PATCH 23/46] fix: ENOBUFS in detect_changes by setting maxBuffer on git/rg execFileSync (#957) * Initial plan * Fix ENOBUFS in detect_changes by setting maxBuffer on git/rg execFileSync Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/bb241ed0-3b39-431f-a242-b0c7ced9707b Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- gitnexus/src/mcp/local/local-backend.ts | 11 ++++- .../test/unit/local-backend-maxbuffer.test.ts | 48 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 gitnexus/test/unit/local-backend-maxbuffer.test.ts diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index fe63ee1a2..0175928f6 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -1765,7 +1765,14 @@ export class LocalBackend { let diffOutput: string; try { - diffOutput = execFileSync('git', diffArgs, { cwd: repo.repoPath, encoding: 'utf-8' }); + // maxBuffer raised from Node's 1MB default to 256MB to avoid ENOBUFS on + // repos with large unstaged/untracked diffs (e.g. unignored build folders). + // See issue: spawnSync git ENOBUFS in detect_changes(scope="unstaged"). + diffOutput = execFileSync('git', diffArgs, { + cwd: repo.repoPath, + encoding: 'utf-8', + maxBuffer: 256 * 1024 * 1024, + }); } catch (err: any) { return { error: `Git diff failed: ${err.message}` }; } @@ -2039,6 +2046,8 @@ export class LocalBackend { cwd: repo.repoPath, encoding: 'utf-8', timeout: 5000, + // Avoid ENOBUFS on large repos: rg -l can list many files. + maxBuffer: 256 * 1024 * 1024, }); const files = output .trim() diff --git a/gitnexus/test/unit/local-backend-maxbuffer.test.ts b/gitnexus/test/unit/local-backend-maxbuffer.test.ts new file mode 100644 index 000000000..f67675021 --- /dev/null +++ b/gitnexus/test/unit/local-backend-maxbuffer.test.ts @@ -0,0 +1,48 @@ +/** + * Source-code regression: ENOBUFS on large git/rg output. + * + * Node's default maxBuffer for execFileSync is 1 MB, which is easily exceeded + * by `git diff` on repos with large unstaged changes (e.g. unignored build + * folders) — see the original bug report: + * + * "spawnSync git ENOBUFS in gitnexus_detect_changes(scope=\"unstaged\") + * due to missing maxBuffer". + * + * Every `execFileSync` call in `local-backend.ts` that captures stdout + * (i.e. sets `encoding`) MUST pass an explicit `maxBuffer`. This test is a + * lightweight static guard so the regression cannot silently come back. + * + * Kept as a standalone file (no LocalBackend import) so it does not depend + * on the LadybugDB native binding being available in the test environment. + */ +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +const SOURCE_PATH = path.join(__dirname, '../../src/mcp/local/local-backend.ts'); + +describe('local-backend: execFileSync maxBuffer regression', () => { + const source = fs.readFileSync(SOURCE_PATH, 'utf-8'); + + it('every stdout-capturing execFileSync call passes maxBuffer', () => { + // Match each `execFileSync(...)` call. The local-backend.ts call sites use + // a single trailing options object literal, so a non-greedy match up to the + // closing `)` of the statement is sufficient. + const callRe = /execFileSync\s*\(([\s\S]*?)\)\s*;/g; + const offenders: string[] = []; + let match: RegExpExecArray | null; + while ((match = callRe.exec(source)) !== null) { + const args = match[1]; + // Only stdout-capturing calls (encoding set) are at risk of ENOBUFS. + if (!/encoding\s*:/.test(args)) continue; + if (!/maxBuffer\s*:/.test(args)) { + const lineNo = source.slice(0, match.index).split('\n').length; + offenders.push(`line ${lineNo}: ${args.replace(/\s+/g, ' ').slice(0, 160)}`); + } + } + expect( + offenders, + `execFileSync calls missing explicit maxBuffer (ENOBUFS risk):\n${offenders.join('\n')}`, + ).toEqual([]); + }); +}); From ac2012e5ed10ae93a4792f60a9cd8746dc35e5c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 15:59:34 +0100 Subject: [PATCH 24/46] feat(shared): DefIndex / ModuleScopeIndex / QualifiedNameIndex (#913, RFC #909 Ring 2 SHARED) (#958) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three flat O(1) indexes + pure build functions over per-file artifacts. Contract-only; no runtime behavior change yet — consumers (#917 Registry lookups, #915 SCC finalize, #919 ScopeExtractor) wire in later. Each index follows the same shape: - build function: flat input list → frozen immutable index - public interface: readonly Map + get/has/size accessors - first-write-wins on id/filePath collisions (upstream bug signal) - pure, side-effect-free, safe to call repeatedly DefIndex — the global "what is this id?" lookup gitnexus-shared/src/scope-resolution/def-index.ts buildDefIndex(defs: readonly SymbolDefinition[]): DefIndex byId: ReadonlyMap Consumed by Registry.lookup (#917) to materialize DefId[] hits back to full SymbolDefinition records. ModuleScopeIndex — `filePath → moduleScopeId` for cross-file hops gitnexus-shared/src/scope-resolution/module-scope-index.ts buildModuleScopeIndex(entries): ModuleScopeIndex byFilePath: ReadonlyMap Consumed by the SCC finalize link pass (#915) to resolve ImportEdge.targetFile to a concrete module scope in constant time. QualifiedNameIndex — cross-kind qualified-name fast path gitnexus-shared/src/scope-resolution/qualified-name-index.ts buildQualifiedNameIndex(defs: readonly SymbolDefinition[]): QualifiedNameIndex byQualifiedName: ReadonlyMap Returns DefId[] (not a single DefId) because partial classes, method overloads, and cross-kind collisions can legitimately share a qualifiedName. Callers filter by acceptedKinds at the lookup site. Consumed by Registry.lookup qualified fast path + resolveTypeRef dotted fallback (#916, #917). Barrel re-exports added to gitnexus-shared/src/index.ts so consumers import from 'gitnexus-shared' rather than deep paths. Tests (gitnexus/test/unit/scope-resolution/, 23 total): def-index.test.ts (6): empty, single def, multiple distinct, first-write-wins collision, missing id returns undefined, byId direct iteration module-scope-index.test.ts (6): empty, single entry, multiple files, first-write-wins on duplicate filePath, missing returns undefined, byFilePath direct iteration qualified-name-index.test.ts (11): empty, single qnamed def, partial classes accumulate, input-order preservation, qname separation, skip undefined/empty qname, pair dedup, cross-kind indexing, frozen-empty-array on miss, direct iteration Verification: - gitnexus-shared + gitnexus build clean (tsc + scripts/build.js) - test/unit/scope-resolution: 23/23 pass - model + shadow + scope-resolution combined: 129/129 pass - No runtime consumer wiring yet — indexes are standalone library functions that #915, #917, #919 will import when ready Depends on #910 (SymbolDefinition, DefId, ScopeId types — already on main). Unblocks #915 (finalize algorithm), #917 (Registry.lookup), #919 (ScopeExtractor materialization). --- gitnexus-shared/src/index.ts | 8 ++ .../src/scope-resolution/def-index.ts | 62 +++++++++ .../scope-resolution/module-scope-index.ts | 65 ++++++++++ .../scope-resolution/qualified-name-index.ts | 92 +++++++++++++ .../unit/scope-resolution/def-index.test.ts | 69 ++++++++++ .../module-scope-index.test.ts | 62 +++++++++ .../qualified-name-index.test.ts | 122 ++++++++++++++++++ 7 files changed, 480 insertions(+) create mode 100644 gitnexus-shared/src/scope-resolution/def-index.ts create mode 100644 gitnexus-shared/src/scope-resolution/module-scope-index.ts create mode 100644 gitnexus-shared/src/scope-resolution/qualified-name-index.ts create mode 100644 gitnexus/test/unit/scope-resolution/def-index.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/module-scope-index.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/qualified-name-index.test.ts diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 4dc93bc8f..0f4bf8e29 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -63,6 +63,14 @@ export { } from './scope-resolution/language-classification.js'; export type { LanguageClassification } from './scope-resolution/language-classification.js'; +// Core indexes over per-file artifacts (RFC §3.1; Ring 2 SHARED #913) +export { buildDefIndex } from './scope-resolution/def-index.js'; +export type { DefIndex } from './scope-resolution/def-index.js'; +export { buildModuleScopeIndex } from './scope-resolution/module-scope-index.js'; +export type { ModuleScopeIndex, ModuleScopeEntry } from './scope-resolution/module-scope-index.js'; +export { buildQualifiedNameIndex } from './scope-resolution/qualified-name-index.js'; +export type { QualifiedNameIndex } from './scope-resolution/qualified-name-index.js'; + // Shadow-mode diff + aggregation (RFC §6.3; Ring 2 SHARED #918) export { diffResolutions } from './scope-resolution/shadow/diff.js'; export type { diff --git a/gitnexus-shared/src/scope-resolution/def-index.ts b/gitnexus-shared/src/scope-resolution/def-index.ts new file mode 100644 index 000000000..bc27f773e --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/def-index.ts @@ -0,0 +1,62 @@ +/** + * `DefIndex` — O(1) `DefId → SymbolDefinition` materialization. + * + * The global "what is this id?" lookup. Every per-kind registry (ClassRegistry, + * MethodRegistry, FieldRegistry) returns `DefId[]` and resolves them back to + * full `SymbolDefinition` records through this index — one central hash map, + * one allocation per def. + * + * Part of RFC #909 Ring 2 SHARED — #913. + * + * Consumed by: #917 (`Registry.lookup` implementations), #915 (SCC finalize). + */ + +import type { SymbolDefinition } from './symbol-definition.js'; +import type { DefId } from './types.js'; + +export interface DefIndex { + readonly byId: ReadonlyMap; + readonly size: number; + get(id: DefId): SymbolDefinition | undefined; + has(id: DefId): boolean; +} + +/** + * Build a `DefIndex` from a flat list of `SymbolDefinition` records. + * + * **Collision policy: first-write-wins.** `DefId` is meant to be unique + * (`nodeId` is the stable graph identifier), so a collision indicates an + * upstream bug — most likely the same symbol parsed twice or a duplicate + * commit into the pipeline. Rather than silently overwriting with a later + * definition that may be partial or wrong, the first record wins and + * subsequent records for the same id are dropped. Pipeline bugs surface + * later as `has(id) === true` but the def looking older than expected, + * which is easier to debug than a silent overwrite. + * + * Pure function — safe to call repeatedly; no side effects. + */ +export function buildDefIndex(defs: readonly SymbolDefinition[]): DefIndex { + const byId = new Map(); + for (const def of defs) { + if (byId.has(def.nodeId)) continue; // first-write-wins + byId.set(def.nodeId, def); + } + return freezeIndex(byId); +} + +// ─── Internal ─────────────────────────────────────────────────────────────── + +function freezeIndex(byId: Map): DefIndex { + return { + byId, + get size() { + return byId.size; + }, + get(id: DefId): SymbolDefinition | undefined { + return byId.get(id); + }, + has(id: DefId): boolean { + return byId.has(id); + }, + }; +} diff --git a/gitnexus-shared/src/scope-resolution/module-scope-index.ts b/gitnexus-shared/src/scope-resolution/module-scope-index.ts new file mode 100644 index 000000000..a71c5d3d7 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/module-scope-index.ts @@ -0,0 +1,65 @@ +/** + * `ModuleScopeIndex` — O(1) `filePath → moduleScopeId` lookup. + * + * Every file parsed produces exactly one `Module` scope at its root. The + * finalize algorithm needs to resolve `ImportEdge.targetFile` to a concrete + * module scope id in constant time during the link pass; this index is that + * mapping. + * + * Part of RFC #909 Ring 2 SHARED — #913. + * + * Consumed by: #915 (SCC finalize link pass), #923 (shadow harness when + * resolving callsite file → enclosing module). + */ + +import type { ScopeId } from './types.js'; + +export interface ModuleScopeIndex { + readonly byFilePath: ReadonlyMap; + readonly size: number; + get(filePath: string): ScopeId | undefined; + has(filePath: string): boolean; +} + +export interface ModuleScopeEntry { + readonly filePath: string; + readonly moduleScopeId: ScopeId; +} + +/** + * Build a `ModuleScopeIndex` from a flat list of `{ filePath, moduleScopeId }` + * pairs. + * + * **Collision policy: first-write-wins.** A file should appear exactly once + * in a single ingestion run; collisions indicate the same file was parsed + * twice or a `filePath` normalization bug upstream. Dropping the later + * entry preserves the first-stable id the rest of the pipeline may already + * have registered against. + * + * Pure function — safe to call repeatedly; no side effects. + */ +export function buildModuleScopeIndex(entries: readonly ModuleScopeEntry[]): ModuleScopeIndex { + const byFilePath = new Map(); + for (const { filePath, moduleScopeId } of entries) { + if (byFilePath.has(filePath)) continue; // first-write-wins + byFilePath.set(filePath, moduleScopeId); + } + return freezeIndex(byFilePath); +} + +// ─── Internal ─────────────────────────────────────────────────────────────── + +function freezeIndex(byFilePath: Map): ModuleScopeIndex { + return { + byFilePath, + get size() { + return byFilePath.size; + }, + get(filePath: string): ScopeId | undefined { + return byFilePath.get(filePath); + }, + has(filePath: string): boolean { + return byFilePath.has(filePath); + }, + }; +} diff --git a/gitnexus-shared/src/scope-resolution/qualified-name-index.ts b/gitnexus-shared/src/scope-resolution/qualified-name-index.ts new file mode 100644 index 000000000..64dbd9630 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/qualified-name-index.ts @@ -0,0 +1,92 @@ +/** + * `QualifiedNameIndex` — O(1) `qualifiedName → DefId[]` lookup across all kinds. + * + * Cross-kind fast path for qualified-name resolution + * (`lookupQualified(qname, scope, params)` in RFC §4.5). Class, method, + * field, and namespace defs all contribute to a single index here; consumers + * filter the returned `DefId[]` by `p.acceptedKinds` at the call site. + * + * Returns `DefId[]` (not a single `DefId`) because multiple defs can legally + * share a qualified name — partial classes in C#, method overloads, or + * accidental cross-kind collisions. The lookup caller filters to the expected + * kind(s) and ranks the survivors. + * + * Part of RFC #909 Ring 2 SHARED — #913. + * + * Consumed by: #917 (`Registry.lookup` qualified fast path, `resolveTypeRef` + * dotted fallback via #916). + */ + +import type { SymbolDefinition } from './symbol-definition.js'; +import type { DefId } from './types.js'; + +export interface QualifiedNameIndex { + readonly byQualifiedName: ReadonlyMap; + readonly size: number; + /** Returns all `DefId`s registered under this qualified name; empty frozen + * array on miss so callers can iterate without null checks. */ + get(qualifiedName: string): readonly DefId[]; + has(qualifiedName: string): boolean; +} + +/** + * Build a `QualifiedNameIndex` from a flat list of `SymbolDefinition` records. + * + * Only defs with a non-empty `qualifiedName` contribute; defs without one are + * silently skipped (not every kind carries a qualified name — anonymous or + * top-level symbols, dynamic-unresolved imports, etc.). + * + * **Duplicate policy: appended in input order.** Each unique `(qname, DefId)` + * pair contributes at most once — repeated entries for the same pair are + * deduplicated. Distinct `DefId`s sharing a `qname` accumulate in insertion + * order (stable output for deterministic lookup ranking at the call site). + * + * Pure function — safe to call repeatedly; no side effects. + */ +export function buildQualifiedNameIndex(defs: readonly SymbolDefinition[]): QualifiedNameIndex { + const byQualifiedName = new Map(); + const seenPairs = new Set(); + + for (const def of defs) { + const qname = def.qualifiedName; + if (qname === undefined || qname.length === 0) continue; + + const pairKey = `${qname}\0${def.nodeId}`; + if (seenPairs.has(pairKey)) continue; + seenPairs.add(pairKey); + + const bucket = byQualifiedName.get(qname); + if (bucket === undefined) { + byQualifiedName.set(qname, [def.nodeId]); + } else { + bucket.push(def.nodeId); + } + } + + // Freeze bucket arrays so consumers can't mutate the index. + const frozen = new Map(); + for (const [k, v] of byQualifiedName) { + frozen.set(k, Object.freeze(v.slice())); + } + + return freezeIndex(frozen); +} + +// ─── Internal ─────────────────────────────────────────────────────────────── + +const EMPTY: readonly DefId[] = Object.freeze([]); + +function freezeIndex(byQualifiedName: Map): QualifiedNameIndex { + return { + byQualifiedName, + get size() { + return byQualifiedName.size; + }, + get(qualifiedName: string): readonly DefId[] { + return byQualifiedName.get(qualifiedName) ?? EMPTY; + }, + has(qualifiedName: string): boolean { + return byQualifiedName.has(qualifiedName); + }, + }; +} diff --git a/gitnexus/test/unit/scope-resolution/def-index.test.ts b/gitnexus/test/unit/scope-resolution/def-index.test.ts new file mode 100644 index 000000000..3b13d4c2d --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/def-index.test.ts @@ -0,0 +1,69 @@ +/** + * Unit tests for `buildDefIndex` / `DefIndex` (RFC #909 Ring 2 SHARED #913). + * + * Covers: build-from-list, O(1) lookup contract, first-write-wins on + * duplicate `nodeId`, readonly surface. + */ + +import { describe, it, expect } from 'vitest'; +import { buildDefIndex, type SymbolDefinition } from 'gitnexus-shared'; + +const makeDef = (overrides: Partial = {}): SymbolDefinition => ({ + nodeId: 'def:test', + filePath: 'src/test.ts', + type: 'Method', + ...overrides, +}); + +describe('buildDefIndex', () => { + it('builds an empty index from an empty input', () => { + const idx = buildDefIndex([]); + expect(idx.size).toBe(0); + expect(idx.get('anything')).toBeUndefined(); + expect(idx.has('anything')).toBe(false); + }); + + it('stores a single def and round-trips by nodeId', () => { + const def = makeDef({ nodeId: 'def:User.save' }); + const idx = buildDefIndex([def]); + expect(idx.size).toBe(1); + expect(idx.has('def:User.save')).toBe(true); + expect(idx.get('def:User.save')).toBe(def); // reference identity + }); + + it('stores multiple defs under their distinct ids', () => { + const a = makeDef({ nodeId: 'def:A' }); + const b = makeDef({ nodeId: 'def:B' }); + const c = makeDef({ nodeId: 'def:C' }); + const idx = buildDefIndex([a, b, c]); + expect(idx.size).toBe(3); + expect(idx.get('def:A')).toBe(a); + expect(idx.get('def:B')).toBe(b); + expect(idx.get('def:C')).toBe(c); + }); + + it('first-write-wins on duplicate nodeId', () => { + const first = makeDef({ nodeId: 'def:dup', returnType: 'Original' }); + const second = makeDef({ nodeId: 'def:dup', returnType: 'Shadow' }); + const idx = buildDefIndex([first, second]); + expect(idx.size).toBe(1); + expect(idx.get('def:dup')).toBe(first); + expect(idx.get('def:dup')?.returnType).toBe('Original'); + }); + + it("returns undefined for a missing id (doesn't throw)", () => { + const idx = buildDefIndex([makeDef({ nodeId: 'def:A' })]); + expect(idx.get('def:missing')).toBeUndefined(); + expect(idx.has('def:missing')).toBe(false); + }); + + it('exposes byId as the underlying read-only Map for direct iteration', () => { + const a = makeDef({ nodeId: 'def:A' }); + const b = makeDef({ nodeId: 'def:B' }); + const idx = buildDefIndex([a, b]); + const entries = Array.from(idx.byId.entries()) + .map(([id]) => id) + .sort(); + expect(entries).toEqual(['def:A', 'def:B']); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/module-scope-index.test.ts b/gitnexus/test/unit/scope-resolution/module-scope-index.test.ts new file mode 100644 index 000000000..08bc29ac1 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/module-scope-index.test.ts @@ -0,0 +1,62 @@ +/** + * Unit tests for `buildModuleScopeIndex` / `ModuleScopeIndex` + * (RFC #909 Ring 2 SHARED #913). + */ + +import { describe, it, expect } from 'vitest'; +import { buildModuleScopeIndex, type ModuleScopeEntry, type ScopeId } from 'gitnexus-shared'; + +const entry = (filePath: string, moduleScopeId: ScopeId): ModuleScopeEntry => ({ + filePath, + moduleScopeId, +}); + +describe('buildModuleScopeIndex', () => { + it('builds an empty index from no entries', () => { + const idx = buildModuleScopeIndex([]); + expect(idx.size).toBe(0); + expect(idx.get('src/app.ts')).toBeUndefined(); + expect(idx.has('src/app.ts')).toBe(false); + }); + + it('round-trips a single entry', () => { + const idx = buildModuleScopeIndex([entry('src/app.ts', 'scope:src/app.ts#1:0-100:0:Module')]); + expect(idx.size).toBe(1); + expect(idx.has('src/app.ts')).toBe(true); + expect(idx.get('src/app.ts')).toBe('scope:src/app.ts#1:0-100:0:Module'); + }); + + it('stores distinct files under their own scopes', () => { + const entries: ModuleScopeEntry[] = [ + entry('src/a.ts', 'scope:a'), + entry('src/b.ts', 'scope:b'), + entry('src/c.ts', 'scope:c'), + ]; + const idx = buildModuleScopeIndex(entries); + expect(idx.size).toBe(3); + expect(idx.get('src/a.ts')).toBe('scope:a'); + expect(idx.get('src/b.ts')).toBe('scope:b'); + expect(idx.get('src/c.ts')).toBe('scope:c'); + }); + + it('first-write-wins when the same filePath appears twice', () => { + const idx = buildModuleScopeIndex([ + entry('src/app.ts', 'scope:first'), + entry('src/app.ts', 'scope:second'), + ]); + expect(idx.size).toBe(1); + expect(idx.get('src/app.ts')).toBe('scope:first'); + }); + + it('returns undefined for a missing filePath (no throw)', () => { + const idx = buildModuleScopeIndex([entry('src/a.ts', 'scope:a')]); + expect(idx.get('src/missing.ts')).toBeUndefined(); + expect(idx.has('src/missing.ts')).toBe(false); + }); + + it('exposes byFilePath as the underlying read-only Map', () => { + const idx = buildModuleScopeIndex([entry('src/a.ts', 'scope:a'), entry('src/b.ts', 'scope:b')]); + const paths = Array.from(idx.byFilePath.keys()).sort(); + expect(paths).toEqual(['src/a.ts', 'src/b.ts']); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/qualified-name-index.test.ts b/gitnexus/test/unit/scope-resolution/qualified-name-index.test.ts new file mode 100644 index 000000000..77fc5ecc7 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/qualified-name-index.test.ts @@ -0,0 +1,122 @@ +/** + * Unit tests for `buildQualifiedNameIndex` / `QualifiedNameIndex` + * (RFC #909 Ring 2 SHARED #913). + * + * Covers: per-kind accumulation, multi-def-per-qname (partial classes / + * overloads), skipping defs without a qualifiedName, duplicate-pair dedup, + * and empty-bucket iteration guarantee. + */ + +import { describe, it, expect } from 'vitest'; +import { buildQualifiedNameIndex, type SymbolDefinition } from 'gitnexus-shared'; + +const makeDef = (overrides: Partial = {}): SymbolDefinition => ({ + nodeId: 'def:test', + filePath: 'src/test.ts', + type: 'Class', + ...overrides, +}); + +describe('buildQualifiedNameIndex', () => { + it('builds an empty index from no defs', () => { + const idx = buildQualifiedNameIndex([]); + expect(idx.size).toBe(0); + expect(idx.get('anything')).toEqual([]); + expect(idx.has('anything')).toBe(false); + }); + + it('indexes a single qualified-named def', () => { + const def = makeDef({ nodeId: 'def:app.User', qualifiedName: 'app.User' }); + const idx = buildQualifiedNameIndex([def]); + expect(idx.size).toBe(1); + expect(idx.has('app.User')).toBe(true); + expect(idx.get('app.User')).toEqual(['def:app.User']); + }); + + it('accumulates distinct DefIds under the same qualified name (partial classes)', () => { + // C# partial classes: same qname, different files/nodeIds + const a = makeDef({ + nodeId: 'def:app.User:Core', + qualifiedName: 'app.User', + filePath: 'src/User.Core.cs', + }); + const b = makeDef({ + nodeId: 'def:app.User:Api', + qualifiedName: 'app.User', + filePath: 'src/User.Api.cs', + }); + const idx = buildQualifiedNameIndex([a, b]); + expect(idx.get('app.User')).toEqual(['def:app.User:Core', 'def:app.User:Api']); + }); + + it('preserves input order in the bucket', () => { + const a = makeDef({ nodeId: 'def:a', qualifiedName: 'app.Foo' }); + const b = makeDef({ nodeId: 'def:b', qualifiedName: 'app.Foo' }); + const c = makeDef({ nodeId: 'def:c', qualifiedName: 'app.Foo' }); + const idx = buildQualifiedNameIndex([c, a, b]); + expect(idx.get('app.Foo')).toEqual(['def:c', 'def:a', 'def:b']); + }); + + it('separates defs that share a simple name but differ in qualifiedName', () => { + const appUser = makeDef({ nodeId: 'def:app.User', qualifiedName: 'app.User' }); + const adminUser = makeDef({ nodeId: 'def:admin.User', qualifiedName: 'admin.User' }); + const idx = buildQualifiedNameIndex([appUser, adminUser]); + expect(idx.get('app.User')).toEqual(['def:app.User']); + expect(idx.get('admin.User')).toEqual(['def:admin.User']); + }); + + it('skips defs that have no qualifiedName', () => { + const qnamed = makeDef({ nodeId: 'def:app.Foo', qualifiedName: 'app.Foo' }); + const anon = makeDef({ nodeId: 'def:anon', qualifiedName: undefined }); + const idx = buildQualifiedNameIndex([qnamed, anon]); + expect(idx.size).toBe(1); + expect(idx.get('app.Foo')).toEqual(['def:app.Foo']); + expect(idx.has('')).toBe(false); + }); + + it('skips defs with an empty-string qualifiedName', () => { + const empty = makeDef({ nodeId: 'def:empty', qualifiedName: '' }); + const idx = buildQualifiedNameIndex([empty]); + expect(idx.size).toBe(0); + expect(idx.has('')).toBe(false); + }); + + it('deduplicates exact (qname, DefId) pairs when the same def appears twice in input', () => { + const def = makeDef({ nodeId: 'def:app.Foo', qualifiedName: 'app.Foo' }); + const idx = buildQualifiedNameIndex([def, def]); + expect(idx.get('app.Foo')).toEqual(['def:app.Foo']); // not duplicated + }); + + it('indexes across heterogeneous kinds (Class + Method + Field may share qname convention)', () => { + const klass = makeDef({ + nodeId: 'def:class:app.User', + type: 'Class', + qualifiedName: 'app.User', + }); + const method = makeDef({ + nodeId: 'def:method:app.User.save', + type: 'Method', + qualifiedName: 'app.User.save', + }); + const idx = buildQualifiedNameIndex([klass, method]); + expect(idx.size).toBe(2); + expect(idx.get('app.User')).toEqual(['def:class:app.User']); + expect(idx.get('app.User.save')).toEqual(['def:method:app.User.save']); + }); + + it('returns a frozen empty array (not undefined) for misses so callers can iterate safely', () => { + const idx = buildQualifiedNameIndex([makeDef({ qualifiedName: 'app.Foo' })]); + const miss = idx.get('app.Missing'); + expect(miss).toEqual([]); + expect(() => (miss as unknown as string[]).push('x')).toThrow(); + }); + + it('exposes byQualifiedName as a read-only Map for direct iteration', () => { + const idx = buildQualifiedNameIndex([ + makeDef({ nodeId: 'def:A', qualifiedName: 'app.A' }), + makeDef({ nodeId: 'def:B', qualifiedName: 'app.B' }), + ]); + const names = Array.from(idx.byQualifiedName.keys()).sort(); + expect(names).toEqual(['app.A', 'app.B']); + }); +}); From 56e32b310bee75cf8d852b18ae9283378954e339 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 16:09:54 +0100 Subject: [PATCH 25/46] feat(shared): resolveTypeRef strict single-return type resolver (#916, RFC #909 Ring 2 SHARED) (#959) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements RFC §4.6: a strict, pure resolver for `TypeRef`s used by `Registry.lookup` Step 2 (type-binding propagation) and by any caller that wants the single best type-target for an annotation without paying for the full evidence pipeline. Algorithm (strict): 1. Walk the scope chain from `ref.declaredAtScope`: - Return the first binding for `rawName` whose origin is in `{'local','import','namespace','reexport'}` AND whose `def.type` is a type-kind (class-like, interface-like, enum-like, alias-like). - If bindings exist but none qualify (non-type shadow, wildcard-only origin), return null immediately — do NOT fall through to the global qualified-name index. 2. If `rawName` is dotted and the scope walk produced no match, consult `QualifiedNameIndex.byQualifiedName`. Only accept a UNIQUE type-kind hit; ambiguous or non-type results return null. `'wildcard'` is deliberately excluded from strict origins — a wildcard-expanded name is too loose to anchor type resolution. Module placement: `gitnexus-shared/src/scope-resolution/resolve-type-ref.ts` (alongside sibling indexes) rather than the issue's suggested `gitnexus-shared/src/resolve-type-ref.ts`, for consistency with the rest of the RFC §2/§3 surface. A minimal `ScopeLookup` interface is declared inline so #916 ships standalone; #912's `ScopeTree` will satisfy this contract without change. Closes part of #909. --- gitnexus-shared/src/index.ts | 4 + .../src/scope-resolution/resolve-type-ref.ts | 152 +++++++++ .../scope-resolution/resolve-type-ref.test.ts | 318 ++++++++++++++++++ 3 files changed, 474 insertions(+) create mode 100644 gitnexus-shared/src/scope-resolution/resolve-type-ref.ts create mode 100644 gitnexus/test/unit/scope-resolution/resolve-type-ref.test.ts diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 0f4bf8e29..b57271606 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -71,6 +71,10 @@ export type { ModuleScopeIndex, ModuleScopeEntry } from './scope-resolution/modu export { buildQualifiedNameIndex } from './scope-resolution/qualified-name-index.js'; export type { QualifiedNameIndex } from './scope-resolution/qualified-name-index.js'; +// Strict type-reference resolver (RFC §4.6; Ring 2 SHARED #916) +export { resolveTypeRef } from './scope-resolution/resolve-type-ref.js'; +export type { ResolveTypeRefContext, ScopeLookup } from './scope-resolution/resolve-type-ref.js'; + // Shadow-mode diff + aggregation (RFC §6.3; Ring 2 SHARED #918) export { diffResolutions } from './scope-resolution/shadow/diff.js'; export type { diff --git a/gitnexus-shared/src/scope-resolution/resolve-type-ref.ts b/gitnexus-shared/src/scope-resolution/resolve-type-ref.ts new file mode 100644 index 000000000..63bd6a8da --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/resolve-type-ref.ts @@ -0,0 +1,152 @@ +/** + * `resolveTypeRef` — strict single-return resolver for `TypeRef`s + * (RFC §4.6; Ring 2 SHARED #916). + * + * Narrower contract than `Registry.lookup`: no name-only global fallback, no + * confidence ranking, no arity check. Used by `Registry.lookup` Step 2 (type- + * binding propagation) and by any caller that wants the single best type- + * target for an annotation without paying for the full evidence pipeline. + * + * **Algorithm (strict).** Walk the scope chain from `ref.declaredAtScope`: + * + * 1. At each scope, inspect `bindings.get(ref.rawName)`: + * - If one of the bindings is a **type-kind** def with a **strict origin** + * (`'local' | 'import' | 'namespace' | 'reexport'`), return it. + * - If any binding for this name exists at this scope but none qualifies + * (e.g., a local variable named `User` shadows an outer import of class + * `User`), return `null`. The nearer binding shadows; we do NOT fall + * through to the global qualified-name index. + * - Otherwise continue to the parent scope. + * 2. If the raw name is a dotted path (e.g., `'models.User'`) and the scope + * walk produced no match, consult `QualifiedNameIndex.byQualifiedName`. + * Only accept **exactly one** type-kind hit — anything ambiguous returns + * `null` rather than a guess. + * 3. Return `null`. + * + * **What `'strict' origins' means.** `'wildcard'` is intentionally excluded. + * A wildcard-expanded name (`from x import *`) is too loose to use as an + * anchor for type resolution — it gives no signal about whether the name was + * actually imported. `Registry.lookup` may accept wildcard bindings at its + * own discretion (with lower evidence weight); `resolveTypeRef` does not. + * + * **What 'type-kind' means.** The subset of `NodeLabel` that a type annotation + * may legitimately reference: class-like, interface-like, enum-like, and + * alias-like kinds. See `TYPE_KINDS` below. + * + * Pure function — safe to call repeatedly; no side effects. + */ + +import type { NodeLabel } from '../graph/types.js'; +import type { SymbolDefinition } from './symbol-definition.js'; +import type { BindingRef, Scope, ScopeId, TypeRef } from './types.js'; +import type { DefIndex } from './def-index.js'; +import type { QualifiedNameIndex } from './qualified-name-index.js'; + +// ─── Public contracts ─────────────────────────────────────────────────────── + +/** + * Minimal scope-lookup contract required by `resolveTypeRef`. Implemented by + * the `ScopeTree` from #912; declared here so #916 can ship as a standalone + * piece without a hard dependency on the full scope-tree implementation. Any + * structure that hands back a `Scope` by `ScopeId` satisfies this contract. + */ +export interface ScopeLookup { + getScope(id: ScopeId): Scope | undefined; +} + +/** + * All inputs `resolveTypeRef` needs from the semantic model. Bundled into a + * context object so the call site stays short and the interface is stable as + * additional indexes get threaded through in later rings. + */ +export interface ResolveTypeRefContext { + readonly scopes: ScopeLookup; + readonly defIndex: DefIndex; + readonly qualifiedNameIndex: QualifiedNameIndex; +} + +// ─── Strict policy constants ──────────────────────────────────────────────── + +/** `'wildcard'` is deliberately absent. See file header. */ +const STRICT_ORIGINS: ReadonlySet = new Set([ + 'local', + 'import', + 'namespace', + 'reexport', +]); + +/** + * `NodeLabel` values that may appear on the RHS of a type annotation. + * + * Includes the usual class-like and interface-like kinds plus the alias-like + * ones (`TypeAlias`, `Typedef`). `Namespace` is excluded — it is a scope + * container, not a value type. `Function` / `Method` / `Variable` are + * excluded by design: a `rawName` bound to them at a strict origin is a + * *shadowing* binding, which the algorithm short-circuits to `null`. + */ +const TYPE_KINDS: ReadonlySet = new Set([ + 'Class', + 'Interface', + 'Enum', + 'Struct', + 'Union', + 'Trait', + 'TypeAlias', + 'Typedef', + 'Record', + 'Delegate', + 'Annotation', + 'Template', +]); + +// ─── Main entry point ────────────────────────────────────────────────────── + +export function resolveTypeRef(ref: TypeRef, ctx: ResolveTypeRefContext): SymbolDefinition | null { + // Phase 1: scope-chain walk anchored at the declaration site. + let currentId: ScopeId | null = ref.declaredAtScope; + const visited = new Set(); + + while (currentId !== null) { + // Cycle guard — a well-formed scope tree never loops, but a bug in the + // construction path should fail fast here rather than hanging. + if (visited.has(currentId)) return null; + visited.add(currentId); + + const scope = ctx.scopes.getScope(currentId); + if (scope === undefined) return null; // broken chain = unresolvable + + const bindings = scope.bindings.get(ref.rawName); + if (bindings !== undefined && bindings.length > 0) { + // At least one binding exists at this scope → it is the shadowing site. + // Either one of them qualifies, or the name is shadowed by a non-type. + for (const binding of bindings) { + if (!STRICT_ORIGINS.has(binding.origin)) continue; + if (TYPE_KINDS.has(binding.def.type)) { + return binding.def; + } + } + // Shadowed by a non-type / non-strict-origin binding. Fail fast — no + // global fallback, no walk to the parent. + return null; + } + + currentId = scope.parent; + } + + // Phase 2: dotted fallback via `QualifiedNameIndex`. Only accept a unique + // type-kind hit; anything ambiguous returns null (strict: no guesses). + if (ref.rawName.includes('.')) { + const candidates = ctx.qualifiedNameIndex.get(ref.rawName); + let onlyTypeDef: SymbolDefinition | null = null; + for (const defId of candidates) { + const def = ctx.defIndex.get(defId); + if (def === undefined) continue; + if (!TYPE_KINDS.has(def.type)) continue; + if (onlyTypeDef !== null) return null; // ambiguous + onlyTypeDef = def; + } + if (onlyTypeDef !== null) return onlyTypeDef; + } + + return null; +} diff --git a/gitnexus/test/unit/scope-resolution/resolve-type-ref.test.ts b/gitnexus/test/unit/scope-resolution/resolve-type-ref.test.ts new file mode 100644 index 000000000..2e2c08263 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/resolve-type-ref.test.ts @@ -0,0 +1,318 @@ +/** + * Unit tests for `resolveTypeRef` (RFC #909 Ring 2 SHARED #916). + * + * Covers: local type, parameter/return annotation via scope walk, aliased + * import, re-exported type, shadowing by local variable, wildcard-origin + * ignored, qualified-name fallback (unique + ambiguous), broken scope chain, + * cycle guard. + */ + +import { describe, it, expect } from 'vitest'; +import { + resolveTypeRef, + buildDefIndex, + buildQualifiedNameIndex, + type ResolveTypeRefContext, + type ScopeLookup, + type BindingRef, + type ImportEdge, + type Scope, + type ScopeId, + type SymbolDefinition, + type TypeRef, +} from 'gitnexus-shared'; + +// ─── Test helpers ─────────────────────────────────────────────────────────── + +const mkDef = (overrides: Partial & { nodeId: string }): SymbolDefinition => ({ + nodeId: overrides.nodeId, + filePath: overrides.filePath ?? 'src/test.ts', + type: overrides.type ?? 'Class', + ...overrides, +}); + +const mkBinding = ( + def: SymbolDefinition, + origin: BindingRef['origin'], + via?: ImportEdge, +): BindingRef => ({ def, origin, ...(via !== undefined ? { via } : {}) }); + +const mkScope = ( + id: ScopeId, + parent: ScopeId | null, + bindings: Record = {}, + filePath = 'src/test.ts', +): Scope => ({ + id, + parent, + kind: 'Module', + range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 }, + filePath, + bindings: new Map(Object.entries(bindings)), + ownedDefs: [], + imports: [], + typeBindings: new Map(), +}); + +const mkLookup = (scopes: Scope[]): ScopeLookup => { + const byId = new Map(scopes.map((s) => [s.id, s])); + return { getScope: (id) => byId.get(id) }; +}; + +const mkCtx = (scopes: Scope[], defs: SymbolDefinition[]): ResolveTypeRefContext => ({ + scopes: mkLookup(scopes), + defIndex: buildDefIndex(defs), + qualifiedNameIndex: buildQualifiedNameIndex(defs), +}); + +const typeRef = ( + rawName: string, + declaredAtScope: ScopeId, + source: TypeRef['source'] = 'parameter-annotation', +): TypeRef => ({ rawName, declaredAtScope, source }); + +// ─── Tests ───────────────────────────────────────────────────────────────── + +describe('resolveTypeRef', () => { + describe('scope-chain walk', () => { + it('resolves a local type defined in the same scope', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class' }); + const moduleScope = mkScope('scope:module', null, { + User: [mkBinding(userClass, 'local')], + }); + const ctx = mkCtx([moduleScope], [userClass]); + const result = resolveTypeRef(typeRef('User', 'scope:module'), ctx); + expect(result).toBe(userClass); + }); + + it('walks parent scopes when the name is not bound locally (return-annotation case)', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class' }); + const moduleScope = mkScope('scope:module', null, { + User: [mkBinding(userClass, 'local')], + }); + const functionScope = mkScope('scope:fn', 'scope:module'); + const ctx = mkCtx([moduleScope, functionScope], [userClass]); + const result = resolveTypeRef(typeRef('User', 'scope:fn', 'return-annotation'), ctx); + expect(result).toBe(userClass); + }); + + it('returns the closest binding when the name is bound at multiple levels', () => { + const outerUser = mkDef({ nodeId: 'def:outer', type: 'Class' }); + const innerUser = mkDef({ nodeId: 'def:inner', type: 'Class' }); + const moduleScope = mkScope('scope:module', null, { + User: [mkBinding(outerUser, 'local')], + }); + const classScope = mkScope('scope:class', 'scope:module', { + User: [mkBinding(innerUser, 'local')], + }); + const ctx = mkCtx([moduleScope, classScope], [outerUser, innerUser]); + const result = resolveTypeRef(typeRef('User', 'scope:class'), ctx); + expect(result).toBe(innerUser); // inner shadows outer + }); + }); + + describe('import origins', () => { + it('resolves a plain named import', () => { + const userClass = mkDef({ nodeId: 'def:User', filePath: 'models.ts', type: 'Class' }); + const moduleScope = mkScope('scope:module', null, { + User: [mkBinding(userClass, 'import')], + }); + const ctx = mkCtx([moduleScope], [userClass]); + expect(resolveTypeRef(typeRef('User', 'scope:module'), ctx)).toBe(userClass); + }); + + it('resolves an aliased import under the alias (e.g., `import { User as Account }`)', () => { + // In `def save_user(user: Account)` where `Account` is an aliased import + // of `User`, we resolve `Account` to the underlying `User` class def. + const userClass = mkDef({ nodeId: 'def:User', filePath: 'models.ts', type: 'Class' }); + const importEdge: ImportEdge = { + localName: 'Account', + targetFile: 'models.ts', + targetExportedName: 'User', + targetDefId: 'def:User', + kind: 'alias', + }; + const moduleScope = mkScope('scope:module', null, { + Account: [mkBinding(userClass, 'import', importEdge)], + }); + const ctx = mkCtx([moduleScope], [userClass]); + expect(resolveTypeRef(typeRef('Account', 'scope:module'), ctx)).toBe(userClass); + }); + + it('resolves a namespace-origin binding (e.g., `import * as np`)', () => { + const numpyMod = mkDef({ nodeId: 'def:numpy-mod', type: 'Namespace' }); + // A namespace binding must resolve to a type-kind def to satisfy strict + // mode. Here the binding is the namespace module itself — treat it as a + // shadowing non-type and expect null. + const moduleScope = mkScope('scope:module', null, { + np: [mkBinding(numpyMod, 'namespace')], + }); + const ctx = mkCtx([moduleScope], [numpyMod]); + // Namespace is NOT a type kind → strict returns null. + expect(resolveTypeRef(typeRef('np', 'scope:module'), ctx)).toBeNull(); + }); + + it('resolves a re-exported type (`export { X } from `./y`)', () => { + const userClass = mkDef({ nodeId: 'def:User', filePath: 'y.ts', type: 'Class' }); + const moduleScope = mkScope('scope:module', null, { + User: [mkBinding(userClass, 'reexport')], + }); + const ctx = mkCtx([moduleScope], [userClass]); + expect(resolveTypeRef(typeRef('User', 'scope:module'), ctx)).toBe(userClass); + }); + + it('ignores wildcard origin — not in the strict set', () => { + const userClass = mkDef({ nodeId: 'def:User', filePath: 'models.ts', type: 'Class' }); + const moduleScope = mkScope('scope:module', null, { + User: [mkBinding(userClass, 'wildcard')], + }); + const ctx = mkCtx([moduleScope], [userClass]); + // Binding exists but wildcard is not strict → return null (shadow). + expect(resolveTypeRef(typeRef('User', 'scope:module'), ctx)).toBeNull(); + }); + + it('prefers a strict-origin type binding over a non-strict binding at the same scope', () => { + const wildcardClass = mkDef({ nodeId: 'def:wild', type: 'Class' }); + const importClass = mkDef({ nodeId: 'def:import', type: 'Class' }); + const moduleScope = mkScope('scope:module', null, { + // The strict-origin binding wins regardless of input order. + User: [mkBinding(wildcardClass, 'wildcard'), mkBinding(importClass, 'import')], + }); + const ctx = mkCtx([moduleScope], [wildcardClass, importClass]); + expect(resolveTypeRef(typeRef('User', 'scope:module'), ctx)).toBe(importClass); + }); + }); + + describe('shadowing (non-type binding fails fast)', () => { + it('returns null when a local variable shadows an outer imported type', () => { + // Outer module has `import { User }`; inner function declares `User = 5`. + // Per RFC §4.6, the local non-type shadows; strict resolver returns null. + const importedUser = mkDef({ nodeId: 'def:imp', type: 'Class' }); + const localUser = mkDef({ nodeId: 'def:local', type: 'Variable' }); + const moduleScope = mkScope('scope:module', null, { + User: [mkBinding(importedUser, 'import')], + }); + const functionScope = mkScope('scope:fn', 'scope:module', { + User: [mkBinding(localUser, 'local')], + }); + const ctx = mkCtx([moduleScope, functionScope], [importedUser, localUser]); + expect(resolveTypeRef(typeRef('User', 'scope:fn'), ctx)).toBeNull(); + }); + + it('returns null when the only binding at the declaration scope is a Method', () => { + const method = mkDef({ nodeId: 'def:m', type: 'Method' }); + const scope = mkScope('scope:class', null, { + save: [mkBinding(method, 'local')], + }); + const ctx = mkCtx([scope], [method]); + expect(resolveTypeRef(typeRef('save', 'scope:class'), ctx)).toBeNull(); + }); + + it('does NOT fall through to the qualified-name index when shadowed', () => { + // Even if `app.User` is a unique type in the qualified-name index, a + // shadowing non-type binding at the declaration scope should short-circuit. + const shadowVar = mkDef({ nodeId: 'def:v', type: 'Variable' }); + const globalType = mkDef({ + nodeId: 'def:g', + qualifiedName: 'app.User', + type: 'Class', + }); + const scope = mkScope('scope:s', null, { + 'app.User': [mkBinding(shadowVar, 'local')], + }); + const ctx = mkCtx([scope], [shadowVar, globalType]); + expect(resolveTypeRef(typeRef('app.User', 'scope:s'), ctx)).toBeNull(); + }); + }); + + describe('dotted qualified-name fallback', () => { + it('resolves a unique qualified name when no scope binding matches', () => { + const userClass = mkDef({ + nodeId: 'def:appUser', + qualifiedName: 'app.models.User', + type: 'Class', + }); + const scope = mkScope('scope:s', null); // no bindings for `app.models.User` + const ctx = mkCtx([scope], [userClass]); + expect(resolveTypeRef(typeRef('app.models.User', 'scope:s'), ctx)).toBe(userClass); + }); + + it('does NOT apply the qualified-name fallback for non-dotted names', () => { + const simple = mkDef({ nodeId: 'def:s', qualifiedName: 'User', type: 'Class' }); + const scope = mkScope('scope:s', null); + const ctx = mkCtx([scope], [simple]); + // Even though `User` is in the qname index as `User`, the fallback only + // fires for dotted names (scope walk is the answer for simple names). + expect(resolveTypeRef(typeRef('User', 'scope:s'), ctx)).toBeNull(); + }); + + it('returns null when the qualified name is ambiguous across type defs', () => { + const a = mkDef({ nodeId: 'def:a', qualifiedName: 'app.User', type: 'Class' }); + const b = mkDef({ nodeId: 'def:b', qualifiedName: 'app.User', type: 'Class' }); + const scope = mkScope('scope:s', null); + const ctx = mkCtx([scope], [a, b]); + expect(resolveTypeRef(typeRef('app.User', 'scope:s'), ctx)).toBeNull(); + }); + + it('ignores non-type-kind hits and accepts the single type hit', () => { + const cls = mkDef({ nodeId: 'def:c', qualifiedName: 'app.User', type: 'Class' }); + const fn = mkDef({ nodeId: 'def:f', qualifiedName: 'app.User', type: 'Function' }); + const scope = mkScope('scope:s', null); + const ctx = mkCtx([scope], [cls, fn]); + expect(resolveTypeRef(typeRef('app.User', 'scope:s'), ctx)).toBe(cls); + }); + + it('returns null when no qualified-name hit is a type kind', () => { + const fn = mkDef({ nodeId: 'def:f', qualifiedName: 'app.User', type: 'Function' }); + const scope = mkScope('scope:s', null); + const ctx = mkCtx([scope], [fn]); + expect(resolveTypeRef(typeRef('app.User', 'scope:s'), ctx)).toBeNull(); + }); + }); + + describe('robustness', () => { + it('returns null for a missing type (no scope binding, no qname hit)', () => { + const scope = mkScope('scope:s', null); + const ctx = mkCtx([scope], []); + expect(resolveTypeRef(typeRef('User', 'scope:s'), ctx)).toBeNull(); + }); + + it('returns null when the declaredAtScope id is not known to the lookup', () => { + const ctx = mkCtx([], []); + expect(resolveTypeRef(typeRef('User', 'scope:missing'), ctx)).toBeNull(); + }); + + it('returns null when a parent pointer references an unknown scope (broken chain)', () => { + const functionScope = mkScope('scope:fn', 'scope:ghost'); + const ctx = mkCtx([functionScope], []); + expect(resolveTypeRef(typeRef('User', 'scope:fn'), ctx)).toBeNull(); + }); + + it('terminates cleanly on a cyclic parent chain (defensive guard)', () => { + // A well-formed scope tree is acyclic; construction bugs shouldn't hang. + const a: Scope = mkScope('scope:a', 'scope:b'); + const b: Scope = mkScope('scope:b', 'scope:a'); + const ctx = mkCtx([a, b], []); + expect(resolveTypeRef(typeRef('User', 'scope:a'), ctx)).toBeNull(); + }); + + it('accepts all annotation source flavors uniformly', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class' }); + const scope = mkScope('scope:s', null, { + User: [mkBinding(userClass, 'local')], + }); + const ctx = mkCtx([scope], [userClass]); + for (const source of [ + 'annotation', + 'parameter-annotation', + 'return-annotation', + 'self', + 'assignment-inferred', + 'constructor-inferred', + 'receiver-propagated', + ] as const) { + expect(resolveTypeRef(typeRef('User', 'scope:s', source), ctx)).toBe(userClass); + } + }); + }); +}); From 5d76dbcfa29e581b3f0a32db48d991fd5edffb47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 16:28:46 +0100 Subject: [PATCH 26/46] feat(shared): MethodDispatchIndex materialized view over HeritageMap (#914, RFC #909 Ring 2 SHARED) (#960) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements RFC §3.1 `MethodDispatchIndex`: a two-way materialized view keyed by `DefId` for O(1) method-dispatch resolution: - `mroByOwnerDefId` — owner class → full MRO ancestor chain (excludes self, per-language strategy order) - `implsByInterfaceDefId` — interface/trait → classes that implement it **Not an MRO implementation.** `buildMethodDispatchIndex` is a pure aggregator that calls back into caller-provided `computeMro` and `implementsOf` functions. The five existing strategies (Python C3, Ruby kind-aware, Java/Kotlin linear, Rust qualified-syntax, COBOL none) stay where they are today (`model/resolve.ts`, `languages/ruby.ts`); this index does not reimplement them. Why callbacks rather than a shared registry: the strategies depend on the CLI's `HeritageMap` + `SemanticModel`. Migrating both to `gitnexus-shared` is out of scope for #914; callbacks let the shared build stay pure. Module placement: `gitnexus-shared/src/scope-resolution/method-dispatch-index.ts` for consistency with the other RFC §3.1 indexes (#913 DefIndex / ModuleScopeIndex / QualifiedNameIndex; #916 resolveTypeRef). Safety surface mirrors sibling indexes: - First-write-wins on duplicate owners. - Repeated (interface, owner) pairs deduplicated. - Stored arrays are `Object.freeze`d; caller mutation of the source array does not leak into the index. - Miss returns a shared frozen empty array. Tests (19, all passing): empty input, single-inheritance chain, Python C3 diamond, Java BFS, Ruby kind-aware mixin, Rust qualified-syntax empty, interface inversion (single, multiple, ordered), dedup within and across callback calls, frozen miss + bucket arrays, callback-array isolation, readonly Map iteration. Closes part of #909. --- gitnexus-shared/src/index.ts | 7 + .../scope-resolution/method-dispatch-index.ts | 137 +++++++++++ .../method-dispatch-index.test.ts | 216 ++++++++++++++++++ 3 files changed, 360 insertions(+) create mode 100644 gitnexus-shared/src/scope-resolution/method-dispatch-index.ts create mode 100644 gitnexus/test/unit/scope-resolution/method-dispatch-index.test.ts diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index b57271606..edacb1214 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -75,6 +75,13 @@ export type { QualifiedNameIndex } from './scope-resolution/qualified-name-index export { resolveTypeRef } from './scope-resolution/resolve-type-ref.js'; export type { ResolveTypeRefContext, ScopeLookup } from './scope-resolution/resolve-type-ref.js'; +// Method-dispatch materialized view over HeritageMap (RFC §3.1; Ring 2 SHARED #914) +export { buildMethodDispatchIndex } from './scope-resolution/method-dispatch-index.js'; +export type { + MethodDispatchIndex, + MethodDispatchInput, +} from './scope-resolution/method-dispatch-index.js'; + // Shadow-mode diff + aggregation (RFC §6.3; Ring 2 SHARED #918) export { diffResolutions } from './scope-resolution/shadow/diff.js'; export type { diff --git a/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts b/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts new file mode 100644 index 000000000..050538db5 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts @@ -0,0 +1,137 @@ +/** + * `MethodDispatchIndex` — materialized view of class hierarchies keyed by + * `DefId` (RFC §3.1; Ring 2 SHARED #914). + * + * Two O(1)-access maps used by `Registry.lookupMethod` and interface- + * dispatch callers: + * + * - `mroByOwnerDefId` : owner class → full MRO ancestor chain + * (excludes the owner itself, in per-language + * strategy order). + * - `implsByInterfaceDefId` : interface/trait → classes that implement it. + * + * **Not an MRO implementation.** The build function is a pure aggregator: it + * asks the caller (via `computeMro` and `implementsOf` callbacks) for the + * per-language answers and materializes the two-way index. MRO strategies + * live where they already do today (`model/resolve.ts § c3Linearize`, + * `languages/ruby.ts § selectDispatch`, etc.) — this index does not + * reimplement them. + * + * Why callbacks and not a shared strategy registry: the five strategies + * (Python C3, Ruby kind-aware, Java/Kotlin linear, Rust qualified-syntax, + * COBOL none) already exist in the CLI package and depend on the CLI's + * `HeritageMap` + `SemanticModel`. Pulling them into `gitnexus-shared` would + * require migrating both — out of scope for #914. Callbacks let the shared + * build stay pure while honoring existing strategies verbatim. + * + * Consumed by: #917 (`Registry.lookupMethod` MRO fast path, interface + * dispatch resolver). + */ + +import type { DefId } from './types.js'; + +// ─── Public contracts ─────────────────────────────────────────────────────── + +export interface MethodDispatchIndex { + /** + * Full MRO ancestor chain per owner class (excludes the owner itself). + * Order reflects the per-language strategy used by `computeMro`. + */ + readonly mroByOwnerDefId: ReadonlyMap; + /** Interfaces / traits → classes that implement them. */ + readonly implsByInterfaceDefId: ReadonlyMap; + + /** `mroByOwnerDefId.get`, with an empty frozen array on miss. */ + mroFor(ownerDefId: DefId): readonly DefId[]; + /** `implsByInterfaceDefId.get`, with an empty frozen array on miss. */ + implementorsOf(interfaceDefId: DefId): readonly DefId[]; +} + +export interface MethodDispatchInput { + /** + * Owner defs to index (classes, structs, traits, interfaces — any kind + * that can appear on the owner side of a method-dispatch graph). + */ + readonly owners: readonly DefId[]; + /** + * Return the full MRO ancestor chain for `ownerDefId`, **excluding the + * owner itself**, in the order dictated by the owner's language-specific + * MRO strategy. + * + * Contract: + * - Pure (no side effects). + * - Deterministic per input. + * - `undefined` not allowed — return `[]` when the owner has no parents. + */ + readonly computeMro: (ownerDefId: DefId) => readonly DefId[]; + /** + * Return the set of interface/trait defs that `ownerDefId` implements. + * Transitive inclusion (e.g., `implements` on a parent class) is the + * caller's choice — the build function simply inverts whatever is + * returned. + * + * Repeated IDs in the output are deduplicated automatically. + */ + readonly implementsOf: (ownerDefId: DefId) => readonly DefId[]; +} + +// ─── Builder ──────────────────────────────────────────────────────────────── + +export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDispatchIndex { + const mroByOwnerDefId = new Map(); + const implsBuilding = new Map(); + const implsSeen = new Map>(); + + for (const ownerId of input.owners) { + // First-write-wins on duplicate owner ids: a stable policy consistent + // with sibling indexes (#913 DefIndex / ModuleScopeIndex). + if (!mroByOwnerDefId.has(ownerId)) { + const chain = input.computeMro(ownerId); + mroByOwnerDefId.set(ownerId, Object.freeze(chain.slice())); + } + + for (const ifaceId of input.implementsOf(ownerId)) { + let seen = implsSeen.get(ifaceId); + if (seen === undefined) { + seen = new Set(); + implsSeen.set(ifaceId, seen); + } + if (seen.has(ownerId)) continue; + seen.add(ownerId); + + let bucket = implsBuilding.get(ifaceId); + if (bucket === undefined) { + bucket = []; + implsBuilding.set(ifaceId, bucket); + } + bucket.push(ownerId); + } + } + + const implsByInterfaceDefId = new Map(); + for (const [ifaceId, owners] of implsBuilding) { + implsByInterfaceDefId.set(ifaceId, Object.freeze(owners.slice())); + } + + return freezeIndex(mroByOwnerDefId, implsByInterfaceDefId); +} + +// ─── Internal ─────────────────────────────────────────────────────────────── + +const EMPTY: readonly DefId[] = Object.freeze([]); + +function freezeIndex( + mroByOwnerDefId: Map, + implsByInterfaceDefId: Map, +): MethodDispatchIndex { + return { + mroByOwnerDefId, + implsByInterfaceDefId, + mroFor(ownerDefId: DefId): readonly DefId[] { + return mroByOwnerDefId.get(ownerDefId) ?? EMPTY; + }, + implementorsOf(interfaceDefId: DefId): readonly DefId[] { + return implsByInterfaceDefId.get(interfaceDefId) ?? EMPTY; + }, + }; +} diff --git a/gitnexus/test/unit/scope-resolution/method-dispatch-index.test.ts b/gitnexus/test/unit/scope-resolution/method-dispatch-index.test.ts new file mode 100644 index 000000000..bc4f75812 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/method-dispatch-index.test.ts @@ -0,0 +1,216 @@ +/** + * Unit tests for `buildMethodDispatchIndex` / `MethodDispatchIndex` + * (RFC #909 Ring 2 SHARED #914). + * + * Covers: empty input, single-inheritance chain, diamond inheritance (caller- + * determined MRO order), interface-only dispatch, multiple implementors, + * dedup, first-write-wins, C3 vs BFS strategy parity (both honored verbatim), + * readonly surface + frozen output. + */ + +import { describe, it, expect } from 'vitest'; +import { buildMethodDispatchIndex, type MethodDispatchInput, type DefId } from 'gitnexus-shared'; + +// ─── Test helpers ─────────────────────────────────────────────────────────── + +const input = ( + owners: readonly DefId[], + mroByOwner: Record, + implementsByOwner: Record = {}, +): MethodDispatchInput => ({ + owners, + computeMro: (owner) => mroByOwner[owner] ?? [], + implementsOf: (owner) => implementsByOwner[owner] ?? [], +}); + +// ─── Tests ────────────────────────────────────────────────────────────────── + +describe('buildMethodDispatchIndex', () => { + describe('empty / degenerate inputs', () => { + it('builds an empty index from no owners', () => { + const idx = buildMethodDispatchIndex(input([], {})); + expect(idx.mroByOwnerDefId.size).toBe(0); + expect(idx.implsByInterfaceDefId.size).toBe(0); + expect(idx.mroFor('anything')).toEqual([]); + expect(idx.implementorsOf('anything')).toEqual([]); + }); + + it('indexes an owner with no parents and no interfaces', () => { + const idx = buildMethodDispatchIndex(input(['def:A'], { 'def:A': [] })); + expect(idx.mroByOwnerDefId.size).toBe(1); + expect(idx.implsByInterfaceDefId.size).toBe(0); + expect(idx.mroFor('def:A')).toEqual([]); + }); + }); + + describe('MRO materialization (single / multi inheritance)', () => { + it('records a single-inheritance chain verbatim from the callback', () => { + // A extends B extends C + const idx = buildMethodDispatchIndex( + input(['def:A', 'def:B', 'def:C'], { + 'def:A': ['def:B', 'def:C'], + 'def:B': ['def:C'], + 'def:C': [], + }), + ); + expect(idx.mroFor('def:A')).toEqual(['def:B', 'def:C']); + expect(idx.mroFor('def:B')).toEqual(['def:C']); + expect(idx.mroFor('def:C')).toEqual([]); + }); + + it('records a C3 linearization verbatim (Python diamond)', () => { + // D(B, C) where B(A), C(A). Classical C3: D, B, C, A. + // Our index stores mro excluding self: [B, C, A]. + const idx = buildMethodDispatchIndex( + input(['def:D'], { 'def:D': ['def:B', 'def:C', 'def:A'] }), + ); + expect(idx.mroFor('def:D')).toEqual(['def:B', 'def:C', 'def:A']); + }); + + it('records a BFS linearization verbatim (Java-style first-wins)', () => { + // D extends B, C; B extends A; C extends A. BFS: B, C, A. + const idx = buildMethodDispatchIndex( + input(['def:D'], { 'def:D': ['def:B', 'def:C', 'def:A'] }), + ); + expect(idx.mroFor('def:D')).toEqual(['def:B', 'def:C', 'def:A']); + }); + + it('records a Ruby-style kind-aware ancestry verbatim', () => { + // class C prepend P1 prepend P2; include M1 include M2 + // ruby-mixin walk order (per callback): [P2, P1, M2, M1] + const idx = buildMethodDispatchIndex( + input(['def:C'], { 'def:C': ['def:P2', 'def:P1', 'def:M2', 'def:M1'] }), + ); + expect(idx.mroFor('def:C')).toEqual(['def:P2', 'def:P1', 'def:M2', 'def:M1']); + }); + + it('records an empty chain for Rust qualified-syntax owners', () => { + // Rust: no auto-MRO; callback returns [] + const idx = buildMethodDispatchIndex(input(['def:RustStruct'], { 'def:RustStruct': [] })); + expect(idx.mroFor('def:RustStruct')).toEqual([]); + }); + }); + + describe('implements inversion', () => { + it('inverts a single class → interface mapping', () => { + const idx = buildMethodDispatchIndex( + input(['def:Impl'], { 'def:Impl': [] }, { 'def:Impl': ['def:IFace'] }), + ); + expect(idx.implementorsOf('def:IFace')).toEqual(['def:Impl']); + }); + + it('aggregates multiple classes implementing the same interface', () => { + const idx = buildMethodDispatchIndex( + input( + ['def:A', 'def:B', 'def:C'], + { 'def:A': [], 'def:B': [], 'def:C': [] }, + { 'def:A': ['def:I'], 'def:B': ['def:I'], 'def:C': ['def:J'] }, + ), + ); + expect(idx.implementorsOf('def:I')).toEqual(['def:A', 'def:B']); + expect(idx.implementorsOf('def:J')).toEqual(['def:C']); + }); + + it('preserves iteration order of owners in each implementors bucket', () => { + const idx = buildMethodDispatchIndex( + input( + ['def:Z', 'def:Y', 'def:X'], + { 'def:Z': [], 'def:Y': [], 'def:X': [] }, + { 'def:Z': ['def:I'], 'def:Y': ['def:I'], 'def:X': ['def:I'] }, + ), + ); + expect(idx.implementorsOf('def:I')).toEqual(['def:Z', 'def:Y', 'def:X']); + }); + + it('deduplicates repeated (interface, owner) pairs within a single callback call', () => { + // Caller may legally return the same interface twice (e.g., a class that + // both `implements IFace` and inherits from a parent that also does). + const idx = buildMethodDispatchIndex( + input(['def:Impl'], { 'def:Impl': [] }, { 'def:Impl': ['def:I', 'def:I', 'def:I'] }), + ); + expect(idx.implementorsOf('def:I')).toEqual(['def:Impl']); + }); + + it('deduplicates when the same owner is listed in `owners` twice (first-write-wins)', () => { + // First-write-wins parity with sibling indexes; subsequent owner entries + // should not re-invoke callbacks for existing MRO, and should not create + // duplicate implementor entries. + let mroCalls = 0; + const impls: Record = { 'def:A': ['def:I'] }; + const idx = buildMethodDispatchIndex({ + owners: ['def:A', 'def:A'], + computeMro: (_) => { + mroCalls++; + return ['def:B']; + }, + implementsOf: (o) => impls[o] ?? [], + }); + expect(mroCalls).toBe(1); + expect(idx.mroFor('def:A')).toEqual(['def:B']); + expect(idx.implementorsOf('def:I')).toEqual(['def:A']); + }); + }); + + describe('lookup miss / safety surface', () => { + it('returns a frozen empty array on MRO miss', () => { + const idx = buildMethodDispatchIndex(input(['def:A'], { 'def:A': [] })); + const miss = idx.mroFor('def:Missing'); + expect(miss).toEqual([]); + expect(() => (miss as unknown as DefId[]).push('x')).toThrow(); + }); + + it('returns a frozen empty array on implementors miss', () => { + const idx = buildMethodDispatchIndex(input(['def:A'], { 'def:A': [] })); + const miss = idx.implementorsOf('def:Missing'); + expect(miss).toEqual([]); + expect(() => (miss as unknown as DefId[]).push('x')).toThrow(); + }); + + it('freezes stored MRO arrays (readonly surface)', () => { + const idx = buildMethodDispatchIndex(input(['def:A'], { 'def:A': ['def:B'] })); + const chain = idx.mroFor('def:A'); + expect(() => (chain as unknown as DefId[]).push('x')).toThrow(); + }); + + it('freezes stored implementors arrays (readonly surface)', () => { + const idx = buildMethodDispatchIndex( + input(['def:A'], { 'def:A': [] }, { 'def:A': ['def:I'] }), + ); + const impls = idx.implementorsOf('def:I'); + expect(() => (impls as unknown as DefId[]).push('x')).toThrow(); + }); + + it('isolates stored MRO from later mutation of the callback-returned array', () => { + const mutable = ['def:B', 'def:C']; + const idx = buildMethodDispatchIndex({ + owners: ['def:A'], + computeMro: () => mutable, + implementsOf: () => [], + }); + mutable.push('def:D'); + expect(idx.mroFor('def:A')).toEqual(['def:B', 'def:C']); + }); + }); + + describe('readonly surface', () => { + it('exposes `mroByOwnerDefId` as a read-only Map for direct iteration', () => { + const idx = buildMethodDispatchIndex( + input(['def:A', 'def:B'], { 'def:A': [], 'def:B': ['def:A'] }), + ); + const owners = Array.from(idx.mroByOwnerDefId.keys()).sort(); + expect(owners).toEqual(['def:A', 'def:B']); + }); + + it('exposes `implsByInterfaceDefId` as a read-only Map for direct iteration', () => { + const idx = buildMethodDispatchIndex( + input( + ['def:A', 'def:B'], + { 'def:A': [], 'def:B': [] }, + { 'def:A': ['def:I'], 'def:B': ['def:J'] }, + ), + ); + const keys = Array.from(idx.implsByInterfaceDefId.keys()).sort(); + expect(keys).toEqual(['def:I', 'def:J']); + }); + }); +}); From ac148612ab2c5b3e61ad5e2b4a2066915217cdf4 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Sat, 18 Apr 2026 16:30:07 +0100 Subject: [PATCH 27/46] feat(search): per-phase timing instrumentation for the query pipeline (#953) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(search): per-phase timing instrumentation for the query pipeline The eval harness already measures search-pipeline latency per phase, but the *product* query() tool has no timing visibility. That leaves production latency opaque: - Is BM25 the tail, or vector search? - How much Promise.all overlap do concurrent searches actually save? - Does symbol_lookup dominate when per-symbol Cypher round-trips pile up? None of this is answerable from the outside, which blocks the latency-quality Pareto work tracked in #546 / #553. Changes: * New PhaseTimer class at src/core/search/phase-timer.ts. Supports three APIs: - start(phase) / stop() for sequential phases (per issue spec) - mark(phase, durationMs) for pre-measured durations - time(phase, promise) to wrap a promise inside Promise.all The issue's original spec was sequential-only, which doesn't work for BM25 + vector inside Promise.all — the second start() would auto-stop the first and only one phase would get timed. The mark() and time() variants resolve that without changing the sequential API for the other phases. * local-backend.ts query() instrumented across seven phase markers: bm25, vector (concurrent via timer.time inside Promise.all) merge (RRF reciprocal-rank-fusion) symbol_lookup (per-symbol process + cohesion + content Cypher) ranking (in-memory priority sort) formatting (response object construction + dedup) wall (end-to-end; separate mark so callers can compare sum(phases) vs wall and see Promise.all savings) * logQueryTiming() helper next to logQueryError(), same console-based pattern (repo has no structured logger). Emits GitNexus [query:timing] query="..." totalMs=N phases={...} to stdout — greppable prefix, JSON-parseable payload, no new deps. * timing: Record added as a top-level field on the query() response. Strict superset of the previous shape — existing tests only assert field presence, so no regression. Other MCP tools use the same top-level-metadata convention (status, row_count, warning) rather than a nested _meta wrapper. Tests: - 6 new unit tests for PhaseTimer covering start/stop, implicit stop-on-start, additive mark(), Promise.all-safe time(), negative/NaN rejection, and totalMs auto-stop. - 3 new assertions on the existing query integration test verifying timing.wall is a non-negative number and at least one of bm25/vector fired. Verification: npx vitest run test/unit/phase-timer.test.ts -> 6 pass npx vitest run test/unit/calltool-dispatch.test.ts -> 65 pass npx vitest run test/integration/local-backend-calltool.test.ts -> 18 pass npm run test:unit -> 3777 pass (4 pre-existing env failures unchanged: skip-git-cli needs built dist/, git-utils tmpdir on Windows worktree) npx tsc --noEmit -> clean Scope declined for v1: - In-process histogram aggregation — the log line is enough for external tooling - Pareto curve generation — issue asks to enable it, not generate it - Sub-phases of symbol_lookup (process vs cohesion vs content) — issue lists them under one bucket; can split later if demand surfaces Closes #553 * fix(search): route query:timing log to stderr to preserve stdio MCP contract CI (#953) failed the `query: JSON appears on stdout, not stderr` e2e test in test/integration/cli-e2e.test.ts with: SyntaxError: Unexpected token 'G', "GitNexus [..." is not valid JSON Root cause: my initial logQueryTiming() in 63fbdc4 used console.log, which writes to stdout. The MCP stdio transport uses stdout exclusively for JSON-RPC responses (#324), and the CLI e2e test guards that contract by asserting stdout parses as JSON on every tool invocation. The "GitNexus [query:timing] ..." line was interleaving with the response JSON and breaking the parse. Fix: route logQueryTiming through console.error instead. stderr is the correct channel for human-readable diagnostics and it is what the sibling logQueryError already uses for the same reason. The log line format is otherwise unchanged -- still greppable, still JSON-parseable payload. Verification (local, with dist built): npx vitest run test/integration/cli-e2e.test.ts -t "query: JSON" -> now passes (was failing across ubuntu/windows/macos in CI) npx tsc --noEmit -> clean Two unrelated pre-existing failures on non-git directory handling remain (same on upstream/main). Closes the CI regression introduced in 63fbdc4. --- gitnexus/src/core/search/phase-timer.ts | 108 ++++++++++++++++++ gitnexus/src/mcp/local/local-backend.ts | 57 ++++++++- .../local-backend-calltool.test.ts | 8 ++ gitnexus/test/unit/phase-timer.test.ts | 76 ++++++++++++ 4 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 gitnexus/src/core/search/phase-timer.ts create mode 100644 gitnexus/test/unit/phase-timer.test.ts diff --git a/gitnexus/src/core/search/phase-timer.ts b/gitnexus/src/core/search/phase-timer.ts new file mode 100644 index 000000000..3a6fd745a --- /dev/null +++ b/gitnexus/src/core/search/phase-timer.ts @@ -0,0 +1,108 @@ +/** + * Per-phase wall-clock timing for the search pipeline and similar + * multi-stage flows. Designed to be called from query() with minimal + * ceremony and negligible overhead (< 0.1 ms per phase recorded). + * + * ### Sequential usage + * + * ```ts + * const t = new PhaseTimer(); + * t.start('bm25'); await bm25Search(...); t.stop(); + * t.start('merge'); doMerge(); t.stop(); + * const phases = t.summary(); // { bm25: 42, merge: 3 } + * ``` + * + * ### Concurrent usage (Promise.all) + * + * `start`/`stop` assume a single active phase at a time, which is wrong + * for concurrent work inside `Promise.all` — the second `start` would + * auto-stop the first and only one of the two would get timed. Use + * {@link PhaseTimer.time} to wrap each concurrent promise instead: + * + * ```ts + * const [a, b] = await Promise.all([ + * t.time('bm25', bm25Search(...)), + * t.time('vector', semanticSearch(...)), + * ]); + * ``` + * + * ### Pre-measured durations + * + * ```ts + * t.mark('inherited', 12.5); + * ``` + */ +export class PhaseTimer { + private phases: Map = new Map(); + private current: string | null = null; + private t0 = 0; + + /** Start a new phase. Implicitly stops the previous one, if any. */ + start(phase: string): void { + this.stop(); + this.current = phase; + this.t0 = performance.now(); + } + + /** Stop the current phase. No-op if no phase is active. */ + stop(): void { + if (this.current !== null) { + const elapsed = performance.now() - this.t0; + this.phases.set(this.current, (this.phases.get(this.current) ?? 0) + elapsed); + this.current = null; + } + } + + /** + * Record a pre-measured duration without touching the active phase. + * Use for concurrent operations inside `Promise.all` where + * `start`/`stop` would step on each other, or for durations imported + * from sub-systems. Additive across repeated calls with the same + * phase name. Ignores negative / non-finite inputs. + */ + mark(phase: string, durationMs: number): void { + if (!Number.isFinite(durationMs) || durationMs < 0) return; + this.phases.set(phase, (this.phases.get(phase) ?? 0) + durationMs); + } + + /** + * Wrap a promise with automatic timing. Records wall time via + * {@link PhaseTimer.mark} regardless of which other phases are + * active — safe to use inside `Promise.all`. + */ + async time(phase: string, promise: Promise): Promise { + const t0 = performance.now(); + try { + return await promise; + } finally { + this.mark(phase, performance.now() - t0); + } + } + + /** + * Snapshot of accumulated durations rounded to 0.1 ms. Stops the + * current phase if one is still running. + */ + summary(): Record { + this.stop(); + const out: Record = {}; + for (const [k, v] of this.phases) out[k] = Math.round(v * 10) / 10; + return out; + } + + /** + * Sum of every recorded phase duration. + * + * Note: for phases recorded via {@link PhaseTimer.time} or + * {@link PhaseTimer.mark} this is the *sum*, not the wall time — + * concurrent work overlaps and the sum can exceed the end-to-end + * wall time. Record wall time separately with `mark('wall', …)` if + * that distinction matters. + */ + totalMs(): number { + this.stop(); + let t = 0; + for (const v of this.phases.values()) t += v; + return Math.round(t * 10) / 10; + } +} diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 0175928f6..5b73885cd 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -30,6 +30,7 @@ import { import { GroupService, type GroupToolPort } from '../../core/group/service.js'; import { collectBestChunks } from '../../core/embeddings/types.js'; import { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME } from '../../core/lbug/schema.js'; +import { PhaseTimer } from '../../core/search/phase-timer.js'; // AI context generation is CLI-only (gitnexus analyze) // import { generateAIContextFiles } from '../../cli/ai-context.js'; @@ -156,6 +157,28 @@ function logQueryError(context: string, err: unknown): void { console.error(`GitNexus [${context}]: ${msg}`); } +/** + * Structured per-query latency log for production aggregation (#553). + * + * Emitted on stderr — NOT stdout — because the MCP stdio transport uses + * stdout exclusively for JSON-RPC responses (#324), and the CLI e2e test + * `tool output goes to stdout via fd 1` asserts that stdout parses cleanly + * as JSON. Any `console.log` from inside a tool handler would corrupt the + * protocol. Matches the existing `logQueryError` convention above, which + * uses stderr for the same reason. + * + * The `GitNexus [query:timing] …` prefix keeps lines greppable; the + * `phases` payload is JSON so log-scraping pipelines can parse it + * without custom format knowledge. + */ +function logQueryTiming(query: string, phases: Record): void { + const totalMs = phases.wall ?? Object.values(phases).reduce((a, b) => a + b, 0); + const truncated = query.length > 80 ? `${query.slice(0, 80)}…` : query; + console.error( + `GitNexus [query:timing] query=${JSON.stringify(truncated)} totalMs=${totalMs} phases=${JSON.stringify(phases)}`, + ); +} + export interface CodebaseContext { projectName: string; stats: { @@ -534,17 +557,29 @@ export class LocalBackend { const includeContent = params.include_content ?? false; const searchQuery = params.query.trim(); - // Step 1: Run hybrid search to get matching symbols + // Per-phase timing instrumentation (#553). Records wall time for each + // observable sub-step of the search pipeline so production latency can + // be aggregated offline for Pareto analysis and bottleneck detection. + // Overhead is <0.1 ms per phase; the timer is passive and never alters + // query behaviour. + const timer = new PhaseTimer(); + const wallStart = performance.now(); + + // Step 1: Run hybrid search to get matching symbols. BM25 and vector + // search run concurrently via Promise.all — use `timer.time()` for + // each so both get independent wall-time records without fighting + // over a single `current` phase slot. const searchLimit = processLimit * maxSymbolsPerProcess; // fetch enough raw results const [bm25SearchResult, semanticResults] = await Promise.all([ - this.bm25Search(repo, searchQuery, searchLimit), - this.semanticSearch(repo, searchQuery, searchLimit), + timer.time('bm25', this.bm25Search(repo, searchQuery, searchLimit)), + timer.time('vector', this.semanticSearch(repo, searchQuery, searchLimit)), ]); const bm25Results = bm25SearchResult.results; const ftsUsed = bm25SearchResult.ftsUsed; // Merge via reciprocal rank fusion + timer.start('merge'); const scoreMap = new Map(); for (let i = 0; i < bm25Results.length; i++) { @@ -574,8 +609,10 @@ export class LocalBackend { const merged = Array.from(scoreMap.entries()) .sort((a, b) => b[1].score - a[1].score) .slice(0, searchLimit); + timer.stop(); // merge // Step 2: For each match with a nodeId, trace to process(es) + timer.start('symbol_lookup'); const processMap = new Map< string, { @@ -708,7 +745,10 @@ export class LocalBackend { } } + timer.stop(); // symbol_lookup + // Step 3: Rank processes by aggregate score + internal cohesion boost + timer.start('ranking'); const rankedProcesses = Array.from(processMap.values()) .map((p) => ({ ...p, @@ -716,8 +756,10 @@ export class LocalBackend { })) .sort((a, b) => b.priority - a.priority) .slice(0, processLimit); + timer.stop(); // ranking // Step 4: Build response + timer.start('formatting'); const processes = rankedProcesses.map((p) => ({ id: p.id, summary: p.heuristicLabel || p.label, @@ -741,11 +783,20 @@ export class LocalBackend { seen.add(s.id); return true; }); + timer.stop(); // formatting + + // End-to-end wall time — deliberately a separate mark so callers can + // compare sum(phases) vs wall to see how much Promise.all concurrency + // saved. Must come before summary() so it's included. + timer.mark('wall', performance.now() - wallStart); + const timing = timer.summary(); + logQueryTiming(searchQuery, timing); return { processes, process_symbols: dedupedSymbols, definitions: definitions.slice(0, 20), // cap standalone definitions + timing, ...(!ftsUsed && { warning: 'FTS extension unavailable - keyword search degraded. Run: gitnexus analyze --force to rebuild indexes.', diff --git a/gitnexus/test/integration/local-backend-calltool.test.ts b/gitnexus/test/integration/local-backend-calltool.test.ts index 52f9b2487..b32aad270 100644 --- a/gitnexus/test/integration/local-backend-calltool.test.ts +++ b/gitnexus/test/integration/local-backend-calltool.test.ts @@ -104,6 +104,14 @@ withTestLbugDB( (result.process_symbols?.length || 0) + (result.definitions?.length || 0); expect(totalResults).toBeGreaterThanOrEqual(1); + + // #553: query response carries per-phase timing metadata. + expect(result.timing).toBeDefined(); + expect(typeof result.timing.wall).toBe('number'); + expect(result.timing.wall).toBeGreaterThanOrEqual(0); + // At least one of the search phases must have fired for any + // non-error response — bm25 and/or vector always runs. + expect(result.timing.bm25 ?? result.timing.vector).toBeGreaterThanOrEqual(0); }); it('unknown tool throws', async () => { diff --git a/gitnexus/test/unit/phase-timer.test.ts b/gitnexus/test/unit/phase-timer.test.ts new file mode 100644 index 000000000..f606ffa7d --- /dev/null +++ b/gitnexus/test/unit/phase-timer.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { PhaseTimer } from '../../src/core/search/phase-timer.js'; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe('PhaseTimer', () => { + it('start/stop records a single phase', async () => { + const t = new PhaseTimer(); + t.start('bm25'); + await sleep(20); + t.stop(); + + const phases = t.summary(); + expect(phases.bm25).toBeGreaterThanOrEqual(15); // allow a bit of scheduler slack + expect(Object.keys(phases)).toEqual(['bm25']); + }); + + it('start implicitly stops the previous phase', async () => { + const t = new PhaseTimer(); + t.start('a'); + await sleep(10); + t.start('b'); // auto-stops 'a' + await sleep(10); + t.stop(); + + const phases = t.summary(); + expect(phases.a).toBeGreaterThanOrEqual(5); + expect(phases.b).toBeGreaterThanOrEqual(5); + }); + + it('mark accumulates additive durations for the same phase', () => { + const t = new PhaseTimer(); + t.mark('x', 5); + t.mark('x', 3); + t.mark('y', 7); + + const phases = t.summary(); + expect(phases.x).toBe(8); + expect(phases.y).toBe(7); + }); + + it('time() records concurrent promises independently (Promise.all safe)', async () => { + const t = new PhaseTimer(); + await Promise.all([t.time('a', sleep(30)), t.time('b', sleep(80))]); + + const phases = t.summary(); + // Both phases recorded independently despite overlapping in time. + expect(phases.a).toBeGreaterThanOrEqual(25); + expect(phases.a).toBeLessThan(80); + expect(phases.b).toBeGreaterThanOrEqual(75); + }); + + it('mark rejects negative or non-finite durations', () => { + const t = new PhaseTimer(); + t.mark('x', -1); + t.mark('x', Number.NaN); + t.mark('x', Number.POSITIVE_INFINITY); + + const phases = t.summary(); + expect(phases.x).toBeUndefined(); + }); + + it('totalMs sums all phases and implicitly stops the active one', async () => { + const t = new PhaseTimer(); + t.mark('a', 10); + t.mark('b', 15); + t.start('c'); + await sleep(20); + // Call totalMs without stopping — it should stop 'c' implicitly. + const total = t.totalMs(); + + expect(total).toBeGreaterThanOrEqual(40); // 10 + 15 + ~20 + const phases = t.summary(); + expect(phases.c).toBeGreaterThanOrEqual(15); + }); +}); From 8cf9ae0e0d8a4425caaeb8d453517dc9f3e677f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 16:41:38 +0100 Subject: [PATCH 28/46] feat(shared): ScopeTree + PositionIndex + makeScopeId (#912, RFC #909 Ring 2 SHARED) (#961) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the scope-tree spine and position-indexed lookup as pure logic in `gitnexus-shared`. Generalizes the `enclosingFunctions` pattern from closed PR #902 to arbitrary `ScopeKind`s. Three modules under `gitnexus-shared/src/scope-resolution/`: 1. `scope-id.ts` — `makeScopeId({filePath, range, kind})` builds the canonical RFC §2.2 shape `scope:{filePath}#{startLine}:{startCol}-{endLine}:{endCol}:{kind}` and interns the result through a process-local pool so repeated calls with structurally identical inputs return the same string reference. `clearScopeIdInternPool()` exported for test isolation. 2. `scope-tree.ts` — `buildScopeTree(scopes)` validates invariants and returns an immutable `ScopeTree`: - `getScope(id)` / `getParent(id)` / `getChildren(id)` / `getAncestors(id)` - Implements the `ScopeLookup` contract from #916, so `resolveTypeRef` can consume a `ScopeTree` directly (test included). Invariants enforced (throw `ScopeTreeInvariantError` on violation): - Non-Module scopes must have a parent. - Parent must exist in the supplied set. - Parent range STRICTLY contains child range (equal ranges rejected). - Sibling ranges under the same parent do not overlap. Ranges that merely touch at the boundary (`a.end == b.start`) are accepted. - Parent and child live in the same filePath. - Duplicate scope ids are rejected. 3. `position-index.ts` — `buildPositionIndex(scopes)` produces a `PositionIndex` with `atPosition(filePath, line, col)`. Per-file sorted array; binary-search the upper bound of `start ≤ query`, scan backward through the prefix, return the first containing hit. Complexity: `O(log N_file + D)` typical (D = lexical depth ≤ ~10); degrades to `O(N_file)` only under pathological inputs (many scopes starting at the same position). "Innermost wins" falls out of the sort + backward-scan contract because `ScopeTree`'s invariants guarantee that scopes containing a point form an ancestor chain. Types: - `ScopeTree` now exported from `scope-tree.ts`. The Ring 1 opaque placeholder in `types.ts` has been removed; LanguageProvider hooks that previously took `ScopeTree = unknown` now receive the concrete interface (CLI `tsc --noEmit` passes — no existing callers rely on the opaque shape). Tests (39, all passing): - scope-id: canonical shape · all six ScopeKinds encoded · identity equality (same inputs → same reference) · distinguished by filePath / range / kind · purity under repeated calls · intern-pool clear preserves canonical shape. - scope-tree: empty tree · single module · nested Module→Class→Function · multiple siblings input-order preserved · ScopeLookup integration with resolveTypeRef · frozen children and ancestor arrays · all six invariant violations (non-Module orphan, parent-not-found, parent doesn't contain, parent == child, siblings overlap, cross-file parent, duplicate id) · boundary-touching siblings accepted. - position-index: empty · unindexed filePath · before/after-file queries · start/end inclusivity · innermost-wins for nested / co- starting / co-ending / same-line scopes · sibling dispatch · multi- file isolation · size · id-dedup. Combined scope-resolution / model / shadow suite: 190/190 pass. `tsc --noEmit` clean in both `gitnexus-shared` and `gitnexus`. Closes part of #909. Unblocks #917 (`Registry.lookup` needs the scope spine); makes `ScopeLookup` in #916 concrete without API churn. --- gitnexus-shared/src/index.ts | 9 +- .../src/scope-resolution/position-index.ts | 154 +++++++++ .../src/scope-resolution/scope-id.ts | 57 ++++ .../src/scope-resolution/scope-tree.ts | 254 +++++++++++++++ gitnexus-shared/src/scope-resolution/types.ts | 9 +- .../scope-resolution/position-index.test.ts | 191 +++++++++++ .../unit/scope-resolution/scope-id.test.ts | 81 +++++ .../unit/scope-resolution/scope-tree.test.ts | 299 ++++++++++++++++++ 8 files changed, 1047 insertions(+), 7 deletions(-) create mode 100644 gitnexus-shared/src/scope-resolution/position-index.ts create mode 100644 gitnexus-shared/src/scope-resolution/scope-id.ts create mode 100644 gitnexus-shared/src/scope-resolution/scope-tree.ts create mode 100644 gitnexus/test/unit/scope-resolution/position-index.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/scope-id.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/scope-tree.test.ts diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index edacb1214..f73c18f73 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -47,7 +47,6 @@ export type { ParsedImport, ParsedTypeBinding, WorkspaceIndex, - ScopeTree, Callsite, } from './scope-resolution/types.js'; @@ -82,6 +81,14 @@ export type { MethodDispatchInput, } from './scope-resolution/method-dispatch-index.js'; +// Scope tree spine + position lookup (RFC §2.2 + §3.1; Ring 2 SHARED #912) +export { makeScopeId, clearScopeIdInternPool } from './scope-resolution/scope-id.js'; +export type { ScopeIdInput } from './scope-resolution/scope-id.js'; +export { buildScopeTree, ScopeTreeInvariantError } from './scope-resolution/scope-tree.js'; +export type { ScopeTree } from './scope-resolution/scope-tree.js'; +export { buildPositionIndex } from './scope-resolution/position-index.js'; +export type { PositionIndex } from './scope-resolution/position-index.js'; + // Shadow-mode diff + aggregation (RFC §6.3; Ring 2 SHARED #918) export { diffResolutions } from './scope-resolution/shadow/diff.js'; export type { diff --git a/gitnexus-shared/src/scope-resolution/position-index.ts b/gitnexus-shared/src/scope-resolution/position-index.ts new file mode 100644 index 000000000..82954a19f --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/position-index.ts @@ -0,0 +1,154 @@ +/** + * `PositionIndex` — O(log N_file) scope-at-position lookup + * (RFC §3.1; Ring 2 SHARED #912). + * + * Per-file sorted array of `(range, scopeId)` entries, sorted by start + * position ASC (`startLine`, then `startCol`). `atPosition(filePath, line, + * col)` binary-searches for the last entry whose start ≤ (line, col), then + * scans backward through the sorted prefix and returns the first entry + * whose range contains the query position. + * + * **Why this works.** `ScopeTree`'s invariants (parent strictly contains + * child; siblings don't overlap) guarantee that the scopes containing a + * given point form an **ancestor chain**. When scanning backward through + * entries sorted by start position ASC, the first scope we find that + * contains the query is the innermost one — any deeper-starting scope + * that also contained the query would appear *later* in the sorted array, + * but we're only scanning entries with start ≤ query, so anything later + * necessarily starts after the query and can't contain it. + * + * Expected complexity: `O(log N_file + D)` where `D` is the lexical depth + * at the query position (typically ≤ 10). Worst-case degrades to `O(N_file)` + * only under pathological inputs (many scopes starting at the same line). + * + * **Line/column conventions.** Matches `Range` in `types.ts`: lines are + * 1-based, columns are 0-based. Ranges are **inclusive on both ends** — + * a scope whose `endLine:endCol` equals the query position still contains + * it. That matches how tree-sitter captures bodies (closing brace + * included) and how closed PR #902's `enclosingFunctions` behaved. + */ + +import type { Range, Scope, ScopeId } from './types.js'; + +export interface PositionIndex { + /** Total scope entries indexed across all files. */ + readonly size: number; + /** + * Innermost scope containing `(line, col)` in `filePath`, or `undefined` + * when nothing contains it (position before file start, after file end, + * or filePath not indexed). + */ + atPosition(filePath: string, line: number, col: number): ScopeId | undefined; +} + +/** + * Build a `PositionIndex` from a flat list of `Scope` records. + * + * Duplicate `id`s are tolerated and deduplicated — the caller's + * `ScopeTree.buildScopeTree` is the authoritative validator of scope + * identity, and the position index does not need to re-check that + * invariant. + */ +export function buildPositionIndex(scopes: readonly Scope[]): PositionIndex { + const entriesByFile = new Map(); + const seen = new Set(); + + for (const scope of scopes) { + if (seen.has(scope.id)) continue; + seen.add(scope.id); + + let bucket = entriesByFile.get(scope.filePath); + if (bucket === undefined) { + bucket = []; + entriesByFile.set(scope.filePath, bucket); + } + bucket.push({ id: scope.id, range: scope.range }); + } + + for (const bucket of entriesByFile.values()) { + bucket.sort(compareEntry); + } + + return freezeIndex(entriesByFile, seen.size); +} + +// ─── Internals ────────────────────────────────────────────────────────────── + +interface Entry { + readonly id: ScopeId; + readonly range: Range; +} + +/** + * Sort by start position ASC, breaking ties by end position DESC so that + * larger (outer) scopes appear before their smaller (inner) co-starting + * siblings in the array. Makes the backward-scan contract crisp: the + * first containing hit from the end of the scanned prefix is the + * innermost scope. + */ +function compareEntry(a: Entry, b: Entry): number { + if (a.range.startLine !== b.range.startLine) return a.range.startLine - b.range.startLine; + if (a.range.startCol !== b.range.startCol) return a.range.startCol - b.range.startCol; + if (a.range.endLine !== b.range.endLine) return b.range.endLine - a.range.endLine; + return b.range.endCol - a.range.endCol; +} + +/** Whether `(line, col)` is at or after `range`'s start. */ +function startIsAtOrBefore(range: Range, line: number, col: number): boolean { + if (range.startLine < line) return true; + if (range.startLine > line) return false; + return range.startCol <= col; +} + +/** Whether `(line, col)` is at or before `range`'s end (inclusive). */ +function endIsAtOrAfter(range: Range, line: number, col: number): boolean { + if (range.endLine > line) return true; + if (range.endLine < line) return false; + return range.endCol >= col; +} + +/** + * Return the largest index `i` in `arr` where `arr[i].range` starts at or + * before `(line, col)`. Returns `-1` if no entry starts ≤ the query. + * + * Classic "upper bound - 1" binary search: find the first entry that + * starts *after* the query, then step back one. + */ +function findLastStartLteIndex(arr: readonly Entry[], line: number, col: number): number { + let lo = 0; + let hi = arr.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (startIsAtOrBefore(arr[mid]!.range, line, col)) { + lo = mid + 1; + } else { + hi = mid; + } + } + return lo - 1; +} + +function freezeIndex(entriesByFile: Map, size: number): PositionIndex { + return { + get size() { + return size; + }, + atPosition(filePath: string, line: number, col: number): ScopeId | undefined { + const bucket = entriesByFile.get(filePath); + if (bucket === undefined || bucket.length === 0) return undefined; + + const endIdx = findLastStartLteIndex(bucket, line, col); + if (endIdx < 0) return undefined; + + // Scan backward; first containing hit is innermost (see file header). + for (let i = endIdx; i >= 0; i--) { + const entry = bucket[i]!; + if (endIsAtOrAfter(entry.range, line, col)) { + // `startIsAtOrBefore` is guaranteed true by the binary search. + return entry.id; + } + } + return undefined; + }, + }; +} diff --git a/gitnexus-shared/src/scope-resolution/scope-id.ts b/gitnexus-shared/src/scope-resolution/scope-id.ts new file mode 100644 index 000000000..b682468cc --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/scope-id.ts @@ -0,0 +1,57 @@ +/** + * `ScopeId` canonical constructor + string intern pool + * (RFC §2.2; Ring 2 SHARED #912). + * + * `ScopeId` is a deterministic string derived from the scope's file path, + * byte range, and kind: + * + * scope:{filePath}#{startLine}:{startCol}-{endLine}:{endCol}:{kind} + * + * Two scopes produced by reparsing the same file at the same positions are + * `===`-equal as strings. Beyond the canonical shape, `makeScopeId` also + * **interns** the string through a process-local pool, so repeated calls + * with structurally identical inputs return the same string reference — + * making `Map` lookups and cache keys identity-fast. + * + * The intern pool is unbounded. The number of distinct `ScopeId`s across a + * single indexing run is O(total scopes in workspace), which is bounded by + * source-text size and already in memory; interning adds no asymptotic + * pressure. `clearScopeIdInternPool` is exported for test isolation. + */ + +import type { Range } from './types.js'; +import type { ScopeId, ScopeKind } from './types.js'; + +/** Inputs required to construct a canonical `ScopeId`. */ +export interface ScopeIdInput { + readonly filePath: string; + readonly range: Range; + readonly kind: ScopeKind; +} + +/** + * Build a canonical `ScopeId` from its structural parts and intern it. + * + * Pure + referentially transparent: given the same input shape, always + * returns the same string reference for the lifetime of the pool. + */ +export function makeScopeId(input: ScopeIdInput): ScopeId { + const raw = `scope:${input.filePath}#${input.range.startLine}:${input.range.startCol}-${input.range.endLine}:${input.range.endCol}:${input.kind}`; + const existing = INTERN_POOL.get(raw); + if (existing !== undefined) return existing; + INTERN_POOL.set(raw, raw); + return raw; +} + +/** + * Drop the intern pool. Intended for test setup/teardown — production code + * should not need this, since the pool's memory usage is bounded by the + * number of live scopes and cleaning it mid-run would break identity + * equality for existing scope ids. + */ +export function clearScopeIdInternPool(): void { + INTERN_POOL.clear(); +} + +/** Internal: shared intern pool (process-local). */ +const INTERN_POOL = new Map(); diff --git a/gitnexus-shared/src/scope-resolution/scope-tree.ts b/gitnexus-shared/src/scope-resolution/scope-tree.ts new file mode 100644 index 000000000..705dfb731 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/scope-tree.ts @@ -0,0 +1,254 @@ +/** + * `ScopeTree` — the lexical-scope spine of the `SemanticModel` + * (RFC §2.2 + §3.1; Ring 2 SHARED #912). + * + * Generalizes the `enclosingFunctions` pattern from closed PR #902 to + * arbitrary `ScopeKind`s. Owns the (parent ↔ children) relationship + * derived from each `Scope.parent` pointer, and validates the structural + * invariants a well-formed scope tree must satisfy. + * + * Invariants enforced at build time (throw on violation): + * + * - Every non-`Module` scope has a non-null parent. + * - Every parent pointer references a scope that was also supplied to + * `buildScopeTree`. + * - Parent range **strictly contains** child range. + * - Sibling ranges under the same parent do not overlap. + * - Parent and child live in the same `filePath`. (Cross-file parent + * pointers would be a category error — a `File` scope is not the + * parent of another file's scopes; imports do that job.) + * + * Satisfies the `ScopeLookup` contract from #916 (`resolve-type-ref`), so + * `resolveTypeRef` can take a `ScopeTree` directly without adapters. + * + * Immutable surface: `byId` is a `ReadonlyMap`; children arrays are + * `Object.freeze`d; miss lookups return a shared frozen empty array. + */ + +import type { Scope, ScopeId, Range } from './types.js'; +import type { ScopeLookup } from './resolve-type-ref.js'; + +// ─── Public contract ──────────────────────────────────────────────────────── + +export interface ScopeTree extends ScopeLookup { + readonly size: number; + readonly byId: ReadonlyMap; + + getScope(id: ScopeId): Scope | undefined; + getParent(id: ScopeId): Scope | undefined; + /** Child `ScopeId`s of `id`, in input order. Frozen empty array on miss. */ + getChildren(id: ScopeId): readonly ScopeId[]; + /** + * Ancestor chain from the immediate parent up to (and including) the + * root module scope. Excludes the starting scope itself. Frozen empty + * array on miss / for a root scope. + */ + getAncestors(id: ScopeId): readonly ScopeId[]; + has(id: ScopeId): boolean; +} + +// ─── Build errors ─────────────────────────────────────────────────────────── + +/** + * Thrown by `buildScopeTree` when the input violates a structural + * invariant. Carries the offending ids + the invariant name so failed + * extraction pipelines can report actionable diagnostics. + */ +export class ScopeTreeInvariantError extends Error { + constructor( + readonly invariant: + | 'non-module-requires-parent' + | 'parent-not-found' + | 'parent-must-contain-child' + | 'sibling-ranges-overlap' + | 'parent-must-share-filepath' + | 'duplicate-scope-id', + message: string, + ) { + super(message); + this.name = 'ScopeTreeInvariantError'; + } +} + +// ─── Builder ─────────────────────────────────────────────────────────────── + +/** + * Build an immutable `ScopeTree` from a flat list of `Scope` records. + * + * Throws `ScopeTreeInvariantError` on the first invariant violation; a + * malformed tree is a bug in the extraction pipeline, not a data case for + * consumers to handle, so fail-fast is the correct posture. + */ +export function buildScopeTree(scopes: readonly Scope[]): ScopeTree { + const byId = new Map(); + const childrenById = new Map(); + + // ── Pass 1: collect by id + duplicate check ─────────────────────────── + for (const scope of scopes) { + if (byId.has(scope.id)) { + throw new ScopeTreeInvariantError( + 'duplicate-scope-id', + `Two scopes share id '${scope.id}'. Scope ids must be unique per tree.`, + ); + } + byId.set(scope.id, scope); + } + + // ── Pass 2: validate parent pointers + build children buckets ───────── + for (const scope of scopes) { + if (scope.parent === null) { + if (scope.kind !== 'Module') { + throw new ScopeTreeInvariantError( + 'non-module-requires-parent', + `Scope '${scope.id}' has kind '${scope.kind}' but no parent. Only 'Module' scopes may be root-level.`, + ); + } + continue; + } + + const parent = byId.get(scope.parent); + if (parent === undefined) { + throw new ScopeTreeInvariantError( + 'parent-not-found', + `Scope '${scope.id}' references parent '${scope.parent}' which is not part of this tree.`, + ); + } + if (parent.filePath !== scope.filePath) { + throw new ScopeTreeInvariantError( + 'parent-must-share-filepath', + `Scope '${scope.id}' (${scope.filePath}) has parent '${parent.id}' in a different file (${parent.filePath}). Parent/child scopes must share filePath.`, + ); + } + if (!rangeStrictlyContains(parent.range, scope.range)) { + throw new ScopeTreeInvariantError( + 'parent-must-contain-child', + `Parent scope '${parent.id}' at ${formatRange(parent.range)} does not strictly contain child '${scope.id}' at ${formatRange(scope.range)}.`, + ); + } + + let bucket = childrenById.get(parent.id); + if (bucket === undefined) { + bucket = []; + childrenById.set(parent.id, bucket); + } + bucket.push(scope.id); + } + + // ── Pass 3: sibling-overlap check ───────────────────────────────────── + for (const [parentId, childIds] of childrenById) { + if (childIds.length < 2) continue; + // Sort siblings by (startLine, startCol) for an O(n log n) pairwise + // scan instead of O(n²) all-pairs. + const children = childIds.map((id) => byId.get(id)!).slice(); + children.sort((a, b) => comparePosition(a.range, b.range)); + for (let i = 1; i < children.length; i++) { + const prev = children[i - 1]!; + const curr = children[i]!; + if (rangesOverlap(prev.range, curr.range)) { + throw new ScopeTreeInvariantError( + 'sibling-ranges-overlap', + `Sibling scopes under parent '${parentId}' overlap: '${prev.id}' ${formatRange(prev.range)} and '${curr.id}' ${formatRange(curr.range)}.`, + ); + } + } + } + + // Freeze children arrays so the surface is truly read-only. + const frozenChildren = new Map(); + for (const [parentId, childIds] of childrenById) { + frozenChildren.set(parentId, Object.freeze(childIds.slice())); + } + + return freezeTree(byId, frozenChildren); +} + +// ─── Internals ────────────────────────────────────────────────────────────── + +const EMPTY_CHILDREN: readonly ScopeId[] = Object.freeze([]); + +function freezeTree( + byId: Map, + childrenById: Map, +): ScopeTree { + return { + byId, + get size() { + return byId.size; + }, + getScope(id: ScopeId): Scope | undefined { + return byId.get(id); + }, + getParent(id: ScopeId): Scope | undefined { + const scope = byId.get(id); + if (scope === undefined || scope.parent === null) return undefined; + return byId.get(scope.parent); + }, + getChildren(id: ScopeId): readonly ScopeId[] { + return childrenById.get(id) ?? EMPTY_CHILDREN; + }, + getAncestors(id: ScopeId): readonly ScopeId[] { + const start = byId.get(id); + if (start === undefined || start.parent === null) return EMPTY_CHILDREN; + const out: ScopeId[] = []; + const visited = new Set([id]); + let cursor: ScopeId | null = start.parent; + while (cursor !== null && !visited.has(cursor)) { + visited.add(cursor); + out.push(cursor); + const next = byId.get(cursor); + cursor = next === undefined ? null : next.parent; + } + return Object.freeze(out); + }, + has(id: ScopeId): boolean { + return byId.has(id); + }, + }; +} + +/** + * `outer` strictly contains `inner` when `outer`'s start is at or before + * `inner`'s start, `outer`'s end is at or after `inner`'s end, and they are + * not the exact same range. Equal ranges are rejected — a child cannot + * occupy the exact same span as its parent. + */ +function rangeStrictlyContains(outer: Range, inner: Range): boolean { + if ( + outer.startLine === inner.startLine && + outer.startCol === inner.startCol && + outer.endLine === inner.endLine && + outer.endCol === inner.endCol + ) { + return false; + } + const outerStartsAtOrBefore = + outer.startLine < inner.startLine || + (outer.startLine === inner.startLine && outer.startCol <= inner.startCol); + const outerEndsAtOrAfter = + outer.endLine > inner.endLine || + (outer.endLine === inner.endLine && outer.endCol >= inner.endCol); + return outerStartsAtOrBefore && outerEndsAtOrAfter; +} + +/** + * Two ranges overlap when neither finishes before the other begins. Ranges + * that merely touch at a single boundary point (`a.end === b.start`) do + * NOT overlap — this matches tree-sitter's half-open-like range semantics + * and the typical "sibling blocks meet but don't overlap" pattern. + */ +function rangesOverlap(a: Range, b: Range): boolean { + const aEndsBeforeB = + a.endLine < b.startLine || (a.endLine === b.startLine && a.endCol <= b.startCol); + const bEndsBeforeA = + b.endLine < a.startLine || (b.endLine === a.startLine && b.endCol <= a.startCol); + return !(aEndsBeforeB || bEndsBeforeA); +} + +function comparePosition(a: Range, b: Range): number { + if (a.startLine !== b.startLine) return a.startLine - b.startLine; + return a.startCol - b.startCol; +} + +function formatRange(r: Range): string { + return `${r.startLine}:${r.startCol}-${r.endLine}:${r.endCol}`; +} diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index 84945dcd9..d04373a94 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -190,12 +190,9 @@ export interface ParsedTypeBinding { */ export type WorkspaceIndex = unknown; -/** - * Scope tree handle consumed by parse-phase hooks (`bindingScopeFor`, - * `importOwningScope`) to navigate the in-progress scope tree. Opaque - * placeholder in Ring 1; concretely typed in Ring 2 SHARED (#912). - */ -export type ScopeTree = unknown; +// `ScopeTree` is exported from `./scope-tree.js` as of Ring 2 SHARED (#912). +// The former opaque placeholder lived here during Ring 1; removed now that +// the concrete type exists. Consumers import from `gitnexus-shared` directly. /** Call-site description passed to `arityCompatibility`. */ export interface Callsite { diff --git a/gitnexus/test/unit/scope-resolution/position-index.test.ts b/gitnexus/test/unit/scope-resolution/position-index.test.ts new file mode 100644 index 000000000..8c3a410b1 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/position-index.test.ts @@ -0,0 +1,191 @@ +/** + * Unit tests for `buildPositionIndex` / `PositionIndex` + * (RFC #909 Ring 2 SHARED #912). + * + * Covers: empty input, single scope, nested scopes (innermost-wins), + * positions before/after all scopes, boundary positions (inclusive ends), + * multi-file isolation, and duplicate-scope-id dedup. + */ + +import { describe, it, expect } from 'vitest'; +import { + buildPositionIndex, + type Range, + type Scope, + type ScopeId, + type ScopeKind, +} from 'gitnexus-shared'; + +// ─── Test helpers ─────────────────────────────────────────────────────────── + +const r = (startLine: number, startCol: number, endLine: number, endCol: number): Range => ({ + startLine, + startCol, + endLine, + endCol, +}); + +const mkScope = ( + id: ScopeId, + filePath: string, + kind: ScopeKind, + range: Range, + parent: ScopeId | null = null, +): Scope => ({ + id, + parent, + kind, + range, + filePath, + bindings: new Map(), + ownedDefs: [], + imports: [], + typeBindings: new Map(), +}); + +// ─── Tests ────────────────────────────────────────────────────────────────── + +describe('buildPositionIndex', () => { + describe('empty / missing', () => { + it('returns undefined for any query on an empty index', () => { + const idx = buildPositionIndex([]); + expect(idx.size).toBe(0); + expect(idx.atPosition('src/any.ts', 1, 0)).toBeUndefined(); + }); + + it('returns undefined for unindexed filePaths', () => { + const idx = buildPositionIndex([mkScope('scope:m', 'a.ts', 'Module', r(1, 0, 10, 0))]); + expect(idx.atPosition('b.ts', 5, 0)).toBeUndefined(); + }); + + it('returns undefined for positions before any scope in the file', () => { + const idx = buildPositionIndex([mkScope('scope:m', 'a.ts', 'Module', r(5, 0, 10, 0))]); + expect(idx.atPosition('a.ts', 1, 0)).toBeUndefined(); + expect(idx.atPosition('a.ts', 4, 99)).toBeUndefined(); + }); + + it('returns undefined for positions after all scopes in the file', () => { + const idx = buildPositionIndex([mkScope('scope:m', 'a.ts', 'Module', r(1, 0, 10, 5))]); + expect(idx.atPosition('a.ts', 11, 0)).toBeUndefined(); + expect(idx.atPosition('a.ts', 10, 6)).toBeUndefined(); + }); + }); + + describe('single scope lookup', () => { + it('returns the scope id for a point inside its range', () => { + const idx = buildPositionIndex([mkScope('scope:m', 'a.ts', 'Module', r(1, 0, 10, 0))]); + expect(idx.atPosition('a.ts', 5, 4)).toBe('scope:m'); + }); + + it('includes the start boundary', () => { + const idx = buildPositionIndex([mkScope('scope:m', 'a.ts', 'Module', r(5, 2, 10, 0))]); + expect(idx.atPosition('a.ts', 5, 2)).toBe('scope:m'); + expect(idx.atPosition('a.ts', 5, 1)).toBeUndefined(); + }); + + it('includes the end boundary', () => { + const idx = buildPositionIndex([mkScope('scope:m', 'a.ts', 'Module', r(1, 0, 10, 5))]); + expect(idx.atPosition('a.ts', 10, 5)).toBe('scope:m'); + expect(idx.atPosition('a.ts', 10, 6)).toBeUndefined(); + }); + }); + + describe('innermost-containing wins', () => { + it('picks the innermost of nested scopes', () => { + // Module[1..100] ⊃ Class[5..80] ⊃ Function[10..60] ⊃ Block[20..50] + const idx = buildPositionIndex([ + mkScope('scope:mod', 'a.ts', 'Module', r(1, 0, 100, 0)), + mkScope('scope:cls', 'a.ts', 'Class', r(5, 0, 80, 0), 'scope:mod'), + mkScope('scope:fn', 'a.ts', 'Function', r(10, 0, 60, 0), 'scope:cls'), + mkScope('scope:blk', 'a.ts', 'Block', r(20, 0, 50, 0), 'scope:fn'), + ]); + expect(idx.atPosition('a.ts', 30, 0)).toBe('scope:blk'); // deepest + expect(idx.atPosition('a.ts', 15, 0)).toBe('scope:fn'); // inside fn, outside blk + expect(idx.atPosition('a.ts', 7, 0)).toBe('scope:cls'); // inside class body only + expect(idx.atPosition('a.ts', 2, 0)).toBe('scope:mod'); // module top + }); + + it('innermost wins when two scopes start at the same position', () => { + // Two scopes both start at line 5 col 0; outer ends at 50, inner at 20. + const idx = buildPositionIndex([ + mkScope('scope:outer', 'a.ts', 'Module', r(5, 0, 50, 0)), + mkScope('scope:inner', 'a.ts', 'Function', r(5, 0, 20, 0), 'scope:outer'), + ]); + expect(idx.atPosition('a.ts', 10, 0)).toBe('scope:inner'); // both contain; inner wins + expect(idx.atPosition('a.ts', 30, 0)).toBe('scope:outer'); // only outer contains + }); + + it('innermost wins when scopes share an end position but differ in start', () => { + const idx = buildPositionIndex([ + mkScope('scope:outer', 'a.ts', 'Module', r(1, 0, 50, 0)), + mkScope('scope:inner', 'a.ts', 'Function', r(30, 0, 50, 0), 'scope:outer'), + ]); + expect(idx.atPosition('a.ts', 40, 0)).toBe('scope:inner'); + expect(idx.atPosition('a.ts', 20, 0)).toBe('scope:outer'); + }); + + it('returns the sibling whose range contains the query, not the other', () => { + // Two non-overlapping siblings under the same parent. + const idx = buildPositionIndex([ + mkScope('scope:mod', 'a.ts', 'Module', r(1, 0, 100, 0)), + mkScope('scope:a', 'a.ts', 'Function', r(5, 0, 20, 0), 'scope:mod'), + mkScope('scope:b', 'a.ts', 'Function', r(25, 0, 40, 0), 'scope:mod'), + ]); + expect(idx.atPosition('a.ts', 10, 0)).toBe('scope:a'); + expect(idx.atPosition('a.ts', 30, 0)).toBe('scope:b'); + expect(idx.atPosition('a.ts', 22, 0)).toBe('scope:mod'); // gap between siblings + }); + }); + + describe('multi-file isolation', () => { + it('indexes each filePath independently — no cross-file hits', () => { + const idx = buildPositionIndex([ + mkScope('scope:a-mod', 'a.ts', 'Module', r(1, 0, 50, 0)), + mkScope('scope:b-mod', 'b.ts', 'Module', r(1, 0, 50, 0)), + ]); + expect(idx.atPosition('a.ts', 10, 0)).toBe('scope:a-mod'); + expect(idx.atPosition('b.ts', 10, 0)).toBe('scope:b-mod'); + }); + + it('counts all indexed scopes in `size`', () => { + const idx = buildPositionIndex([ + mkScope('scope:a-mod', 'a.ts', 'Module', r(1, 0, 50, 0)), + mkScope('scope:a-fn', 'a.ts', 'Function', r(10, 0, 20, 0), 'scope:a-mod'), + mkScope('scope:b-mod', 'b.ts', 'Module', r(1, 0, 50, 0)), + ]); + expect(idx.size).toBe(3); + }); + }); + + describe('column handling on the same line', () => { + it('handles a single-line scope across columns', () => { + const idx = buildPositionIndex([ + mkScope('scope:expr', 'a.ts', 'Expression', r(5, 10, 5, 20)), + ]); + expect(idx.atPosition('a.ts', 5, 10)).toBe('scope:expr'); // start inclusive + expect(idx.atPosition('a.ts', 5, 15)).toBe('scope:expr'); // middle + expect(idx.atPosition('a.ts', 5, 20)).toBe('scope:expr'); // end inclusive + expect(idx.atPosition('a.ts', 5, 9)).toBeUndefined(); + expect(idx.atPosition('a.ts', 5, 21)).toBeUndefined(); + }); + + it('handles nested scopes on the same line', () => { + const idx = buildPositionIndex([ + mkScope('scope:outer', 'a.ts', 'Expression', r(5, 0, 5, 30)), + mkScope('scope:inner', 'a.ts', 'Expression', r(5, 10, 5, 20), 'scope:outer'), + ]); + expect(idx.atPosition('a.ts', 5, 15)).toBe('scope:inner'); + expect(idx.atPosition('a.ts', 5, 5)).toBe('scope:outer'); + expect(idx.atPosition('a.ts', 5, 25)).toBe('scope:outer'); + }); + }); + + describe('robustness', () => { + it('deduplicates scopes with the same id', () => { + const s = mkScope('scope:dup', 'a.ts', 'Module', r(1, 0, 10, 0)); + const idx = buildPositionIndex([s, s, s]); + expect(idx.size).toBe(1); + expect(idx.atPosition('a.ts', 5, 0)).toBe('scope:dup'); + }); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/scope-id.test.ts b/gitnexus/test/unit/scope-resolution/scope-id.test.ts new file mode 100644 index 000000000..901c89868 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/scope-id.test.ts @@ -0,0 +1,81 @@ +/** + * Unit tests for `makeScopeId` (RFC #909 Ring 2 SHARED #912). + * + * Covers canonical shape, determinism across calls, string interning, + * and that different inputs produce different ids. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { makeScopeId, clearScopeIdInternPool, type Range, type ScopeKind } from 'gitnexus-shared'; + +const r = (startLine: number, startCol: number, endLine: number, endCol: number): Range => ({ + startLine, + startCol, + endLine, + endCol, +}); + +describe('makeScopeId', () => { + beforeEach(() => { + clearScopeIdInternPool(); + }); + + it('produces the canonical RFC §2.2 shape', () => { + const id = makeScopeId({ filePath: 'src/app.ts', range: r(1, 0, 100, 0), kind: 'Module' }); + expect(id).toBe('scope:src/app.ts#1:0-100:0:Module'); + }); + + it('encodes each ScopeKind verbatim in the id', () => { + const kinds: readonly ScopeKind[] = [ + 'Module', + 'Namespace', + 'Class', + 'Function', + 'Block', + 'Expression', + ]; + for (const kind of kinds) { + const id = makeScopeId({ filePath: 'f.ts', range: r(1, 0, 2, 0), kind }); + expect(id.endsWith(`:${kind}`)).toBe(true); + } + }); + + it('returns the SAME string reference for structurally identical inputs (interned)', () => { + const a = makeScopeId({ filePath: 'src/a.ts', range: r(5, 4, 10, 2), kind: 'Function' }); + const b = makeScopeId({ filePath: 'src/a.ts', range: r(5, 4, 10, 2), kind: 'Function' }); + expect(a).toBe(b); + // `Object.is` catches the same reference even for weird strings. + expect(Object.is(a, b)).toBe(true); + }); + + it('distinguishes ids that differ only by filePath', () => { + const a = makeScopeId({ filePath: 'src/a.ts', range: r(1, 0, 2, 0), kind: 'Module' }); + const b = makeScopeId({ filePath: 'src/b.ts', range: r(1, 0, 2, 0), kind: 'Module' }); + expect(a).not.toBe(b); + }); + + it('distinguishes ids that differ only by range', () => { + const a = makeScopeId({ filePath: 'f.ts', range: r(1, 0, 2, 0), kind: 'Function' }); + const b = makeScopeId({ filePath: 'f.ts', range: r(1, 0, 3, 0), kind: 'Function' }); + expect(a).not.toBe(b); + }); + + it('distinguishes ids that differ only by kind', () => { + const a = makeScopeId({ filePath: 'f.ts', range: r(1, 0, 2, 0), kind: 'Function' }); + const b = makeScopeId({ filePath: 'f.ts', range: r(1, 0, 2, 0), kind: 'Block' }); + expect(a).not.toBe(b); + }); + + it('is safe to call repeatedly (pure)', () => { + const inputs = { filePath: 'f.ts', range: r(1, 0, 5, 0), kind: 'Function' as const }; + const ids = Array.from({ length: 10 }, () => makeScopeId(inputs)); + expect(new Set(ids).size).toBe(1); + }); + + it('clearScopeIdInternPool drops the intern pool without changing id shape', () => { + const before = makeScopeId({ filePath: 'f.ts', range: r(1, 0, 2, 0), kind: 'Module' }); + clearScopeIdInternPool(); + const after = makeScopeId({ filePath: 'f.ts', range: r(1, 0, 2, 0), kind: 'Module' }); + expect(after).toBe(before); // same string value, canonical-by-construction + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/scope-tree.test.ts b/gitnexus/test/unit/scope-resolution/scope-tree.test.ts new file mode 100644 index 000000000..7173ba3fc --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/scope-tree.test.ts @@ -0,0 +1,299 @@ +/** + * Unit tests for `buildScopeTree` / `ScopeTree` (RFC #909 Ring 2 SHARED #912). + * + * Covers: empty tree, single-module tree, nested module→class→function, + * siblings, ancestors walk, children lookup, readonly surface, and all six + * invariant violations (non-module without parent, parent not found, parent + * doesn't contain child, siblings overlap, cross-file parent, duplicate id). + * Also confirms that a `ScopeTree` satisfies the `ScopeLookup` contract from + * #916 so `resolveTypeRef` can consume it directly. + */ + +import { describe, it, expect } from 'vitest'; +import { + buildScopeTree, + ScopeTreeInvariantError, + resolveTypeRef, + buildDefIndex, + buildQualifiedNameIndex, + type BindingRef, + type Range, + type Scope, + type ScopeId, + type ScopeKind, + type SymbolDefinition, +} from 'gitnexus-shared'; + +// ─── Test helpers ─────────────────────────────────────────────────────────── + +const r = (startLine: number, startCol: number, endLine: number, endCol: number): Range => ({ + startLine, + startCol, + endLine, + endCol, +}); + +interface ScopeFixture { + id: ScopeId; + parent: ScopeId | null; + kind: ScopeKind; + range: Range; + filePath?: string; + bindings?: Record; +} + +const mkScope = (f: ScopeFixture): Scope => ({ + id: f.id, + parent: f.parent, + kind: f.kind, + range: f.range, + filePath: f.filePath ?? 'src/test.ts', + bindings: new Map(Object.entries(f.bindings ?? {})), + ownedDefs: [], + imports: [], + typeBindings: new Map(), +}); + +// ─── Tests ────────────────────────────────────────────────────────────────── + +describe('buildScopeTree', () => { + describe('shape + lookup', () => { + it('builds an empty tree from no scopes', () => { + const tree = buildScopeTree([]); + expect(tree.size).toBe(0); + expect(tree.has('scope:missing')).toBe(false); + expect(tree.getScope('scope:missing')).toBeUndefined(); + expect(tree.getParent('scope:missing')).toBeUndefined(); + expect(tree.getChildren('scope:missing')).toEqual([]); + expect(tree.getAncestors('scope:missing')).toEqual([]); + }); + + it('round-trips a single module scope', () => { + const m = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 100, 0) }); + const tree = buildScopeTree([m]); + expect(tree.size).toBe(1); + expect(tree.has('scope:m')).toBe(true); + expect(tree.getScope('scope:m')).toBe(m); + expect(tree.getParent('scope:m')).toBeUndefined(); + expect(tree.getChildren('scope:m')).toEqual([]); + expect(tree.getAncestors('scope:m')).toEqual([]); + }); + + it('tracks parent/children for a nested module → class → function tree', () => { + const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 50, 0) }); + const cls = mkScope({ + id: 'scope:c', + parent: 'scope:m', + kind: 'Class', + range: r(5, 0, 40, 0), + }); + const fn = mkScope({ + id: 'scope:f', + parent: 'scope:c', + kind: 'Function', + range: r(10, 2, 30, 2), + }); + const tree = buildScopeTree([mod, cls, fn]); + + expect(tree.size).toBe(3); + expect(tree.getParent('scope:f')).toBe(cls); + expect(tree.getParent('scope:c')).toBe(mod); + expect(tree.getChildren('scope:m')).toEqual(['scope:c']); + expect(tree.getChildren('scope:c')).toEqual(['scope:f']); + expect(tree.getAncestors('scope:f')).toEqual(['scope:c', 'scope:m']); + expect(tree.getAncestors('scope:c')).toEqual(['scope:m']); + }); + + it('records multiple siblings in input order', () => { + const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 100, 0) }); + const fn1 = mkScope({ + id: 'scope:f1', + parent: 'scope:m', + kind: 'Function', + range: r(5, 0, 10, 0), + }); + const fn2 = mkScope({ + id: 'scope:f2', + parent: 'scope:m', + kind: 'Function', + range: r(15, 0, 20, 0), + }); + const fn3 = mkScope({ + id: 'scope:f3', + parent: 'scope:m', + kind: 'Function', + range: r(25, 0, 30, 0), + }); + const tree = buildScopeTree([mod, fn2, fn1, fn3]); // deliberately out of order + expect(tree.getChildren('scope:m')).toEqual(['scope:f2', 'scope:f1', 'scope:f3']); + }); + }); + + describe('ScopeLookup compatibility (#916)', () => { + it('resolveTypeRef can consume a ScopeTree directly', () => { + const userClass: SymbolDefinition = { + nodeId: 'def:User', + filePath: 'src/test.ts', + type: 'Class', + }; + const module = mkScope({ + id: 'scope:m', + parent: null, + kind: 'Module', + range: r(1, 0, 100, 0), + bindings: { User: [{ def: userClass, origin: 'local' }] }, + }); + const fn = mkScope({ + id: 'scope:f', + parent: 'scope:m', + kind: 'Function', + range: r(5, 0, 10, 0), + }); + + const tree = buildScopeTree([module, fn]); + const result = resolveTypeRef( + { rawName: 'User', declaredAtScope: 'scope:f', source: 'parameter-annotation' }, + { + scopes: tree, + defIndex: buildDefIndex([userClass]), + qualifiedNameIndex: buildQualifiedNameIndex([userClass]), + }, + ); + expect(result).toBe(userClass); + }); + }); + + describe('readonly surface', () => { + it('freezes children arrays', () => { + const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 50, 0) }); + const fn = mkScope({ + id: 'scope:f', + parent: 'scope:m', + kind: 'Function', + range: r(5, 0, 10, 0), + }); + const tree = buildScopeTree([mod, fn]); + const children = tree.getChildren('scope:m'); + expect(() => (children as unknown as ScopeId[]).push('x')).toThrow(); + }); + + it('freezes ancestor arrays', () => { + const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 50, 0) }); + const fn = mkScope({ + id: 'scope:f', + parent: 'scope:m', + kind: 'Function', + range: r(5, 0, 10, 0), + }); + const tree = buildScopeTree([mod, fn]); + const ancestors = tree.getAncestors('scope:f'); + expect(() => (ancestors as unknown as ScopeId[]).push('x')).toThrow(); + }); + }); + + describe('invariant violations', () => { + it('throws when a non-Module scope has a null parent', () => { + const orphan = mkScope({ + id: 'scope:f', + parent: null, + kind: 'Function', + range: r(1, 0, 5, 0), + }); + expect(() => buildScopeTree([orphan])).toThrowError(ScopeTreeInvariantError); + expect(() => buildScopeTree([orphan])).toThrowError(/Module/); + }); + + it('throws when a parent pointer references a scope not in the tree', () => { + const fn = mkScope({ + id: 'scope:f', + parent: 'scope:ghost', + kind: 'Function', + range: r(1, 0, 5, 0), + }); + expect(() => buildScopeTree([fn])).toThrowError(ScopeTreeInvariantError); + }); + + it('throws when a parent range does not strictly contain a child range', () => { + const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 10, 0) }); + const fn = mkScope({ + id: 'scope:f', + parent: 'scope:m', + kind: 'Function', + range: r(5, 0, 50, 0), // extends beyond the module + }); + expect(() => buildScopeTree([mod, fn])).toThrowError(ScopeTreeInvariantError); + expect(() => buildScopeTree([mod, fn])).toThrowError(/strictly contain/i); + }); + + it('rejects child ranges identical to the parent (not strictly contained)', () => { + const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 10, 0) }); + const fn = mkScope({ + id: 'scope:f', + parent: 'scope:m', + kind: 'Function', + range: r(1, 0, 10, 0), + }); + expect(() => buildScopeTree([mod, fn])).toThrowError(ScopeTreeInvariantError); + }); + + it('throws when sibling ranges overlap', () => { + const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 100, 0) }); + const a = mkScope({ + id: 'scope:a', + parent: 'scope:m', + kind: 'Function', + range: r(5, 0, 20, 0), + }); + const b = mkScope({ + id: 'scope:b', + parent: 'scope:m', + kind: 'Function', + range: r(15, 0, 30, 0), // overlaps with a + }); + expect(() => buildScopeTree([mod, a, b])).toThrowError(ScopeTreeInvariantError); + expect(() => buildScopeTree([mod, a, b])).toThrowError(/overlap/i); + }); + + it('accepts sibling ranges that merely touch at the boundary', () => { + const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 100, 0) }); + const a = mkScope({ + id: 'scope:a', + parent: 'scope:m', + kind: 'Block', + range: r(5, 0, 10, 0), + }); + const b = mkScope({ + id: 'scope:b', + parent: 'scope:m', + kind: 'Block', + range: r(10, 0, 15, 0), // touches a at 10:0 but does not overlap + }); + expect(() => buildScopeTree([mod, a, b])).not.toThrow(); + }); + + it('throws when parent and child live in different files', () => { + const mod = mkScope({ + id: 'scope:m', + parent: null, + kind: 'Module', + range: r(1, 0, 100, 0), + filePath: 'a.ts', + }); + const fn = mkScope({ + id: 'scope:f', + parent: 'scope:m', + kind: 'Function', + range: r(5, 0, 10, 0), + filePath: 'b.ts', + }); + expect(() => buildScopeTree([mod, fn])).toThrowError(ScopeTreeInvariantError); + expect(() => buildScopeTree([mod, fn])).toThrowError(/filePath/i); + }); + + it('throws on duplicate scope ids', () => { + const a = mkScope({ id: 'scope:dup', parent: null, kind: 'Module', range: r(1, 0, 10, 0) }); + const b = mkScope({ id: 'scope:dup', parent: null, kind: 'Module', range: r(1, 0, 10, 0) }); + expect(() => buildScopeTree([a, b])).toThrowError(ScopeTreeInvariantError); + }); + }); +}); From a9a5e1c388bfeb8964c88affe142f5b5ed07b35f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 17:26:07 +0100 Subject: [PATCH 29/46] feat(shared): SCC-aware finalize algorithm with bounded fixpoint (#915, RFC #909 Ring 2 SHARED) (#962) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(shared): SCC-aware finalize algorithm with bounded fixpoint (#915, RFC #909 Ring 2 SHARED) Implements RFC §3.2 Phase 2 as pure logic in `gitnexus-shared`. Takes per-file parse output and returns linked `ImportEdge[]` + materialized module-scope bindings, fully language-agnostic (target resolution, wildcard expansion, and binding precedence all go through caller hooks). Three-phase algorithm: 1. Tarjan SCC over the file-level import graph (iterative, deterministic node order, O(V+E)). Returns SCCs in reverse-topological order so leaves finalize before dependents — and so disjoint SCCs are explicitly surfaced for parallel-processing callers. 2. Per-SCC bounded fixpoint. For each SCC in topo order, iterate up to `N = |intra-SCC edges|`; each pass tries to resolve every still- unlinked edge by looking up the imported name in the target file's local defs. Stops early when no progress. Edges still unlinked after the cap get `linkStatus: 'unresolved'` — keeps malformed inputs bounded and preserves the RFC §4v2 capped-signal contract for unresolved markers. 3. Wildcard expansion + module-scope binding materialization. For each `wildcard` ParsedImport that linked to a module, expand via `expandsWildcardTo` into one `wildcard-expanded` ImportEdge per exported name. Bindings per module scope are the merge of local defs (`origin: 'local'`), named / alias / reexport imports (`origin: 'import' | 'reexport'`), namespace imports (`origin: 'namespace'`), and wildcard expansions (`origin: 'wildcard'`), with precedence delegated to `provider.mergeBindings`. Dynamic imports rule: `kind: 'dynamic-unresolved'` passes through as an ImportEdge with `targetFile: null` and no BindingRef. Re-export flattening: reexport edges land with `transitiveVia: [targetFile]`. Multi-hop chains settle iteratively across the fixpoint. Types: - Adds `'wildcard'` variant to ParsedImport (parse-time signal for `import * from M`). The finalize-only `'wildcard-expanded'` ImportEdge kind is unchanged and remains finalize output only, as documented. - Exports `finalize` + `FinalizeFile` / `FinalizeInput` / `FinalizeHooks` / `FinalizeOutput` / `FinalizedScc` / `FinalizeStats`. Simple-name derivation: `deriveSimpleName` uses `def.qualifiedName` as the authoritative source (tail after the last `.`). Defs without a qualifiedName are not name-resolvable by this algorithm — an explicit design choice that trades strictness for predictability (no heuristic nodeId parsing). Tests (20, all passing): - Trivial: empty workspace · acyclic resolution · unresolvable target (file + name) · dynamic-unresolved passthrough. - Cycles: A↔B two-file cycle linked · cycles packed into SCC with isCycle=true · disjoint cycles produce disjoint SCCs · mixed linked/unresolved edges reported correctly in stats. - Wildcards: one ImportEdge per exported name · unresolved wildcards survive as single edges · expanded bindings carry origin='wildcard'. - Reexports: transitiveVia carries the intermediate file path. - Aliased + namespace: alias preserves targetExportedName under its local name · namespace links to module scope even without a module-def. - Bindings: locals land as origin='local' · imports layer on via mergeBindings · mergeBindings can drop existing (last-write-wins precedence honored). - SCC-DAG: reverse-topological ordering verified (leaf first). Combined scope-resolution / model / shadow suite: 229/229 pass. `tsc --noEmit` clean in both `gitnexus-shared` and `gitnexus`. Closes part of #909. Unblocks #917 (Registry.lookup's import-chain fast path consumes finalized ImportEdges); unblocks Ring 3 language migrations (per-language providers supply FinalizeHooks implementations). * chore(shared): address #915 review findings — dead code, docs, tests Review thread on PR #962. Code changes: - Remove dead `resolvedTargets` map + `keyFor` + `ParsedImportKey` type alias. The map was populated but never read; originally intended to cache / dedup resolutions for later phases but that path was never wired (finding 1.1). - Drop unused params (`_edgeIndex`, `_hooks`, `_workspace`) from `tryFinalize`. No planned fixpoint-state consultation; no reason to keep them reserved (finding 2.1). Documentation: - `FinalizeFile.localDefs` now documents the multi-hop re-export contract explicitly: `finalize` looks names up in the target's static `localDefs`; if B only re-exports from C and doesn't surface the name in its own localDefs, A's import of that name from B will hit the cap and be marked unresolved. Parsers that want multi-hop chains to settle end-to-end must include re-exported names in the intermediate file's localDefs (finding 1.2). - `FinalizeStats` now documents its counting granularity: all edge counters are per-`ParsedImport`, not per-materialized-`ImportEdge`. A wildcard expanding to N exports counts as one linked edge; dynamic-unresolved pass-throughs count as linked. The bindings map is the authoritative "has a BindingRef" source (finding 3.2). Tests (2 added, 22 total in finalize-algorithm.test.ts, 231/231 combined): - Explicit cap-hit → `linkStatus: 'unresolved'` assertion for a cycle where the name-level lookup never succeeds (distinct from `targetFile: null`; cap exhaustion path) (finding 3.1). - Multi-hop re-export contract test: demonstrates both variants — intermediate B WITHOUT X in localDefs → unresolved; B WITH X in localDefs → resolved to the original source DefId (finding 1.2). Not addressed (filed as follow-up issues): - LanguageProvider.resolveImportTarget vs FinalizeHooks signature divergence (finding 1.3) — pre-Ring-3 concern. - findDefById O(F×D) scan in Phase 5 (finding 4.1) — acceptable for Ring 2; optimize before large-workspace Ring 3 migrations. --- gitnexus-shared/src/index.ts | 11 + .../scope-resolution/finalize-algorithm.ts | 663 ++++++++++++++++++ gitnexus-shared/src/scope-resolution/types.ts | 15 + .../finalize-algorithm.test.ts | 443 ++++++++++++ 4 files changed, 1132 insertions(+) create mode 100644 gitnexus-shared/src/scope-resolution/finalize-algorithm.ts create mode 100644 gitnexus/test/unit/scope-resolution/finalize-algorithm.test.ts diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index f73c18f73..723c4970b 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -81,6 +81,17 @@ export type { MethodDispatchInput, } from './scope-resolution/method-dispatch-index.js'; +// SCC-aware cross-file finalize (RFC §3.2 Phase 2; Ring 2 SHARED #915) +export { finalize } from './scope-resolution/finalize-algorithm.js'; +export type { + FinalizeInput, + FinalizeFile, + FinalizeHooks, + FinalizeOutput, + FinalizedScc, + FinalizeStats, +} from './scope-resolution/finalize-algorithm.js'; + // Scope tree spine + position lookup (RFC §2.2 + §3.1; Ring 2 SHARED #912) export { makeScopeId, clearScopeIdInternPool } from './scope-resolution/scope-id.js'; export type { ScopeIdInput } from './scope-resolution/scope-id.js'; diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts new file mode 100644 index 000000000..6f6de8ba6 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -0,0 +1,663 @@ +/** + * `finalize` — cross-file finalize algorithm for the SemanticModel + * (RFC §3.2 Phase 2; Ring 2 SHARED #915). + * + * Pure logic that takes per-file parse output (`ParsedImport[]` + + * `SymbolDefinition[]`) and returns: + * + * - Linked `ImportEdge[]` per module scope, with `targetModuleScope` and + * `targetDefId` filled where resolvable; edges that could not be + * resolved within the hard fixpoint cap are marked + * `linkStatus: 'unresolved'`. + * - Materialized `bindings` per module scope — local defs merged with + * imported / wildcard-expanded / re-exported names via the provider's + * `mergeBindings` precedence. + * - The SCC condensation of the import graph, exposed so disjoint SCCs + * can be processed in parallel by callers that want that. + * + * The algorithm is **SCC-aware**: it runs Tarjan SCC over the file-level + * import graph, processes SCCs in reverse-topological order (leaves + * first), and within each SCC runs a bounded fixpoint link pass capped at + * `N = |edges in SCC|`. Cyclic imports finalize without hanging; malformed + * inputs are bounded by the cap. + * + * **No language-specific logic.** Target resolution, wildcard expansion, + * and binding precedence all go through caller-supplied hooks + * (`resolveImportTarget`, `expandsWildcardTo`, `mergeBindings`) that + * match the LanguageProvider surface from #911. + * + * **Dynamic imports rule.** `kind === 'dynamic-unresolved'` passes through + * as an `ImportEdge { kind: 'dynamic-unresolved', targetFile: null }` + * with no `BindingRef`. They are parse-time signals, not linkable targets. + */ + +import type { SymbolDefinition } from './symbol-definition.js'; +import type { BindingRef, ImportEdge, ParsedImport, ScopeId, WorkspaceIndex } from './types.js'; + +// ─── Public contracts ─────────────────────────────────────────────────────── + +/** Per-file input for the finalize pass. */ +export interface FinalizeFile { + readonly filePath: string; + /** The module scope id for this file; owns the finalized imports + bindings. */ + readonly moduleScope: ScopeId; + readonly parsedImports: readonly ParsedImport[]; + /** + * Defs exported from this file — the "what other files can import by name" + * surface. Typically those with `isExported: true` (the module's own + * declarations) plus, for multi-hop re-export chains, the re-exported + * names the parser chose to surface here. + * + * **Multi-hop re-export contract.** `finalize` resolves an edge + * `A → B (importedName: 'X')` by looking up `X` in `B.localDefs`. If B + * only has `export { X } from './C'` and the parser *does not* include + * `X` in `B.localDefs`, A's edge hits the fixpoint cap and is marked + * `linkStatus: 'unresolved'`. The fixpoint does NOT mutate `localDefs` + * across iterations — it is static input. + * + * Parsers that want multi-hop re-export chains to settle end-to-end must + * include re-exported names in the intermediate file's `localDefs` (with + * the original `DefId` of the source symbol). This keeps the algorithm + * O(1) per lookup and avoids graph-crawl during finalize. + */ + readonly localDefs: readonly SymbolDefinition[]; +} + +/** Input to `finalize`. */ +export interface FinalizeInput { + readonly files: readonly FinalizeFile[]; + /** Opaque workspace context forwarded to provider hooks. */ + readonly workspaceIndex: WorkspaceIndex; +} + +/** + * Provider-supplied hooks. Mirror the optional LanguageProvider scope- + * resolution hooks declared in #911; `finalize` calls them pure-ly and + * expects pure answers. + */ +export interface FinalizeHooks { + /** + * Resolve a raw import target to the concrete file path that owns it. + * Return `null` when no target file is resolvable (e.g., `np.foo` when + * `numpy` is external to the workspace). + */ + resolveImportTarget( + targetRaw: string, + fromFile: string, + workspaceIndex: WorkspaceIndex, + ): string | null; + + /** + * For a wildcard `import * from M`, return the names visible in the + * exporting module scope `M`. The finalize pass looks each name up in + * `M`'s local defs to produce a concrete `BindingRef`; names with no + * matching export are dropped. + */ + expandsWildcardTo(targetModuleScope: ScopeId, workspaceIndex: WorkspaceIndex): readonly string[]; + + /** + * Merge `incoming` bindings into `existing` for a given name. Called + * once per name at each scope. Typical rules: + * - Python: local > imported > wildcard (last-write-wins within tier). + * - Rust: explicit `use` > glob; `pub use` overrides. + * Return value replaces the bucket entirely — no implicit append. + */ + mergeBindings( + existing: readonly BindingRef[], + incoming: readonly BindingRef[], + scope: ScopeId, + ): readonly BindingRef[]; +} + +/** One SCC in the file-level import graph. */ +export interface FinalizedScc { + readonly files: readonly string[]; + /** True iff this SCC has ≥ 2 files OR a single file that self-imports. */ + readonly isCycle: boolean; +} + +/** + * Counters reported by `finalize`. + * + * **Counting granularity** — all edge counters are **per-`ParsedImport`**, + * not per-materialized-`ImportEdge`. A single `wildcard` ParsedImport that + * expands to N exports counts as one linked edge in these stats; the + * materialized output (`FinalizeOutput.imports`) will have N edges for + * that input. `dynamic-unresolved` ParsedImports count as linked (they + * pass through with no `linkStatus`), so `linkedEdges` ≠ "has a + * BindingRef" — use the `bindings` map for that. + * + * In other words: `totalEdges === input.parsedImports.length` summed + * across files, and `linkedEdges + unresolvedEdges === totalEdges`. + */ +export interface FinalizeStats { + readonly totalFiles: number; + /** Total `ParsedImport` records seen across all files. */ + readonly totalEdges: number; + /** + * `ParsedImport`s whose finalized edge does NOT carry + * `linkStatus: 'unresolved'`. Includes `dynamic-unresolved` pass-throughs. + */ + readonly linkedEdges: number; + /** `ParsedImport`s whose finalized edge carries `linkStatus: 'unresolved'`. */ + readonly unresolvedEdges: number; + readonly sccCount: number; + readonly largestSccSize: number; +} + +export interface FinalizeOutput { + /** Linked `ImportEdge[]` per module scope, in original input order. */ + readonly imports: ReadonlyMap; + /** Materialized bindings per module scope. */ + readonly bindings: ReadonlyMap>; + /** SCCs in reverse-topological order (leaves first). */ + readonly sccs: readonly FinalizedScc[]; + readonly stats: FinalizeStats; +} + +// ─── Entry point ─────────────────────────────────────────────────────────── + +export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOutput { + const byFilePath = new Map(); + for (const f of input.files) byFilePath.set(f.filePath, f); + + // ── Phase 0: pre-resolve raw import targets (one syscall-equivalent per + // (file, parsedImport)). Edges with no resolvable target become + // `linkStatus: 'unresolved'` or, for dynamic-unresolved, pass through + // with `targetFile: null`. + const edgeIndex = new Map(); // filePath → drafts + let totalEdges = 0; + + for (const file of input.files) { + const drafts: ImportEdgeDraft[] = []; + for (const parsed of file.parsedImports) { + const draft = makeEdgeDraft(parsed, file, hooks, input.workspaceIndex); + drafts.push(draft); + totalEdges++; + } + edgeIndex.set(file.filePath, drafts); + } + + // ── Phase 1: build file-level import graph (only resolvable edges form + // graph edges; unresolvable ones are terminal and contribute no + // fixpoint obligation). + const graph = new Map>(); + for (const file of input.files) { + graph.set(file.filePath, new Set()); + } + for (const [fromFile, drafts] of edgeIndex) { + const edges = graph.get(fromFile)!; + for (const d of drafts) { + if (d.targetFile !== null && byFilePath.has(d.targetFile)) { + edges.add(d.targetFile); + } + } + } + + // ── Phase 2: Tarjan SCC → reverse-topological list of SCCs. + const sccs = tarjanSccs(graph); + + // ── Phase 3: process SCCs in reverse-topological order (leaves first). + // Within each SCC, run a bounded fixpoint that resolves intra-SCC edges. + // Edges leaving the SCC are already resolved (their target SCC is + // already finalized); edges inside the SCC may need multiple passes. + const linkedByScope = new Map(); + let linkedEdges = 0; + + for (const scc of sccs) { + const sccFiles = new Set(scc.files); + const capacity = countEdgesWithin(edgeIndex, sccFiles); + + // Run the fixpoint up to `capacity` iterations. Each iteration tries to + // resolve every still-unlinked edge in the SCC; stops early if a pass + // makes no progress. + let progressed = true; + let iterations = 0; + while (progressed && iterations < capacity) { + progressed = false; + iterations++; + for (const filePath of scc.files) { + const drafts = edgeIndex.get(filePath)!; + for (const draft of drafts) { + if (draft.finalized !== null) continue; + const finalized = tryFinalize(draft, byFilePath); + if (finalized !== null) { + draft.finalized = finalized; + progressed = true; + } + } + } + } + + // Any drafts still not finalized within this SCC hit the cap → unresolved. + for (const filePath of scc.files) { + const drafts = edgeIndex.get(filePath)!; + for (const draft of drafts) { + if (draft.finalized !== null) continue; + draft.finalized = { + ...draft.base, + linkStatus: 'unresolved' as const, + }; + } + } + } + + // ── Phase 4: collect finalized `ImportEdge[]` per module scope, preserving + // input order within each file, and wildcard-expand where applicable. + for (const file of input.files) { + const drafts = edgeIndex.get(file.filePath)!; + const finalized: ImportEdge[] = []; + for (const d of drafts) { + const edge = d.finalized!; + if (d.source.kind === 'wildcard' && edge.linkStatus !== 'unresolved') { + // Produce one `wildcard-expanded` ImportEdge per exported name. + const expanded = expandWildcard(edge, byFilePath, hooks, input.workspaceIndex); + for (const e of expanded) finalized.push(e); + } else { + finalized.push(edge); + } + if (edge.linkStatus !== 'unresolved') linkedEdges++; + } + linkedByScope.set(file.moduleScope, Object.freeze(finalized)); + } + + // ── Phase 5: materialize module-scope bindings (local + imports + wildcards), + // delegating precedence to `provider.mergeBindings`. + const bindingsByScope = materializeBindings(input.files, linkedByScope, hooks); + + // ── Stats. + const sccCount = sccs.length; + let largestSccSize = 0; + for (const scc of sccs) { + if (scc.files.length > largestSccSize) largestSccSize = scc.files.length; + } + const stats: FinalizeStats = { + totalFiles: input.files.length, + totalEdges, + linkedEdges, + unresolvedEdges: totalEdges - linkedEdges, + sccCount, + largestSccSize, + }; + + return Object.freeze({ + imports: linkedByScope, + bindings: bindingsByScope, + sccs, + stats, + }); +} + +// ─── Internal: edge drafting (phase 0) ────────────────────────────────────── + +interface ImportEdgeDraft { + readonly source: ParsedImport; + readonly fromFile: string; + readonly fromScope: ScopeId; + readonly targetFile: string | null; + readonly base: ImportEdge; + finalized: ImportEdge | null; +} + +function makeEdgeDraft( + parsed: ParsedImport, + file: FinalizeFile, + hooks: FinalizeHooks, + workspace: WorkspaceIndex, +): ImportEdgeDraft { + // Dynamic-unresolved passes through — no `BindingRef`, no target file. + if (parsed.kind === 'dynamic-unresolved') { + const base: ImportEdge = { + localName: parsed.localName, + targetFile: null, + targetExportedName: '', + kind: 'dynamic-unresolved', + }; + return { + source: parsed, + fromFile: file.filePath, + fromScope: file.moduleScope, + targetFile: null, + base, + finalized: base, // already fully finalized + }; + } + + const targetFile = hooks.resolveImportTarget(parsed.targetRaw ?? '', file.filePath, workspace); + + // Edge is unresolvable at the file level — mark unresolved now. + if (targetFile === null) { + const edgeKind = parsed.kind === 'wildcard' ? 'wildcard-expanded' : parsed.kind; + const localName = parsed.kind === 'wildcard' ? '' : parsed.localName; + const targetExportedName = extractExportedName(parsed); + const base: ImportEdge = { + localName, + targetFile: null, + targetExportedName, + kind: edgeKind, + linkStatus: 'unresolved', + }; + return { + source: parsed, + fromFile: file.filePath, + fromScope: file.moduleScope, + targetFile: null, + base, + finalized: base, + }; + } + + // Resolvable at the file level; intra-SCC fixpoint may still fail to fill + // in `targetDefId` (e.g., symbol not exported from target). + const edgeKind = parsed.kind === 'wildcard' ? 'wildcard-expanded' : parsed.kind; + const localName = parsed.kind === 'wildcard' ? '' : parsed.localName; + const targetExportedName = extractExportedName(parsed); + const base: ImportEdge = { + localName, + targetFile, + targetExportedName, + kind: edgeKind, + }; + return { + source: parsed, + fromFile: file.filePath, + fromScope: file.moduleScope, + targetFile, + base, + finalized: null, + }; +} + +function extractExportedName(parsed: ParsedImport): string { + switch (parsed.kind) { + case 'named': + case 'alias': + case 'namespace': + case 'reexport': + return parsed.importedName; + case 'wildcard': + case 'dynamic-unresolved': + return ''; + } +} + +// ─── Internal: per-edge finalization (phase 3) ───────────────────────────── + +function tryFinalize( + draft: ImportEdgeDraft, + byFilePath: Map, +): ImportEdge | null { + const targetFile = draft.targetFile; + if (targetFile === null) return draft.base; // already terminal + + const targetModule = byFilePath.get(targetFile); + if (targetModule === undefined) return draft.base; // external target — leave as-is + + // Wildcards finalize at the file level; their per-name expansion happens + // in phase 4. At this stage we just record the target module scope. + if (draft.source.kind === 'wildcard') { + return { + ...draft.base, + targetModuleScope: targetModule.moduleScope, + }; + } + + // Namespace imports alias the target *module*; they don't name a + // specific export. Link the module scope unconditionally. If the target + // also exposes a def whose simple name matches `importedName` (some + // languages emit a synthetic module-def), pick it up as the `targetDefId` + // so consumers can reach the module as a symbol — but its absence is not + // a failure. + if (draft.source.kind === 'namespace') { + const moduleDef = findExportByName(targetModule.localDefs, extractExportedName(draft.source)); + return { + ...draft.base, + targetModuleScope: targetModule.moduleScope, + ...(moduleDef !== undefined ? { targetDefId: moduleDef.nodeId } : {}), + }; + } + + // named / alias / reexport: look up the imported name in the target's + // local defs. Multi-hop re-export chains settle iteratively — each hop + // resolves once its prior hop is finalized. + const importedName = extractExportedName(draft.source); + const exported = findExportByName(targetModule.localDefs, importedName); + + if (exported === undefined) { + // Target resolvable but the name isn't exported — keep trying in case a + // re-export inside the target's SCC surfaces it in a later iteration. + return null; + } + + const transitiveVia = draft.source.kind === 'reexport' ? Object.freeze([targetFile]) : undefined; + + return { + ...draft.base, + targetModuleScope: targetModule.moduleScope, + targetDefId: exported.nodeId, + ...(transitiveVia !== undefined ? { transitiveVia } : {}), + }; +} + +/** + * The "simple" (unqualified) name of a def, for import-name matching. + * + * Canonical source: `def.qualifiedName` — the tail after the last `.` (or + * the whole string if no dot). Defs without a qualifiedName can't be + * resolved by name here and return `null`; callers treat that as "name + * not exported" and either retry in a later fixpoint iteration or mark + * the edge unresolved. + */ +function deriveSimpleName(def: SymbolDefinition): string | null { + const q = def.qualifiedName; + if (q === undefined || q.length === 0) return null; + const dot = q.lastIndexOf('.'); + return dot === -1 ? q : q.slice(dot + 1); +} + +function findExportByName( + defs: readonly SymbolDefinition[], + name: string, +): SymbolDefinition | undefined { + for (const d of defs) { + if (deriveSimpleName(d) === name) return d; + } + return undefined; +} + +function countEdgesWithin(edgeIndex: Map, files: Set): number { + let n = 0; + for (const filePath of files) { + const drafts = edgeIndex.get(filePath); + if (drafts === undefined) continue; + for (const d of drafts) { + if (d.targetFile !== null && files.has(d.targetFile)) n++; + } + } + // Guarantee at least one pass even for a trivial SCC (ensures deterministic + // fixpoint termination even when a single-file SCC has zero intra-SCC edges + // but still needs one settle pass). + return Math.max(n, 1); +} + +// ─── Internal: wildcard expansion (phase 4) ──────────────────────────────── + +function expandWildcard( + edge: ImportEdge, + byFilePath: Map, + hooks: FinalizeHooks, + workspace: WorkspaceIndex, +): readonly ImportEdge[] { + if (edge.targetModuleScope === undefined || edge.targetFile === null) { + return [edge]; // unresolvable wildcard survives as a single unlinked edge + } + const target = byFilePath.get(edge.targetFile); + if (target === undefined) return [edge]; + + const names = hooks.expandsWildcardTo(edge.targetModuleScope, workspace); + if (names.length === 0) return []; + + const expanded: ImportEdge[] = []; + for (const name of names) { + const def = findExportByName(target.localDefs, name); + if (def === undefined) continue; + expanded.push({ + localName: name, + targetFile: edge.targetFile, + targetExportedName: name, + kind: 'wildcard-expanded', + targetModuleScope: edge.targetModuleScope, + targetDefId: def.nodeId, + }); + } + return expanded; +} + +// ─── Internal: bindings materialization (phase 5) ─────────────────────────── + +function materializeBindings( + files: readonly FinalizeFile[], + linkedByScope: ReadonlyMap, + hooks: FinalizeHooks, +): ReadonlyMap> { + const out = new Map>(); + + for (const file of files) { + const scopeBindings = new Map(); + + // Start with local defs as `origin: 'local'` bindings. + for (const def of file.localDefs) { + const name = deriveSimpleName(def); + if (name === null) continue; + const incoming: BindingRef[] = [{ def, origin: 'local' }]; + const existing = scopeBindings.get(name) ?? []; + scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope)); + } + + // Layer in finalized imports. + const imports = linkedByScope.get(file.moduleScope) ?? []; + for (const edge of imports) { + if (edge.targetDefId === undefined || edge.linkStatus === 'unresolved') continue; + // Every def the importing file needs to reach is in some other file's + // `localDefs`; walk all files to find it. In practice we could index + // this, but at finalize-time N(files) is small per workspace pass. + const def = findDefById(files, edge.targetDefId); + if (def === undefined) continue; + + const origin: BindingRef['origin'] = + edge.kind === 'namespace' + ? 'namespace' + : edge.kind === 'wildcard-expanded' + ? 'wildcard' + : edge.kind === 'reexport' + ? 'reexport' + : 'import'; + const fallback = deriveSimpleName(def); + const name = edge.localName.length > 0 ? edge.localName : fallback; + if (name === null) continue; + const incoming: BindingRef[] = [{ def, origin, via: edge }]; + const existing = scopeBindings.get(name) ?? []; + scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope)); + } + + // Freeze nested buckets for immutability. + const frozen = new Map(); + for (const [name, refs] of scopeBindings) { + frozen.set(name, Object.freeze(refs.slice())); + } + out.set(file.moduleScope, frozen); + } + + return out; +} + +function findDefById(files: readonly FinalizeFile[], defId: string): SymbolDefinition | undefined { + for (const f of files) { + for (const d of f.localDefs) { + if (d.nodeId === defId) return d; + } + } + return undefined; +} + +// ─── Internal: Tarjan SCC ────────────────────────────────────────────────── + +/** + * Iterative Tarjan SCC. Returns SCCs in **reverse-topological** order + * (leaves first — a property Tarjan gives for free, and the order + * `finalize` wants so leaves are fully resolved before their dependents). + */ +function tarjanSccs(graph: ReadonlyMap>): FinalizedScc[] { + const index = new Map(); + const lowlink = new Map(); + const onStack = new Set(); + const stack: string[] = []; + const sccs: FinalizedScc[] = []; + let idx = 0; + + // Iterative DFS to avoid stack overflow on deep import chains. + const allNodes = Array.from(graph.keys()).sort(); // deterministic order + const iterStack: Array<{ node: string; children: Iterator; entered: boolean }> = []; + + for (const root of allNodes) { + if (index.has(root)) continue; + iterStack.push({ + node: root, + children: (graph.get(root) ?? new Set()).values(), + entered: false, + }); + while (iterStack.length > 0) { + const frame = iterStack[iterStack.length - 1]!; + + if (!frame.entered) { + frame.entered = true; + index.set(frame.node, idx); + lowlink.set(frame.node, idx); + idx++; + stack.push(frame.node); + onStack.add(frame.node); + } + + const nextChild = frame.children.next(); + if (nextChild.done) { + // Post-visit: compute SCC membership if frame.node is a root. + if (lowlink.get(frame.node) === index.get(frame.node)) { + const scc: string[] = []; + let selfInCycle = false; + while (true) { + const w = stack.pop()!; + onStack.delete(w); + scc.push(w); + // A single-file self-loop counts as a cycle. + if (w === frame.node) { + selfInCycle = (graph.get(w) ?? new Set()).has(w); + break; + } + } + const isCycle = scc.length > 1 || selfInCycle; + sccs.push({ files: Object.freeze(scc), isCycle }); + } + iterStack.pop(); + // Propagate lowlink to parent. + if (iterStack.length > 0) { + const parent = iterStack[iterStack.length - 1]!; + lowlink.set(parent.node, Math.min(lowlink.get(parent.node)!, lowlink.get(frame.node)!)); + } + continue; + } + + const child = nextChild.value; + if (!index.has(child)) { + iterStack.push({ + node: child, + children: (graph.get(child) ?? new Set()).values(), + entered: false, + }); + } else if (onStack.has(child)) { + lowlink.set(frame.node, Math.min(lowlink.get(frame.node)!, index.get(child)!)); + } + } + } + + return sccs; +} diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index d04373a94..9a08a3a38 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -152,6 +152,21 @@ export type ParsedImport = /** Set when the re-export renames the symbol (e.g. `export { X as Y } from './y'`). */ readonly alias?: string; } + /** + * Wildcard import — brings every exported name from the target module into + * the importing scope. The finalize algorithm expands this into one + * `BindingRef` per exported name via the provider's `expandsWildcardTo` + * hook, producing the finalize-only `ImportEdge` kind `'wildcard-expanded'`. + * + * Examples: + * - Python `from foo import *` → `{ kind: 'wildcard', targetRaw: 'foo' }` + * - JS `export * from './foo'` → `{ kind: 'wildcard', targetRaw: './foo' }` + * - Rust `pub use foo::*` → `{ kind: 'wildcard', targetRaw: 'foo' }` + */ + | { + readonly kind: 'wildcard'; + readonly targetRaw: string; + } /** * Runtime-computed target — the import path is not a static literal at * parse time. Providers SHOULD emit the unresolvable expression's source diff --git a/gitnexus/test/unit/scope-resolution/finalize-algorithm.test.ts b/gitnexus/test/unit/scope-resolution/finalize-algorithm.test.ts new file mode 100644 index 000000000..501d0cba9 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/finalize-algorithm.test.ts @@ -0,0 +1,443 @@ +/** + * Unit tests for `finalize` (RFC #909 Ring 2 SHARED #915). + * + * Covers: acyclic chain · single-SCC cycle · multi-SCC · wildcard + * expansion · re-export flattening · dynamic-unresolved passthrough · + * bounded fixpoint cap · module-scope binding materialization · unresolved + * target · external target · provider `mergeBindings` precedence. + */ + +import { describe, it, expect } from 'vitest'; +import { + finalize, + type FinalizeFile, + type FinalizeHooks, + type ParsedImport, + type BindingRef, + type SymbolDefinition, + type ScopeId, +} from 'gitnexus-shared'; + +// ─── Test helpers ─────────────────────────────────────────────────────────── + +const def = ( + nodeId: string, + type: SymbolDefinition['type'] = 'Class', + qualifiedName?: string, +): SymbolDefinition => ({ + nodeId, + filePath: 'x', + type, + ...(qualifiedName !== undefined ? { qualifiedName } : {}), +}); + +const file = ( + filePath: string, + localDefs: SymbolDefinition[] = [], + parsedImports: ParsedImport[] = [], +): FinalizeFile => ({ + filePath, + moduleScope: `scope:${filePath}#1:0-9999:0:Module`, + localDefs: localDefs.map((d) => ({ ...d, filePath })), + parsedImports, +}); + +/** Simple hook set: `resolveImportTarget` does a direct path lookup; wildcard + * expansion returns the concrete names from the target's own local defs; + * `mergeBindings` appends (no precedence logic). */ +const defaultHooks = (files: readonly FinalizeFile[]): FinalizeHooks => ({ + resolveImportTarget(targetRaw) { + if (targetRaw === null || targetRaw.length === 0) return null; + return files.some((f) => f.filePath === targetRaw) ? targetRaw : null; + }, + expandsWildcardTo(targetModuleScope) { + const target = files.find((f) => f.moduleScope === targetModuleScope); + if (target === undefined) return []; + return target.localDefs.map((d) => deriveSimple(d)).filter((n): n is string => n !== null); + }, + mergeBindings(existing, incoming) { + return [...existing, ...incoming]; + }, +}); + +function deriveSimple(d: SymbolDefinition): string | null { + const q = d.qualifiedName; + if (q === undefined || q.length === 0) return null; + const dot = q.lastIndexOf('.'); + return dot === -1 ? q : q.slice(dot + 1); +} + +const named = (localName: string, importedName: string, targetRaw: string): ParsedImport => ({ + kind: 'named', + localName, + importedName, + targetRaw, +}); + +const aliased = ( + localName: string, + importedName: string, + alias: string, + targetRaw: string, +): ParsedImport => ({ kind: 'alias', localName, importedName, alias, targetRaw }); + +const namespace = (localName: string, importedName: string, targetRaw: string): ParsedImport => ({ + kind: 'namespace', + localName, + importedName, + targetRaw, +}); + +const reexport = (localName: string, importedName: string, targetRaw: string): ParsedImport => ({ + kind: 'reexport', + localName, + importedName, + targetRaw, +}); + +const wildcard = (targetRaw: string): ParsedImport => ({ kind: 'wildcard', targetRaw }); + +const dynamic = (localName: string, targetRaw: string | null): ParsedImport => ({ + kind: 'dynamic-unresolved', + localName, + targetRaw, +}); + +const firstImport = (out: ReturnType, scope: ScopeId) => { + const imports = out.imports.get(scope); + return imports?.[0]; +}; + +const bindingsFor = ( + out: ReturnType, + scope: ScopeId, + name: string, +): readonly BindingRef[] => { + const scopeBindings = out.bindings.get(scope); + return scopeBindings?.get(name) ?? []; +}; + +// ─── Tests ────────────────────────────────────────────────────────────────── + +describe('finalize', () => { + describe('trivial / acyclic', () => { + it('handles an empty workspace', () => { + const out = finalize({ files: [], workspaceIndex: undefined }, defaultHooks([])); + expect(out.stats.totalFiles).toBe(0); + expect(out.stats.totalEdges).toBe(0); + expect(out.sccs).toEqual([]); + }); + + it('resolves a single named import across two files', () => { + const b = file('b', [def('def:b.User', 'Class', 'b.User')]); + const a = file('a', [], [named('User', 'User', 'b')]); + const files = [a, b]; + const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files)); + + const edge = firstImport(out, a.moduleScope)!; + expect(edge.kind).toBe('named'); + expect(edge.targetFile).toBe('b'); + expect(edge.targetModuleScope).toBe(b.moduleScope); + expect(edge.targetDefId).toBe('def:b.User'); + expect(edge.linkStatus).toBeUndefined(); + expect(out.stats.linkedEdges).toBe(1); + expect(out.stats.unresolvedEdges).toBe(0); + }); + + it('marks an edge unresolved when target file cannot be resolved', () => { + const a = file('a', [], [named('User', 'User', 'external-pkg')]); + const out = finalize({ files: [a], workspaceIndex: undefined }, defaultHooks([a])); + const edge = firstImport(out, a.moduleScope)!; + expect(edge.linkStatus).toBe('unresolved'); + expect(edge.targetFile).toBeNull(); + }); + + it('marks an edge unresolved when target file exists but name is not exported', () => { + const b = file('b', [def('def:b.Other', 'Class', 'b.Other')]); + const a = file('a', [], [named('User', 'User', 'b')]); + const files = [a, b]; + const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files)); + const edge = firstImport(out, a.moduleScope)!; + expect(edge.linkStatus).toBe('unresolved'); + // targetFile still known — unresolvability is at the name level. + expect(edge.targetFile).toBe('b'); + }); + + it('passes dynamic-unresolved edges through without linking', () => { + const a = file('a', [], [dynamic('', 'runtime.computed')]); + const out = finalize({ files: [a], workspaceIndex: undefined }, defaultHooks([a])); + const edge = firstImport(out, a.moduleScope)!; + expect(edge.kind).toBe('dynamic-unresolved'); + expect(edge.targetFile).toBeNull(); + expect(edge.linkStatus).toBeUndefined(); + }); + }); + + describe('cycles + bounded fixpoint', () => { + it('finalizes a two-file cycle (A → B → A) without hanging', () => { + const a = file('a', [def('def:a.X', 'Class', 'a.X')], [named('Y', 'Y', 'b')]); + const b = file('b', [def('def:b.Y', 'Class', 'b.Y')], [named('X', 'X', 'a')]); + const files = [a, b]; + const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files)); + + const aEdge = firstImport(out, a.moduleScope)!; + const bEdge = firstImport(out, b.moduleScope)!; + expect(aEdge.targetDefId).toBe('def:b.Y'); + expect(bEdge.targetDefId).toBe('def:a.X'); + expect(out.stats.sccCount).toBeGreaterThanOrEqual(1); + }); + + it('packs cyclic files into a single SCC with isCycle=true', () => { + const a = file('a', [def('def:a.X', 'Class', 'a.X')], [named('Y', 'Y', 'b')]); + const b = file('b', [def('def:b.Y', 'Class', 'b.Y')], [named('X', 'X', 'a')]); + const files = [a, b]; + const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files)); + const cycles = out.sccs.filter((scc) => scc.isCycle); + expect(cycles.length).toBe(1); + expect(cycles[0]!.files.length).toBe(2); + expect(new Set(cycles[0]!.files)).toEqual(new Set(['a', 'b'])); + }); + + it('separates disjoint SCCs', () => { + // a↔b cycle, c↔d cycle — disjoint. + const a = file('a', [def('def:a.X', 'Class', 'a.X')], [named('Y', 'Y', 'b')]); + const b = file('b', [def('def:b.Y', 'Class', 'b.Y')], [named('X', 'X', 'a')]); + const c = file('c', [def('def:c.P', 'Class', 'c.P')], [named('Q', 'Q', 'd')]); + const d = file('d', [def('def:d.Q', 'Class', 'd.Q')], [named('P', 'P', 'c')]); + const files = [a, b, c, d]; + const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files)); + const cycleSCCs = out.sccs.filter((scc) => scc.isCycle); + expect(cycleSCCs.length).toBe(2); + }); + + it('reports stats distinguishing linked from unresolved edges in a cycle', () => { + const a = file( + 'a', + [def('def:a.X', 'Class', 'a.X')], + [named('Y', 'Y', 'b'), named('Ghost', 'Ghost', 'b')], + ); + const b = file('b', [def('def:b.Y', 'Class', 'b.Y')], [named('X', 'X', 'a')]); + const files = [a, b]; + const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files)); + expect(out.stats.linkedEdges).toBe(2); // a→b.Y and b→a.X resolve + expect(out.stats.unresolvedEdges).toBe(1); // a→b.Ghost doesn't + }); + + it('transitions an intra-SCC edge to linkStatus=unresolved when the cap is reached', () => { + // A↔B cycle; A imports a name that B never exports. The file-level + // target resolves (b exists), but the name-level lookup never + // succeeds, so the fixpoint exhausts its cap and we fall through to + // `linkStatus: 'unresolved'` (distinct from `targetFile: null`). + const a = file( + 'a', + [def('def:a.X', 'Class', 'a.X')], + [named('Ghost', 'Ghost', 'b'), named('Y', 'Y', 'b')], + ); + const b = file('b', [def('def:b.Y', 'Class', 'b.Y')], [named('X', 'X', 'a')]); + const files = [a, b]; + const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files)); + + const aEdges = out.imports.get(a.moduleScope) ?? []; + const ghost = aEdges.find((e) => e.localName === 'Ghost'); + expect(ghost).toBeDefined(); + // Cap-hit distinction: file target is known, but name never resolved. + expect(ghost!.targetFile).toBe('b'); + expect(ghost!.linkStatus).toBe('unresolved'); + expect(ghost!.targetDefId).toBeUndefined(); + }); + }); + + describe('wildcard expansion', () => { + it('expands `wildcard` into one ImportEdge per exported name', () => { + const b = file('b', [ + def('def:b.X', 'Class', 'b.X'), + def('def:b.Y', 'Class', 'b.Y'), + def('def:b.Z', 'Class', 'b.Z'), + ]); + const a = file('a', [], [wildcard('b')]); + const files = [a, b]; + const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files)); + const edges = out.imports.get(a.moduleScope) ?? []; + expect(edges.length).toBe(3); + expect(edges.every((e) => e.kind === 'wildcard-expanded')).toBe(true); + expect(new Set(edges.map((e) => e.localName))).toEqual(new Set(['X', 'Y', 'Z'])); + expect(new Set(edges.map((e) => e.targetDefId))).toEqual( + new Set(['def:b.X', 'def:b.Y', 'def:b.Z']), + ); + }); + + it('leaves a wildcard unresolved when the target file cannot be resolved', () => { + const a = file('a', [], [wildcard('external-pkg')]); + const out = finalize({ files: [a], workspaceIndex: undefined }, defaultHooks([a])); + const edges = out.imports.get(a.moduleScope) ?? []; + expect(edges.length).toBe(1); + expect(edges[0]!.linkStatus).toBe('unresolved'); + }); + + it('expanded bindings land at `origin: wildcard`', () => { + const b = file('b', [def('def:b.X', 'Class', 'b.X')]); + const a = file('a', [], [wildcard('b')]); + const files = [a, b]; + const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files)); + const bindings = bindingsFor(out, a.moduleScope, 'X'); + expect(bindings.length).toBeGreaterThanOrEqual(1); + const imported = bindings.find((br) => br.origin === 'wildcard'); + expect(imported).toBeDefined(); + expect(imported!.def.nodeId).toBe('def:b.X'); + }); + }); + + describe('re-export flattening', () => { + it('sets transitiveVia on reexport edges', () => { + const c = file('c', [def('def:c.X', 'Class', 'c.X')]); + const b = file('b', [], [reexport('X', 'X', 'c')]); + const a = file('a', [], [named('X', 'X', 'b')]); + const files = [a, b, c]; + const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files)); + const reexportEdge = firstImport(out, b.moduleScope)!; + expect(reexportEdge.kind).toBe('reexport'); + expect(reexportEdge.transitiveVia).toEqual(['c']); + }); + + it('multi-hop re-export chains only resolve when intermediate files include the name in localDefs', () => { + // Contract (see FinalizeFile.localDefs doc): `finalize` looks up + // `importedName` in `B.localDefs`. If B re-exports X from C but does + // NOT include X in its own localDefs, A's import of X from B cannot + // resolve — the fixpoint doesn't mutate localDefs across iterations. + // + // This test documents the current behavior: parsers that want + // multi-hop chains to settle end-to-end must surface re-exported + // names in the intermediate file's localDefs (with the original + // source DefId). + const c = file('c', [def('def:c.X', 'Class', 'c.X')]); + // Variant 1: B does NOT include X in its own localDefs → A's import + // fails. + const bThin = file('b', [], [reexport('X', 'X', 'c')]); + const aThin = file('a', [], [named('X', 'X', 'b')]); + const thinFiles = [aThin, bThin, c]; + const thinOut = finalize( + { files: thinFiles, workspaceIndex: undefined }, + defaultHooks(thinFiles), + ); + expect(firstImport(thinOut, aThin.moduleScope)!.linkStatus).toBe('unresolved'); + + // Variant 2: B includes X in its localDefs (re-exports surfaced) → A resolves. + const bThick = file( + 'b', + [def('def:c.X', 'Class', 'b.X')], // B surfaces X with its own qname + [reexport('X', 'X', 'c')], + ); + const aThick = file('a', [], [named('X', 'X', 'b')]); + const thickFiles = [aThick, bThick, c]; + const thickOut = finalize( + { files: thickFiles, workspaceIndex: undefined }, + defaultHooks(thickFiles), + ); + expect(firstImport(thickOut, aThick.moduleScope)!.linkStatus).toBeUndefined(); + expect(firstImport(thickOut, aThick.moduleScope)!.targetDefId).toBe('def:c.X'); + }); + }); + + describe('aliased + namespace imports', () => { + it('resolves an alias under its local name while preserving targetExportedName', () => { + const b = file('b', [def('def:b.User', 'Class', 'b.User')]); + const a = file('a', [], [aliased('Account', 'User', 'Account', 'b')]); + const files = [a, b]; + const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files)); + const edge = firstImport(out, a.moduleScope)!; + expect(edge.kind).toBe('alias'); + expect(edge.localName).toBe('Account'); + expect(edge.targetExportedName).toBe('User'); + expect(edge.targetDefId).toBe('def:b.User'); + }); + + it('records namespace imports with origin=namespace in bindings', () => { + // Provider emits a synthetic module-representing def so the namespace + // binding can anchor to a real SymbolDefinition. + const numpyFile = file('numpy.py', [ + def('def:numpy', 'Namespace', 'numpy'), + def('def:numpy.array', 'Function', 'numpy.array'), + ]); + const a = file('a', [], [namespace('np', 'numpy', 'numpy.py')]); + const files = [a, numpyFile]; + const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files)); + const npEdge = firstImport(out, a.moduleScope)!; + expect(npEdge.kind).toBe('namespace'); + expect(npEdge.targetModuleScope).toBe(numpyFile.moduleScope); + + const bindings = bindingsFor(out, a.moduleScope, 'np'); + expect(bindings.some((b) => b.origin === 'namespace')).toBe(true); + expect(bindings.find((b) => b.origin === 'namespace')!.def.nodeId).toBe('def:numpy'); + }); + + it('links a namespace import to the module scope even when no module-def exists', () => { + // No synthetic def in target — the edge still resolves to the module + // scope, just without a `targetDefId`. Bindings materialization skips + // the binding (no def to anchor to), but the edge itself is linked. + const numpyFile = file('numpy.py', [def('def:numpy.array', 'Function', 'numpy.array')]); + const a = file('a', [], [namespace('np', 'numpy', 'numpy.py')]); + const files = [a, numpyFile]; + const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files)); + const npEdge = firstImport(out, a.moduleScope)!; + expect(npEdge.kind).toBe('namespace'); + expect(npEdge.linkStatus).toBeUndefined(); + expect(npEdge.targetModuleScope).toBe(numpyFile.moduleScope); + expect(npEdge.targetDefId).toBeUndefined(); + }); + }); + + describe('module-scope binding materialization', () => { + it('lays down local defs with origin=local', () => { + const a = file('a', [def('def:a.X', 'Class', 'a.X')]); + const out = finalize({ files: [a], workspaceIndex: undefined }, defaultHooks([a])); + const bindings = bindingsFor(out, a.moduleScope, 'X'); + expect(bindings.length).toBe(1); + expect(bindings[0]!.origin).toBe('local'); + expect(bindings[0]!.def.nodeId).toBe('def:a.X'); + }); + + it('layers imports on top of local defs via mergeBindings', () => { + const b = file('b', [def('def:b.User', 'Class', 'b.User')]); + const a = file('a', [def('def:a.User', 'Class', 'a.User')], [named('User', 'User', 'b')]); + const files = [a, b]; + const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files)); + const bindings = bindingsFor(out, a.moduleScope, 'User'); + expect(bindings.length).toBe(2); + expect(bindings.some((br) => br.origin === 'local')).toBe(true); + expect(bindings.some((br) => br.origin === 'import')).toBe(true); + }); + + it('honors provider precedence: mergeBindings can drop existing bindings', () => { + // Provider decides imports win over locals (Python-ish precedence). + const b = file('b', [def('def:b.User', 'Class', 'b.User')]); + const a = file('a', [def('def:a.User', 'Class', 'a.User')], [named('User', 'User', 'b')]); + const files = [a, b]; + const hooks: FinalizeHooks = { + ...defaultHooks(files), + mergeBindings(_existing, incoming) { + // Replace existing with incoming — last-write-wins across tiers. + return incoming; + }, + }; + const out = finalize({ files, workspaceIndex: undefined }, hooks); + const bindings = bindingsFor(out, a.moduleScope, 'User'); + // Only the last merged layer (the import) remains. + expect(bindings.length).toBe(1); + expect(bindings[0]!.origin).toBe('import'); + }); + }); + + describe('SCC-DAG exposure for parallelism', () => { + it('returns SCCs in reverse-topological order (leaves first)', () => { + // c ← b ← a (a imports b, b imports c, c has no imports) + const c = file('c', [def('def:c.C', 'Class', 'c.C')]); + const b = file('b', [def('def:b.B', 'Class', 'b.B')], [named('C', 'C', 'c')]); + const a = file('a', [def('def:a.A', 'Class', 'a.A')], [named('B', 'B', 'b')]); + const files = [a, b, c]; + const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files)); + // First SCC processed must be `c` (leaf), last must be `a`. + expect(out.sccs[0]!.files[0]).toBe('c'); + expect(out.sccs[out.sccs.length - 1]!.files[0]).toBe('a'); + }); + }); +}); From 1bf9fb4ef1221884b1f043dcebc0296fe8108002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 17:58:26 +0100 Subject: [PATCH 30/46] feat(shared): ClassRegistry / MethodRegistry / FieldRegistry + 7-step lookup (#917, RFC #909 Ring 2 SHARED) (#963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capstone of Ring 2 SHARED. Implements RFC §4 — the shared, scope-aware resolution surface the rest of the semantic model feeds into. ## Modules (`gitnexus-shared/src/scope-resolution/registries/`) - `context.ts` — `RegistryContext` bundling ScopeTree / DefIndex / QualifiedNameIndex / ModuleScopeIndex / MethodDispatchIndex + provider hooks. Narrows Ring 1's opaque `RegistryContributor` to concrete `OwnerScopedContributor`. - `tie-breaks.ts` — `compareByConfidenceWithTiebreaks`, the RFC Appendix B cascade: confidence DESC → scope depth ASC → MRO depth ASC → ORIGIN_PRIORITY ASC → DefId.localeCompare. - `evidence.ts` — `composeEvidence(signals)` / `confidenceFromEvidence`. Translates raw walk signals into the typed `ResolutionEvidence[]` using authoritative `EvidenceWeights`. No magic numbers. - `lookup-qualified.ts`— RFC §4.5. Qualified-name fast path consumed by `resolveTypeRef` dotted fallback and by Step 6 of lookup-core. - `lookup-core.ts` — The 7-step canonical algorithm. Pure. Param- eterized by `CoreLookupParams`. - `{class,method,field}-registry.ts` — Thin wrappers over `lookupCore` that fix `acceptedKinds` + `useReceiverTypeBinding` per kind. `buildClassRegistry` / `buildMethodRegistry` / `buildFieldRegistry` factory functions. ## RFC §4.2 algorithm contract (honored verbatim) 1. Lexical scope-chain walk. Hard shadow on any `scope.bindings.has(name)` regardless of kind survivorship. 2. Type-binding resolution (methods/fields only, opt-in via `useReceiverTypeBinding`). MRO walk via `MethodDispatchIndex.mroFor`. MRO-depth-decayed weight via `typeBindingWeightAtDepth`. 3. Owner-scoped contributor — when the caller knows the receiver owner, its direct members merge in as `origin: 'local'`. 4. Kind filter — `acceptedKinds` per registry; `kind-match` evidence at weight 0 is always emitted for debuggability. 5. Arity filter — `provider.arityCompatibility` per candidate. When at least one compatible candidate exists, incompatibles are dropped; otherwise the −0.15 penalty alone disambiguates (they stay in the result, just ranked lower). 6. Global fallback — fires only when Steps 1-3 produced NO candidates AND the name is dotted. Delegates to `lookupQualified`. 7. Rank + tie-break — evidence list sorted by the Appendix B cascade. ## §4.7 invariants asserted in tests - No tier vocabulary in the return type (`Resolution`, not `TierXResult`). - Confidence is per-candidate (not per-tier). - Shadowing is a HARD filter; globals are consulted ONLY when lexically empty. - Caller can read `[0]` for one-shot answers. - `Resolution.confidence` is capped at 1.0. - `kind-match` is always emitted (weight 0). ## Unresolved-import + dynamic-unresolved evidence shape - `BindingRef.via.linkStatus === 'unresolved'` applies the `unlinkedImportMultiplier` (0.5×) to the where-found signal only. Corroborators (`arity-match`, `owner-match`, `type-binding`) remain unaffected — the RFC §4v2 capped-signal rule applies per-signal, not per-candidate. - `BindingRef.via.kind === 'dynamic-unresolved'` adds a degraded `dynamic-import-unresolved` evidence signal at weight 0.02. ## Tests (28 in registries.test.ts, 259/259 combined) Organized per RFC §4.2 step so a regression localizes to the step it broke: - Step 1: local + walk-to-parent + hard-shadow + origin=import - Step 2: explicit receiver type-binding + MRO depth decay on ancestor - Step 3: owner-scoped contributor + owner-match - Step 5: drop-incompatible-when-compatible-exists + soft-penalty-when-all- incompatible + unknown-when-no-provider - Step 6: global-qualified fires only when lexically empty + never for non-dotted names + not consulted when lexical hit exists - Step 7: tie-break cascade (inner shadows outer; defId.localeCompare final) - Corroborators: unresolved-import 0.5× cap per-signal + dynamic- unresolved 0.02 degraded signal - §4.5: lookupQualified kind filter + empty on miss + deterministic defId order for partial classes - §4.7: invariants — confidence per-candidate, capped at 1.0, kind-match always present, [0]-for-one-shot ## Known follow-up optimizations `collectOwnedMembers` in `lookup-core.ts` iterates `defs.byId.values()` for each MRO hop — O(D) per call. Acceptable for Ring 2 fixtures; a by-owner index should land before Ring 3 migrates large-workspace languages. Tracked alongside the existing `findDefById` follow-up from #915 review. ## Module placement All under `gitnexus-shared/src/scope-resolution/registries/` — consistent with the Ring 2 SHARED folder layout (#912/#913/#914/#915/#916/#918). Slight deviation from the issue's `gitnexus-shared/src/registries/` suggestion for consistency with siblings. ## Part of - Parent: #909 - Depends on (code): #910, #911, #912, #913, #914, #915, #916, #918. - Closes the Ring 2 SHARED delivery band. Unblocks Ring 2 PKG (#919–#925 bridges to the gitnexus/ CLI package) and Ring 3 language migrations. --- gitnexus-shared/src/index.ts | 32 + .../registries/class-registry.ts | 41 ++ .../scope-resolution/registries/context.ts | 110 +++ .../scope-resolution/registries/evidence.ts | 191 +++++ .../registries/field-registry.ts | 43 ++ .../registries/lookup-core.ts | 447 ++++++++++++ .../registries/lookup-qualified.ts | 71 ++ .../registries/method-registry.ts | 54 ++ .../scope-resolution/registries/tie-breaks.ts | 76 ++ .../unit/scope-resolution/registries.test.ts | 669 ++++++++++++++++++ 10 files changed, 1734 insertions(+) create mode 100644 gitnexus-shared/src/scope-resolution/registries/class-registry.ts create mode 100644 gitnexus-shared/src/scope-resolution/registries/context.ts create mode 100644 gitnexus-shared/src/scope-resolution/registries/evidence.ts create mode 100644 gitnexus-shared/src/scope-resolution/registries/field-registry.ts create mode 100644 gitnexus-shared/src/scope-resolution/registries/lookup-core.ts create mode 100644 gitnexus-shared/src/scope-resolution/registries/lookup-qualified.ts create mode 100644 gitnexus-shared/src/scope-resolution/registries/method-registry.ts create mode 100644 gitnexus-shared/src/scope-resolution/registries/tie-breaks.ts create mode 100644 gitnexus/test/unit/scope-resolution/registries.test.ts diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 723c4970b..255aa9345 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -92,6 +92,38 @@ export type { FinalizeStats, } from './scope-resolution/finalize-algorithm.js'; +// Scope-aware registries + 7-step lookup (RFC §4; Ring 2 SHARED #917) +export { buildClassRegistry } from './scope-resolution/registries/class-registry.js'; +export type { ClassRegistry } from './scope-resolution/registries/class-registry.js'; +export { buildMethodRegistry } from './scope-resolution/registries/method-registry.js'; +export type { + MethodRegistry, + MethodLookupOptions, +} from './scope-resolution/registries/method-registry.js'; +export { buildFieldRegistry } from './scope-resolution/registries/field-registry.js'; +export type { + FieldRegistry, + FieldLookupOptions, +} from './scope-resolution/registries/field-registry.js'; +export { lookupCore } from './scope-resolution/registries/lookup-core.js'; +export type { CoreLookupParams } from './scope-resolution/registries/lookup-core.js'; +export { lookupQualified } from './scope-resolution/registries/lookup-qualified.js'; +export type { LookupQualifiedParams } from './scope-resolution/registries/lookup-qualified.js'; +export { composeEvidence, confidenceFromEvidence } from './scope-resolution/registries/evidence.js'; +export type { RawSignals } from './scope-resolution/registries/evidence.js'; +export { + compareByConfidenceWithTiebreaks, + CONFIDENCE_EPSILON, +} from './scope-resolution/registries/tie-breaks.js'; +export type { TieBreakKey } from './scope-resolution/registries/tie-breaks.js'; +export { CLASS_KINDS, METHOD_KINDS, FIELD_KINDS } from './scope-resolution/registries/context.js'; +export type { + RegistryContext, + RegistryProviders, + OwnerScopedContributor, + ArityVerdict, +} from './scope-resolution/registries/context.js'; + // Scope tree spine + position lookup (RFC §2.2 + §3.1; Ring 2 SHARED #912) export { makeScopeId, clearScopeIdInternPool } from './scope-resolution/scope-id.js'; export type { ScopeIdInput } from './scope-resolution/scope-id.js'; diff --git a/gitnexus-shared/src/scope-resolution/registries/class-registry.ts b/gitnexus-shared/src/scope-resolution/registries/class-registry.ts new file mode 100644 index 000000000..20a08e2b8 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/class-registry.ts @@ -0,0 +1,41 @@ +/** + * `ClassRegistry` — scope-aware lookup for class-like symbols + * (RFC §4.4; Ring 2 SHARED #917). + * + * Thin wrapper over `lookupCore`, specialized for class kinds: + * + * - `acceptedKinds` = Class / Interface / Enum / Struct / Union / + * Trait / TypeAlias / Typedef / Record / Delegate / Annotation / + * Template / Namespace. + * - `useReceiverTypeBinding` is **false** — classes are resolved by + * name through the lexical chain + global qualified fallback, not + * via a receiver type. + * - Arity filter is not applicable (classes are not called with + * argument counts at lookup time). + */ + +import type { Resolution, ScopeId } from '../types.js'; +import { lookupCore, type CoreLookupParams } from './lookup-core.js'; +import { CLASS_KINDS, type RegistryContext } from './context.js'; + +export interface ClassRegistry { + /** + * Look up a class-like symbol by simple or dotted name anchored at + * `scope`. Returns a confidence-ranked `Resolution[]`; consume `[0]` + * for the best answer. + */ + lookup(name: string, scope: ScopeId): readonly Resolution[]; +} + +export function buildClassRegistry(ctx: RegistryContext): ClassRegistry { + const params: CoreLookupParams = { + acceptedKinds: CLASS_KINDS, + useReceiverTypeBinding: false, + ownerScopedContributor: null, + }; + return { + lookup(name: string, scope: ScopeId) { + return lookupCore(name, scope, params, ctx); + }, + }; +} diff --git a/gitnexus-shared/src/scope-resolution/registries/context.ts b/gitnexus-shared/src/scope-resolution/registries/context.ts new file mode 100644 index 000000000..9adbbda2e --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/context.ts @@ -0,0 +1,110 @@ +/** + * `RegistryContext` — the injected state required by the scope-aware + * registry lookups (RFC §4; Ring 2 SHARED #917). + * + * Bundles every Ring 2 index + every provider hook the 7-step algorithm + * might consult. Threaded through `lookupCore` and the three public + * registries unchanged; construction is the caller's responsibility + * (typically once per workspace-indexing pass in Ring 2 PKG). + * + * The design intent is **pure-logic in `gitnexus-shared`, data + hooks + * supplied by the caller**. Nothing here loads files, parses AST, or + * reaches into the CLI package. + */ + +import type { NodeLabel } from '../../graph/types.js'; +import type { SymbolDefinition } from '../symbol-definition.js'; +import type { Callsite, DefId } from '../types.js'; +import type { DefIndex } from '../def-index.js'; +import type { QualifiedNameIndex } from '../qualified-name-index.js'; +import type { ModuleScopeIndex } from '../module-scope-index.js'; +import type { ScopeTree } from '../scope-tree.js'; +import type { MethodDispatchIndex } from '../method-dispatch-index.js'; + +// ─── Provider hooks consumed by the registries ───────────────────────────── + +export interface RegistryProviders { + /** + * Language-specific arity compatibility between a callsite and a candidate + * `def`. Mirrors `LanguageProvider.arityCompatibility` from #911. Optional: + * when absent, every candidate receives `'unknown'` (neutral signal). + */ + arityCompatibility?(callsite: Callsite, def: SymbolDefinition): ArityVerdict; +} + +export type ArityVerdict = 'compatible' | 'unknown' | 'incompatible'; + +// ─── Owner-scoped contributor (concrete shape for `RegistryContributor`) ──── + +/** + * Per-owner membership view plugged into `LookupParams.ownerScopedContributor`. + * + * When the caller knows a receiver is of type `Owner` (e.g., after + * resolving an explicit receiver or via `self`), it can supply the + * `Owner`'s own member bucket here. `lookupCore` treats hits from this + * contributor as `origin: 'local'` inside the owner's body scope — + * strongest-visibility evidence, unaffected by the scope-chain hop + * deduction that punishes outer-scope hits. + * + * Ring 1's `RegistryContributor = unknown` opaque placeholder is narrowed + * to this concrete shape here in Ring 2 SHARED (#917). + */ +export interface OwnerScopedContributor { + /** The owner (class/struct/trait/interface) that bounds this view. */ + readonly ownerDefId: DefId; + /** + * Methods / fields directly declared on the owner, keyed by simple name. + * Return empty array on miss; implementations should NOT walk the MRO — + * that's `MethodDispatchIndex`'s job, handled in the type-binding step. + */ + byName(name: string): readonly SymbolDefinition[]; +} + +// ─── Top-level context threaded through every lookup ─────────────────────── + +export interface RegistryContext { + readonly scopes: ScopeTree; + readonly defs: DefIndex; + readonly qualifiedNames: QualifiedNameIndex; + readonly moduleScopes: ModuleScopeIndex; + /** + * Method-dispatch index; required for method/field registries that + * honor `useReceiverTypeBinding`. Omit for class-only lookups. + */ + readonly methodDispatch?: MethodDispatchIndex; + readonly providers: RegistryProviders; +} + +// ─── Per-kind default `acceptedKinds` sets ───────────────────────────────── +// +// Exported so the three public registries stay declarative (each one just +// points at the right constant + passes it to `lookupCore`). + +export const CLASS_KINDS: readonly NodeLabel[] = Object.freeze([ + 'Class', + 'Interface', + 'Enum', + 'Struct', + 'Union', + 'Trait', + 'TypeAlias', + 'Typedef', + 'Record', + 'Delegate', + 'Annotation', + 'Template', + 'Namespace', +]); + +export const METHOD_KINDS: readonly NodeLabel[] = Object.freeze([ + 'Method', + 'Function', + 'Constructor', +]); + +export const FIELD_KINDS: readonly NodeLabel[] = Object.freeze([ + 'Variable', + 'Property', + 'Const', + 'Static', +]); diff --git a/gitnexus-shared/src/scope-resolution/registries/evidence.ts b/gitnexus-shared/src/scope-resolution/registries/evidence.ts new file mode 100644 index 000000000..bacf6c30d --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/evidence.ts @@ -0,0 +1,191 @@ +/** + * `composeEvidence` — translate accumulated raw signals per candidate + * into a `ResolutionEvidence[]` using the authoritative `EvidenceWeights` + * map (RFC §4.3 + Appendix A; Ring 2 SHARED #917). + * + * Each `RawSignals` record describes what was observed about a candidate + * during the 7-step walk: where it was found, at what depth, whether + * anything corroborates it. This module turns those raw facts into the + * typed evidence list attached to the outgoing `Resolution`. + * + * **Every weight comes from `EvidenceWeights`.** No inline magic numbers. + * Extends issue #429 (centralize hardcoded confidence values). + * + * **Confidence compose rule.** Signals add; the sum is capped at 1.0 at + * the call site (inside `lookupCore`). This module only emits the list; + * it does NOT compute the capped sum so callers can inspect per-signal + * contributions for debugging. + */ + +import type { BindingRef, ResolutionEvidence } from '../types.js'; +import { EvidenceWeights, typeBindingWeightAtDepth } from '../evidence-weights.js'; + +/** + * Raw signals observed for a single candidate during the 7-step walk. + * Optional fields encode "this signal did not fire"; presence encodes + * "emit an evidence record". + */ +export interface RawSignals { + // ── Where-found ──────────────────────────────────────────────────────── + /** Visibility origin of the binding that produced this candidate. */ + readonly origin?: BindingRef['origin'] | 'global-qualified' | 'global-name'; + /** Depth at which the binding was found (hops up from start scope). */ + readonly scopeChainDepth?: number; + /** `ImportEdge` that brought the name in; present when origin is a non-local. */ + readonly viaUnlinkedImport?: boolean; + + // ── Type-binding path ────────────────────────────────────────────────── + /** Set when the candidate came via the receiver's type-binding MRO walk. */ + readonly typeBindingMroDepth?: number; + + // ── Corroborators ────────────────────────────────────────────────────── + /** `def.ownerId === resolvedReceiver.def.nodeId`. */ + readonly ownerMatch?: boolean; + /** Always fires for candidates that pass `acceptedKinds`; weight 0. */ + readonly kindMatch: true; + + // ── Arity ────────────────────────────────────────────────────────────── + readonly arityVerdict?: 'compatible' | 'unknown' | 'incompatible'; + + // ── Dynamic-unresolved passthrough ───────────────────────────────────── + /** Candidate flows through a `kind: 'dynamic-unresolved'` ImportEdge. */ + readonly dynamicUnresolved?: boolean; +} + +/** + * Compose the raw signals into a stable `ResolutionEvidence[]` list. + * + * Emission order mirrors the `EvidenceWeights` layout: where-found → + * type-binding → corroborators → arity → degraded. Stable order makes + * the per-signal contributions easy to reason about in tests and in the + * shadow-mode parity dashboard. + */ +export function composeEvidence(signals: RawSignals): readonly ResolutionEvidence[] { + const out: ResolutionEvidence[] = []; + + // ── Where-found visibility ───────────────────────────────────────────── + if (signals.origin !== undefined) { + const baseWeight = getOriginWeight(signals.origin); + const capped = signals.viaUnlinkedImport + ? baseWeight * EvidenceWeights.unlinkedImportMultiplier + : baseWeight; + const evidenceKind = whereFoundEvidenceKind(signals.origin); + out.push({ + kind: evidenceKind, + weight: capped, + ...(signals.viaUnlinkedImport + ? { note: `via unresolved import (${EvidenceWeights.unlinkedImportMultiplier}× cap)` } + : {}), + }); + } + + // ── Scope-chain depth deduction (per-hop, only meaningful for lexical + // hits where scopeChainDepth ≥ 1). Depth 0 = no deduction; depth N ≥ 1 + // emits a single `scope-chain` evidence with the accumulated penalty. + if (signals.scopeChainDepth !== undefined && signals.scopeChainDepth > 0) { + out.push({ + kind: 'scope-chain', + weight: EvidenceWeights.scopeChainPerDepth * signals.scopeChainDepth, + note: `depth=${signals.scopeChainDepth}`, + }); + } + + // ── Type-binding / MRO path ──────────────────────────────────────────── + if (signals.typeBindingMroDepth !== undefined) { + out.push({ + kind: 'type-binding', + weight: typeBindingWeightAtDepth(signals.typeBindingMroDepth), + note: `mroDepth=${signals.typeBindingMroDepth}`, + }); + } + + // ── Owner match (explanatory for debug) ──────────────────────────────── + if (signals.ownerMatch === true) { + out.push({ + kind: 'owner-match', + weight: EvidenceWeights.ownerMatch, + }); + } + + // ── Kind match (always present; weight 0; retained for debuggability) ── + out.push({ + kind: 'kind-match', + weight: EvidenceWeights.kindMatch, + }); + + // ── Arity ────────────────────────────────────────────────────────────── + if (signals.arityVerdict !== undefined) { + const weight = + signals.arityVerdict === 'compatible' + ? EvidenceWeights.arityMatchCompatible + : signals.arityVerdict === 'incompatible' + ? EvidenceWeights.arityMatchIncompatible + : EvidenceWeights.arityMatchUnknown; + out.push({ + kind: 'arity-match', + weight, + note: signals.arityVerdict, + }); + } + + // ── Dynamic-unresolved (degraded signal) ─────────────────────────────── + if (signals.dynamicUnresolved === true) { + out.push({ + kind: 'dynamic-import-unresolved', + weight: EvidenceWeights.dynamicImportUnresolved, + }); + } + + return out; +} + +/** + * Sum evidence weights and clamp to `[0, 1]`. Separate from `composeEvidence` + * so tests and the parity dashboard can inspect the raw evidence list. + */ +export function confidenceFromEvidence(evidence: readonly ResolutionEvidence[]): number { + let sum = 0; + for (const e of evidence) sum += e.weight; + if (sum < 0) return 0; + if (sum > 1) return 1; + return sum; +} + +// ─── Internal ─────────────────────────────────────────────────────────────── + +function getOriginWeight(origin: NonNullable): number { + switch (origin) { + case 'local': + return EvidenceWeights.local; + case 'import': + return EvidenceWeights.import; + case 'reexport': + return EvidenceWeights.reexport; + case 'namespace': + return EvidenceWeights.namespace; + case 'wildcard': + return EvidenceWeights.wildcard; + case 'global-qualified': + return EvidenceWeights.globalQualified; + case 'global-name': + return EvidenceWeights.globalName; + } +} + +function whereFoundEvidenceKind( + origin: NonNullable, +): ResolutionEvidence['kind'] { + switch (origin) { + case 'local': + return 'local'; + case 'import': + case 'reexport': + case 'namespace': + case 'wildcard': + return 'import'; + case 'global-qualified': + return 'global-qualified'; + case 'global-name': + return 'global-name'; + } +} diff --git a/gitnexus-shared/src/scope-resolution/registries/field-registry.ts b/gitnexus-shared/src/scope-resolution/registries/field-registry.ts new file mode 100644 index 000000000..9e6a7aa0f --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/field-registry.ts @@ -0,0 +1,43 @@ +/** + * `FieldRegistry` — scope-aware lookup for field / property / variable + * access (RFC §4.4; Ring 2 SHARED #917). + * + * Thin wrapper over `lookupCore`, specialized for data-member kinds: + * + * - `acceptedKinds` = Variable / Property / Const / Static. + * - `useReceiverTypeBinding` is **true** — fields are resolved against + * the receiver type's MRO first, then via the lexical chain for + * free variables. + * - `callsite` is not meaningful for field access (no arity), but the + * `explicitReceiver` and `ownerScopedContributor` knobs are. + */ + +import type { Resolution, ScopeId } from '../types.js'; +import { lookupCore, type CoreLookupParams } from './lookup-core.js'; +import type { OwnerScopedContributor, RegistryContext } from './context.js'; +import { FIELD_KINDS } from './context.js'; + +export interface FieldLookupOptions { + readonly explicitReceiver?: { readonly name: string }; + readonly ownerScopedContributor?: OwnerScopedContributor; +} + +export interface FieldRegistry { + lookup(name: string, scope: ScopeId, options?: FieldLookupOptions): readonly Resolution[]; +} + +export function buildFieldRegistry(ctx: RegistryContext): FieldRegistry { + return { + lookup(name: string, scope: ScopeId, options: FieldLookupOptions = {}) { + const params: CoreLookupParams = { + acceptedKinds: FIELD_KINDS, + useReceiverTypeBinding: true, + ownerScopedContributor: options.ownerScopedContributor ?? null, + ...(options.explicitReceiver !== undefined + ? { explicitReceiver: options.explicitReceiver } + : {}), + }; + return lookupCore(name, scope, params, ctx); + }, + }; +} diff --git a/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts new file mode 100644 index 000000000..baea0d55e --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts @@ -0,0 +1,447 @@ +/** + * `lookupCore` — the shared 7-step canonical resolution algorithm + * (RFC §4.2; Ring 2 SHARED #917). + * + * Pure function. Given a name, a starting scope, and per-kind parameters, + * walks lexical scopes + optional type-binding MRO + optional owner + * contributor + global qualified-name fallback, and returns a ranked + * `Resolution[]` with per-candidate evidence. + * + * All three public registries (`ClassRegistry` / `MethodRegistry` / + * `FieldRegistry`) dispatch into this function, differing only in the + * parameters they pass. The CHOICE of which steps fire is expressed + * through `LookupParams`, not through different algorithms per kind. + * + * ## Algorithm (RFC §4.2, verbatim names) + * + * **Step 1 — Lexical scope-chain walk.** From `startScope`, walk + * parent-ward. At each scope, consult `scope.bindings.get(name)`: + * - Filter candidates whose `def.type ∈ acceptedKinds`. + * - For each surviving candidate, record a raw signal with the + * binding's origin + the current scope-chain depth. + * - **Hard shadow.** If `bindings.get(name)` is non-empty (including + * non-kind-matching candidates), stop walking. The name is + * lexically bound here; outer scopes are not consulted. + * + * **Step 2 — Type-binding resolution.** When `useReceiverTypeBinding` + * is true, resolve the receiver's type at `startScope` (from + * `scope.typeBindings`), then walk the MRO via + * `MethodDispatchIndex.mroFor(ownerDefId)`. Membership per owner comes + * through `RegistryContext.methodDispatch` + owner lookups into + * `scope.ownedDefs`; each hit records a raw signal with the owner's + * MRO depth. + * + * **Step 3 — Owner-scoped contributor.** When + * `params.ownerScopedContributor` is present, merge its `byName(name)` + * hits with `origin: 'local'` (they are declared directly on the + * receiver). Distinct from Step 2 — Step 2 walks the MRO; Step 3 only + * looks at the directly-declared owner members. + * + * **Step 4 — Kind filter (emit `kind-match` evidence).** Already + * applied during Steps 1-3; this step just adds a `kind-match` signal + * at weight 0 to every candidate for debuggability (so the evidence + * array is self-describing). + * + * **Step 5 — Arity filter.** Call `providers.arityCompatibility(callsite, + * def)` per surviving candidate. Verdicts: `compatible` / `unknown` / + * `incompatible`. If at least one candidate is `compatible`, drop + * `incompatible` ones. Otherwise keep all (the penalty weight alone + * will rank them lower but they remain in the result). + * + * **Step 6 — Global fallback.** When Steps 1-3 produced **no** + * candidates and the name contains a `.`, consult the + * `QualifiedNameIndex` via `lookupQualified` — see §4.5. The `scope` + * argument is NOT passed here because global lookup is scope-agnostic. + * + * **Step 7 — Rank + tie-break.** Compose evidence, compute confidence + * (sum capped at 1.0), sort by the RFC Appendix B cascade. + * + * ## What this module does NOT do + * + * - No AST reads (pure data in, pure data out). + * - No `gitnexus/` imports. + * - No language switches. Language-specific behavior flows exclusively + * through `providers.*` and the `params` object. + * - No caching. Callers that want memoization can wrap this function. + */ + +import type { NodeLabel } from '../../graph/types.js'; +import type { SymbolDefinition } from '../symbol-definition.js'; +import type { + BindingRef, + Callsite, + DefId, + LookupParams, + Resolution, + Scope, + ScopeId, +} from '../types.js'; +import type { OriginForTieBreak } from '../origin-priority.js'; +import { composeEvidence, confidenceFromEvidence, type RawSignals } from './evidence.js'; +import { compareByConfidenceWithTiebreaks, type TieBreakKey } from './tie-breaks.js'; +import { lookupQualified } from './lookup-qualified.js'; +import type { ArityVerdict, OwnerScopedContributor, RegistryContext } from './context.js'; + +// ─── Public entry point ───────────────────────────────────────────────────── + +/** Extended `LookupParams` narrowing `ownerScopedContributor` to the concrete shape. */ +export interface CoreLookupParams extends Omit { + readonly ownerScopedContributor: OwnerScopedContributor | null; + /** Call-site description forwarded to `arityCompatibility`. Optional — for non-call lookups. */ + readonly callsite?: Callsite; +} + +/** + * Run the 7-step lookup. Returns a non-empty `Resolution[]` when any + * candidate was found; an empty array otherwise. Callers consume `[0]` + * for the best answer and optionally inspect the rest for alternates. + */ +export function lookupCore( + name: string, + startScope: ScopeId, + params: CoreLookupParams, + ctx: RegistryContext, +): readonly Resolution[] { + const acceptedKinds = new Set(params.acceptedKinds); + const perCandidate = new Map(); + + // ── Step 1: lexical scope-chain walk ────────────────────────────────── + const lexicalShadowed = walkLexicalChain(name, startScope, acceptedKinds, ctx, perCandidate); + + // ── Step 2: type-binding / MRO walk (methods/fields) ────────────────── + if (params.useReceiverTypeBinding && ctx.methodDispatch !== undefined) { + walkReceiverTypeBinding(name, startScope, acceptedKinds, params, ctx, perCandidate); + } + + // ── Step 3: owner-scoped contributor ────────────────────────────────── + if (params.ownerScopedContributor !== null) { + seedFromOwnerScopedContributor( + name, + params.ownerScopedContributor, + acceptedKinds, + perCandidate, + ); + } + + // ── Step 4: kind-match evidence (emitted by composeEvidence directly) ── + // Handled inside `composeEvidence`. + + // ── Step 5: arity filter ────────────────────────────────────────────── + if (params.callsite !== undefined) { + applyArityFilter(params.callsite, perCandidate, ctx); + } + + // ── Step 6: global fallback (only when Steps 1-3 produced nothing) ── + if (perCandidate.size === 0 && !lexicalShadowed && name.includes('.')) { + const globals = lookupQualified(name, { acceptedKinds: params.acceptedKinds }, ctx); + if (globals.length > 0) return globals; + } + + if (perCandidate.size === 0) return EMPTY; + + // ── Step 7: compose evidence + rank ────────────────────────────────── + return rankCandidates(perCandidate); +} + +// ─── Internal state ──────────────────────────────────────────────────────── + +interface CandidateState { + readonly def: SymbolDefinition; + readonly signals: MutableRawSignals; + readonly tieBreakKey: MutableTieBreakKey; +} + +interface MutableRawSignals { + origin?: BindingRef['origin'] | 'global-qualified' | 'global-name'; + scopeChainDepth?: number; + viaUnlinkedImport?: boolean; + typeBindingMroDepth?: number; + ownerMatch?: boolean; + kindMatch: true; + arityVerdict?: ArityVerdict; + dynamicUnresolved?: boolean; +} + +interface MutableTieBreakKey { + scopeDepth: number; + mroDepth: number; + origin: OriginForTieBreak; +} + +function ensureCandidate( + perCandidate: Map, + def: SymbolDefinition, +): CandidateState { + const existing = perCandidate.get(def.nodeId); + if (existing !== undefined) return existing; + const fresh: CandidateState = { + def, + signals: { kindMatch: true }, + tieBreakKey: { scopeDepth: 0, mroDepth: 0, origin: 'local' }, + }; + perCandidate.set(def.nodeId, fresh); + return fresh; +} + +// ─── Step 1 implementation ───────────────────────────────────────────────── + +/** + * Walk the lexical scope chain from `startScope` upward. Returns `true` + * iff a scope with any `bindings.get(name)` entries was found — the + * caller uses this to decide whether to run the global fallback. + */ +function walkLexicalChain( + name: string, + startScope: ScopeId, + acceptedKinds: ReadonlySet, + ctx: RegistryContext, + perCandidate: Map, +): boolean { + let currentId: ScopeId | null = startScope; + let depth = 0; + const visited = new Set(); + + while (currentId !== null) { + if (visited.has(currentId)) return false; + visited.add(currentId); + + const scope: Scope | undefined = ctx.scopes.getScope(currentId); + if (scope === undefined) return false; + + const bindings = scope.bindings.get(name); + if (bindings !== undefined && bindings.length > 0) { + for (const binding of bindings) { + if (!acceptedKinds.has(binding.def.type)) continue; + recordLexicalHit(perCandidate, binding, depth); + } + return true; // hard shadow regardless of kind-filter survivorship + } + + currentId = scope.parent; + depth++; + } + + return false; +} + +function recordLexicalHit( + perCandidate: Map, + binding: BindingRef, + scopeChainDepth: number, +): void { + const state = ensureCandidate(perCandidate, binding.def); + state.signals.origin = binding.origin; + state.signals.scopeChainDepth = scopeChainDepth; + if (binding.via?.linkStatus === 'unresolved') { + state.signals.viaUnlinkedImport = true; + } + if (binding.via?.kind === 'dynamic-unresolved') { + state.signals.dynamicUnresolved = true; + } + state.tieBreakKey.scopeDepth = scopeChainDepth; + state.tieBreakKey.origin = binding.origin as OriginForTieBreak; +} + +// ─── Step 2 implementation ───────────────────────────────────────────────── + +function walkReceiverTypeBinding( + name: string, + startScope: ScopeId, + acceptedKinds: ReadonlySet, + params: CoreLookupParams, + ctx: RegistryContext, + perCandidate: Map, +): void { + const ownerDefId = resolveReceiverOwner(startScope, params, ctx); + if (ownerDefId === undefined) return; + + if (ctx.methodDispatch === undefined) return; + + const ownerDef = ctx.defs.get(ownerDefId); + if (ownerDef === undefined) return; + + // Walk the owner itself at depth 0, then its MRO chain. + const walk: DefId[] = [ownerDefId, ...ctx.methodDispatch.mroFor(ownerDefId)]; + + for (let mroDepth = 0; mroDepth < walk.length; mroDepth++) { + const currentOwnerId = walk[mroDepth]!; + const members = collectOwnedMembers(currentOwnerId, name, ctx); + for (const def of members) { + if (!acceptedKinds.has(def.type)) continue; + recordTypeBindingHit(perCandidate, def, mroDepth, ownerDefId); + } + } +} + +function resolveReceiverOwner( + startScope: ScopeId, + params: CoreLookupParams, + ctx: RegistryContext, +): DefId | undefined { + // Explicit receiver: consult the callsite scope's typeBindings for the + // named receiver; the attached TypeRef identifies the owner. Without a + // ready resolveTypeRef call (that module is separate), we do a direct + // lookup and trust the caller to have populated the binding. + if (params.explicitReceiver !== undefined) { + return lookupReceiverType(startScope, params.explicitReceiver.name, ctx); + } + + // Implicit `self` / `this` — the scope's typeBindings should carry it. + for (const implicitName of IMPLICIT_RECEIVERS) { + const owner = lookupReceiverType(startScope, implicitName, ctx); + if (owner !== undefined) return owner; + } + return undefined; +} + +const IMPLICIT_RECEIVERS: readonly string[] = Object.freeze(['self', 'this']); + +function lookupReceiverType( + startScope: ScopeId, + receiverName: string, + ctx: RegistryContext, +): DefId | undefined { + let currentId: ScopeId | null = startScope; + const visited = new Set(); + while (currentId !== null) { + if (visited.has(currentId)) return undefined; + visited.add(currentId); + + const scope = ctx.scopes.getScope(currentId); + if (scope === undefined) return undefined; + + const typeRef = scope.typeBindings.get(receiverName); + if (typeRef !== undefined) { + // rawName must resolve to a def via qualifiedNames; if it doesn't, we + // can't claim the receiver type. No fallback — that's what + // `resolveTypeRef` would do, but we keep this path lean and let + // callers pre-resolve if they want the richer semantics. + const candidateIds = ctx.qualifiedNames.get(typeRef.rawName); + if (candidateIds.length === 1) return candidateIds[0]; + // If ambiguous or missing, try a name-match among class-like defs — + // but only when the rawName has no dots (simple name). + return undefined; + } + currentId = scope.parent; + } + return undefined; +} + +function collectOwnedMembers( + ownerDefId: DefId, + memberName: string, + ctx: RegistryContext, +): readonly SymbolDefinition[] { + // An owner's members are defs whose `ownerId === ownerDefId` and whose + // simple name matches `memberName`. We iterate `defs.byId` — O(D) per + // call today. A future by-owner index would make this O(K); tracked as + // a follow-up optimization before Ring 3 flips go production. + const out: SymbolDefinition[] = []; + for (const def of ctx.defs.byId.values()) { + if (def.ownerId !== ownerDefId) continue; + if (simpleNameOf(def) !== memberName) continue; + out.push(def); + } + return out; +} + +function simpleNameOf(def: SymbolDefinition): string | undefined { + if (def.qualifiedName === undefined || def.qualifiedName.length === 0) return undefined; + const dot = def.qualifiedName.lastIndexOf('.'); + return dot === -1 ? def.qualifiedName : def.qualifiedName.slice(dot + 1); +} + +function recordTypeBindingHit( + perCandidate: Map, + def: SymbolDefinition, + mroDepth: number, + receiverOwner: DefId, +): void { + const state = ensureCandidate(perCandidate, def); + // Only replace if this hit is shallower (smaller MRO depth). + if ( + state.signals.typeBindingMroDepth === undefined || + mroDepth < state.signals.typeBindingMroDepth + ) { + state.signals.typeBindingMroDepth = mroDepth; + state.tieBreakKey.mroDepth = mroDepth; + } + if (def.ownerId === receiverOwner) { + state.signals.ownerMatch = true; + } +} + +// ─── Step 3 implementation ───────────────────────────────────────────────── + +function seedFromOwnerScopedContributor( + name: string, + contributor: OwnerScopedContributor, + acceptedKinds: ReadonlySet, + perCandidate: Map, +): void { + for (const def of contributor.byName(name)) { + if (!acceptedKinds.has(def.type)) continue; + const state = ensureCandidate(perCandidate, def); + // Treat the contributor's direct membership as `origin: 'local'` — + // strongest visibility, no scope-chain penalty. + state.signals.origin = 'local'; + state.signals.scopeChainDepth = 0; + state.signals.ownerMatch = def.ownerId === contributor.ownerDefId; + state.tieBreakKey.origin = 'local'; + } +} + +// ─── Step 5 implementation ───────────────────────────────────────────────── + +function applyArityFilter( + callsite: Callsite, + perCandidate: Map, + ctx: RegistryContext, +): void { + const arityFn = ctx.providers.arityCompatibility; + if (arityFn === undefined) { + // No provider → record 'unknown' for every candidate; keeps signal + // shape uniform for composeEvidence. + for (const state of perCandidate.values()) { + state.signals.arityVerdict = 'unknown'; + } + return; + } + + let anyCompatible = false; + for (const state of perCandidate.values()) { + const verdict = arityFn(callsite, state.def); + state.signals.arityVerdict = verdict; + if (verdict === 'compatible') anyCompatible = true; + } + + if (!anyCompatible) return; + + // Filter: when at least one compatible candidate exists, drop incompatibles. + for (const [defId, state] of perCandidate) { + if (state.signals.arityVerdict === 'incompatible') { + perCandidate.delete(defId); + } + } +} + +// ─── Step 7 implementation ───────────────────────────────────────────────── + +function rankCandidates(perCandidate: Map): readonly Resolution[] { + const resolutions: Resolution[] = []; + const tieKeys = new Map(); + + for (const state of perCandidate.values()) { + const evidence = composeEvidence(state.signals as RawSignals); + const confidence = confidenceFromEvidence(evidence); + resolutions.push({ def: state.def, confidence, evidence }); + tieKeys.set(state.def.nodeId, { ...state.tieBreakKey }); + } + + resolutions.sort((a, b) => compareByConfidenceWithTiebreaks(a, b, tieKeys)); + return Object.freeze(resolutions); +} + +// ─── Constants ────────────────────────────────────────────────────────────── + +const EMPTY: readonly Resolution[] = Object.freeze([]); diff --git a/gitnexus-shared/src/scope-resolution/registries/lookup-qualified.ts b/gitnexus-shared/src/scope-resolution/registries/lookup-qualified.ts new file mode 100644 index 000000000..21b630486 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/lookup-qualified.ts @@ -0,0 +1,71 @@ +/** + * `lookupQualified` — qualified-name fast path (RFC §4.5; Ring 2 SHARED #917). + * + * Consults `QualifiedNameIndex` directly, filters by `acceptedKinds`, and + * returns `Resolution[]` with `origin: 'global-qualified'` evidence. Used by: + * + * - `resolveTypeRef` dotted fallback (#916) + * - `Registry.lookup` Step 6 when no lexical candidate survived + * - Explicit dotted identifiers in Cypher / MCP tools where the caller + * knows the target's canonical qualified name + * + * **Strict + deterministic.** No receiver-type resolution, no scope walk. + * Every surviving candidate gets the same base confidence (from + * `EvidenceWeights.globalQualified`), then the tie-break cascade + * disambiguates. + */ + +import type { NodeLabel } from '../../graph/types.js'; +import type { Resolution } from '../types.js'; +import { composeEvidence, confidenceFromEvidence } from './evidence.js'; +import { compareByConfidenceWithTiebreaks, type TieBreakKey } from './tie-breaks.js'; +import type { RegistryContext } from './context.js'; + +export interface LookupQualifiedParams { + readonly acceptedKinds: readonly NodeLabel[]; +} + +/** + * Look up a canonical qualified name (e.g., `app.models.User`) across all + * defs, filtered by `acceptedKinds`. Returns an empty array when the name + * is not indexed or no candidate matches the kind filter. + * + * Callers consume `[0]` for the strict single-return answer; the remainder + * carries alternate candidates (partial classes, overloads, accidental + * cross-kind hits) ordered by the tie-break cascade. + */ +export function lookupQualified( + qualifiedName: string, + params: LookupQualifiedParams, + ctx: RegistryContext, +): readonly Resolution[] { + const defIds = ctx.qualifiedNames.get(qualifiedName); + if (defIds.length === 0) return EMPTY; + + const acceptedKinds = new Set(params.acceptedKinds); + + const resolutions: Resolution[] = []; + const tieKeys = new Map(); + + for (const defId of defIds) { + const def = ctx.defs.get(defId); + if (def === undefined) continue; + if (!acceptedKinds.has(def.type)) continue; + + const evidence = composeEvidence({ origin: 'global-qualified', kindMatch: true }); + const confidence = confidenceFromEvidence(evidence); + resolutions.push({ def, confidence, evidence }); + tieKeys.set(def.nodeId, { + scopeDepth: 0, + mroDepth: 0, + origin: 'global-qualified', + }); + } + + if (resolutions.length === 0) return EMPTY; + + resolutions.sort((a, b) => compareByConfidenceWithTiebreaks(a, b, tieKeys)); + return Object.freeze(resolutions); +} + +const EMPTY: readonly Resolution[] = Object.freeze([]); diff --git a/gitnexus-shared/src/scope-resolution/registries/method-registry.ts b/gitnexus-shared/src/scope-resolution/registries/method-registry.ts new file mode 100644 index 000000000..ed206d164 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/method-registry.ts @@ -0,0 +1,54 @@ +/** + * `MethodRegistry` — scope-aware lookup for method / function / constructor + * dispatch (RFC §4.4; Ring 2 SHARED #917). + * + * Thin wrapper over `lookupCore`, specialized for callable kinds: + * + * - `acceptedKinds` = Method / Function / Constructor. + * - `useReceiverTypeBinding` is **true** — the type-binding + MRO walk + * (Step 2) is the primary evidence path for receiver-dispatched calls. + * - `callsite.arity` flows through to `provider.arityCompatibility` + * when provided. When the provider is absent, arity evidence is + * `unknown` (neutral signal). + */ + +import type { Callsite, Resolution, ScopeId } from '../types.js'; +import { lookupCore, type CoreLookupParams } from './lookup-core.js'; +import type { OwnerScopedContributor, RegistryContext } from './context.js'; +import { METHOD_KINDS } from './context.js'; + +/** + * Extra per-call parameters that vary across call sites but NOT across + * registries. Kept as a separate shape so `MethodRegistry.lookup` stays + * concise while still exposing the explicit-receiver + owner-contributor + + * arity knobs the RFC algorithm needs. + */ +export interface MethodLookupOptions { + /** Call-site arity for `provider.arityCompatibility`. */ + readonly callsite?: Callsite; + /** Explicit receiver (e.g., `user` in `user.save()`). See §4.1. */ + readonly explicitReceiver?: { readonly name: string }; + /** Optional per-owner contributor (Step 3). */ + readonly ownerScopedContributor?: OwnerScopedContributor; +} + +export interface MethodRegistry { + lookup(name: string, scope: ScopeId, options?: MethodLookupOptions): readonly Resolution[]; +} + +export function buildMethodRegistry(ctx: RegistryContext): MethodRegistry { + return { + lookup(name: string, scope: ScopeId, options: MethodLookupOptions = {}) { + const params: CoreLookupParams = { + acceptedKinds: METHOD_KINDS, + useReceiverTypeBinding: true, + ownerScopedContributor: options.ownerScopedContributor ?? null, + ...(options.callsite !== undefined ? { callsite: options.callsite } : {}), + ...(options.explicitReceiver !== undefined + ? { explicitReceiver: options.explicitReceiver } + : {}), + }; + return lookupCore(name, scope, params, ctx); + }, + }; +} diff --git a/gitnexus-shared/src/scope-resolution/registries/tie-breaks.ts b/gitnexus-shared/src/scope-resolution/registries/tie-breaks.ts new file mode 100644 index 000000000..9d6f0dee9 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/tie-breaks.ts @@ -0,0 +1,76 @@ +/** + * `compareByConfidenceWithTiebreaks` — the RFC §4.2 Step 7 total order + * over `Resolution` candidates (Ring 2 SHARED #917). + * + * Primary key is confidence (DESC). Remaining ties within `CONFIDENCE_EPSILON` + * fall through a deterministic cascade so the same inputs always produce + * the same winner, independent of insertion order. + * + * Tie-break cascade (per RFC Appendix B): + * + * 1. confidence DESC (primary) + * 2. scope depth ASC (nearer lexical scope wins) + * 3. MRO depth ASC (nearer class in hierarchy wins) + * 4. `ORIGIN_PRIORITY` ASC (local > import > … > global-name) + * 5. DefId.localeCompare (final deterministic tiebreaker) + * + * The per-candidate inputs needed beyond `Resolution.confidence` — + * `scopeDepth`, `mroDepth`, `origin` — are supplied via a sidecar + * `TieBreakKey` so the comparator stays pure and `Resolution` itself + * doesn't need to carry book-keeping fields. + */ + +import { ORIGIN_PRIORITY, type OriginForTieBreak } from '../origin-priority.js'; +import type { Resolution } from '../types.js'; + +export const CONFIDENCE_EPSILON = 0.001; + +/** Side-information per candidate used for secondary tie-breaks. */ +export interface TieBreakKey { + readonly scopeDepth: number; + readonly mroDepth: number; + readonly origin: OriginForTieBreak; +} + +/** + * Pure comparator suitable for `Array.prototype.sort`. Return value follows + * the JavaScript convention: negative → `a` wins, positive → `b` wins. + * + * **Important:** `keys` is keyed by `Resolution.def.nodeId`, not by array + * index — stable across reorderings. Missing keys fall back to neutral + * values (`scopeDepth: 0`, `mroDepth: 0`, `origin: 'local'`), which means + * the tie-break degrades gracefully to defId-lexicographic ordering when + * side-info is unavailable. That keeps the total order deterministic + * even on malformed inputs. + */ +export function compareByConfidenceWithTiebreaks( + a: Resolution, + b: Resolution, + keys: ReadonlyMap, +): number { + // Primary: confidence DESC, treating values within epsilon as equal. + const delta = b.confidence - a.confidence; + if (Math.abs(delta) >= CONFIDENCE_EPSILON) return delta < 0 ? -1 : 1; + + const ka = keys.get(a.def.nodeId) ?? DEFAULT_KEY; + const kb = keys.get(b.def.nodeId) ?? DEFAULT_KEY; + + // Secondary: scope depth ASC. + if (ka.scopeDepth !== kb.scopeDepth) return ka.scopeDepth - kb.scopeDepth; + + // Tertiary: MRO depth ASC. + if (ka.mroDepth !== kb.mroDepth) return ka.mroDepth - kb.mroDepth; + + // Quaternary: ORIGIN_PRIORITY ASC. + const po = ORIGIN_PRIORITY[ka.origin] - ORIGIN_PRIORITY[kb.origin]; + if (po !== 0) return po; + + // Final: DefId lexicographic, locale-aware for deterministic cross-platform output. + return a.def.nodeId.localeCompare(b.def.nodeId); +} + +const DEFAULT_KEY: TieBreakKey = Object.freeze({ + scopeDepth: 0, + mroDepth: 0, + origin: 'local', +}); diff --git a/gitnexus/test/unit/scope-resolution/registries.test.ts b/gitnexus/test/unit/scope-resolution/registries.test.ts new file mode 100644 index 000000000..d47badc78 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/registries.test.ts @@ -0,0 +1,669 @@ +/** + * Unit tests for the scope-aware registries (RFC §4; Ring 2 SHARED #917). + * + * Tests are organized per RFC §4.2 step so a regression localizes to the + * step it broke: + * + * §4.2 Step 1 — lexical scope-chain walk + shadowing + * §4.2 Step 2 — type-binding / MRO walk (method/field registries) + * §4.2 Step 3 — owner-scoped contributor + * §4.2 Step 4 — kind filter + kind-match evidence + * §4.2 Step 5 — arity filter + * §4.2 Step 6 — global-qualified fallback + * §4.2 Step 7 — rank + tie-break cascade + * §4.5 — lookupQualified helper + * §4.7 — invariants + * + * Corroborators (owner-match, unresolved-import cap, dynamic-unresolved) + * get their own sections. + */ + +import { describe, it, expect } from 'vitest'; +import { + buildClassRegistry, + buildFieldRegistry, + buildMethodRegistry, + buildDefIndex, + buildMethodDispatchIndex, + buildModuleScopeIndex, + buildQualifiedNameIndex, + buildScopeTree, + lookupCore, + lookupQualified, + EvidenceWeights, + type BindingRef, + type ImportEdge, + type Range, + type RegistryContext, + type Resolution, + type Scope, + type ScopeId, + type ScopeKind, + type SymbolDefinition, + type TypeRef, +} from 'gitnexus-shared'; + +// ─── Test helpers ─────────────────────────────────────────────────────────── + +const r = (startLine: number, startCol: number, endLine: number, endCol: number): Range => ({ + startLine, + startCol, + endLine, + endCol, +}); + +const mkDef = (overrides: Partial & { nodeId: string }): SymbolDefinition => ({ + nodeId: overrides.nodeId, + filePath: overrides.filePath ?? 'x.ts', + type: overrides.type ?? 'Class', + ...overrides, +}); + +const mkBinding = ( + def: SymbolDefinition, + origin: BindingRef['origin'], + via?: ImportEdge, +): BindingRef => ({ def, origin, ...(via !== undefined ? { via } : {}) }); + +interface ScopeSpec { + id: ScopeId; + parent: ScopeId | null; + kind?: ScopeKind; + range?: Range; + filePath?: string; + bindings?: Record; + ownedDefs?: readonly SymbolDefinition[]; + typeBindings?: Record; +} + +const mkScope = (s: ScopeSpec): Scope => ({ + id: s.id, + parent: s.parent, + kind: s.kind ?? 'Module', + range: s.range ?? r(1, 0, 1000, 0), + filePath: s.filePath ?? 'x.ts', + bindings: new Map(Object.entries(s.bindings ?? {})), + ownedDefs: s.ownedDefs ?? [], + imports: [], + typeBindings: new Map(Object.entries(s.typeBindings ?? {})), +}); + +const typeRef = (rawName: string, declaredAtScope: ScopeId): TypeRef => ({ + rawName, + declaredAtScope, + source: 'parameter-annotation', +}); + +function makeCtx( + scopes: Scope[], + defs: SymbolDefinition[], + opts: { + mro?: Record; + implsByInterface?: Record; + arity?: ( + callsite: { arity: number }, + def: SymbolDefinition, + ) => 'compatible' | 'unknown' | 'incompatible'; + } = {}, +): RegistryContext { + const defIndex = buildDefIndex(defs); + const qualifiedNameIndex = buildQualifiedNameIndex(defs); + const moduleScopes = buildModuleScopeIndex( + scopes + .filter((s) => s.kind === 'Module') + .map((s) => ({ filePath: s.filePath, moduleScopeId: s.id })), + ); + const owners = Array.from(new Set(defs.map((d) => d.nodeId))); + const methodDispatch = buildMethodDispatchIndex({ + owners, + computeMro: (owner) => opts.mro?.[owner] ?? [], + implementsOf: (owner) => { + const out: string[] = []; + for (const [iface, impls] of Object.entries(opts.implsByInterface ?? {})) { + if (impls.includes(owner)) out.push(iface); + } + return out; + }, + }); + return { + scopes: buildScopeTree(scopes), + defs: defIndex, + qualifiedNames: qualifiedNameIndex, + moduleScopes, + methodDispatch, + providers: opts.arity !== undefined ? { arityCompatibility: opts.arity } : {}, + }; +} + +const evidenceOfKind = (res: Resolution, kind: string) => res.evidence.find((e) => e.kind === kind); + +// ─── §4.2 Step 1 — lexical scope-chain walk + shadowing ──────────────────── + +describe('Step 1: lexical scope-chain walk', () => { + it('finds a class declared at the start scope with origin=local', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class' }); + const mod = mkScope({ + id: 'scope:m', + parent: null, + bindings: { User: [mkBinding(userClass, 'local')] }, + }); + const ctx = makeCtx([mod], [userClass]); + const registry = buildClassRegistry(ctx); + const results = registry.lookup('User', 'scope:m'); + + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(userClass); + expect(evidenceOfKind(results[0]!, 'local')?.weight).toBe(EvidenceWeights.local); + }); + + it('walks parent scopes when the name is not bound at the start scope', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class' }); + const mod = mkScope({ + id: 'scope:m', + parent: null, + bindings: { User: [mkBinding(userClass, 'import')] }, + }); + const fn = mkScope({ + id: 'scope:f', + parent: 'scope:m', + kind: 'Function', + range: r(2, 0, 10, 0), + }); + const ctx = makeCtx([mod, fn], [userClass]); + const results = buildClassRegistry(ctx).lookup('User', 'scope:f'); + + expect(results[0]!.def).toBe(userClass); + const scopeChain = evidenceOfKind(results[0]!, 'scope-chain'); + expect(scopeChain?.weight).toBe(EvidenceWeights.scopeChainPerDepth * 1); + }); + + it('enforces hard shadow: outer bindings are ignored once a name is bound at an inner scope', () => { + const outerClass = mkDef({ nodeId: 'def:outer', type: 'Class' }); + const innerVar = mkDef({ nodeId: 'def:inner', type: 'Variable' }); + const mod = mkScope({ + id: 'scope:m', + parent: null, + bindings: { User: [mkBinding(outerClass, 'local')] }, + }); + const fn = mkScope({ + id: 'scope:f', + parent: 'scope:m', + kind: 'Function', + range: r(2, 0, 10, 0), + bindings: { User: [mkBinding(innerVar, 'local')] }, + }); + const ctx = makeCtx([mod, fn], [outerClass, innerVar]); + + // Inner binding is a Variable (not a Class) → class registry returns empty. + const results = buildClassRegistry(ctx).lookup('User', 'scope:f'); + expect(results).toEqual([]); + }); + + it('emits origin=import evidence when the binding is imported', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class' }); + const mod = mkScope({ + id: 'scope:m', + parent: null, + bindings: { User: [mkBinding(userClass, 'import')] }, + }); + const ctx = makeCtx([mod], [userClass]); + const res = buildClassRegistry(ctx).lookup('User', 'scope:m'); + expect(evidenceOfKind(res[0]!, 'import')?.weight).toBe(EvidenceWeights.import); + }); +}); + +// ─── §4.2 Step 5 — arity filter ──────────────────────────────────────────── + +describe('Step 5: arity filter', () => { + it('drops incompatible candidates when at least one compatible candidate exists', () => { + const save2 = mkDef({ + nodeId: 'def:save-two', + type: 'Method', + qualifiedName: 'User.save', + parameterCount: 2, + }); + const save1 = mkDef({ + nodeId: 'def:save-one', + type: 'Method', + qualifiedName: 'User.save', + parameterCount: 1, + }); + const mod = mkScope({ + id: 'scope:m', + parent: null, + bindings: { save: [mkBinding(save2, 'local'), mkBinding(save1, 'local')] }, + }); + const ctx = makeCtx([mod], [save2, save1], { + arity: (callsite, def) => { + const count = def.parameterCount ?? 0; + if (count === callsite.arity) return 'compatible'; + return 'incompatible'; + }, + }); + const results = buildMethodRegistry(ctx).lookup('save', 'scope:m', { + callsite: { arity: 1 }, + }); + expect(results).toHaveLength(1); + expect(results[0]!.def.nodeId).toBe('def:save-one'); + expect(evidenceOfKind(results[0]!, 'arity-match')?.weight).toBe( + EvidenceWeights.arityMatchCompatible, + ); + }); + + it('keeps incompatible candidates when no compatible candidate exists (soft penalty)', () => { + const save3 = mkDef({ + nodeId: 'def:save-three', + type: 'Method', + qualifiedName: 'User.save', + parameterCount: 3, + }); + const mod = mkScope({ + id: 'scope:m', + parent: null, + bindings: { save: [mkBinding(save3, 'local')] }, + }); + const ctx = makeCtx([mod], [save3], { + arity: () => 'incompatible', + }); + const results = buildMethodRegistry(ctx).lookup('save', 'scope:m', { + callsite: { arity: 1 }, + }); + expect(results).toHaveLength(1); + expect(evidenceOfKind(results[0]!, 'arity-match')?.weight).toBe( + EvidenceWeights.arityMatchIncompatible, + ); + }); + + it('records arity=unknown when the provider is missing (neutral signal)', () => { + const m = mkDef({ nodeId: 'def:m', type: 'Method', qualifiedName: 'C.m' }); + const mod = mkScope({ + id: 'scope:m', + parent: null, + bindings: { m: [mkBinding(m, 'local')] }, + }); + const ctx = makeCtx([mod], [m]); // no arity provider + const results = buildMethodRegistry(ctx).lookup('m', 'scope:m', { + callsite: { arity: 7 }, + }); + expect(evidenceOfKind(results[0]!, 'arity-match')?.weight).toBe( + EvidenceWeights.arityMatchUnknown, + ); + }); +}); + +// ─── §4.2 Step 6 — global-qualified fallback ─────────────────────────────── + +describe('Step 6: global-qualified fallback', () => { + it('falls back to the qualified-name index when no lexical candidate is found', () => { + const cls = mkDef({ nodeId: 'def:app.User', qualifiedName: 'app.User', type: 'Class' }); + const mod = mkScope({ id: 'scope:m', parent: null }); // no lexical binding + const ctx = makeCtx([mod], [cls]); + const results = buildClassRegistry(ctx).lookup('app.User', 'scope:m'); + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(cls); + expect(evidenceOfKind(results[0]!, 'global-qualified')?.weight).toBe( + EvidenceWeights.globalQualified, + ); + }); + + it('does NOT consult the global index when a lexical hit exists (shadowing)', () => { + const localCls = mkDef({ nodeId: 'def:local', type: 'Class' }); + const globalCls = mkDef({ + nodeId: 'def:global', + qualifiedName: 'other.User', + type: 'Class', + }); + const mod = mkScope({ + id: 'scope:m', + parent: null, + bindings: { User: [mkBinding(localCls, 'local')] }, + }); + const ctx = makeCtx([mod], [localCls, globalCls]); + const results = buildClassRegistry(ctx).lookup('User', 'scope:m'); + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(localCls); + }); + + it('does NOT apply the global fallback for non-dotted names', () => { + const cls = mkDef({ nodeId: 'def:x', qualifiedName: 'User', type: 'Class' }); + const mod = mkScope({ id: 'scope:m', parent: null }); + const ctx = makeCtx([mod], [cls]); + // 'User' has no dot → no qname fallback. + const results = buildClassRegistry(ctx).lookup('User', 'scope:m'); + expect(results).toEqual([]); + }); +}); + +// ─── §4.2 Step 7 — tie-breaks ────────────────────────────────────────────── + +describe('Step 7: tie-break cascade', () => { + it('confidence DESC is the primary key', () => { + const nearClass = mkDef({ nodeId: 'def:near', type: 'Class' }); + const farClass = mkDef({ nodeId: 'def:far', type: 'Class' }); + const mod = mkScope({ + id: 'scope:m', + parent: null, + bindings: { User: [mkBinding(farClass, 'local')] }, + }); + const fn = mkScope({ + id: 'scope:f', + parent: 'scope:m', + kind: 'Function', + range: r(2, 0, 10, 0), + bindings: { User: [mkBinding(nearClass, 'local')] }, + }); + const ctx = makeCtx([mod, fn], [nearClass, farClass]); + const results = buildClassRegistry(ctx).lookup('User', 'scope:f'); + + // Inner binding shadows; only the near class should appear. + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(nearClass); + }); + + it('breaks ties by DefId.localeCompare when all secondary keys are equal', () => { + const a = mkDef({ nodeId: 'def:aaa', type: 'Class' }); + const b = mkDef({ nodeId: 'def:bbb', type: 'Class' }); + const mod = mkScope({ + id: 'scope:m', + parent: null, + bindings: { User: [mkBinding(b, 'local'), mkBinding(a, 'local')] }, // reversed + }); + const ctx = makeCtx([mod], [a, b]); + const results = buildClassRegistry(ctx).lookup('User', 'scope:m'); + expect(results[0]!.def.nodeId).toBe('def:aaa'); + expect(results[1]!.def.nodeId).toBe('def:bbb'); + }); +}); + +// ─── Corroborators: unresolved-import cap (per-signal) ───────────────────── + +describe('unresolved-import cap (per-signal)', () => { + it('halves the import evidence weight when via.linkStatus is unresolved', () => { + const cls = mkDef({ nodeId: 'def:User', type: 'Class' }); + const unresolvedEdge: ImportEdge = { + localName: 'User', + targetFile: null, + targetExportedName: 'User', + kind: 'named', + linkStatus: 'unresolved', + }; + const mod = mkScope({ + id: 'scope:m', + parent: null, + bindings: { User: [mkBinding(cls, 'import', unresolvedEdge)] }, + }); + const ctx = makeCtx([mod], [cls]); + const results = buildClassRegistry(ctx).lookup('User', 'scope:m'); + const importEv = evidenceOfKind(results[0]!, 'import'); + expect(importEv?.weight).toBe( + EvidenceWeights.import * EvidenceWeights.unlinkedImportMultiplier, + ); + }); + + it('leaves arity & owner-match signals unaffected by the unresolved-import cap', () => { + const m = mkDef({ + nodeId: 'def:m', + type: 'Method', + qualifiedName: 'User.save', + parameterCount: 1, + }); + const unresolved: ImportEdge = { + localName: 'save', + targetFile: null, + targetExportedName: 'save', + kind: 'named', + linkStatus: 'unresolved', + }; + const mod = mkScope({ + id: 'scope:m', + parent: null, + bindings: { save: [mkBinding(m, 'import', unresolved)] }, + }); + const ctx = makeCtx([mod], [m], { + arity: () => 'compatible', + }); + const results = buildMethodRegistry(ctx).lookup('save', 'scope:m', { + callsite: { arity: 1 }, + }); + expect(evidenceOfKind(results[0]!, 'arity-match')?.weight).toBe( + EvidenceWeights.arityMatchCompatible, + ); + }); +}); + +// ─── Corroborators: dynamic-unresolved degraded signal ───────────────────── + +describe('dynamic-unresolved passthrough', () => { + it('emits a degraded dynamic-import-unresolved signal for dynamic edges', () => { + const cls = mkDef({ nodeId: 'def:X', type: 'Class' }); + const dynEdge: ImportEdge = { + localName: 'X', + targetFile: null, + targetExportedName: '', + kind: 'dynamic-unresolved', + }; + const mod = mkScope({ + id: 'scope:m', + parent: null, + bindings: { X: [mkBinding(cls, 'import', dynEdge)] }, + }); + const ctx = makeCtx([mod], [cls]); + const results = buildClassRegistry(ctx).lookup('X', 'scope:m'); + expect(evidenceOfKind(results[0]!, 'dynamic-import-unresolved')?.weight).toBe( + EvidenceWeights.dynamicImportUnresolved, + ); + }); +}); + +// ─── lookupQualified helper (§4.5) ───────────────────────────────────────── + +describe('lookupQualified (§4.5)', () => { + it('filters by acceptedKinds', () => { + const cls = mkDef({ nodeId: 'def:c', qualifiedName: 'app.User', type: 'Class' }); + const fn = mkDef({ nodeId: 'def:f', qualifiedName: 'app.User', type: 'Function' }); + const ctx = makeCtx([mkScope({ id: 'scope:m', parent: null })], [cls, fn]); + const results = lookupQualified('app.User', { acceptedKinds: ['Class'] }, ctx); + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(cls); + }); + + it('returns empty for unknown qualified names', () => { + const ctx = makeCtx([mkScope({ id: 'scope:m', parent: null })], []); + expect(lookupQualified('app.Ghost', { acceptedKinds: ['Class'] }, ctx)).toEqual([]); + }); + + it('orders multiple partial-class defs deterministically by defId', () => { + const a = mkDef({ nodeId: 'def:aaa', qualifiedName: 'app.User', type: 'Class' }); + const b = mkDef({ nodeId: 'def:bbb', qualifiedName: 'app.User', type: 'Class' }); + const ctx = makeCtx([mkScope({ id: 'scope:m', parent: null })], [b, a]); + const results = lookupQualified('app.User', { acceptedKinds: ['Class'] }, ctx); + expect(results.map((r) => r.def.nodeId)).toEqual(['def:aaa', 'def:bbb']); + }); +}); + +// ─── lookupCore with owner-scoped contributor (Step 3) ───────────────────── + +describe('Step 3: owner-scoped contributor', () => { + it('merges contributor hits as origin=local at the receiver scope', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const saveMethod = mkDef({ + nodeId: 'def:User.save', + type: 'Method', + qualifiedName: 'User.save', + ownerId: 'def:User', + }); + const mod = mkScope({ id: 'scope:m', parent: null }); + const ctx = makeCtx([mod], [userClass, saveMethod]); + const results = buildMethodRegistry(ctx).lookup('save', 'scope:m', { + ownerScopedContributor: { + ownerDefId: 'def:User', + byName: (n) => (n === 'save' ? [saveMethod] : []), + }, + }); + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(saveMethod); + expect(evidenceOfKind(results[0]!, 'local')?.weight).toBe(EvidenceWeights.local); + expect(evidenceOfKind(results[0]!, 'owner-match')?.weight).toBe(EvidenceWeights.ownerMatch); + }); +}); + +// ─── Step 2: type-binding / MRO walk ─────────────────────────────────────── + +describe('Step 2: type-binding + MRO walk', () => { + it('emits type-binding evidence with MRO-depth-decayed weight (explicit receiver)', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const saveMethod = mkDef({ + nodeId: 'def:User.save', + type: 'Method', + qualifiedName: 'User.save', + ownerId: 'def:User', + }); + const callScope = mkScope({ + id: 'scope:call', + parent: null, + typeBindings: { user: typeRef('User', 'scope:call') }, + }); + const ctx = makeCtx([callScope], [userClass, saveMethod]); + const results = buildMethodRegistry(ctx).lookup('save', 'scope:call', { + explicitReceiver: { name: 'user' }, + }); + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(saveMethod); + const typeBinding = evidenceOfKind(results[0]!, 'type-binding'); + expect(typeBinding?.weight).toBe(EvidenceWeights.typeBindingByMroDepth[0]); + }); + + it('walks up the MRO when the method is declared on an ancestor', () => { + const baseClass = mkDef({ nodeId: 'def:Base', type: 'Class', qualifiedName: 'Base' }); + const derivedClass = mkDef({ nodeId: 'def:Derived', type: 'Class', qualifiedName: 'Derived' }); + const saveOnBase = mkDef({ + nodeId: 'def:Base.save', + type: 'Method', + qualifiedName: 'Base.save', + ownerId: 'def:Base', + }); + const callScope = mkScope({ + id: 'scope:call', + parent: null, + typeBindings: { d: typeRef('Derived', 'scope:call') }, + }); + const ctx = makeCtx([callScope], [baseClass, derivedClass, saveOnBase], { + mro: { 'def:Derived': ['def:Base'] }, + }); + const results = buildMethodRegistry(ctx).lookup('save', 'scope:call', { + explicitReceiver: { name: 'd' }, + }); + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(saveOnBase); + // MRO depth for Base when receiver is Derived = 1. + expect(evidenceOfKind(results[0]!, 'type-binding')?.weight).toBe( + EvidenceWeights.typeBindingByMroDepth[1], + ); + }); +}); + +// ─── §4.7 invariants ────────────────────────────────────────────────────── + +describe('§4.7 invariants', () => { + it('Resolution has confidence per-candidate (not per-tier)', () => { + const cls = mkDef({ nodeId: 'def:c', type: 'Class' }); + const mod = mkScope({ + id: 'scope:m', + parent: null, + bindings: { X: [mkBinding(cls, 'local')] }, + }); + const ctx = makeCtx([mod], [cls]); + const results = buildClassRegistry(ctx).lookup('X', 'scope:m'); + expect(typeof results[0]!.confidence).toBe('number'); + expect(results[0]!.confidence).toBeGreaterThan(0); + expect(results[0]!.confidence).toBeLessThanOrEqual(1); + }); + + it('Resolution confidence is capped at 1.0', () => { + const cls = mkDef({ nodeId: 'def:c', type: 'Class' }); + const dummyVia: ImportEdge = { + localName: 'X', + targetFile: 't.ts', + targetExportedName: 'X', + kind: 'named', + }; + const mod = mkScope({ + id: 'scope:m', + parent: null, + // Same def bound via multiple origins — evidence may stack. + bindings: { X: [mkBinding(cls, 'local', dummyVia)] }, + }); + const ctx = makeCtx([mod], [cls], { arity: () => 'compatible' }); + const results = buildClassRegistry(ctx).lookup('X', 'scope:m'); + expect(results[0]!.confidence).toBeLessThanOrEqual(1); + }); + + it('kind-match evidence is always present (weight 0) for debuggability', () => { + const cls = mkDef({ nodeId: 'def:c', type: 'Class' }); + const mod = mkScope({ + id: 'scope:m', + parent: null, + bindings: { X: [mkBinding(cls, 'local')] }, + }); + const ctx = makeCtx([mod], [cls]); + const results = buildClassRegistry(ctx).lookup('X', 'scope:m'); + expect(evidenceOfKind(results[0]!, 'kind-match')).toBeDefined(); + expect(evidenceOfKind(results[0]!, 'kind-match')!.weight).toBe(0); + }); + + it('caller can read [0] for one-shot answers', () => { + const cls = mkDef({ nodeId: 'def:c', type: 'Class' }); + const mod = mkScope({ + id: 'scope:m', + parent: null, + bindings: { X: [mkBinding(cls, 'local')] }, + }); + const ctx = makeCtx([mod], [cls]); + const results = buildClassRegistry(ctx).lookup('X', 'scope:m'); + expect(results[0]!.def).toBe(cls); + }); +}); + +// ─── Misses ─────────────────────────────────────────────────────────────── + +describe('misses', () => { + it('returns empty for an unknown name with no lexical or global hit', () => { + const mod = mkScope({ id: 'scope:m', parent: null }); + const ctx = makeCtx([mod], []); + expect(buildClassRegistry(ctx).lookup('Ghost', 'scope:m')).toEqual([]); + }); + + it('filters out candidates whose kind is not in acceptedKinds', () => { + const method = mkDef({ nodeId: 'def:m', type: 'Method' }); + const mod = mkScope({ + id: 'scope:m', + parent: null, + bindings: { save: [mkBinding(method, 'local')] }, + }); + const ctx = makeCtx([mod], [method]); + // ClassRegistry excludes Method kind → empty. + expect(buildClassRegistry(ctx).lookup('save', 'scope:m')).toEqual([]); + // FieldRegistry also excludes Method → empty. + expect(buildFieldRegistry(ctx).lookup('save', 'scope:m')).toEqual([]); + }); +}); + +// ─── lookupCore direct invocation ───────────────────────────────────────── + +describe('lookupCore direct invocation', () => { + it('accepts an empty params surface and returns empty for an unknown name', () => { + const mod = mkScope({ id: 'scope:m', parent: null }); + const ctx = makeCtx([mod], []); + const results = lookupCore( + 'Ghost', + 'scope:m', + { + acceptedKinds: ['Class'], + useReceiverTypeBinding: false, + ownerScopedContributor: null, + }, + ctx, + ); + expect(results).toEqual([]); + }); +}); From e944f908791a55830f24d97f8a98ba8997f42816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 18:40:29 +0100 Subject: [PATCH 31/46] chore(shared): apply Ring 2 SHARED review follow-ups in one diff (#964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(shared): apply Ring 2 SHARED review follow-ups in one diff Aggregates all actionable follow-ups from the 9 Ring 2 SHARED PRs (#949–#963) before proceeding to Ring 2 PKG. No behavior changes; docstring edits, test refinements, and one structural cleanup. ## #913 (DefIndex / ModuleScopeIndex / QualifiedNameIndex) - Rename `freezeIndex` → `wrapIndex` across all three index builders. The old name implied `Object.freeze` on the wrapper, which we never applied; `wrapIndex` more accurately describes the lightweight readonly-interface wrap. Safety surface (frozen bucket arrays, frozen miss-empty array, readonly Maps) is unchanged. - Document in `buildModuleScopeIndex` JSDoc that callers must pre-normalize `filePath` keys (no path-separator canonicalization happens here). Prevents silent cross-platform misses. - Add an explicit hit-path freeze assertion in `qualified-name-index.test.ts` (the existing test covered only the miss-path `EMPTY` array). ## #914 (MethodDispatchIndex) - Differentiate the C3 and BFS test cases: both tests now use distinct MRO orderings so they prove the materializer stores whatever order the `computeMro` callback produces (not that C3 and BFS yield identical output). - Add `implementsOfCalls` counter in the first-write-wins test, and document the call-count contract in `MethodDispatchInput.implementsOf` JSDoc: `implementsOf` fires **per occurrence** in `input.owners` (not per unique owner); `computeMro` fires at most once per unique owner. Callers with expensive `implementsOf` implementations should pre-dedupe `owners`. ## #916 (resolveTypeRef) - Document the deliberate exclusion of `'Type'` from `TYPE_KINDS` (verified no extractor in `gitnexus/src/core/ingestion/` emits `type: 'Type'` for annotation-relevant symbols). - Rename the namespace-origin test from `'resolves ...'` to `'returns null for a namespace-origin binding whose def is not a type kind'`, matching the failure-case intent. ## #918 (shadow diff + aggregate) - Remove the partial re-export `export type { ShadowAgreement, ShadowDiff };` from `aggregate.ts` — it omitted `ShadowCallsite` and diverged from the top-level barrel. Consumers import all three from the `gitnexus-shared` entry point. - Fix the invalid `'wildcard'` evidence kind in `diff.test.ts` fixture (that kind is not a valid `ResolutionEvidence.kind`). Replaced with `'global-name'`, a real kind the test treats identically. ## #912 (ScopeTree / PositionIndex / makeScopeId) - Document the touching-boundary semantics on `PositionIndex.atPosition`: when siblings share a boundary point, the right (later-start) sibling wins per the existing innermost-wins sort contract. - Resolve the layer-inversion flagged by review: move `ScopeLookup` from `resolve-type-ref.ts` to `types.ts` (its natural home in the data-model layer). `scope-tree.ts` now imports `ScopeLookup` from `types.js` directly; the old re-export from `resolve-type-ref.ts` is removed per repo convention (`feedback_no_reexport`). Barrel export moved alongside. ## #917 (ClassRegistry / MethodRegistry / FieldRegistry) - Replace the dangling "try a name-match among class-like defs" comment in `lookupReceiverType` with explicit prose that callers must pre-resolve via `resolveTypeRef` if they want richer semantics. No behavior change — the function already returned `undefined` on ambiguous/missing qnames. - Fix `tieBreakKey.origin` default for pure Step-2 candidates. Type-binding-only hits no longer falsely inherit `'local'` from `ensureCandidate`'s neutral default; they now demote to `'import'` on their first type-binding hit, and only a later Step-1 lexical hit can upgrade them back to `'local'`. Keeps the Appendix B cascade faithful to the true origin. - Document `'global-name'` in `evidence.ts`: currently reserved for Ring 3's byName global index; `lookupCore` never emits it today. The weight stays live so `composeEvidence` remains exhaustive over the origin union. - Rename the mislabeled Step-7 test from `'confidence DESC is the primary key'` (which actually tested hard-shadow baseline) to `'inner scope shadows outer, yielding single result'`, and add a separate test that actually exercises multi-candidate confidence ordering (local vs wildcard at the same scope). ## Verification - `tsc --noEmit` clean (both `gitnexus-shared` and `gitnexus`) - `gitnexus-shared` build clean - Combined scope-resolution / model / shadow suite: **260/260 pass** (+1 from the new multi-candidate ordering test in #917) ## Not addressed (non-actionable) - #949 CI "failure with zero failing tests": pre-existing Swift Node 22 grammar flake unrelated to #910 scope. - #950: the two non-blocking findings were already addressed in follow-up commit `cbac32ba` (ParsedImport discriminated union + `ScopeId | null` on the two hooks). - #915: the five in-scope findings were already addressed in follow-up commit `54515a7e` (dead code, unused params, multi-hop docs, cap-hit test, stats granularity). - #915 LanguageProvider.resolveImportTarget signature divergence + `findDefById` O(F×D) perf: tracked separately as follow-up issues for the Ring 3 migration window. * chore(shared): address ce:review findings on the follow-up diff ce:review (interactive) on PR #964 surfaced two P2s and several P3s. This commit applies all `safe_auto` fixes + both manual tests in-line so the PR ships with a cleaner review trail. ## P2 fixes - **Complete `freezeIndex` → `wrapIndex` rename.** The prior commit renamed 3 of 5 sibling index files; `method-dispatch-index.ts` and `position-index.ts` still carried the old name. Now all 5 helpers use the consistent `wrapIndex` naming. (maintainability + project-standards reviewers both flagged this.) - **Add regression tests for the `recordTypeBindingHit` origin demotion.** The prior commit introduced the `tieBreakKey.origin = 'import'` demotion for Step-2-only candidates without a direct test. Added: - `registries.test.ts`: two Step-2-only siblings under the same interface, asserting deterministic DefId.localeCompare tie-break AND the stronger invariant that composeEvidence never emits a where-found signal for Step-2-only candidates (no `signals.origin`). - `position-index.test.ts`: touching-boundary test proving the right-sibling-wins rule documented in the new JSDoc. (testing + kieran-typescript + api-contract reviewers all flagged these gaps.) ## P3 fixes - Fix wrong comment in `recordTypeBindingHit` that claimed Step 1 could later upgrade a demoted origin. Step 1 runs BEFORE Step 2 — the actual upgrade path is Step 3 (`seedFromOwnerScopedContributor`). Comment now describes execution order correctly. - Fix inaccurate "re-exported there" comment in `index.ts`. `types.ts` *defines* ScopeLookup natively; it's not a re-export. Phrasing now says "defined in types.ts and exported from the type-export block above — not from this module." - Update stale `scope-tree.ts` file-header prose that still referenced `ScopeLookup` as living in #916/resolve-type-ref.ts. Now points to `./types.js` with a cross-ref to both #916 and #917 consumers. - Expand `atPosition` touching-boundary JSDoc to name the mechanism (backward scan through start-sorted array) so readers can trace the binary-search code to the claim. - Add breadcrumb to `aggregate.ts` module header pointing future readers to `./diff.ts` / the top-level barrel for `ShadowAgreement`, `ShadowCallsite`, and `ShadowDiff`. - Remove unnecessary non-null assertion in `recordTypeBindingHit`. Local `const existingMroDepth = ...` lets TS narrow to `number` in the else-branch, eliminating the `!` without behavior change. ## Verification - `tsc --noEmit` clean (both `gitnexus-shared` and `gitnexus`) - `gitnexus-shared` build clean - Combined scope-resolution / model / shadow suite: **262/262 pass** (+2 from the new origin-demotion + touching-boundary regression tests) --- gitnexus-shared/src/index.ts | 5 +- .../src/scope-resolution/def-index.ts | 4 +- .../scope-resolution/method-dispatch-index.ts | 12 ++- .../scope-resolution/module-scope-index.ts | 12 ++- .../src/scope-resolution/position-index.ts | 16 +++- .../scope-resolution/qualified-name-index.ts | 4 +- .../scope-resolution/registries/evidence.ts | 5 ++ .../registries/lookup-core.ts | 28 +++++-- .../src/scope-resolution/resolve-type-ref.ts | 18 ++--- .../src/scope-resolution/scope-tree.ts | 8 +- .../src/scope-resolution/shadow/aggregate.ts | 7 +- gitnexus-shared/src/scope-resolution/types.ts | 12 +++ .../method-dispatch-index.test.ts | 37 ++++++--- .../scope-resolution/position-index.test.ts | 18 +++++ .../qualified-name-index.test.ts | 3 + .../unit/scope-resolution/registries.test.ts | 81 ++++++++++++++++++- .../scope-resolution/resolve-type-ref.test.ts | 8 +- gitnexus/test/unit/shadow/diff.test.ts | 6 +- 18 files changed, 233 insertions(+), 51 deletions(-) diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 255aa9345..beeb53587 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -48,6 +48,7 @@ export type { ParsedTypeBinding, WorkspaceIndex, Callsite, + ScopeLookup, } from './scope-resolution/types.js'; // Evidence + tie-break constants (RFC Appendix A, Appendix B) @@ -71,8 +72,10 @@ export { buildQualifiedNameIndex } from './scope-resolution/qualified-name-index export type { QualifiedNameIndex } from './scope-resolution/qualified-name-index.js'; // Strict type-reference resolver (RFC §4.6; Ring 2 SHARED #916) +// `ScopeLookup` is defined in `./scope-resolution/types.js` and exported +// from the type-export block above — not from this module. export { resolveTypeRef } from './scope-resolution/resolve-type-ref.js'; -export type { ResolveTypeRefContext, ScopeLookup } from './scope-resolution/resolve-type-ref.js'; +export type { ResolveTypeRefContext } from './scope-resolution/resolve-type-ref.js'; // Method-dispatch materialized view over HeritageMap (RFC §3.1; Ring 2 SHARED #914) export { buildMethodDispatchIndex } from './scope-resolution/method-dispatch-index.js'; diff --git a/gitnexus-shared/src/scope-resolution/def-index.ts b/gitnexus-shared/src/scope-resolution/def-index.ts index bc27f773e..a34eab961 100644 --- a/gitnexus-shared/src/scope-resolution/def-index.ts +++ b/gitnexus-shared/src/scope-resolution/def-index.ts @@ -41,12 +41,12 @@ export function buildDefIndex(defs: readonly SymbolDefinition[]): DefIndex { if (byId.has(def.nodeId)) continue; // first-write-wins byId.set(def.nodeId, def); } - return freezeIndex(byId); + return wrapIndex(byId); } // ─── Internal ─────────────────────────────────────────────────────────────── -function freezeIndex(byId: Map): DefIndex { +function wrapIndex(byId: Map): DefIndex { return { byId, get size() { diff --git a/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts b/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts index 050538db5..d09e8fa89 100644 --- a/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts +++ b/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts @@ -71,6 +71,14 @@ export interface MethodDispatchInput { * returned. * * Repeated IDs in the output are deduplicated automatically. + * + * **Call-count contract.** `implementsOf` is invoked **once per + * occurrence** of an owner in `input.owners`, not once per unique + * owner. Duplicate owners therefore re-invoke it; dedup happens at + * the bucket layer (after the callback returns). Callers with + * expensive `implementsOf` implementations should pass a deduplicated + * `owners` list. `computeMro`, by contrast, is memoized by the first- + * write-wins policy and fires at most once per unique owner. */ readonly implementsOf: (ownerDefId: DefId) => readonly DefId[]; } @@ -113,14 +121,14 @@ export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDisp implsByInterfaceDefId.set(ifaceId, Object.freeze(owners.slice())); } - return freezeIndex(mroByOwnerDefId, implsByInterfaceDefId); + return wrapIndex(mroByOwnerDefId, implsByInterfaceDefId); } // ─── Internal ─────────────────────────────────────────────────────────────── const EMPTY: readonly DefId[] = Object.freeze([]); -function freezeIndex( +function wrapIndex( mroByOwnerDefId: Map, implsByInterfaceDefId: Map, ): MethodDispatchIndex { diff --git a/gitnexus-shared/src/scope-resolution/module-scope-index.ts b/gitnexus-shared/src/scope-resolution/module-scope-index.ts index a71c5d3d7..a57c02d27 100644 --- a/gitnexus-shared/src/scope-resolution/module-scope-index.ts +++ b/gitnexus-shared/src/scope-resolution/module-scope-index.ts @@ -36,6 +36,14 @@ export interface ModuleScopeEntry { * entry preserves the first-stable id the rest of the pipeline may already * have registered against. * + * **Caller contract: filePath keys must be pre-normalized.** This index + * keys on the raw `filePath` string and does NOT canonicalize separators, + * case, or trailing slashes. Callers upstream of this function must agree + * on a canonical form (typically repo-root-relative, POSIX separators, + * no trailing slash) before constructing entries — otherwise `C:\foo\bar.ts`, + * `C:/foo/bar.ts`, and `foo/bar.ts` will all hash to distinct buckets and + * `get()` will miss. + * * Pure function — safe to call repeatedly; no side effects. */ export function buildModuleScopeIndex(entries: readonly ModuleScopeEntry[]): ModuleScopeIndex { @@ -44,12 +52,12 @@ export function buildModuleScopeIndex(entries: readonly ModuleScopeEntry[]): Mod if (byFilePath.has(filePath)) continue; // first-write-wins byFilePath.set(filePath, moduleScopeId); } - return freezeIndex(byFilePath); + return wrapIndex(byFilePath); } // ─── Internal ─────────────────────────────────────────────────────────────── -function freezeIndex(byFilePath: Map): ModuleScopeIndex { +function wrapIndex(byFilePath: Map): ModuleScopeIndex { return { byFilePath, get size() { diff --git a/gitnexus-shared/src/scope-resolution/position-index.ts b/gitnexus-shared/src/scope-resolution/position-index.ts index 82954a19f..a2fd80828 100644 --- a/gitnexus-shared/src/scope-resolution/position-index.ts +++ b/gitnexus-shared/src/scope-resolution/position-index.ts @@ -37,6 +37,18 @@ export interface PositionIndex { * Innermost scope containing `(line, col)` in `filePath`, or `undefined` * when nothing contains it (position before file start, after file end, * or filePath not indexed). + * + * **Touching-boundary semantics.** Ranges are inclusive on both ends. + * When two sibling scopes share a boundary point — e.g. + * `[5:0, 10:0]` and `[10:0, 15:0]`, which is legal under `ScopeTree`'s + * non-overlap invariant — a query at the shared point `(10, 0)` is + * contained by **both**. The innermost-wins tie-break rule applies as + * usual: since neither is nested inside the other, the one that + * **starts latest** wins, i.e. the **right** sibling. The mechanism + * is the backward scan through the start-position-sorted array (see + * `findLastStartLteIndex` below) — both siblings land before the + * upper-bound cursor, and the right sibling is scanned first. Queries at non-boundary positions between them naturally + * fall to the unique containing scope. */ atPosition(filePath: string, line: number, col: number): ScopeId | undefined; } @@ -69,7 +81,7 @@ export function buildPositionIndex(scopes: readonly Scope[]): PositionIndex { bucket.sort(compareEntry); } - return freezeIndex(entriesByFile, seen.size); + return wrapIndex(entriesByFile, seen.size); } // ─── Internals ────────────────────────────────────────────────────────────── @@ -128,7 +140,7 @@ function findLastStartLteIndex(arr: readonly Entry[], line: number, col: number) return lo - 1; } -function freezeIndex(entriesByFile: Map, size: number): PositionIndex { +function wrapIndex(entriesByFile: Map, size: number): PositionIndex { return { get size() { return size; diff --git a/gitnexus-shared/src/scope-resolution/qualified-name-index.ts b/gitnexus-shared/src/scope-resolution/qualified-name-index.ts index 64dbd9630..e231b01d5 100644 --- a/gitnexus-shared/src/scope-resolution/qualified-name-index.ts +++ b/gitnexus-shared/src/scope-resolution/qualified-name-index.ts @@ -69,14 +69,14 @@ export function buildQualifiedNameIndex(defs: readonly SymbolDefinition[]): Qual frozen.set(k, Object.freeze(v.slice())); } - return freezeIndex(frozen); + return wrapIndex(frozen); } // ─── Internal ─────────────────────────────────────────────────────────────── const EMPTY: readonly DefId[] = Object.freeze([]); -function freezeIndex(byQualifiedName: Map): QualifiedNameIndex { +function wrapIndex(byQualifiedName: Map): QualifiedNameIndex { return { byQualifiedName, get size() { diff --git a/gitnexus-shared/src/scope-resolution/registries/evidence.ts b/gitnexus-shared/src/scope-resolution/registries/evidence.ts index bacf6c30d..cabeb6a95 100644 --- a/gitnexus-shared/src/scope-resolution/registries/evidence.ts +++ b/gitnexus-shared/src/scope-resolution/registries/evidence.ts @@ -168,6 +168,11 @@ function getOriginWeight(origin: NonNullable): number { case 'global-qualified': return EvidenceWeights.globalQualified; case 'global-name': + // Reserved for Ring 3 byName global index. `lookupCore` today only + // emits `'global-qualified'` (via `lookupQualified`, dotted-name + // fallback); no code path constructs `origin: 'global-name'` yet. + // Kept here so the Appendix A weight stays live and `composeEvidence` + // remains exhaustive over the origin union. return EvidenceWeights.globalName; } } diff --git a/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts index baea0d55e..a18ad4930 100644 --- a/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts +++ b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts @@ -318,8 +318,9 @@ function lookupReceiverType( // callers pre-resolve if they want the richer semantics. const candidateIds = ctx.qualifiedNames.get(typeRef.rawName); if (candidateIds.length === 1) return candidateIds[0]; - // If ambiguous or missing, try a name-match among class-like defs — - // but only when the rawName has no dots (simple name). + // Ambiguous (≥ 2) or missing (0) — caller must pre-resolve via + // `resolveTypeRef` (#916) if they want the richer semantics. We + // intentionally do NOT re-implement a simple-name fallback here. return undefined; } currentId = scope.parent; @@ -358,17 +359,30 @@ function recordTypeBindingHit( receiverOwner: DefId, ): void { const state = ensureCandidate(perCandidate, def); - // Only replace if this hit is shallower (smaller MRO depth). - if ( - state.signals.typeBindingMroDepth === undefined || - mroDepth < state.signals.typeBindingMroDepth - ) { + const existingMroDepth = state.signals.typeBindingMroDepth; + const firstHit = existingMroDepth === undefined; + // Only replace if this hit is shallower (smaller MRO depth). The local + // const lets TS narrow to `number` in the `else` branch so no `!` + // assertion is needed. + if (firstHit || mroDepth < existingMroDepth) { state.signals.typeBindingMroDepth = mroDepth; state.tieBreakKey.mroDepth = mroDepth; } if (def.ownerId === receiverOwner) { state.signals.ownerMatch = true; } + // Pure type-binding candidates (no lexical hit) would otherwise keep the + // `ensureCandidate` default `tieBreakKey.origin === 'local'`, making the + // Appendix B cascade lump them with local-origin candidates. Demote them + // to `'import'` — the strongest non-local origin — only when no earlier + // phase set an origin for this candidate. Lexical hits from Step 1 set + // `signals.origin` before Step 2 runs, so the guard skips them; Step 3 + // (`seedFromOwnerScopedContributor`) runs AFTER Step 2 and unconditionally + // overrides `tieBreakKey.origin` back to `'local'` for direct-owner + // members, so any same-def overlap still ends up ranked correctly. + if (firstHit && state.signals.origin === undefined) { + state.tieBreakKey.origin = 'import'; + } } // ─── Step 3 implementation ───────────────────────────────────────────────── diff --git a/gitnexus-shared/src/scope-resolution/resolve-type-ref.ts b/gitnexus-shared/src/scope-resolution/resolve-type-ref.ts index 63bd6a8da..2f8ba7bd5 100644 --- a/gitnexus-shared/src/scope-resolution/resolve-type-ref.ts +++ b/gitnexus-shared/src/scope-resolution/resolve-type-ref.ts @@ -38,22 +38,12 @@ import type { NodeLabel } from '../graph/types.js'; import type { SymbolDefinition } from './symbol-definition.js'; -import type { BindingRef, Scope, ScopeId, TypeRef } from './types.js'; +import type { BindingRef, ScopeId, ScopeLookup, TypeRef } from './types.js'; import type { DefIndex } from './def-index.js'; import type { QualifiedNameIndex } from './qualified-name-index.js'; // ─── Public contracts ─────────────────────────────────────────────────────── -/** - * Minimal scope-lookup contract required by `resolveTypeRef`. Implemented by - * the `ScopeTree` from #912; declared here so #916 can ship as a standalone - * piece without a hard dependency on the full scope-tree implementation. Any - * structure that hands back a `Scope` by `ScopeId` satisfies this contract. - */ -export interface ScopeLookup { - getScope(id: ScopeId): Scope | undefined; -} - /** * All inputs `resolveTypeRef` needs from the semantic model. Bundled into a * context object so the call site stays short and the interface is stable as @@ -83,6 +73,12 @@ const STRICT_ORIGINS: ReadonlySet = new Set = new Set([ 'Class', diff --git a/gitnexus-shared/src/scope-resolution/scope-tree.ts b/gitnexus-shared/src/scope-resolution/scope-tree.ts index 705dfb731..7f2b54684 100644 --- a/gitnexus-shared/src/scope-resolution/scope-tree.ts +++ b/gitnexus-shared/src/scope-resolution/scope-tree.ts @@ -18,15 +18,15 @@ * pointers would be a category error — a `File` scope is not the * parent of another file's scopes; imports do that job.) * - * Satisfies the `ScopeLookup` contract from #916 (`resolve-type-ref`), so - * `resolveTypeRef` can take a `ScopeTree` directly without adapters. + * Satisfies the `ScopeLookup` contract (defined in `./types.js`), so + * `resolveTypeRef` (#916) and the scope-aware registries (#917) can take a + * `ScopeTree` directly without adapters. * * Immutable surface: `byId` is a `ReadonlyMap`; children arrays are * `Object.freeze`d; miss lookups return a shared frozen empty array. */ -import type { Scope, ScopeId, Range } from './types.js'; -import type { ScopeLookup } from './resolve-type-ref.js'; +import type { Scope, ScopeId, ScopeLookup, Range } from './types.js'; // ─── Public contract ──────────────────────────────────────────────────────── diff --git a/gitnexus-shared/src/scope-resolution/shadow/aggregate.ts b/gitnexus-shared/src/scope-resolution/shadow/aggregate.ts index 08ff25322..27c24ff92 100644 --- a/gitnexus-shared/src/scope-resolution/shadow/aggregate.ts +++ b/gitnexus-shared/src/scope-resolution/shadow/aggregate.ts @@ -5,6 +5,11 @@ * Pure functions; no I/O. The harness persists per-run JSON; the dashboard * reads `.gitnexus/shadow-parity/latest.json` and renders. * + * Related types — `ShadowAgreement`, `ShadowCallsite`, `ShadowDiff` — are + * defined alongside `diffResolutions` in `./diff.ts` and re-exported + * through the top-level `gitnexus-shared` barrel. Consumers import all + * three from `gitnexus-shared`, not from this module. + * * Part of RFC #909 Ring 2 SHARED — #918. */ @@ -181,5 +186,3 @@ function buildOverallRow( const parity = resolved > 0 ? bothAgree / resolved : 0; return { totalCalls, bothAgree, onlyLegacy, onlyNew, bothDisagree, bothEmpty, parity }; } - -export type { ShadowAgreement, ShadowDiff }; diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index 9a08a3a38..3e1611593 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -209,6 +209,18 @@ export type WorkspaceIndex = unknown; // The former opaque placeholder lived here during Ring 1; removed now that // the concrete type exists. Consumers import from `gitnexus-shared` directly. +/** + * Minimal scope-lookup contract: map a `ScopeId` back to its `Scope` record. + * + * Lives in the data-model layer so both `ScopeTree` (§3.1) and + * `resolveTypeRef` / `Registry.lookup` (§4) can depend on it without + * inverting each other. `ScopeTree` is the canonical implementation; + * tests and future alternative containers may supply their own. + */ +export interface ScopeLookup { + getScope(id: ScopeId): Scope | undefined; +} + /** Call-site description passed to `arityCompatibility`. */ export interface Callsite { /** Number of arguments at the call site. */ diff --git a/gitnexus/test/unit/scope-resolution/method-dispatch-index.test.ts b/gitnexus/test/unit/scope-resolution/method-dispatch-index.test.ts index bc4f75812..386474723 100644 --- a/gitnexus/test/unit/scope-resolution/method-dispatch-index.test.ts +++ b/gitnexus/test/unit/scope-resolution/method-dispatch-index.test.ts @@ -59,8 +59,9 @@ describe('buildMethodDispatchIndex', () => { }); it('records a C3 linearization verbatim (Python diamond)', () => { - // D(B, C) where B(A), C(A). Classical C3: D, B, C, A. - // Our index stores mro excluding self: [B, C, A]. + // D(B, C) where B(A), C(A). Classical C3 keeps A last because the + // merge step defers A until both B and C have been emitted. + // Our index stores MRO excluding self: [B, C, A]. const idx = buildMethodDispatchIndex( input(['def:D'], { 'def:D': ['def:B', 'def:C', 'def:A'] }), ); @@ -68,11 +69,15 @@ describe('buildMethodDispatchIndex', () => { }); it('records a BFS linearization verbatim (Java-style first-wins)', () => { - // D extends B, C; B extends A; C extends A. BFS: B, C, A. + // Same class hierarchy as the C3 case, but the BFS walker visits + // A before C via the B→A edge. Expected MRO differs from C3: [B, A, C]. + // This test proves the materializer preserves whatever ordering the + // per-language `computeMro` callback produces — NOT that C3 and BFS + // produce identical output. const idx = buildMethodDispatchIndex( - input(['def:D'], { 'def:D': ['def:B', 'def:C', 'def:A'] }), + input(['def:D'], { 'def:D': ['def:B', 'def:A', 'def:C'] }), ); - expect(idx.mroFor('def:D')).toEqual(['def:B', 'def:C', 'def:A']); + expect(idx.mroFor('def:D')).toEqual(['def:B', 'def:A', 'def:C']); }); it('records a Ruby-style kind-aware ancestry verbatim', () => { @@ -133,9 +138,19 @@ describe('buildMethodDispatchIndex', () => { it('deduplicates when the same owner is listed in `owners` twice (first-write-wins)', () => { // First-write-wins parity with sibling indexes; subsequent owner entries - // should not re-invoke callbacks for existing MRO, and should not create - // duplicate implementor entries. + // should not re-invoke `computeMro` for existing MRO, and should not + // create duplicate implementor entries. + // + // NOTE on `implementsOf` call count: the builder calls `implementsOf` + // ONCE PER OCCURRENCE of an owner in `input.owners`, not once per + // unique owner. Duplicate owners therefore re-invoke `implementsOf`; + // the dedup lives at the bucket layer (via `implsSeen`), not the + // callback layer. Callers with expensive `implementsOf` callbacks + // should dedupe `input.owners` upfront. This counter assertion pins + // that contract so a future refactor can't silently collapse the + // second call without updating the docstring. let mroCalls = 0; + let implementsOfCalls = 0; const impls: Record = { 'def:A': ['def:I'] }; const idx = buildMethodDispatchIndex({ owners: ['def:A', 'def:A'], @@ -143,9 +158,13 @@ describe('buildMethodDispatchIndex', () => { mroCalls++; return ['def:B']; }, - implementsOf: (o) => impls[o] ?? [], + implementsOf: (o) => { + implementsOfCalls++; + return impls[o] ?? []; + }, }); - expect(mroCalls).toBe(1); + expect(mroCalls).toBe(1); // MRO dedup is at the callback layer (first-write-wins) + expect(implementsOfCalls).toBe(2); // implementsOf fires per occurrence; dedup at bucket expect(idx.mroFor('def:A')).toEqual(['def:B']); expect(idx.implementorsOf('def:I')).toEqual(['def:A']); }); diff --git a/gitnexus/test/unit/scope-resolution/position-index.test.ts b/gitnexus/test/unit/scope-resolution/position-index.test.ts index 8c3a410b1..ae037e4b2 100644 --- a/gitnexus/test/unit/scope-resolution/position-index.test.ts +++ b/gitnexus/test/unit/scope-resolution/position-index.test.ts @@ -135,6 +135,24 @@ describe('buildPositionIndex', () => { expect(idx.atPosition('a.ts', 30, 0)).toBe('scope:b'); expect(idx.atPosition('a.ts', 22, 0)).toBe('scope:mod'); // gap between siblings }); + + it('returns the right (later-start) sibling when two siblings share a boundary point', () => { + // Legal touching-boundary scenario per ScopeTree's non-overlap rule: + // [5:0..10:0] and [10:0..15:0] meet at (10, 0) but do not overlap + // (rangesOverlap treats end == start as "touches, not overlaps"). + // A query AT the shared point is contained by BOTH siblings; the + // innermost-wins comparator breaks the tie by start position ASC: + // the right sibling (starts at 10:0) is scanned first during the + // backward pass and wins. See `atPosition` JSDoc. + const idx = buildPositionIndex([ + mkScope('scope:mod', 'a.ts', 'Module', r(1, 0, 100, 0)), + mkScope('scope:left', 'a.ts', 'Block', r(5, 0, 10, 0), 'scope:mod'), + mkScope('scope:right', 'a.ts', 'Block', r(10, 0, 15, 0), 'scope:mod'), + ]); + expect(idx.atPosition('a.ts', 10, 0)).toBe('scope:right'); // shared boundary + expect(idx.atPosition('a.ts', 7, 0)).toBe('scope:left'); // inside left only + expect(idx.atPosition('a.ts', 12, 0)).toBe('scope:right'); // inside right only + }); }); describe('multi-file isolation', () => { diff --git a/gitnexus/test/unit/scope-resolution/qualified-name-index.test.ts b/gitnexus/test/unit/scope-resolution/qualified-name-index.test.ts index 77fc5ecc7..a9cbced57 100644 --- a/gitnexus/test/unit/scope-resolution/qualified-name-index.test.ts +++ b/gitnexus/test/unit/scope-resolution/qualified-name-index.test.ts @@ -47,6 +47,9 @@ describe('buildQualifiedNameIndex', () => { }); const idx = buildQualifiedNameIndex([a, b]); expect(idx.get('app.User')).toEqual(['def:app.User:Core', 'def:app.User:Api']); + // Hit-path bucket is frozen just like the miss path — consumers cannot + // mutate the returned array. + expect(() => (idx.get('app.User') as unknown as string[]).push('x')).toThrow(); }); it('preserves input order in the bucket', () => { diff --git a/gitnexus/test/unit/scope-resolution/registries.test.ts b/gitnexus/test/unit/scope-resolution/registries.test.ts index d47badc78..b204287b5 100644 --- a/gitnexus/test/unit/scope-resolution/registries.test.ts +++ b/gitnexus/test/unit/scope-resolution/registries.test.ts @@ -337,7 +337,11 @@ describe('Step 6: global-qualified fallback', () => { // ─── §4.2 Step 7 — tie-breaks ────────────────────────────────────────────── describe('Step 7: tie-break cascade', () => { - it('confidence DESC is the primary key', () => { + it('inner scope shadows outer, yielding single result (hard-shadow baseline)', () => { + // Baseline: the hard-shadow rule in Step 1 means a near binding fully + // replaces the far one. No "confidence DESC" ordering to observe here + // because there is only one candidate — the far class never enters + // the result set. See the next test for true multi-candidate ranking. const nearClass = mkDef({ nodeId: 'def:near', type: 'Class' }); const farClass = mkDef({ nodeId: 'def:far', type: 'Class' }); const mod = mkScope({ @@ -355,11 +359,31 @@ describe('Step 7: tie-break cascade', () => { const ctx = makeCtx([mod, fn], [nearClass, farClass]); const results = buildClassRegistry(ctx).lookup('User', 'scope:f'); - // Inner binding shadows; only the near class should appear. expect(results).toHaveLength(1); expect(results[0]!.def).toBe(nearClass); }); + it('orders multiple same-scope candidates by confidence DESC', () => { + // Two candidates co-exist at the same scope, one with origin=local + // (weight 0.55) and one with origin=wildcard (weight 0.30). Both pass + // the Class kind filter; confidence DESC should sort local first. + const localClass = mkDef({ nodeId: 'def:local', type: 'Class' }); + const wildcardClass = mkDef({ nodeId: 'def:wildcard', type: 'Class' }); + const mod = mkScope({ + id: 'scope:m', + parent: null, + bindings: { + User: [mkBinding(wildcardClass, 'wildcard'), mkBinding(localClass, 'local')], + }, + }); + const ctx = makeCtx([mod], [localClass, wildcardClass]); + const results = buildClassRegistry(ctx).lookup('User', 'scope:m'); + expect(results).toHaveLength(2); + expect(results[0]!.def).toBe(localClass); // local (0.55) > wildcard (0.30) + expect(results[1]!.def).toBe(wildcardClass); + expect(results[0]!.confidence).toBeGreaterThan(results[1]!.confidence); + }); + it('breaks ties by DefId.localeCompare when all secondary keys are equal', () => { const a = mkDef({ nodeId: 'def:aaa', type: 'Class' }); const b = mkDef({ nodeId: 'def:bbb', type: 'Class' }); @@ -533,6 +557,59 @@ describe('Step 2: type-binding + MRO walk', () => { expect(typeBinding?.weight).toBe(EvidenceWeights.typeBindingByMroDepth[0]); }); + it('demotes Step-2-only candidates to tieBreakKey.origin=import (pins rank vs same-origin siblings)', () => { + // Two method defs named `impl`, both owned by the same interface and + // both reached ONLY via the Step 2 type-binding MRO walk (no lexical + // binding). Each candidate's `recordTypeBindingHit` path demotes its + // `tieBreakKey.origin` from the `ensureCandidate` default `'local'` + // to `'import'`. With both at equal confidence (owner-match + type- + // binding at depth 0), the tie-break cascade must fall through + // scope-depth / MRO-depth / origin (all equal) to DefId.localeCompare. + // + // If the demotion regressed (e.g., tieBreakKey.origin left as `'local'`), + // both candidates would still share the same origin and this test would + // pass by coincidence — so the test ALSO asserts `signals.origin` is + // absent from the evidence list (no false where-found weight emitted), + // which is the strongest observable invariant the demotion guarantees. + const iface = mkDef({ + nodeId: 'def:Iface', + type: 'Interface', + qualifiedName: 'Iface', + }); + const implA = mkDef({ + nodeId: 'def:aaa.impl', + type: 'Method', + qualifiedName: 'Iface.impl', + ownerId: 'def:Iface', + }); + const implB = mkDef({ + nodeId: 'def:bbb.impl', + type: 'Method', + qualifiedName: 'Iface.impl', + ownerId: 'def:Iface', + }); + const scope = mkScope({ + id: 'scope:call', + parent: null, + typeBindings: { x: typeRef('Iface', 'scope:call') }, + }); + const ctx = makeCtx([scope], [iface, implA, implB]); + const results = buildMethodRegistry(ctx).lookup('impl', 'scope:call', { + explicitReceiver: { name: 'x' }, + }); + expect(results).toHaveLength(2); + // DefId.localeCompare: 'def:aaa.impl' < 'def:bbb.impl'. + expect(results[0]!.def).toBe(implA); + expect(results[1]!.def).toBe(implB); + // Demotion invariant: Step-2-only candidates have no `signals.origin`, + // so composeEvidence never emits a where-found signal for them. + for (const res of results) { + expect(evidenceOfKind(res, 'local')).toBeUndefined(); + expect(evidenceOfKind(res, 'import')).toBeUndefined(); + expect(evidenceOfKind(res, 'type-binding')).toBeDefined(); + } + }); + it('walks up the MRO when the method is declared on an ancestor', () => { const baseClass = mkDef({ nodeId: 'def:Base', type: 'Class', qualifiedName: 'Base' }); const derivedClass = mkDef({ nodeId: 'def:Derived', type: 'Class', qualifiedName: 'Derived' }); diff --git a/gitnexus/test/unit/scope-resolution/resolve-type-ref.test.ts b/gitnexus/test/unit/scope-resolution/resolve-type-ref.test.ts index 2e2c08263..3b7573d53 100644 --- a/gitnexus/test/unit/scope-resolution/resolve-type-ref.test.ts +++ b/gitnexus/test/unit/scope-resolution/resolve-type-ref.test.ts @@ -139,16 +139,16 @@ describe('resolveTypeRef', () => { expect(resolveTypeRef(typeRef('Account', 'scope:module'), ctx)).toBe(userClass); }); - it('resolves a namespace-origin binding (e.g., `import * as np`)', () => { + it('returns null for a namespace-origin binding whose def is not a type kind', () => { const numpyMod = mkDef({ nodeId: 'def:numpy-mod', type: 'Namespace' }); // A namespace binding must resolve to a type-kind def to satisfy strict - // mode. Here the binding is the namespace module itself — treat it as a - // shadowing non-type and expect null. + // mode. Here the binding is the namespace module itself — `Namespace` + // is intentionally NOT in `TYPE_KINDS` (see resolve-type-ref.ts), so + // the binding is treated as a shadowing non-type and we fail fast. const moduleScope = mkScope('scope:module', null, { np: [mkBinding(numpyMod, 'namespace')], }); const ctx = mkCtx([moduleScope], [numpyMod]); - // Namespace is NOT a type kind → strict returns null. expect(resolveTypeRef(typeRef('np', 'scope:module'), ctx)).toBeNull(); }); diff --git a/gitnexus/test/unit/shadow/diff.test.ts b/gitnexus/test/unit/shadow/diff.test.ts index 700f08b91..e9baa606f 100644 --- a/gitnexus/test/unit/shadow/diff.test.ts +++ b/gitnexus/test/unit/shadow/diff.test.ts @@ -160,7 +160,11 @@ describe('diffResolutions — metadata + ordering', () => { ]; const next = [ makeResolution('def:User.save', ['local']), - makeResolution('def:yet-another', ['wildcard']), + // The 2nd entry is here to verify index-0 isolation — the only kind + // requirement is that it be a valid `ResolutionEvidence.kind` so the + // fixture is type-correct. `'global-name'` is a real kind that + // `diffResolutions` never treats specially. + makeResolution('def:yet-another', ['global-name']), ]; const result = diffResolutions(callsite, legacy, next); expect(result.agreement).toBe('both-agree'); From c6a291de67813683be4302e83e89d9880b647f6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 19:28:51 +0100 Subject: [PATCH 32/46] =?UTF-8?q?feat(ingestion):=20ScopeExtractor=20drive?= =?UTF-8?q?r=20=E2=80=94=205-pass=20CaptureMatch=20=E2=86=92=20ParsedFile?= =?UTF-8?q?=20(#919,=20RFC=20#909=20Ring=202=20PKG)=20(#965)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ingestion): ScopeExtractor driver — 5-pass CaptureMatch → ParsedFile (#919, RFC #909 Ring 2 PKG) Kicks off Ring 2 PKG. Implements RFC §5.3 + §3.2 Phase 1: the central, source-agnostic driver that turns a language provider's `CaptureMatch[]` into a `ParsedFile` — the per-file artifact the finalize orchestrator (#921) feeds into the shared `finalize()` algorithm (#915). ## Files ### New shared contracts - `gitnexus-shared/src/scope-resolution/parsed-file.ts` Per-file extraction artifact: scopes, parsedImports, localDefs, referenceSites. Structural superset of `FinalizeFile` so the finalize orchestrator threads `ParsedFile` through unchanged. - `gitnexus-shared/src/scope-resolution/reference-site.ts` Pre-resolution usage fact: name, atRange, inScope, kind, optional callForm/explicitReceiver/arity. Converted to `Reference` records by the resolution phase (populates `ReferenceIndex`). ### Ring 1 collateral tweak - `language-provider.ts: emitScopeCaptures` now returns `Promise` (was `readonly Capture[]`). Pre-grouping per tree-sitter match is the provider's job — the extractor expects coherent matches, not flat captures. No consumers yet (all languages still on legacy DAG), so no breakage. Docstring updated. ### New CLI module - `gitnexus/src/core/ingestion/scope-extractor.ts` Single entry point: `extract(matches, filePath, provider): ParsedFile`. Five-pass pipeline: Pass 1 — Build scope tree. `@scope.*` → `ScopeDraft[]` via range-containment parent derivation. Honors `provider.shouldCreateScope` (skip-but-reparent-children) and `provider.resolveScopeKind`. Throws `ScopeTreeInvariantError` via `buildScopeTree` on malformed input. Pass 2 — Attach declarations + local bindings. `@declaration.*` → `SymbolDefinition` + `BindingRef { origin: 'local' }`. Default attachment: innermost containing scope. Hoisting via `provider.bindingScopeFor`. Pass 3 — Collect raw imports. `@import.*` → `ParsedImport` via `provider.interpretImport`. Attached to ParsedFile (finalize resolves owning scope in Phase 2). Pass 4 — Collect type bindings. `@type-binding.*` → `TypeRef` via `provider.interpretTypeBinding` → `scope.typeBindings`. Hoistable via `bindingScopeFor`. Pass 5 — Collect reference sites. `@reference.*` → `ReferenceSite[]`. Call form from declarative sub-tag (`@reference.call.member`) or `provider.classifyCallForm`. ### Tests - `gitnexus/test/unit/scope-resolution/scope-extractor.test.ts` 23 tests organized by pass + one end-to-end fixture exercising all 5 passes together. MockProvider emits synthetic `CaptureMatch[]` with no AST — extractor is pure given those. ## Design notes - **Source-agnostic.** No `Tree` / `SyntaxNode` types leak into the driver. Works for tree-sitter providers and COBOL's regex tagger. - **One AST walk per language.** Providers do the walk inside `emitScopeCaptures`; this driver does zero traversal. - **Invariants delegated.** `ScopeTree.buildScopeTree` enforces structural rules (non-Module has parent, parent contains child, siblings don't overlap). The extractor doesn't try to repair malformed captures. - **Sub-tag whitelist.** `@reference.receiver`, `@declaration.name`, `@import.source`, etc. are known sub-tags — excluded from anchor selection so the broadest-range heuristic doesn't mis-identify them as anchors for their topic. Bug surfaced in the end-to-end fixture test (member call with a large-range receiver) and was fixed before commit. ## Verification - `tsc --noEmit` clean (both `gitnexus-shared` and `gitnexus`) - `gitnexus-shared` build clean - 23/23 new tests pass - Full scope-resolution / model / shadow suite: **285/285 pass** ## Closes part of #909. Unblocks - #920 parse-worker integration (emit ParsedFile from the worker) - #921 finalize orchestrator (consume ParsedFile[] workspace-wide) - #922 per-language import adapters * chore(ingestion): address #919 review findings on the extractor Addresses all 5 items from the PR #965 review in-PR. ## Structural changes - **Extract `ScopeExtractorHooks` as the narrow dependency surface.** The extractor now declares its dependency on a `Pick`-narrowed subset of `LanguageProvider` (just the 6 scope-resolution hooks it actually reads). Test mocks implement exactly that interface — no more `as unknown as LanguageProvider` cast hiding missing-field bugs. Adding a new hook read becomes a compile error, not a silent test pass. (Finding 3.2) - **Remove dead `ownerDefIdFor` stub + `isOwnerKind` helper.** The function always returned `undefined` with `void innermost; void drafts;` suppressors — an incomplete-implementation signal. The code path was also misleading: creating a clone of the def with `ownerId: undefined` is structurally identical to keeping the original. Pass 2 now keeps the def as-is. Contract is documented in a code comment: providers that need `ownerId` set it from their declaration hook; `finalize` (via #914 `MethodDispatchIndex`) fills in method/field `ownerId` in a post-extraction pass that has full def visibility. (Finding 2.1) - **Standardize `filePath` threading across passes 4 and 5.** Pass 4 was reading `drafts[0]!.filePath`; pass 5 was reading `anyFilePathFromScopeTree(scopeTree)`. Both equivalent but inconsistent. Both now take `filePath` as a parameter from the top-level `extract()` call. The `anyFilePathFromScopeTree` helper is removed. (Finding 2.2) ## Documentation - **Snapshot-semantics comment on `scopeTree` + `positionIndex`.** The hooks called during Passes 2-5 receive a `scopeTree` built BEFORE any bindings/ownedDefs/typeBindings were written. Hooks MUST NOT rely on `scope.bindings` etc. being populated — they're for parent/range/kind queries only. Added a doc block at the `scopeTree`/`positionIndex` construction site so future Ring 3 implementers don't write a `classifyCallForm` that reads bindings. (Finding 2.3) ## Tests - **Regression for the anchor-vs-receiver bug** (Finding 3.1): a member-call match where `@reference.receiver` spans columns 0-10 (wider) and the call name spans 11-15 (narrower). Without the `KNOWN_SUB_TAGS` exclusion, the broadest-range heuristic would have picked the receiver; the test pins that the call name is the one that ends up in `referenceSites[0].name`. - **Mock provider now types exactly `ScopeExtractorHooks`**, no more double-cast. Any future hook added to `extract()` that isn't in `ScopeExtractorHooks` is a compile error. ## Verification - `tsc --noEmit` clean in both `gitnexus-shared` and `gitnexus` - `gitnexus-shared` build clean - 24/24 scope-extractor tests pass (+1 regression) - Full scope-resolution / model / shadow suite: **286/286 pass** --- gitnexus-shared/src/index.ts | 4 + .../src/scope-resolution/parsed-file.ts | 65 ++ .../src/scope-resolution/reference-site.ts | 74 ++ .../src/core/ingestion/language-provider.ts | 26 +- .../src/core/ingestion/scope-extractor.ts | 823 ++++++++++++++++++ .../scope-resolution/scope-extractor.test.ts | 558 ++++++++++++ 6 files changed, 1541 insertions(+), 9 deletions(-) create mode 100644 gitnexus-shared/src/scope-resolution/parsed-file.ts create mode 100644 gitnexus-shared/src/scope-resolution/reference-site.ts create mode 100644 gitnexus/src/core/ingestion/scope-extractor.ts create mode 100644 gitnexus/test/unit/scope-resolution/scope-extractor.test.ts diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index beeb53587..d4efe8a89 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -77,6 +77,10 @@ export type { QualifiedNameIndex } from './scope-resolution/qualified-name-index export { resolveTypeRef } from './scope-resolution/resolve-type-ref.js'; export type { ResolveTypeRefContext } from './scope-resolution/resolve-type-ref.js'; +// ScopeExtractor output contracts (RFC §3.2 Phase 1; Ring 2 PKG #919) +export type { ParsedFile } from './scope-resolution/parsed-file.js'; +export type { ReferenceSite, ReferenceKind, CallForm } from './scope-resolution/reference-site.js'; + // Method-dispatch materialized view over HeritageMap (RFC §3.1; Ring 2 SHARED #914) export { buildMethodDispatchIndex } from './scope-resolution/method-dispatch-index.js'; export type { diff --git a/gitnexus-shared/src/scope-resolution/parsed-file.ts b/gitnexus-shared/src/scope-resolution/parsed-file.ts new file mode 100644 index 000000000..ddd8afccd --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/parsed-file.ts @@ -0,0 +1,65 @@ +/** + * `ParsedFile` — the per-file artifact produced by `ScopeExtractor` + * (RFC §3.2 Phase 1; Ring 2 PKG #919). + * + * The boundary between Phase 1 (extraction, per-file, parallelizable) and + * Phase 2 (finalize, cross-file). One `ParsedFile` is emitted per source + * file; the finalize orchestrator (#921) collects them into a workspace- + * wide set and feeds them to the shared `finalize` algorithm (#915). + * + * ## Shape + * + * - `scopes` — every `Scope` created for this file, in tree- + * topological order (module first, then children). + * `Scope.bindings` carry **local-only** bindings at + * this stage; finalize merges imports/wildcards on top. + * - `parsedImports` — raw `ParsedImport[]` for this file; finalize + * resolves each to a concrete `ImportEdge`. + * - `localDefs` — defs structurally declared in this file. A + * superset of every `Scope.ownedDefs` union. + * Listed separately so `finalize` can dedup-index + * without re-walking scopes. + * - `referenceSites` — pre-resolution usage facts; populated by the + * resolution phase into `ReferenceIndex`. + * + * ## What `ParsedFile` deliberately does NOT carry + * + * - Linked `ImportEdge`s. Those are finalize output. + * - A `ScopeTree` instance. Callers build one from `scopes` (cheap — + * `buildScopeTree(parsedFile.scopes)`). Keeping the ParsedFile flat + * makes IPC serialization from worker threads straightforward. + * - Merged module-scope bindings. Finalize owns that materialization. + * + * ## Compatibility with `FinalizeFile` + * + * `FinalizeFile` (defined in `./finalize-algorithm.ts`) is a structural + * subset of `ParsedFile` — `filePath`, `moduleScope`, `parsedImports`, + * `localDefs`. A `ParsedFile` is trivially convertible to a `FinalizeFile` + * by picking those four fields, so the finalize orchestrator threads + * ParsedFile through to the shared algorithm without shape-shifting. + */ + +import type { Scope, ScopeId } from './types.js'; +import type { ParsedImport } from './types.js'; +import type { SymbolDefinition } from './symbol-definition.js'; +import type { ReferenceSite } from './reference-site.js'; + +export interface ParsedFile { + readonly filePath: string; + /** `Scope.id` of the file's root `Module` scope. */ + readonly moduleScope: ScopeId; + /** + * All scopes in this file, typically emitted in tree-topological order. + * Caller reconstructs a `ScopeTree` via `buildScopeTree(scopes)` when + * navigation or invariant re-validation is needed. + */ + readonly scopes: readonly Scope[]; + readonly parsedImports: readonly ParsedImport[]; + /** + * All defs structurally declared in this file (classes, methods, fields, + * variables). Mirrors the union of `Scope.ownedDefs` across `scopes`, + * pre-flattened for O(N) consumption by finalize. + */ + readonly localDefs: readonly SymbolDefinition[]; + readonly referenceSites: readonly ReferenceSite[]; +} diff --git a/gitnexus-shared/src/scope-resolution/reference-site.ts b/gitnexus-shared/src/scope-resolution/reference-site.ts new file mode 100644 index 000000000..c9abdef7e --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/reference-site.ts @@ -0,0 +1,74 @@ +/** + * `ReferenceSite` — a pre-resolution usage fact collected by `ScopeExtractor` + * (RFC §3.2 Phase 1; Ring 2 PKG #919). + * + * One record per `@reference.*` capture. The extractor records: + * - the name being referenced (method/field/class name), + * - the source range, + * - the innermost lexical scope containing the reference, + * - the reference kind (call, read, write, inherits, etc.), + * - optional call-form classification from `provider.classifyCallForm`, + * - optional explicit-receiver hint for dotted calls (`user.save()`), + * - optional arity for call sites. + * + * Reference sites are consumed by the resolution phase (RFC §3.2 Phase 4) + * which routes each through `Registry.lookup` / `resolveTypeRef` and + * emits the final `Reference` record into `ReferenceIndex`. + * + * **Pre-resolution only.** `ReferenceSite` intentionally carries no + * `toDef`, `confidence`, or `evidence`. Those are populated by the + * resolution step that reads this record and produces a `Reference` + * (defined in `./types.ts`). + */ + +import type { Range, ScopeId } from './types.js'; + +/** + * What kind of usage this reference represents — the graph-edge kind + * emitted after resolution (`CALLS`, `READS`, `WRITES`, etc.). + * + * Matches the `kind` field on `Reference` in `./types.ts` so the + * resolution phase can pass it through without re-classification. + */ +export type ReferenceKind = + | 'call' + | 'read' + | 'write' + | 'type-reference' + | 'inherits' + | 'import-use'; + +/** + * How a call site binds its target. Informs `Registry.lookup` Step 2 + * (type-binding path): + * - `'free'` — bare call (no receiver); resolution via lexical chain. + * - `'member'` — dotted call (`x.foo()`); resolution via receiver type. + * - `'constructor'` — `new Foo()`; receiver is the class itself. + * - `'index'` — index expression (`arr[0]`); rare as a dispatch site. + * + * Only meaningful for `kind === 'call'`; ignored for reads/writes. + */ +export type CallForm = 'free' | 'member' | 'constructor' | 'index'; + +export interface ReferenceSite { + /** The name being referenced (e.g., `'save'`, `'User'`, `'count'`). */ + readonly name: string; + /** Source-text range of this reference. */ + readonly atRange: Range; + /** + * Innermost lexical scope that contains `atRange`. Resolved by the + * extractor via position lookup and frozen here so the resolution + * phase doesn't re-compute it per call. + */ + readonly inScope: ScopeId; + readonly kind: ReferenceKind; + /** Set when `kind === 'call'`. */ + readonly callForm?: CallForm; + /** + * Explicit receiver for dotted calls (`user.save()` → `{ name: 'user' }`). + * Passed through to `Registry.lookup.explicitReceiver`. + */ + readonly explicitReceiver?: { readonly name: string }; + /** Argument count at the call site; used by `provider.arityCompatibility`. */ + readonly arity?: number; +} diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 2777c991f..770d15aee 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -12,7 +12,6 @@ import type { SupportedLanguages, MroStrategy, - Capture, CaptureMatch, BindingRef, TypeRef, @@ -303,22 +302,31 @@ interface LanguageProviderConfig { // ── Parse phase (per-capture interpretation) ─────────────────────── /** - * Emit scope captures from raw source. Tree-sitter-based providers run a - * `scopes.scm` query; standalone providers (COBOL) emit captures from a - * regex tagger. The return shape is parser-agnostic: the central - * `ScopeExtractor` consumes `Capture[]` without knowing which parser - * produced them. + * Emit scope captures from raw source, **pre-grouped per tree-sitter + * query match**. Tree-sitter-based providers run a `scopes.scm` query + * and emit one `CaptureMatch` per query match; standalone providers + * (COBOL) emit matches from a regex tagger. The return shape is + * parser-agnostic: the central `ScopeExtractor` consumes + * `CaptureMatch[]` without knowing which parser produced them. + * + * **Pre-grouping is the provider's job.** The extractor expects each + * `CaptureMatch` to correspond to one logical match — e.g., an import + * statement match carries `@import.statement` + `@import.source` + + * `@import.name` keyed under their capture names. Providers MUST + * preserve the tree-sitter match boundaries so the extractor's topic + * routing (scope / declaration / import / type-binding / reference) + * lands on coherent records. * * Required for any provider participating in scope-based resolution. - * Providers that have not yet migrated continue to run through the legacy - * DAG path (feature-flagged per `REGISTRY_PRIMARY_`). + * Providers that have not yet migrated continue to run through the + * legacy DAG path (feature-flagged per `REGISTRY_PRIMARY_`). * * Default: undefined (language continues to use legacy DAG). */ readonly emitScopeCaptures?: ( sourceText: string, filePath: string, - ) => Promise; + ) => Promise; /** * Interpret a raw `@import.statement` capture group into a `ParsedImport`. diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts new file mode 100644 index 000000000..4ab0a6257 --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -0,0 +1,823 @@ +/** + * `ScopeExtractor` — the central, source-agnostic driver that turns a + * language provider's `CaptureMatch[]` into a `ParsedFile` + * (RFC §5.3 + §3.2 Phase 1; Ring 2 PKG #919). + * + * Exactly one entry point: `extract(matches, filePath, provider) → ParsedFile`. + * Runs a five-pass pipeline over the matches. Each pass is internal; the + * public contract is the output `ParsedFile`. + * + * ## Design principles + * + * - **Source-agnostic.** Consumes `CaptureMatch[]` from providers; + * doesn't know whether they came from tree-sitter queries or COBOL's + * regex tagger. No `Tree` / `SyntaxNode` types leak into this file. + * - **One AST walk per language.** Providers do the AST walk inside + * their `emitScopeCaptures` hook; this driver does zero further + * traversal — it consumes captures only. + * - **Pure-ish.** The extractor itself is pure (same matches → + * same ParsedFile) when providers are pure. No side effects, no I/O. + * - **Centralized invariant enforcement.** Structural invariants on the + * scope tree (non-module has parent; parent contains child; siblings + * don't overlap) are enforced by `buildScopeTree` from Ring 2 SHARED + * (#912). Malformed inputs throw `ScopeTreeInvariantError`. + * + * ## The five passes + * + * 1. **Build scope tree.** Walk `@scope.*` matches. For each, consult + * `provider.shouldCreateScope` (default true) and + * `provider.resolveScopeKind` (default: suffix of the capture name). + * Derive parent by lexical-range containment. Hand the resulting + * `Scope[]` to `buildScopeTree` for validation. + * 2. **Attach declarations + local bindings.** Walk `@declaration.*` + * matches. For each, build a `SymbolDefinition` and attach it to + * `provider.bindingScopeFor` (default: innermost containing scope) + * as `ownedDefs` + a local `BindingRef { origin: 'local' }`. + * 3. **Collect raw imports.** Walk `@import.*` matches. Call + * `provider.interpretImport` per match; attach the returned + * `ParsedImport` to the ParsedFile (not to any `Scope` — finalize + * reconstructs the owning scope via `provider.importOwningScope` + * during Phase 2). + * 4. **Collect type bindings.** Walk `@type-binding.*` matches. Call + * `provider.interpretTypeBinding` per match. Attach the resulting + * `TypeRef` to the innermost containing scope's `typeBindings` + * (or override via `provider.bindingScopeFor` if set). + * 5. **Collect reference sites.** Walk `@reference.*` matches. Emit + * one `ReferenceSite` per match. Classify call form via + * `provider.classifyCallForm` (default: the capture's sub-tag if + * present; else `'free'`). + * + * ## What gets attached where + * + * - `Scope.bindings` — **local bindings only** at this stage (Pass 2). + * Finalize (#915) merges imports/wildcards on top. + * - `Scope.ownedDefs` — declarations structurally owned by this scope. + * - `Scope.typeBindings` — local type facts (parameter annotations, `self`). + * - `Scope.imports` — empty here. Populated by the finalize algorithm + * when it resolves `ParsedImport.targetRaw`. + * - `ParsedFile.parsedImports` — every raw import in this file. + * - `ParsedFile.localDefs` — flattened union of `Scope.ownedDefs`. + * - `ParsedFile.referenceSites` — pre-resolution usage facts. + */ + +import type { + BindingRef, + CaptureMatch, + ImportEdge, + ParsedFile, + ParsedImport, + ReferenceSite, + ReferenceKind, + Range, + Scope, + ScopeId, + ScopeKind, + SymbolDefinition, + TypeRef, +} from 'gitnexus-shared'; +import { buildPositionIndex, buildScopeTree, makeScopeId } from 'gitnexus-shared'; +import type { LanguageProvider } from './language-provider.js'; + +// ─── Narrow hook surface the extractor actually uses ─────────────────────── + +/** + * The subset of `LanguageProvider` hooks that `extract()` reads. Declared + * as its own type so: + * + * - Tests can implement just these six hooks without faking the whole + * `LanguageProvider` interface (which is ~40 fields including the + * legacy-DAG surface). + * - The extractor's dependency contract stays explicit — adding a new + * hook read requires updating this type. + * + * Real callers pass a full `LanguageProvider` — structural typing makes it + * a `ScopeExtractorHooks` for free. + */ +export type ScopeExtractorHooks = Pick< + LanguageProvider, + | 'shouldCreateScope' + | 'resolveScopeKind' + | 'bindingScopeFor' + | 'interpretImport' + | 'interpretTypeBinding' + | 'classifyCallForm' +>; + +// ─── Public entry point ───────────────────────────────────────────────────── + +/** + * Drive the five extraction passes and return a `ParsedFile`. + * + * Throws `ScopeTreeInvariantError` (from #912) when the provider emits + * captures that violate structural scope invariants. The error surfaces + * upward rather than being silently corrected — a malformed capture set + * is a bug in the provider's `emitScopeCaptures`, not a data condition + * to tolerate. + */ +export function extract( + matches: readonly CaptureMatch[], + filePath: string, + provider: ScopeExtractorHooks, +): ParsedFile { + // Partition matches by topic up front — one linear pass over the input. + const partitioned = partitionByTopic(matches); + + // ── Pass 1: build the scope tree ───────────────────────────────────── + const scopeDrafts = pass1BuildScopes(partitioned.scope, filePath, provider); + const scopes = scopeDrafts.map(draftToScope); + // buildScopeTree validates invariants (throws on violation) and exposes + // the lookup contract consumed by Passes 2-5. + // + // **Snapshot semantics.** Both `scopeTree` and `positionIndex` are built + // from the post-Pass-1 `scopes` — parent/range/kind are accurate, but + // `bindings`, `ownedDefs`, and `typeBindings` are all empty here. Later + // passes write into the *drafts*, not into these snapshots; any hook + // that reads `scope.bindings` etc. via the `scopeTree` argument sees a + // structural view only. This is by design — hooks use scopeTree for + // "what's the parent chain?" queries, not for content queries. + const scopeTree = buildScopeTree(scopes); + const positionIndex = buildPositionIndex(scopes); + + const moduleScope = scopeDrafts.find((s) => s.kind === 'Module'); + if (moduleScope === undefined) { + throw new Error( + `ScopeExtractor: no Module scope found for '${filePath}'. ` + + `Provider must emit at least one @scope.module capture per file.`, + ); + } + + // ── Pass 2: attach declarations + local bindings ──────────────────── + const localDefs: SymbolDefinition[] = []; + pass2AttachDeclarations( + partitioned.declaration, + scopeDrafts, + positionIndex, + localDefs, + filePath, + provider, + scopeTree, + ); + + // ── Pass 3: collect raw imports ───────────────────────────────────── + const parsedImports: ParsedImport[] = []; + pass3CollectImports(partitioned.import_, parsedImports, provider); + + // ── Pass 4: collect type bindings ─────────────────────────────────── + pass4CollectTypeBindings( + partitioned.typeBinding, + scopeDrafts, + positionIndex, + filePath, + provider, + scopeTree, + ); + + // ── Pass 5: collect reference sites ───────────────────────────────── + const referenceSites: ReferenceSite[] = []; + pass5CollectReferences( + partitioned.reference, + positionIndex, + filePath, + referenceSites, + provider, + scopeTree, + ); + + // Freeze Scope drafts into final shape and return. + const frozenScopes = scopeDrafts.map(draftToScope); + return Object.freeze({ + filePath, + moduleScope: moduleScope.id, + scopes: Object.freeze(frozenScopes), + parsedImports: Object.freeze(parsedImports.slice()), + localDefs: Object.freeze(localDefs.slice()), + referenceSites: Object.freeze(referenceSites.slice()), + }); +} + +// ─── Internal: partitioning by topic ─────────────────────────────────────── + +interface Partitioned { + readonly scope: readonly CaptureMatch[]; + readonly declaration: readonly CaptureMatch[]; + readonly import_: readonly CaptureMatch[]; + readonly typeBinding: readonly CaptureMatch[]; + readonly reference: readonly CaptureMatch[]; +} + +/** + * Bucket each match by the topic of its anchor capture. The anchor is the + * capture whose name is prefixed with the match's topic (`@scope.*`, + * `@declaration.*`, `@import.*`, `@type-binding.*`, `@reference.*`). + * + * A match may contain additional captures (e.g., `@import.source`, + * `@declaration.class.name`) that are used by the provider hooks to + * decode details. Those live inside the `CaptureMatch` and are surfaced + * to hooks verbatim — the extractor itself only routes by anchor. + */ +function partitionByTopic(matches: readonly CaptureMatch[]): Partitioned { + const scope: CaptureMatch[] = []; + const declaration: CaptureMatch[] = []; + const import_: CaptureMatch[] = []; + const typeBinding: CaptureMatch[] = []; + const reference: CaptureMatch[] = []; + + for (const match of matches) { + const topic = topicOf(match); + switch (topic) { + case 'scope': + scope.push(match); + break; + case 'declaration': + declaration.push(match); + break; + case 'import': + import_.push(match); + break; + case 'type-binding': + typeBinding.push(match); + break; + case 'reference': + reference.push(match); + break; + case 'unknown': + // Unrecognized anchor — silently skip. Providers may emit extra + // captures (e.g., `@comment`) that the extractor has no topic for. + break; + } + } + + return { scope, declaration, import_, typeBinding, reference }; +} + +type Topic = 'scope' | 'declaration' | 'import' | 'type-binding' | 'reference' | 'unknown'; + +function topicOf(match: CaptureMatch): Topic { + // The anchor is the capture whose name uses one of the known topic + // prefixes. For multi-capture matches, ALL captures share the topic; + // we pick the first matching key for efficiency. + for (const name of Object.keys(match)) { + if (name.startsWith('@scope.')) return 'scope'; + if (name.startsWith('@declaration.')) return 'declaration'; + if (name.startsWith('@import.')) return 'import'; + if (name.startsWith('@type-binding.')) return 'type-binding'; + if (name.startsWith('@reference.')) return 'reference'; + } + return 'unknown'; +} + +// ─── Internal: Scope draft model ─────────────────────────────────────────── + +/** + * Mutable Scope record used during extraction. The final `Scope` (readonly, + * returned in `ParsedFile.scopes`) is produced by `draftToScope` at the end + * of each pass's writes. + */ +interface ScopeDraft { + readonly id: ScopeId; + readonly parent: ScopeId | null; + readonly kind: ScopeKind; + readonly range: Range; + readonly filePath: string; + readonly bindings: Map; + readonly ownedDefs: SymbolDefinition[]; + readonly imports: ImportEdge[]; + readonly typeBindings: Map; +} + +function draftToScope(draft: ScopeDraft): Scope { + const frozenBindings = new Map(); + for (const [name, refs] of draft.bindings) { + frozenBindings.set(name, Object.freeze(refs.slice())); + } + return { + id: draft.id, + parent: draft.parent, + kind: draft.kind, + range: draft.range, + filePath: draft.filePath, + bindings: frozenBindings, + ownedDefs: Object.freeze(draft.ownedDefs.slice()), + imports: Object.freeze(draft.imports.slice()), + typeBindings: new Map(draft.typeBindings), + }; +} + +// ─── Pass 1: build scope tree ────────────────────────────────────────────── + +/** + * Convert `@scope.*` matches into `ScopeDraft[]`. Parent relationships + * are derived from range containment (outermost scope containing `range` + * becomes the parent). Scopes with `shouldCreateScope === false` are + * silently omitted — their children reparent to the next enclosing + * real scope. + */ +function pass1BuildScopes( + matches: readonly CaptureMatch[], + filePath: string, + provider: ScopeExtractorHooks, +): ScopeDraft[] { + interface Candidate { + readonly match: CaptureMatch; + readonly range: Range; + readonly kind: ScopeKind; + readonly create: boolean; + readonly id: ScopeId; + } + + const candidates: Candidate[] = []; + for (const match of matches) { + const anchor = anchorCaptureFor(match, '@scope.'); + if (anchor === undefined) continue; + const kind = resolveKindForScopeMatch(match, anchor, provider); + if (kind === null) continue; + const create = provider.shouldCreateScope?.(match) ?? true; + const id = makeScopeId({ filePath, range: anchor.range, kind }); + candidates.push({ match, range: anchor.range, kind, create, id }); + } + + // Sort by (startLine, startCol) ASC, (endLine, endCol) DESC so outer + // scopes appear before their children for parent-resolution. + candidates.sort((a, b) => { + if (a.range.startLine !== b.range.startLine) return a.range.startLine - b.range.startLine; + if (a.range.startCol !== b.range.startCol) return a.range.startCol - b.range.startCol; + if (a.range.endLine !== b.range.endLine) return b.range.endLine - a.range.endLine; + return b.range.endCol - a.range.endCol; + }); + + const drafts: ScopeDraft[] = []; + const stack: Candidate[] = []; // enclosing real scopes, outermost at [0] + + for (const cand of candidates) { + // Pop the stack until the top strictly contains this candidate. + while (stack.length > 0 && !rangeStrictlyContains(stack[stack.length - 1]!.range, cand.range)) { + stack.pop(); + } + + if (cand.create) { + const parent = stack.length > 0 ? stack[stack.length - 1]!.id : null; + drafts.push(makeDraft(cand.id, parent, cand.kind, cand.range, filePath)); + stack.push(cand); + } + // If `cand.create === false`, we don't push it onto the stack — child + // scopes will reparent to whatever's below it. + } + + return drafts; +} + +function resolveKindForScopeMatch( + match: CaptureMatch, + anchor: { readonly name: string }, + provider: ScopeExtractorHooks, +): ScopeKind | null { + // Provider override takes precedence. + const override = provider.resolveScopeKind?.(match); + if (override !== undefined && override !== null) return override; + + // Default: derive from capture name suffix (`@scope.function` → 'Function'). + const suffix = anchor.name.slice('@scope.'.length); + switch (suffix.toLowerCase()) { + case 'module': + return 'Module'; + case 'namespace': + return 'Namespace'; + case 'class': + return 'Class'; + case 'function': + return 'Function'; + case 'block': + return 'Block'; + case 'expression': + return 'Expression'; + default: + return null; + } +} + +function makeDraft( + id: ScopeId, + parent: ScopeId | null, + kind: ScopeKind, + range: Range, + filePath: string, +): ScopeDraft { + return { + id, + parent, + kind, + range, + filePath, + bindings: new Map(), + ownedDefs: [], + imports: [], + typeBindings: new Map(), + }; +} + +// ─── Pass 2: attach declarations + local bindings ────────────────────────── + +function pass2AttachDeclarations( + matches: readonly CaptureMatch[], + drafts: readonly ScopeDraft[], + positionIndex: ReturnType, + localDefs: SymbolDefinition[], + filePath: string, + provider: ScopeExtractorHooks, + scopeTree: ReturnType, +): void { + const draftById = new Map(); + for (const d of drafts) draftById.set(d.id, d); + + for (const match of matches) { + const anchor = anchorCaptureFor(match, '@declaration.'); + if (anchor === undefined) continue; + + const def = buildDefFromDeclarationMatch(match, anchor, filePath); + if (def === undefined) continue; + + // Find the innermost scope that contains the declaration's anchor range. + const innermostId = positionIndex.atPosition( + filePath, + anchor.range.startLine, + anchor.range.startCol, + ); + if (innermostId === undefined) continue; + const innermost = draftById.get(innermostId); + if (innermost === undefined) continue; + + // Ownership: attach the def to the innermost scope's `ownedDefs` — that + // is the structural owner. `def.ownerId` is NOT populated here — the + // extractor has no clean path to the parent's own DefId mid-extraction + // (the parent declaration may not yet have been processed, or may live + // in a different scope entirely). Providers that need `ownerId` should + // set it directly from the declaration hook (e.g., derive from the + // `@declaration.owner` capture or the parent scope id); otherwise + // `finalize` populates method/field `ownerId` via `MethodDispatchIndex` + // (#914) in a follow-up pass that sees every def already in place. + innermost.ownedDefs.push(def); + localDefs.push(def); + + // Binding visibility: default to innermost; allow hoisting via + // `provider.bindingScopeFor`. `draftToScope(innermost)` here is a + // **structural** snapshot — parent/range/kind only. Hooks MUST NOT + // rely on `scope.bindings`, `ownedDefs`, or `typeBindings` being + // populated during Pass 2: those fields are written across passes, + // so reading them mid-extraction yields a partial view. The + // `scopeTree` argument is similarly snapshot-before-mutation. + const bindingScopeId = + provider.bindingScopeFor?.(match, draftToScope(innermost), scopeTree) ?? innermost.id; + const bindingHost = draftById.get(bindingScopeId) ?? innermost; + + const nameKey = deriveDeclarationName(match, def); + if (nameKey === undefined) continue; + + const existing = bindingHost.bindings.get(nameKey) ?? []; + existing.push({ def, origin: 'local' }); + bindingHost.bindings.set(nameKey, existing); + } +} + +function buildDefFromDeclarationMatch( + match: CaptureMatch, + anchor: { readonly name: string; readonly range: Range; readonly text: string }, + filePath: string, +): SymbolDefinition | undefined { + // Anchor name pattern: `@declaration.` where maps to NodeLabel. + const kindStr = anchor.name.slice('@declaration.'.length); + const type = normalizeNodeLabel(kindStr); + if (type === undefined) return undefined; + + const nameCap = + match['@declaration.name'] ?? match[`@declaration.${kindStr}.name`] ?? match[anchor.name]; + if (nameCap === undefined) return undefined; + + const qualifiedCap = match['@declaration.qualified_name']; + const qualifiedName = qualifiedCap?.text; + + return { + nodeId: makeDefId(filePath, anchor.range, type, nameCap.text), + filePath, + type, + ...(qualifiedName !== undefined ? { qualifiedName } : { qualifiedName: nameCap.text }), + }; +} + +function deriveDeclarationName(match: CaptureMatch, def: SymbolDefinition): string | undefined { + const nameCap = + match['@declaration.name'] ?? + match[ + Object.keys(match).find((k) => k.startsWith('@declaration.') && k.endsWith('.name')) ?? '' + ]; + if (nameCap !== undefined) return nameCap.text; + // Fall back to qualifiedName tail. + const q = def.qualifiedName; + if (q !== undefined && q.length > 0) { + const dot = q.lastIndexOf('.'); + return dot === -1 ? q : q.slice(dot + 1); + } + return undefined; +} + +/** + * Map a lower-case declaration kind (from `@declaration.`) to a + * graph `NodeLabel`. Silently returns `undefined` for kinds we don't + * recognize — providers can emit richer captures without breaking the + * driver. + */ +function normalizeNodeLabel(kindStr: string): SymbolDefinition['type'] | undefined { + switch (kindStr.toLowerCase()) { + case 'class': + return 'Class'; + case 'interface': + return 'Interface'; + case 'enum': + return 'Enum'; + case 'struct': + return 'Struct'; + case 'union': + return 'Union'; + case 'trait': + return 'Trait'; + case 'method': + return 'Method'; + case 'function': + return 'Function'; + case 'constructor': + return 'Constructor'; + case 'field': + case 'property': + return 'Property'; + case 'variable': + case 'const': + return 'Variable'; + case 'typealias': + case 'type_alias': + return 'TypeAlias'; + case 'typedef': + return 'Typedef'; + case 'record': + return 'Record'; + case 'delegate': + return 'Delegate'; + case 'annotation': + return 'Annotation'; + case 'namespace': + return 'Namespace'; + default: + return undefined; + } +} + +function makeDefId( + filePath: string, + range: Range, + type: SymbolDefinition['type'], + name: string, +): string { + return `def:${filePath}#${range.startLine}:${range.startCol}:${type}:${name}`; +} + +// ─── Pass 3: collect raw imports ─────────────────────────────────────────── + +function pass3CollectImports( + matches: readonly CaptureMatch[], + parsedImports: ParsedImport[], + provider: ScopeExtractorHooks, +): void { + if (provider.interpretImport === undefined) return; + for (const match of matches) { + const anchor = anchorCaptureFor(match, '@import.'); + if (anchor === undefined) continue; + const parsed = provider.interpretImport(match); + if (parsed === null) continue; + parsedImports.push(parsed); + } +} + +// ─── Pass 4: collect type bindings ───────────────────────────────────────── + +function pass4CollectTypeBindings( + matches: readonly CaptureMatch[], + drafts: readonly ScopeDraft[], + positionIndex: ReturnType, + filePath: string, + provider: ScopeExtractorHooks, + scopeTree: ReturnType, +): void { + const draftById = new Map(); + for (const d of drafts) draftById.set(d.id, d); + + for (const match of matches) { + const anchor = anchorCaptureFor(match, '@type-binding.'); + if (anchor === undefined) continue; + + const parsed = provider.interpretTypeBinding?.(match); + if (parsed === null || parsed === undefined) continue; + + const innermostId = positionIndex.atPosition( + filePath, + anchor.range.startLine, + anchor.range.startCol, + ); + if (innermostId === undefined) continue; + const innermost = draftById.get(innermostId); + if (innermost === undefined) continue; + + // `bindingScopeFor` may hoist the type binding to an outer scope. + const hostId = + provider.bindingScopeFor?.(match, draftToScope(innermost), scopeTree) ?? innermost.id; + const host = draftById.get(hostId) ?? innermost; + + const typeRef: TypeRef = { + rawName: parsed.rawTypeName, + declaredAtScope: host.id, + source: parsed.source, + }; + host.typeBindings.set(parsed.boundName, typeRef); + } +} + +// ─── Pass 5: collect reference sites ─────────────────────────────────────── + +function pass5CollectReferences( + matches: readonly CaptureMatch[], + positionIndex: ReturnType, + filePath: string, + referenceSites: ReferenceSite[], + provider: ScopeExtractorHooks, + scopeTree: ReturnType, +): void { + for (const match of matches) { + const anchor = anchorCaptureFor(match, '@reference.'); + if (anchor === undefined) continue; + + const kind = referenceKindFromAnchor(anchor.name); + if (kind === undefined) continue; + + const nameCap = match['@reference.name'] ?? anchor; + const inScopeId = positionIndex.atPosition( + filePath, + anchor.range.startLine, + anchor.range.startCol, + ); + if (inScopeId === undefined) continue; + + const callForm = + kind === 'call' + ? classifyCallFormForMatch(match, anchor.name, provider, scopeTree, inScopeId) + : undefined; + const explicitReceiver = extractExplicitReceiver(match); + const arity = extractArity(match); + + const site: ReferenceSite = { + name: nameCap.text, + atRange: anchor.range, + inScope: inScopeId, + kind, + ...(callForm !== undefined ? { callForm } : {}), + ...(explicitReceiver !== undefined ? { explicitReceiver } : {}), + ...(arity !== undefined ? { arity } : {}), + }; + referenceSites.push(site); + } +} + +function referenceKindFromAnchor(name: string): ReferenceKind | undefined { + const suffix = name.slice('@reference.'.length); + // Strip sub-tag after the kind (`@reference.call.member` → `call`). + const firstDot = suffix.indexOf('.'); + const head = firstDot === -1 ? suffix : suffix.slice(0, firstDot); + switch (head.toLowerCase()) { + case 'call': + return 'call'; + case 'read': + return 'read'; + case 'write': + return 'write'; + case 'type': + case 'type_reference': + return 'type-reference'; + case 'inherits': + return 'inherits'; + case 'import_use': + case 'import-use': + return 'import-use'; + default: + return undefined; + } +} + +function classifyCallFormForMatch( + match: CaptureMatch, + anchorName: string, + provider: ScopeExtractorHooks, + scopeTree: ReturnType, + inScopeId: ScopeId, +): 'free' | 'member' | 'constructor' | 'index' { + // Declarative sub-tag path first: `@reference.call.member` → 'member'. + const suffix = anchorName.slice('@reference.call.'.length); + switch (suffix.toLowerCase()) { + case 'free': + return 'free'; + case 'member': + return 'member'; + case 'constructor': + return 'constructor'; + case 'index': + return 'index'; + } + + // Hook-based path: provider knows. + const hook = provider.classifyCallForm; + if (hook !== undefined) { + const scope = scopeTree.getScope(inScopeId); + if (scope !== undefined) return hook(match, scope); + } + + return 'free'; +} + +function extractExplicitReceiver(match: CaptureMatch): { readonly name: string } | undefined { + const cap = match['@reference.receiver']; + if (cap === undefined) return undefined; + return { name: cap.text }; +} + +function extractArity(match: CaptureMatch): number | undefined { + const cap = match['@reference.arity']; + if (cap === undefined) return undefined; + const n = Number.parseInt(cap.text, 10); + return Number.isFinite(n) ? n : undefined; +} + +// ─── Internal: range + capture utilities ─────────────────────────────────── + +function rangeStrictlyContains(outer: Range, inner: Range): boolean { + if ( + outer.startLine === inner.startLine && + outer.startCol === inner.startCol && + outer.endLine === inner.endLine && + outer.endCol === inner.endCol + ) { + return false; + } + const startsBefore = + outer.startLine < inner.startLine || + (outer.startLine === inner.startLine && outer.startCol <= inner.startCol); + const endsAfter = + outer.endLine > inner.endLine || + (outer.endLine === inner.endLine && outer.endCol >= inner.endCol); + return startsBefore && endsAfter; +} + +/** + * Capture names that are never anchors — they are sub-tags nested inside a + * larger anchor (e.g., the receiver expression inside a `@reference.call` + * may span more source than the called name, but is not the call itself). + * + * The list is maintained here centrally rather than per-pass because the + * set is small and stable; adding a new sub-tag convention is a one-line + * change. + */ +const KNOWN_SUB_TAGS: ReadonlySet = new Set([ + '@declaration.name', + '@declaration.qualified_name', + '@import.name', + '@import.source', + '@import.alias', + '@type-binding.name', + '@type-binding.type', + '@reference.name', + '@reference.receiver', + '@reference.arity', +]); + +/** + * Return the anchor capture for a match — the one whose name begins with + * `prefix` AND is not in the known-sub-tag set. When multiple candidates + * remain, the broadest-ranged one wins: tree-sitter queries often tag + * both a whole statement and a sub-token under the same topic + * (`@scope.function` + `@scope.function.name`); the anchor is the + * statement-level one. + */ +function anchorCaptureFor( + match: CaptureMatch, + prefix: string, +): { readonly name: string; readonly range: Range; readonly text: string } | undefined { + let best: { readonly name: string; readonly range: Range; readonly text: string } | undefined; + let bestSpan = -1; + for (const name of Object.keys(match)) { + if (!name.startsWith(prefix)) continue; + if (KNOWN_SUB_TAGS.has(name)) continue; + const cap = match[name]!; + const span = + (cap.range.endLine - cap.range.startLine) * 1_000_000 + + (cap.range.endCol - cap.range.startCol); + if (span > bestSpan) { + bestSpan = span; + best = cap; + } + } + return best; +} diff --git a/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts b/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts new file mode 100644 index 000000000..0a03e95f7 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts @@ -0,0 +1,558 @@ +/** + * Unit tests for `scope-extractor.extract` — the 5-pass driver + * (RFC §5.3; Ring 2 PKG #919). + * + * Tests are organized by pass so a regression localizes to the pass it + * broke. A `MockProvider` emits synthetic `CaptureMatch[]` with no real + * AST; the extractor is pure given those captures. + */ + +import { describe, it, expect } from 'vitest'; +import type { + Capture, + CaptureMatch, + ParsedImport, + ParsedTypeBinding, + ReferenceKind, + Scope, + ScopeKind, +} from 'gitnexus-shared'; +import { extract, type ScopeExtractorHooks } from '../../../src/core/ingestion/scope-extractor.js'; + +// ─── Synthetic-capture helpers ────────────────────────────────────────────── + +const cap = ( + name: string, + startLine: number, + startCol: number, + endLine: number, + endCol: number, + text = '', +): Capture => ({ + name, + range: { startLine, startCol, endLine, endCol }, + text, +}); + +const scopeMatch = ( + kind: Lowercase, + startLine: number, + startCol: number, + endLine: number, + endCol: number, +): CaptureMatch => ({ + [`@scope.${kind}`]: cap(`@scope.${kind}`, startLine, startCol, endLine, endCol), +}); + +const declMatch = ( + kindStr: string, + name: string, + startLine: number, + startCol: number, + endLine: number, + endCol: number, + extras: Record = {}, +): CaptureMatch => ({ + [`@declaration.${kindStr}`]: cap(`@declaration.${kindStr}`, startLine, startCol, endLine, endCol), + '@declaration.name': cap('@declaration.name', startLine, startCol, endLine, endCol, name), + ...extras, +}); + +const importMatch = ( + startLine: number, + startCol: number, + endLine: number, + endCol: number, +): CaptureMatch => ({ + '@import.statement': cap('@import.statement', startLine, startCol, endLine, endCol), +}); + +const typeBindingMatch = ( + startLine: number, + startCol: number, + endLine: number, + endCol: number, +): CaptureMatch => ({ + '@type-binding.parameter': cap('@type-binding.parameter', startLine, startCol, endLine, endCol), +}); + +const refMatch = ( + suffix: string, + name: string, + startLine: number, + startCol: number, + endLine: number, + endCol: number, + extras: Record = {}, +): CaptureMatch => ({ + [`@reference.${suffix}`]: cap(`@reference.${suffix}`, startLine, startCol, endLine, endCol), + '@reference.name': cap('@reference.name', startLine, startCol, endLine, endCol, name), + ...extras, +}); + +// ─── MockProvider ─────────────────────────────────────────────────────────── +// +// The extractor declares its dependency on a narrow `ScopeExtractorHooks` +// surface — not the full `LanguageProvider`. Tests implement exactly that +// surface, so adding a new hook to `extract()` that's not in +// `ScopeExtractorHooks` is a compile error, not a silent test pass. + +function mockProvider(hooks: Partial = {}): ScopeExtractorHooks { + return hooks; +} + +// ─── §Pass 1: scope tree construction ────────────────────────────────────── + +describe('Pass 1: scope tree', () => { + it('creates a single Module scope from one @scope.module match', () => { + const result = extract([scopeMatch('module', 1, 0, 100, 0)], 'a.ts', mockProvider()); + expect(result.scopes).toHaveLength(1); + expect(result.scopes[0]!.kind).toBe('Module'); + expect(result.scopes[0]!.parent).toBeNull(); + expect(result.moduleScope).toBe(result.scopes[0]!.id); + }); + + it('nests Class under Module when the class range is contained in the module range', () => { + const result = extract( + [scopeMatch('module', 1, 0, 100, 0), scopeMatch('class', 5, 0, 50, 0)], + 'a.ts', + mockProvider(), + ); + expect(result.scopes).toHaveLength(2); + const cls = result.scopes.find((s) => s.kind === 'Class')!; + const mod = result.scopes.find((s) => s.kind === 'Module')!; + expect(cls.parent).toBe(mod.id); + }); + + it('nests Method under Class, Class under Module — deep nesting', () => { + const result = extract( + [ + scopeMatch('module', 1, 0, 100, 0), + scopeMatch('class', 5, 0, 50, 0), + scopeMatch('function', 10, 2, 30, 2), + ], + 'a.ts', + mockProvider(), + ); + const mod = result.scopes.find((s) => s.kind === 'Module')!; + const cls = result.scopes.find((s) => s.kind === 'Class')!; + const fn = result.scopes.find((s) => s.kind === 'Function')!; + expect(cls.parent).toBe(mod.id); + expect(fn.parent).toBe(cls.id); + }); + + it('places non-nested siblings at the same level under the module', () => { + const result = extract( + [ + scopeMatch('module', 1, 0, 100, 0), + scopeMatch('function', 10, 0, 20, 0), + scopeMatch('function', 30, 0, 40, 0), + ], + 'a.ts', + mockProvider(), + ); + const mod = result.scopes.find((s) => s.kind === 'Module')!; + const fns = result.scopes.filter((s) => s.kind === 'Function'); + expect(fns).toHaveLength(2); + for (const fn of fns) expect(fn.parent).toBe(mod.id); + }); + + it('honors `provider.shouldCreateScope === false` by reparenting children to next real scope', () => { + // Block at [10:0..25:0] is SUPPRESSED; inner function at [12:0..20:0] + // should reparent to the enclosing Module instead of the Block. + const result = extract( + [ + scopeMatch('module', 1, 0, 100, 0), + scopeMatch('block', 10, 0, 25, 0), + scopeMatch('function', 12, 0, 20, 0), + ], + 'a.ts', + mockProvider({ + shouldCreateScope: (match) => match['@scope.block'] === undefined, + }), + ); + const mod = result.scopes.find((s) => s.kind === 'Module')!; + const fn = result.scopes.find((s) => s.kind === 'Function')!; + expect(result.scopes).toHaveLength(2); // block suppressed + expect(fn.parent).toBe(mod.id); + }); + + it('uses `provider.resolveScopeKind` to override the default kind from the suffix', () => { + // Provider upgrades a `@scope.block` to `Expression` for a comprehension- + // style use case. + const result = extract( + [scopeMatch('module', 1, 0, 100, 0), scopeMatch('block', 10, 0, 15, 0)], + 'a.ts', + mockProvider({ + resolveScopeKind: (match) => (match['@scope.block'] !== undefined ? 'Expression' : null), + }), + ); + expect(result.scopes.find((s) => s.kind === 'Expression')).toBeDefined(); + }); + + it('throws ScopeTreeInvariantError when siblings overlap (provider bug)', () => { + expect(() => + extract( + [ + scopeMatch('module', 1, 0, 100, 0), + scopeMatch('function', 10, 0, 20, 0), + scopeMatch('function', 15, 0, 25, 0), // overlaps + ], + 'a.ts', + mockProvider(), + ), + ).toThrow(/overlap/i); + }); + + it('throws when no Module scope is present', () => { + expect(() => extract([scopeMatch('function', 1, 0, 10, 0)], 'a.ts', mockProvider())).toThrow( + /Module/, + ); + }); +}); + +// ─── §Pass 2: declarations + local bindings ──────────────────────────────── + +describe('Pass 2: declarations + local bindings', () => { + it('attaches a Class declaration to its enclosing Module scope', () => { + const result = extract( + [ + scopeMatch('module', 1, 0, 100, 0), + scopeMatch('class', 5, 0, 50, 0), + declMatch('class', 'User', 5, 6, 5, 10), + ], + 'a.ts', + mockProvider(), + ); + // The declaration sits at line 5 → innermost scope is Class (at 5:0..50:0). + const cls = result.scopes.find((s) => s.kind === 'Class')!; + expect(cls.ownedDefs).toHaveLength(1); + expect(cls.ownedDefs[0]!.type).toBe('Class'); + expect(cls.ownedDefs[0]!.qualifiedName).toBe('User'); + expect(cls.bindings.get('User')).toBeDefined(); + expect(cls.bindings.get('User')![0]!.origin).toBe('local'); + }); + + it('records the declaration in `localDefs` as well', () => { + const result = extract( + [scopeMatch('module', 1, 0, 100, 0), declMatch('function', 'render', 5, 0, 5, 6)], + 'a.ts', + mockProvider(), + ); + expect(result.localDefs).toHaveLength(1); + expect(result.localDefs[0]!.type).toBe('Function'); + }); + + it('honors `provider.bindingScopeFor` to hoist a binding to an outer scope', () => { + // Treat every declaration as hoisted to the module scope. + const result = extract( + [ + scopeMatch('module', 1, 0, 100, 0), + scopeMatch('function', 10, 0, 30, 0), + declMatch('variable', 'x', 15, 4, 15, 5), + ], + 'a.ts', + mockProvider({ + bindingScopeFor: (_match, _innermost, scopeTree) => { + for (const s of scopeTree.byId.values()) if (s.kind === 'Module') return s.id; + return null; + }, + }), + ); + const mod = result.scopes.find((s) => s.kind === 'Module')!; + const fn = result.scopes.find((s) => s.kind === 'Function')!; + // Binding hoisted to module; function scope's bindings empty for 'x'. + expect(mod.bindings.get('x')).toBeDefined(); + expect(fn.bindings.get('x')).toBeUndefined(); + // `ownedDefs` stays structural (innermost = function). + expect(fn.ownedDefs).toHaveLength(1); + }); + + it('ignores declarations with unknown kind suffixes', () => { + const result = extract( + [scopeMatch('module', 1, 0, 100, 0), declMatch('mystery', 'x', 5, 0, 5, 1)], + 'a.ts', + mockProvider(), + ); + expect(result.localDefs).toHaveLength(0); + }); +}); + +// ─── §Pass 3: imports ────────────────────────────────────────────────────── + +describe('Pass 3: raw imports', () => { + it('collects imports via `provider.interpretImport`', () => { + const named: ParsedImport = { + kind: 'named', + localName: 'User', + importedName: 'User', + targetRaw: './models', + }; + const result = extract( + [scopeMatch('module', 1, 0, 100, 0), importMatch(3, 0, 3, 30)], + 'a.ts', + mockProvider({ + interpretImport: () => named, + }), + ); + expect(result.parsedImports).toEqual([named]); + }); + + it('drops imports when `interpretImport` returns null', () => { + const result = extract( + [scopeMatch('module', 1, 0, 100, 0), importMatch(3, 0, 3, 30)], + 'a.ts', + mockProvider({ + interpretImport: () => null, + }), + ); + expect(result.parsedImports).toEqual([]); + }); + + it('emits no imports when the provider does not implement `interpretImport`', () => { + const result = extract( + [scopeMatch('module', 1, 0, 100, 0), importMatch(3, 0, 3, 30)], + 'a.ts', + mockProvider(), + ); + expect(result.parsedImports).toEqual([]); + }); +}); + +// ─── §Pass 4: type bindings ─────────────────────────────────────────────── + +describe('Pass 4: type bindings', () => { + it('attaches a parameter-annotation TypeRef to the innermost scope', () => { + const parsed: ParsedTypeBinding = { + boundName: 'user', + rawTypeName: 'User', + source: 'parameter-annotation', + }; + const result = extract( + [ + scopeMatch('module', 1, 0, 100, 0), + scopeMatch('function', 5, 0, 20, 0), + typeBindingMatch(6, 4, 6, 14), + ], + 'a.ts', + mockProvider({ + interpretTypeBinding: () => parsed, + }), + ); + const fn = result.scopes.find((s) => s.kind === 'Function')!; + const tb = fn.typeBindings.get('user'); + expect(tb).toBeDefined(); + expect(tb!.rawName).toBe('User'); + expect(tb!.source).toBe('parameter-annotation'); + expect(tb!.declaredAtScope).toBe(fn.id); + }); + + it('skips type-binding matches when the provider returns null', () => { + const result = extract( + [ + scopeMatch('module', 1, 0, 100, 0), + scopeMatch('function', 5, 0, 20, 0), + typeBindingMatch(6, 4, 6, 14), + ], + 'a.ts', + mockProvider({ + interpretTypeBinding: () => null, + }), + ); + const fn = result.scopes.find((s) => s.kind === 'Function')!; + expect(fn.typeBindings.size).toBe(0); + }); +}); + +// ─── §Pass 5: reference sites ───────────────────────────────────────────── + +describe('Pass 5: reference sites', () => { + it('emits a call reference with the innermost scope anchor', () => { + const result = extract( + [ + scopeMatch('module', 1, 0, 100, 0), + scopeMatch('function', 5, 0, 20, 0), + refMatch('call.free', 'print', 10, 4, 10, 9), + ], + 'a.ts', + mockProvider(), + ); + const fn = result.scopes.find((s) => s.kind === 'Function')!; + expect(result.referenceSites).toHaveLength(1); + expect(result.referenceSites[0]!.name).toBe('print'); + expect(result.referenceSites[0]!.kind).toBe('call'); + expect(result.referenceSites[0]!.callForm).toBe('free'); + expect(result.referenceSites[0]!.inScope).toBe(fn.id); + }); + + it('classifies member calls via the `@reference.call.member` sub-tag', () => { + const result = extract( + [ + scopeMatch('module', 1, 0, 100, 0), + refMatch('call.member', 'save', 3, 4, 3, 8, { + '@reference.receiver': cap('@reference.receiver', 3, 0, 3, 4, 'user'), + }), + ], + 'a.ts', + mockProvider(), + ); + expect(result.referenceSites[0]!.callForm).toBe('member'); + expect(result.referenceSites[0]!.explicitReceiver).toEqual({ name: 'user' }); + }); + + it('falls back to `provider.classifyCallForm` when the anchor has no sub-tag', () => { + const result = extract( + [scopeMatch('module', 1, 0, 100, 0), refMatch('call', 'foo', 3, 0, 3, 3)], + 'a.ts', + mockProvider({ + classifyCallForm: () => 'member', + }), + ); + expect(result.referenceSites[0]!.callForm).toBe('member'); + }); + + it('recognizes all reference kinds (call, read, write, inherits, type, import_use)', () => { + const kindsToEmit: Array<[string, ReferenceKind]> = [ + ['call.free', 'call'], + ['read', 'read'], + ['write', 'write'], + ['inherits', 'inherits'], + ['type', 'type-reference'], + ['import_use', 'import-use'], + ]; + const matches = [ + scopeMatch('module', 1, 0, 100, 0), + ...kindsToEmit.map(([suffix], i) => refMatch(suffix, `ref${i}`, 10 + i, 0, 10 + i, 5)), + ]; + const result = extract(matches, 'a.ts', mockProvider()); + expect(result.referenceSites.map((s) => s.kind)).toEqual(kindsToEmit.map(([, kind]) => kind)); + }); + + it('picks the call anchor over a wider-ranged @reference.receiver (regression for KNOWN_SUB_TAGS exclusion)', () => { + // Regression for the bug fixed before commit: a member call like + // `user.save()` where the receiver capture (`user`) spans MORE source + // than the call anchor (`save`). The broadest-range anchor heuristic + // would have picked the receiver — `anchorCaptureFor` must exclude + // known sub-tags (`@reference.receiver`, `@reference.name`, etc.) to + // route the match as a `call` reference. + const result = extract( + [ + scopeMatch('module', 1, 0, 100, 0), + { + // Receiver spans columns 0-10 (wider). + '@reference.receiver': cap('@reference.receiver', 3, 0, 3, 10, 'longUserName'), + // Call name spans columns 11-15 (narrower). + '@reference.name': cap('@reference.name', 3, 11, 3, 15, 'save'), + // The anchor — call.member — spans 0-17 (full expression). In the + // buggy behavior the receiver would have tied-or-won. Even here, + // the fix guarantees we pick the call anchor, never the sub-tag. + '@reference.call.member': cap('@reference.call.member', 3, 0, 3, 17), + }, + ], + 'a.ts', + mockProvider(), + ); + expect(result.referenceSites).toHaveLength(1); + expect(result.referenceSites[0]!.name).toBe('save'); // NOT 'longUserName' + expect(result.referenceSites[0]!.kind).toBe('call'); + expect(result.referenceSites[0]!.callForm).toBe('member'); + expect(result.referenceSites[0]!.explicitReceiver).toEqual({ name: 'longUserName' }); + }); + + it('parses arity from @reference.arity when present', () => { + const result = extract( + [ + scopeMatch('module', 1, 0, 100, 0), + refMatch('call.free', 'foo', 3, 0, 3, 3, { + '@reference.arity': cap('@reference.arity', 3, 0, 3, 0, '2'), + }), + ], + 'a.ts', + mockProvider(), + ); + expect(result.referenceSites[0]!.arity).toBe(2); + }); +}); + +// ─── §End-to-end fixture ────────────────────────────────────────────────── + +describe('end-to-end fixture (all 5 passes together)', () => { + it('produces a well-formed ParsedFile from a representative multi-pass input', () => { + const matches: CaptureMatch[] = [ + // Pass 1: nested scopes + scopeMatch('module', 1, 0, 100, 0), + scopeMatch('class', 5, 0, 50, 0), + scopeMatch('function', 10, 2, 40, 2), + // Pass 2: declarations + declMatch('class', 'User', 5, 6, 5, 10), + declMatch('method', 'save', 10, 2, 10, 6), + declMatch('field', 'count', 7, 2, 7, 7), + // Pass 3: import + importMatch(3, 0, 3, 30), + // Pass 4: type binding + typeBindingMatch(10, 14, 10, 18), + // Pass 5: references + refMatch('call.member', 'log', 20, 4, 20, 7, { + '@reference.receiver': cap('@reference.receiver', 20, 0, 20, 4, 'self'), + }), + refMatch('read', 'count', 25, 4, 25, 9), + ]; + + const parsedImport: ParsedImport = { + kind: 'named', + localName: 'Logger', + importedName: 'Logger', + targetRaw: './logger', + }; + const parsedTypeBinding: ParsedTypeBinding = { + boundName: 'name', + rawTypeName: 'string', + source: 'parameter-annotation', + }; + + const result = extract( + matches, + 'user.ts', + mockProvider({ + interpretImport: () => parsedImport, + interpretTypeBinding: () => parsedTypeBinding, + }), + ); + + // Three scopes, properly nested. + expect(result.scopes).toHaveLength(3); + const kinds = result.scopes.map((s: Scope) => s.kind); + expect(kinds).toEqual(expect.arrayContaining(['Module', 'Class', 'Function'])); + + // Declarations landed on the correct scopes. + const cls = result.scopes.find((s) => s.kind === 'Class')!; + const fn = result.scopes.find((s) => s.kind === 'Function')!; + expect(cls.ownedDefs.map((d) => d.qualifiedName).sort()).toEqual(['User', 'count'].sort()); + expect(fn.ownedDefs.map((d) => d.qualifiedName)).toEqual(['save']); + + // Local bindings present. + expect(cls.bindings.get('User')).toBeDefined(); + expect(cls.bindings.get('count')).toBeDefined(); + expect(fn.bindings.get('save')).toBeDefined(); + + // Import collected. + expect(result.parsedImports).toEqual([parsedImport]); + + // Type binding attached to function scope. + expect(fn.typeBindings.get('name')?.rawName).toBe('string'); + + // References emitted. + expect(result.referenceSites).toHaveLength(2); + expect(result.referenceSites.map((r) => r.kind)).toEqual(['call', 'read']); + + // `localDefs` is the union across scopes. + expect(result.localDefs).toHaveLength(3); + expect(result.localDefs.map((d) => d.type).sort()).toEqual( + ['Class', 'Method', 'Property'].sort(), + ); + + // Module scope id matches the ParsedFile header. + const mod = result.scopes.find((s) => s.kind === 'Module')!; + expect(result.moduleScope).toBe(mod.id); + }); +}); From eece6344fc1107de383d4e87c374730b2c00b752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 19:54:54 +0100 Subject: [PATCH 33/46] feat(ingestion): REGISTRY_PRIMARY_ per-language flag reader (#924, RFC #909 Ring 2 PKG) (#968) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the per-language feature flag primitive that gates the Ring 3 registry-primary rollout. Single source of truth for whether a given language uses `Registry.lookup` (new) or the legacy DAG (current). ## Shipped ### `gitnexus/src/core/ingestion/registry-primary-flag.ts` - `isRegistryPrimary(lang): boolean` — reads `REGISTRY_PRIMARY_` from `process.env`. - `envVarNameFor(lang): string` — exposed for CI tooling that cross-references flag flips (and for test assertions). - `primaryLanguages(): ReadonlySet` — all currently-on languages; useful for startup logging + the #923 shadow dashboard which distinguishes "primary: legacy" vs "primary: registry" rows. ### Contract - Default: `false` for every language. A language must explicitly opt in by setting its env var. - Truthy: `'true'`, `'1'`, `'yes'` (case-insensitive, whitespace- trimmed). Anything else — typos, empty string, `'off'` — is `false`. Fail-safe posture: a misspelled flag doesn't accidentally flip a language. - No per-process caching. `process.env` is read per call; overhead is negligible (one lookup per file at resolution time), and test isolation is lexical (no cache-reset coordination). ### Env-var mapping Uses the enum VALUE, not the TS key, for the env-var suffix: - `SupportedLanguages.Python` → `REGISTRY_PRIMARY_PYTHON` - `SupportedLanguages.CPlusPlus` → `REGISTRY_PRIMARY_CPP` (value `'cpp'`) - `SupportedLanguages.CSharp` → `REGISTRY_PRIMARY_CSHARP` Users flip languages by their canonical name, not the TS symbol. ## Tests (16, all passing) - `envVarNameFor` (3): upper-casing · enum-VALUE-not-KEY mapping · all-languages uniqueness smoke-test - `isRegistryPrimary` (9): default false · `'true'` / `'1'` / `'yes'` truthy · mixed-case + whitespace-padded · falsy-looking values · unrecognized tokens (typo-safe) · per-language isolation · no stale cache on mid-process mutation · CPlusPlus mapping - `primaryLanguages` (3): empty · exact membership · Set instanceof Tests scrub every `REGISTRY_PRIMARY_*` env var in `beforeEach` + `afterEach` so parallel vitest runs on the same process don't bleed state. ## What's NOT in this PR (deferred by design) The actual integration in `call-processor.ts` belongs in #921 (finalize-orchestrator). Reason: the "new path" requires a populated `SemanticModel` to call `Registry.lookup` against, and the model becomes accessible only after #921 orchestrates finalize. Wiring a dead branch now would just get rewritten then. This PR ships the flag primitive in isolation so #921 has a clean, tested utility to consult — and so `#923` (shadow harness) has a stable boolean to read for its "which row is primary?" rendering. ## Closes part of #909. Unblocks - #921 finalize-orchestrator — can now consult `isRegistryPrimary` at resolution time - #923 shadow harness — can distinguish primary-flipped rows --- .../core/ingestion/registry-primary-flag.ts | 83 ++++++++++ .../test/unit/registry-primary-flag.test.ts | 152 ++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 gitnexus/src/core/ingestion/registry-primary-flag.ts create mode 100644 gitnexus/test/unit/registry-primary-flag.test.ts diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts new file mode 100644 index 000000000..8b59912bd --- /dev/null +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -0,0 +1,83 @@ +/** + * `REGISTRY_PRIMARY_` per-language feature flags for the scope-based + * resolution rollout (RFC §6.1 Ring 3; Ring 2 PKG #924). + * + * This module is the single source of truth for whether a given language + * has been flipped to registry-primary call resolution. When a language's + * flag is true, its files route through `Registry.lookup` (RFC §4) instead + * of the legacy call-resolution DAG; when false (the default), the legacy + * DAG runs unchanged. + * + * ## Contract + * + * - Env-var name per language: `REGISTRY_PRIMARY_`. + * Example: `SupportedLanguages.Python` → `REGISTRY_PRIMARY_PYTHON`; + * `SupportedLanguages.CPlusPlus` (value `'cpp'`) → `REGISTRY_PRIMARY_CPP`. + * - Truthy values: `'true'`, `'1'`, `'yes'` (case-insensitive, + * whitespace-trimmed). Anything else — including `undefined`, empty + * string, or unknown tokens — is `false`. + * - No per-process caching. `process.env` is read on every call. The + * flag is consulted once per file at call-resolution time, so the + * overhead is negligible; skipping caching keeps test isolation + * trivial (no `resetFlagCache()` coordination needed). + * + * ## Integration site + * + * `call-processor.ts` integration lands in **#921** (`finalize-orchestrator`) + * where the `SemanticModel` becomes accessible and `Registry.lookup` can + * actually be called with a populated context. This module ships the flag + * primitive in isolation so #921 has a clean, tested utility to consult. + * + * ## Shadow mode is orthogonal + * + * Shadow mode (`GITNEXUS_SHADOW_MODE=1`, introduced in #923) runs BOTH + * legacy and registry paths regardless of the per-language flag, so the + * parity dashboard has signal even for un-flipped languages. That logic + * lives in `shadow-harness.ts` (#923), not here. + */ + +import { SupportedLanguages } from 'gitnexus-shared'; + +/** + * Return the env-var name that controls a given language's registry- + * primary flag. Exported for test assertions and for the PR-labeling + * CI job that cross-references per-language flag changes. + */ +export function envVarNameFor(lang: SupportedLanguages): string { + return `REGISTRY_PRIMARY_${lang.toUpperCase()}`; +} + +/** + * Whether `lang` has been flipped to registry-primary call resolution. + * + * Returns `false` by default — a language must explicitly set its env + * var to a truthy value to opt in. The flag is the sole control surface: + * flipping it requires no code change, and reverting it requires no code + * change. + */ +export function isRegistryPrimary(lang: SupportedLanguages): boolean { + return parseFlag(process.env[envVarNameFor(lang)]); +} + +/** + * All languages whose registry-primary flag is currently on. Useful for + * startup-time logging + the shadow-harness dashboard, which wants to + * distinguish "primary: legacy" from "primary: registry" rows. + */ +export function primaryLanguages(): ReadonlySet { + const out = new Set(); + for (const lang of Object.values(SupportedLanguages)) { + if (isRegistryPrimary(lang)) out.add(lang); + } + return out; +} + +// ─── Internal ─────────────────────────────────────────────────────────────── + +/** Accepted truthy strings (case-insensitive, trimmed). */ +const TRUTHY_VALUES: ReadonlySet = new Set(['true', '1', 'yes']); + +function parseFlag(raw: string | undefined): boolean { + if (raw === undefined) return false; + return TRUTHY_VALUES.has(raw.trim().toLowerCase()); +} diff --git a/gitnexus/test/unit/registry-primary-flag.test.ts b/gitnexus/test/unit/registry-primary-flag.test.ts new file mode 100644 index 000000000..20a547e24 --- /dev/null +++ b/gitnexus/test/unit/registry-primary-flag.test.ts @@ -0,0 +1,152 @@ +/** + * Unit tests for `registry-primary-flag` (RFC #909 Ring 2 PKG #924). + * + * Flag is `REGISTRY_PRIMARY_`. Each test manipulates + * `process.env` directly and restores it in `afterEach` — there is no + * per-process cache to invalidate, so isolation is lexical. + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { + envVarNameFor, + isRegistryPrimary, + primaryLanguages, +} from '../../src/core/ingestion/registry-primary-flag.js'; + +// ─── Test isolation ───────────────────────────────────────────────────────── +// +// Scrub every `REGISTRY_PRIMARY_*` env var before + after each test so +// parallel vitest runs on the same process don't bleed state. + +function clearAllRegistryPrimaryVars(): void { + for (const key of Object.keys(process.env)) { + if (key.startsWith('REGISTRY_PRIMARY_')) delete process.env[key]; + } +} + +beforeEach(clearAllRegistryPrimaryVars); +afterEach(clearAllRegistryPrimaryVars); + +// ─── envVarNameFor ───────────────────────────────────────────────────────── + +describe('envVarNameFor', () => { + it('produces upper-cased env-var names from the enum value', () => { + expect(envVarNameFor(SupportedLanguages.Python)).toBe('REGISTRY_PRIMARY_PYTHON'); + expect(envVarNameFor(SupportedLanguages.TypeScript)).toBe('REGISTRY_PRIMARY_TYPESCRIPT'); + expect(envVarNameFor(SupportedLanguages.JavaScript)).toBe('REGISTRY_PRIMARY_JAVASCRIPT'); + }); + + it('uses the enum VALUE, not the key, for languages whose key differs from the value', () => { + // Key 'CPlusPlus' → value 'cpp' → env var 'REGISTRY_PRIMARY_CPP'. + // Users see the language by its canonical name, not its TS symbol. + expect(envVarNameFor(SupportedLanguages.CPlusPlus)).toBe('REGISTRY_PRIMARY_CPP'); + expect(envVarNameFor(SupportedLanguages.CSharp)).toBe('REGISTRY_PRIMARY_CSHARP'); + }); + + it('covers every member of SupportedLanguages', () => { + // Build env-var names for every language and assert no duplicates — + // catches a future enum-value collision or accidental renaming. + const names = new Set(); + for (const lang of Object.values(SupportedLanguages)) { + names.add(envVarNameFor(lang)); + } + expect(names.size).toBe(Object.values(SupportedLanguages).length); + }); +}); + +// ─── isRegistryPrimary ───────────────────────────────────────────────────── + +describe('isRegistryPrimary', () => { + it('returns false by default (no env var set)', () => { + for (const lang of Object.values(SupportedLanguages)) { + expect(isRegistryPrimary(lang)).toBe(false); + } + }); + + it("returns true when the env var is 'true' (lowercase)", () => { + process.env['REGISTRY_PRIMARY_PYTHON'] = 'true'; + expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); + }); + + it("returns true when the env var is '1'", () => { + process.env['REGISTRY_PRIMARY_PYTHON'] = '1'; + expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); + }); + + it("returns true when the env var is 'yes'", () => { + process.env['REGISTRY_PRIMARY_PYTHON'] = 'yes'; + expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); + }); + + it('accepts mixed-case and whitespace-padded truthy values', () => { + process.env['REGISTRY_PRIMARY_PYTHON'] = ' TRUE '; + expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); + process.env['REGISTRY_PRIMARY_PYTHON'] = 'Yes'; + expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); + }); + + it("returns false for falsy-looking values ('false', '0', empty, 'off')", () => { + for (const value of ['false', '0', '', 'off', 'no', 'disabled']) { + process.env['REGISTRY_PRIMARY_PYTHON'] = value; + expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(false); + } + }); + + it('returns false for unrecognized tokens (fail-safe on typos)', () => { + // User meant to type 'true' but fat-fingered — conservative: treat as off. + for (const value of ['ture', 'tru', 'yeah', 'enable', 'y']) { + process.env['REGISTRY_PRIMARY_PYTHON'] = value; + expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(false); + } + }); + + it('isolates flags per-language (one on does not affect others)', () => { + process.env['REGISTRY_PRIMARY_PYTHON'] = 'true'; + expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); + expect(isRegistryPrimary(SupportedLanguages.Java)).toBe(false); + expect(isRegistryPrimary(SupportedLanguages.Go)).toBe(false); + }); + + it('respects a mid-process env-var mutation (no stale cache)', () => { + expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(false); + process.env['REGISTRY_PRIMARY_PYTHON'] = 'true'; + expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); + delete process.env['REGISTRY_PRIMARY_PYTHON']; + expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(false); + }); + + it('handles the CPlusPlus → REGISTRY_PRIMARY_CPP mapping correctly', () => { + process.env['REGISTRY_PRIMARY_CPP'] = 'true'; + expect(isRegistryPrimary(SupportedLanguages.CPlusPlus)).toBe(true); + // Negative: the TS-key-style name is NOT read. + delete process.env['REGISTRY_PRIMARY_CPP']; + process.env['REGISTRY_PRIMARY_CPLUSPLUS'] = 'true'; + expect(isRegistryPrimary(SupportedLanguages.CPlusPlus)).toBe(false); + }); +}); + +// ─── primaryLanguages ────────────────────────────────────────────────────── + +describe('primaryLanguages', () => { + it('returns an empty set when no flags are set', () => { + expect(primaryLanguages().size).toBe(0); + }); + + it('returns exactly the flipped languages', () => { + process.env['REGISTRY_PRIMARY_PYTHON'] = 'true'; + process.env['REGISTRY_PRIMARY_GO'] = '1'; + process.env['REGISTRY_PRIMARY_JAVA'] = 'false'; // explicitly off + const enabled = primaryLanguages(); + expect(enabled.has(SupportedLanguages.Python)).toBe(true); + expect(enabled.has(SupportedLanguages.Go)).toBe(true); + expect(enabled.has(SupportedLanguages.Java)).toBe(false); + expect(enabled.size).toBe(2); + }); + + it('returns a plain Set (not a frozen proxy) — consistent shape', () => { + process.env['REGISTRY_PRIMARY_PYTHON'] = 'true'; + const enabled = primaryLanguages(); + expect(enabled).toBeInstanceOf(Set); + }); +}); From 39b5d295c70dc096ff696218c0cefc8e88e15721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 20:27:56 +0100 Subject: [PATCH 34/46] feat(ingestion): wire ScopeExtractor into parse-worker + processor (#920, RFC #909 Ring 2 PKG) (#969) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plumbs the ScopeExtractor (#919) into the real parsing pipeline. `ParsedFile` artifacts now flow from workers to the parsing-processor without changing any legacy-DAG behavior. ## Shipped ### `gitnexus/src/core/ingestion/scope-extractor-bridge.ts` (new) - `extractParsedFile(provider, sourceText, filePath, onWarn?)` - Short-circuits (returns `undefined`) when the provider has not implemented `emitScopeCaptures`. True for every language today — this is the default no-op path. - Invokes the hook + `ScopeExtractor.extract`, returns a `ParsedFile`. - **Swallows exceptions on both sides.** Failures route through the optional `onWarn` callback (or `console.warn`) and return `undefined`. Scope-extraction errors NEVER break legacy parsing on the same file. - Standalone module (not nested in `parse-worker.ts`) so tests can import it directly without triggering the worker's top-level `parentPort!.on(...)`. ### `gitnexus/src/core/ingestion/workers/parse-worker.ts` - `ParseWorkerResult.parsedFiles: ParsedFile[]` added. - `processFileGroup` calls `extractParsedFile` AFTER tree parse, BEFORE legacy extraction. Worker provides an `onWarn` callback that routes bridge warnings through `parentPort.postMessage({ type: 'warning', message })`. - `mergeResult` includes `parsedFiles` in the sub-batch merge. - Initial + reset accumulator templates include `parsedFiles: []`. ### `gitnexus/src/core/ingestion/parsing-processor.ts` - `WorkerExtractedData.parsedFiles: ParsedFile[]` added. - Empty-result branch and the across-chunk aggregation both include `parsedFiles`. Aggregation is tolerant of workers that don't emit the field (older builds / partial rollouts). ### Ring 1 tweak: `emitScopeCaptures` sync return `readonly CaptureMatch[]` (was `Promise`). Tree-sitter and COBOL's regex tagger are both synchronous; no foreseeable need for async work inside this hook. Sync lets the already-sync worker pipeline invoke it inline without cascading `async` up through the batch driver + IPC handler. ## Tests (9 new; full suite 311/311) `gitnexus/test/unit/scope-resolution/parse-worker-scope-integration.test.ts`: - Not-migrated (2): undefined-returning hook · never-invokes-extractor - Migrated (3): happy path · argument threading · honors `shouldCreateScope` override - Error resilience (4): hook throws · extractor throws (no Module) · extractor throws (sibling overlap) · `onWarn` gets routed message with filePath + error body ## Verification - `tsc --noEmit` clean in both packages - `gitnexus-shared` build clean - 311/311 combined scope-resolution / shadow / model / flag suite - 9/9 new bridge tests ## What's NOT in this PR (still deferred to #921) - Actually using the `parsedFiles` — that's the finalize orchestrator. - `ModuleScopeIndex.byFilePath` materialization — belongs alongside the rest of the SemanticModel indexes in #921. ## Closes part of #909. Unblocks - #921 finalize-orchestrator — consumes `WorkerExtractedData.parsedFiles` --- .../src/core/ingestion/language-provider.ts | 11 +- .../src/core/ingestion/parsing-processor.ts | 17 ++ .../core/ingestion/scope-extractor-bridge.ts | 54 ++++++ .../core/ingestion/workers/parse-worker.ts | 29 ++- .../parse-worker-scope-integration.test.ts | 166 ++++++++++++++++++ 5 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 gitnexus/src/core/ingestion/scope-extractor-bridge.ts create mode 100644 gitnexus/test/unit/scope-resolution/parse-worker-scope-integration.test.ts diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 770d15aee..351b339a7 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -321,12 +321,15 @@ interface LanguageProviderConfig { * Providers that have not yet migrated continue to run through the * legacy DAG path (feature-flagged per `REGISTRY_PRIMARY_`). * + * **Sync return.** Tree-sitter query execution and COBOL's regex + * tagger are both synchronous; no current or foreseeable provider + * needs async work inside this hook. The sync signature lets + * `parse-worker.ts` (#920) invoke it inline in its already-sync + * per-file loop without cascading `async` through the batch pipeline. + * * Default: undefined (language continues to use legacy DAG). */ - readonly emitScopeCaptures?: ( - sourceText: string, - filePath: string, - ) => Promise; + readonly emitScopeCaptures?: (sourceText: string, filePath: string) => readonly CaptureMatch[]; /** * Interpret a raw `@import.statement` capture group into a `ParsedImport`. diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 90186c659..b7b143039 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -31,6 +31,7 @@ import { buildCollisionGroups, } from './utils/method-props.js'; import type { LanguageProvider } from './language-provider.js'; +import type { ParsedFile } from 'gitnexus-shared'; import { WorkerPool } from './workers/worker-pool.js'; import type { ParseWorkerResult, @@ -62,6 +63,14 @@ export interface WorkerExtractedData { ormQueries: ExtractedORMQuery[]; constructorBindings: FileConstructorBindings[]; fileScopeBindings: FileScopeBindings[]; + /** + * Per-file `ParsedFile` artifacts from the new scope-based resolution + * pipeline (RFC #909 Ring 2). Empty until a provider implements + * `emitScopeCaptures` — additive to the legacy DAG path. Aggregated + * from every worker chunk; consumed downstream by #921's + * finalize-orchestrator. + */ + parsedFiles: ParsedFile[]; } // ============================================================================ @@ -96,6 +105,7 @@ const processParsingWithWorkers = async ( ormQueries: [], constructorBindings: [], fileScopeBindings: [], + parsedFiles: [], }; const total = files.length; @@ -120,6 +130,7 @@ const processParsingWithWorkers = async ( const allORMQueries: ExtractedORMQuery[] = []; const allConstructorBindings: FileConstructorBindings[] = []; const fileScopeBindingsByFile: FileScopeBindings[] = []; + const allParsedFiles: ParsedFile[] = []; for (const result of chunkResults) { for (const node of result.nodes) { graph.addNode({ @@ -157,6 +168,11 @@ const processParsingWithWorkers = async ( for (const item of result.constructorBindings) allConstructorBindings.push(item); if (result.fileScopeBindings) for (const item of result.fileScopeBindings) fileScopeBindingsByFile.push(item); + // RFC #909 Ring 2: aggregate per-file scope artifacts. Tolerant of + // workers that don't emit the field yet (older worker builds or + // partial rollouts), since the additive contract means undefined = + // "this worker produced no ParsedFiles for this chunk". + if (result.parsedFiles) for (const item of result.parsedFiles) allParsedFiles.push(item); } // Merge and log skipped languages from workers @@ -187,6 +203,7 @@ const processParsingWithWorkers = async ( ormQueries: allORMQueries, constructorBindings: allConstructorBindings, fileScopeBindings: fileScopeBindingsByFile, + parsedFiles: allParsedFiles, }; }; diff --git a/gitnexus/src/core/ingestion/scope-extractor-bridge.ts b/gitnexus/src/core/ingestion/scope-extractor-bridge.ts new file mode 100644 index 000000000..dfa3b9b4f --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-extractor-bridge.ts @@ -0,0 +1,54 @@ +/** + * Bridge between a language provider's `emitScopeCaptures` hook and the + * `ScopeExtractor` (RFC #909 Ring 2 PKG #920). + * + * Extracted into its own module so it can be imported by test code + * without pulling in `parse-worker.ts` — which has a top-level + * `parentPort!.on('message', ...)` call that assumes a worker-thread + * context and throws on direct import. + * + * The bridge: + * + * 1. Short-circuits when the provider has NOT implemented + * `emitScopeCaptures`. Returns `undefined`; zero work done. This is + * the state of every language today — `ParsedFile` production stays + * dormant until a language migrates. + * 2. Invokes the hook + feeds its output to `ScopeExtractor.extract`. + * 3. **Swallows exceptions from either side.** A failure here returns + * `undefined` and emits a warning via `onWarn`; legacy parsing on + * the same file continues unaffected by the scope-extraction miss. + * Scope-based resolution is the new path under construction — it + * must not destabilize the legacy DAG. + */ + +import type { ParsedFile } from 'gitnexus-shared'; +import { extract as extractScope } from './scope-extractor.js'; +import type { LanguageProvider } from './language-provider.js'; + +/** Callback used to report scope-extraction warnings to the host (worker or direct). */ +export type ScopeBridgeWarn = (message: string) => void; + +/** + * Produce a `ParsedFile` for the given file, or `undefined` when the + * provider hasn't migrated / the extractor throws. Never propagates + * exceptions. + */ +export function extractParsedFile( + provider: LanguageProvider, + sourceText: string, + filePath: string, + onWarn?: ScopeBridgeWarn, +): ParsedFile | undefined { + if (provider.emitScopeCaptures === undefined) return undefined; + try { + const captures = provider.emitScopeCaptures(sourceText, filePath); + return extractScope(captures, filePath, provider); + } catch (err) { + const message = `scope extraction failed for ${filePath}: ${ + err instanceof Error ? err.message : String(err) + }`; + if (onWarn !== undefined) onWarn(message); + else console.warn(message); + return undefined; + } +} diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 203e027fa..8b56c1c16 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -77,6 +77,8 @@ import { buildCollisionGroups, } from '../utils/method-props.js'; import type { LanguageProvider } from '../language-provider.js'; +import type { ParsedFile } from 'gitnexus-shared'; +import { extractParsedFile } from '../scope-extractor-bridge.js'; // ============================================================================ // Types for serializable results @@ -269,6 +271,14 @@ export interface ParseWorkerResult { constructorBindings: FileConstructorBindings[]; /** All-scope type bindings from TypeEnv for BindingAccumulator (includes function-local). */ fileScopeBindings: FileScopeBindings[]; + /** + * Per-file `ParsedFile` artifacts from the new scope-based resolution + * pipeline (RFC #909 Ring 2). Empty unless the file's provider implements + * `emitScopeCaptures` — default for every language today, so this is + * additive and leaves the legacy DAG untouched. Consumed by #921's + * finalize-orchestrator. + */ + parsedFiles: ParsedFile[]; skippedLanguages: Record; fileCount: number; } @@ -711,6 +721,7 @@ const processBatch = ( ormQueries: [], constructorBindings: [], fileScopeBindings: [], + parsedFiles: [], skippedLanguages: {}, fileCount: 0, }; @@ -1396,11 +1407,24 @@ const processFileGroup = ( continue; } + const provider = getProvider(language); + + // RFC #909 Ring 2: produce a `ParsedFile` for the new scope-based + // resolution pipeline. No-op (returns undefined) for every language + // today — only fires once a provider implements `emitScopeCaptures`. + // Runs BEFORE legacy extraction and its result is independent: a + // failure here is caught inside `extractParsedFile` and does NOT + // affect the legacy DAG path that follows. + const parsedFile = extractParsedFile(provider, parseContent, file.path, (message) => { + if (parentPort) parentPort.postMessage({ type: 'warning', message }); + else console.warn(message); + }); + if (parsedFile !== undefined) result.parsedFiles.push(parsedFile); + // Pre-pass: extract heritage from query matches to build parentMap for buildTypeEnv. // Heritage edges (EXTENDS/IMPLEMENTS) are created by heritage-processor which runs // in PARALLEL with call-processor, so the graph edges don't exist when buildTypeEnv // runs. This pre-pass makes parent class information available for type resolution. - const provider = getProvider(language); const fileParentMap = new Map(); if (provider.heritageExtractor) { for (const match of matches) { @@ -2282,6 +2306,7 @@ let accumulated: ParseWorkerResult = { ormQueries: [], constructorBindings: [], fileScopeBindings: [], + parsedFiles: [], skippedLanguages: {}, fileCount: 0, }; @@ -2309,6 +2334,7 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => { appendAll(target.ormQueries, src.ormQueries); appendAll(target.constructorBindings, src.constructorBindings); appendAll(target.fileScopeBindings, src.fileScopeBindings); + appendAll(target.parsedFiles, src.parsedFiles); for (const [lang, count] of Object.entries(src.skippedLanguages)) { target.skippedLanguages[lang] = (target.skippedLanguages[lang] || 0) + count; } @@ -2360,6 +2386,7 @@ parentPort!.on('message', (msg: WorkerIncomingMessage) => { ormQueries: [], constructorBindings: [], fileScopeBindings: [], + parsedFiles: [], skippedLanguages: {}, fileCount: 0, }; diff --git a/gitnexus/test/unit/scope-resolution/parse-worker-scope-integration.test.ts b/gitnexus/test/unit/scope-resolution/parse-worker-scope-integration.test.ts new file mode 100644 index 000000000..5d6959298 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/parse-worker-scope-integration.test.ts @@ -0,0 +1,166 @@ +/** + * Unit tests for `extractParsedFile` — the parse-worker → ScopeExtractor + * bridge (RFC #909 Ring 2 PKG #920). + * + * The goal is to pin three invariants: + * + * 1. When a provider does NOT implement `emitScopeCaptures`, the helper + * returns `undefined` silently. This is the state of every language + * today — `ParseWorkerResult.parsedFiles` stays empty and the legacy + * DAG continues unaffected. + * 2. When a provider DOES implement the hook, the helper threads its + * output through `ScopeExtractor.extract` and returns a `ParsedFile`. + * 3. Exceptions from either the hook or the extractor are caught + * locally. The helper returns `undefined` — scope-extraction + * failures must NEVER break legacy parsing on the same file. + */ + +import { describe, it, expect } from 'vitest'; +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js'; +import type { LanguageProvider } from '../../../src/core/ingestion/language-provider.js'; + +// ─── Capture helpers ──────────────────────────────────────────────────────── + +const cap = ( + name: string, + startLine: number, + startCol: number, + endLine: number, + endCol: number, + text = '', +): Capture => ({ name, range: { startLine, startCol, endLine, endCol }, text }); + +const moduleScopeMatch = (): CaptureMatch => ({ + '@scope.module': cap('@scope.module', 1, 0, 100, 0), +}); + +/** + * Build a `LanguageProvider` whose shape is only as narrow as + * `extractParsedFile` reads. Tests cast to the full provider type since + * `extractParsedFile` is typed against `LanguageProvider` (not the narrow + * `ScopeExtractorHooks`); the real worker always has a full provider. + */ +function fakeProvider( + hooks: Partial< + Pick + >, +): LanguageProvider { + return hooks as unknown as LanguageProvider; +} + +// ─── Tests ───────────────────────────────────────────────────────────────── + +describe('extractParsedFile', () => { + describe('provider has NOT migrated (no emitScopeCaptures)', () => { + it('returns undefined — silent no-op for legacy languages', () => { + const provider = fakeProvider({}); // no hook + const result = extractParsedFile(provider, 'source text', 'src/file.ts'); + expect(result).toBeUndefined(); + }); + + it('never calls the scope extractor when the hook is absent — cannot throw', () => { + // If the extractor was wrongly invoked, it would complain about the + // missing Module scope for empty captures. This test proves the + // short-circuit actually fires. + const provider = fakeProvider({}); + expect(() => extractParsedFile(provider, '', 'x.ts')).not.toThrow(); + }); + }); + + describe('provider HAS migrated', () => { + it('threads emitScopeCaptures output through ScopeExtractor', () => { + const provider = fakeProvider({ + emitScopeCaptures: () => [moduleScopeMatch()], + }); + const result = extractParsedFile(provider, 'source text', 'src/file.ts'); + expect(result).toBeDefined(); + expect(result!.filePath).toBe('src/file.ts'); + expect(result!.scopes).toHaveLength(1); + expect(result!.scopes[0]!.kind).toBe('Module'); + }); + + it('forwards the correct arguments to emitScopeCaptures', () => { + let seenText: string | undefined; + let seenPath: string | undefined; + const provider = fakeProvider({ + emitScopeCaptures: (text, path) => { + seenText = text; + seenPath = path; + return [moduleScopeMatch()]; + }, + }); + extractParsedFile(provider, 'the real text', 'deep/path/file.ts'); + expect(seenText).toBe('the real text'); + expect(seenPath).toBe('deep/path/file.ts'); + }); + + it('honors provider hooks beyond emitScopeCaptures (shouldCreateScope)', () => { + // A Block scope the provider declines to create — the resulting + // ParsedFile should have only the Module scope, not the Block. + const provider = fakeProvider({ + emitScopeCaptures: () => [ + moduleScopeMatch(), + { '@scope.block': cap('@scope.block', 10, 0, 20, 0) }, + ], + shouldCreateScope: (match) => match['@scope.block'] === undefined, + }); + const result = extractParsedFile(provider, 'src', 'a.ts'); + expect(result!.scopes).toHaveLength(1); + expect(result!.scopes[0]!.kind).toBe('Module'); + }); + }); + + describe('error resilience — never breaks legacy parsing', () => { + it('returns undefined when emitScopeCaptures throws', () => { + const provider = fakeProvider({ + emitScopeCaptures: () => { + throw new Error('provider boom'); + }, + }); + const result = extractParsedFile(provider, 'src', 'a.ts'); + expect(result).toBeUndefined(); + }); + + it('routes errors through the onWarn callback when provided', () => { + const warnings: string[] = []; + const provider = fakeProvider({ + emitScopeCaptures: () => { + throw new Error('provider boom'); + }, + }); + const result = extractParsedFile(provider, 'src', 'path/to/file.ts', (msg) => { + warnings.push(msg); + }); + expect(result).toBeUndefined(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('path/to/file.ts'); + expect(warnings[0]).toContain('provider boom'); + }); + + it('returns undefined when ScopeExtractor throws (missing Module scope)', () => { + // Emits a Class scope but no Module — extractor throws; helper + // swallows and returns undefined. Legacy parsing on the same file + // continues unaffected by this failure. + const provider = fakeProvider({ + emitScopeCaptures: () => [{ '@scope.class': cap('@scope.class', 5, 0, 10, 0) }], + }); + const result = extractParsedFile(provider, 'src', 'a.ts'); + expect(result).toBeUndefined(); + }); + + it('returns undefined when ScopeExtractor throws on malformed captures (overlap)', () => { + // Siblings with overlapping ranges trip the ScopeTreeInvariantError + // from #912. The helper catches it and returns undefined. + const provider = fakeProvider({ + emitScopeCaptures: () => [ + moduleScopeMatch(), + { '@scope.function': cap('@scope.function', 10, 0, 20, 0) }, + { '@scope.function': cap('@scope.function', 15, 0, 25, 0) }, // overlap + ], + }); + const result = extractParsedFile(provider, 'src', 'a.ts'); + expect(result).toBeUndefined(); + }); + }); +}); From 25520e90a511b3e436a02b648edff3214c3dfa23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 20:51:19 +0100 Subject: [PATCH 35/46] feat(ingestion): finalize-orchestrator materializes ScopeResolutionIndexes (#921, RFC #909 Ring 2 PKG) (#970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ties the Ring 2 pipeline together. Takes the `ParsedFile[]` produced by #920's parse-worker integration, feeds them to shared `finalize()` (#915), and bundles every workspace-wide index for attachment onto `MutableSemanticModel`. Thin integration glue per issue #884's boundary — all algorithm lives in `gitnexus-shared`. ## Shipped ### `model/scope-resolution-indexes.ts` (new) ```ts interface ScopeResolutionIndexes { readonly scopeTree: ScopeTree; readonly defs: DefIndex; readonly qualifiedNames: QualifiedNameIndex; readonly moduleScopes: ModuleScopeIndex; readonly methodDispatch: MethodDispatchIndex; readonly imports: ReadonlyMap; readonly bindings: ReadonlyMap>; readonly referenceSites: readonly ReferenceSite[]; readonly sccs: readonly FinalizedScc[]; readonly stats: FinalizeStats; } ``` The bundle produced by the orchestrator, consumed by the resolution phase. `ReferenceIndex` is deliberately NOT here — it's populated in the next phase (#925). ### `model/semantic-model.ts` — extended - `SemanticModel.scopes?: ScopeResolutionIndexes` — undefined until attached; once attached, frozen. - `MutableSemanticModel.attachScopeIndexes(indexes)` — one-shot write. Throws on second call; `Object.freeze`s the bundle on write. `clear()` resets the slot back to `undefined` so re-ingestion can re-attach. ### `finalize-orchestrator.ts` (new) ```ts finalizeScopeModel(parsedFiles, options?): ScopeResolutionIndexes ``` Orchestration steps: 1. Map `ParsedFile[]` → `FinalizeInput` (`FinalizeFile` is a structural subset, so no shape-shifting). 2. Call shared `finalize()` with provider hooks (defaults provided for the zero-provider case today). 3. Build the four workspace indexes (`DefIndex`, `QualifiedNameIndex`, `ModuleScopeIndex`, `ScopeTree`) from per-file unions. 4. Build an empty `MethodDispatchIndex` as a placeholder (owners=[], both callbacks return []). Real MRO wiring lands with the per-language adapters in #922. 5. Bundle + return. **Empty-input safety.** Zero parsedFiles → valid but empty bundle with all zero-sized indexes and `stats.totalFiles === 0`. Downstream code can consult `model.scopes` without branching on presence — only on `stats`. **Hook defaults** (`withDefaultHooks`) for missing provider hooks: - `resolveImportTarget: () => null` — every import goes `unresolved` - `expandsWildcardTo: () => []` — wildcards don't materialize - `mergeBindings: (a, b) => [...a, ...b]` — append without precedence Providers override these in #922 (per-language import adapters). ## Tests (10, all passing) - **Empty input** (1): zero parsedFiles → valid empty bundle - **Single file** (2): all per-file indexes populated · referenceSites aggregated - **Cross-file imports** (3): resolveImportTarget threads through + links · default-null resolver → unresolved · stats reflect graph - **MutableSemanticModel integration** (4): undefined initially · attach once · Object.freeze applied · throws on re-attach · clear() resets ## Verification - `tsc --noEmit` clean in both packages - `gitnexus-shared` build clean - 10/10 new tests pass - Full scope-resolution / shadow / model / flag suite: **321/321 pass** ## What's deferred (not this PR, per RFC #909 scope) - **Per-language hook adapters** (#922): `resolveImportTarget` + `expandsWildcardTo` + `mergeBindings` wired per language. - **MethodDispatchIndex wiring via HeritageMap**: populate MRO + implements via the existing CLI-package HeritageMap strategies. Likely companion to #922 or a focused follow-up. - **Pipeline invocation**: actually calling `finalizeScopeModel` from the real ingestion pipeline. The orchestrator is callable today; the ingestion entry point wiring lands with the shadow harness (#923). - **`ReferenceIndex` population**: RFC §3.2 Phase 4 / #925. ## Closes part of #909. Unblocks - #923 shadow harness — now has a fully materialized `model.scopes` to query against the legacy DAG for parity measurement - #925 ReferenceIndex → LadybugDB emission — consumes `model.scopes` - Ring 3 language migrations (#926+) — a language flipping to `REGISTRY_PRIMARY_=true` can now expect `model.scopes` to be populated when the pipeline wires the orchestrator in --- .../core/ingestion/finalize-orchestrator.ts | 196 ++++++++++++++++ .../model/scope-resolution-indexes.ts | 73 ++++++ .../core/ingestion/model/semantic-model.ts | 45 ++++ .../finalize-orchestrator.test.ts | 219 ++++++++++++++++++ 4 files changed, 533 insertions(+) create mode 100644 gitnexus/src/core/ingestion/finalize-orchestrator.ts create mode 100644 gitnexus/src/core/ingestion/model/scope-resolution-indexes.ts create mode 100644 gitnexus/test/unit/scope-resolution/finalize-orchestrator.test.ts diff --git a/gitnexus/src/core/ingestion/finalize-orchestrator.ts b/gitnexus/src/core/ingestion/finalize-orchestrator.ts new file mode 100644 index 000000000..8541e58aa --- /dev/null +++ b/gitnexus/src/core/ingestion/finalize-orchestrator.ts @@ -0,0 +1,196 @@ +/** + * `finalizeScopeModel` — turn a workspace's `ParsedFile[]` into a + * materialized `ScopeResolutionIndexes` (RFC §3.2 Phase 2; Ring 2 PKG #921). + * + * Thin integration glue, per issue #884's boundary: all algorithmic logic + * lives in `gitnexus-shared` (finalize algorithm #915, the four per-file + * indexes #913, the method-dispatch materialization #914, the scope tree + * #912). This file does three things only: + * + * 1. Map `ParsedFile[]` → `FinalizeInput` and call shared `finalize()`. + * 2. Build the four workspace-wide indexes from the union of per-file + * defs/scopes/modules/qualified-names. + * 3. Bundle the results into `ScopeResolutionIndexes` for + * `MutableSemanticModel.attachScopeIndexes(...)`. + * + * ## What this module is NOT responsible for + * + * - Invoking tree-sitter or running AST walks. That's the extractor (#919). + * - Per-language import-target resolution. Hooks are plumbed through + * but default to "unresolved" when no provider supplies them — the + * real adapters land with #922. + * - Populating `ReferenceIndex`. That's the resolution phase (#925). + * - Deciding which language uses registry-primary lookup. That's the + * flag reader (#924). + * + * ## Empty-input behavior + * + * When `parsedFiles` is empty (the common case today — no language has + * migrated yet), the orchestrator produces a valid but empty bundle: all + * indexes are zero-sized, the scope tree is empty, and + * `finalize.stats.totalFiles === 0`. This lets downstream consumers + * safely consult `model.scopes` without branching on presence. + */ + +import type { + BindingRef, + FinalizeFile, + FinalizeHooks, + ParsedFile, + Scope, + ScopeId, + SymbolDefinition, + WorkspaceIndex, +} from 'gitnexus-shared'; +import { + buildDefIndex, + buildMethodDispatchIndex, + buildModuleScopeIndex, + buildQualifiedNameIndex, + buildScopeTree, + finalize, +} from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from './model/scope-resolution-indexes.js'; + +// ─── Public entry point ───────────────────────────────────────────────────── + +/** + * Options forwarded to the orchestrator. All fields optional so callers + * that don't yet have per-language hooks (today) get sensible defaults; + * #922 will populate `hooks.resolveImportTarget` + friends per language. + */ +export interface FinalizeOrchestratorOptions { + /** + * Hooks forwarded to shared `finalize()`. Any omitted field gets a + * no-op default: unresolved targets, empty wildcard expansion, append + * merge for bindings. + */ + readonly hooks?: Partial; + /** + * Opaque workspace context forwarded to hooks. `undefined` today; Ring + * 2 PKG #922 populates this with a real cross-file index for the + * per-language resolvers. + */ + readonly workspaceIndex?: WorkspaceIndex; +} + +/** + * Produce a fully materialized `ScopeResolutionIndexes` from the + * workspace's per-file artifacts. + * + * Pure function (given pure hooks). No I/O, no globals consulted. The + * pipeline calls this once per ingestion run and hands the result to + * `MutableSemanticModel.attachScopeIndexes`. + */ +export function finalizeScopeModel( + parsedFiles: readonly ParsedFile[], + options: FinalizeOrchestratorOptions = {}, +): ScopeResolutionIndexes { + const hooks = withDefaultHooks(options.hooks ?? {}); + const workspaceIndex: WorkspaceIndex = options.workspaceIndex ?? undefined; + + // ── Step 1: Shared finalize — runs SCC-aware cross-file link + binding + // materialization. Returns linked imports + merged bindings per module + // scope + SCC condensation + stats. + const finalizeInput = { + files: parsedFiles.map(toFinalizeFile), + workspaceIndex, + }; + const finalizeOut = finalize(finalizeInput, hooks); + + // ── Step 2: Workspace-wide indexes built from the per-file unions. + // These are pure aggregations — no algorithm beyond what the builders + // in gitnexus-shared already encapsulate (first-write-wins, qname + // collision buckets, etc.). + + const allScopes: Scope[] = []; + const allDefs: SymbolDefinition[] = []; + const moduleEntries: { filePath: string; moduleScopeId: ScopeId }[] = []; + const allReferenceSites = [] as ReturnType; + + for (const file of parsedFiles) { + for (const s of file.scopes) allScopes.push(s); + for (const d of file.localDefs) allDefs.push(d); + moduleEntries.push({ filePath: file.filePath, moduleScopeId: file.moduleScope }); + } + // References kept out of the loop above to centralize list-init. + allReferenceSites.push(...collectReferenceSites(parsedFiles)); + + const scopeTree = buildScopeTree(allScopes); + const defs = buildDefIndex(allDefs); + const qualifiedNames = buildQualifiedNameIndex(allDefs); + const moduleScopes = buildModuleScopeIndex(moduleEntries); + + // ── Step 3: MethodDispatchIndex. Today we lack per-language MRO + // strategies wired into this orchestrator (that belongs with the + // HeritageMap bridge, a separate piece of work). Ship an EMPTY index + // so the bundle shape is consistent; the callbacks return `[]` for + // every owner and `implementsOf` returns `[]`. Populating this + // properly is tracked alongside the per-language provider hooks. + const methodDispatch = buildMethodDispatchIndex({ + owners: [], // empty → no MRO entries; `mroFor(x)` returns the frozen empty array + computeMro: () => [], + implementsOf: () => [], + }); + + return { + scopeTree, + defs, + qualifiedNames, + moduleScopes, + methodDispatch, + imports: finalizeOut.imports, + bindings: finalizeOut.bindings, + referenceSites: Object.freeze([...allReferenceSites]), + sccs: finalizeOut.sccs, + stats: finalizeOut.stats, + }; +} + +// ─── Internal ─────────────────────────────────────────────────────────────── + +/** Shape-reduce a `ParsedFile` to the narrower `FinalizeFile` the shared + * algorithm reads. The subset is stable — `FinalizeFile` is a proper + * subset of `ParsedFile`. */ +function toFinalizeFile(file: ParsedFile): FinalizeFile { + return { + filePath: file.filePath, + moduleScope: file.moduleScope, + parsedImports: file.parsedImports, + localDefs: file.localDefs, + }; +} + +/** Flatten every file's reference sites into one list. Order reflects + * input-file order, then capture order inside each file. Deterministic. */ +function collectReferenceSites(parsedFiles: readonly ParsedFile[]) { + const out: ParsedFile['referenceSites'][number][] = []; + for (const file of parsedFiles) { + for (const site of file.referenceSites) out.push(site); + } + return out; +} + +/** + * Fill in no-op defaults for any omitted hook. Keeps `finalize()` + * behavior well-defined for the zero-provider case today: + * + * - `resolveImportTarget: () => null` — every import edge ends up + * `linkStatus: 'unresolved'` (or dynamic-unresolved pass-through). + * - `expandsWildcardTo: () => []` — wildcards don't materialize. + * - `mergeBindings: (existing, incoming) => [...existing, ...incoming]` + * — append without precedence; providers override to implement local- + * shadows-import and similar rules. + */ +function withDefaultHooks(partial: Partial): FinalizeHooks { + return { + resolveImportTarget: partial.resolveImportTarget ?? (() => null), + expandsWildcardTo: partial.expandsWildcardTo ?? (() => []), + mergeBindings: + partial.mergeBindings ?? + (( + existing: readonly BindingRef[], + incoming: readonly BindingRef[], + ): readonly BindingRef[] => [...existing, ...incoming]), + }; +} diff --git a/gitnexus/src/core/ingestion/model/scope-resolution-indexes.ts b/gitnexus/src/core/ingestion/model/scope-resolution-indexes.ts new file mode 100644 index 000000000..67e32fc0e --- /dev/null +++ b/gitnexus/src/core/ingestion/model/scope-resolution-indexes.ts @@ -0,0 +1,73 @@ +/** + * `ScopeResolutionIndexes` — the bundle of materialized indexes produced + * by the finalize-orchestrator (RFC #909 Ring 2 PKG #921) and attached + * to `MutableSemanticModel`. + * + * Produced by `finalizeScopeModel(parsedFiles, hooks)` in + * `finalize-orchestrator.ts`. Consumed by the resolution phase (future + * tickets) where `Registry.lookup` / `resolveTypeRef` query this bundle + * to answer call-resolution questions without re-walking any AST. + * + * ## Lifecycle + * + * 1. Pipeline collects `ParsedFile[]` from the parsing-processor (#920). + * 2. Pipeline invokes `finalizeScopeModel(parsedFiles, hooks)` → + * returns a `ScopeResolutionIndexes` (this interface). + * 3. Pipeline calls `model.attachScopeIndexes(indexes)` to stamp them + * onto the `MutableSemanticModel`. This is a **one-shot write**; + * subsequent calls throw. After attachment, the indexes are frozen + * at the type level (everything is `readonly`) and at runtime via + * `Object.freeze` on the bundle. + * 4. Resolution callers hold a `SemanticModel` reference and read + * `model.scopes` to query. + * + * ## Content + * + * - `scopeTree` / `moduleScopes` / `defs` / `qualifiedNames` — the + * four Ring 2 SHARED indexes built over per-file artifacts. + * - `methodDispatch` — MRO + implements materialized view (#914). + * - `imports` — finalized `ImportEdge[]` per module scope (`parsedImports` + * resolved through cross-file link + wildcard expansion). + * - `bindings` — merged bindings per module scope (local + import + + * wildcard + re-export), with the provider's precedence applied. + * - `referenceSites` — union of every file's pre-resolution usage + * facts. Consumed by the resolution phase (future) to emit + * `Reference` records into `ReferenceIndex`. + * - `stats` — coarse-grained counts from the shared finalize algorithm + * (total files/edges, linked vs unresolved, SCC topology). + * + * `ReferenceIndex` is deliberately NOT here — it is populated in a later + * phase (RFC §3.2 Phase 4 / Ring 2 PKG #925) and owned separately. + */ + +import type { + BindingRef, + DefIndex, + FinalizedScc, + FinalizeStats, + ImportEdge, + MethodDispatchIndex, + ModuleScopeIndex, + QualifiedNameIndex, + ReferenceSite, + ScopeId, + ScopeTree, +} from 'gitnexus-shared'; + +export interface ScopeResolutionIndexes { + readonly scopeTree: ScopeTree; + readonly defs: DefIndex; + readonly qualifiedNames: QualifiedNameIndex; + readonly moduleScopes: ModuleScopeIndex; + readonly methodDispatch: MethodDispatchIndex; + /** Finalized `ImportEdge[]` per module scope. */ + readonly imports: ReadonlyMap; + /** Merged bindings (local + imports + wildcards) per module scope. */ + readonly bindings: ReadonlyMap>; + /** Pre-resolution usage facts; consumed by the resolution phase. */ + readonly referenceSites: readonly ReferenceSite[]; + /** SCC condensation of the file-level import graph — callers that want + * parallel per-SCC processing in the resolution phase read this. */ + readonly sccs: readonly FinalizedScc[]; + readonly stats: FinalizeStats; +} diff --git a/gitnexus/src/core/ingestion/model/semantic-model.ts b/gitnexus/src/core/ingestion/model/semantic-model.ts index 35a2628a5..d1c5f1446 100644 --- a/gitnexus/src/core/ingestion/model/semantic-model.ts +++ b/gitnexus/src/core/ingestion/model/semantic-model.ts @@ -57,6 +57,7 @@ import type { SymbolDefinition } from 'gitnexus-shared'; import type { SymbolTableReader, SymbolTableWriter, AddMetadata } from './symbol-table.js'; import { createSymbolTable } from './symbol-table.js'; import { createRegistrationTable } from './registration-table.js'; +import type { ScopeResolutionIndexes } from './scope-resolution-indexes.js'; // --------------------------------------------------------------------------- // Public read-only interface @@ -83,6 +84,19 @@ export interface SemanticModel { readonly methods: MethodRegistry; readonly fields: FieldRegistry; readonly symbols: SymbolTableReader; + /** + * Materialized scope-resolution indexes from RFC #909 Ring 2 PKG #921. + * + * `undefined` until the finalize-orchestrator attaches them. While + * `undefined`, the legacy DAG is the sole resolution surface; once set, + * resolvers whose language has `REGISTRY_PRIMARY_=true` consult + * these indexes instead. + * + * The attach is a one-shot write (see `MutableSemanticModel`). Callers + * holding a read-only `SemanticModel` handle see either `undefined` or + * the final frozen bundle — never a half-populated view. + */ + readonly scopes?: ScopeResolutionIndexes; } // --------------------------------------------------------------------------- @@ -100,6 +114,17 @@ export interface MutableSemanticModel extends SemanticModel { readonly symbols: SymbolTableWriter; /** Clear all registries AND the nested SymbolTable. */ clear(): void; + /** + * Stamp the finalize-orchestrator's output onto this model. + * + * **One-shot write.** Throws when called a second time — the indexes are + * meant to be materialized once per ingestion run. `Object.freeze` is + * applied to the attached bundle so consumers cannot mutate after attach. + * + * `clear()` resets the attached bundle back to `undefined`, enabling a + * fresh re-ingestion to attach a new bundle. + */ + attachScopeIndexes(indexes: ScopeResolutionIndexes): void; } // --------------------------------------------------------------------------- @@ -152,6 +177,21 @@ export const createSemanticModel = (): MutableSemanticModel => { return def; }; + // Scope-resolution bundle slot. Starts `undefined`; populated by a + // one-shot `attachScopeIndexes(...)` from the finalize-orchestrator. + // Held inside the factory closure so the returned `SemanticModel` + // surface exposes it as a plain `readonly` property without a setter. + let attachedScopes: ScopeResolutionIndexes | undefined; + + const attachScopeIndexes = (indexes: ScopeResolutionIndexes): void => { + if (attachedScopes !== undefined) { + throw new Error( + 'SemanticModel: scope indexes already attached. ' + 'Call `clear()` before re-attaching.', + ); + } + attachedScopes = Object.freeze(indexes); + }; + // Cascade clear: single source of truth for "reset the entire model". // Wired into both `model.clear()` AND `model.symbols.clear()` so that a // caller holding only a SymbolTable reference can't leave the @@ -162,6 +202,7 @@ export const createSemanticModel = (): MutableSemanticModel => { methods.clear(); fields.clear(); rawSymbols.clear(); + attachedScopes = undefined; }; // Writer-typed facade: exposes reads + add, but NO `clear` field. @@ -184,6 +225,10 @@ export const createSemanticModel = (): MutableSemanticModel => { methods, fields, symbols, + get scopes() { + return attachedScopes; + }, clear: cascadeClear, + attachScopeIndexes, }; }; diff --git a/gitnexus/test/unit/scope-resolution/finalize-orchestrator.test.ts b/gitnexus/test/unit/scope-resolution/finalize-orchestrator.test.ts new file mode 100644 index 000000000..f3d92f0f0 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/finalize-orchestrator.test.ts @@ -0,0 +1,219 @@ +/** + * Unit tests for `finalize-orchestrator` (RFC #909 Ring 2 PKG #921). + * + * Covers empty-input, single-file, multi-file-with-imports, and the + * `MutableSemanticModel.attachScopeIndexes` one-shot contract. + * + * Builds synthetic `ParsedFile` inputs directly — the orchestrator is + * below the extraction layer and independent of tree-sitter, so the + * tests don't need a real parser. + */ + +import { describe, it, expect } from 'vitest'; +import type { + BindingRef, + ParsedFile, + ParsedImport, + Scope, + ScopeId, + SymbolDefinition, +} from 'gitnexus-shared'; +import { finalizeScopeModel } from '../../../src/core/ingestion/finalize-orchestrator.js'; +import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js'; +import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js'; + +// ─── Fixture helpers ──────────────────────────────────────────────────────── + +const mkScope = ( + id: ScopeId, + parent: ScopeId | null, + filePath: string, + bindings: Record = {}, +): Scope => ({ + id, + parent, + kind: parent === null ? 'Module' : 'Class', + range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 }, + filePath, + bindings: new Map(Object.entries(bindings)), + ownedDefs: [], + imports: [], + typeBindings: new Map(), +}); + +const mkFile = (filePath: string, overrides: Partial = {}): ParsedFile => ({ + filePath, + moduleScope: `scope:${filePath}#module`, + scopes: overrides.scopes ?? [mkScope(`scope:${filePath}#module`, null, filePath)], + parsedImports: overrides.parsedImports ?? [], + localDefs: overrides.localDefs ?? [], + referenceSites: overrides.referenceSites ?? [], +}); + +const mkDef = (nodeId: string, filePath: string, qname: string): SymbolDefinition => ({ + nodeId, + filePath, + type: 'Class', + qualifiedName: qname, +}); + +// ─── Empty input ─────────────────────────────────────────────────────────── + +describe('finalizeScopeModel: empty input', () => { + it('produces a valid but empty bundle for zero parsedFiles', () => { + const out = finalizeScopeModel([]); + expect(out.scopeTree.size).toBe(0); + expect(out.defs.size).toBe(0); + expect(out.qualifiedNames.size).toBe(0); + expect(out.moduleScopes.size).toBe(0); + expect(out.methodDispatch.mroByOwnerDefId.size).toBe(0); + expect(out.imports.size).toBe(0); + expect(out.bindings.size).toBe(0); + expect(out.referenceSites).toEqual([]); + expect(out.sccs).toEqual([]); + expect(out.stats.totalFiles).toBe(0); + expect(out.stats.totalEdges).toBe(0); + }); +}); + +// ─── Single file ─────────────────────────────────────────────────────────── + +describe('finalizeScopeModel: single file', () => { + it('builds all per-file indexes from a single ParsedFile', () => { + const userClass = mkDef('def:User', 'models.ts', 'models.User'); + const file = mkFile('models.ts', { + localDefs: [userClass], + }); + const out = finalizeScopeModel([file]); + + expect(out.scopeTree.size).toBe(1); + expect(out.defs.get('def:User')).toBe(userClass); + expect(out.qualifiedNames.get('models.User')).toEqual(['def:User']); + expect(out.moduleScopes.get('models.ts')).toBe(file.moduleScope); + expect(out.stats.totalFiles).toBe(1); + }); + + it('forwards per-file referenceSites into the aggregated list', () => { + const file = mkFile('a.ts', { + referenceSites: [ + { + name: 'save', + atRange: { startLine: 5, startCol: 0, endLine: 5, endCol: 4 }, + inScope: 'scope:a.ts#module', + kind: 'call', + }, + ], + }); + const out = finalizeScopeModel([file]); + expect(out.referenceSites).toHaveLength(1); + expect(out.referenceSites[0]!.name).toBe('save'); + }); +}); + +// ─── Multi-file with cross-file imports ──────────────────────────────────── + +describe('finalizeScopeModel: cross-file imports', () => { + it('links a named import when the caller provides resolveImportTarget', () => { + const userClass = mkDef('def:User', 'models.ts', 'models.User'); + const modelsFile = mkFile('models.ts', { localDefs: [userClass] }); + + const importOfUser: ParsedImport = { + kind: 'named', + localName: 'User', + importedName: 'User', + targetRaw: 'models.ts', + }; + const appFile = mkFile('app.ts', { parsedImports: [importOfUser] }); + + const out = finalizeScopeModel([appFile, modelsFile], { + hooks: { + resolveImportTarget: (targetRaw) => (targetRaw === 'models.ts' ? 'models.ts' : null), + }, + }); + + const appImports = out.imports.get(appFile.moduleScope) ?? []; + expect(appImports).toHaveLength(1); + expect(appImports[0]!.linkStatus).toBeUndefined(); + expect(appImports[0]!.targetFile).toBe('models.ts'); + expect(appImports[0]!.targetDefId).toBe('def:User'); + }); + + it('leaves imports unresolved when no resolveImportTarget is supplied (default hook)', () => { + // Default `resolveImportTarget: () => null` — every import ends up + // with `linkStatus: 'unresolved'`. This is the zero-provider case + // today; behavior is well-defined, not a crash. + const importOfUser: ParsedImport = { + kind: 'named', + localName: 'User', + importedName: 'User', + targetRaw: 'models.ts', + }; + const appFile = mkFile('app.ts', { parsedImports: [importOfUser] }); + + const out = finalizeScopeModel([appFile]); + const appImports = out.imports.get(appFile.moduleScope) ?? []; + expect(appImports).toHaveLength(1); + expect(appImports[0]!.linkStatus).toBe('unresolved'); + }); + + it('surfaces FinalizeStats for observability', () => { + const userClass = mkDef('def:User', 'models.ts', 'models.User'); + const modelsFile = mkFile('models.ts', { localDefs: [userClass] }); + const appFile = mkFile('app.ts', { + parsedImports: [ + { + kind: 'named', + localName: 'User', + importedName: 'User', + targetRaw: 'models.ts', + }, + ], + }); + const out = finalizeScopeModel([appFile, modelsFile], { + hooks: { resolveImportTarget: () => 'models.ts' }, + }); + expect(out.stats.totalFiles).toBe(2); + expect(out.stats.totalEdges).toBe(1); + expect(out.stats.linkedEdges).toBe(1); + expect(out.stats.unresolvedEdges).toBe(0); + }); +}); + +// ─── Integration with MutableSemanticModel ───────────────────────────────── + +describe('MutableSemanticModel.attachScopeIndexes', () => { + it('starts as undefined and accepts a one-shot attach', () => { + const model = createSemanticModel(); + expect(model.scopes).toBeUndefined(); + + const indexes = finalizeScopeModel([]); + model.attachScopeIndexes(indexes); + + expect(model.scopes).toBe(indexes); + expect(model.scopes!.stats.totalFiles).toBe(0); + }); + + it('freezes the attached bundle (callers cannot mutate after attach)', () => { + const model = createSemanticModel(); + const indexes: ScopeResolutionIndexes = finalizeScopeModel([]); + model.attachScopeIndexes(indexes); + + expect(Object.isFrozen(model.scopes)).toBe(true); + }); + + it('throws on a second attach without clear()', () => { + const model = createSemanticModel(); + model.attachScopeIndexes(finalizeScopeModel([])); + expect(() => model.attachScopeIndexes(finalizeScopeModel([]))).toThrowError(/already attached/); + }); + + it('clear() resets the bundle, enabling re-attach', () => { + const model = createSemanticModel(); + model.attachScopeIndexes(finalizeScopeModel([])); + model.clear(); + expect(model.scopes).toBeUndefined(); + // Second attach now succeeds. + model.attachScopeIndexes(finalizeScopeModel([])); + expect(model.scopes).toBeDefined(); + }); +}); From 3adb97e9937f37cb95b9bf669016168ee49c3d7c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:55:19 +0100 Subject: [PATCH 36/46] feat(docker): ship signed UI + CLI/server images via docker-compose (#967) * Initial plan * docker: ship signed UI + CLI/server images via docker-compose Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/883bcee1-4a1d-4b3d-bbb9-accd8846da96 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * docker: lock image version to npm package + harden cosign verify guidance Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/6afd4fcd-5656-4e02-b796-a22b59000bde Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * docker: add Sigstore ClusterImagePolicy + k8s admission docs Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/aea3dd70-e2a9-443a-b578-cb3eca4093e1 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * docker(k8s): collapse redundant image globs in ClusterImagePolicy Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/aea3dd70-e2a9-443a-b578-cb3eca4093e1 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * docker(ci): drop deprecated COSIGN_EXPERIMENTAL, dead build-args, and loose verify regex in comment Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/bdf0d2cf-607c-4558-982a-be9b216b2d36 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * docker(ci): use ${{ github.repository }} in verify-comment regex for fork portability Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/bdf0d2cf-607c-4558-982a-be9b216b2d36 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * style(deploy): prettier-format cluster-image-policy.yaml (single quotes) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b09a016e-56a1-4b73-bacc-e69084a48782 * ci(docker): drop workflow_dispatch, harden signing loop, fix verify-comment placeholder Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/6755064c-7871-4b2e-9b46-b4779eb215ac --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- .env.example | 18 ++- .github/workflows/docker.yml | 122 ++++++++++++++--- Dockerfile.cli | 57 ++++++++ Dockerfile => Dockerfile.web | 1 - README.md | 137 +++++++++++++++++--- deploy/kubernetes/cluster-image-policy.yaml | 64 +++++++++ docker-compose.yaml | 40 +++++- 7 files changed, 393 insertions(+), 46 deletions(-) create mode 100644 Dockerfile.cli rename Dockerfile => Dockerfile.web (93%) create mode 100644 deploy/kubernetes/cluster-image-policy.yaml diff --git a/.env.example b/.env.example index b52d05ae1..ec967c79e 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,15 @@ -IMAGE_NAME=ghcr.io/abhigyanpatwari/gitnexus:latest -CONTAINER_NAME=gitnexus -HOST_PORT=4173 +# Images (signed Cosign keyless on every push from main / vX.Y.Z tags) +SERVER_IMAGE=ghcr.io/abhigyanpatwari/gitnexus:latest +WEB_IMAGE=ghcr.io/abhigyanpatwari/gitnexus-web:latest + +# Container names +SERVER_CONTAINER_NAME=gitnexus-server +WEB_CONTAINER_NAME=gitnexus-web + +# Host ports — the web UI expects the server on http://localhost:4747 by default. +SERVER_HOST_PORT=4747 +WEB_HOST_PORT=4173 + +# Optional read-only mount, exposed to the server as /workspace. +# Override with the directory that contains the repos you want to index. +WORKSPACE_DIR=./ diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 40a1a6eef..d27358bf6 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -4,30 +4,72 @@ on: push: tags: - 'v*' - branches: - - main - paths-ignore: ['**.md', 'docs/**', 'LICENSE'] - workflow_dispatch: + # No workflow_dispatch: publishing is exclusively tag-driven so that every + # signed image corresponds 1:1 to a published `gitnexus@X.Y.Z` on npm. A + # manual run from a branch ref would fail the version check below anyway. # Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". -# Tag refs are unique per release — distinct tags run in parallel. -# Pushes to main serialize; cancel superseded runs. +# Tag refs are unique per release, so distinct tags run in parallel. +# Re-pushes of the same tag serialize. cancel-in-progress: false — never cancel a publish mid-flight. concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.ref == 'refs/heads/main' }} + cancel-in-progress: false jobs: build-push: - name: Build & Push image + name: Build & Push ${{ matrix.image.name }} runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 permissions: contents: read packages: write + # Required for Cosign keyless signing via the OIDC token exchange, + # and for build provenance / SBOM attestations. + id-token: write + attestations: write + + strategy: + fail-fast: false + matrix: + image: + # Static UI bundle. Small, fast image. Drop-in replacement for the + # legacy single-image setup at the same `gitnexus` repository slug + # is intentionally avoided — the UI now lives at `gitnexus-web` and + # the CLI/server takes the canonical `gitnexus` slug below. + - name: gitnexus-web + dockerfile: Dockerfile.web + slug: gitnexus-web + # CLI / `gitnexus serve` backend. Heavy native deps (tree-sitter, + # onnxruntime-node) live only in this image. + - name: gitnexus + dockerfile: Dockerfile.cli + slug: gitnexus steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + # ── Lock the docker image version to the npm package version ────────── + # Mirrors the check in publish.yml: refuse to build unless the git tag + # exactly matches `gitnexus/package.json`'s version. This guarantees + # `ghcr.io//gitnexus:X.Y.Z` always corresponds to the same + # `gitnexus@X.Y.Z` published to npm — no drift, no surprises. + - name: Verify tag matches gitnexus/package.json version + id: version + shell: bash + run: | + TAG_VERSION="${GITHUB_REF#refs/tags/v}" + if ! [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + echo "::error::Tag does not follow semver: v$TAG_VERSION" + exit 1 + fi + PKG_VERSION=$(node -p "require('./gitnexus/package.json').version") + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "::error::Tag version (v$TAG_VERSION) does not match gitnexus/package.json version ($PKG_VERSION)" + exit 1 + fi + echo "version=$PKG_VERSION" >> "$GITHUB_OUTPUT" + echo "Version verified: $PKG_VERSION" + # Required for multi-platform (linux/arm64) emulation. - name: Set up QEMU uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 @@ -35,6 +77,9 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - name: Install Cosign + uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 + - name: Log in to GitHub Container Registry uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: @@ -42,30 +87,69 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # Computes image tags and labels from Git metadata: - # v* tag → ghcr.io//: (e.g. 1.2.3, 1.2, 1) - # main push → ghcr.io//:latest + # Computes image tags and labels from the verified semver tag: + # v1.2.3 → :1.2.3, :1.2, :1, :latest (auto, only for non-prerelease) + # v1.2.3-rc.1 → :1.2.3-rc.1 only (prereleases never become :latest) + # `:latest` is only emitted for tag pushes thanks to `flavor: latest=auto`, + # ensuring it always points at a real npm-published version. - name: Extract Docker metadata id: meta uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: - images: ghcr.io/${{ github.repository }} + images: ghcr.io/${{ github.repository_owner }}/${{ matrix.image.slug }} + flavor: latest=auto tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} - type=raw,value=latest,enable={{is_default_branch}} - type=sha,prefix=sha-,format=short - name: Build and push + id: build uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . + file: ${{ matrix.image.dockerfile }} platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - build-args: | - BUILDPLATFORM=${{ runner.os == 'Linux' && 'linux/amd64' || 'linux/amd64' }} + cache-from: type=gha,scope=${{ matrix.image.slug }} + cache-to: type=gha,mode=max,scope=${{ matrix.image.slug }} + provenance: mode=max + sbom: true + + # Cosign keyless signing. Each pushed tag is signed by the workflow's + # OIDC identity, so consumers can verify the image with the strict, + # fully-anchored identity regex (kept in sync with README.md and + # deploy/kubernetes/cluster-image-policy.yaml — update all three together). + # NOTE: `${...}` expression syntax is NOT evaluated inside YAML comments, so + # the example below uses literal `/` placeholders that consumers + # substitute themselves; the canonical, fully-rendered command lives in README.md. + # cosign verify ghcr.io//: \ + # --certificate-identity-regexp '^https://github\.com///\.github/workflows/docker\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$' \ + # --certificate-oidc-issuer https://token.actions.githubusercontent.com + # Do NOT relax to `@.*` — that accepts signatures from any ref, including + # unprotected branches and PRs, and defeats the supply-chain guarantee. + - name: Sign image with Cosign (keyless) + env: + # Cosign v2 (installed by sigstore/cosign-installer above) makes + # keyless the default. COSIGN_EXPERIMENTAL is a v1-only opt-in flag + # that is now deprecated/no-op, so it is intentionally omitted. + DIGEST: ${{ steps.build.outputs.digest }} + TAGS: ${{ steps.meta.outputs.tags }} + run: | + # Sign every tag at the same digest so consumers can verify by tag or by digest. + # Use `while read` instead of `for $TAGS` to be robust against tags that + # could ever contain whitespace (the metadata-action output is newline- + # separated, not space-separated). + while IFS= read -r tag; do + [[ -n "$tag" ]] && cosign sign --yes "${tag}@${DIGEST}" + done <<< "$TAGS" + + # Attach the SBOM produced by buildx as a verifiable attestation on the digest. + - name: Generate build provenance attestation + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: ghcr.io/${{ github.repository_owner }}/${{ matrix.image.slug }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true diff --git a/Dockerfile.cli b/Dockerfile.cli new file mode 100644 index 000000000..d1d4f45a4 --- /dev/null +++ b/Dockerfile.cli @@ -0,0 +1,57 @@ +ARG BUILDPLATFORM +ARG TARGETPLATFORM + +# ── Builder ──────────────────────────────────────────────────────────── +# Native modules (tree-sitter-*, onnxruntime-node, node-gyp builds for +# tree-sitter-proto / tree-sitter-swift) require python3 + a C/C++ toolchain. +FROM node:22-alpine AS builder + +WORKDIR /app + +# Toolchain for node-gyp / native builds. +RUN apk add --no-cache python3 make g++ git + +# Build gitnexus-shared first — gitnexus depends on it as a workspace. +COPY gitnexus-shared/package.json gitnexus-shared/package-lock.json ./gitnexus-shared/ +RUN npm ci --prefix gitnexus-shared +COPY gitnexus-shared ./gitnexus-shared +RUN npm run build --prefix gitnexus-shared + +# Copy the full gitnexus package before installing — `npm ci` triggers +# `postinstall` (patches tree-sitter-swift, builds the vendored +# tree-sitter-proto) and `prepare` (compiles TypeScript via scripts/build.js), +# both of which need the source tree. +COPY gitnexus ./gitnexus +RUN npm ci --prefix gitnexus + +# Drop dev dependencies for a smaller runtime layer. +RUN npm prune --omit=dev --prefix gitnexus + +# ── Runtime ──────────────────────────────────────────────────────────── +FROM node:22-alpine AS runtime + +# curl for the healthcheck; git so `gitnexus` can clone repos at runtime. +RUN apk add --no-cache curl git + +WORKDIR /app + +# Pre-create the data directory and hand it to the unprivileged `node` user +# so the bind-mounted volume is writable without root. +RUN mkdir -p /data/gitnexus && chown -R node:node /data + +COPY --from=builder --chown=node:node /app/gitnexus/dist ./gitnexus/dist +COPY --from=builder --chown=node:node /app/gitnexus/node_modules ./gitnexus/node_modules +COPY --from=builder --chown=node:node /app/gitnexus/package.json ./gitnexus/package.json +COPY --from=builder --chown=node:node /app/gitnexus/vendor ./gitnexus/vendor + +USER node + +# The web UI defaults to http://localhost:4747 — keep that contract. +ENV GITNEXUS_HOME=/data/gitnexus \ + NODE_ENV=production \ + PORT=4747 + +EXPOSE 4747 + +# Bind to 0.0.0.0 so the server is reachable from the host's mapped port. +CMD ["node", "gitnexus/dist/cli/index.js", "serve", "--host", "0.0.0.0", "--port", "4747"] diff --git a/Dockerfile b/Dockerfile.web similarity index 93% rename from Dockerfile rename to Dockerfile.web index 347888c23..d4f342509 100644 --- a/Dockerfile +++ b/Dockerfile.web @@ -11,7 +11,6 @@ RUN npm ci --prefix gitnexus-shared COPY gitnexus-shared ./gitnexus-shared RUN npm run build --prefix gitnexus-shared -COPY gitnexus/package.json ./gitnexus/package.json COPY gitnexus-web/package.json gitnexus-web/package-lock.json ./gitnexus-web/ RUN npm ci --prefix gitnexus-web diff --git a/README.md b/README.md index d61fc54d5..e60d8c8c1 100644 --- a/README.md +++ b/README.md @@ -337,39 +337,138 @@ npm run dev ## Docker -```bash -docker run --rm \ - --name gitnexus \ - -p 4173:4173 \ - ghcr.io/abhigyanpatwari/gitnexus:latest -``` +The official Docker setup ships **two signed images** orchestrated by `docker-compose.yaml`: -Or with Docker Compose: +| Image | Purpose | +| -------------------------------------------------- | ---------------------------------------------------------------------- | +| `ghcr.io/abhigyanpatwari/gitnexus:latest` | CLI / `gitnexus serve` backend (HTTP API on port `4747`, MCP, indexer) | +| `ghcr.io/abhigyanpatwari/gitnexus-web:latest` | Static web UI (port `4173`) | + +> **Heads-up — image rename.** Earlier releases published the web UI under +> `ghcr.io/abhigyanpatwari/gitnexus`. Starting with the introduction of the +> bundled backend, that slug now hosts the CLI/server image and the UI moved +> to `ghcr.io/abhigyanpatwari/gitnexus-web`. The previous tags remain +> available for pulling, but new versions are only published under the new +> slugs. Update your `docker run` / compose files accordingly (or just adopt +> the bundled compose). + +### One-command setup ```bash docker compose up -d ``` -Optional env file: +This starts the server on `http://localhost:4747` and the web UI on +`http://localhost:4173`. The UI auto-detects the server because the browser +runs on the host and reaches the container via the mapped port. + +A named volume (`gitnexus-data`) persists the global registry, indexes, and +cloned repos at `/data/gitnexus` inside the server container. To make repos on +your host machine indexable, set `WORKSPACE_DIR` before bringing the stack up: + +```bash +WORKSPACE_DIR=$HOME/code docker compose up -d +# Inside the server container the directory is mounted read-only at /workspace. +docker compose exec gitnexus-server gitnexus index /workspace/my-repo +``` + +### Direct `docker run` + +```bash +# Server +docker run --rm -d \ + --name gitnexus-server \ + -p 4747:4747 \ + -v gitnexus-data:/data/gitnexus \ + ghcr.io/abhigyanpatwari/gitnexus:latest + +# Web UI +docker run --rm -d \ + --name gitnexus-web \ + -p 4173:4173 \ + ghcr.io/abhigyanpatwari/gitnexus-web:latest +``` + +Optional env file (override image tags, container names, ports, workspace dir): ```bash cp .env.example .env -set -a -source .env -set +a +docker compose --env-file .env up -d ``` -Docker files: +### Versioning & supply-chain protection -- [Dockerfile](Dockerfile) is the source for the published `gitnexus` image. It builds `gitnexus-shared` and `gitnexus-web`, then serves the production frontend. -- [docker-compose.yaml](docker-compose.yaml) starts the published image with Docker Compose. -- [.env.example](.env.example) sets the image name, container name, and exposed port for the example commands. +The Docker images are version-locked to the npm package: -Notes: +- Both images are **only published from `vX.Y.Z` git tags**, and the workflow + refuses to build unless the tag exactly matches `gitnexus/package.json`'s + version. So `ghcr.io/abhigyanpatwari/gitnexus:1.6.2` is byte-for-byte the + same release as `npm install gitnexus@1.6.2` — no drift, no floating + builds from `main`. +- `:latest` is auto-promoted only from non-prerelease tags by the Docker + metadata action, so it always points at a real, npm-published version. -- The published image serves the production frontend only. It does not start `gitnexus serve`. -- In backend mode, the app still defaults to `http://localhost:4747` unless you change the server URL in the UI. -- If you do not want an env file, the defaults are `ghcr.io/abhigyanpatwari/gitnexus:latest`, container name `gitnexus`, and port `4173`. +Both images are signed with [Cosign keyless signing][cosign-keyless] using the +workflow's GitHub OIDC identity, and shipped with build provenance and SBOM +attestations. **This is your protection against supply-chain attacks**: even if +an attacker republishes a same-named image elsewhere (or somehow pushes to a +typo-squatted registry), they cannot forge a Cosign signature tied to +`abhigyanpatwari/GitNexus`'s `docker.yml`. Always verify before pulling into +sensitive environments: + +```bash +cosign verify ghcr.io/abhigyanpatwari/gitnexus:1.6.2 \ + --certificate-identity-regexp '^https://github\.com/abhigyanpatwari/GitNexus/\.github/workflows/docker\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com +``` + +The regex pins the certificate identity to this repo's `docker.yml` workflow +**run from a `v*` tag** — rejecting unsigned images, images signed by other +workflows, and images signed from unprotected refs. + +You can also inspect the build provenance and SBOM: + +```bash +cosign download attestation ghcr.io/abhigyanpatwari/gitnexus:1.6.2 \ + --predicate-type https://slsa.dev/provenance/v1 +``` + +#### Kubernetes: enforce signatures at admission + +For Kubernetes deployments, ship the bundled +[`ClusterImagePolicy`](deploy/kubernetes/cluster-image-policy.yaml) so the +[Sigstore policy-controller][policy-controller] rejects any GitNexus pod whose +image is not signed by this repo's `docker.yml` running from a `vX.Y.Z` tag — +the same identity the `cosign verify` snippet above pins. + +```bash +# 1. Install the controller (one-time, cluster-wide) +helm repo add sigstore https://sigstore.github.io/helm-charts && helm repo update +helm install policy-controller -n cosign-system --create-namespace \ + sigstore/policy-controller + +# 2. Opt your namespace in +kubectl label namespace policy.sigstore.dev/include=true + +# 3. Apply the policy +kubectl apply -f deploy/kubernetes/cluster-image-policy.yaml +``` + +After this, attempting to deploy an unsigned image — or one signed by anything +other than `abhigyanpatwari/GitNexus`'s `docker.yml` at a `v*` tag — fails the +admission webhook before a pod is ever created. This turns the verifiable +signature into an enforced policy, which is the supply-chain control most +clusters actually need. + +[cosign-keyless]: https://docs.sigstore.dev/cosign/signing/overview/ +[policy-controller]: https://docs.sigstore.dev/policy-controller/overview/ + +### Files + +- [Dockerfile.web](Dockerfile.web) — builds `gitnexus-shared` and `gitnexus-web`, then serves the production frontend. +- [Dockerfile.cli](Dockerfile.cli) — builds the CLI/server (with its native deps) and runs `gitnexus serve --host 0.0.0.0`. +- [docker-compose.yaml](docker-compose.yaml) — starts both signed images side by side. +- [.env.example](.env.example) — overrides for image names, container names, ports, and the workspace mount. The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAssembly (Tree-sitter WASM, LadybugDB WASM, in-browser embeddings). It's great for quick exploration but limited by browser memory for larger repos. diff --git a/deploy/kubernetes/cluster-image-policy.yaml b/deploy/kubernetes/cluster-image-policy.yaml new file mode 100644 index 000000000..9287a3c45 --- /dev/null +++ b/deploy/kubernetes/cluster-image-policy.yaml @@ -0,0 +1,64 @@ +# Sigstore policy-controller ClusterImagePolicy for GitNexus container images. +# +# This enforces — at admission time — that every Pod pulling a +# `ghcr.io/abhigyanpatwari/gitnexus` or `gitnexus-web` image is using a build +# that was Cosign-keyless-signed by this repository's `docker.yml` workflow +# running from a `vX.Y.Z` git tag. Unsigned images, images signed by other +# workflows, and images signed from unprotected refs (e.g. `main`, PR branches) +# are rejected. +# +# Prerequisites +# ------------- +# 1. Install the Sigstore policy-controller in your cluster (Helm): +# +# helm repo add sigstore https://sigstore.github.io/helm-charts +# helm repo update +# helm install policy-controller -n cosign-system --create-namespace \ +# sigstore/policy-controller +# +# 2. Opt namespaces in to verification: +# +# kubectl label namespace policy.sigstore.dev/include=true +# +# 3. Apply this policy: +# +# kubectl apply -f deploy/kubernetes/cluster-image-policy.yaml +# +# After this, `kubectl run --image=ghcr.io/abhigyanpatwari/gitnexus:` in +# any opted-in namespace will only succeed if the image carries a valid +# Sigstore signature with the pinned identity. +# +# References +# - https://docs.sigstore.dev/policy-controller/overview/ +# - https://github.com/sigstore/policy-controller +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: gitnexus-signed-images +spec: + # Apply to both published GitNexus images on GHCR. Image references always + # carry a tag or digest at admission time, so these two globs cover every + # `gitnexus:`, `gitnexus@sha256:...`, `gitnexus-web:`, and + # `gitnexus-web@sha256:...` reference. + images: + - glob: 'ghcr.io/abhigyanpatwari/gitnexus*' + authorities: + - name: gitnexus-cosign-keyless + keyless: + # Public-good Sigstore Fulcio root. + url: https://fulcio.sigstore.dev + identities: + # Pin both the OIDC issuer (GitHub Actions) AND the exact workflow + # path running from a `vX.Y.Z` (or `vX.Y.Z-prerelease`) tag. Same + # regex the README's `cosign verify` example uses; it rejects: + # * unsigned images + # * signatures from any other repo / workflow + # * signatures from non-tag refs (main, PRs, release branches) + # * signatures from arbitrary non-semver tags + - issuer: https://token.actions.githubusercontent.com + subjectRegExp: ^https://github\.com/abhigyanpatwari/GitNexus/\.github/workflows/docker\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ + # Cross-check the signature against the public Rekor transparency log, + # so an attacker who briefly compromised Fulcio cannot retroactively + # mint a signature without leaving a public, append-only audit record. + ctlog: + url: https://rekor.sigstore.dev diff --git a/docker-compose.yaml b/docker-compose.yaml index 849a3ae14..d17f012db 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,9 +1,38 @@ services: - gitnexus: - image: ${IMAGE_NAME:-ghcr.io/brainifii/gitnexus:latest} - container_name: ${CONTAINER_NAME:-gitnexus} + gitnexus-server: + image: ${SERVER_IMAGE:-ghcr.io/abhigyanpatwari/gitnexus:latest} + container_name: ${SERVER_CONTAINER_NAME:-gitnexus-server} + # Map the server to the same host port the web UI expects by default + # (http://localhost:4747). The browser runs on the host, so the UI's + # built-in default works without any reconfiguration. ports: - - '${HOST_PORT:-4173}:4173' + - '${SERVER_HOST_PORT:-4747}:4747' + volumes: + # Persist the global registry, indexes, and cloned repos across runs. + - gitnexus-data:/data/gitnexus + # Optional: mount a host workspace so `gitnexus index ` can see + # repos you already have on disk. The default points at an empty + # `./workspace/` sibling that compose will create on first start — + # it intentionally does NOT bind-mount the repo root, which would + # expose `.git`, `.env`, and CI secrets to the container. + # Override with `WORKSPACE_DIR=/abs/path/to/your/repos`. + - ${WORKSPACE_DIR:-./workspace}:/workspace:ro + restart: unless-stopped + healthcheck: + test: ['CMD', 'curl', '-fsS', 'http://localhost:4747/api/heartbeat'] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + + gitnexus-web: + image: ${WEB_IMAGE:-ghcr.io/abhigyanpatwari/gitnexus-web:latest} + container_name: ${WEB_CONTAINER_NAME:-gitnexus-web} + ports: + - '${WEB_HOST_PORT:-4173}:4173' + depends_on: + gitnexus-server: + condition: service_healthy restart: unless-stopped healthcheck: test: ['CMD', 'curl', '-f', 'http://localhost:4173/'] @@ -11,3 +40,6 @@ services: timeout: 5s retries: 3 start_period: 10s + +volumes: + gitnexus-data: From 0c37eda482d4b1f84eebdce1d3c80c34cc863d6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 21:11:24 +0100 Subject: [PATCH 37/46] feat(ingestion): per-language resolveImportTarget adapter (#922, RFC #909 Ring 2 PKG) (#971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridges the CLI's existing per-language `ImportResolverFn`s (16 languages already implemented) to the shared `FinalizeHooks.resolveImportTarget` contract consumed by `finalize()` (#915) and `finalizeScopeModel` (#921). No resolver logic is reimplemented — the adapter wraps `provider.importResolver` from each `LanguageProvider` verbatim. ## Shipped ### `import-target-adapter.ts` (new) ```ts buildImportTargetWorkspace(providers, resolveCtx): ImportTargetWorkspace resolveImportTargetAcrossLanguages(targetRaw, fromFile, workspaceIndex): string | null ``` - `ImportTargetWorkspace` is the opaque `workspaceIndex` shape the adapter recognizes: `{ perLanguage: Map }`. Callers build it once per ingestion run from the active language providers. - `resolveImportTargetAcrossLanguages` is the `FinalizeHook` implementation. It: 1. Reads `getLanguageFromFilename(fromFile)`. 2. Looks up the per-language entry. 3. Calls the existing `ImportResolverFn` — same signature, same code path the legacy DAG uses today. 4. Picks `result.files[0]` (covers both `'files'` and `'package'` result kinds; the legacy pipeline's richer multi-file + dirSuffix semantics stay accessible through `importResolver` directly). 5. Returns `null` on any null result, empty files[], unknown extension, missing workspace, or resolver exception. - Exceptions from resolvers are swallowed — the finalize algorithm treats `null` as `linkStatus: 'unresolved'`, which is the right fallback for malformed inputs. ### What's deliberately NOT here - **Re-implementation of any per-language resolver.** Wraps the existing `importResolver` field on each provider. - **Dynamic-import handling.** The shared finalize algorithm short- circuits `ParsedImport { kind: 'dynamic-unresolved' }` before calling `resolveImportTarget`, so the adapter never sees them. - **`importPathPreprocessor`.** Preprocessing belongs inside the provider's `interpretImport` hook that produces `ParsedImport.targetRaw`; the adapter forwards that verbatim. ## Tests (12, all passing) - **`buildImportTargetWorkspace`** (3): registers providers with importResolver · skips providers without · threads shared ctx into every entry - **`resolveImportTargetAcrossLanguages`** (9): forwards targetRaw + fromFile · dispatches by extension · null resolver result → null · `package`-kind takes first file · empty files[] → null · no registered resolver → null · unknown extension → null · undefined/malformed workspace → null · resolver throw → null Real per-language resolver correctness is covered by the existing per-language resolver test suites — the adapter is the bridge layer. ## Verification - `tsc --noEmit` clean (both `gitnexus-shared` and `gitnexus`) - `gitnexus-shared` build clean - 12/12 new tests pass - Full scope-resolution / shadow / model / flag suite: **333/333 pass** ## Integration flow ```ts const workspace = buildImportTargetWorkspace(providers, resolveCtx); const indexes = finalizeScopeModel(parsedFiles, { hooks: { resolveImportTarget: resolveImportTargetAcrossLanguages }, workspaceIndex: workspace, }); model.attachScopeIndexes(indexes); ``` ## Closes part of #909. Unblocks - Ring 3 language migrations (#926+): a language flipping to `REGISTRY_PRIMARY_=true` now has correct import-target resolution out of the box via its existing `importResolver`. - #923 shadow harness — can run the dual-path comparison knowing both sides use the same per-language resolution semantics. --- .../core/ingestion/import-target-adapter.ts | 124 ++++++++++++++ .../import-target-adapter.test.ts | 152 ++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 gitnexus/src/core/ingestion/import-target-adapter.ts create mode 100644 gitnexus/test/unit/scope-resolution/import-target-adapter.test.ts diff --git a/gitnexus/src/core/ingestion/import-target-adapter.ts b/gitnexus/src/core/ingestion/import-target-adapter.ts new file mode 100644 index 000000000..80a5a265e --- /dev/null +++ b/gitnexus/src/core/ingestion/import-target-adapter.ts @@ -0,0 +1,124 @@ +/** + * Bridge between CLI-package per-language `ImportResolverFn`s and the + * shared `FinalizeHooks.resolveImportTarget` contract + * (RFC §5.2; Ring 2 PKG #922). + * + * The shared finalize algorithm (#915) asks one question: + * + * resolveImportTarget(targetRaw, fromFile, workspaceIndex): string | null + * + * The CLI already has 16 language-specific resolvers satisfying a + * richer signature: + * + * ImportResolverFn(rawImportPath, filePath, resolveCtx): ImportResult + * + * This module builds a dispatch adapter — one FinalizeHook implementation + * that looks up the file's language from its path and delegates to the + * right per-language resolver. Callers package per-language resolvers + + * a shared `ResolveCtx` into an opaque `ImportTargetWorkspace` and pass + * it as `workspaceIndex` to `finalizeScopeModel`. + * + * ## What's deliberately NOT here + * + * - **Re-implementation of any per-language resolver.** We wrap the + * existing `importResolver` field on each `LanguageProvider` — the + * same code path the legacy DAG uses today. + * - **Dynamic-import handling.** The shared finalize algorithm short- + * circuits `ParsedImport { kind: 'dynamic-unresolved' }` before + * calling `resolveImportTarget`, so the adapter never sees those. + * - **`importPathPreprocessor`.** Preprocessing belongs inside the + * provider's `interpretImport` hook (which writes the final + * `ParsedImport.targetRaw`). By the time finalize passes a + * `targetRaw` to this adapter, it is the string the provider wants + * resolved verbatim. + */ + +import { + getLanguageFromFilename, + type SupportedLanguages, + type WorkspaceIndex, +} from 'gitnexus-shared'; +import type { ImportResolverFn, ImportResult, ResolveCtx } from './import-resolvers/types.js'; +import type { LanguageProvider } from './language-provider.js'; + +/** A single language's resolver bundled with the context it needs. */ +export interface LanguageResolverEntry { + readonly resolver: ImportResolverFn; + readonly ctx: ResolveCtx; +} + +/** + * The opaque `workspaceIndex` shape recognized by + * `resolveImportTargetAcrossLanguages`. Built once per ingestion run via + * `buildImportTargetWorkspace`, threaded through `finalizeScopeModel`. + */ +export interface ImportTargetWorkspace { + readonly perLanguage: ReadonlyMap; +} + +/** + * Build the workspace index from a map of language → provider. Providers + * whose `importResolver` is absent are silently skipped (no language will + * ever hit that branch at dispatch time). + * + * The `resolveCtx` is shared across all languages. Callers assemble it + * once per run (the existing pipeline already does this for the legacy + * DAG) and hand it to both the legacy resolution path and this factory. + */ +export function buildImportTargetWorkspace( + providers: ReadonlyMap, + resolveCtx: ResolveCtx, +): ImportTargetWorkspace { + const perLanguage = new Map(); + for (const [lang, provider] of providers) { + if (provider.importResolver === undefined) continue; + perLanguage.set(lang, { resolver: provider.importResolver, ctx: resolveCtx }); + } + return { perLanguage }; +} + +/** + * The FinalizeHooks-compatible implementation. Dispatches on `fromFile`'s + * extension → per-language resolver. Returns the first resolved file, + * or `null` if the resolver returns `null` or doesn't know about the + * language. + * + * Picks the first entry of `files[]` for both `'files'` and `'package'` + * result kinds — the legacy pipeline uses the whole array, but the + * shared `finalize()` hook contract is single-file. If the workspace + * later needs richer semantics (split-target packages), this is the + * single site to extend. + */ +export function resolveImportTargetAcrossLanguages( + targetRaw: string, + fromFile: string, + workspaceIndex: WorkspaceIndex, +): string | null { + const workspace = workspaceIndex as ImportTargetWorkspace | undefined; + if (workspace === undefined || workspace.perLanguage === undefined) return null; + + const lang = getLanguageFromFilename(fromFile); + if (lang === null) return null; + + const entry = workspace.perLanguage.get(lang); + if (entry === undefined) return null; + + let result: ImportResult; + try { + result = entry.resolver(targetRaw, fromFile, entry.ctx); + } catch { + // Existing resolvers can throw on malformed inputs (e.g., Python + // relative paths above the workspace root). Swallow — the shared + // algorithm treats a null here as `linkStatus: 'unresolved'`, which + // is the right fallback. + return null; + } + if (result === null) return null; + + // Both `files` and `package` variants expose a `files` array; the + // package variant also carries `dirSuffix` which we ignore at the + // FinalizeHook boundary (single-file contract). Legacy consumers + // continue to see the full result via `importResolver` directly. + const first = result.files[0]; + return first ?? null; +} diff --git a/gitnexus/test/unit/scope-resolution/import-target-adapter.test.ts b/gitnexus/test/unit/scope-resolution/import-target-adapter.test.ts new file mode 100644 index 000000000..8459fc4ba --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/import-target-adapter.test.ts @@ -0,0 +1,152 @@ +/** + * Unit tests for `import-target-adapter` (RFC #909 Ring 2 PKG #922). + * + * Exercises the language-dispatching FinalizeHook. We don't need the + * real per-language resolvers here — mock `ImportResolverFn`s let each + * branch be tested in isolation. Real-resolver integration is covered + * by the existing per-language import-resolver test suites. + */ + +import { describe, it, expect } from 'vitest'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { + buildImportTargetWorkspace, + resolveImportTargetAcrossLanguages, + type ImportTargetWorkspace, +} from '../../../src/core/ingestion/import-target-adapter.js'; +import type { + ImportResolverFn, + ResolveCtx, +} from '../../../src/core/ingestion/import-resolvers/types.js'; +import type { LanguageProvider } from '../../../src/core/ingestion/language-provider.js'; + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +const emptyCtx: ResolveCtx = { + allFilePaths: new Set(), + allFileList: [], + normalizedFileList: [], + index: { bySuffix: new Map() } as unknown as ResolveCtx['index'], + resolveCache: new Map(), + configs: { + tsconfigPaths: null, + goModule: null, + composerConfig: null, + swiftPackageConfig: null, + csharpConfigs: [], + }, +}; + +function fakeProvider(importResolver: ImportResolverFn | undefined): LanguageProvider { + return { importResolver } as unknown as LanguageProvider; +} + +function workspace( + entries: Array<[SupportedLanguages, ImportResolverFn | undefined]>, +): ImportTargetWorkspace { + const providers = new Map(); + for (const [lang, resolver] of entries) providers.set(lang, fakeProvider(resolver)); + return buildImportTargetWorkspace(providers, emptyCtx); +} + +// ─── buildImportTargetWorkspace ──────────────────────────────────────────── + +describe('buildImportTargetWorkspace', () => { + it('registers languages that expose an importResolver', () => { + const pyResolver: ImportResolverFn = () => ({ kind: 'files', files: ['resolved.py'] }); + const ws = workspace([[SupportedLanguages.Python, pyResolver]]); + expect(ws.perLanguage.has(SupportedLanguages.Python)).toBe(true); + }); + + it("skips providers whose importResolver is absent (defensive — shouldn't happen in practice)", () => { + const ws = workspace([[SupportedLanguages.Python, undefined]]); + expect(ws.perLanguage.size).toBe(0); + }); + + it('threads the shared ResolveCtx into every entry', () => { + const pyResolver: ImportResolverFn = () => ({ kind: 'files', files: ['x.py'] }); + const tsResolver: ImportResolverFn = () => ({ kind: 'files', files: ['x.ts'] }); + const ws = workspace([ + [SupportedLanguages.Python, pyResolver], + [SupportedLanguages.TypeScript, tsResolver], + ]); + expect(ws.perLanguage.get(SupportedLanguages.Python)!.ctx).toBe(emptyCtx); + expect(ws.perLanguage.get(SupportedLanguages.TypeScript)!.ctx).toBe(emptyCtx); + }); +}); + +// ─── resolveImportTargetAcrossLanguages ──────────────────────────────────── + +describe('resolveImportTargetAcrossLanguages', () => { + it('dispatches to the resolver for the fromFile extension', () => { + let seenPath: string | undefined; + const pyResolver: ImportResolverFn = (raw, _file) => { + seenPath = raw; + return { kind: 'files', files: ['models/user.py'] }; + }; + const ws = workspace([[SupportedLanguages.Python, pyResolver]]); + const result = resolveImportTargetAcrossLanguages('models.user', 'src/app.py', ws); + expect(seenPath).toBe('models.user'); + expect(result).toBe('models/user.py'); + }); + + it('routes to different resolvers based on the fromFile extension', () => { + const pyResolver: ImportResolverFn = () => ({ kind: 'files', files: ['resolved.py'] }); + const tsResolver: ImportResolverFn = () => ({ kind: 'files', files: ['resolved.ts'] }); + const ws = workspace([ + [SupportedLanguages.Python, pyResolver], + [SupportedLanguages.TypeScript, tsResolver], + ]); + expect(resolveImportTargetAcrossLanguages('x', 'a.py', ws)).toBe('resolved.py'); + expect(resolveImportTargetAcrossLanguages('x', 'a.ts', ws)).toBe('resolved.ts'); + }); + + it('returns null when the resolver returns null', () => { + const pyResolver: ImportResolverFn = () => null; + const ws = workspace([[SupportedLanguages.Python, pyResolver]]); + expect(resolveImportTargetAcrossLanguages('external_pkg', 'app.py', ws)).toBeNull(); + }); + + it('takes the first file from a package-kind result', () => { + const resolver: ImportResolverFn = () => ({ + kind: 'package', + files: ['pkg/index.py', 'pkg/other.py'], + dirSuffix: 'pkg', + }); + const ws = workspace([[SupportedLanguages.Python, resolver]]); + expect(resolveImportTargetAcrossLanguages('pkg', 'app.py', ws)).toBe('pkg/index.py'); + }); + + it('returns null when a result has kind=files but an empty files[]', () => { + // Defensive: resolvers shouldn't return this shape, but tolerate it. + const resolver: ImportResolverFn = () => ({ kind: 'files', files: [] }); + const ws = workspace([[SupportedLanguages.Python, resolver]]); + expect(resolveImportTargetAcrossLanguages('x', 'a.py', ws)).toBeNull(); + }); + + it('returns null when no resolver is registered for the language', () => { + const ws = workspace([]); // empty + // .py file but no Python resolver registered + expect(resolveImportTargetAcrossLanguages('x', 'a.py', ws)).toBeNull(); + }); + + it('returns null when fromFile has an unknown extension', () => { + const pyResolver: ImportResolverFn = () => ({ kind: 'files', files: ['resolved.py'] }); + const ws = workspace([[SupportedLanguages.Python, pyResolver]]); + expect(resolveImportTargetAcrossLanguages('x', 'README.xyz', ws)).toBeNull(); + }); + + it('returns null when workspaceIndex is undefined / malformed', () => { + expect(resolveImportTargetAcrossLanguages('x', 'a.py', undefined)).toBeNull(); + // Cast to exercise the runtime guard against caller misuse. + expect(resolveImportTargetAcrossLanguages('x', 'a.py', {} as unknown)).toBeNull(); + }); + + it('swallows resolver exceptions and returns null (treated upstream as unresolved)', () => { + const throwingResolver: ImportResolverFn = () => { + throw new Error('resolver boom'); + }; + const ws = workspace([[SupportedLanguages.Python, throwingResolver]]); + expect(resolveImportTargetAcrossLanguages('x', 'a.py', ws)).toBeNull(); + }); +}); From e2ba4a04c923a18887c2ec1d36495c0b5cbbb096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 21:30:43 +0100 Subject: [PATCH 38/46] feat(ingestion): shadow-mode parity harness + static dashboard (#923, RFC #909 Ring 2 PKG) (#972) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ingestion): shadow-mode parity harness + static dashboard (#923, RFC #909 Ring 2 PKG) Side-car observability for the RFC #909 registry rollout. Callers that dual-run legacy-DAG + `Registry.lookup` feed their result pairs into the harness; the harness diffs each pair via shared `diffResolutions` (#918), aggregates via `aggregateDiffs`, and persists a per-language parity report that the static dashboard can render offline. ## Shipped ### `gitnexus/src/core/ingestion/shadow-harness.ts` (new) ```ts createShadowHarness(): ShadowHarness ``` API: - `enabled` — `true` iff `GITNEXUS_SHADOW_MODE` is truthy at construction. Captured once; later env-var mutations don't flip it. - `record({ language, callsite, legacy, newResult, primary })` — accumulator. No-op when `enabled === false` (near-zero overhead). - `size()` — diagnostic counter. - `snapshot(now?)` — deterministic `ShadowParityReport` from the accumulated diffs. - `persist(outputDir, now?)` — writes BOTH a timestamped `.json` and a `latest.json` pointer. Creates outputDir if absent. Returns the per-run file path. - `clear()` — resets the accumulator; preserves `enabled`. Activation: `GITNEXUS_SHADOW_MODE` accepts `'true'` / `'1'` / `'yes'` (case-insensitive, trimmed); same truthy convention as `REGISTRY_PRIMARY_` from #924. Typos → disabled (fail-safe). Persisted payload (`PersistedShadowReport`) is schema-versioned (`v1`): ```jsonc { "schemaVersion": 1, "runId": "YYYYMMDD-HHMMSS-xxxxxxxx", "generatedAt": "ISO 8601", "primaryByLanguage": { "python": "legacy", ... }, "report": { /* ShadowParityReport from #918 aggregateDiffs */ } } ``` `runId` prefix is the timestamp so files sort chronologically; the entropy suffix prevents collisions within a clock-second. ### `gitnexus/shadow-parity-dashboard/index.html` (new) Minimal static dashboard — one HTML file, zero build step, zero runtime deps. Fetches `./latest.json` and renders: - Overall summary cards (total calls, both agree, disagree, overall parity %) - Per-language table: language tag ("primary: legacy" / "primary: registry" pill) + total / agree / only-legacy / only-new / disagree / both-empty / parity% - Parity cells colored by threshold: ≥95% green, ≥80% amber, <80% red - Light / dark via `prefers-color-scheme` - Empty-state message when no records yet File-serving is static: `cp .gitnexus/shadow-parity/latest.json gitnexus/shadow-parity-dashboard/` + open in a browser. ## Tests (14, all passing) - **Flag detection** (5): default off · truthy variants case-insensitive · falsy / typo → off · record() is no-op when disabled · env flip AFTER construction doesn't enable (constructed-once semantics) - **Record + snapshot** (4): multi-language accumulation · per-language rows with correct outcomes · snapshot determinism · `clear()` resets accumulator + `primaryByLanguage` - **Persistence** (5): mkdir-p on missing outputDir · per-run + latest.json match byte-for-byte · schema v1 payload shape · runId timestamp prefix sorts chronologically · empty report persists gracefully Tests use a per-test tmpdir (`fs.mkdtemp`), cleaned in `afterEach`, so parallel vitest runs don't collide. `GITNEXUS_SHADOW_MODE` is saved + restored per-test. ## What's deliberately NOT in this PR (call-out in harness docstring) - **Dual-run dispatch.** The harness is a side-car — it does NOT invoke either resolution path. Call-processor integration that actually runs both legacy + registry paths lands as a follow-up. Without that integration, `record()` is never called in production today. The harness is tested in isolation with synthetic inputs. - **CI artifact publishing.** Config work to upload `latest.json` + the dashboard HTML per CI run. Tracked separately; the harness + dashboard are ready when the CI job wires in. - **Fixture-level drill-down.** The issue mentions per-fixture AST snippet + evidence trace drill-down. MVP dashboard shows per-language rows only; drill-down extends the static JSON format + the dashboard JS in a focused follow-up. ## Verification - `tsc --noEmit` clean (both `gitnexus-shared` and `gitnexus`) - 14/14 new tests pass - Full scope-resolution / shadow / model / flag suite: **335/335 pass** ## Part of - Parent: #909 - Depends on (code): #917 (registries), #918 (diff + aggregate) - Unblocks Ring 3 language flips: the parity dashboard becomes the checkpoint before flipping `REGISTRY_PRIMARY_=true` for a language — once per-language parity stabilizes, the flip ships. * chore: prettier format on shadow-parity-dashboard index.html --- gitnexus/shadow-parity-dashboard/index.html | 291 ++++++++++++++++++ gitnexus/src/core/ingestion/shadow-harness.ts | 222 +++++++++++++ .../scope-resolution/shadow-harness.test.ts | 290 +++++++++++++++++ 3 files changed, 803 insertions(+) create mode 100644 gitnexus/shadow-parity-dashboard/index.html create mode 100644 gitnexus/src/core/ingestion/shadow-harness.ts create mode 100644 gitnexus/test/unit/scope-resolution/shadow-harness.test.ts diff --git a/gitnexus/shadow-parity-dashboard/index.html b/gitnexus/shadow-parity-dashboard/index.html new file mode 100644 index 000000000..104d7b026 --- /dev/null +++ b/gitnexus/shadow-parity-dashboard/index.html @@ -0,0 +1,291 @@ + + + + + + GitNexus — Shadow Parity Dashboard + + + + +
+

Shadow Parity — RFC #909

+
loading latest.json…
+
+ + + + + + + + + + + + + + +
LanguageTotalAgreeOnly legacyOnly newDisagreeBoth emptyParity
+ +
+ + + diff --git a/gitnexus/src/core/ingestion/shadow-harness.ts b/gitnexus/src/core/ingestion/shadow-harness.ts new file mode 100644 index 000000000..549f2bf49 --- /dev/null +++ b/gitnexus/src/core/ingestion/shadow-harness.ts @@ -0,0 +1,222 @@ +/** + * Shadow-mode parity harness — dual-run observability for the RFC #909 + * registry rollout (RFC §6.3; Ring 2 PKG #923). + * + * ## What it does + * + * - Exposes `record({ language, callsite, legacy, newResult })` for + * every call site where the caller has BOTH a legacy-DAG resolution + * and a new `Registry.lookup` resolution. + * - Computes a `ShadowDiff` per record via shared `diffResolutions` + * (#918) and accumulates them in a per-language bucket. + * - At the end of a run, aggregates into a `ShadowParityReport` via + * shared `aggregateDiffs` (#918) — per-language parity %, + * evidence-kind breakdown of divergences, grand-total overall row. + * - Optionally persists the report as JSON under + * `.gitnexus/shadow-parity/` so the static dashboard at + * `gitnexus/shadow-parity-dashboard/` can render it offline. + * + * ## What it does NOT do + * + * - **Invoke either resolution path itself.** The caller must run + * legacy + `Registry.lookup` and pass results in. The harness is a + * side-car, not a dispatcher — this keeps call-processor integration + * surgical when it lands (tracked as a follow-up; the shared model + * doesn't dual-invoke on its own). + * - **Flip anything.** `REGISTRY_PRIMARY_` lives in + * `registry-primary-flag.ts` (#924); the harness records the + * caller-supplied "which side is primary" bit for each record so the + * dashboard can label rows, but it does not consult the flag itself. + * + * ## Activation + * + * `GITNEXUS_SHADOW_MODE=1` (or `'true'`, `'yes'`, case-insensitive, + * trimmed) enables the harness. When disabled, `record()` is a cheap + * no-op: no accumulation, no allocation beyond the harness object + * itself. Callers can always construct a harness and hand it through; + * the "off" overhead is near-zero. + * + * ## Persistence shape + * + * When `persist()` is called, the harness writes TWO files: + * + * - `/.json` — the timestamped snapshot (immutable) + * - `/latest.json` — a pointer that the dashboard reads + * + * Both files contain the same `PersistedShadowReport` payload: + * + * { + * schemaVersion: 1, + * runId: "-", + * generatedAt: "", + * primaryByLanguage: { [lang]: "legacy" | "registry" }, + * report: + * } + * + * Schema-version-gated so future format changes don't silently confuse + * older dashboards. The dashboard renders `report.perLanguage` rows and + * annotates each with `primaryByLanguage[lang]`. + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { + aggregateDiffs, + diffResolutions, + type Resolution, + type ShadowCallsite, + type ShadowDiff, + type ShadowParityReport, + type SupportedLanguages, +} from 'gitnexus-shared'; + +// ─── Public API ──────────────────────────────────────────────────────────── + +/** Which side of the dual-run is considered authoritative for this language. */ +export type PrimarySide = 'legacy' | 'registry'; + +/** One record per call site the caller dual-runs. */ +export interface ShadowRecordInput { + readonly language: SupportedLanguages; + readonly callsite: ShadowCallsite; + readonly legacy: readonly Resolution[]; + readonly newResult: readonly Resolution[]; + /** + * Which side drove the actual runtime answer for this record. Lets the + * dashboard distinguish "registry-primary, legacy is shadow" from the + * default "legacy-primary, registry is shadow" without re-reading + * `REGISTRY_PRIMARY_` env vars at render time. + */ + readonly primary: PrimarySide; +} + +/** Persisted JSON shape. Schema-versioned for future migrations. */ +export interface PersistedShadowReport { + readonly schemaVersion: 1; + readonly runId: string; + readonly generatedAt: string; + readonly primaryByLanguage: Readonly>>; + readonly report: ShadowParityReport; +} + +export interface ShadowHarness { + /** `true` iff `GITNEXUS_SHADOW_MODE` is truthy. When `false`, `record()` is a no-op. */ + readonly enabled: boolean; + /** Accumulate a dual-run observation. No-op when `enabled === false`. */ + record(input: ShadowRecordInput): void; + /** Number of records accumulated so far. Useful for diagnostics / tests. */ + size(): number; + /** + * Aggregate the accumulated records into a `ShadowParityReport` + * without persisting. Returns a deterministic snapshot each call; + * idempotent with respect to `record()` ordering. + */ + snapshot(now?: Date): ShadowParityReport; + /** + * Write the aggregated snapshot to JSON. Resolves to the path of the + * per-run file. Also writes/overwrites `latest.json` alongside. + * + * Creates `outputDir` if it doesn't exist. + */ + persist(outputDir: string, now?: Date): Promise; + /** Reset the accumulator. Preserves `enabled`. */ + clear(): void; +} + +/** + * Construct a harness. Reads `GITNEXUS_SHADOW_MODE` at construction time + * (not per-`record()` call) so repeated no-op records don't re-check the + * env var in the hot path. + */ +export function createShadowHarness(): ShadowHarness { + const enabled = parseShadowModeEnv(process.env['GITNEXUS_SHADOW_MODE']); + + interface Accumulated { + readonly language: SupportedLanguages; + readonly diff: ShadowDiff; + } + const records: Accumulated[] = []; + const primaryByLanguage: Partial> = {}; + + const recordImpl = (input: ShadowRecordInput): void => { + if (!enabled) return; + const diff = diffResolutions(input.callsite, input.legacy, input.newResult); + records.push({ language: input.language, diff }); + // Primary per-language is resolved by last-write. In practice a run + // is single-threaded with respect to flag readings, so this is + // deterministic; a language's primary cannot change mid-run. + primaryByLanguage[input.language] = input.primary; + }; + + const snapshotImpl = (now: Date = new Date()): ShadowParityReport => { + return aggregateDiffs(records, now); + }; + + const persistImpl = async (outputDir: string, now: Date = new Date()): Promise => { + await fs.mkdir(outputDir, { recursive: true }); + const report = snapshotImpl(now); + const runId = makeRunId(now); + const payload: PersistedShadowReport = { + schemaVersion: 1, + runId, + generatedAt: now.toISOString(), + primaryByLanguage, + report, + }; + const json = JSON.stringify(payload, null, 2); + const perRunPath = path.join(outputDir, `${runId}.json`); + const latestPath = path.join(outputDir, 'latest.json'); + await fs.writeFile(perRunPath, json, 'utf8'); + await fs.writeFile(latestPath, json, 'utf8'); + return perRunPath; + }; + + const clearImpl = (): void => { + records.length = 0; + for (const key of Object.keys(primaryByLanguage)) { + delete primaryByLanguage[key as SupportedLanguages]; + } + }; + + return { + enabled, + record: recordImpl, + size: () => records.length, + snapshot: snapshotImpl, + persist: persistImpl, + clear: clearImpl, + }; +} + +// ─── Internal helpers ───────────────────────────────────────────────────── + +/** + * Env-var parser for `GITNEXUS_SHADOW_MODE`. Accepts the same truthy + * conventions as `REGISTRY_PRIMARY_` from #924: `'true'` / `'1'` / + * `'yes'`, case-insensitive, whitespace-trimmed. Anything else — including + * `undefined`, `''`, `'false'`, `'off'`, typos — is false. + */ +function parseShadowModeEnv(raw: string | undefined): boolean { + if (raw === undefined) return false; + const normalized = raw.trim().toLowerCase(); + return normalized === 'true' || normalized === '1' || normalized === 'yes'; +} + +/** + * Deterministic run id derived from the timestamp plus 4 random bytes + * of entropy. The timestamp comes first so files sort chronologically; + * the entropy suffix prevents collisions when multiple runs share a + * clock-second. Shape: `YYYYMMDD-HHMMSS-xxxxxxxx`. + */ +function makeRunId(now: Date): string { + const y = now.getUTCFullYear().toString().padStart(4, '0'); + const m = (now.getUTCMonth() + 1).toString().padStart(2, '0'); + const d = now.getUTCDate().toString().padStart(2, '0'); + const h = now.getUTCHours().toString().padStart(2, '0'); + const min = now.getUTCMinutes().toString().padStart(2, '0'); + const s = now.getUTCSeconds().toString().padStart(2, '0'); + const entropy = Math.floor(Math.random() * 0xffffffff) + .toString(16) + .padStart(8, '0'); + return `${y}${m}${d}-${h}${min}${s}-${entropy}`; +} diff --git a/gitnexus/test/unit/scope-resolution/shadow-harness.test.ts b/gitnexus/test/unit/scope-resolution/shadow-harness.test.ts new file mode 100644 index 000000000..c6770c82d --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/shadow-harness.test.ts @@ -0,0 +1,290 @@ +/** + * Unit tests for `shadow-harness` (RFC #909 Ring 2 PKG #923). + * + * Covers flag detection, record accumulation, aggregation, and JSON + * persistence (real fs in a per-test tmpdir). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as fsp from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + EvidenceWeights, + SupportedLanguages, + type Resolution, + type ShadowCallsite, + type SymbolDefinition, +} from 'gitnexus-shared'; +import { + createShadowHarness, + type PersistedShadowReport, + type ShadowHarness, +} from '../../../src/core/ingestion/shadow-harness.js'; + +// ─── Env isolation — GITNEXUS_SHADOW_MODE bleeds between tests otherwise ── + +let savedEnv: string | undefined; +beforeEach(() => { + savedEnv = process.env['GITNEXUS_SHADOW_MODE']; + delete process.env['GITNEXUS_SHADOW_MODE']; +}); +afterEach(() => { + if (savedEnv === undefined) delete process.env['GITNEXUS_SHADOW_MODE']; + else process.env['GITNEXUS_SHADOW_MODE'] = savedEnv; +}); + +// ─── Fixture helpers ────────────────────────────────────────────────────── + +const callsite = (filePath = 'a.ts', line = 1): ShadowCallsite => ({ + filePath, + range: { startLine: line, startCol: 0, endLine: line, endCol: 10 }, +}); + +const def = (nodeId: string): SymbolDefinition => ({ + nodeId, + filePath: 'x.ts', + type: 'Class', +}); + +const resolution = (nodeId: string): Resolution => ({ + def: def(nodeId), + confidence: EvidenceWeights.local, + evidence: [{ kind: 'local', weight: EvidenceWeights.local }], +}); + +function enable(): void { + process.env['GITNEXUS_SHADOW_MODE'] = 'true'; +} + +function freshHarness(): ShadowHarness { + return createShadowHarness(); +} + +// ─── Flag detection ─────────────────────────────────────────────────────── + +describe('createShadowHarness: enabled flag', () => { + it('is disabled by default (no env var set)', () => { + expect(freshHarness().enabled).toBe(false); + }); + + it("is enabled when GITNEXUS_SHADOW_MODE is 'true' / '1' / 'yes' / case-insensitive", () => { + for (const value of ['true', '1', 'yes', 'TRUE', ' Yes ']) { + process.env['GITNEXUS_SHADOW_MODE'] = value; + expect(freshHarness().enabled).toBe(true); + } + }); + + it('stays disabled for falsy-looking or typo values', () => { + for (const value of ['', 'false', '0', 'off', 'tru']) { + process.env['GITNEXUS_SHADOW_MODE'] = value; + expect(freshHarness().enabled).toBe(false); + } + }); + + it('record() is a no-op when disabled', () => { + const h = freshHarness(); // disabled + h.record({ + language: SupportedLanguages.Python, + callsite: callsite(), + legacy: [resolution('def:a')], + newResult: [resolution('def:a')], + primary: 'legacy', + }); + expect(h.size()).toBe(0); + }); + + it('does NOT re-check the env var per call (constructed-once semantics)', () => { + const h = freshHarness(); // disabled at construction + process.env['GITNEXUS_SHADOW_MODE'] = 'true'; // flip AFTER construction + h.record({ + language: SupportedLanguages.Python, + callsite: callsite(), + legacy: [resolution('def:a')], + newResult: [resolution('def:a')], + primary: 'legacy', + }); + // Still disabled — the harness captured its `enabled` at construction. + expect(h.size()).toBe(0); + }); +}); + +// ─── Record + snapshot ──────────────────────────────────────────────────── + +describe('record + snapshot', () => { + it('accumulates records across languages', () => { + enable(); + const h = freshHarness(); + h.record({ + language: SupportedLanguages.Python, + callsite: callsite(), + legacy: [resolution('def:a')], + newResult: [resolution('def:a')], + primary: 'legacy', + }); + h.record({ + language: SupportedLanguages.TypeScript, + callsite: callsite('b.ts'), + legacy: [resolution('def:b')], + newResult: [], + primary: 'registry', + }); + expect(h.size()).toBe(2); + }); + + it('snapshot reports per-language rows with correct outcomes', () => { + enable(); + const h = freshHarness(); + h.record({ + language: SupportedLanguages.Python, + callsite: callsite(), + legacy: [resolution('def:a')], + newResult: [resolution('def:a')], + primary: 'legacy', + }); + h.record({ + language: SupportedLanguages.Python, + callsite: callsite('a.py', 2), + legacy: [resolution('def:b')], + newResult: [], + primary: 'legacy', + }); + const report = h.snapshot(new Date('2026-04-18T00:00:00Z')); + expect(report.perLanguage).toHaveLength(1); + const py = report.perLanguage[0]!; + expect(py.language).toBe(SupportedLanguages.Python); + expect(py.totalCalls).toBe(2); + expect(py.bothAgree).toBe(1); + expect(py.onlyLegacy).toBe(1); + }); + + it('snapshot is deterministic across repeated calls', () => { + enable(); + const h = freshHarness(); + h.record({ + language: SupportedLanguages.Python, + callsite: callsite(), + legacy: [resolution('def:a')], + newResult: [resolution('def:a')], + primary: 'legacy', + }); + const now = new Date('2026-04-18T12:00:00Z'); + const a = h.snapshot(now); + const b = h.snapshot(now); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + }); + + it('clear() resets the accumulator and primaryByLanguage', async () => { + enable(); + const h = freshHarness(); + h.record({ + language: SupportedLanguages.Python, + callsite: callsite(), + legacy: [resolution('def:a')], + newResult: [resolution('def:a')], + primary: 'registry', + }); + expect(h.size()).toBe(1); + h.clear(); + expect(h.size()).toBe(0); + // Verify primary is also cleared: persist after a fresh record with a + // different primary should reflect the new value only. + h.record({ + language: SupportedLanguages.Python, + callsite: callsite('a.py'), + legacy: [resolution('def:a')], + newResult: [resolution('def:a')], + primary: 'legacy', + }); + const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gn-sh-clear-')); + try { + await h.persist(dir); + const payload = JSON.parse(fs.readFileSync(path.join(dir, 'latest.json'), 'utf8')); + expect(payload.primaryByLanguage.python).toBe('legacy'); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } + }); +}); + +// ─── Persistence ────────────────────────────────────────────────────────── + +describe('persist', () => { + let tmpDir: string; + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gn-shadow-harness-')); + }); + afterEach(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }); + }); + + it('creates outputDir if it does not exist', async () => { + enable(); + const h = freshHarness(); + const nested = path.join(tmpDir, 'nested', 'a', 'b'); + await h.persist(nested); + expect(fs.existsSync(nested)).toBe(true); + expect(fs.existsSync(path.join(nested, 'latest.json'))).toBe(true); + }); + + it('writes BOTH a timestamped file and latest.json with the same payload', async () => { + enable(); + const h = freshHarness(); + h.record({ + language: SupportedLanguages.Python, + callsite: callsite(), + legacy: [resolution('def:a')], + newResult: [resolution('def:a')], + primary: 'legacy', + }); + const perRunPath = await h.persist(tmpDir, new Date('2026-04-18T12:34:56Z')); + const latestPath = path.join(tmpDir, 'latest.json'); + + expect(fs.existsSync(perRunPath)).toBe(true); + expect(fs.existsSync(latestPath)).toBe(true); + expect(fs.readFileSync(perRunPath, 'utf8')).toBe(fs.readFileSync(latestPath, 'utf8')); + }); + + it('persisted payload matches the schema v1 shape', async () => { + enable(); + const h = freshHarness(); + h.record({ + language: SupportedLanguages.TypeScript, + callsite: callsite('a.ts'), + legacy: [resolution('def:a')], + newResult: [resolution('def:a')], + primary: 'registry', + }); + const now = new Date('2026-04-18T00:00:00Z'); + await h.persist(tmpDir, now); + const payload: PersistedShadowReport = JSON.parse( + fs.readFileSync(path.join(tmpDir, 'latest.json'), 'utf8'), + ); + expect(payload.schemaVersion).toBe(1); + expect(payload.runId).toMatch(/^\d{8}-\d{6}-[0-9a-f]{8}$/); + expect(payload.generatedAt).toBe('2026-04-18T00:00:00.000Z'); + expect(payload.primaryByLanguage.typescript).toBe('registry'); + expect(payload.report.overall.totalCalls).toBe(1); + expect(payload.report.overall.bothAgree).toBe(1); + }); + + it('runId embeds the run timestamp for chronological sorting', async () => { + enable(); + const h1 = freshHarness(); + const h2 = freshHarness(); + const p1 = await h1.persist(tmpDir, new Date('2026-04-18T00:00:00Z')); + const p2 = await h2.persist(tmpDir, new Date('2026-04-18T01:00:00Z')); + // Timestamp prefix means the second file sorts after the first. + expect(path.basename(p2) > path.basename(p1)).toBe(true); + }); + + it('persists an empty report gracefully (no records, no error)', async () => { + enable(); + const h = freshHarness(); + await h.persist(tmpDir); + const payload = JSON.parse(fs.readFileSync(path.join(tmpDir, 'latest.json'), 'utf8')); + expect(payload.report.overall.totalCalls).toBe(0); + expect(payload.report.perLanguage).toEqual([]); + }); +}); From 6222b5be9bbb06f31e0d19a2ed2b994867cf5aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 23:36:10 +0100 Subject: [PATCH 39/46] feat(ingestion): emit-references drains ReferenceIndex to graph edges (#925, RFC #909 Ring 2 PKG) (#973) --- gitnexus-shared/src/graph/types.ts | 16 + .../src/core/ingestion/emit-references.ts | 299 +++++++++++ .../scope-resolution/emit-references.test.ts | 495 ++++++++++++++++++ 3 files changed, 810 insertions(+) create mode 100644 gitnexus/src/core/ingestion/emit-references.ts create mode 100644 gitnexus/test/unit/scope-resolution/emit-references.test.ts diff --git a/gitnexus-shared/src/graph/types.ts b/gitnexus-shared/src/graph/types.ts index 49762d145..d3dc81625 100644 --- a/gitnexus-shared/src/graph/types.ts +++ b/gitnexus-shared/src/graph/types.ts @@ -131,4 +131,20 @@ export interface GraphRelationship { confidence: number; reason: string; step?: number; + /** + * Per-signal evidence trace for edges emitted by the scope-based + * resolution pipeline (RFC #909 Ring 2 PKG #925). Populated by + * `emit-references.ts` when draining `ReferenceIndex` into the graph + * so downstream query / audit tools can inspect *why* a given edge + * was emitted with its confidence value. + * + * Optional and additive — every existing edge emitter ignores this + * field, and every existing query continues to work whether or not + * an edge carries it. + */ + evidence?: readonly { + readonly kind: string; + readonly weight: number; + readonly note?: string; + }[]; } diff --git a/gitnexus/src/core/ingestion/emit-references.ts b/gitnexus/src/core/ingestion/emit-references.ts new file mode 100644 index 000000000..1d2c4db5b --- /dev/null +++ b/gitnexus/src/core/ingestion/emit-references.ts @@ -0,0 +1,299 @@ +/** + * Phase 5 of the RFC #909 ingestion lifecycle: drain `ReferenceIndex` + * into the knowledge graph as labeled edges with `confidence` and + * `evidence` properties (Ring 2 PKG #925). + * + * The resolution phase (future PR) writes `Reference` records into + * `model.scopes.referenceSites`-derived `ReferenceIndex`; this module + * materializes those records as `GraphRelationship`s via + * `graph.addRelationship`. Every emitted edge carries: + * + * - `type`: one of `'CALLS' | 'ACCESSES' | 'INHERITS' | 'USES'` + * (mapped from `Reference.kind` — `'read'` and `'write'` both route + * to `ACCESSES`; `'type-reference'` and `'import-use'` route to + * `USES`; `'call'` stays `CALLS`; `'inherits'` stays `INHERITS`). + * - `confidence`: the pre-computed confidence from the Reference record. + * - `reason`: human-readable summary (`"scope-resolution: call | confidence 0.75"`). + * - `evidence`: the full `ResolutionEvidence[]` trace — additive graph + * property (see `GraphRelationship.evidence` in gitnexus-shared), + * so queries that don't know about it are unaffected. + * - `step`: carries the reference's access-kind discriminant when + * available (`1` for read, `2` for write) so `ACCESSES` edges retain + * the read/write distinction without forcing a new edge type. + * + * ## Optional scope-tree flush + * + * When `INGESTION_EMIT_SCOPES=1` is set, this module also emits: + * + * - `Scope` nodes for every `Scope` in the tree + * - `CONTAINS` edges from parent scope to child scope + * - `DEFINES` edges from scope to its `ownedDefs` members + * - `IMPORTS` edges from scope to `targetModuleScope` of each finalized + * `ImportEdge` that carries one + * + * Off by default — existing queries that don't know about `Scope` nodes + * continue to work, and the storage cost is opt-in. + * + * ## Source-of-truth: the caller def for a reference + * + * A `Reference` says "some code inside `fromScope` references `toDef`". + * The graph wants `(callerNodeId, calleeNodeId)`. We resolve the caller + * by walking up the scope tree from `fromScope` until we find a scope + * whose `ownedDefs` contains a Function-like def. If no such ancestor + * exists, the edge is attributed to the first def owned by the innermost + * ancestor scope, and if THAT produces nothing either the edge is + * skipped (with a count returned in `EmitStats.skippedNoCaller`). + */ + +import type { + NodeLabel, + RelationshipType, + Reference, + ReferenceIndex, + ResolutionEvidence, + Scope, + ScopeId, + SymbolDefinition, +} from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../graph/types.js'; +import type { ScopeResolutionIndexes } from './model/scope-resolution-indexes.js'; + +// ─── Public API ───────────────────────────────────────────────────────────── + +export interface EmitStats { + readonly edgesEmitted: number; + /** References dropped because no caller def could be resolved. */ + readonly skippedNoCaller: number; + /** References dropped because `toDef` was not found in the DefIndex. */ + readonly skippedMissingTarget: number; + /** Scope nodes emitted — `0` unless `INGESTION_EMIT_SCOPES=1`. */ + readonly scopeNodesEmitted: number; + /** Scope-tree structural edges emitted — `0` unless `INGESTION_EMIT_SCOPES=1`. */ + readonly scopeEdgesEmitted: number; +} + +export interface EmitReferencesInput { + readonly graph: KnowledgeGraph; + readonly scopes: ScopeResolutionIndexes; + readonly referenceIndex: ReferenceIndex; + /** Human-consumable label for the `reason` prefix. Defaults to `'scope-resolution'`. */ + readonly sourceLabel?: string; +} + +/** + * Drain `referenceIndex.bySourceScope` into graph edges. + * + * The scope-tree flush is controlled separately by + * `INGESTION_EMIT_SCOPES` — callers can run `emitReferencesToGraph` + * without scope-node emission or layer the two calls as needed. + */ +export function emitReferencesToGraph(input: EmitReferencesInput): EmitStats { + const { graph, scopes, referenceIndex } = input; + const sourceLabel = input.sourceLabel ?? 'scope-resolution'; + + let edgesEmitted = 0; + let skippedNoCaller = 0; + let skippedMissingTarget = 0; + + for (const [fromScope, refs] of referenceIndex.bySourceScope) { + for (const ref of refs) { + const targetDef = scopes.defs.get(ref.toDef); + if (targetDef === undefined) { + skippedMissingTarget++; + continue; + } + const callerId = resolveCallerNodeId(fromScope, scopes); + if (callerId === undefined) { + skippedNoCaller++; + continue; + } + graph.addRelationship(buildRelationship(ref, callerId, targetDef, sourceLabel)); + edgesEmitted++; + } + } + + const scopeStats = isScopeEmissionEnabled() + ? emitScopeGraph({ graph, scopes }) + : { scopeNodesEmitted: 0, scopeEdgesEmitted: 0 }; + + return { edgesEmitted, skippedNoCaller, skippedMissingTarget, ...scopeStats }; +} + +/** + * Emit `Scope` nodes + `CONTAINS`/`DEFINES`/`IMPORTS` edges representing + * the lexical scope tree itself. Skipped unless `INGESTION_EMIT_SCOPES=1` + * at the public entry point; exported here for tests that want to + * exercise the path directly. + */ +export function emitScopeGraph(input: { + readonly graph: KnowledgeGraph; + readonly scopes: ScopeResolutionIndexes; +}): { readonly scopeNodesEmitted: number; readonly scopeEdgesEmitted: number } { + const { graph, scopes } = input; + let scopeNodesEmitted = 0; + let scopeEdgesEmitted = 0; + + for (const scope of scopes.scopeTree.byId.values()) { + graph.addNode({ + id: scope.id, + label: 'CodeElement' as NodeLabel, // the generic bucket for non-symbol graph nodes + properties: { + name: scope.kind, + filePath: scope.filePath, + startLine: scope.range.startLine, + endLine: scope.range.endLine, + description: `Scope: ${scope.kind}`, + } as unknown as Parameters[0]['properties'], + }); + scopeNodesEmitted++; + + if (scope.parent !== null) { + graph.addRelationship({ + id: `rel:contains:${scope.parent}->${scope.id}`, + sourceId: scope.parent, + targetId: scope.id, + type: 'CONTAINS', + confidence: 1, + reason: 'scope-tree parent/child', + }); + scopeEdgesEmitted++; + } + + for (const def of scope.ownedDefs) { + graph.addRelationship({ + id: `rel:defines:${scope.id}->${def.nodeId}`, + sourceId: scope.id, + targetId: def.nodeId, + type: 'DEFINES', + confidence: 1, + reason: 'scope.ownedDefs', + }); + scopeEdgesEmitted++; + } + } + + for (const [scopeId, edges] of scopes.imports) { + for (const edge of edges) { + if (edge.targetModuleScope === undefined) continue; + graph.addRelationship({ + id: `rel:imports:${scopeId}->${edge.targetModuleScope}:${edge.localName}`, + sourceId: scopeId, + targetId: edge.targetModuleScope, + type: 'IMPORTS', + confidence: edge.linkStatus === 'unresolved' ? 0.5 : 1, + reason: `import ${edge.kind} ${edge.localName}`, + }); + scopeEdgesEmitted++; + } + } + + return { scopeNodesEmitted, scopeEdgesEmitted }; +} + +// ─── Internal ─────────────────────────────────────────────────────────────── + +/** Accepted truthy values for `INGESTION_EMIT_SCOPES`. */ +const TRUTHY: ReadonlySet = new Set(['true', '1', 'yes']); + +function isScopeEmissionEnabled(): boolean { + const raw = process.env['INGESTION_EMIT_SCOPES']; + if (raw === undefined) return false; + return TRUTHY.has(raw.trim().toLowerCase()); +} + +/** + * Walk up from `startScope` looking for the first ancestor scope whose + * `ownedDefs` contains a Function-like def (Function / Method / + * Constructor). Fall back to the innermost ancestor's first `ownedDef` + * if none is found; return `undefined` if all ancestors have no defs. + */ +function resolveCallerNodeId( + startScope: ScopeId, + scopes: ScopeResolutionIndexes, +): string | undefined { + const tree = scopes.scopeTree; + let current: ScopeId | null = startScope; + const visited = new Set(); + let firstOwnedFallback: string | undefined; + + while (current !== null) { + if (visited.has(current)) break; + visited.add(current); + + const scope: Scope | undefined = tree.getScope(current); + if (scope === undefined) break; + + // Prefer a Function-like owner. + const fnDef = scope.ownedDefs.find((d) => isFunctionLike(d.type)); + if (fnDef !== undefined) return fnDef.nodeId; + + // Stash the first owned def we see as a conservative fallback. + if (firstOwnedFallback === undefined && scope.ownedDefs.length > 0) { + firstOwnedFallback = scope.ownedDefs[0]!.nodeId; + } + + current = scope.parent; + } + + return firstOwnedFallback; +} + +function isFunctionLike(type: NodeLabel): boolean { + return type === 'Function' || type === 'Method' || type === 'Constructor'; +} + +function buildRelationship( + ref: Reference, + callerId: string, + targetDef: SymbolDefinition, + sourceLabel: string, +): Parameters[0] { + const type = mapKindToType(ref.kind); + const reason = `${sourceLabel}: ${ref.kind} | confidence ${ref.confidence.toFixed(3)}`; + // `step` encodes read/write discriminator for ACCESSES edges (1=read, 2=write). + // Other kinds omit `step`. + const step = ref.kind === 'read' ? 1 : ref.kind === 'write' ? 2 : undefined; + return { + id: `rel:${type}:${callerId}->${targetDef.nodeId}:${ref.atRange.startLine}:${ref.atRange.startCol}`, + sourceId: callerId, + targetId: targetDef.nodeId, + type, + confidence: ref.confidence, + reason, + evidence: ref.evidence.map(serializeEvidence), + ...(step !== undefined ? { step } : {}), + }; +} + +/** + * Map a `Reference.kind` to an existing `RelationshipType`. Read/write + * both fold into `ACCESSES`; `type-reference` + `import-use` both fold + * into `USES`. This keeps the graph schema additive — no new + * RelationshipType values are introduced by this module. + */ +function mapKindToType(kind: Reference['kind']): RelationshipType { + switch (kind) { + case 'call': + return 'CALLS'; + case 'read': + case 'write': + return 'ACCESSES'; + case 'inherits': + return 'INHERITS'; + case 'type-reference': + case 'import-use': + return 'USES'; + } +} + +function serializeEvidence(e: ResolutionEvidence): { + readonly kind: string; + readonly weight: number; + readonly note?: string; +} { + return { + kind: e.kind, + weight: e.weight, + ...(e.note !== undefined ? { note: e.note } : {}), + }; +} diff --git a/gitnexus/test/unit/scope-resolution/emit-references.test.ts b/gitnexus/test/unit/scope-resolution/emit-references.test.ts new file mode 100644 index 000000000..c88dc998e --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/emit-references.test.ts @@ -0,0 +1,495 @@ +/** + * Unit tests for `emit-references` (RFC #909 Ring 2 PKG #925). + * + * Covers kind → RelationshipType mapping, enclosing-def resolution + * through the scope tree, evidence serialization onto emitted edges, + * skip counts, and the optional `INGESTION_EMIT_SCOPES` scope-node + * flush. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + buildDefIndex, + buildMethodDispatchIndex, + buildModuleScopeIndex, + buildQualifiedNameIndex, + buildScopeTree, + type BindingRef, + type DefId, + type Range, + type Reference, + type ReferenceIndex, + type Scope, + type ScopeId, + type SymbolDefinition, +} from 'gitnexus-shared'; +import { createKnowledgeGraph } from '../../../src/core/graph/graph.js'; +import { + emitReferencesToGraph, + emitScopeGraph, +} from '../../../src/core/ingestion/emit-references.js'; +import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js'; + +// ─── Env isolation ──────────────────────────────────────────────────────── + +let savedEnv: string | undefined; +beforeEach(() => { + savedEnv = process.env['INGESTION_EMIT_SCOPES']; + delete process.env['INGESTION_EMIT_SCOPES']; +}); +afterEach(() => { + if (savedEnv === undefined) delete process.env['INGESTION_EMIT_SCOPES']; + else process.env['INGESTION_EMIT_SCOPES'] = savedEnv; +}); + +// ─── Fixture builders ───────────────────────────────────────────────────── + +const range = (sl = 1, sc = 0, el = 100, ec = 0): Range => ({ + startLine: sl, + startCol: sc, + endLine: el, + endCol: ec, +}); + +const def = ( + nodeId: string, + type: SymbolDefinition['type'] = 'Method', + qname?: string, +): SymbolDefinition => ({ + nodeId, + filePath: 'x.ts', + type, + ...(qname !== undefined ? { qualifiedName: qname } : {}), +}); + +const scope = ( + id: ScopeId, + parent: ScopeId | null, + kind: Scope['kind'], + ownedDefs: readonly SymbolDefinition[] = [], + r: Range = range(), + filePath = 'x.ts', + bindings: Record = {}, +): Scope => ({ + id, + parent, + kind, + range: r, + filePath, + bindings: new Map(Object.entries(bindings)), + ownedDefs, + imports: [], + typeBindings: new Map(), +}); + +function makeIndexes(scopes: Scope[], allDefs: SymbolDefinition[]): ScopeResolutionIndexes { + return { + scopeTree: buildScopeTree(scopes), + defs: buildDefIndex(allDefs), + qualifiedNames: buildQualifiedNameIndex(allDefs), + moduleScopes: buildModuleScopeIndex( + scopes + .filter((s) => s.kind === 'Module') + .map((s) => ({ filePath: s.filePath, moduleScopeId: s.id })), + ), + methodDispatch: buildMethodDispatchIndex({ + owners: [], + computeMro: () => [], + implementsOf: () => [], + }), + imports: new Map(), + bindings: new Map(), + referenceSites: [], + sccs: [], + stats: { + totalFiles: 0, + totalEdges: 0, + linkedEdges: 0, + unresolvedEdges: 0, + sccCount: 0, + largestSccSize: 0, + }, + }; +} + +function buildRefIndex(sourceScope: ScopeId, refs: readonly Reference[]): ReferenceIndex { + const bySource = new Map(); + bySource.set(sourceScope, refs); + const byTarget = new Map(); + for (const ref of refs) { + const bucket = byTarget.get(ref.toDef) ?? []; + bucket.push(ref); + byTarget.set(ref.toDef, bucket); + } + return { + bySourceScope: bySource, + byTargetDef: new Map( + Array.from(byTarget.entries()).map(([k, v]) => [k, Object.freeze([...v])]), + ), + }; +} + +// ─── Kind mapping + basic emission ──────────────────────────────────────── + +describe('emitReferencesToGraph: kind mapping', () => { + it('maps call → CALLS and carries confidence + evidence onto the edge', () => { + const callerFn = def('def:saveUser', 'Function', 'saveUser'); + const targetFn = def('def:User.save', 'Method', 'User.save'); + const mod = scope('scope:m', null, 'Module', [callerFn, targetFn]); + const indexes = makeIndexes([mod], [callerFn, targetFn]); + + const ref: Reference = { + fromScope: 'scope:m', + toDef: 'def:User.save', + atRange: range(10, 4, 10, 8), + kind: 'call', + confidence: 0.75, + evidence: [ + { kind: 'local', weight: 0.55 }, + { kind: 'arity-match', weight: 0.1, note: 'compatible' }, + ], + }; + + const graph = createKnowledgeGraph(); + const stats = emitReferencesToGraph({ + graph, + scopes: indexes, + referenceIndex: buildRefIndex('scope:m', [ref]), + }); + + expect(stats.edgesEmitted).toBe(1); + expect(graph.relationships).toHaveLength(1); + const edge = graph.relationships[0]!; + expect(edge.type).toBe('CALLS'); + expect(edge.sourceId).toBe('def:saveUser'); + expect(edge.targetId).toBe('def:User.save'); + expect(edge.confidence).toBe(0.75); + expect(edge.evidence).toEqual([ + { kind: 'local', weight: 0.55 }, + { kind: 'arity-match', weight: 0.1, note: 'compatible' }, + ]); + expect(edge.reason).toContain('call'); + expect(edge.reason).toContain('0.750'); + }); + + it('maps read / write → ACCESSES and stamps step=1 / step=2 for discrimination', () => { + const fn = def('def:render', 'Function'); + const field = def('def:User.name', 'Property'); + const mod = scope('scope:m', null, 'Module', [fn, field]); + const indexes = makeIndexes([mod], [fn, field]); + + const readRef: Reference = { + fromScope: 'scope:m', + toDef: 'def:User.name', + atRange: range(5, 0, 5, 4), + kind: 'read', + confidence: 0.55, + evidence: [{ kind: 'local', weight: 0.55 }], + }; + const writeRef: Reference = { + fromScope: 'scope:m', + toDef: 'def:User.name', + atRange: range(6, 0, 6, 4), + kind: 'write', + confidence: 0.55, + evidence: [{ kind: 'local', weight: 0.55 }], + }; + + const graph = createKnowledgeGraph(); + emitReferencesToGraph({ + graph, + scopes: indexes, + referenceIndex: buildRefIndex('scope:m', [readRef, writeRef]), + }); + + const edges = graph.relationships; + expect(edges).toHaveLength(2); + expect(edges.every((e) => e.type === 'ACCESSES')).toBe(true); + const readEdge = edges.find((e) => e.step === 1)!; + const writeEdge = edges.find((e) => e.step === 2)!; + expect(readEdge).toBeDefined(); + expect(writeEdge).toBeDefined(); + }); + + it('maps inherits → INHERITS and type-reference/import-use → USES', () => { + const hostFn = def('def:host', 'Function'); + const base = def('def:Base', 'Class'); + const mixin = def('def:Mixin', 'Class'); + const module = def('def:SomeModule', 'Namespace'); + const mod = scope('scope:m', null, 'Module', [hostFn, base, mixin, module]); + const indexes = makeIndexes([mod], [hostFn, base, mixin, module]); + + const refs: Reference[] = [ + { + fromScope: 'scope:m', + toDef: 'def:Base', + atRange: range(1, 0, 1, 4), + kind: 'inherits', + confidence: 0.9, + evidence: [], + }, + { + fromScope: 'scope:m', + toDef: 'def:Mixin', + atRange: range(2, 0, 2, 4), + kind: 'type-reference', + confidence: 0.7, + evidence: [], + }, + { + fromScope: 'scope:m', + toDef: 'def:SomeModule', + atRange: range(3, 0, 3, 4), + kind: 'import-use', + confidence: 0.5, + evidence: [], + }, + ]; + + const graph = createKnowledgeGraph(); + emitReferencesToGraph({ + graph, + scopes: indexes, + referenceIndex: buildRefIndex('scope:m', refs), + }); + + const types = graph.relationships.map((r) => r.type).sort(); + expect(types).toEqual(['INHERITS', 'USES', 'USES']); + }); +}); + +// ─── Enclosing-def resolution ───────────────────────────────────────────── + +describe('enclosing-def resolution', () => { + it('uses the innermost Function/Method ancestor as the caller', () => { + const method = def('def:User.save', 'Method'); + const classScope = scope('scope:c', 'scope:m', 'Class', [], range(5, 0, 40, 0)); + const methodScope = scope('scope:f', 'scope:c', 'Function', [method], range(10, 0, 30, 0)); + const mod = scope('scope:m', null, 'Module', [], range(1, 0, 100, 0)); + const target = def('def:Logger.log', 'Method'); + const indexes = makeIndexes([mod, classScope, methodScope], [method, target]); + + // Reference fires from a block inside the method scope. + const ref: Reference = { + fromScope: 'scope:f', + toDef: 'def:Logger.log', + atRange: range(20, 4, 20, 8), + kind: 'call', + confidence: 0.75, + evidence: [], + }; + + const graph = createKnowledgeGraph(); + emitReferencesToGraph({ + graph, + scopes: indexes, + referenceIndex: buildRefIndex('scope:f', [ref]), + }); + + expect(graph.relationships[0]!.sourceId).toBe('def:User.save'); + }); + + it('walks up to the parent Class if the immediate scope has no Function def', () => { + const classDef = def('def:User', 'Class'); + const targetFn = def('def:Logger.log', 'Method'); + const mod = scope('scope:m', null, 'Module', [], range(1, 0, 100, 0)); + // Class scope owns the Class def but no Function/Method; fallback + // walks into the Class's owned defs. + const classScope = scope('scope:c', 'scope:m', 'Class', [classDef], range(5, 0, 50, 0)); + const indexes = makeIndexes([mod, classScope], [classDef, targetFn]); + + const ref: Reference = { + fromScope: 'scope:c', + toDef: 'def:Logger.log', + atRange: range(7, 0, 7, 4), + kind: 'call', + confidence: 0.55, + evidence: [], + }; + + const graph = createKnowledgeGraph(); + emitReferencesToGraph({ + graph, + scopes: indexes, + referenceIndex: buildRefIndex('scope:c', [ref]), + }); + + // No Function ancestor — falls back to the first owned def: the class itself. + expect(graph.relationships[0]!.sourceId).toBe('def:User'); + }); + + it('increments skippedNoCaller when no ancestor has any owned defs', () => { + // Module scope is empty; the lone child scope references something + // but neither it nor its ancestors own anything. + const target = def('def:someClass', 'Class'); + const mod = scope('scope:m', null, 'Module', [], range(1, 0, 100, 0)); + const child = scope('scope:c', 'scope:m', 'Function', [], range(5, 0, 10, 0)); + const indexes = makeIndexes([mod, child], [target]); + + const ref: Reference = { + fromScope: 'scope:c', + toDef: 'def:someClass', + atRange: range(7, 0, 7, 4), + kind: 'type-reference', + confidence: 0.3, + evidence: [], + }; + + const graph = createKnowledgeGraph(); + const stats = emitReferencesToGraph({ + graph, + scopes: indexes, + referenceIndex: buildRefIndex('scope:c', [ref]), + }); + + expect(stats.edgesEmitted).toBe(0); + expect(stats.skippedNoCaller).toBe(1); + expect(graph.relationships).toHaveLength(0); + }); +}); + +// ─── Missing target ────────────────────────────────────────────────────── + +describe('missing target', () => { + it('skips references whose toDef is not in the DefIndex', () => { + const callerFn = def('def:caller', 'Function'); + const mod = scope('scope:m', null, 'Module', [callerFn]); + const indexes = makeIndexes([mod], [callerFn]); // target def missing + + const ref: Reference = { + fromScope: 'scope:m', + toDef: 'def:ghost', + atRange: range(5, 0, 5, 4), + kind: 'call', + confidence: 0.3, + evidence: [], + }; + const graph = createKnowledgeGraph(); + const stats = emitReferencesToGraph({ + graph, + scopes: indexes, + referenceIndex: buildRefIndex('scope:m', [ref]), + }); + expect(stats.edgesEmitted).toBe(0); + expect(stats.skippedMissingTarget).toBe(1); + expect(graph.relationships).toHaveLength(0); + }); +}); + +// ─── Scope-graph emission (INGESTION_EMIT_SCOPES) ───────────────────────── + +describe('scope-graph emission', () => { + it('stays off by default — no scope nodes emitted', () => { + const callerFn = def('def:caller', 'Function'); + const targetFn = def('def:target', 'Method'); + const mod = scope('scope:m', null, 'Module', [callerFn, targetFn]); + const indexes = makeIndexes([mod], [callerFn, targetFn]); + + const ref: Reference = { + fromScope: 'scope:m', + toDef: 'def:target', + atRange: range(5, 0, 5, 4), + kind: 'call', + confidence: 0.5, + evidence: [], + }; + const graph = createKnowledgeGraph(); + const stats = emitReferencesToGraph({ + graph, + scopes: indexes, + referenceIndex: buildRefIndex('scope:m', [ref]), + }); + expect(stats.scopeNodesEmitted).toBe(0); + expect(stats.scopeEdgesEmitted).toBe(0); + // No scope nodes in the graph either. + expect(graph.nodes.filter((n) => n.id.startsWith('scope:')).length).toBe(0); + }); + + it('emits Scope nodes + CONTAINS + DEFINES when INGESTION_EMIT_SCOPES=1', () => { + process.env['INGESTION_EMIT_SCOPES'] = '1'; + const fn = def('def:fn', 'Function'); + const childScope = scope('scope:f', 'scope:m', 'Function', [fn], range(5, 0, 10, 0)); + const mod = scope('scope:m', null, 'Module', [], range(1, 0, 100, 0)); + const indexes = makeIndexes([mod, childScope], [fn]); + + const graph = createKnowledgeGraph(); + const stats = emitReferencesToGraph({ + graph, + scopes: indexes, + referenceIndex: buildRefIndex('scope:f', []), + }); + + expect(stats.scopeNodesEmitted).toBe(2); // module + function scope + // 1 CONTAINS (module→function) + 1 DEFINES (function→fn def) = 2 + expect(stats.scopeEdgesEmitted).toBe(2); + const containsEdge = graph.relationships.find((e) => e.type === 'CONTAINS'); + const definesEdge = graph.relationships.find((e) => e.type === 'DEFINES'); + expect(containsEdge).toBeDefined(); + expect(containsEdge!.sourceId).toBe('scope:m'); + expect(containsEdge!.targetId).toBe('scope:f'); + expect(definesEdge).toBeDefined(); + expect(definesEdge!.targetId).toBe('def:fn'); + }); + + it("treats 'true', 'yes' (case-insensitive) as enabled; anything else as disabled", () => { + const fn = def('def:fn', 'Function'); + const mod = scope('scope:m', null, 'Module', [fn]); + const indexes = makeIndexes([mod], [fn]); + + for (const value of ['true', 'TRUE', 'yes', '1']) { + process.env['INGESTION_EMIT_SCOPES'] = value; + const g = createKnowledgeGraph(); + const stats = emitReferencesToGraph({ + graph: g, + scopes: indexes, + referenceIndex: buildRefIndex('scope:m', []), + }); + expect(stats.scopeNodesEmitted).toBeGreaterThan(0); + } + for (const value of ['false', '0', '', 'off', 'tru']) { + process.env['INGESTION_EMIT_SCOPES'] = value; + const g = createKnowledgeGraph(); + const stats = emitReferencesToGraph({ + graph: g, + scopes: indexes, + referenceIndex: buildRefIndex('scope:m', []), + }); + expect(stats.scopeNodesEmitted).toBe(0); + } + }); + + it('emitScopeGraph can be called directly (bypasses env flag)', () => { + const fn = def('def:fn', 'Function'); + const mod = scope('scope:m', null, 'Module', [fn]); + const indexes = makeIndexes([mod], [fn]); + + const graph = createKnowledgeGraph(); + const stats = emitScopeGraph({ graph, scopes: indexes }); + expect(stats.scopeNodesEmitted).toBe(1); + expect(stats.scopeEdgesEmitted).toBe(1); // only the DEFINES edge; no parent scope + }); +}); + +// ─── Empty input ────────────────────────────────────────────────────────── + +describe('empty input', () => { + it('returns zeroed stats and mutates nothing when ReferenceIndex is empty', () => { + const mod = scope('scope:m', null, 'Module', []); + const indexes = makeIndexes([mod], []); + const graph = createKnowledgeGraph(); + const stats = emitReferencesToGraph({ + graph, + scopes: indexes, + referenceIndex: { bySourceScope: new Map(), byTargetDef: new Map() }, + }); + expect(stats).toEqual({ + edgesEmitted: 0, + skippedNoCaller: 0, + skippedMissingTarget: 0, + scopeNodesEmitted: 0, + scopeEdgesEmitted: 0, + }); + expect(graph.nodes).toHaveLength(0); + expect(graph.relationships).toHaveLength(0); + }); +}); From 363245eb6334c72ea55151eecb51ce5c0d183f24 Mon Sep 17 00:00:00 2001 From: Ryanba <92616678+Gujiassh@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:10:36 +0800 Subject: [PATCH 40/46] fix: detect React component paths before lowercasing (#260) --- gitnexus/src/core/ingestion/framework-detection.ts | 8 ++++++-- gitnexus/test/unit/framework-detection.test.ts | 9 ++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/gitnexus/src/core/ingestion/framework-detection.ts b/gitnexus/src/core/ingestion/framework-detection.ts index 9ea43800c..739f22967 100644 --- a/gitnexus/src/core/ingestion/framework-detection.ts +++ b/gitnexus/src/core/ingestion/framework-detection.ts @@ -34,10 +34,14 @@ export interface FrameworkHint { */ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null { // Normalize path separators and ensure leading slash for consistent matching - let p = filePath.toLowerCase().replace(/\\/g, '/'); + const originalPath = filePath.replace(/\\/g, '/'); + let p = originalPath.toLowerCase(); if (!p.startsWith('/')) { p = '/' + p; // Add leading slash so patterns like '/app/' match 'app/...' } + const originalPathWithLeadingSlash = originalPath.startsWith('/') + ? originalPath + : `/${originalPath}`; // ========== JAVASCRIPT / TYPESCRIPT FRAMEWORKS ========== @@ -128,7 +132,7 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null (p.endsWith('.tsx') || p.endsWith('.jsx')) ) { // Only boost if PascalCase filename (likely a component, not util) - const fileName = p.split('/').pop() || ''; + const fileName = originalPathWithLeadingSlash.split('/').pop() || ''; if (/^[A-Z]/.test(fileName)) { return { framework: 'react', entryPointMultiplier: 1.5, reason: 'react-component' }; } diff --git a/gitnexus/test/unit/framework-detection.test.ts b/gitnexus/test/unit/framework-detection.test.ts index b07580044..ce9d76f10 100644 --- a/gitnexus/test/unit/framework-detection.test.ts +++ b/gitnexus/test/unit/framework-detection.test.ts @@ -90,12 +90,11 @@ describe('detectFrameworkFromPath', () => { describe('React', () => { it('has React component detection rule for views/components folders', () => { - // Note: The current implementation lowercases the path before checking - // PascalCase, so PascalCase detection currently can't match. - // This test documents the current behavior. const result = detectFrameworkFromPath('views/Button.tsx'); - // Returns null because path is lowercased before PascalCase regex check - expect(result).toBeNull(); + expect(result).not.toBeNull(); + expect(result!.framework).toBe('react'); + expect(result!.entryPointMultiplier).toBe(1.5); + expect(result!.reason).toBe('react-component'); }); }); From dae7bd3b3fd387b0c1370a0ce65907710c1d314e Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Sun, 19 Apr 2026 07:23:48 +0100 Subject: [PATCH 41/46] feat(cli): analyze --name + duplicate-name guard for the repo registry (#955) --- gitnexus/src/cli/analyze.ts | 46 ++++- gitnexus/src/cli/index.ts | 10 ++ gitnexus/src/cli/list.ts | 13 +- gitnexus/src/core/run-analyze.ts | 31 +++- gitnexus/src/mcp/local/local-backend.ts | 22 ++- gitnexus/src/storage/repo-manager.ts | 116 ++++++++++++- gitnexus/test/integration/cli-e2e.test.ts | 201 ++++++++++++++++++++++ gitnexus/test/unit/repo-manager.test.ts | 138 +++++++++++++++ 8 files changed, 564 insertions(+), 13 deletions(-) diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 1e75ea675..46cedc434 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -13,7 +13,11 @@ import { execFileSync } from 'child_process'; import v8 from 'v8'; import cliProgress from 'cli-progress'; import { closeLbug } from '../core/lbug/lbug-adapter.js'; -import { getStoragePaths, getGlobalRegistryPath } from '../storage/repo-manager.js'; +import { + getStoragePaths, + getGlobalRegistryPath, + RegistryNameCollisionError, +} from '../storage/repo-manager.js'; import { getGitRoot, hasGitDir } from '../storage/git.js'; import { runFullAnalysis } from '../core/run-analyze.js'; import fs from 'fs/promises'; @@ -59,6 +63,21 @@ export interface AnalyzeOptions { noStats?: boolean; /** Index the folder even when no .git directory is present. */ skipGit?: boolean; + /** + * Override the default basename-derived registry `name` with a + * user-supplied alias (#829). Disambiguates repos whose paths share a + * basename. Persisted — subsequent re-analyses of the same path without + * `--name` preserve the alias. + */ + name?: string; + /** + * Allow registration even when another path already uses the same + * `--name` alias (#829). Intentionally a distinct flag from `--force` + * because the user may want to coexist under the same name WITHOUT + * paying the cost of a pipeline re-index. Maps to registerRepo's + * `allowDuplicateName` option end-to-end. + */ + allowDuplicateName?: boolean; } export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => { @@ -186,11 +205,20 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption const result = await runFullAnalysis( repoPath, { + // Pipeline re-index — OR'd with --skills because skill generation + // needs a fresh pipelineResult. Has no bearing on the registry + // collision guard (see allowDuplicateName below). force: options?.force || options?.skills, embeddings: options?.embeddings, skipGit: options?.skipGit, skipAgentsMd: options?.skipAgentsMd, noStats: options?.noStats, + registryName: options?.name, + // Registry-collision bypass — its own CLI flag, intentionally NOT + // overloading --force. A user who hits the collision guard should + // be able to accept the duplicate name without also paying the + // cost of a full pipeline re-index. See #829 review round 2. + allowDuplicateName: options?.allowDuplicateName, }, { onProgress: (_phase, percent, message) => { @@ -298,6 +326,22 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption bar.stop(); const msg = err.message || String(err); + + // Registry name-collision from --name (#829) — surface as an + // actionable error rather than a generic stack-trace. + if (err instanceof RegistryNameCollisionError) { + console.error(`\n Registry name collision:\n`); + console.error(` "${err.registryName}" is already used by "${err.existingPath}".\n`); + console.error(` Options:`); + console.error(` • Pick a different alias: gitnexus analyze --name `); + console.error( + ` • Allow the duplicate: gitnexus analyze --allow-duplicate-name (leaves "-r ${err.registryName}" ambiguous)`, + ); + console.error(''); + process.exitCode = 1; + return; + } + console.error(`\n Analysis failed: ${msg}\n`); // Provide helpful guidance for known failure modes diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 02581ae47..dca5983e0 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -28,6 +28,16 @@ program .option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md') .option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md') .option('--skip-git', 'Index a folder without requiring a .git directory') + .option( + '--name ', + 'Register this repo under a custom name in ~/.gitnexus/registry.json ' + + '(disambiguates repos whose paths share a basename, e.g. two different .../app folders)', + ) + .option( + '--allow-duplicate-name', + 'Register this repo even if another path already uses the same --name alias. ' + + 'Leaves `-r ` ambiguous for the two paths; use -r to disambiguate.', + ) .option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)') .addHelpText( 'after', diff --git a/gitnexus/src/cli/list.ts b/gitnexus/src/cli/list.ts index 722c8ad59..5da9a86f0 100644 --- a/gitnexus/src/cli/list.ts +++ b/gitnexus/src/cli/list.ts @@ -17,12 +17,23 @@ export const listCommand = async () => { console.log(`\n Indexed Repositories (${entries.length})\n`); + // Count occurrences of each name so colliding entries can be + // disambiguated in the header (#829). Unique-name entries render + // identically to pre-#829 output; only collisions gain a suffix. + const nameCounts = new Map(); + for (const e of entries) { + const key = e.name.toLowerCase(); + nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1); + } + for (const entry of entries) { const indexedDate = new Date(entry.indexedAt).toLocaleString(); const stats = entry.stats || {}; const commitShort = entry.lastCommit?.slice(0, 7) || 'unknown'; + const hasCollision = (nameCounts.get(entry.name.toLowerCase()) ?? 0) > 1; + const header = hasCollision ? `${entry.name} (${entry.path})` : entry.name; - console.log(` ${entry.name}`); + console.log(` ${header}`); console.log(` Path: ${entry.path}`); console.log(` Indexed: ${indexedDate}`); console.log(` Commit: ${commitShort}`); diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 910624f5f..b17fb8e57 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -46,6 +46,12 @@ export interface AnalyzeCallbacks { } export interface AnalyzeOptions { + /** + * Force a full re-index of the pipeline. Callers may OR this with + * other flags that imply re-analysis (e.g. `--skills`), so the value + * here is the PIPELINE-force signal, NOT the registry-collision + * bypass. See `allowDuplicateName` below. + */ force?: boolean; embeddings?: boolean; skipGit?: boolean; @@ -53,6 +59,21 @@ export interface AnalyzeOptions { skipAgentsMd?: boolean; /** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */ noStats?: boolean; + /** + * User-provided alias for the registry `name` (#829). When set, + * forwarded to `registerRepo` so the indexed repo is stored under + * this alias instead of the path-derived basename. + */ + registryName?: string; + /** + * Bypass the `RegistryNameCollisionError` guard and allow two paths + * to register under the same `name` (#829). Controlled by the + * dedicated `--allow-duplicate-name` CLI flag, intentionally + * independent from `--force` — users who hit the collision guard + * should be able to accept the duplicate without paying the cost + * of a pipeline re-index. + */ + allowDuplicateName?: boolean; } export interface AnalyzeResult { @@ -313,7 +334,15 @@ export async function runFullAnalysis( }, }; await saveMeta(storagePath, meta); - await registerRepo(repoPath, meta); + // Forward the --name alias and the registry-collision bypass bit. + // `allowDuplicateName` is its own concern — independent from the + // pipeline `force` above. The CLI maps it from + // `--allow-duplicate-name` only; `--force` and `--skills` both + // trigger pipeline re-run but never bypass the registry guard. + await registerRepo(repoPath, meta, { + name: options.registryName, + allowDuplicateName: options.allowDuplicateName, + }); // Only attempt to update .gitignore when a .git directory is present. if (hasGitDir(repoPath)) { diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 5b73885cd..55157cf05 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -342,13 +342,25 @@ export class LocalBackend { if (this.repos.size === 0) { throw new Error('No indexed repositories. Run: gitnexus analyze'); } - if (repoParam) { - const names = [...this.repos.values()].map((h) => h.name); - throw new Error(`Repository "${repoParam}" not found. Available: ${names.join(', ')}`); + + // Build a disambiguated "Available: …" list (#829). When two handles + // share a name, annotate each colliding label with its path so the + // caller can actually pick the right one. Single-name entries render + // identically to pre-#829 output. + const nameCounts = new Map(); + for (const h of this.repos.values()) { + const key = h.name.toLowerCase(); + nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1); + } + const labels = [...this.repos.values()].map((h) => + (nameCounts.get(h.name.toLowerCase()) ?? 0) > 1 ? `${h.name} (${h.repoPath})` : h.name, + ); + + if (repoParam) { + throw new Error(`Repository "${repoParam}" not found. Available: ${labels.join(', ')}`); } - const names = [...this.repos.values()].map((h) => h.name); throw new Error( - `Multiple repositories indexed. Specify which one with the "repo" parameter. Available: ${names.join(', ')}`, + `Multiple repositories indexed. Specify which one with the "repo" parameter. Available: ${labels.join(', ')}`, ); } diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index b233d44fb..4ba17b21b 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -244,21 +244,127 @@ const writeRegistry = async (entries: RegistryEntry[]): Promise => { await fs.writeFile(getGlobalRegistryPath(), JSON.stringify(entries, null, 2), 'utf-8'); }; +/** + * Options for {@link registerRepo}. All optional — callers without any + * disambiguation requirement can keep calling `registerRepo(path, meta)` + * unchanged. + */ +export interface RegisterRepoOptions { + /** + * User-provided alias from `analyze --name ` (#829). Overrides + * the default basename-derived registry `name`. Persisted — subsequent + * re-analyses of the same path without `--name` preserve the alias. + */ + name?: string; + /** + * Allow two DIFFERENT repo paths to register under the same alias + * (#829). Mapped from the `--allow-duplicate-name` CLI flag. + * + * Scope: this flag governs cross-path alias sharing only — one repo + * path always has exactly one registry entry (and therefore exactly + * one alias). Re-analyzing the same path with `--name Y` overwrites + * a previous `--name X`; it does NOT create a second entry or a + * second alias for the same path (see the upsert-by-resolved-path + * logic in {@link registerRepo} and the + * `re-registerRepo with a different name overrides the previous + * alias` test in `test/unit/repo-manager.test.ts`). + * + * Distinct from `--force` (which only triggers pipeline re-index); + * a user accepting a duplicate alias should not be forced to also + * re-run the full pipeline. + */ + allowDuplicateName?: boolean; +} + +/** + * Thrown by {@link registerRepo} when a requested name is already in + * use by a DIFFERENT path. The CLI layer surfaces this as an actionable + * error instead of relying on `.message` string-matching. + * + * The colliding alias is exposed as `err.registryName` (not `err.name`). + * `err.name` keeps its inherited `Error.prototype.name` semantics (the + * class name) so downstream code can do the usual `err.name === + * 'RegistryNameCollisionError'` checks; use the `kind` discriminant or + * `instanceof RegistryNameCollisionError` for type-safe narrowing. + */ +export class RegistryNameCollisionError extends Error { + readonly kind = 'RegistryNameCollisionError' as const; + constructor( + public readonly registryName: string, + public readonly existingPath: string, + public readonly requestedPath: string, + ) { + super( + `Registry name "${registryName}" is already used by "${existingPath}".\n` + + `Pass --name to register "${requestedPath}" under a different name, ` + + `or --allow-duplicate-name to allow both paths under the same name (leaves -r ambiguous for these two).`, + ); + this.name = 'RegistryNameCollisionError'; + } +} + +/** Returns true when a previously-registered entry's `name` differs from + * `path.basename(entry.path)` — i.e. a user explicitly aliased it via + * `analyze --name ` on a prior run. Used to preserve the alias + * across re-analyses that omit `--name`. */ +const hasCustomAlias = (entry: RegistryEntry): boolean => { + return entry.name !== path.basename(path.resolve(entry.path)); +}; + /** * Register (add or update) a repo in the global registry. * Called after `gitnexus analyze` completes. + * + * Name resolution precedence (#829): + * 1. explicit `opts.name` (from `analyze --name `) + * 2. preserved alias on an existing entry for this path + * 3. `path.basename(repoPath)` (the original default) + * + * Duplicate-name guard: if another path already uses the resolved + * `name`, throw {@link RegistryNameCollisionError} unless + * `opts.allowDuplicateName` is set. The guard ONLY fires when the user explicitly passed a + * `name`; un-aliased basename collisions continue to register silently + * so existing users who don't know about `--name` see no behaviour + * change. */ -export const registerRepo = async (repoPath: string, meta: RepoMeta): Promise => { +export const registerRepo = async ( + repoPath: string, + meta: RepoMeta, + opts?: RegisterRepoOptions, +): Promise => { const resolved = path.resolve(repoPath); - const name = path.basename(resolved); const { storagePath } = getStoragePaths(resolved); const entries = await readRegistry(); - const existing = entries.findIndex((e) => { + const existingIdx = entries.findIndex((e) => { const a = path.resolve(e.path); const b = resolved; return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b; }); + const existing = existingIdx >= 0 ? entries[existingIdx] : null; + + // Precedence: explicit --name > preserved alias > basename. + const name = + opts?.name ?? (existing && hasCustomAlias(existing) ? existing.name : path.basename(resolved)); + + // Duplicate-name guard: only fire when the user EXPLICITLY asked for + // this name (via opts.name or a preserved alias). Unqualified basename + // collisions are preserved for backward-compat — they still register, + // and the user sees the ambiguity at `-r` / `list` resolution time + // (which is already improved by the disambiguated error messages and + // list output this PR also ships). + const explicitName = opts?.name !== undefined || (existing && hasCustomAlias(existing)); + if (explicitName && !opts?.allowDuplicateName) { + const collidingEntry = entries.find( + (e, i) => + i !== existingIdx && + e.name.toLowerCase() === name.toLowerCase() && + path.resolve(e.path) !== resolved, + ); + if (collidingEntry) { + throw new RegistryNameCollisionError(name, collidingEntry.path, resolved); + } + } const entry: RegistryEntry = { name, @@ -269,8 +375,8 @@ export const registerRepo = async (repoPath: string, meta: RepoMeta): Promise= 0) { - entries[existing] = entry; + if (existingIdx >= 0) { + entries[existingIdx] = entry; } else { entries.push(entry); } diff --git a/gitnexus/test/integration/cli-e2e.test.ts b/gitnexus/test/integration/cli-e2e.test.ts index abdd436a4..844e3978d 100644 --- a/gitnexus/test/integration/cli-e2e.test.ts +++ b/gitnexus/test/integration/cli-e2e.test.ts @@ -110,6 +110,55 @@ function runCliRaw(extraArgs: string[], cwd: string, timeoutMs = 15000) { }); } +/** + * Like runCliRaw but accepts extra env vars. Used by tests that need to + * isolate the global registry via GITNEXUS_HOME so they don't touch the + * developer / CI agent's real ~/.gitnexus/registry.json (#829). + */ +function runCliWithEnv( + extraArgs: string[], + cwd: string, + extraEnv: Record, + timeoutMs = 15000, +) { + return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, ...extraArgs], { + cwd, + encoding: 'utf8', + timeout: timeoutMs, + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), + ...extraEnv, + }, + }); +} + +/** + * Create a fresh git-initialised throwaway repo at `/` + * and return its path. Used for tests that need multiple repos whose + * basenames intentionally collide (#829 reproduction). + */ +function makeMiniRepoCopy(basename: string, prefix: string): string { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const repo = path.join(parent, basename); + fs.cpSync(FIXTURE_SRC, repo, { recursive: true }); + spawnSync('git', ['init'], { cwd: repo, stdio: 'pipe' }); + spawnSync('git', ['add', '-A'], { cwd: repo, stdio: 'pipe' }); + spawnSync('git', ['commit', '-m', 'initial commit'], { + cwd: repo, + stdio: 'pipe', + env: { + ...process.env, + GIT_AUTHOR_NAME: 'test', + GIT_AUTHOR_EMAIL: 'test@test', + GIT_COMMITTER_NAME: 'test', + GIT_COMMITTER_EMAIL: 'test@test', + }, + }); + return repo; +} + describe('CLI end-to-end', () => { it('status command exits cleanly', () => { const result = runCli('status', MINI_REPO); @@ -144,6 +193,158 @@ describe('CLI end-to-end', () => { expect(fs.statSync(gitnexusDir).isDirectory()).toBe(true); }); + // ─── analyze --name + --allow-duplicate-name (#829) ────── + // + // End-to-end regression guard for the name-collision feature: + // 1. `analyze --name X` persists the alias to ~/.gitnexus/registry.json + // 2. A second `analyze --name X` on a DIFFERENT path is rejected with + // a collision error (exit code 1, "already used" in output) + // 3. `analyze --name X --allow-duplicate-name` bypasses the guard; + // both entries coexist in registry.json + // 4. Pipeline-re-index flags (e.g. --skills) WITHOUT + // --allow-duplicate-name must STILL hit the collision guard — + // the bypass must stay gated on its dedicated flag so it isn't + // silently triggered by unrelated pipeline signals + // (review round 2/3 design decision). + // + // This test invokes the real CLI → runFullAnalysis → registerRepo + // chain, so any wiring regression fails here. + describe('analyze --name and --allow-duplicate-name (#829)', () => { + // Path-equality assertions across CLI spawn boundaries are fragile + // cross-platform: + // - macOS: os.tmpdir() returns /var/folders/...; child processes + // resolve the symlink to /private/var/folders/... + // - Windows: os.tmpdir() on GitHub runners returns 8.3 short-name + // form (C:\Users\RUNNER~1\...); the child sees the long form + // (C:\Users\runneradmin\...). fs.realpathSync does NOT reliably + // expand 8.3 to long form. + // Rather than fight the platform-path quagmire, we assert STRUCTURAL + // properties: entry count, alias value, path basename, path + // distinctness. That covers the behavior this test is here to + // protect without depending on exact-string path equality. + + it('--name alias stores; collision rejects; --allow-duplicate-name bypasses', () => { + // Isolate the global registry so this test never touches the + // developer's real ~/.gitnexus. + const gnHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-home-')); + + // Two mini-repo copies whose basenames intentionally collide. + const repoA = makeMiniRepoCopy('collide-app', 'gn-collide-a-'); + const repoB = makeMiniRepoCopy('collide-app', 'gn-collide-b-'); + const parentA = path.dirname(repoA); + const parentB = path.dirname(repoB); + + try { + // Step 1: analyze repoA with --name shared → registry entry created. + const r1 = runCliWithEnv( + ['analyze', '--name', 'shared'], + repoA, + { GITNEXUS_HOME: gnHome }, + 60000, + ); + if (r1.status === null) return; // CI timeout tolerance + expect( + r1.status, + [`step 1 exited with ${r1.status}`, `stdout: ${r1.stdout}`, `stderr: ${r1.stderr}`].join( + '\n', + ), + ).toBe(0); + + const registryPath = path.join(gnHome, 'registry.json'); + const afterStep1 = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); + expect(Array.isArray(afterStep1)).toBe(true); + expect(afterStep1).toHaveLength(1); + expect(afterStep1[0].name).toBe('shared'); + expect(path.basename(afterStep1[0].path)).toBe('collide-app'); + + // Step 2: analyze repoB with the SAME --name → collision error. + const r2 = runCliWithEnv( + ['analyze', '--name', 'shared'], + repoB, + { GITNEXUS_HOME: gnHome }, + 60000, + ); + if (r2.status === null) return; + expect(r2.status).toBe(1); + const r2Output = `${r2.stdout}${r2.stderr}`; + expect(r2Output).toMatch(/Registry name collision|already used/i); + + // Registry still has just the first entry — step 2 must not have + // silently added, overwritten, or corrupted anything. + const afterStep2 = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); + expect(afterStep2).toHaveLength(1); + // Registry still has only the step-1 entry — the failed call + // must not have silently added, overwritten, or corrupted state. + expect(afterStep2[0].path).toBe(afterStep1[0].path); + + // Step 3: REGRESSION GUARD for the missing collision-bypass wire + // (originally a --force passthrough bug; per review round 3 the + // bypass moved to its own --allow-duplicate-name flag to avoid + // conflating it with pipeline re-index). + const r3 = runCliWithEnv( + ['analyze', '--name', 'shared', '--allow-duplicate-name'], + repoB, + { GITNEXUS_HOME: gnHome }, + 60000, + ); + if (r3.status === null) return; + expect( + r3.status, + [ + `step 3 (--allow-duplicate-name bypass) exited with ${r3.status}`, + `stdout: ${r3.stdout}`, + `stderr: ${r3.stderr}`, + ].join('\n'), + ).toBe(0); + + const afterStep3 = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); + expect(afterStep3).toHaveLength(2); + expect(afterStep3.every((e: { name: string }) => e.name === 'shared')).toBe(true); + // Both entries point to distinct paths (we registered two different + // repos under the same alias) and both have the right basename. + const step3Basenames = afterStep3.map((e: { path: string }) => path.basename(e.path)); + expect(step3Basenames).toEqual(['collide-app', 'collide-app']); + const step3Paths = new Set(afterStep3.map((e: { path: string }) => e.path)); + expect(step3Paths.size).toBe(2); + // One of the two entries is the original from step 1 — unchanged. + expect(afterStep3.map((e: { path: string }) => e.path)).toContain(afterStep1[0].path); + + // Step 4: REGRESSION GUARD for the design decision in review + // round 2/3 — pipeline-re-index flags must NOT bypass the + // registry collision guard. `--skills` triggers pipeline + // re-run (skills generation needs a fresh pipelineResult) but + // must leave the registry guard in force. Bypass requires the + // explicit --allow-duplicate-name flag. + const repoC = makeMiniRepoCopy('collide-app', 'gn-collide-c-'); + const parentC = path.dirname(repoC); + try { + const r4 = runCliWithEnv( + ['analyze', '--name', 'shared', '--skills'], + repoC, + { GITNEXUS_HOME: gnHome }, + 60000, + ); + if (r4.status === null) return; + expect(r4.status).toBe(1); + const r4Output = `${r4.stdout}${r4.stderr}`; + expect(r4Output).toMatch(/Registry name collision|already used/i); + // The error hint should point at the new flag. + expect(r4Output).toMatch(/--allow-duplicate-name/); + + // Registry unchanged — still only A + B under "shared". + const afterStep4 = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); + expect(afterStep4).toHaveLength(2); + } finally { + fs.rmSync(parentC, { recursive: true, force: true }); + } + } finally { + fs.rmSync(gnHome, { recursive: true, force: true }); + fs.rmSync(parentA, { recursive: true, force: true }); + fs.rmSync(parentB, { recursive: true, force: true }); + } + }, 360000); // 6-min outer budget (4 × ~60s analyze calls + fixture setup) + }); + describe('unhappy path', () => { it('exits with error when no command is given', () => { const result = runCliRaw([], MINI_REPO); diff --git a/gitnexus/test/unit/repo-manager.test.ts b/gitnexus/test/unit/repo-manager.test.ts index 08afceaff..b9eabccb4 100644 --- a/gitnexus/test/unit/repo-manager.test.ts +++ b/gitnexus/test/unit/repo-manager.test.ts @@ -13,6 +13,10 @@ import { getStoragePaths, readRegistry, loadCLIConfig, + registerRepo, + listRegisteredRepos, + RegistryNameCollisionError, + type RepoMeta, } from '../../src/storage/repo-manager.js'; import { createTempDir } from '../helpers/test-db.js'; @@ -133,3 +137,137 @@ describe('API key file permissions', () => { expect(source).toContain("process.platform !== 'win32'"); }); }); + +// ─── analyze --name + duplicate-name guard (#829) ──────────── +// +// Each test isolates the global registry by pointing GITNEXUS_HOME at a +// per-test tmpdir. `getGlobalDir()` honors that env var, so registerRepo +// writes/reads a sandboxed registry.json without touching the user's +// real ~/.gitnexus. + +describe('registerRepo name override + collision guard (#829)', () => { + let tmpHome: Awaited>; + let tmpRepoA: Awaited>; + let tmpRepoB: Awaited>; + let savedGitnexusHome: string | undefined; + + const meta: RepoMeta = { + repoPath: '', + lastCommit: 'abc1234', + indexedAt: '2026-04-18T12:00:00.000Z', + stats: { files: 1, nodes: 1 }, + }; + + beforeEach(async () => { + tmpHome = await createTempDir('gitnexus-registry-home-'); + tmpRepoA = await createTempDir('gitnexus-repo-a-'); + tmpRepoB = await createTempDir('gitnexus-repo-b-'); + savedGitnexusHome = process.env.GITNEXUS_HOME; + process.env.GITNEXUS_HOME = tmpHome.dbPath; + }); + + afterEach(async () => { + if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = savedGitnexusHome; + await tmpHome.cleanup(); + await tmpRepoA.cleanup(); + await tmpRepoB.cleanup(); + }); + + it('registerRepo({ name: "alias" }) stores the alias instead of basename', async () => { + await registerRepo(tmpRepoA.dbPath, meta, { name: 'custom-alias' }); + + const entries = await listRegisteredRepos(); + expect(entries).toHaveLength(1); + expect(entries[0].name).toBe('custom-alias'); + expect(entries[0].name).not.toBe(path.basename(tmpRepoA.dbPath)); + }); + + it('re-registerRepo on same path without name preserves an existing alias', async () => { + await registerRepo(tmpRepoA.dbPath, meta, { name: 'custom-alias' }); + // Second call with no opts should keep the alias, not revert to basename. + await registerRepo(tmpRepoA.dbPath, meta); + + const entries = await listRegisteredRepos(); + expect(entries).toHaveLength(1); + expect(entries[0].name).toBe('custom-alias'); + }); + + it('re-registerRepo with a different name overrides the previous alias', async () => { + await registerRepo(tmpRepoA.dbPath, meta, { name: 'old-alias' }); + await registerRepo(tmpRepoA.dbPath, meta, { name: 'new-alias' }); + + const entries = await listRegisteredRepos(); + expect(entries).toHaveLength(1); + expect(entries[0].name).toBe('new-alias'); + }); + + it('registerRepo throws RegistryNameCollisionError when another path uses the name', async () => { + await registerRepo(tmpRepoA.dbPath, meta, { name: 'shared' }); + + await expect(registerRepo(tmpRepoB.dbPath, meta, { name: 'shared' })).rejects.toBeInstanceOf( + RegistryNameCollisionError, + ); + + // And the colliding entry in the error carries enough info for the + // CLI layer to surface an actionable message without string-matching. + try { + await registerRepo(tmpRepoB.dbPath, meta, { name: 'shared' }); + } catch (e) { + expect(e).toBeInstanceOf(RegistryNameCollisionError); + const err = e as RegistryNameCollisionError; + // err.registryName carries the colliding alias (exposed as its own + // field so err.name retains the inherited Error.prototype.name + // semantics for downstream `err.name === '…Error'` checks). + expect(err.registryName).toBe('shared'); + expect(err.name).toBe('RegistryNameCollisionError'); + expect(path.resolve(err.existingPath)).toBe(path.resolve(tmpRepoA.dbPath)); + expect(path.resolve(err.requestedPath)).toBe(path.resolve(tmpRepoB.dbPath)); + } + + // Registry still only has the first entry — the failed call didn't + // corrupt state. + const entries = await listRegisteredRepos(); + expect(entries).toHaveLength(1); + expect(entries[0].name).toBe('shared'); + }); + + it('registerRepo({ name, allowDuplicateName: true }) allows the duplicate to coexist', async () => { + await registerRepo(tmpRepoA.dbPath, meta, { name: 'shared' }); + await registerRepo(tmpRepoB.dbPath, meta, { name: 'shared', allowDuplicateName: true }); + + const entries = await listRegisteredRepos(); + expect(entries).toHaveLength(2); + expect(entries.every((e) => e.name === 'shared')).toBe(true); + // Both paths are stored distinctly — the collision is surfaced to the + // user via resolveRepo / list output, not hidden at the storage layer. + const paths = entries.map((e) => path.resolve(e.path)).sort(); + expect(paths).toEqual([path.resolve(tmpRepoA.dbPath), path.resolve(tmpRepoB.dbPath)].sort()); + }); + + it('basename collisions without an explicit --name still register silently (backward-compat)', async () => { + // Create two sibling dirs whose basenames collide. Neither caller + // passes { name }, so the guard must NOT fire — this preserves the + // pre-#829 behaviour for users who don't know about --name yet. + const parentA = await createTempDir('gitnexus-collide-parent-a-'); + const parentB = await createTempDir('gitnexus-collide-parent-b-'); + const sharedBasename = 'app'; + const pathA = path.join(parentA.dbPath, sharedBasename); + const pathB = path.join(parentB.dbPath, sharedBasename); + await fs.mkdir(pathA, { recursive: true }); + await fs.mkdir(pathB, { recursive: true }); + + try { + await registerRepo(pathA, meta); + await registerRepo(pathB, meta); // must NOT throw + + const entries = await listRegisteredRepos(); + expect(entries).toHaveLength(2); + expect(entries[0].name).toBe(sharedBasename); + expect(entries[1].name).toBe(sharedBasename); + } finally { + await parentA.cleanup(); + await parentB.cleanup(); + } + }); +}); From fa39a4b4a5e175ef4a125bbc13e01fca899355bf Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 07:46:21 +0100 Subject: [PATCH 42/46] fix(docker): build and push Docker images for Release Candidates (#978) --- .github/scripts/check-workflow-concurrency.py | 20 ++++++---- .github/workflows/docker.yml | 37 ++++++++++++++++++- .github/workflows/release-candidate.yml | 22 +++++++++++ CONTRIBUTING.md | 28 +++++++++++++- README.md | 24 +++++++++--- 5 files changed, 116 insertions(+), 15 deletions(-) diff --git a/.github/scripts/check-workflow-concurrency.py b/.github/scripts/check-workflow-concurrency.py index 300cc7f4b..0d7a49291 100644 --- a/.github/scripts/check-workflow-concurrency.py +++ b/.github/scripts/check-workflow-concurrency.py @@ -11,10 +11,14 @@ Rules: `concurrency:` block. 2. Reusable workflows (on: workflow_call ONLY) do NOT declare one. 3. The `concurrency.group` expression MUST reference either - `${{ github.workflow }}` or a literal `CI-` prefix (the documented - ci.yml reusable-workflow-safe exception). This is checked by substring - containment rather than prefix match because ci.yml's group is a - conditional expression that resolves to a `CI-…` literal at runtime. + `${{ github.workflow }}` or one of the approved hardcoded literal prefixes + for workflows that are simultaneously entry-points AND reusable (on: push/ + workflow_call). Two such exceptions are currently approved: + - `CI-` for ci.yml (the original canonical form) + - `docker-build-push-` for docker.yml + This is checked by substring containment rather than prefix match because + the group value is a conditional expression that resolves to a `CI-…` or + `docker-build-push-…` literal at runtime. We deliberately do not use a YAML library — keeps the script dependency-free on any vanilla runner. `on:` block parsing is line-based and handles both the @@ -28,7 +32,7 @@ import re import sys -REQUIRED_TOKENS = ("${{ github.workflow }}", "CI-") +REQUIRED_TOKENS = ("${{ github.workflow }}", "CI-", "docker-build-push-") def is_reusable(lines: list[str]) -> bool: @@ -150,8 +154,10 @@ def check(workflows_dir: pathlib.Path) -> int: if not any(token in group for token in REQUIRED_TOKENS): print( f"::error file={path}::concurrency.group `{group}` must " - f"reference one of {REQUIRED_TOKENS}. See CONTRIBUTING.md -> " - "GitHub Actions — Concurrency Convention." + f"reference one of {REQUIRED_TOKENS} (use ${{{{ github.workflow }}}} " + "for normal entry-point workflows; use an approved literal prefix " + "only for workflows that are both entry-points AND reusable — " + "see CONTRIBUTING.md -> GitHub Actions — Concurrency Convention)." ) fail = 1 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d27358bf6..ce583e537 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -7,12 +7,26 @@ on: # No workflow_dispatch: publishing is exclusively tag-driven so that every # signed image corresponds 1:1 to a published `gitnexus@X.Y.Z` on npm. A # manual run from a branch ref would fail the version check below anyway. + workflow_call: + inputs: + tag: + description: >- + The full v-prefixed tag to build (e.g. v1.2.3-rc.1). + The tag must already exist in the repo and its tree must contain + a gitnexus/package.json whose version matches the tag. + required: true + type: string # Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". # Tag refs are unique per release, so distinct tags run in parallel. # Re-pushes of the same tag serialize. cancel-in-progress: false — never cancel a publish mid-flight. +# Hardcoded `docker-build-push-` prefix (not `${{ github.workflow }}`) when invoked as a reusable +# workflow: in called-workflow context `github.workflow` is ambiguous and could resolve to the +# caller's name, sharing a concurrency group with the caller → deadlock. +# Direct tag-push invocations use `docker-build-push-`; workflow_call invocations get a +# per-run-unique group (they are already serialized by the caller's own concurrency group). concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ (github.event_name == 'push') && format('docker-build-push-{0}', github.ref) || format('docker-build-push-nested-{0}', github.run_id) }} cancel-in-progress: false jobs: @@ -46,7 +60,12 @@ jobs: slug: gitnexus steps: + # When triggered by workflow_call the caller passes the RC tag as an input; + # we check out that tag so the Dockerfile and package.json match the built image. + # For tag-push events github.ref is already the tag ref — no override needed. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.tag || github.ref }} # ── Lock the docker image version to the npm package version ────────── # Mirrors the check in publish.yml: refuse to build unless the git tag @@ -56,8 +75,16 @@ jobs: - name: Verify tag matches gitnexus/package.json version id: version shell: bash + env: + # For workflow_call the tag comes from the caller input; for push events + # it is derived from GITHUB_REF (set to empty so the else-branch fires). + INPUT_TAG: ${{ inputs.tag }} run: | - TAG_VERSION="${GITHUB_REF#refs/tags/v}" + if [ -n "$INPUT_TAG" ]; then + TAG_VERSION="${INPUT_TAG#v}" + else + TAG_VERSION="${GITHUB_REF#refs/tags/v}" + fi if ! [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then echo "::error::Tag does not follow semver: v$TAG_VERSION" exit 1 @@ -92,6 +119,11 @@ jobs: # v1.2.3-rc.1 → :1.2.3-rc.1 only (prereleases never become :latest) # `:latest` is only emitted for tag pushes thanks to `flavor: latest=auto`, # ensuring it always points at a real npm-published version. + # + # For workflow_call invocations github.ref is the caller's branch ref, so + # the type=semver patterns would not match. In that case we add an explicit + # type=raw tag using the version already verified above, so the same + # image-naming rules apply regardless of how the workflow was triggered. - name: Extract Docker metadata id: meta uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 @@ -102,6 +134,7 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} + type=raw,value=${{ steps.version.outputs.version }},enable=${{ github.event_name == 'workflow_call' }} - name: Build and push id: build diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index d4db75db0..3e35a58ee 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -125,6 +125,8 @@ jobs: permissions: contents: write # push rc tag + marker id-token: write # npm provenance + outputs: + vtag: ${{ steps.reltag.outputs.vtag }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -364,3 +366,23 @@ jobs: Release candidates are pre-stable builds intended for early testing. Stable releases remain on the `latest` dist-tag. + + # ── Build & push RC Docker images ──────────────────────────────────── + # Calls docker.yml as a reusable workflow so that the build, signing, and + # attestation logic stays in one place. The publish job exposes `vtag` + # (e.g. `v1.2.3-rc.1`) as an output so we can pass it as the tag input. + # RC images are signed with Cosign keyless signing; the OIDC identity + # will be `docker.yml@refs/heads/main` (the caller's ref) rather than a + # tag ref — see README.md § Docker for the correct verify command for RCs. + docker: + name: Build & Push RC Docker images + needs: [guard, publish] + if: needs.guard.outputs.should_run == 'true' + uses: ./.github/workflows/docker.yml + permissions: + contents: read + packages: write + id-token: write + attestations: write + with: + tag: ${{ needs.publish.outputs.vtag }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 22104edb4..7f797f9a0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,7 +77,7 @@ Every workflow under `.github/workflows/` MUST declare a top-level `concurrency: - Per-PR scope (for `issue_comment`, `pull_request_review*`, `pull_request` meta events): `${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}` - `workflow_run` scope (e.g. `ci-report.yml`): `${{ github.workflow }}-${{ github.event.workflow_run.pull_requests[0].number || format('{0}/{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) }}` — the fork fallback must be stable across reruns (never `workflow_run.id`, which is per-run-unique and defeats serialization). - Global single-slot (manual dispatch utilities): `${{ github.workflow }}` - - **Reusable workflows invoked via `workflow_call`:** do NOT use `${{ github.workflow }}` in the group key — in called-workflow context its evaluation is ambiguous and can resolve to the caller's name, which would deadlock against the caller's own group. Use a hardcoded literal prefix and a `github.event_name`-aware expression that falls through to `github.run_id` for reusable invocations (see `ci.yml` for the canonical form). + - **Reusable workflows invoked via `workflow_call`:** do NOT use `${{ github.workflow }}` in the group key — in called-workflow context its evaluation is ambiguous and can resolve to the caller's name, which would deadlock against the caller's own group. Use a hardcoded literal prefix and a `github.event_name`-aware expression that falls through to `github.run_id` for reusable invocations (see `ci.yml` for the canonical form). Approved literal prefixes: `CI-` (`ci.yml`) and `docker-build-push-` (`docker.yml`). The `check-workflow-concurrency.py` validation script must be updated whenever a new approved literal prefix is added. - **Merge queue (`merge_group`)**: when this event is added, use `${{ github.workflow }}-${{ github.event.merge_group.head_ref }}` with `cancel-in-progress: false` (every queue entry is a distinct ref; never cancel). - **`cancel-in-progress` policy:** @@ -127,6 +127,11 @@ Two publish workflows ship `gitnexus` to npm: the cycle from `latest`. - `N` is auto-incremented against existing `X.Y.Z-rc.*` entries on the registry. First rc for a given base is `rc.1`. + - After the npm publish succeeds, the workflow calls `docker.yml` as a + reusable workflow to build and push the corresponding RC Docker images + (e.g. `ghcr.io/abhigyanpatwari/gitnexus:1.7.0-rc.1`). The images are + signed with Cosign; the OIDC identity is `docker.yml@refs/heads/main` + (the caller's ref — see README.md § Docker for the verify command). Idempotency: the workflow pushes an `rc/` marker tag and a `v` release tag **atomically, before** calling `npm publish`. The guard @@ -140,6 +145,27 @@ Two publish workflows ship `gitnexus` to npm: # then redispatch the workflow with force: true ``` + **Docker-only partial failure:** if `publish` succeeds (npm tarball + tags + are live) but the `docker` job subsequently fails (e.g. GHCR flakiness), + the npm RC is already published and the `rc/` marker is in place. + Re-running `release-candidate.yml` with `force: true` will abort at the + "Version already exists on npm" guard. To recover without cutting a new RC: + + ```bash + # 1. Manually trigger only the docker workflow, passing the existing RC tag: + gh workflow run docker.yml --ref main -f tag=v + # (requires a workflow_dispatch trigger on docker.yml — see note below) + ``` + + Because `docker.yml` intentionally has no `workflow_dispatch` (images are + tag-driven by design), the practical recovery options are: + - Wait for the next commit on `main`, which will cut a new RC that includes + the Docker build. + - Manually run `docker build` + `docker push` locally and sign with Cosign + against the same digest. + - Delete `rc/` and `v` tags, then redispatch with `force: + true` to re-run the full RC pipeline (cuts a new RC number). + The rc workflow never moves `latest`. To verify after a change, inspect dist-tags: ```bash diff --git a/README.md b/README.md index e60d8c8c1..4c273ab47 100644 --- a/README.md +++ b/README.md @@ -400,11 +400,14 @@ docker compose --env-file .env up -d The Docker images are version-locked to the npm package: -- Both images are **only published from `vX.Y.Z` git tags**, and the workflow - refuses to build unless the tag exactly matches `gitnexus/package.json`'s - version. So `ghcr.io/abhigyanpatwari/gitnexus:1.6.2` is byte-for-byte the - same release as `npm install gitnexus@1.6.2` — no drift, no floating - builds from `main`. +- Stable images are **only published from `vX.Y.Z` git tags** (via `docker.yml` + triggered directly by the tag push), and the workflow refuses to build unless + the tag exactly matches `gitnexus/package.json`'s version. So + `ghcr.io/abhigyanpatwari/gitnexus:1.6.2` is byte-for-byte the same release + as `npm install gitnexus@1.6.2` — no drift, no floating builds from `main`. +- Release-candidate images (e.g. `:1.7.0-rc.1`) are published alongside each + RC npm release. They are built by `release-candidate.yml` calling `docker.yml` + as a reusable workflow after the RC tag is created and pushed. - `:latest` is auto-promoted only from non-prerelease tags by the Docker metadata action, so it always points at a real, npm-published version. @@ -416,6 +419,8 @@ typo-squatted registry), they cannot forge a Cosign signature tied to `abhigyanpatwari/GitNexus`'s `docker.yml`. Always verify before pulling into sensitive environments: +**Stable releases** — signed from the `v*` tag ref: + ```bash cosign verify ghcr.io/abhigyanpatwari/gitnexus:1.6.2 \ --certificate-identity-regexp '^https://github\.com/abhigyanpatwari/GitNexus/\.github/workflows/docker\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$' \ @@ -426,6 +431,15 @@ The regex pins the certificate identity to this repo's `docker.yml` workflow **run from a `v*` tag** — rejecting unsigned images, images signed by other workflows, and images signed from unprotected refs. +**Release candidates** — signed from `refs/heads/main` (the caller's ref when +`release-candidate.yml` invokes `docker.yml` as a reusable workflow): + +```bash +cosign verify ghcr.io/abhigyanpatwari/gitnexus:1.7.0-rc.1 \ + --certificate-identity 'https://github.com/abhigyanpatwari/GitNexus/.github/workflows/docker.yml@refs/heads/main' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com +``` + You can also inspect the build provenance and SBOM: ```bash From 9926804d75b6da7479eb67b13ccbf145af01cb18 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 09:14:14 +0100 Subject: [PATCH 43/46] feat(cli): infer registry name from `git remote.origin.url` (#981) * Initial plan * Plan: smarter index name inference via git remote URL Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/95064d2d-b1da-4c89-9069-5b3e9cc2636a Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * feat(cli): infer registry name from git remote.origin.url (#979) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/95064d2d-b1da-4c89-9069-5b3e9cc2636a Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * refactor: skip git subprocess when --name was supplied (review feedback) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/95064d2d-b1da-4c89-9069-5b3e9cc2636a Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * style: prettier --write on run-analyze.ts Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a4bf631d-ea6b-4d84-b426-29b1e5c3539f Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- gitnexus/package-lock.json | 4 +- gitnexus/src/core/run-analyze.ts | 12 +- gitnexus/src/storage/git.ts | 52 +++++++ gitnexus/src/storage/repo-manager.ts | 67 ++++++--- gitnexus/test/unit/repo-manager.test.ts | 182 ++++++++++++++++++++++++ 5 files changed, 294 insertions(+), 23 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index f2605cb43..76afc1fde 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitnexus", - "version": "1.6.1", + "version": "1.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitnexus", - "version": "1.6.1", + "version": "1.6.2", "hasInstallScript": true, "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index b17fb8e57..5c2191003 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -30,7 +30,7 @@ import { registerRepo, cleanupOldKuzuFiles, } from '../storage/repo-manager.js'; -import { getCurrentCommit, hasGitDir } from '../storage/git.js'; +import { getCurrentCommit, hasGitDir, getInferredRepoName } from '../storage/git.js'; import type { CachedEmbedding } from './embeddings/types.js'; import { generateAIContextFiles } from '../cli/ai-context.js'; import { EMBEDDING_TABLE_NAME } from './lbug/schema.js'; @@ -152,7 +152,7 @@ export async function runFullAnalysis( // Non-git folders have currentCommit = '' — always rebuild since we can't detect changes if (currentCommit !== '') { return { - repoName: path.basename(repoPath), + repoName: options.registryName ?? getInferredRepoName(repoPath) ?? path.basename(repoPath), repoPath, stats: existingMeta.stats ?? {}, alreadyUpToDate: true, @@ -339,7 +339,11 @@ export async function runFullAnalysis( // pipeline `force` above. The CLI maps it from // `--allow-duplicate-name` only; `--force` and `--skills` both // trigger pipeline re-run but never bypass the registry guard. - await registerRepo(repoPath, meta, { + // The returned name is the one actually written to the registry + // (after applying the precedence chain in registerRepo) — reuse it + // so AGENTS.md / skill files reference the same name MCP clients + // will look up (#979). + const projectName = await registerRepo(repoPath, meta, { name: options.registryName, allowDuplicateName: options.allowDuplicateName, }); @@ -349,8 +353,6 @@ export async function runFullAnalysis( await addToGitignore(repoPath); } - const projectName = path.basename(repoPath); - // ── Generate AI context files (best-effort) ─────────────────────── let aggregatedClusterCount = 0; if (pipelineResult.communityResult?.communities) { diff --git a/gitnexus/src/storage/git.ts b/gitnexus/src/storage/git.ts index b0e9e6d3e..c8d05ac4b 100644 --- a/gitnexus/src/storage/git.ts +++ b/gitnexus/src/storage/git.ts @@ -53,6 +53,58 @@ export const hasGitDir = (dirPath: string): boolean => { } }; +/** + * Read `remote.origin.url` from a git repository, or `null` if not a + * git repo, has no `origin` remote, or git is unavailable. + * + * Used by the registry-name inference path (#979) to recover a + * meaningful repo name when `path.basename(repoPath)` is generic + * (e.g. monorepo subprojects, git worktrees, Gas-Town-style + * `/refinery/rig/` layouts). + */ +export const getRemoteOriginUrl = (repoPath: string): string | null => { + try { + const url = execSync('git config --get remote.origin.url', { + cwd: repoPath, + stdio: ['ignore', 'pipe', 'ignore'], + }) + .toString() + .trim(); + return url || null; + } catch { + return null; + } +}; + +/** + * Parse a repository name out of a git remote URL. Handles the common + * SSH (`git@host:owner/repo.git`), HTTPS (`https://host/owner/repo.git`), + * `git://`, `ssh://`, and `file://` shapes. Returns `null` for empty / + * unparseable input. + * + * The heuristic: strip a trailing `.git` and trailing slashes, then + * take the segment after the last `/` or `:`. + */ +export const parseRepoNameFromUrl = (url: string | null | undefined): string | null => { + if (!url) return null; + const trimmed = url.trim(); + if (!trimmed) return null; + // Strip `.git` suffix (case-insensitive) and any trailing slashes. + const withoutSuffix = trimmed.replace(/\.git\/*$/i, '').replace(/\/+$/, ''); + // Last path segment, splitting on either `/` or `:` (covers SSH form). + const m = withoutSuffix.match(/[/:]([^/:]+)$/); + const candidate = m ? m[1] : withoutSuffix; + return candidate || null; +}; + +/** + * Convenience wrapper: derive a registry-friendly name from the repo's + * `origin` remote, or `null` when it cannot be inferred. + */ +export const getInferredRepoName = (repoPath: string): string | null => { + return parseRepoNameFromUrl(getRemoteOriginUrl(repoPath)); +}; + export interface DiffHunk { startLine: number; endLine: number; diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 4ba17b21b..1b151cec1 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -9,6 +9,7 @@ import fs from 'fs/promises'; import path from 'path'; import os from 'os'; +import { getInferredRepoName } from './git.js'; export interface RepoMeta { repoPath: string; @@ -304,21 +305,33 @@ export class RegistryNameCollisionError extends Error { } /** Returns true when a previously-registered entry's `name` differs from - * `path.basename(entry.path)` — i.e. a user explicitly aliased it via - * `analyze --name ` on a prior run. Used to preserve the alias - * across re-analyses that omit `--name`. */ -const hasCustomAlias = (entry: RegistryEntry): boolean => { - return entry.name !== path.basename(path.resolve(entry.path)); + * both `path.basename(entry.path)` and the git-remote-derived name — + * i.e. a user explicitly aliased it via `analyze --name ` on a + * prior run. Used to preserve the alias across re-analyses that omit + * `--name`. The remote-derived name is treated as an inference, not a + * custom alias, so re-analyses keep tracking remote renames. + * + * `inferredName` is passed in (rather than re-derived) so callers can + * avoid a second `git config` subprocess invocation. */ +const hasCustomAlias = (entry: RegistryEntry, inferredName: string | null): boolean => { + const resolved = path.resolve(entry.path); + if (entry.name === path.basename(resolved)) return false; + if (inferredName && entry.name === inferredName) return false; + return true; }; /** * Register (add or update) a repo in the global registry. * Called after `gitnexus analyze` completes. * - * Name resolution precedence (#829): + * Name resolution precedence (#829, #979): * 1. explicit `opts.name` (from `analyze --name `) * 2. preserved alias on an existing entry for this path - * 3. `path.basename(repoPath)` (the original default) + * 3. `git config --get remote.origin.url` repo name (#979 — recovers + * a meaningful name for monorepo subprojects, git worktrees, and + * Gas-Town-style `/refinery/rig/` layouts where the basename + * is generic) + * 4. `path.basename(repoPath)` (the original default) * * Duplicate-name guard: if another path already uses the resolved * `name`, throw {@link RegistryNameCollisionError} unless @@ -326,12 +339,16 @@ const hasCustomAlias = (entry: RegistryEntry): boolean => { * `name`; un-aliased basename collisions continue to register silently * so existing users who don't know about `--name` see no behaviour * change. + * + * Returns the `name` that was actually written to the registry — the + * caller can re-use it to keep AGENTS.md / skill files aligned with the + * MCP-visible repo name (#979). */ export const registerRepo = async ( repoPath: string, meta: RepoMeta, opts?: RegisterRepoOptions, -): Promise => { +): Promise => { const resolved = path.resolve(repoPath); const { storagePath } = getStoragePaths(resolved); @@ -343,17 +360,34 @@ export const registerRepo = async ( }); const existing = existingIdx >= 0 ? entries[existingIdx] : null; - // Precedence: explicit --name > preserved alias > basename. - const name = - opts?.name ?? (existing && hasCustomAlias(existing) ? existing.name : path.basename(resolved)); + // Precedence: explicit --name > preserved alias > remote-inferred > basename. + // Skip the `git config` subprocess entirely when --name was passed — + // the remote isn't consulted in that case. + let name: string; + let isPreservedAlias = false; + if (opts?.name !== undefined) { + name = opts.name; + } else { + // Compute the remote-derived name at most once. It feeds both the + // alias-preservation check (`hasCustomAlias` needs it to distinguish + // a sticky user alias from a previously-stored remote inference) and + // the fallback name when neither --name nor a preserved alias apply. + const inferred = getInferredRepoName(resolved); + if (existing && hasCustomAlias(existing, inferred)) { + name = existing.name; + isPreservedAlias = true; + } else { + name = inferred ?? path.basename(resolved); + } + } // Duplicate-name guard: only fire when the user EXPLICITLY asked for // this name (via opts.name or a preserved alias). Unqualified basename - // collisions are preserved for backward-compat — they still register, - // and the user sees the ambiguity at `-r` / `list` resolution time - // (which is already improved by the disambiguated error messages and - // list output this PR also ships). - const explicitName = opts?.name !== undefined || (existing && hasCustomAlias(existing)); + // and remote-inferred collisions are preserved for backward-compat — + // they still register, and the user sees the ambiguity at `-r` / `list` + // resolution time (which is already improved by the disambiguated error + // messages and list output #829 ships). + const explicitName = opts?.name !== undefined || isPreservedAlias; if (explicitName && !opts?.allowDuplicateName) { const collidingEntry = entries.find( (e, i) => @@ -382,6 +416,7 @@ export const registerRepo = async ( } await writeRegistry(entries); + return name; }; /** diff --git a/gitnexus/test/unit/repo-manager.test.ts b/gitnexus/test/unit/repo-manager.test.ts index b9eabccb4..83827fc15 100644 --- a/gitnexus/test/unit/repo-manager.test.ts +++ b/gitnexus/test/unit/repo-manager.test.ts @@ -18,6 +18,8 @@ import { RegistryNameCollisionError, type RepoMeta, } from '../../src/storage/repo-manager.js'; +import { parseRepoNameFromUrl, getInferredRepoName } from '../../src/storage/git.js'; +import { execSync } from 'child_process'; import { createTempDir } from '../helpers/test-db.js'; // ─── getStoragePath ────────────────────────────────────────────────── @@ -271,3 +273,183 @@ describe('registerRepo name override + collision guard (#829)', () => { } }); }); + +// ─── parseRepoNameFromUrl + getInferredRepoName (#979) ─────────────── + +describe('parseRepoNameFromUrl', () => { + it('parses HTTPS URLs and strips .git', () => { + expect(parseRepoNameFromUrl('https://github.com/owner/lume_spark.git')).toBe('lume_spark'); + expect(parseRepoNameFromUrl('https://github.com/owner/lume_spark')).toBe('lume_spark'); + }); + + it('parses SSH URLs (git@host:owner/repo.git)', () => { + expect(parseRepoNameFromUrl('git@github.com:owner/lume_spark.git')).toBe('lume_spark'); + expect(parseRepoNameFromUrl('git@gitlab.com:group/sub/lume_spark.git')).toBe('lume_spark'); + }); + + it('parses ssh:// and git:// URLs', () => { + expect(parseRepoNameFromUrl('ssh://git@host.example/owner/lume_spark.git')).toBe('lume_spark'); + expect(parseRepoNameFromUrl('git://host.example/owner/lume_spark.git')).toBe('lume_spark'); + }); + + it('parses local file:// URLs', () => { + expect(parseRepoNameFromUrl('file:///srv/git/lume_spark.git')).toBe('lume_spark'); + }); + + it('handles trailing slashes and mixed-case .git', () => { + expect(parseRepoNameFromUrl('https://github.com/owner/lume_spark.GIT/')).toBe('lume_spark'); + expect(parseRepoNameFromUrl('https://github.com/owner/lume_spark/')).toBe('lume_spark'); + }); + + it('returns null for empty / null / undefined / unparseable input', () => { + expect(parseRepoNameFromUrl('')).toBeNull(); + expect(parseRepoNameFromUrl(' ')).toBeNull(); + expect(parseRepoNameFromUrl(null)).toBeNull(); + expect(parseRepoNameFromUrl(undefined)).toBeNull(); + }); +}); + +describe('getInferredRepoName + registerRepo (#979 — git remote inference)', () => { + let tmpHome: Awaited>; + let savedGitnexusHome: string | undefined; + + const meta: RepoMeta = { + repoPath: '', + lastCommit: 'abc1234', + indexedAt: '2026-04-19T00:00:00.000Z', + stats: { files: 1, nodes: 1 }, + }; + + /** Initialise a real git repo at `dir` with the given remote URL. */ + const initGitRepo = (dir: string, remoteUrl: string | null) => { + execSync('git init -q', { cwd: dir }); + execSync('git config user.email "test@example.com"', { cwd: dir }); + execSync('git config user.name "Test"', { cwd: dir }); + if (remoteUrl) { + execSync(`git remote add origin ${remoteUrl}`, { cwd: dir }); + } + }; + + beforeEach(async () => { + tmpHome = await createTempDir('gitnexus-registry-home-979-'); + savedGitnexusHome = process.env.GITNEXUS_HOME; + process.env.GITNEXUS_HOME = tmpHome.dbPath; + }); + + afterEach(async () => { + if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = savedGitnexusHome; + await tmpHome.cleanup(); + }); + + it('getInferredRepoName returns null when there is no .git directory', async () => { + const tmp = await createTempDir('gitnexus-no-git-'); + try { + expect(getInferredRepoName(tmp.dbPath)).toBeNull(); + } finally { + await tmp.cleanup(); + } + }); + + it('getInferredRepoName returns null when origin is unset', async () => { + const tmp = await createTempDir('gitnexus-no-origin-'); + try { + initGitRepo(tmp.dbPath, null); + expect(getInferredRepoName(tmp.dbPath)).toBeNull(); + } finally { + await tmp.cleanup(); + } + }); + + it('getInferredRepoName returns the remote repo name when origin is set', async () => { + const tmp = await createTempDir('gitnexus-with-origin-'); + try { + initGitRepo(tmp.dbPath, 'https://github.com/owner/lume_spark.git'); + expect(getInferredRepoName(tmp.dbPath)).toBe('lume_spark'); + } finally { + await tmp.cleanup(); + } + }); + + it('registerRepo derives name from git remote when basename is generic (Gas-Town repro)', async () => { + // Reproduce /refinery/rig/.git layout: leaf basename is "rig", + // but origin URL says "lume_spark". The new precedence MUST pick up + // the remote-derived name instead of the basename. + const root = await createTempDir('gitnexus-gastown-'); + try { + const rigPath = path.join(root.dbPath, 'lume_spark', 'refinery', 'rig'); + await fs.mkdir(rigPath, { recursive: true }); + initGitRepo(rigPath, 'git@github.com:gastown/lume_spark.git'); + + const name = await registerRepo(rigPath, meta); + expect(name).toBe('lume_spark'); + expect(name).not.toBe('rig'); + + const entries = await listRegisteredRepos(); + expect(entries).toHaveLength(1); + expect(entries[0].name).toBe('lume_spark'); + } finally { + await root.cleanup(); + } + }); + + it('two analyze calls of differently-remoted "rig" leaves no longer collide', async () => { + // Without the remote inference both would register as "rig"; with + // inference they pick up their distinct remotes — the original issue. + const root = await createTempDir('gitnexus-gastown-2-'); + try { + const rigA = path.join(root.dbPath, 'lume_spark', 'refinery', 'rig'); + const rigB = path.join(root.dbPath, 'gemba', 'refinery', 'rig'); + await fs.mkdir(rigA, { recursive: true }); + await fs.mkdir(rigB, { recursive: true }); + initGitRepo(rigA, 'git@github.com:gastown/lume_spark.git'); + initGitRepo(rigB, 'git@github.com:gastown/gemba.git'); + + const nameA = await registerRepo(rigA, meta); + const nameB = await registerRepo(rigB, meta); + expect(nameA).toBe('lume_spark'); + expect(nameB).toBe('gemba'); + + const entries = await listRegisteredRepos(); + expect(entries.map((e) => e.name).sort()).toEqual(['gemba', 'lume_spark']); + } finally { + await root.cleanup(); + } + }); + + it('explicit --name still wins over remote inference', async () => { + const tmp = await createTempDir('gitnexus-name-wins-'); + try { + initGitRepo(tmp.dbPath, 'https://github.com/owner/from-remote.git'); + const name = await registerRepo(tmp.dbPath, meta, { name: 'user-alias' }); + expect(name).toBe('user-alias'); + } finally { + await tmp.cleanup(); + } + }); + + it('preserved alias still wins over remote inference on re-analyze', async () => { + const tmp = await createTempDir('gitnexus-preserve-alias-'); + try { + initGitRepo(tmp.dbPath, 'https://github.com/owner/from-remote.git'); + // First analyze sets the alias… + await registerRepo(tmp.dbPath, meta, { name: 'sticky-alias' }); + // …second analyze with no opts must keep it (not silently switch + // to the remote-derived name). + const name = await registerRepo(tmp.dbPath, meta); + expect(name).toBe('sticky-alias'); + } finally { + await tmp.cleanup(); + } + }); + + it('falls back to basename when no .git / no remote is available', async () => { + const tmp = await createTempDir('gitnexus-fallback-basename-'); + try { + const name = await registerRepo(tmp.dbPath, meta); + expect(name).toBe(path.basename(tmp.dbPath)); + } finally { + await tmp.cleanup(); + } + }); +}); From d976038dc835bdc5d0edc26c67081a76bfc37dd3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 09:41:30 +0100 Subject: [PATCH 44/46] fix: guard RC docker job against empty vtag and add early validation in docker.yml (#983) * Initial plan * fix: guard docker job and add tag validation in docker.yml - Add `&& needs.publish.outputs.vtag != ''` to the `docker` job's `if:` in release-candidate.yml so it is skipped when publish produces no vtag, preventing an opaque buildx "tag is needed" error. - Add an early "Validate tag input" step in docker.yml that fails fast with a clear ::error:: message when inputs.tag is empty, covering direct workflow_call invocations that bypass the release-candidate guard. Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b9afe2df-85ea-4a87-bf30-77f0e945a64d Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: scope docker.yml tag validation to workflow_call only Direct tag-push triggers (on: push, tags: v*) populate the tag from GITHUB_REF and have inputs.tag empty, so the unconditional validation step would fail every direct tag-push run. Restrict the new step to workflow_call invocations, which is the only path where an empty tag is actually a problem. Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/4b7e3bfa-15c0-4186-affa-95cd71e50153 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- .github/workflows/docker.yml | 11 +++++++++++ .github/workflows/release-candidate.yml | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index ce583e537..1b83a1e4b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -60,6 +60,17 @@ jobs: slug: gitnexus steps: + - name: Validate tag input + if: github.event_name == 'workflow_call' + shell: bash + env: + TAG_INPUT: ${{ inputs.tag }} + run: | + if [ -z "${TAG_INPUT}" ]; then + echo "::error::No tag provided to docker.yml — refusing to build/push." + exit 1 + fi + # When triggered by workflow_call the caller passes the RC tag as an input; # we check out that tag so the Dockerfile and package.json match the built image. # For tag-push events github.ref is already the tag ref — no override needed. diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 3e35a58ee..61782da1c 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -377,7 +377,7 @@ jobs: docker: name: Build & Push RC Docker images needs: [guard, publish] - if: needs.guard.outputs.should_run == 'true' + if: needs.guard.outputs.should_run == 'true' && needs.publish.outputs.vtag != '' uses: ./.github/workflows/docker.yml permissions: contents: read From 2b7cff5fd275c6c01f9f5379ffddf8d01ac6b40a Mon Sep 17 00:00:00 2001 From: evolution Date: Mon, 20 Apr 2026 15:25:31 +0800 Subject: [PATCH 45/46] feat(embeddings): structural chunking with data-driven CHUNKING_RULES dispatch (#987) * feat(embeddings): structural chunking with data-driven CHUNKING_RULES dispatch Replace hardcoded label comparisons with a CHUNKING_RULES lookup table that drives chunking strategy and text generation. Key changes: - Data-driven dispatch: CHUNKING_RULES table maps labels to chunking mode (ast-function / ast-declaration), prefix/suffix, field grouping, and structural text mode - Struct support: add Struct to AST declaration chunking with field grouping (same as Class) - Multi-chunk context: preceding chunk tail (prevTail) injected into embedding text for cross-chunk coherence - Version-gated hashes: EMBEDDING_TEXT_VERSION prefix in content hashes invalidates stale vectors when text template changes - Compact container context: first declaration line preserved in every structural chunk for identity * fix(embeddings): address PR review findings for CHUNKING_RULES refactor - Remove LABEL_ENUM from STRUCTURAL_LABELS to avoid wasted AST parses - Add maintenance note about extractStructuralNames and EMBEDDING_TEXT_VERSION - Clarify CHUNK_MODE_CHARACTER is a no-op in CHUNKING_RULES - Strengthen EMBEDDING_TEXT_VERSION test assertion to exact value --------- Co-authored-by: wangjichao --- gitnexus/src/core/embeddings/chunker.ts | 77 +++++---- .../src/core/embeddings/embedding-pipeline.ts | 26 ++- .../src/core/embeddings/text-generator.ts | 71 +++++--- gitnexus/src/core/embeddings/types.ts | 151 ++++++++++++++---- gitnexus/test/unit/chunker.test.ts | 36 ++++- gitnexus/test/unit/embedding-chunking.test.ts | 67 +++++++- gitnexus/test/unit/embedding-pipeline.test.ts | 126 ++++++++++++++- gitnexus/test/unit/text-generator.test.ts | 59 +++++++ 8 files changed, 520 insertions(+), 93 deletions(-) diff --git a/gitnexus/src/core/embeddings/chunker.ts b/gitnexus/src/core/embeddings/chunker.ts index 114a5f68c..073abfb9c 100644 --- a/gitnexus/src/core/embeddings/chunker.ts +++ b/gitnexus/src/core/embeddings/chunker.ts @@ -13,6 +13,12 @@ import { characterChunk } from './character-chunk.js'; import type { Chunk } from './character-chunk.js'; import { ensureAndParse, findDeclarationNode, findFunctionNode } from './ast-utils.js'; import { buildLineIndex, resolveChunkLines } from './line-index.js'; +import { + CHUNKING_RULES, + CHUNK_MODE_AST_DECLARATION, + CHUNK_MODE_AST_FUNCTION, + type ChunkingRule, +} from './types.js'; /** * Main chunkNode function: dispatches by label @@ -40,31 +46,39 @@ export const chunkNode = async ( ]; } - // Only function-like labels get AST chunking - if (label === 'Function' || label === 'Method' || label === 'Constructor') { - try { - const astChunks = await astChunk(content, filePath, startLine, endLine, chunkSize, overlap); - if (astChunks.length > 0) return astChunks; - } catch { - // AST parsing failed — fall through to character fallback - } + const rule = CHUNKING_RULES[label]; + if (!rule) { + return characterChunk(content, startLine, endLine, chunkSize, overlap); } - if (label === 'Class' || label === 'Interface') { - try { - const declarationChunks = await declarationChunk( - label, + try { + if (rule.mode === CHUNK_MODE_AST_FUNCTION) { + const astChunks = await astChunk( content, filePath, startLine, endLine, chunkSize, overlap, + rule, + ); + if (astChunks.length > 0) return astChunks; + } + + if (rule.mode === CHUNK_MODE_AST_DECLARATION) { + const declarationChunks = await declarationChunk( + content, + filePath, + startLine, + endLine, + chunkSize, + overlap, + rule, ); if (declarationChunks.length > 0) return declarationChunks; - } catch { - // AST parsing failed — fall through to character fallback } + } catch { + // AST parsing failed — fall through to character fallback } // Character-based fallback for everything else @@ -83,6 +97,7 @@ const astChunk = async ( endLine: number, chunkSize: number, overlap: number, + rule: ChunkingRule, ): Promise => { const tree = await ensureAndParse(content, filePath); if (!tree) return []; @@ -121,8 +136,8 @@ const astChunk = async ( statements, targetNode.startIndex, targetNode.endIndex, - true, - true, + rule.includePrefix, + rule.includeSuffix, ); }; @@ -145,13 +160,13 @@ const FIELD_LIKE_MEMBER_TYPES = new Set([ ]); const declarationChunk = async ( - label: 'Class' | 'Interface', content: string, filePath: string, startLine: number, endLine: number, chunkSize: number, overlap: number, + rule: ChunkingRule, ): Promise => { const tree = await ensureAndParse(content, filePath); if (!tree) return []; @@ -162,7 +177,7 @@ const declarationChunk = async ( const bodyNode = getDeclarationBodyNode(targetNode); if (!bodyNode) return []; - const members = collectDeclarationUnits(bodyNode, label); + const members = collectDeclarationUnits(bodyNode, rule.groupFields); if (members.length === 0) return []; return chunkByUnits( @@ -174,8 +189,8 @@ const declarationChunk = async ( members, targetNode.startIndex, targetNode.endIndex, - false, - false, + rule.includePrefix, + rule.includeSuffix, ); }; @@ -237,14 +252,22 @@ const chunkByUnits = ( if (candidateEndOffset - chunkStartOffset > chunkSize) { const oversizedUnit = units[chunkStartUnitIdx]; + const oversizedStartOffset = + chunkStartUnitIdx === 0 && includeContainerPrefixOnFirstChunk + ? containerStartOffset + : oversizedUnit.startIndex; + const oversizedEndOffset = + chunkStartUnitIdx === units.length - 1 && includeContainerSuffixOnLastChunk + ? containerEndOffset + : oversizedUnit.endIndex; const oversizedLineRange = resolveChunkLines( lineOffsets, - oversizedUnit.startIndex, - oversizedUnit.endIndex, + oversizedStartOffset, + oversizedEndOffset, baseStartLine, ); const oversizedChunks = characterChunk( - content.slice(oversizedUnit.startIndex, oversizedUnit.endIndex), + content.slice(oversizedStartOffset, oversizedEndOffset), oversizedLineRange.startLine, oversizedLineRange.endLine, chunkSize, @@ -252,8 +275,8 @@ const chunkByUnits = ( ).map((chunk, offsetIdx) => ({ ...chunk, chunkIndex: chunks.length + offsetIdx, - startOffset: chunk.startOffset + oversizedUnit.startIndex, - endOffset: chunk.endOffset + oversizedUnit.startIndex, + startOffset: chunk.startOffset + oversizedStartOffset, + endOffset: chunk.endOffset + oversizedStartOffset, })); chunks.push(...oversizedChunks); chunkStartUnitIdx += 1; @@ -325,7 +348,7 @@ const getDeclarationBodyNode = (node: any): any | null => { const collectDeclarationUnits = ( bodyNode: any, - label: 'Class' | 'Interface', + groupFields: boolean, ): Array<{ startIndex: number; endIndex: number }> => { const members: Array<{ startIndex: number; endIndex: number; groupable: boolean }> = []; @@ -335,7 +358,7 @@ const collectDeclarationUnits = ( members.push({ startIndex: child.startIndex, endIndex: child.endIndex, - groupable: label === 'Class' && FIELD_LIKE_MEMBER_TYPES.has(child.type), + groupable: groupFields && FIELD_LIKE_MEMBER_TYPES.has(child.type), }); } diff --git a/gitnexus/src/core/embeddings/embedding-pipeline.ts b/gitnexus/src/core/embeddings/embedding-pipeline.ts index 302903f8b..be16789c2 100644 --- a/gitnexus/src/core/embeddings/embedding-pipeline.ts +++ b/gitnexus/src/core/embeddings/embedding-pipeline.ts @@ -30,6 +30,7 @@ import { DEFAULT_EMBEDDING_CONFIG, EMBEDDABLE_LABELS, isShortLabel, + LABEL_METHOD, LABELS_WITH_EXPORTED, STRUCTURAL_LABELS, collectBestChunks, @@ -43,6 +44,12 @@ import { import { loadVectorExtension } from '../lbug/lbug-adapter.js'; const isDev = process.env.NODE_ENV === 'development'; +/** + * Bump this when the embedding text template changes in a way that should + * invalidate existing vectors, such as metadata/header shape changes, + * structural container context changes, or preceding-context formatting rules. + */ +export const EMBEDDING_TEXT_VERSION = 'v2'; /** * Compute a stable content fingerprint for an embeddable node. @@ -57,12 +64,13 @@ export const contentHashForNode = ( // Hash must be deterministic across runs, so exclude methodNames/fieldNames // which are populated during the batch loop via AST extraction. // Using only node.content ensures the hash stays stable. + // NOTE: A change to extractStructuralNames behavior requires bumping EMBEDDING_TEXT_VERSION. const text = generateEmbeddingText( { ...node, methodNames: undefined, fieldNames: undefined }, node.content, config, ); - return createHash('sha1').update(text).digest('hex'); + return createHash('sha1').update(EMBEDDING_TEXT_VERSION).update('\n').update(text).digest('hex'); }; /** @@ -83,7 +91,7 @@ const queryEmbeddableNodes = async ( try { let query: string; - if (label === 'Method') { + if (label === LABEL_METHOD) { // Method has parameterCount and returnType query = ` MATCH (n:Method) @@ -115,7 +123,7 @@ const queryEmbeddableNodes = async ( const rows = await executeQuery(query); for (const row of rows) { - const hasExportedColumn = label === 'Method' || LABELS_WITH_EXPORTED.has(label); + const hasExportedColumn = label === LABEL_METHOD || LABELS_WITH_EXPORTED.has(label); allNodes.push({ id: row.id ?? row[0], name: row.name ?? row[1], @@ -126,7 +134,7 @@ const queryEmbeddableNodes = async ( endLine: row.endLine ?? row[6], isExported: hasExportedColumn ? (row.isExported ?? row[7]) : undefined, description: row.description ?? (hasExportedColumn ? row[8] : row[7]), - ...(label === 'Method' + ...(label === LABEL_METHOD ? { parameterCount: row.parameterCount ?? row[9], returnType: row.returnType ?? row[10], @@ -415,8 +423,15 @@ export const runEmbeddingPipeline = async ( } } + let prevTail = ''; for (const chunk of chunks) { - const text = generateEmbeddingText(node, chunk.text, finalConfig); + const text = generateEmbeddingText( + node, + chunk.text, + finalConfig, + chunk.chunkIndex, + prevTail, + ); allTexts.push(text); allUpdates.push({ nodeId: node.id, @@ -425,6 +440,7 @@ export const runEmbeddingPipeline = async ( endLine: chunk.endLine, contentHash: hash, }); + prevTail = overlap > 0 ? chunk.text.slice(-overlap) : ''; } } diff --git a/gitnexus/src/core/embeddings/text-generator.ts b/gitnexus/src/core/embeddings/text-generator.ts index 5b96f6b5e..74e90e9ce 100644 --- a/gitnexus/src/core/embeddings/text-generator.ts +++ b/gitnexus/src/core/embeddings/text-generator.ts @@ -10,7 +10,12 @@ */ import type { EmbeddableNode, EmbeddingConfig } from './types.js'; -import { DEFAULT_EMBEDDING_CONFIG, isShortLabel } from './types.js'; +import { + CHUNKING_RULES, + DEFAULT_EMBEDDING_CONFIG, + STRUCTURAL_TEXT_MODE_DECLARATION, + isShortLabel, +} from './types.js'; /** * Truncate description to max length at sentence/word boundary @@ -95,47 +100,62 @@ const generateCodeBodyText = ( node: EmbeddableNode, codeBody: string, config: Partial, + prevTail?: string, ): string => { const header = buildMetadataHeader(node, config); - const cleaned = cleanContent(codeBody); - return `${header}\n\n${cleaned}`; + const parts = [header]; + if (prevTail) { + parts.push(`[preceding context]: ...${cleanContent(prevTail)}`); + } + parts.push('', cleanContent(codeBody)); + return parts.join('\n'); }; -/** - * Generate embedding text for Class nodes - * Signature + properties + method name list only (no method bodies) - * Method/field names come from AST extractors via node.methodNames/node.fieldNames. - */ -const generateClassText = ( - node: EmbeddableNode, - codeBody: string, - config: Partial, -): string => { - return generateStructuralTypeText(node, codeBody, config); +const getCompactContainerContext = ( + cleanedContent: string, + declarationOnly: string, +): string | undefined => { + const source = declarationOnly || cleanedContent; + const nlIdx = source.indexOf('\n'); + const firstLine = (nlIdx === -1 ? source : source.substring(0, nlIdx)).trim(); + return firstLine ? `Container: ${firstLine}` : undefined; }; const generateStructuralTypeText = ( node: EmbeddableNode, codeBody: string, config: Partial, + chunkIndex?: number, + prevTail?: string, ): string => { const header = buildMetadataHeader(node, config); const parts: string[] = [header]; + const isFirstChunk = chunkIndex === undefined || chunkIndex === 0; + const cleanedContent = cleanContent(node.content); + const declarationOnly = extractDeclarationOnly(cleanedContent); + const compactContainerContext = getCompactContainerContext(cleanedContent, declarationOnly); - if (node.methodNames?.length) { + if (compactContainerContext) { + parts.push(compactContainerContext); + } + + if (prevTail) { + parts.push(`[preceding context]: ...${cleanContent(prevTail)}`); + } + + if (isFirstChunk && node.methodNames?.length) { parts.push(`Methods: ${node.methodNames.join(', ')}`); } - if (node.fieldNames?.length) { + if (isFirstChunk && node.fieldNames?.length) { parts.push(`Properties: ${node.fieldNames.join(', ')}`); } - const declarationOnly = extractDeclarationOnly(cleanContent(node.content)); - if (declarationOnly) { + if (isFirstChunk && declarationOnly) { parts.push('', declarationOnly); } const cleanedChunk = cleanContent(codeBody); - if (cleanedChunk && cleanedChunk !== cleanContent(node.content)) { + if (cleanedChunk && cleanedChunk !== cleanedContent) { parts.push('', cleanedChunk); } @@ -229,6 +249,8 @@ export const generateEmbeddingText = ( node: EmbeddableNode, codeBody: string, config: Partial = {}, + chunkIndex?: number, + prevTail?: string, ): string => { if (isShortLabel(node.label)) { const header = buildMetadataHeader(node, config); @@ -236,15 +258,12 @@ export const generateEmbeddingText = ( return `${header}\n\n${cleaned}`; } - if (node.label === 'Class') { - return generateClassText(node, codeBody, config); + const chunkingRule = CHUNKING_RULES[node.label]; + if (chunkingRule?.structuralTextMode === STRUCTURAL_TEXT_MODE_DECLARATION) { + return generateStructuralTypeText(node, codeBody, config, chunkIndex, prevTail); } - if (node.label === 'Interface') { - return generateStructuralTypeText(node, codeBody, config); - } - - return generateCodeBodyText(node, codeBody, config); + return generateCodeBodyText(node, codeBody, config, prevTail); }; /** diff --git a/gitnexus/src/core/embeddings/types.ts b/gitnexus/src/core/embeddings/types.ts index c24dcdf40..4156e9b64 100644 --- a/gitnexus/src/core/embeddings/types.ts +++ b/gitnexus/src/core/embeddings/types.ts @@ -4,35 +4,76 @@ * Type definitions for the embedding generation and semantic search system. */ +export const LABEL_FUNCTION = 'Function' as const; +export const LABEL_METHOD = 'Method' as const; +export const LABEL_CONSTRUCTOR = 'Constructor' as const; +export const LABEL_CLASS = 'Class' as const; +export const LABEL_INTERFACE = 'Interface' as const; +export const LABEL_STRUCT = 'Struct' as const; +export const LABEL_ENUM = 'Enum' as const; +export const LABEL_TRAIT = 'Trait' as const; +export const LABEL_IMPL = 'Impl' as const; +export const LABEL_MACRO = 'Macro' as const; +export const LABEL_NAMESPACE = 'Namespace' as const; +export const LABEL_TYPE_ALIAS = 'TypeAlias' as const; +export const LABEL_TYPEDEF = 'Typedef' as const; +export const LABEL_CONST = 'Const' as const; +export const LABEL_PROPERTY = 'Property' as const; +export const LABEL_RECORD = 'Record' as const; +export const LABEL_UNION = 'Union' as const; +export const LABEL_STATIC = 'Static' as const; +export const LABEL_VARIABLE = 'Variable' as const; +export const LABEL_CODE_ELEMENT = 'CodeElement' as const; + +export const CHUNK_MODE_AST_FUNCTION = 'ast-function' as const; +export const CHUNK_MODE_AST_DECLARATION = 'ast-declaration' as const; +// CHUNK_MODE_CHARACTER exists for type completeness but is a no-op in CHUNKING_RULES — +// omit the entry entirely to get character fallback via chunker.ts dispatch. +export const CHUNK_MODE_CHARACTER = 'character' as const; + +export const STRUCTURAL_TEXT_MODE_NONE = 'none' as const; +export const STRUCTURAL_TEXT_MODE_DECLARATION = 'declaration' as const; + +export interface ChunkingRule { + mode: + | typeof CHUNK_MODE_AST_FUNCTION + | typeof CHUNK_MODE_AST_DECLARATION + | typeof CHUNK_MODE_CHARACTER; + includePrefix: boolean; + includeSuffix: boolean; + groupFields: boolean; + structuralTextMode: typeof STRUCTURAL_TEXT_MODE_NONE | typeof STRUCTURAL_TEXT_MODE_DECLARATION; +} + /** * Node labels that need chunking (have code body, potentially long) */ export const CHUNKABLE_LABELS = [ - 'Function', - 'Method', - 'Constructor', - 'Class', - 'Interface', - 'Struct', - 'Enum', - 'Trait', - 'Impl', - 'Macro', - 'Namespace', + LABEL_FUNCTION, + LABEL_METHOD, + LABEL_CONSTRUCTOR, + LABEL_CLASS, + LABEL_INTERFACE, + LABEL_STRUCT, + LABEL_ENUM, + LABEL_TRAIT, + LABEL_IMPL, + LABEL_MACRO, + LABEL_NAMESPACE, ] as const; /** * Node labels that are short (no chunking needed, embed directly) */ export const SHORT_LABELS = [ - 'TypeAlias', - 'Typedef', - 'Const', - 'Property', - 'Record', - 'Union', - 'Static', - 'Variable', + LABEL_TYPE_ALIAS, + LABEL_TYPEDEF, + LABEL_CONST, + LABEL_PROPERTY, + LABEL_RECORD, + LABEL_UNION, + LABEL_STATIC, + LABEL_VARIABLE, ] as const; /** @@ -61,26 +102,78 @@ export const isShortLabel = (label: string): boolean => (SHORT_LABELS as readonly string[]).includes(label); /** - * Node labels that have structural names (methods/fields) extractable via AST + * Node labels that have structural names (methods/fields) extractable via AST. + * Only labels that consume methodNames/fieldNames in their embedding text should + * be listed here — extra entries trigger wasted AST parses with no effect on output. */ export const STRUCTURAL_LABELS: ReadonlySet = new Set([ - 'Class', - 'Struct', - 'Interface', - 'Enum', + LABEL_CLASS, + LABEL_STRUCT, + LABEL_INTERFACE, ]); /** * Node labels that have isExported column in their schema */ export const LABELS_WITH_EXPORTED = new Set([ - 'Function', - 'Class', - 'Interface', - 'Method', - 'CodeElement', + LABEL_FUNCTION, + LABEL_CLASS, + LABEL_INTERFACE, + LABEL_METHOD, + LABEL_CODE_ELEMENT, ]) as ReadonlySet; +/** + * Labels that need special chunking and/or structural text semantics. + * Any chunkable label omitted here intentionally falls back to characterChunk + * plus generateCodeBodyText (for example Enum/Trait/Impl/Macro/Namespace). + */ +type ChunkableLabel = (typeof CHUNKABLE_LABELS)[number]; +export const CHUNKING_RULES: Readonly>> = { + [LABEL_FUNCTION]: { + mode: CHUNK_MODE_AST_FUNCTION, + includePrefix: true, + includeSuffix: true, + groupFields: false, + structuralTextMode: STRUCTURAL_TEXT_MODE_NONE, + }, + [LABEL_METHOD]: { + mode: CHUNK_MODE_AST_FUNCTION, + includePrefix: true, + includeSuffix: true, + groupFields: false, + structuralTextMode: STRUCTURAL_TEXT_MODE_NONE, + }, + [LABEL_CONSTRUCTOR]: { + mode: CHUNK_MODE_AST_FUNCTION, + includePrefix: true, + includeSuffix: true, + groupFields: false, + structuralTextMode: STRUCTURAL_TEXT_MODE_NONE, + }, + [LABEL_CLASS]: { + mode: CHUNK_MODE_AST_DECLARATION, + includePrefix: true, + includeSuffix: false, + groupFields: true, + structuralTextMode: STRUCTURAL_TEXT_MODE_DECLARATION, + }, + [LABEL_INTERFACE]: { + mode: CHUNK_MODE_AST_DECLARATION, + includePrefix: true, + includeSuffix: false, + groupFields: false, + structuralTextMode: STRUCTURAL_TEXT_MODE_DECLARATION, + }, + [LABEL_STRUCT]: { + mode: CHUNK_MODE_AST_DECLARATION, + includePrefix: true, + includeSuffix: false, + groupFields: true, + structuralTextMode: STRUCTURAL_TEXT_MODE_DECLARATION, + }, +}; + /** * Embedding pipeline phases */ diff --git a/gitnexus/test/unit/chunker.test.ts b/gitnexus/test/unit/chunker.test.ts index 77ac839ba..91bda726a 100644 --- a/gitnexus/test/unit/chunker.test.ts +++ b/gitnexus/test/unit/chunker.test.ts @@ -209,11 +209,12 @@ describe('chunkNode', () => { const result = await chunkNode('Class', content, 'test.ts', 1, 6, 90, 0); expect(result).toHaveLength(2); + expect(result[0].text).toContain('class Parser {'); expect(result[0].text).toContain('options: ParserOptions;'); expect(result[0].text).toContain('cache: Map;'); expect(result[1].text).toContain('parseJSON()'); expect(result[1].text).toContain('validate()'); - expect(result[0].startLine).toBe(2); + expect(result[0].startLine).toBe(1); expect(result[1].startLine).toBe(4); }); @@ -237,11 +238,44 @@ describe('chunkNode', () => { const result = await chunkNode('Interface', content, 'test.ts', 10, 14, 500, 0); expect(result).toHaveLength(1); + expect(result[0].text).toContain('interface Handler {'); expect(result[0].text).toContain('handle(event: Event): void;'); expect(result[0].text).toContain('validate(input: string): boolean;'); expect(result[0].text).toContain('readonly name: string;'); }); + it('uses declaration-aware chunking for Struct labels', async () => { + const content = [ + 'struct User {', + ' name: String,', + ' email: String,', + ' age: u32,', + ' address: String,', + '}', + ].join('\n'); + const tree = makeDeclarationTree('struct_item', 'declaration_list', content, [ + 'name: String,', + 'email: String,', + 'age: u32,', + 'address: String,', + ]); + createParserForLanguage.mockResolvedValue({ + parse: vi.fn().mockReturnValue(tree), + }); + + const result = await chunkNode('Struct', content, 'test.rs', 40, 45, 45, 0); + + expect(result).toHaveLength(2); + expect(result[0].text).toContain('struct User {'); + expect(result[0].text).toContain('name: String,'); + expect(result[0].text).toContain('email: String'); + const combinedText = result.map((chunk) => chunk.text).join('\n'); + expect(combinedText).toContain('email: String'); + expect(combinedText).toContain('age: u32'); + expect(combinedText).toContain('address: String'); + expect(result[0].startLine).toBe(40); + }); + it('splits a function into multiple AST-aware chunks using snippet offsets', async () => { const content = [ 'function example() {', diff --git a/gitnexus/test/unit/embedding-chunking.test.ts b/gitnexus/test/unit/embedding-chunking.test.ts index 62fdd2b23..244efe63d 100644 --- a/gitnexus/test/unit/embedding-chunking.test.ts +++ b/gitnexus/test/unit/embedding-chunking.test.ts @@ -18,12 +18,19 @@ vi.mock('../../src/core/tree-sitter/parser-loader.js', () => ({ resolveLanguageKey: vi.fn((language: string) => language), })); -vi.mock('gitnexus-shared', () => ({ +const { getLanguageFromFilename } = vi.hoisted(() => ({ getLanguageFromFilename: vi.fn().mockReturnValue('typescript'), })); +vi.mock('gitnexus-shared', () => ({ + getLanguageFromFilename, +})); + import { chunkNode } from '../../src/core/embeddings/chunker.js'; +const CLASS_PREV_TAIL_SAMPLE = 30; +const STRUCT_PREV_TAIL_SAMPLE = 20; + type FakeNode = { type: string; startIndex: number; @@ -183,10 +190,18 @@ describe('embedding-chunking integration', () => { const chunks = await chunkNode(node.label, node.content, node.filePath, 20, 25, 90, 0); expect(chunks).toHaveLength(2); - const secondText = generateEmbeddingText(node, chunks[1].text); + const secondText = generateEmbeddingText( + node, + chunks[1].text, + {}, + chunks[1].chunkIndex, + chunks[0].text.slice(-CLASS_PREV_TAIL_SAMPLE), + ); expect(secondText).toContain('Class: Parser'); - expect(secondText).toContain('Methods: parseJSON, validate'); - expect(secondText).toContain('Properties: options, cache'); + expect(secondText).toContain('Container: class Parser {'); + expect(secondText).toContain('[preceding context]: ...'); + expect(secondText).not.toContain('Methods: parseJSON, validate'); + expect(secondText).not.toContain('Properties: options, cache'); expect(secondText).toContain('parseJSON(text: string)'); }); @@ -220,9 +235,53 @@ describe('embedding-chunking integration', () => { const text = generateEmbeddingText(node, chunks[0].text); expect(text).toContain('Interface: Handler'); expect(text).toContain('Methods: handle, validate'); + expect(text).toContain('Container: interface Handler {'); expect(text).toContain('readonly name: string;'); }); + it('struct chunks retain structural container context', async () => { + getLanguageFromFilename.mockReturnValue('rust'); + const node = makeNode({ + label: 'Struct', + name: 'User', + fieldNames: ['name', 'email', 'age', 'address'], + content: `struct User { + name: String, + email: String, + age: u32, + address: String, +}`, + startLine: 40, + endLine: 45, + filePath: 'src/user.rs', + }); + createParserForLanguage.mockResolvedValue({ + parse: vi.fn().mockReturnValue( + makeDeclarationTree('struct_item', 'declaration_list', node.content, [ + { text: 'name: String,', type: 'field_definition' }, + { text: 'email: String,', type: 'field_definition' }, + { text: 'age: u32,', type: 'field_definition' }, + { text: 'address: String,', type: 'field_definition' }, + ]), + ), + }); + + const chunks = await chunkNode(node.label, node.content, node.filePath, 40, 45, 45, 0); + expect(chunks).toHaveLength(2); + + const secondText = generateEmbeddingText( + node, + chunks[1].text, + {}, + chunks[1].chunkIndex, + chunks[0].text.slice(-STRUCT_PREV_TAIL_SAMPLE), + ); + expect(secondText).toContain('Struct: User'); + expect(secondText).toContain('Container: struct User {'); + expect(secondText).not.toContain('Properties: name, email, age, address'); + expect(secondText).toContain('age: u32,'); + }); + it('metadata is present in every chunk', () => { const longContent = 'x'.repeat(3000); const node = makeNode({ diff --git a/gitnexus/test/unit/embedding-pipeline.test.ts b/gitnexus/test/unit/embedding-pipeline.test.ts index 5276fd3da..caa160d1e 100644 --- a/gitnexus/test/unit/embedding-pipeline.test.ts +++ b/gitnexus/test/unit/embedding-pipeline.test.ts @@ -1,11 +1,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createHash } from 'crypto'; -import { contentHashForNode } from '../../src/core/embeddings/embedding-pipeline.js'; +import { + contentHashForNode, + EMBEDDING_TEXT_VERSION, +} from '../../src/core/embeddings/embedding-pipeline.js'; import { generateEmbeddingText } from '../../src/core/embeddings/text-generator.js'; import type { EmbeddableNode, EmbeddingProgress } from '../../src/core/embeddings/types.js'; import { DEFAULT_EMBEDDING_CONFIG, EMBEDDABLE_LABELS } from '../../src/core/embeddings/types.js'; import { STALE_HASH_SENTINEL } from '../../src/core/lbug/schema.js'; +const CLASS_CHUNK_SIZE = 90; +const CLASS_OVERLAP = 10; + // ──────────────────────────────────────────────────────────────────────────── // contentHashForNode // ──────────────────────────────────────────────────────────────────────────── @@ -32,6 +38,8 @@ describe('contentHashForNode', () => { it('matches sha1(generateEmbeddingText(node, node.content))', () => { const node = makeNode(); const expected = createHash('sha1') + .update(EMBEDDING_TEXT_VERSION) + .update('\n') .update(generateEmbeddingText(node, node.content)) .digest('hex'); expect(contentHashForNode(node)).toBe(expected); @@ -56,6 +64,10 @@ describe('contentHashForNode', () => { const hashWithFullDefaults = contentHashForNode(node, DEFAULT_EMBEDDING_CONFIG); expect(hashWithEmptyConfig).toBe(hashWithFullDefaults); }); + + it('exports a text template version marker', () => { + expect(EMBEDDING_TEXT_VERSION).toBe('v2'); + }); }); // ──────────────────────────────────────────────────────────────────────────── @@ -439,6 +451,118 @@ describe('runEmbeddingPipeline incremental filter', () => { expect(vectorIndexCalls.length).toBeGreaterThanOrEqual(1); }); + it('does not inject preceding context when overlap is disabled', async () => { + const embedBatchSpy = vi + .fn() + .mockImplementation((texts: string[]) => + Promise.resolve(texts.map(() => new Float32Array(384))), + ); + vi.doMock('../../src/core/embeddings/embedder.js', () => ({ + initEmbedder: vi.fn().mockResolvedValue(undefined), + embedBatch: embedBatchSpy, + embedText: vi.fn().mockResolvedValue(new Float32Array(384)), + embeddingToArray: vi.fn().mockImplementation((emb: Float32Array) => Array.from(emb)), + isEmbedderReady: vi.fn().mockReturnValue(true), + })); + vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({ + loadVectorExtension: vi.fn().mockResolvedValue(undefined), + })); + + const node = makeNode({ + label: 'Class', + name: 'Parser', + content: `class Parser { + options: ParserOptions; + cache: Map; + parseJSON() { return JSON.parse("{}"); } + validate() { return true; } +}`, + startLine: 1, + endLine: 6, + }); + + const executeQuery = mockExecuteQuery([node]); + const executeWithReusedStatement = mockExecuteWithReusedStatement(); + + const { runEmbeddingPipeline } = + await import('../../src/core/embeddings/embedding-pipeline.js'); + + await runEmbeddingPipeline( + executeQuery, + executeWithReusedStatement, + onProgress, + { chunkSize: 90, overlap: 0 }, + undefined, + undefined, + new Map(), + ); + + const embeddedTexts = embedBatchSpy.mock.calls.flatMap((call) => call[0] as string[]); + const laterChunks = embeddedTexts.slice(1); + expect(laterChunks.length).toBeGreaterThan(0); + for (const text of laterChunks) { + expect(text).not.toContain('[preceding context]:'); + } + }); + + it('truncates preceding context to the configured overlap size', async () => { + const embedBatchSpy = vi + .fn() + .mockImplementation((texts: string[]) => + Promise.resolve(texts.map(() => new Float32Array(384))), + ); + vi.doMock('../../src/core/embeddings/embedder.js', () => ({ + initEmbedder: vi.fn().mockResolvedValue(undefined), + embedBatch: embedBatchSpy, + embedText: vi.fn().mockResolvedValue(new Float32Array(384)), + embeddingToArray: vi.fn().mockImplementation((emb: Float32Array) => Array.from(emb)), + isEmbedderReady: vi.fn().mockReturnValue(true), + })); + vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({ + loadVectorExtension: vi.fn().mockResolvedValue(undefined), + })); + + const node = makeNode({ + label: 'Class', + name: 'Parser', + content: `class Parser { + options: ParserOptions; + cache: Map; + parseJSON() { return JSON.parse("{}"); } + validate() { return true; } +}`, + startLine: 1, + endLine: 6, + }); + + const executeQuery = mockExecuteQuery([node]); + const executeWithReusedStatement = mockExecuteWithReusedStatement(); + + const { runEmbeddingPipeline } = + await import('../../src/core/embeddings/embedding-pipeline.js'); + + await runEmbeddingPipeline( + executeQuery, + executeWithReusedStatement, + onProgress, + { chunkSize: CLASS_CHUNK_SIZE, overlap: CLASS_OVERLAP }, + undefined, + undefined, + new Map(), + ); + + const embeddedTexts = embedBatchSpy.mock.calls.flatMap((call) => call[0] as string[]); + const laterChunk = embeddedTexts.find((text) => text.includes('[preceding context]:')); + expect(laterChunk).toBeDefined(); + expect(laterChunk).toContain('[preceding context]: ...'); + const precedingContextLine = laterChunk + ?.split('\n') + .find((line) => line.startsWith('[preceding context]: ...')); + expect(precedingContextLine).toBeDefined(); + expect(precedingContextLine).toContain('ring, any>'); + expect(precedingContextLine).not.toContain('parseJSON() {'); + }); + it('throws when DELETE for stale nodes fails with non-trivial error', async () => { mockEmbedderSetup(); diff --git a/gitnexus/test/unit/text-generator.test.ts b/gitnexus/test/unit/text-generator.test.ts index 28e16044d..e411428df 100644 --- a/gitnexus/test/unit/text-generator.test.ts +++ b/gitnexus/test/unit/text-generator.test.ts @@ -148,6 +148,65 @@ describe('text-generator', () => { expect(text).toContain('class Parser {'); expect(text).toContain('parseJSON(text: string) { return JSON.parse(text); }'); }); + + it('generates Struct text with structural metadata', () => { + const node: EmbeddableNode = { + ...baseNode, + label: 'Struct', + name: 'User', + fieldNames: ['name', 'age'], + content: `struct User { + name: String, + age: u32, +}`, + }; + const text = generateEmbeddingText(node, node.content); + expect(text).toContain('Struct: User'); + expect(text).toContain('Properties: name, age'); + expect(text).toContain('Container: struct User {'); + expect(text).toContain('struct User {'); + }); + + it('keeps compact container context on later structural chunks', () => { + const node: EmbeddableNode = { + ...baseNode, + label: 'Class', + name: 'Parser', + methodNames: ['parseJSON', 'validate'], + fieldNames: ['options', 'cache'], + content: `class Parser { + options: ParserOptions; + cache: Map; + parseJSON(text: string) { return JSON.parse(text); } + validate() { return true; } +}`, + }; + const text = generateEmbeddingText( + node, + 'validate() { return true; }', + {}, + 1, + 'parseJSON(text: string) { return JSON.parse(text); }', + ); + expect(text).toContain('Class: Parser'); + expect(text).toContain('Container: class Parser {'); + expect(text).toContain('[preceding context]: ...parseJSON(text: string)'); + expect(text).not.toContain('Methods: parseJSON, validate'); + expect(text).not.toContain('Properties: options, cache'); + }); + + it('adds preceding context to non-structural chunk text', () => { + const text = generateEmbeddingText( + baseNode, + 'return JSON.parse(text);', + {}, + 1, + 'function parseJSON(text: string): Result {', + ); + expect(text).toContain('Function: parseJSON'); + expect(text).toContain('[preceding context]: ...function parseJSON'); + expect(text).toContain('return JSON.parse(text);'); + }); }); describe('Constructor label', () => { From f53e2820261da292c345beaf919d99094c009d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Mon, 20 Apr 2026 08:59:50 +0100 Subject: [PATCH 46/46] Update Discord link in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4c273ab47..af10557f4 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

Join the official Discord to discuss ideas, issues etc!

- + Discord