diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5f6ccf404..8ab8342d6 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -48,7 +48,7 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: languages: ${{ matrix.language }} queries: security-and-quality @@ -69,6 +69,6 @@ jobs: - '**/test/fixtures/**' - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 5d564db79..5c24cfa29 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -53,6 +53,6 @@ jobs: retention-days: 5 - name: Upload to Security tab - uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: sarif_file: results.sarif diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 6ac2c4d08..76f995c2a 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -76,7 +76,7 @@ jobs: exit-code: '0' - name: Upload to Security tab - uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: sarif_file: trivy-${{ matrix.image.name }}.sarif category: trivy-${{ matrix.image.name }} diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index 42effcdf7..8b121b7c1 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -76,7 +76,7 @@ jobs: continue-on-error: true - name: Upload SARIF - uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: sarif_file: zizmor.sarif category: zizmor diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index f31a7ef5e..b9719e46b 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -11,12 +11,12 @@ "@langchain/anthropic": "^1.3.29", "@langchain/core": "^1.1.44", "@langchain/google-genai": "^2.1.30", - "@langchain/langgraph": "^1.2.9", + "@langchain/langgraph": "^1.3.2", "@langchain/ollama": "^1.2.6", "@langchain/openai": "^1.4.5", "@sigma/edge-curve": "^3.1.0", "@tailwindcss/vite": "^4.3.0", - "axios": "^1.16.0", + "axios": "^1.16.1", "d3": "^7.9.0", "dompurify": "^3.4.3", "gitnexus-shared": "file:../gitnexus-shared", @@ -48,12 +48,12 @@ }, "devDependencies": { "@babel/types": "^7.29.0", - "@playwright/test": "^1.58.2", + "@playwright/test": "^1.60.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/dompurify": "^3.2.0", - "@types/node": "^25.6.0", + "@types/node": "^25.9.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", @@ -1393,13 +1393,14 @@ } }, "node_modules/@langchain/langgraph": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.2.9.tgz", - "integrity": "sha512-3c7BtGycHC2v9p6w/Hv8L7kEl1YnZYOQTDJtmAp3knk6JOedO7d2bYP3y0SRyhv5orUEGf/KGvx8ZsB/ideP7g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.3.2.tgz", + "integrity": "sha512-SL7Ktsr681R7da+1b2MVOWEbaCoFJOXEJPTGOjg4JIG4C7quWbTYC8DzxhcCxte6D/8cGp0rYDBnbKLXEpNqlA==", "license": "MIT", "dependencies": { - "@langchain/langgraph-checkpoint": "^1.0.1", - "@langchain/langgraph-sdk": "~1.8.9", + "@langchain/langgraph-checkpoint": "^1.0.2", + "@langchain/langgraph-sdk": "~1.9.4", + "@langchain/protocol": "^0.0.15", "@standard-schema/spec": "1.1.0", "uuid": "^10.0.0" }, @@ -1407,7 +1408,7 @@ "node": ">=18" }, "peerDependencies": { - "@langchain/core": "^1.1.40", + "@langchain/core": "^1.1.44", "zod": "^3.25.32 || ^4.2.0", "zod-to-json-schema": "^3.x" }, @@ -1418,9 +1419,9 @@ } }, "node_modules/@langchain/langgraph-checkpoint": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.1.tgz", - "integrity": "sha512-HM0cJLRpIsSlWBQ/xuDC67l52SqZ62Bh2Y61DX+Xorqwoh5e1KxYvfCD7GnSTbWWhjBOutvnR0vPhu4orFkZfw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.2.tgz", + "integrity": "sha512-F4E5Tr0nt8FGghgdscJtHw+ABzChOHeI80R7Y1pjIHdiJom6c2ieo76vL+FWiny80JmoGqhrVAEIWrw0cXKPxg==", "license": "MIT", "dependencies": { "uuid": "^10.0.0" @@ -1429,7 +1430,7 @@ "node": ">=18" }, "peerDependencies": { - "@langchain/core": "^1.0.1" + "@langchain/core": "^1.1.44" } }, "node_modules/@langchain/langgraph-checkpoint/node_modules/uuid": { @@ -1446,27 +1447,25 @@ } }, "node_modules/@langchain/langgraph-sdk": { - "version": "1.8.10", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.8.10.tgz", - "integrity": "sha512-wrB3rkRw5KAmsqezwvKP3midT4qJrV6Hj9XJMYo+cbvXC4HYpSAmyY/VriSyeTFRbLG/OP/pY2Yz+9Z54nSaXQ==", + "version": "1.9.9", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.9.tgz", + "integrity": "sha512-aiWHbmqxWj5sAMwFsaB3eSGQvKpMbUKTlt9zbAC0T7IiFqDYUWi9gJUGsTdvJutAfB3P/NzC4s8ETUtUQEUlYg==", "license": "MIT", "dependencies": { + "@langchain/protocol": "^0.0.15", "@types/json-schema": "^7.0.15", "p-queue": "^9.0.1", "p-retry": "^7.1.1", "uuid": "^13.0.0" }, "peerDependencies": { - "@langchain/core": "^1.1.16", + "@langchain/core": "^1.1.44", "react": "^18 || ^19", "react-dom": "^18 || ^19", "svelte": "^4.0.0 || ^5.0.0", "vue": "^3.0.0" }, "peerDependenciesMeta": { - "@langchain/core": { - "optional": true - }, "react": { "optional": true }, @@ -1488,9 +1487,9 @@ "license": "MIT" }, "node_modules/@langchain/langgraph-sdk/node_modules/p-queue": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.2.0.tgz", - "integrity": "sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.0.tgz", + "integrity": "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==", "license": "MIT", "dependencies": { "eventemitter3": "^5.0.4", @@ -1516,9 +1515,9 @@ } }, "node_modules/@langchain/langgraph-sdk/node_modules/uuid": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.1.tgz", - "integrity": "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -1587,6 +1586,12 @@ "@langchain/core": "^1.1.42" } }, + "node_modules/@langchain/protocol": { + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/@langchain/protocol/-/protocol-0.0.15.tgz", + "integrity": "sha512-MllvbpMjqHevUm+v94M422mH7XKN+wGCvJRBVROTWBotEDOATYB4Ktk2UheYP859y9o2LlhtPek5t1T9eyfAbQ==", + "license": "MIT" + }, "node_modules/@mapbox/node-pre-gyp": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.3.tgz", @@ -1684,13 +1689,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.58.2" + "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -2854,13 +2859,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/prismjs": { @@ -3414,16 +3419,42 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", - "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -5444,9 +5475,9 @@ } }, "node_modules/is-network-error": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz", - "integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", + "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", "license": "MIT", "engines": { "node": ">=16" @@ -7572,13 +7603,13 @@ } }, "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.2" + "playwright-core": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -7591,9 +7622,9 @@ } }, "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -8567,9 +8598,9 @@ } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "devOptional": true, "license": "MIT" }, diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 318624e3e..f6943cbf2 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -21,12 +21,12 @@ "@langchain/anthropic": "^1.3.29", "@langchain/core": "^1.1.44", "@langchain/google-genai": "^2.1.30", - "@langchain/langgraph": "^1.2.9", + "@langchain/langgraph": "^1.3.2", "@langchain/ollama": "^1.2.6", "@langchain/openai": "^1.4.5", "@sigma/edge-curve": "^3.1.0", "@tailwindcss/vite": "^4.3.0", - "axios": "^1.16.0", + "axios": "^1.16.1", "d3": "^7.9.0", "dompurify": "^3.4.3", "gitnexus-shared": "file:../gitnexus-shared", @@ -58,12 +58,12 @@ }, "devDependencies": { "@babel/types": "^7.29.0", - "@playwright/test": "^1.58.2", + "@playwright/test": "^1.60.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/dompurify": "^3.2.0", - "@types/node": "^25.6.0", + "@types/node": "^25.9.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", diff --git a/gitnexus/src/core/ingestion/languages/cpp/adl.ts b/gitnexus/src/core/ingestion/languages/cpp/adl.ts index 0bcda9322..9c23c73cc 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/adl.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/adl.ts @@ -24,22 +24,18 @@ * V2 additionally walks class ancestors (via MRO), so base-class enclosing * namespaces also contribute associated namespaces. * - * **GitNexus approximation (not strict ISO C++ ADL):** passing a qualified - * function reference like `utils::worker` contributes `utils` to the associated - * set, enabling resolution of unqualified calls like `with_callback(utils::worker)` - * to `utils::with_callback`. Under ISO C++ `[basic.lookup.argdep]`, associated - * entities for function-type arguments come from the **parameter types and return - * type** of each function in the overload set — NOT the function's enclosing - * namespace. For `void worker()`, the standard-compliant associated set is empty. - * GitNexus instead contributes the enclosing namespace of any Function/Method - * def whose simple name matches, because it enables the dominant real-world ADL - * pattern at reasonable precision cost. + * Function-reference arguments follow ISO C++ `[basic.lookup.argdep]`: + * associated entities come from the parameter types and return type of each + * referenced function in the overload set, not from the function's enclosing + * namespace. For `void worker()`, the associated set is empty. For + * `void worker(api::Token)` or `api::Token make_token()`, `api` is associated + * through `Token`. * - * For qualified refs (e.g. `utils::worker`) the namespace is confirmed via a - * workspace lookup (only contributed when a Function/Method named `worker` exists - * in `utils`). For unqualified refs the workspace is searched for any Function - * def with that simple name. Locally-declared function-pointer variables - * (e.g. `void (*g)()`) and function parameters are excluded from this path. + * For qualified refs (e.g. `utils::worker`) the workspace lookup is restricted + * to functions/methods named `worker` in `utils`; for unqualified refs the + * workspace is searched for matching functions/methods by simple name. Locally + * declared function-pointer variables and function parameters are excluded + * from this path. * * ADL candidates are merged with ordinary unqualified-lookup candidates * in the free-call fallback before overload narrowing. @@ -70,6 +66,7 @@ import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { normalizeCppParamType } from './arity-metadata.js'; import { isCppInlineNamespaceScope } from './inline-namespaces.js'; /** @@ -97,11 +94,8 @@ export interface CppAdlArgInfo { /** When set, the arg is a potential free-function reference (not a locally- * declared function-pointer variable or function parameter). Contains the * identifier text as written in source (e.g. `"utils::worker"` or - * `"worker"`). GitNexus approximation: the function's enclosing namespace - * is contributed to the ADL associated set. For qualified refs a workspace - * lookup confirms a Function/Method with that simple name exists in the - * namespace before contributing; for unqualified refs every namespace - * containing a matching Function/Method def is contributed. */ + * `"worker"`). Resolution contributes associated namespaces from each + * referenced Function/Method def's parameter and return types. */ readonly functionRefText?: string; } @@ -207,7 +201,12 @@ export function pickCppAdlCandidates( for (const arg of args) { collectAssociatedNamespacesForAdlArg(arg, scopes, associatedNamespaces); if (arg.functionRefText !== undefined) { - collectFunctionRefNamespaces(arg.functionRefText, parsedFiles, associatedNamespaces); + collectFunctionTypeAssociatedNamespaces( + arg.functionRefText, + scopes, + parsedFiles, + associatedNamespaces, + ); } } if (associatedNamespaces.size === 0) return undefined; @@ -472,23 +471,12 @@ function findCppClassDefBySimpleName( } /** - * Contribute associated namespaces for a function-reference argument. - * - * - **Qualified refs** (`utils::worker`, `outer::inner::fn`): the namespace - * is extracted from the qualifier text (converting `::` to `.` for dot-joined - * QName matching). A workspace lookup then **verifies** that a Function or - * Method def named `worker` (the simple name after the last `::`) actually - * exists in the extracted namespace. This prevents false positives from - * namespace-qualified variables, enum values, and static data members, which - * also produce `qualified_identifier` AST nodes in tree-sitter-cpp (the - * AST node type alone does not distinguish functions from non-function names). - * - **Unqualified refs** (`worker`): the workspace is searched for any - * Function/Method def whose simple name matches. Every distinct enclosing - * namespace found is added — overloads across the same namespace produce - * a single entry; GitNexus does not select a specific overload at this stage. + * Contribute associated namespaces for a function-reference argument by walking + * the referenced overload set's parameter and return types. */ -function collectFunctionRefNamespaces( +function collectFunctionTypeAssociatedNamespaces( refText: string, + scopes: ScopeResolutionIndexes, parsedFiles: readonly ParsedFile[], out: Set, ): void { @@ -511,30 +499,130 @@ function collectFunctionRefNamespaces( for (const def of scope.ownedDefs) { if (def.type !== 'Function' && def.type !== 'Method') continue; const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; - if (simple === simpleName) { - out.add(nsText); - return; // Namespace confirmed; no need to scan further files. - } + if (simple === simpleName) collectAssociatedNamespacesForFunctionDef(def, scopes, out); } } } return; } - // Unqualified: search all namespace scopes for a Function def with this - // simple name and contribute its enclosing namespace. + // Unqualified function references are approximated workspace-wide, matching + // the previous V1 lookup scope. The stricter part of this PR is what each + // overload contributes: only namespaces from parameter/return types, never + // the function's own enclosing namespace. for (const parsed of parsedFiles) { - const scopesById = new Map(); - for (const sc of parsed.scopes) scopesById.set(sc.id, sc); for (const scope of parsed.scopes) { if (scope.kind !== 'Namespace') continue; for (const def of scope.ownedDefs) { if (def.type !== 'Function' && def.type !== 'Method') continue; const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; if (simple !== refText) continue; - const nsQName = computeNamespaceQName(scope, scopesById); - if (nsQName !== '') out.add(nsQName); + collectAssociatedNamespacesForFunctionDef(def, scopes, out); } } } } + +function collectAssociatedNamespacesForFunctionDef( + def: SymbolDefinition, + scopes: ScopeResolutionIndexes, + out: Set, +): void { + const parameterTypes = def.parameterTypeClasses?.map((typeClass) => typeClass.base); + for (const paramType of parameterTypes ?? def.parameterTypes ?? []) { + collectAssociatedNamespacesForFunctionTypeText(paramType, scopes, out); + } + if (def.returnType !== undefined) { + collectAssociatedNamespacesForFunctionTypeText(def.returnType, scopes, out); + } +} + +function collectAssociatedNamespacesForFunctionTypeText( + typeText: string, + scopes: ScopeResolutionIndexes, + out: Set, +): void { + for (const token of extractCppTypeNameTokens(typeText)) { + if (isIgnoredCppAdlNamespace(token.namespaceName)) continue; + addAssociatedNamespaceForClassName(token.simpleName, scopes, out); + if (token.namespaceName !== '') out.add(token.namespaceName); + } +} + +function extractCppTypeNameTokens(typeText: string): readonly { + readonly simpleName: string; + readonly namespaceName: string; +}[] { + const cleaned = normalizeCppParamType(typeText); + if (cleaned === '' || isPrimitiveCppAdlType(cleaned)) return []; + const out: { simpleName: string; namespaceName: string }[] = []; + const seen = new Set(); + const tokenSource = typeText.includes('<') ? `${cleaned} ${typeText}` : cleaned; + for (const rawToken of tokenSource.match(/[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*/g) ?? []) { + if (isPrimitiveCppAdlType(rawToken)) continue; + const segments = rawToken.split('::').filter((part) => part.length > 0); + const simpleName = segments.at(-1) ?? ''; + if (simpleName === '' || isPrimitiveCppAdlType(simpleName)) continue; + const namespaceName = segments.length > 1 ? segments.slice(0, -1).join('.') : ''; + const key = `${namespaceName}\0${simpleName}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ + simpleName, + namespaceName, + }); + } + return out; +} + +const CPP_ADL_PRIMITIVE_OR_KEYWORD_TYPES = new Set([ + 'alignas', + 'alignof', + 'auto', + 'bool', + 'char', + 'char8_t', + 'char16_t', + 'char32_t', + 'class', + 'const', + 'consteval', + 'constexpr', + 'constinit', + 'decltype', + 'double', + 'enum', + 'explicit', + 'extern', + 'float', + 'inline', + 'int', + 'long', + 'mutable', + 'noexcept', + 'null', + 'register', + 'short', + 'signed', + 'static', + 'string', + 'struct', + 'template', + 'thread_local', + 'typename', + 'union', + 'unknown', + 'unsigned', + 'void', + 'volatile', + 'wchar_t', + '...', +]); + +function isPrimitiveCppAdlType(typeText: string): boolean { + return CPP_ADL_PRIMITIVE_OR_KEYWORD_TYPES.has(typeText); +} + +function isIgnoredCppAdlNamespace(namespaceName: string): boolean { + return namespaceName === 'std' || namespaceName.startsWith('std.'); +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index 354f6ce4c..86384a759 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -126,6 +126,14 @@ export function emitCppScopeCaptures( JSON.stringify(arity.parameterTypeClasses), ); } + const returnType = extractCppDeclarationReturnType(fnNode); + if (returnType !== undefined) { + grouped['@declaration.return-type'] = syntheticCapture( + '@declaration.return-type', + fnNode, + returnType, + ); + } if (hasExplicitSpecifier(fnNode)) { grouped['@declaration.is-explicit'] = syntheticCapture( '@declaration.is-explicit', @@ -417,6 +425,30 @@ export function emitCppScopeCaptures( return out; } +function extractCppDeclarationReturnType(fnNode: SyntaxNode): string | undefined { + const typeNode = fnNode.childForFieldName('type'); + if (typeNode === null) return undefined; + const funcDeclarator = findFunctionDeclarator(fnNode); + if (funcDeclarator !== null && isCppUnsupportedReturnTypeDeclarator(funcDeclarator)) { + return undefined; + } + const typeText = typeNode.text.trim(); + if (typeText !== 'auto') return typeText.length > 0 ? typeText : undefined; + if (funcDeclarator === null) return typeText; + for (let i = 0; i < funcDeclarator.namedChildCount; i++) { + const child = funcDeclarator.namedChild(i); + if (child?.type !== 'trailing_return_type') continue; + const typeDesc = child.firstNamedChild; + return typeDesc?.text.trim() || typeText; + } + return typeText; +} + +function isCppUnsupportedReturnTypeDeclarator(funcDeclarator: SyntaxNode): boolean { + const text = funcDeclarator.text; + return /\boperator\b/.test(text) || /(^|[(:\s])~\s*[A-Za-z_]\w*/.test(text); +} + /** * Walk every C++ class/struct base clause and emit `@reference.inherits` * captures for each base so scope resolution can resolve them into EXTENDS diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 963cf4862..973d4ac76 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -1087,6 +1087,7 @@ const KNOWN_SUB_TAGS: ReadonlySet = new Set([ '@declaration.required-parameter-count', '@declaration.parameter-types', '@declaration.parameter-type-classes', + '@declaration.return-type', '@declaration.template-constraints', '@declaration.is-explicit', ]); diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index cf9ab483b..5a9290394 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -2907,9 +2907,11 @@ export class LocalBackend { limit?: number; offset?: number; summaryOnly?: boolean; + skipPerSymbolEnrichment?: boolean; }, ): Promise { const { maxDepth, relationTypes, includeTests, minConfidence } = opts; + const skipPerSymbolEnrichment = opts.skipPerSymbolEnrichment ?? false; const hasExplicitLimit = typeof opts.limit === 'number' && Number.isFinite(opts.limit); const paginationLimit = hasExplicitLimit ? Math.max(1, Math.min(Math.trunc(opts.limit!), 10000)) @@ -3066,13 +3068,25 @@ export class LocalBackend { const directCount = (grouped[1] || []).length; let affectedProcesses: any[] = []; let affectedModules: any[] = []; + // Per-symbol process membership: maps impacted symbol id -> list of processes + // it participates in. Populated by a second chunked Cypher pass below when + // any process is affected at all. Surfaced as `processes: [...]` on each + // byDepth item so consumers can tell which caller belongs to which cron/ + // webhook/route without a follow-up query. + const perSymbolProcesses = new Map< + string, + Array<{ id: string; label: string; processType: string; step: number }> + >(); + + // Chunking bounds for batched DB round-trips. Declared at function scope so + // both the in-block enrichment passes and the post-pagination per-symbol + // process enrichment can reference them. + const CHUNK_SIZE = 100; + // Max number of chunks to process to avoid unbounded DB round-trips. + // Configurable via env IMPACT_MAX_CHUNKS, default 10 => max items = 1000 + const MAX_CHUNKS = parseInt(process.env.IMPACT_MAX_CHUNKS || '10', 10); if (impacted.length > 0) { - const CHUNK_SIZE = 100; - // Max number of chunks to process to avoid unbounded DB round-trips. - // Configurable via env IMPACT_MAX_CHUNKS, default 10 => max items = 1000 - const MAX_CHUNKS = parseInt(process.env.IMPACT_MAX_CHUNKS || '10', 10); - // ── Process enrichment: batched chunking (bounded by MAX_CHUNKS) ─ // Uses merged Cypher query (WITH + OPTIONAL MATCH) to fetch // process + entry point info in 1 round-trip per chunk. Converted to @@ -3218,6 +3232,10 @@ export class LocalBackend { })) .sort((a, b) => b.total_hits - a.total_hits); + // Per-symbol process membership is populated post-pagination (see below) + // so it covers exactly the symbols returned in byDepth, not a pre-capped + // flat slice that could miss depth-2+ symbols when depth-1 is large. + // ── Module enrichment: use same cap as process enrichment and parameterized queries const maxItems = Math.min(impacted.length, MAX_CHUNKS * CHUNK_SIZE); const cappedImpacted = impacted.slice(0, maxItems); @@ -3360,7 +3378,7 @@ export class LocalBackend { return base; } - // Apply limit/offset pagination per depth level + // Apply limit/offset pagination per depth level. const paginatedGrouped: Record = {}; let anyTruncated = false; for (const [depth, items] of Object.entries(grouped)) { @@ -3372,8 +3390,82 @@ export class LocalBackend { } } + // ── Per-symbol process membership enrichment (post-pagination) ─────── + // Runs after paginatedGrouped is built so we enrich only the IDs that + // actually appear in the response. This eliminates the false-empty + // processes:[] case where a depth-2+ symbol's flat position in `impacted` + // exceeded MAX_CHUNKS*CHUNK_SIZE even though it is returned by byDepth. + // Also uses DISTINCT + MIN(r.step) per (symbol, process) pair to avoid + // duplicate entries when a symbol has multiple STEP_IN_PROCESS edges. + // Skipped entirely when `skipPerSymbolEnrichment` is set (group cross-repo + // fan-out, which consumes byDepth but not byDepth[].processes); the + // attach-loop below still stamps an empty processes:[] for shape stability. + let perSymbolEnrichmentCapped = false; + if (affectedProcesses.length > 0 && !skipPerSymbolEnrichment) { + // Collect unique IDs from the paginated result in one pass. + const pageIds = new Set(); + for (const items of Object.values(paginatedGrouped)) { + for (const it of items) { + const id = String(it.id ?? ''); + if (id) pageIds.add(id); + } + } + // Bound the enrichment to the same ceiling as the aggregation pass + // (MAX_CHUNKS * CHUNK_SIZE) so a large paginated page cannot trigger + // unbounded DB round-trips (DoD 2.6). When capped, mark the result + // partial so callers know some returned symbols may carry an empty + // processes:[] that is a cap artifact, not a true absence. + const maxPageIds = MAX_CHUNKS * CHUNK_SIZE; + let pageIdArr = Array.from(pageIds); + if (pageIdArr.length > maxPageIds) { + pageIdArr = pageIdArr.slice(0, maxPageIds); + perSymbolEnrichmentCapped = true; + } + for (let i = 0; i < pageIdArr.length; i += CHUNK_SIZE) { + const chunkIds = pageIdArr.slice(i, i + CHUNK_SIZE); + try { + const rows = await executeParameterized( + repo.id, + ` + MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) + WHERE s.id IN $ids + RETURN s.id AS sid, p.id AS pid, p.heuristicLabel AS pName, + p.processType AS pType, MIN(r.step) AS step + `, + { ids: chunkIds }, + ).catch(() => []); + for (const row of rows) { + const sid = row.sid ?? row[0]; + if (!sid) continue; + const procEntry = { + id: String(row.pid ?? row[1] ?? ''), + label: String(row.pName ?? row[2] ?? ''), + processType: String(row.pType ?? row[3] ?? ''), + step: Number(row.step ?? row[4] ?? -1), + }; + const list = perSymbolProcesses.get(String(sid)); + if (list) list.push(procEntry); + else perSymbolProcesses.set(String(sid), [procEntry]); + } + } catch (e) { + logQueryError('impact:per-symbol-process-chunk', e); + } + } + } + + // Attach processes field to each paginated item. + for (const items of Object.values(paginatedGrouped)) { + for (const it of items) { + it.processes = perSymbolProcesses.get(String(it.id)) ?? []; + } + } + return { ...base, + // Surface partial if the per-symbol enrichment was capped, even when the + // BFS traversal itself completed — some returned symbols may carry an + // empty processes:[] that is a cap artifact rather than a true absence. + ...(perSymbolEnrichmentCapped && { partial: true }), ...(anyTruncated && { pagination: { ...(Number.isFinite(paginationLimit) && { limit: paginationLimit }), @@ -3467,11 +3559,20 @@ export class LocalBackend { ]; try { + // skipPerSymbolEnrichment suppresses ONLY the per-symbol STEP_IN_PROCESS + // enrichment pass while preserving byDepth. Group-mode cross-repo fan-out + // may fan across many repos; the per-symbol pass adds up to MAX_CHUNKS + // extra round-trips per repo, which is unacceptable at group scale. But + // cross-impact fan-out DOES consume byDepth (cross-impact.ts reads + // fan.byDepth to populate group by_depth), so summaryOnly would wrongly + // drop it. Group callers do not consume byDepth[].processes, so skipping + // only that enrichment is the correct, targeted suppression. return await this._runImpactBFS(repo, sym, symType, dir, { maxDepth: opts.maxDepth, relationTypes, includeTests: opts.includeTests, minConfidence: opts.minConfidence, + skipPerSymbolEnrichment: true, }); } catch { return null; diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 15b7dc7d4..6ee8b5488 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -336,7 +336,7 @@ Output includes: - summary: direct callers, processes affected, modules affected - affected_processes: which execution flows break and at which step - affected_modules: which functional areas are hit (direct vs indirect) -- byDepth: affected symbols grouped by traversal depth (paginated by limit/offset; omitted when summaryOnly:true — use byDepthCounts for totals per depth, pagination object when truncated) +- byDepth: affected symbols grouped by traversal depth (paginated by limit/offset; omitted when summaryOnly:true — use byDepthCounts for totals per depth, pagination object when truncated). Each item includes a processes:[{id,label,processType,step}] field listing the execution flows that symbol participates in. Empty when the symbol has no process membership. Can ALSO be empty when partial:true is set — either the process-aggregation pass hit its cap before detecting affected processes, or per-symbol enrichment was capped on a very large page. When partial:true, do NOT treat processes:[] as proof of no participation; cross-check the top-level affected_processes list. Depth groups: - d=1: WILL BREAK (direct callers/importers) diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref-return-strict/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref-return-strict/app.cpp new file mode 100644 index 000000000..d8d768ded --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref-return-strict/app.cpp @@ -0,0 +1,7 @@ +#include "lib.h" + +namespace caller { + void run() { + run_callback(utils::make_token); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref-return-strict/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref-return-strict/lib.h new file mode 100644 index 000000000..857d9cc42 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref-return-strict/lib.h @@ -0,0 +1,11 @@ +#pragma once + +namespace api { + struct Token { + friend void run_callback(Token t) {} + }; +} + +namespace utils { + api::Token make_token(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref-strict/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref-strict/app.cpp new file mode 100644 index 000000000..6c3eb0786 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref-strict/app.cpp @@ -0,0 +1,7 @@ +#include "lib.h" + +namespace caller { + void run() { + run_callback(utils::worker); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref-strict/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref-strict/lib.h new file mode 100644 index 000000000..9463986aa --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref-strict/lib.h @@ -0,0 +1,11 @@ +#pragma once + +namespace api { + struct Token { + friend void run_callback(Token t) {} + }; +} + +namespace utils { + void worker(api::Token token); +} diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index babdaf569..a4a9a9fa8 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -2963,41 +2963,66 @@ describe('C++ ADL — block-scope function declaration suppresses ADL', () => { }); // --------------------------------------------------------------------------- -// ADL V2 — free-function reference args contribute their namespace. +// ADL V2 - strict function-type associated entities. // -// GitNexus approximation (not strict ISO C++ ADL): when a qualified_identifier -// like `utils::worker` is passed as an argument, GitNexus contributes the -// enclosing namespace (`utils`) to the associated set, provided a Function or -// Method named `worker` is found in the `utils` namespace at resolution time. -// Under ISO C++ [basic.lookup.argdep] the associated entities for a function-type -// argument come from the parameter types and return type of the overload set — -// NOT the function's enclosing namespace. For `void worker()`, the standard- -// compliant associated set is empty. The approximation captures the dominant -// real-world pattern (pass a utility function → find its sibling) at the cost -// of potential false positives when an unrelated function with the same simple -// name exists in the same namespace (bounded by the workspace-function lookup). +// Function-reference arguments follow strict ISO C++ ADL: GitNexus walks the +// referenced overload set's parameter and return types instead of contributing +// the referenced function's enclosing namespace. +// For `void worker()`, the associated set is empty; for `void worker(api::Token)` +// or `api::Token make_token()`, `api` is associated through `Token`. // --------------------------------------------------------------------------- -describe('C++ ADL — qualified free-function reference contributes its namespace', () => { +describe('C++ ADL - free-function reference does not contribute its namespace', () => { let result: PipelineResult; beforeAll(async () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-adl-free-func-ref'), () => {}); }, 60000); - it('with_callback(utils::worker) resolves to utils::with_callback via ADL', () => { + it('with_callback(utils::worker) emits zero CALLS edges when worker has no class parameter or return type', () => { const calls = getRelationships(result, 'CALLS'); const cbCalls = calls.filter((c) => c.source === 'run' && c.target === 'with_callback'); - // Ordinary lookup inside caller::run finds nothing (no `using`, no local - // declaration). utils::worker is a qualified_identifier argument, so ADL - // contributes `utils` to the associated-namespace set. utils::with_callback - // is then discovered as the sole candidate. - expect(cbCalls.length).toBe(1); - expect(cbCalls[0].targetFilePath).toContain('utils.h'); + expect(cbCalls.length).toBe(0); }); }); -describe('C++ ADL — overloaded free-function reference does not crash', () => { +describe('C++ ADL - free-function reference contributes parameter-type associated namespace', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-adl-free-func-ref-strict'), + () => {}, + ); + }, 60000); + + it('run_callback(utils::worker) resolves hidden friend through worker(api::Token)', () => { + const calls = getRelationships(result, 'CALLS'); + const cbCalls = calls.filter((c) => c.source === 'run' && c.target === 'run_callback'); + expect(cbCalls.length).toBe(1); + expect(cbCalls[0].targetFilePath).toContain('lib.h'); + }); +}); + +describe('C++ ADL - free-function reference contributes return-type associated namespace', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-adl-free-func-ref-return-strict'), + () => {}, + ); + }, 60000); + + it('run_callback(utils::make_token) resolves hidden friend through api::Token return type', () => { + const calls = getRelationships(result, 'CALLS'); + const cbCalls = calls.filter((c) => c.source === 'run' && c.target === 'run_callback'); + expect(cbCalls.length).toBe(1); + expect(cbCalls[0].targetFilePath).toContain('lib.h'); + }); +}); + +describe('C++ ADL - overloaded free-function reference stays strict', () => { let result: PipelineResult; beforeAll(async () => { @@ -3007,15 +3032,10 @@ describe('C++ ADL — overloaded free-function reference does not crash', () => ); }, 60000); - it('with_callback(utils::worker) with overloaded utils::worker still resolves utils::with_callback via ADL', () => { + it('with_callback(utils::worker) with overloaded utils::worker still emits zero CALLS edges', () => { const calls = getRelationships(result, 'CALLS'); const cbCalls = calls.filter((c) => c.source === 'run' && c.target === 'with_callback'); - // utils::worker has two overloads (worker() and worker(int)). V1 - // simplification: contribute the namespace if ANY overload exists in the - // workspace, regardless of which one would be selected. The namespace - // `utils` is still added, and utils::with_callback is discovered. - expect(cbCalls.length).toBe(1); - expect(cbCalls[0].targetFilePath).toContain('utils.h'); + expect(cbCalls.length).toBe(0); }); }); @@ -3035,10 +3055,10 @@ describe('C++ ADL — namespace-qualified variable arg does NOT contribute names // data::value is a namespace-qualified integer variable. tree-sitter-cpp // produces a qualified_identifier AST node regardless of whether `value` // denotes a function, variable, enum, or static member. The GitNexus guard - // in collectFunctionRefNamespaces verifies that a Function/Method named - // `value` exists in the `data` namespace before contributing it. Since - // `data::value` is an int variable, `data` is never added to the associated - // set, so data::process is never found as an ADL candidate. + // in collectFunctionTypeAssociatedNamespaces verifies that a Function/Method + // named `value` exists in the `data` namespace before walking any function + // type. Since `data::value` is an int variable, no function type is walked, + // so data::process is never found as an ADL candidate. expect(processCalls.length).toBe(0); }); }); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index bd17483ba..f91349d91 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -363,6 +363,11 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly { expect(result.target).toBeDefined(); }); + it('impact byDepth items include a processes field (default empty when no processes)', async () => { + // Resolver returns target; BFS returns one frontier caller; no STEP_IN_PROCESS rows. + (executeParameterized as any).mockResolvedValue([ + { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, + ]); + (executeQuery as any).mockResolvedValue([ + { + id: 'func:caller', + name: 'caller', + type: 'Function', + filePath: 'src/uses-main.ts', + relType: 'CALLS', + confidence: 0.9, + }, + ]); + + const result = await backend.callTool('impact', { target: 'main', direction: 'upstream' }); + const d1 = result.byDepth?.[1] || result.byDepth?.['1'] || []; + expect(d1.length).toBeGreaterThan(0); + for (const item of d1) { + expect(item).toHaveProperty('processes'); + expect(Array.isArray(item.processes)).toBe(true); + } + }); + + it('impact populates byDepth processes when STEP_IN_PROCESS rows exist', async () => { + (executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => { + // Symbol resolver name-lookup + if (cypher.includes('WHERE n.name =')) { + return Promise.resolve([ + { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, + ]); + } + // Aggregation pass (must return at least one row so per-symbol pass is gated open) + if (cypher.includes('COUNT(DISTINCT s.id)')) { + return Promise.resolve([ + { + pId: 'proc:cron_daily', + name: 'Daily cron', + heuristicLabel: 'Daily cron', + processType: 'cron', + entryPointId: 'func:cron_entry', + hits: 1, + minStep: 1, + stepCount: 5, + epName: 'cron_entry', + epType: 'Function', + epFilePath: 'src/cron.ts', + }, + ]); + } + // New per-symbol pass added by this change + if (cypher.includes('RETURN s.id AS sid')) { + return Promise.resolve([ + { + sid: 'func:caller', + pid: 'proc:cron_daily', + pName: 'Daily cron', + pType: 'cron', + step: 2, + }, + ]); + } + return Promise.resolve([]); + }); + (executeQuery as any).mockResolvedValue([ + { + id: 'func:caller', + name: 'caller', + type: 'Function', + filePath: 'src/uses-main.ts', + relType: 'CALLS', + confidence: 0.9, + }, + ]); + + const result = await backend.callTool('impact', { target: 'main', direction: 'upstream' }); + const d1 = result.byDepth?.[1] || result.byDepth?.['1'] || []; + const caller = d1.find((it: any) => it.id === 'func:caller'); + expect(caller).toBeDefined(); + expect(caller.processes).toHaveLength(1); + expect(caller.processes[0]).toMatchObject({ + id: 'proc:cron_daily', + label: 'Daily cron', + processType: 'cron', + step: 2, + }); + }); + + it('impact summaryOnly:true skips the per-symbol STEP_IN_PROCESS enrichment pass', async () => { + // Resolver returns target; BFS returns one caller; aggregation returns one process row. + (executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => { + if (cypher.includes('WHERE n.name =')) { + return Promise.resolve([ + { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, + ]); + } + if (cypher.includes('COUNT(DISTINCT s.id)')) { + return Promise.resolve([ + { + pId: 'proc:daily', + name: 'Daily cron', + heuristicLabel: 'Daily cron', + processType: 'cron', + entryPointId: 'func:cron_entry', + hits: 1, + minStep: 1, + stepCount: 5, + epName: 'cron_entry', + epType: 'Function', + epFilePath: 'src/cron.ts', + }, + ]); + } + return Promise.resolve([]); + }); + (executeQuery as any).mockResolvedValue([ + { + id: 'func:caller', + name: 'caller', + type: 'Function', + filePath: 'src/a.ts', + relType: 'CALLS', + confidence: 0.9, + }, + ]); + + const result = await backend.callTool('impact', { + target: 'main', + direction: 'upstream', + summaryOnly: true, + }); + + // summaryOnly should return base fields only, no byDepth + expect(result.summary).toBeDefined(); + expect(result.byDepth).toBeUndefined(); + + // The per-symbol enrichment query contains 'RETURN s.id AS sid'; verify it + // was never called (the gate should have suppressed it). + const perSymbolCalls = (executeParameterized as any).mock.calls.filter( + ([, cypher]: [string, string]) => + typeof cypher === 'string' && cypher.includes('RETURN s.id AS sid'), + ); + expect(perSymbolCalls).toHaveLength(0); + }); + + it('impactByUid preserves byDepth while skipping per-symbol enrichment (group fan-out)', async () => { + // Regression guard for the cross-repo by_depth contract: impactByUid must + // suppress only the per-symbol STEP_IN_PROCESS pass, NOT the whole byDepth + // field. cross-impact.ts reads fan.byDepth to populate group `by_depth`; + // using summaryOnly here would silently empty it. + // + // impactByUid takes an explicit repoId and calls refreshRepos() internally. + // Use a fresh backend whose repo path is already absolute/resolved so the + // derived repoId stays stable across that refresh (an unresolved POSIX + // fixture path triggers the path-collision rehash and drops the key). + const resolvedRepoPath = path.resolve('/tmp/test-project'); + (listRegisteredRepos as any).mockResolvedValue([ + { ...MOCK_REPO_ENTRY, path: resolvedRepoPath }, + ]); + backend = new LocalBackend(); + await backend.init(); + + (executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => { + // UID resolver + if (cypher.includes('WHERE n.id = $uid')) { + return Promise.resolve([ + { id: 'func:main', name: 'main', filePath: 'src/index.ts', type: 'Function' }, + ]); + } + // Aggregation pass (returns a process row so affectedProcesses > 0; if the + // per-symbol pass were not skipped, this would open its gate) + if (cypher.includes('COUNT(DISTINCT s.id)')) { + return Promise.resolve([ + { + pId: 'proc:daily', + name: 'Daily cron', + heuristicLabel: 'Daily cron', + processType: 'cron', + entryPointId: 'func:cron_entry', + hits: 1, + minStep: 1, + stepCount: 5, + epName: 'cron_entry', + epType: 'Function', + epFilePath: 'src/cron.ts', + }, + ]); + } + return Promise.resolve([]); + }); + (executeQuery as any).mockResolvedValue([ + { + id: 'func:caller', + name: 'caller', + type: 'Function', + filePath: 'src/uses-main.ts', + relType: 'CALLS', + confidence: 0.9, + }, + ]); + + const result = await backend.impactByUid('test-project', 'uid:main', 'upstream', { + maxDepth: 5, + relationTypes: ['CALLS'], + minConfidence: 0, + includeTests: true, + }); + + // byDepth must survive (Finding A regression guard) + expect(result).not.toBeNull(); + expect(result.byDepth).toBeDefined(); + const d1 = result.byDepth?.[1] || result.byDepth?.['1'] || []; + expect(d1.find((it: any) => it.id === 'func:caller')).toBeDefined(); + + // The per-symbol enrichment query must never fire under skipPerSymbolEnrichment + const perSymbolCalls = (executeParameterized as any).mock.calls.filter( + ([, cypher]: [string, string]) => + typeof cypher === 'string' && cypher.includes('RETURN s.id AS sid'), + ); + expect(perSymbolCalls).toHaveLength(0); + }); + it('dispatches detect_changes tool', async () => { // detect_changes calls execFileSync which we haven't mocked at module level, // so it will throw a git error — that's fine, we test the error path diff --git a/gitnexus/test/unit/impact-batching-grouping.test.ts b/gitnexus/test/unit/impact-batching-grouping.test.ts index 2a018d972..098d72cd7 100644 --- a/gitnexus/test/unit/impact-batching-grouping.test.ts +++ b/gitnexus/test/unit/impact-batching-grouping.test.ts @@ -97,7 +97,10 @@ describe('impact: batching and grouping', () => { executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); const params = args[2] || {}; - if (query.includes('STEP_IN_PROCESS')) { + // Match only the aggregation chunk (which uses COUNT(DISTINCT s.id)), + // not the per-symbol enrichment pass added by impact byDepth processes + // (which also matches STEP_IN_PROCESS but has a different RETURN shape). + if (query.includes('STEP_IN_PROCESS') && query.includes('COUNT(DISTINCT s.id)')) { // Count ids passed in as params.ids const ids = Array.isArray(params.ids) ? params.ids : []; const cnt = ids.length; @@ -263,7 +266,10 @@ describe('impact: batching and grouping', () => { executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); const params = args[2] || {}; - if (query.includes('STEP_IN_PROCESS')) { + // Match only the aggregation chunk (which uses COUNT(DISTINCT s.id)), + // not the per-symbol enrichment pass added by impact byDepth processes + // (which also matches STEP_IN_PROCESS but has a different RETURN shape). + if (query.includes('STEP_IN_PROCESS') && query.includes('COUNT(DISTINCT s.id)')) { const ids = Array.isArray(params.ids) ? params.ids : []; chunkSizes.push(ids.length); return [