From 72556b0f79357ebe8836c8c81d3c4ca7b9be62da Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 05:53:32 +0100 Subject: [PATCH 01/20] chore(deps)(deps-dev): bump @babel/types in /gitnexus-web (#3298) --- gitnexus-web/package-lock.json | 8 ++++---- gitnexus-web/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 2b0deefda..eb19090f5 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -47,7 +47,7 @@ "zod": "^4.5.4" }, "devDependencies": { - "@babel/types": "^8.0.4", + "@babel/types": "^8.0.5", "@playwright/test": "^1.62.1", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.3", @@ -237,9 +237,9 @@ } }, "node_modules/@babel/types": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", - "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.5.tgz", + "integrity": "sha512-eVdMqi3ej5aHhyQ2Si6yD2cAWeV8FJK9UrhK5aL0Sd8hu5GhT+YswhVNbVheOGVYMg8kuGuMaUpkB3stjj4z8A==", "dev": true, "license": "MIT", "dependencies": { diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 841f8b875..4c8445793 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -57,7 +57,7 @@ "zod": "^4.5.4" }, "devDependencies": { - "@babel/types": "^8.0.4", + "@babel/types": "^8.0.5", "@playwright/test": "^1.62.1", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.3", From d2f10f3924e1b4da6450784a1067adcb12d0034b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:05:13 +0100 Subject: [PATCH 02/20] chore(deps)(deps-dev): bump @types/node in /gitnexus-web (#3300) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 26.0.1 to 26.5.1. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 26.5.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus-web/package-lock.json | 16 ++++++++-------- gitnexus-web/package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index eb19090f5..5bde85748 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -53,7 +53,7 @@ "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", "@types/dompurify": "^3.2.0", - "@types/node": "^26.0.1", + "@types/node": "^26.5.1", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@types/react-syntax-highlighter": "^15.5.13", @@ -2493,13 +2493,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", - "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", + "version": "26.5.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz", + "integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~8.3.0" + "undici-types": "~8.9.0" } }, "node_modules/@types/prismjs": { @@ -8061,9 +8061,9 @@ } }, "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "devOptional": true, "license": "MIT" }, diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 4c8445793..6c1b266f7 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -63,7 +63,7 @@ "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", "@types/dompurify": "^3.2.0", - "@types/node": "^26.0.1", + "@types/node": "^26.5.1", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@types/react-syntax-highlighter": "^15.5.13", From a7606021c91b6b03055c6c3c4d22c2cba33115b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:05:30 +0100 Subject: [PATCH 03/20] chore(deps)(deps): bump @langchain/openai in /gitnexus-web (#3302) Bumps [@langchain/openai](https://github.com/langchain-ai/langchainjs) from 1.5.3 to 1.5.13. - [Release notes](https://github.com/langchain-ai/langchainjs/releases) - [Commits](https://github.com/langchain-ai/langchainjs/compare/@langchain/openai@1.5.3...@langchain/openai@1.5.13) --- updated-dependencies: - dependency-name: "@langchain/openai" dependency-version: 1.5.13 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus-web/package-lock.json | 37 ++++++++++++++++++++-------------- gitnexus-web/package.json | 2 +- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 5bde85748..718db2875 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -13,7 +13,7 @@ "@langchain/google-genai": "^2.2.0", "@langchain/langgraph": "^1.4.14", "@langchain/ollama": "^1.3.0", - "@langchain/openai": "^1.5.3", + "@langchain/openai": "^1.5.13", "@sigma/edge-curve": "^3.1.0", "@tailwindcss/vite": "^4.3.3", "axios": "^1.20.0", @@ -1114,9 +1114,9 @@ } }, "node_modules/@langchain/core": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.10.tgz", - "integrity": "sha512-skr9zkyidZSzhoqhWINdULRstPT5aMD+w3tWtE3puxffWH7wxAp8tx1gMqadjKZwlgo6exlK0nHncFJv6m+3Cg==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.11.tgz", + "integrity": "sha512-8yuWLLloTSYA453akm2JSadOVa8kGDY8v+kTzQ6kTY6aIETTEIxgysjZWyKrWQLo3UazctsSoGJ8JrdCGFL4/w==", "license": "MIT", "dependencies": { "@cfworker/json-schema": "^4.0.2", @@ -1252,20 +1252,20 @@ } }, "node_modules/@langchain/openai": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.5.3.tgz", - "integrity": "sha512-OStS2AUvy9oe/hEf/3ndBOFztUDOfuJYLNXh89m3iiJAI2Cp5Dp0n/pvpO27MO0b+VgENd+xSHVyQZ7fe+ulxg==", + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.5.13.tgz", + "integrity": "sha512-SA1QWNgM/p8xhdwU82CJ7OofhHEExpAaIwJ7j9ELIyVW795Mn0jDRQO/5yChawLcPhuLkeL+jMesi0FnXbImSg==", "license": "MIT", "dependencies": { "js-tiktoken": "^1.0.12", - "openai": "^6.41.0", + "openai": "^7.10.0", "zod": "^3.25.76 || ^4" }, "engines": { - "node": ">=20" + "node": ">=22" }, "peerDependencies": { - "@langchain/core": "^1.2.1" + "@langchain/core": "^1.2.11" } }, "node_modules/@langchain/protocol": { @@ -6845,15 +6845,19 @@ } }, "node_modules/openai": { - "version": "6.45.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz", - "integrity": "sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==", + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.15.0.tgz", + "integrity": "sha512-2DIwesnPSduw68MFx7nAdgvshrgvS8WOan5W8+tgUMlAy/tY+Q41g80Pa+w/v64rHA6rtn+dketa8QvMVxU96w==", "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", - "ws": "^8.18.0", + "undici": ">=5 <9", + "ws": "^8.21.0", "zod": "^3.25 || ^4.0" }, "peerDependenciesMeta": { @@ -6866,6 +6870,9 @@ "@smithy/signature-v4": { "optional": true }, + "undici": { + "optional": true + }, "ws": { "optional": true }, @@ -8054,7 +8061,7 @@ "version": "6.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18.17" diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 6c1b266f7..483c5d0c2 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -23,7 +23,7 @@ "@langchain/google-genai": "^2.2.0", "@langchain/langgraph": "^1.4.14", "@langchain/ollama": "^1.3.0", - "@langchain/openai": "^1.5.3", + "@langchain/openai": "^1.5.13", "@sigma/edge-curve": "^3.1.0", "@tailwindcss/vite": "^4.3.3", "axios": "^1.20.0", From f90ae7dcb26aba466244f2c48fd14fb58aacb1f5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:05:46 +0100 Subject: [PATCH 04/20] chore(deps)(deps): bump @langchain/google-genai in /gitnexus-web (#3303) Bumps [@langchain/google-genai](https://github.com/langchain-ai/langchainjs) from 2.2.0 to 2.3.1. - [Release notes](https://github.com/langchain-ai/langchainjs/releases) - [Commits](https://github.com/langchain-ai/langchainjs/compare/@langchain/google-genai@2.2.0...@langchain/google-genai@2.3.1) --- updated-dependencies: - dependency-name: "@langchain/google-genai" dependency-version: 2.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus-web/package-lock.json | 10 +++++----- gitnexus-web/package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 718db2875..9bff38739 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@langchain/anthropic": "^1.5.8", "@langchain/core": "^1.2.8", - "@langchain/google-genai": "^2.2.0", + "@langchain/google-genai": "^2.3.1", "@langchain/langgraph": "^1.4.14", "@langchain/ollama": "^1.3.0", "@langchain/openai": "^1.5.13", @@ -1132,9 +1132,9 @@ } }, "node_modules/@langchain/google-genai": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-2.2.0.tgz", - "integrity": "sha512-1mDqbmB6+iC6ZBQY15r5xJg9wPErnQ774inpKh6qi6BrrjadDwaPHoklJW5IXU94edKiDpm1akIzJCrQDWe6yA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-2.3.1.tgz", + "integrity": "sha512-xoJVzzPA2zgGgHCy4pqTAlXSBKncutXcfzjtYdl9g8biRKjjvczpdpVs2gB0OxsYVX15fgaIJaCmo64+72cj2w==", "license": "MIT", "dependencies": { "@google/generative-ai": "^0.24.1" @@ -1143,7 +1143,7 @@ "node": ">=20" }, "peerDependencies": { - "@langchain/core": "^1.2.0" + "@langchain/core": "^1.2.10" } }, "node_modules/@langchain/langgraph": { diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 483c5d0c2..11fc68b1e 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -20,7 +20,7 @@ "dependencies": { "@langchain/anthropic": "^1.5.8", "@langchain/core": "^1.2.8", - "@langchain/google-genai": "^2.2.0", + "@langchain/google-genai": "^2.3.1", "@langchain/langgraph": "^1.4.14", "@langchain/ollama": "^1.3.0", "@langchain/openai": "^1.5.13", From a67e74cdb6cd6f36a9540c67482d8234098c924b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:06:03 +0100 Subject: [PATCH 05/20] chore(deps)(deps): bump js-yaml from 5.4.1 to 5.4.2 in /gitnexus (#3304) Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 5.4.1 to 5.4.2. - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/5.4.1...5.4.2) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 5.4.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index d39daf3c1..15047dc21 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -2908,9 +2908,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.1.tgz", - "integrity": "sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.2.tgz", + "integrity": "sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==", "funding": [ { "type": "github", From d2a43e33df7d30cf17d9183beab655c0b915a1c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:44:09 +0100 Subject: [PATCH 06/20] chore(deps): bump the codeql-action group with 3 updates (#3305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action). Updates `github/codeql-action/init` from 4.37.9 to 4.38.0 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63) Updates `github/codeql-action/analyze` from 4.37.9 to 4.38.0 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63) Updates `github/codeql-action/upload-sarif` from 4.37.9 to 4.38.0 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63) --- updated-dependencies: - dependency-name: github/codeql-action/init dependency-version: 4.38.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: codeql-action - dependency-name: github/codeql-action/analyze dependency-version: 4.38.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: codeql-action - dependency-name: github/codeql-action/upload-sarif dependency-version: 4.38.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: codeql-action ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- .github/workflows/codeql.yml | 4 ++-- .github/workflows/scorecard.yml | 2 +- .github/workflows/trivy.yml | 2 +- .github/workflows/workflow-lint.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c7c85ba03..9075d8c90 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@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: languages: ${{ matrix.language }} queries: security-and-quality @@ -87,6 +87,6 @@ jobs: - '.github/scripts/fetch-lbug-fts-artifacts.mjs' - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 50afe6914..d22add2ff 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@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: sarif_file: results.sarif diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 33c3f3257..106deaf2b 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@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 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 15f4e0534..c3aeb5fa8 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@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: sarif_file: zizmor.sarif category: zizmor From a2e1710ac098a60ef25377395d4592ea15abdd5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Thu, 17 Sep 2026 16:18:29 +0100 Subject: [PATCH 07/20] fix(embeddings): stop Caching embeddings OOM on large incremental analyze (#3310) * fix(embeddings): spill cached vectors to a Float32 temp file Keep restore metadata in RAM and write embeddings once the in-memory row limit is exceeded so incremental analyze can survive large caches without a full-table number[] heap (#3306). Co-authored-by: Cursor * fix(lbug): stream CodeEmbedding cache under the connection lock Spill vectors once the in-memory limit is crossed and fail the load instead of adopting an empty snapshot, so incremental analyze cannot OOM or quietly drop the restore cache (#3306). Co-authored-by: Cursor * fix(analyze): restore cached embeddings from a streamed spill snapshot Hold row metadata across wipe, materialize 200-row batches, and treat cache-load failures as warn-and-continue so incremental analyze can preserve vectors without a full-table heap (#3306). Co-authored-by: Cursor * Address PR review feedback (#3310) Loop spill writes until the full vector lands, keep materialize failures out of the insert catch and the Phase 4 hash skip-set, and assert spilled restore subsets by node id instead of scan order. Co-authored-by: Cursor * Address PR review feedback (#3310) Discard only this analyze run's embedding spills so a concurrent analyze on another index keeps its restore file, and isolate the default in-memory limit test from inherited env. Co-authored-by: Cursor * Address PR review feedback (#3310) Mark a node stale when any restore batch fails so leftover chunks are deleted and rembedded, and exercise a full-length bad-magic spill header. --------- Co-authored-by: Gergo Magyar Co-authored-by: Cursor --- .../embeddings/embedding-restore-spill.ts | 483 ++++++++++++++++++ gitnexus/src/core/lbug/lbug-adapter.ts | 101 ++-- gitnexus/src/core/run-analyze.ts | 242 ++++++--- .../load-cached-embeddings-spill.test.ts | 113 ++++ .../test/unit/embedding-restore-spill.test.ts | 274 ++++++++++ .../unit/incremental-dirty-recovery.test.ts | 187 ++++--- .../test/unit/run-analyze-fts-repair.test.ts | 309 ++++++++++- gitnexus/vitest.config.ts | 2 + 8 files changed, 1493 insertions(+), 218 deletions(-) create mode 100644 gitnexus/src/core/embeddings/embedding-restore-spill.ts create mode 100644 gitnexus/test/integration/load-cached-embeddings-spill.test.ts create mode 100644 gitnexus/test/unit/embedding-restore-spill.test.ts diff --git a/gitnexus/src/core/embeddings/embedding-restore-spill.ts b/gitnexus/src/core/embeddings/embedding-restore-spill.ts new file mode 100644 index 000000000..10ca51b45 --- /dev/null +++ b/gitnexus/src/core/embeddings/embedding-restore-spill.ts @@ -0,0 +1,483 @@ +/** + * Disk-backed restore cache for CodeEmbedding rows (#3306). + * + * `loadCachedEmbeddings` used to `getAll()` the table and `map(Number)` every + * vector into a JS `number[]`. On a large already-indexed repo that single + * structure OOMs the V8 heap during "Caching embeddings..." even when the + * incremental diff is a handful of nodes. + * + * This module keeps metadata in RAM and writes vectors to a temp Float32 + * spill. Restore materializes only the rows that Phase 3.5 will re-insert, + * in the existing 200-row batches. + */ +import { closeSync, openSync, readSync, unlinkSync, writeSync } from 'node:fs'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomBytes } from 'node:crypto'; +import os from 'node:os'; +import path from 'node:path'; +import type { CachedEmbedding } from './types.js'; + +/** In-RAM vector copies stay below this row count; larger tables use the spill. */ +export const DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT = 2048; + +const SPILL_MAGIC = 'GNXE'; +const SPILL_VERSION = 1; +const SPILL_HEADER_BYTES = 12; + +export interface CachedEmbeddingMeta { + nodeId: string; + chunkIndex: number; + startLine: number; + endLine: number; + contentHash?: string; + /** Row order in the spill file (and in `embeddings` when in-memory). */ + vectorIndex: number; +} + +export interface EmbeddingVectorSpill { + path: string; + dims: number; + rowCount: number; +} + +export interface CachedEmbeddingsSnapshot { + embeddingNodeIds: Set; + /** Populated only when the table is at or under the in-memory row limit. */ + embeddings: CachedEmbedding[]; + rows: CachedEmbeddingMeta[]; + spill?: EmbeddingVectorSpill; +} + +export interface LoadCachedEmbeddingsOptions { + /** + * Keep full `number[]` vectors in RAM at or below this many rows. + * `0` always spills. Default {@link DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT} + * or `GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT`. + */ + inMemoryRowLimit?: number; + /** Directory for the spill file (default `os.tmpdir()`). */ + spillDir?: string; +} + +export interface CachedEmbeddingsBuilder { + embeddingNodeIds: Set; + rows: CachedEmbeddingMeta[]; + /** Float32 vectors kept in RAM until the in-memory row limit is exceeded. */ + inMemory: Float32Array[] | null; + inMemoryRowLimit: number; + writer: EmbeddingSpillWriter; +} + +export function emptyCachedEmbeddingsSnapshot(): CachedEmbeddingsSnapshot { + return { embeddingNodeIds: new Set(), embeddings: [], rows: [] }; +} + +export function resolveEmbeddingCacheInMemoryRowLimit(override?: number): number { + if (override !== undefined) { + if (!Number.isFinite(override) || override < 0) { + return DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT; + } + return Math.floor(override); + } + const raw = process.env.GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT; + if (raw === undefined || raw === '') return DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT; + const parsed = parseInt(raw, 10); + return Number.isFinite(parsed) && parsed >= 0 + ? parsed + : DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT; +} + +export function normalizeCachedEmbeddings(raw: { + embeddingNodeIds?: Set; + embeddings?: CachedEmbedding[]; + rows?: CachedEmbeddingMeta[]; + spill?: EmbeddingVectorSpill; +}): CachedEmbeddingsSnapshot { + const embeddings = raw.embeddings ?? []; + const embeddingNodeIds = raw.embeddingNodeIds ?? new Set(embeddings.map((row) => row.nodeId)); + const rows = + raw.rows ?? + embeddings.map((row, vectorIndex) => ({ + nodeId: row.nodeId, + chunkIndex: row.chunkIndex, + startLine: row.startLine, + endLine: row.endLine, + contentHash: row.contentHash, + vectorIndex, + })); + return { embeddingNodeIds, embeddings, rows, spill: raw.spill }; +} + +export function cacheRowCount(snapshot: CachedEmbeddingsSnapshot): number { + return snapshot.rows.length > 0 ? snapshot.rows.length : snapshot.embeddings.length; +} + +export function snapshotEmbeddingDims(snapshot: CachedEmbeddingsSnapshot): number | undefined { + if (snapshot.spill && snapshot.spill.dims > 0) return snapshot.spill.dims; + const dims = snapshot.embeddings[0]?.embedding.length; + return dims && dims > 0 ? dims : undefined; +} + +export function coerceEmbeddingToFloat32(embedding: unknown): Float32Array | null { + if (embedding == null) return null; + if (embedding instanceof Float32Array) { + return embedding.length > 0 ? embedding : null; + } + if (ArrayBuffer.isView(embedding) && !(embedding instanceof DataView)) { + const view = embedding as Exclude & { length: number }; + if (view.length === 0) return null; + return Float32Array.from({ length: view.length }, (_, i) => Number(view[i])); + } + if ( + typeof embedding === 'object' && + typeof (embedding as Iterable)[Symbol.iterator] === 'function' + ) { + const arr = Array.isArray(embedding) + ? (embedding as unknown[]) + : Array.from(embedding as Iterable); + if (arr.length === 0) return null; + return Float32Array.from(arr, (value) => Number(value)); + } + return null; +} + +export function float32ToNumberArray(vec: Float32Array): number[] { + const out = new Array(vec.length); + for (let i = 0; i < vec.length; i++) out[i] = vec[i]!; + return out; +} + +/** + * `fs.writeSync` can return a short byte count. Loop until the whole buffer + * lands, matching `sync-csv-writer.ts`, so a partial write never advances + * `rowCount` on a truncated vector. + */ +function writeAllSync(fd: number, data: Uint8Array): void { + let offset = 0; + while (offset < data.length) { + const n = writeSync(fd, data, offset, data.length - offset); + if (n <= 0) { + throw new Error(`embedding spill short write: wrote ${n} of ${data.length - offset} bytes`); + } + offset += n; + } +} + +function unlinkBestEffort(filePath: string): void { + try { + unlinkSync(filePath); + } catch { + /* ENOENT or already removed */ + } +} + +const liveSpillPaths = new Set(); +const spillScope = new AsyncLocalStorage>(); +let spillExitHookInstalled = false; + +function trackLiveSpillPath(filePath: string): void { + liveSpillPaths.add(filePath); + spillScope.getStore()?.add(filePath); + if (!spillExitHookInstalled) { + spillExitHookInstalled = true; + process.on('exit', () => { + for (const spillPath of liveSpillPaths) { + unlinkBestEffort(spillPath); + } + }); + } +} + +function untrackLiveSpillPath(filePath: string): void { + liveSpillPaths.delete(filePath); +} + +/** Best-effort unlink of every tracked spill. Safe to call more than once. */ +export function discardLiveEmbeddingSpills(): void { + for (const spillPath of [...liveSpillPaths]) { + unlinkBestEffort(spillPath); + liveSpillPaths.delete(spillPath); + } +} + +/** Run `fn` so later {@link discardScopedEmbeddingSpills} only unlinks this run. */ +export function withEmbeddingSpillScope(fn: () => T): T { + return spillScope.run(new Set(), fn); +} + +/** Unlink spills created inside the current {@link withEmbeddingSpillScope}. */ +export function discardScopedEmbeddingSpills(): void { + const owned = spillScope.getStore(); + if (!owned) return; + for (const spillPath of [...owned]) { + unlinkBestEffort(spillPath); + liveSpillPaths.delete(spillPath); + owned.delete(spillPath); + } +} + +export class EmbeddingSpillWriter { + readonly path: string; + dims = 0; + rowCount = 0; + private fd: number | null = null; + private closed = false; + + constructor(dir: string) { + this.path = path.join( + dir, + `gitnexus-embed-restore-${process.pid}-${randomBytes(8).toString('hex')}.bin`, + ); + } + + append(vec: Float32Array): void { + if (this.closed) { + throw new Error('embedding spill writer already closed'); + } + if (this.fd === null) { + this.dims = vec.length; + this.fd = openSync(this.path, 'wx', 0o600); + trackLiveSpillPath(this.path); + const header = Buffer.alloc(SPILL_HEADER_BYTES); + header.write(SPILL_MAGIC, 0, 4, 'ascii'); + header.writeUInt8(SPILL_VERSION, 4); + header.writeUInt32LE(this.dims, 5); + writeAllSync(this.fd, header); + } else if (vec.length !== this.dims) { + throw new Error( + `embedding dim mismatch while spilling: got ${vec.length}, expected ${this.dims}`, + ); + } + writeAllSync(this.fd, Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength)); + this.rowCount++; + } + + finish(): EmbeddingVectorSpill | undefined { + if (this.fd !== null) { + closeSync(this.fd); + this.fd = null; + } + this.closed = true; + if (this.rowCount === 0) { + this.unlinkQuiet(); + return undefined; + } + return { path: this.path, dims: this.dims, rowCount: this.rowCount }; + } + + abort(): void { + const opened = this.fd !== null; + if (this.fd !== null) { + try { + closeSync(this.fd); + } catch { + /* already closed */ + } + this.fd = null; + } + this.closed = true; + if (opened || this.rowCount > 0) { + this.unlinkQuiet(); + } + } + + private unlinkQuiet(): void { + unlinkBestEffort(this.path); + untrackLiveSpillPath(this.path); + } +} + +/** Validates the spill header once and reads vectors without reopening the file. */ +export class EmbeddingSpillReader { + private fd: number | null = null; + private readonly bytesPerVec: number; + readonly dims: number; + readonly rowCount: number; + + constructor(spill: EmbeddingVectorSpill) { + this.rowCount = spill.rowCount; + const fd = openSync(spill.path, 'r'); + try { + const header = Buffer.alloc(SPILL_HEADER_BYTES); + const headerRead = readSync(fd, header, 0, SPILL_HEADER_BYTES, 0); + if (headerRead !== SPILL_HEADER_BYTES || header.toString('ascii', 0, 4) !== SPILL_MAGIC) { + throw new Error(`invalid embedding spill header: ${spill.path}`); + } + if (header.readUInt8(4) !== SPILL_VERSION) { + throw new Error(`unsupported embedding spill version in ${spill.path}`); + } + const dims = header.readUInt32LE(5); + if (dims !== spill.dims) { + throw new Error(`embedding spill dim mismatch: file ${dims}, expected ${spill.dims}`); + } + this.dims = dims; + this.bytesPerVec = dims * 4; + this.fd = fd; + } catch (err) { + closeSync(fd); + throw err; + } + } + + read(indices: readonly number[]): Float32Array[] { + if (this.fd === null) { + throw new Error('embedding spill reader already closed'); + } + const out: Float32Array[] = []; + for (const index of indices) { + if (!Number.isInteger(index) || index < 0 || index >= this.rowCount) { + throw new Error(`embedding spill index out of range: ${index}`); + } + const offset = SPILL_HEADER_BYTES + index * this.bytesPerVec; + const copy = new Float32Array(this.dims); + const bytes = new Uint8Array(copy.buffer, copy.byteOffset, this.bytesPerVec); + const n = readSync(this.fd, bytes, 0, this.bytesPerVec, offset); + if (n !== this.bytesPerVec) { + throw new Error(`short embedding spill read at index ${index}`); + } + out.push(copy); + } + return out; + } + + close(): void { + if (this.fd === null) return; + closeSync(this.fd); + this.fd = null; + } +} + +export function readSpillVectors( + spill: EmbeddingVectorSpill, + indices: readonly number[], +): Float32Array[] { + const reader = new EmbeddingSpillReader(spill); + try { + return reader.read(indices); + } finally { + reader.close(); + } +} + +export function disposeEmbeddingSpill(spill?: EmbeddingVectorSpill): void { + if (!spill?.path) return; + unlinkBestEffort(spill.path); + untrackLiveSpillPath(spill.path); +} + +export function createCachedEmbeddingsBuilder( + options?: LoadCachedEmbeddingsOptions, +): CachedEmbeddingsBuilder { + const inMemoryRowLimit = resolveEmbeddingCacheInMemoryRowLimit(options?.inMemoryRowLimit); + return { + embeddingNodeIds: new Set(), + rows: [], + inMemory: inMemoryRowLimit <= 0 ? null : [], + inMemoryRowLimit, + writer: new EmbeddingSpillWriter(options?.spillDir ?? os.tmpdir()), + }; +} + +export function ingestCachedEmbeddingRow( + builder: CachedEmbeddingsBuilder, + row: Record | unknown[], + hasContentHash: boolean, +): void { + const rec = row as Record & unknown[]; + const nodeId = String(rec.nodeId ?? rec[0] ?? ''); + if (!nodeId) return; + const embedding = rec.embedding ?? rec[4]; + const f32 = coerceEmbeddingToFloat32(embedding); + if (!f32) return; + + builder.embeddingNodeIds.add(nodeId); + const meta: CachedEmbeddingMeta = { + nodeId, + chunkIndex: Number(rec.chunkIndex ?? rec[1] ?? 0), + startLine: Number(rec.startLine ?? rec[2] ?? 0), + endLine: Number(rec.endLine ?? rec[3] ?? 0), + contentHash: hasContentHash + ? ((rec.contentHash ?? rec[5] ?? undefined) as string | undefined) + : undefined, + vectorIndex: builder.rows.length, + }; + builder.rows.push(meta); + + if (builder.inMemory && builder.rows.length <= builder.inMemoryRowLimit) { + builder.inMemory.push(f32); + return; + } + + if (builder.inMemory) { + for (const prior of builder.inMemory) { + builder.writer.append(prior); + } + builder.inMemory = null; + } + builder.writer.append(f32); +} + +export function finalizeCachedEmbeddingsSnapshot( + builder: CachedEmbeddingsBuilder, +): CachedEmbeddingsSnapshot { + const inMemory = builder.inMemory; + if (inMemory) { + builder.writer.abort(); + return { + embeddingNodeIds: builder.embeddingNodeIds, + embeddings: builder.rows.map((meta, i) => ({ + nodeId: meta.nodeId, + chunkIndex: meta.chunkIndex, + startLine: meta.startLine, + endLine: meta.endLine, + contentHash: meta.contentHash, + embedding: float32ToNumberArray(inMemory[i]!), + })), + rows: builder.rows, + }; + } + return { + embeddingNodeIds: builder.embeddingNodeIds, + embeddings: [], + rows: builder.rows, + spill: builder.writer.finish(), + }; +} + +export function abortCachedEmbeddingsBuilder(builder: CachedEmbeddingsBuilder): void { + builder.writer.abort(); +} + +export function materializeCachedEmbeddings( + snapshot: CachedEmbeddingsSnapshot, + metas: readonly CachedEmbeddingMeta[], + spillReader?: EmbeddingSpillReader, +): CachedEmbedding[] { + if (metas.length === 0) return []; + if (snapshot.spill && snapshot.embeddings.length === 0) { + const indices = metas.map((meta) => meta.vectorIndex); + const vectors = spillReader + ? spillReader.read(indices) + : readSpillVectors(snapshot.spill, indices); + return metas.map((meta, i) => ({ + nodeId: meta.nodeId, + chunkIndex: meta.chunkIndex, + startLine: meta.startLine, + endLine: meta.endLine, + contentHash: meta.contentHash, + embedding: float32ToNumberArray(vectors[i]!), + })); + } + if (snapshot.embeddings.length === 0) return []; + const byKey = new Map( + snapshot.embeddings.map((row) => [`${row.nodeId}:${row.chunkIndex}`, row] as const), + ); + return metas.map((meta) => { + const hit = + byKey.get(`${meta.nodeId}:${meta.chunkIndex}`) ?? snapshot.embeddings[meta.vectorIndex]; + if (!hit) { + throw new Error(`missing cached embedding ${meta.nodeId}:${meta.chunkIndex}`); + } + return hit; + }); +} diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 621d26da6..5f5a076d4 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -34,7 +34,16 @@ import type { GraphEmitManifest } from './graph-emit-sink.js'; import type { PdgEmitManifest } from './pdg-emit-sink.js'; import { PDG_EDGE_TYPES } from './pdg-emit-sink.js'; import { getNodeLabel as deriveNodeLabel, type WriteStreamFactory } from './rel-pair-routing.js'; -import { EMBEDDABLE_LABELS, type CachedEmbedding } from '../embeddings/types.js'; +import { EMBEDDABLE_LABELS } from '../embeddings/types.js'; +import { + abortCachedEmbeddingsBuilder, + createCachedEmbeddingsBuilder, + emptyCachedEmbeddingsSnapshot, + finalizeCachedEmbeddingsSnapshot, + ingestCachedEmbeddingRow, + type CachedEmbeddingsSnapshot, + type LoadCachedEmbeddingsOptions, +} from '../embeddings/embedding-restore-spill.js'; import { extensionManager, getFtsCapability, @@ -2069,28 +2078,31 @@ export const getLbugStats = async (): Promise<{ /** * Load cached embeddings from LadybugDB before a rebuild. - * Returns all embedding vectors so they can be re-inserted after the graph is reloaded, - * avoiding expensive re-embedding of unchanged nodes. + * + * Streams `CodeEmbedding` rows with `hasNext`/`getNext` under `withConnLock` + * (#2264, #3306). Vectors are spilled to a temp Float32 file once the table + * exceeds the in-memory row limit so incremental analyze cannot OOM the V8 + * heap by materializing every `number[]` up front. Small tables still return + * in-RAM `embeddings` for existing callers/tests. * * Detects old schema (no chunkIndex column) and returns empty cache to trigger rebuild. */ -export const loadCachedEmbeddings = async (): Promise<{ - embeddingNodeIds: Set; - embeddings: CachedEmbedding[]; -}> => { +export const loadCachedEmbeddings = async ( + options?: LoadCachedEmbeddingsOptions, +): Promise => { const c = conn; if (!c) { - return { embeddingNodeIds: new Set(), embeddings: [] }; + return emptyCachedEmbeddingsSnapshot(); } // The whole read runs inside the connection lock (#2264 review P2). It's safe // today only by call-ordering (loadCachedEmbeddings runs before the WAL driver // starts), but the lock makes it robust to future reordering — a concurrent // CHECKPOINT on the singleton connection is the documented corruption trigger. - // Leaf read: no nested withConnLock-wrapped helpers inside. + // Leaf read: no nested withConnLock-wrapped helpers inside. Do NOT call + // `streamQuery` here — that path is unlocked and would race a CHECKPOINT. return withConnLock(async () => { - const embeddingNodeIds = new Set(); - const embeddings: CachedEmbedding[] = []; + const builder = createCachedEmbeddingsBuilder(options); try { // Schema migration detection: query with new columns to verify schema version. // Old schema only had (nodeId, embedding); new schema adds (id, chunkIndex, startLine, endLine, contentHash). @@ -2104,51 +2116,46 @@ export const loadCachedEmbeddings = async (): Promise<{ ); await readQueryRows(check); } catch { - return { embeddingNodeIds: new Set(), embeddings: [] }; + abortCachedEmbeddingsBuilder(builder); + return emptyCachedEmbeddingsSnapshot(); } - // Try to read contentHash alongside chunk columns - let rows: any; + let queryResult: lbug.QueryResult | lbug.QueryResult[] | undefined; let hasContentHash = true; try { - rows = await c.query( - `MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding, e.contentHash AS contentHash`, - ); - } catch (err: any) { - // Fallback for legacy DBs without contentHash column - const msg = err?.message ?? ''; - if (isMissingColumnOrTableError(msg)) { - hasContentHash = false; - rows = await c.query( - `MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding`, + try { + queryResult = await c.query( + `MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding, e.contentHash AS contentHash`, ); - } else { - throw err; + } catch (err: any) { + // Fallback for legacy DBs without contentHash column + const msg = err?.message ?? ''; + if (isMissingColumnOrTableError(msg)) { + hasContentHash = false; + queryResult = await c.query( + `MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding`, + ); + } else { + throw err; + } } - } - for (const row of await readQueryRows(rows)) { - const nodeId = String(row.nodeId ?? row[0] ?? ''); - if (!nodeId) continue; - embeddingNodeIds.add(nodeId); - const embedding = row.embedding ?? row[4]; - if (embedding) { - embeddings.push({ - nodeId, - chunkIndex: Number(row.chunkIndex ?? row[1] ?? 0), - startLine: Number(row.startLine ?? row[2] ?? 0), - endLine: Number(row.endLine ?? row[3] ?? 0), - embedding: Array.isArray(embedding) - ? embedding.map(Number) - : Array.from(embedding as any).map(Number), - contentHash: hasContentHash ? (row.contentHash ?? row[5] ?? undefined) : undefined, - }); + const results = Array.isArray(queryResult) ? queryResult : [queryResult]; + const result = results[0]; + while (await result.hasNext()) { + const row = await result.getNext(); + ingestCachedEmbeddingRow(builder, row, hasContentHash); } + return finalizeCachedEmbeddingsSnapshot(builder); + } catch (err) { + abortCachedEmbeddingsBuilder(builder); + throw err; + } finally { + if (queryResult) await closeQueryResults(queryResult); } - } catch { - /* embedding table may not exist */ + } catch (err) { + abortCachedEmbeddingsBuilder(builder); + throw err; } - - return { embeddingNodeIds, embeddings }; }); }; diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 10188637e..8ea0b0f66 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -221,7 +221,18 @@ import { } from '../storage/git.js'; import { isGitNexusManagedPath } from '../storage/gitnexus-managed-paths.js'; import { getMaxFileSizeBytes } from './ingestion/utils/max-file-size.js'; -import type { CachedEmbedding } from './embeddings/types.js'; +import { + cacheRowCount, + discardScopedEmbeddingSpills, + disposeEmbeddingSpill, + withEmbeddingSpillScope, + emptyCachedEmbeddingsSnapshot, + EmbeddingSpillReader, + materializeCachedEmbeddings, + normalizeCachedEmbeddings, + snapshotEmbeddingDims, + type CachedEmbeddingsSnapshot, +} from './embeddings/embedding-restore-spill.js'; import { generateAIContextFiles } from '../cli/ai-context.js'; import { sanitizeDetectedBranch } from '../cli/analyze-config.js'; import { @@ -1166,59 +1177,64 @@ export async function runFullAnalysis( let writeTarget = await resolveWriteTarget(repoPath, options); let lock = await acquireIndexLock(writeTarget.metaDir, acquireOpts); - try { - requireExclusiveIndexLock( - lock, - `Cannot acquire the index lock at ${writeTarget.metaDir}; refusing an unlocked analysis.`, - ); - // #2658 review H2: acquireIndexLock can wait up to the timeout ceiling, - // during which git HEAD/branch — and thus the resolved write slot — may - // change (a commit lands, a branch is switched, or another writer adopts the - // flat slot). The pre-wait snapshot must NOT be reused: re-resolve UNDER the - // lock so the freshness check (`existingMeta.lastCommit === currentCommit`) - // and the meta stamps see current git state, honoring the module's "re-check - // freshness after acquiring" contract. If the slot itself moved we hold the - // WRONG lock — release and re-acquire the correct one. Bounded so a - // pathologically churning checkout can't loop forever; after the cap we - // proceed on the current lock. The loop is INSIDE the try so a re-resolve - // that throws (e.g. a `--branch` that stopped matching the now-switched - // checkout) still releases the held lock via `finally` (no leak). - const MAX_RELOCK = 3; - for (let attempt = 0; attempt < MAX_RELOCK; attempt++) { - // Never pass the pre-lock storagePath as already-validated: requireStoragePath - // must run again under the lock so a now-foreign slot aborts (and finally - // still releases the lock). - const fresh = await resolveWriteTarget(repoPath, options); - if (fresh.metaDir === writeTarget.metaDir) { - writeTarget = fresh; // same slot — adopt the freshly-read commit/branch/placement - break; - } - log( - `Index write target moved while waiting for the lock ` + - `(${writeTarget.metaDir} → ${fresh.metaDir}); re-acquiring the correct slot.`, - ); - lock.release(); - writeTarget = fresh; - lock = await acquireIndexLock(fresh.metaDir, acquireOpts); + return withEmbeddingSpillScope(async () => { + try { requireExclusiveIndexLock( lock, - `Cannot acquire the index lock at ${fresh.metaDir}; refusing an unlocked analysis.`, + `Cannot acquire the index lock at ${writeTarget.metaDir}; refusing an unlocked analysis.`, ); - if (attempt === MAX_RELOCK - 1) { - log('Index write target still moving after repeated re-acquire; proceeding on this lock.'); + // #2658 review H2: acquireIndexLock can wait up to the timeout ceiling, + // during which git HEAD/branch — and thus the resolved write slot — may + // change (a commit lands, a branch is switched, or another writer adopts the + // flat slot). The pre-wait snapshot must NOT be reused: re-resolve UNDER the + // lock so the freshness check (`existingMeta.lastCommit === currentCommit`) + // and the meta stamps see current git state, honoring the module's "re-check + // freshness after acquiring" contract. If the slot itself moved we hold the + // WRONG lock — release and re-acquire the correct one. Bounded so a + // pathologically churning checkout can't loop forever; after the cap we + // proceed on the current lock. The loop is INSIDE the try so a re-resolve + // that throws (e.g. a `--branch` that stopped matching the now-switched + // checkout) still releases the held lock via `finally` (no leak). + const MAX_RELOCK = 3; + for (let attempt = 0; attempt < MAX_RELOCK; attempt++) { + // Never pass the pre-lock storagePath as already-validated: requireStoragePath + // must run again under the lock so a now-foreign slot aborts (and finally + // still releases the lock). + const fresh = await resolveWriteTarget(repoPath, options); + if (fresh.metaDir === writeTarget.metaDir) { + writeTarget = fresh; // same slot — adopt the freshly-read commit/branch/placement + break; + } + log( + `Index write target moved while waiting for the lock ` + + `(${writeTarget.metaDir} → ${fresh.metaDir}); re-acquiring the correct slot.`, + ); + lock.release(); + writeTarget = fresh; + lock = await acquireIndexLock(fresh.metaDir, acquireOpts); + requireExclusiveIndexLock( + lock, + `Cannot acquire the index lock at ${fresh.metaDir}; refusing an unlocked analysis.`, + ); + if (attempt === MAX_RELOCK - 1) { + log( + 'Index write target still moving after repeated re-acquire; proceeding on this lock.', + ); + } } + return await runFullAnalysisInner( + repoPath, + options, + callbacks, + writeTarget, + contentRetention, + runnerIdentityAtBootstrap, + ); + } finally { + discardScopedEmbeddingSpills(); + lock.release(); } - return await runFullAnalysisInner( - repoPath, - options, - callbacks, - writeTarget, - contentRetention, - runnerIdentityAtBootstrap, - ); - } finally { - lock.release(); - } + }); } async function runFullAnalysisInner( @@ -2201,8 +2217,18 @@ async function runFullAnalysisInner( // The default-preserve branch is what makes a routine `analyze` (e.g. a // post-commit hook) safe: a multi-minute embedding pass is no longer // silently dropped just because the caller omitted `--embeddings`. - let cachedEmbeddingNodeIds = new Set(); - let cachedEmbeddings: CachedEmbedding[] = []; + let cachedSnapshot: CachedEmbeddingsSnapshot = emptyCachedEmbeddingsSnapshot(); + const adoptCachedEmbeddings = (raw: CachedEmbeddingsSnapshot): void => { + cachedSnapshot = normalizeCachedEmbeddings(raw); + }; + const discardCachedEmbeddings = (): void => { + disposeEmbeddingSpill(cachedSnapshot.spill); + cachedSnapshot = emptyCachedEmbeddingsSnapshot(); + }; + const discardCachedEmbeddingSpill = (): void => { + disposeEmbeddingSpill(cachedSnapshot.spill); + cachedSnapshot = { ...cachedSnapshot, spill: undefined }; + }; const existingEmbeddingCount = existingMeta?.stats?.embeddings ?? 0; const { @@ -2237,7 +2263,7 @@ async function runFullAnalysisInner( // of the predicted `willTryIncremental`). The post-pipeline branch may // disagree with the prediction (e.g. when the pipeline produces zero // File nodes, `isIncremental` flips false and the full-rebuild path - // wipes the DB) — loading unconditionally is cheap insurance against + // wipes the DB) — loading unconditionally is insurance against // silently dropping embeddings on a mispredicted run. The re-insert // step gates itself on the actual `isIncremental` value to avoid // PK-conflicts when the incremental writeback path keeps the rows. @@ -2251,9 +2277,7 @@ async function runFullAnalysisInner( try { progress('embeddings', 0, 'Caching embeddings...'); await initAnalysisLbug(lbugPath); - const cached = await loadCachedEmbeddings(); - cachedEmbeddingNodeIds = cached.embeddingNodeIds; - cachedEmbeddings = cached.embeddings; + adoptCachedEmbeddings(await loadCachedEmbeddings()); await closeLbug(); } catch (err: any) { // Surface cache-load failures explicitly: silently swallowing here would @@ -2264,8 +2288,7 @@ async function runFullAnalysisInner( `(${err?.message ?? String(err)}). ` + `Embeddings will not be preserved on this run.`, ); - cachedEmbeddingNodeIds = new Set(); - cachedEmbeddings = []; + discardCachedEmbeddings(); try { await closeLbug(); } catch { @@ -2378,6 +2401,7 @@ async function runFullAnalysisInner( }, ); } catch (err) { + discardCachedEmbeddingSpill(); await removeColdParseRebuildDir(coldParseRebuildDir, true); throw err; } @@ -2614,6 +2638,7 @@ async function runFullAnalysisInner( try { await wipeLbugDbFiles(buildPath); } catch (error) { + discardCachedEmbeddingSpill(); if (liveIndexMutationStarted) recordLiveIndexMutationRisk(error); throw error; } @@ -2642,6 +2667,7 @@ async function runFullAnalysisInner( try { await initAnalysisLbug(buildPath); } catch (error) { + discardCachedEmbeddingSpill(); if (liveIndexMutationStarted) recordLiveIndexMutationRisk(error); throw error; } @@ -2963,7 +2989,7 @@ async function runFullAnalysisInner( const extensionForcedRebuild = !embeddingRowDmlSafe || !ftsRowDmlSafe; // `!options.dropEmbeddings` (H1): this rescue reads the rows back OUT of // the DB, so it must never fire on the one path whose entire purpose is to - // destroy them. `--drop-embeddings` deliberately leaves `cachedEmbeddings` + // destroy them. `--drop-embeddings` deliberately leaves `cachedSnapshot` // empty (`deriveEmbeddingMode` returns `shouldLoadCache: false` for it by // construction — see the four-mode comment at the cache-load site), and its // `options.force = true` conversion sits INSIDE @@ -2978,12 +3004,16 @@ async function runFullAnalysisInner( // while rows survive ⇒ `hasExisting` false ⇒ `shouldLoadCache` false), i.e. // it would fix the wipe by deleting the safeguard. Covers // `--drop-embeddings --embeddings` too — the rescue repopulates - // `cachedEmbeddingNodeIds`, which Phase 4 hands `runEmbeddingPipeline` as + // `cachedSnapshot.embeddingNodeIds`, which Phase 4 hands `runEmbeddingPipeline` as // the already-embedded set, so the very nodes the user asked to REGENERATE // would be skipped. - if (extensionForcedRebuild && !options.dropEmbeddings && cachedEmbeddings.length === 0) { + if ( + extensionForcedRebuild && + !options.dropEmbeddings && + cacheRowCount(cachedSnapshot) === 0 + ) { // The escalation below WIPES the DB files, and Phase 3.5 restores - // embedding rows from `cachedEmbeddings` — which is only populated when + // embedding rows from `cachedSnapshot` — which is only populated when // `deriveEmbeddingMode` saw `meta.stats.embeddings > 0`. A DB whose meta // under-reports its embeddings (meta restored from an older run, or a // count that never got stamped) would therefore have every vector @@ -2991,14 +3021,21 @@ async function runFullAnalysisInner( // while the DB is still intact — a plain MATCH, which needs no VECTOR // extension. Rows whose owning node is gone are dropped by Phase 3.5's // live-graph filter, exactly as on any other wiped path. - const rescued = await loadCachedEmbeddings(); - if (rescued.embeddings.length > 0) { - cachedEmbeddings = rescued.embeddings; - cachedEmbeddingNodeIds = rescued.embeddingNodeIds; + try { + adoptCachedEmbeddings(await loadCachedEmbeddings()); + if (cacheRowCount(cachedSnapshot) > 0) { + log( + `Preserving ${cacheRowCount(cachedSnapshot)} embedding row(s) across the forced rebuild ` + + `(the index metadata did not account for them).`, + ); + } + } catch (err: any) { log( - `Preserving ${rescued.embeddings.length} embedding row(s) across the forced rebuild ` + - `(the index metadata did not account for them).`, + `Warning: could not load cached embeddings ` + + `(${err?.message ?? String(err)}). ` + + `Embeddings will not be preserved on this run.`, ); + discardCachedEmbeddings(); } } // Hoisted out of the `||` below (§5.D): the size verdict has to be KNOWN @@ -3624,26 +3661,27 @@ async function runFullAnalysisInner( // propagates errors (a completed writeback means a deterministic // delete outcome) and this process holds the exclusive DB lock (no // concurrent writer). - // The per-batch try/catch stays as a last-resort guard only — it no - // longer fires on the happy path. + // Materialize runs outside the insert catch so a spill I/O failure is not + // treated as a benign PK conflict. Any node with a failed restore batch is + // marked stale in the Phase 4 map so leftover chunks are deleted and rembedded. let restoredEmbeddingCount = 0; - if (cachedEmbeddings.length > 0) { - const cachedDims = cachedEmbeddings[0].embedding.length; + const restoreFailedNodeIds = new Set(); + if (cacheRowCount(cachedSnapshot) > 0) { + const cachedDims = snapshotEmbeddingDims(cachedSnapshot); const { EMBEDDING_DIMS } = await import('./lbug/schema.js'); - if (cachedDims !== EMBEDDING_DIMS) { + if (cachedDims !== undefined && cachedDims !== EMBEDDING_DIMS) { // Dimensions changed (e.g. switched embedding model) — discard cache and re-embed all log( `Embedding dimensions changed (${cachedDims}d -> ${EMBEDDING_DIMS}d), discarding cache`, ); - cachedEmbeddings = []; - cachedEmbeddingNodeIds = new Set(); + discardCachedEmbeddings(); } else { const { batchInsertEmbeddings: batchInsert } = await import('./embeddings/embedding-pipeline.js'); // (1) Live-graph filter — the FULL pipeline graph (always produced), // NOT the incremental subgraph, or unchanged files' rows would be // dropped from the restore set. - const liveEmbeddings = cachedEmbeddings.filter( + const liveEmbeddings = cachedSnapshot.rows.filter( (e) => pipelineResult.graph.getNode(e.nodeId) !== undefined, ); // (2) Restore-scope filter (see the discipline note above). @@ -3656,13 +3694,36 @@ async function runFullAnalysisInner( }); progress('embeddings', 88, `Restoring ${rowsToRestore.length} cached embeddings...`); const EMBED_BATCH = 200; - for (const batch of chunk(rowsToRestore, EMBED_BATCH)) { - try { - await batchInsert(executeWithReusedStatement, batch); - restoredEmbeddingCount += batch.length; - } catch { - /* last-resort guard — conflict-free by construction above */ + let spillReader: EmbeddingSpillReader | undefined; + try { + for (const batch of chunk(rowsToRestore, EMBED_BATCH)) { + let materialized; + try { + if (!spillReader && cachedSnapshot.spill && cachedSnapshot.embeddings.length === 0) { + spillReader = new EmbeddingSpillReader(cachedSnapshot.spill); + } + materialized = materializeCachedEmbeddings(cachedSnapshot, batch, spillReader); + } catch (err) { + for (const row of batch) restoreFailedNodeIds.add(row.nodeId); + log( + `Warning: could not materialize ${batch.length} cached embedding(s) for restore ` + + `(${(err as Error).message}); those nodes will be re-embedded if this run generates embeddings.`, + ); + continue; + } + try { + await batchInsert(executeWithReusedStatement, materialized); + restoredEmbeddingCount += batch.length; + } catch (err) { + for (const row of batch) restoreFailedNodeIds.add(row.nodeId); + log( + `Warning: could not restore ${batch.length} cached embedding(s) ` + + `(${(err as Error).message}); those nodes will be re-embedded if this run generates embeddings.`, + ); + } } + } finally { + spillReader?.close(); } // Legacy-orphan sweep (FIX 3, finder B): the live-graph filter's @@ -3679,7 +3740,7 @@ async function runFullAnalysisInner( // sweep failure must never fail a completed writeback, so the whole // sweep warns-and-continues. if (deletedFilePathsForRestore !== null) { - const orphanRowIds = cachedEmbeddings + const orphanRowIds = cachedSnapshot.rows .filter((e) => pipelineResult.graph.getNode(e.nodeId) === undefined) .map((e) => `${e.nodeId}:${e.chunkIndex}`); if (orphanRowIds.length > 0) { @@ -3708,6 +3769,9 @@ async function runFullAnalysisInner( } } } + // Vectors are on disk only to survive the wipe/delete. After restore, + // drop the spill so Phase 4 does not keep a multi-GB temp file open. + discardCachedEmbeddingSpill(); // ── Phase 4: Embeddings (90–98%) ────────────────────────────────── const stats = await getLbugStats(); @@ -3957,9 +4021,16 @@ async function runFullAnalysisInner( const embeddingIdentity = embeddingIdentityForRun; // Build a Map from cached embeddings for incremental mode let existingEmbeddings: Map | undefined; - if (cachedEmbeddingNodeIds.size > 0) { + if (cachedSnapshot.embeddingNodeIds.size > 0) { existingEmbeddings = new Map(); - for (const e of cachedEmbeddings) { + for (const e of cachedSnapshot.rows) { + if (restoreFailedNodeIds.has(e.nodeId)) { + // Any failed batch for this node: mark stale so Phase 4 DELETEs + // leftover chunks and re-embeds. Omitting the id would treat the + // node as new and PK-conflict on rows that already restored. + existingEmbeddings.set(e.nodeId, STALE_HASH_SENTINEL); + continue; + } existingEmbeddings.set(e.nodeId, e.contentHash ?? STALE_HASH_SENTINEL); } } @@ -4036,7 +4107,7 @@ async function runFullAnalysisInner( progress('embeddings', scaled, label); }, {}, - cachedEmbeddingNodeIds.size > 0 ? cachedEmbeddingNodeIds : undefined, + cachedSnapshot.embeddingNodeIds.size > 0 ? cachedSnapshot.embeddingNodeIds : undefined, existingEmbeddings, { forceReembedNodeIds: pendingEmbeddingNodeIds, @@ -4718,6 +4789,7 @@ async function runFullAnalysisInner( } } await removeColdParseRebuildDir(coldParseRebuildDir, true); + discardCachedEmbeddingSpill(); if (liveIndexMutationStarted) { // Preserve the original error identity/prototype: callers distinguish // IndexLockTimeoutError and other domain failures with `instanceof`. diff --git a/gitnexus/test/integration/load-cached-embeddings-spill.test.ts b/gitnexus/test/integration/load-cached-embeddings-spill.test.ts new file mode 100644 index 000000000..99adb47d0 --- /dev/null +++ b/gitnexus/test/integration/load-cached-embeddings-spill.test.ts @@ -0,0 +1,113 @@ +/** + * Real-DB coverage for #3306: loadCachedEmbeddings must stream CodeEmbedding + * rows instead of getAll()+map(Number) of the whole table, and must be able + * to spill vectors so incremental analyze does not keep every embedding in + * the V8 heap. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createTempDir, type TestDBHandle } from '../helpers/test-db.js'; +import { EMBEDDING_DIMS } from '../../src/core/lbug/schema.js'; +import { batchInsertEmbeddings } from '../../src/core/embeddings/embedding-pipeline.js'; +import { + disposeEmbeddingSpill, + materializeCachedEmbeddings, +} from '../../src/core/embeddings/embedding-restore-spill.js'; + +describe('loadCachedEmbeddings streaming (#3306)', () => { + let tmp: TestDBHandle | undefined; + + afterEach(async () => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + try { + await adapter.closeLbug(); + } catch { + /* already closed */ + } + await tmp?.cleanup(); + tmp = undefined; + }); + + async function seedDb(rowCount: number) { + tmp = await createTempDir('gitnexus-lbug-'); + const dbPath = path.join(tmp.dbPath, 'lbug'); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.initLbug(dbPath); + const rows = Array.from({ length: rowCount }, (_, i) => ({ + nodeId: `Function:src/f${i}.ts:fn${i}:1`, + chunkIndex: 0, + startLine: 1, + endLine: 3, + embedding: Array.from({ length: EMBEDDING_DIMS }, (__, d) => (d === 0 ? i + 1 : 0)), + contentHash: `hash-${i}`, + })); + await batchInsertEmbeddings(adapter.executeWithReusedStatement, rows); + return { adapter, rows }; + } + + it('materializes a small table in RAM (skip-fts / mock-compatible shape)', async () => { + const { adapter, rows } = await seedDb(3); + const cached = await adapter.loadCachedEmbeddings(); + expect(cached.spill).toBeUndefined(); + expect(cached.embeddings).toHaveLength(3); + expect(cached.rows).toHaveLength(3); + expect(cached.embeddingNodeIds.size).toBe(3); + expect(cached.embeddings.map((e) => e.nodeId).sort()).toEqual(rows.map((r) => r.nodeId).sort()); + expect(cached.embeddings.find((e) => e.nodeId === rows[1]!.nodeId)?.embedding[0]).toBe(2); + }); + + it('streams into a spill file when the in-memory limit is 0 and restores a subset', async () => { + const { adapter, rows } = await seedDb(12); + const cached = await adapter.loadCachedEmbeddings({ inMemoryRowLimit: 0 }); + try { + expect(cached.embeddings).toEqual([]); + expect(cached.spill?.rowCount).toBe(12); + expect(cached.rows).toHaveLength(12); + const wanted = new Set([rows[0]!.nodeId, rows[5]!.nodeId, rows[10]!.nodeId]); + const subset = materializeCachedEmbeddings( + cached, + cached.rows.filter((meta) => wanted.has(meta.nodeId)), + ); + expect(subset).toHaveLength(3); + const byId = new Map(subset.map((row) => [row.nodeId, row])); + expect(byId.get(rows[0]!.nodeId)?.embedding[0]).toBe(1); + expect(byId.get(rows[5]!.nodeId)?.embedding[0]).toBe(6); + expect(byId.get(rows[10]!.nodeId)?.embedding[0]).toBe(11); + expect(byId.get(rows[0]!.nodeId)?.contentHash).toBe(rows[0]!.contentHash); + } finally { + disposeEmbeddingSpill(cached.spill); + } + }); + + it('flips from RAM to spill once a non-zero in-memory limit is crossed', async () => { + const { adapter, rows } = await seedDb(8); + const cached = await adapter.loadCachedEmbeddings({ inMemoryRowLimit: 4 }); + try { + expect(cached.embeddings).toEqual([]); + expect(cached.spill?.rowCount).toBe(8); + expect(cached.rows).toHaveLength(8); + expect(fs.statSync(cached.spill!.path).size).toBe(12 + 8 * EMBEDDING_DIMS * 4); + const wanted = new Set([rows[2]!.nodeId, rows[3]!.nodeId]); + const subset = materializeCachedEmbeddings( + cached, + cached.rows.filter((meta) => wanted.has(meta.nodeId)), + ); + expect(subset).toHaveLength(2); + const byId = new Map(subset.map((row) => [row.nodeId, row])); + expect(byId.get(rows[2]!.nodeId)?.embedding[0]).toBe(3); + expect(byId.get(rows[3]!.nodeId)?.embedding[0]).toBe(4); + } finally { + disposeEmbeddingSpill(cached.spill); + } + }); + + it('surfaces a spill write failure instead of adopting an empty snapshot', async () => { + const { adapter } = await seedDb(3); + const spillDir = path.join(tmp!.dbPath, 'not-a-directory'); + fs.writeFileSync(spillDir, 'x'); + await expect(adapter.loadCachedEmbeddings({ inMemoryRowLimit: 0, spillDir })).rejects.toThrow( + /ENOTDIR|not a directory|ENOSPC|EACCES/i, + ); + }); +}); diff --git a/gitnexus/test/unit/embedding-restore-spill.test.ts b/gitnexus/test/unit/embedding-restore-spill.test.ts new file mode 100644 index 000000000..b11695110 --- /dev/null +++ b/gitnexus/test/unit/embedding-restore-spill.test.ts @@ -0,0 +1,274 @@ +import { existsSync, statSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + abortCachedEmbeddingsBuilder, + cacheRowCount, + createCachedEmbeddingsBuilder, + DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT, + discardLiveEmbeddingSpills, + discardScopedEmbeddingSpills, + disposeEmbeddingSpill, + EmbeddingSpillReader, + finalizeCachedEmbeddingsSnapshot, + ingestCachedEmbeddingRow, + materializeCachedEmbeddings, + normalizeCachedEmbeddings, + readSpillVectors, + resolveEmbeddingCacheInMemoryRowLimit, + snapshotEmbeddingDims, + withEmbeddingSpillScope, +} from '../../src/core/embeddings/embedding-restore-spill.js'; + +const DIMS = 8; + +function vector(fill: number): number[] { + return Array.from({ length: DIMS }, () => fill); +} + +function row(id: string, fill: number, hash = `hash-${id}`) { + return { + nodeId: id, + chunkIndex: 0, + startLine: 1, + endLine: 2, + embedding: vector(fill), + contentHash: hash, + }; +} + +describe('embedding-restore-spill (#3306)', () => { + const spills: Array<{ path: string }> = []; + afterEach(() => { + for (const spill of spills) disposeEmbeddingSpill(spill); + spills.length = 0; + }); + + it('keeps small tables in RAM and does not leave a spill file', () => { + const builder = createCachedEmbeddingsBuilder({ + inMemoryRowLimit: 4, + spillDir: os.tmpdir(), + }); + ingestCachedEmbeddingRow(builder, row('n1', 0.25), true); + ingestCachedEmbeddingRow(builder, row('n2', 0.5), true); + const snapshot = finalizeCachedEmbeddingsSnapshot(builder); + expect(snapshot.spill).toBeUndefined(); + expect(existsSync(builder.writer.path)).toBe(false); + expect(snapshot.embeddings).toHaveLength(2); + expect(snapshot.rows).toHaveLength(2); + expect(snapshot.embeddings[0]?.embedding[0]).toBeCloseTo(0.25); + expect(cacheRowCount(snapshot)).toBe(2); + expect(snapshotEmbeddingDims(snapshot)).toBe(DIMS); + }); + + it('spills vectors once the in-memory limit is exceeded and materializes a subset', () => { + const builder = createCachedEmbeddingsBuilder({ + inMemoryRowLimit: 2, + spillDir: os.tmpdir(), + }); + for (let i = 0; i < 5; i++) { + ingestCachedEmbeddingRow(builder, row(`n${i}`, i + 1), true); + } + const snapshot = finalizeCachedEmbeddingsSnapshot(builder); + if (snapshot.spill) spills.push(snapshot.spill); + expect(snapshot.embeddings).toEqual([]); + expect(snapshot.spill?.rowCount).toBe(5); + expect(existsSync(snapshot.spill!.path)).toBe(true); + expect(snapshot.embeddingNodeIds.size).toBe(5); + + const subset = materializeCachedEmbeddings(snapshot, snapshot.rows.slice(1, 3)); + expect(subset).toHaveLength(2); + expect(subset[0]?.nodeId).toBe('n1'); + expect(subset[0]?.embedding[0]).toBeCloseTo(2); + expect(subset[1]?.embedding[0]).toBeCloseTo(3); + }); + + it('always spills when the in-memory limit is 0 (no Number[] table in RAM)', () => { + const builder = createCachedEmbeddingsBuilder({ + inMemoryRowLimit: 0, + spillDir: os.tmpdir(), + }); + ingestCachedEmbeddingRow(builder, row('only', 0.75), true); + const snapshot = finalizeCachedEmbeddingsSnapshot(builder); + if (snapshot.spill) spills.push(snapshot.spill); + expect(snapshot.embeddings).toEqual([]); + expect(snapshot.rows).toHaveLength(1); + expect(materializeCachedEmbeddings(snapshot, snapshot.rows)[0]?.embedding[0]).toBeCloseTo(0.75); + }); + + it('aborts an unfinished builder without leaking a spill file', () => { + const builder = createCachedEmbeddingsBuilder({ + inMemoryRowLimit: 0, + spillDir: os.tmpdir(), + }); + ingestCachedEmbeddingRow(builder, row('n1', 1), true); + expect(existsSync(builder.writer.path)).toBe(true); + abortCachedEmbeddingsBuilder(builder); + expect(existsSync(builder.writer.path)).toBe(false); + }); + + it('normalizes mock {embeddings} payloads so Phase 3.5 can restore without a spill', () => { + const snapshot = normalizeCachedEmbeddings({ + embeddingNodeIds: new Set(['Function:a:foo']), + embeddings: [ + { + nodeId: 'Function:a:foo', + chunkIndex: 0, + startLine: 0, + endLine: 3, + embedding: vector(0.1), + contentHash: 'stub', + }, + ], + }); + expect(snapshot.rows).toHaveLength(1); + expect(snapshot.rows[0]?.vectorIndex).toBe(0); + const restored = materializeCachedEmbeddings(snapshot, snapshot.rows); + expect(restored[0]?.contentHash).toBe('stub'); + expect(restored[0]?.embedding).toHaveLength(DIMS); + }); + + it('defaults the in-memory row limit to 2048 and honors GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT', () => { + expect(DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT).toBe(2048); + vi.stubEnv('GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT', ''); + expect(resolveEmbeddingCacheInMemoryRowLimit()).toBe(2048); + vi.stubEnv('GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT', '0'); + expect(resolveEmbeddingCacheInMemoryRowLimit()).toBe(0); + vi.stubEnv('GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT', '12'); + expect(resolveEmbeddingCacheInMemoryRowLimit()).toBe(12); + vi.stubEnv('GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT', 'nope'); + expect(resolveEmbeddingCacheInMemoryRowLimit()).toBe(2048); + vi.unstubAllEnvs(); + expect(resolveEmbeddingCacheInMemoryRowLimit(7)).toBe(7); + expect(resolveEmbeddingCacheInMemoryRowLimit(-1)).toBe(2048); + }); + + it('spills above the default 2048-row limit with header-plus-body size 12 + N * D * 4', () => { + const n = DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT + 1; + const builder = createCachedEmbeddingsBuilder({ spillDir: os.tmpdir() }); + for (let i = 0; i < n; i++) { + ingestCachedEmbeddingRow(builder, row(`n${i}`, 1), true); + } + const snapshot = finalizeCachedEmbeddingsSnapshot(builder); + if (snapshot.spill) spills.push(snapshot.spill); + expect(snapshot.embeddings).toEqual([]); + expect(snapshot.spill?.rowCount).toBe(n); + expect(statSync(snapshot.spill!.path).size).toBe(12 + n * DIMS * 4); + }); + + it('rejects a dim mismatch once spilling and rejects a short or bad-magic header', () => { + const builder = createCachedEmbeddingsBuilder({ + inMemoryRowLimit: 0, + spillDir: os.tmpdir(), + }); + ingestCachedEmbeddingRow(builder, row('a', 1), true); + expect(() => + ingestCachedEmbeddingRow(builder, { ...row('b', 2), embedding: [1, 2, 3] }, true), + ).toThrow(/dim mismatch/); + abortCachedEmbeddingsBuilder(builder); + + const shortPath = path.join(os.tmpdir(), `gitnexus-embed-restore-short-${process.pid}.bin`); + writeFileSync(shortPath, Buffer.from('NOPE')); + spills.push({ path: shortPath }); + expect(() => readSpillVectors({ path: shortPath, dims: DIMS, rowCount: 1 }, [0])).toThrow( + /invalid embedding spill header/, + ); + + const badMagic = Buffer.alloc(12); + badMagic.write('NOPE', 0, 4, 'ascii'); + badMagic.writeUInt8(1, 4); + badMagic.writeUInt32LE(DIMS, 5); + const badPath = path.join(os.tmpdir(), `gitnexus-embed-restore-bad-${process.pid}.bin`); + writeFileSync(badPath, badMagic); + spills.push({ path: badPath }); + expect(() => readSpillVectors({ path: badPath, dims: DIMS, rowCount: 1 }, [0])).toThrow( + /invalid embedding spill header/, + ); + }); + + it('reuses an open spill reader across materialize batches', () => { + const builder = createCachedEmbeddingsBuilder({ + inMemoryRowLimit: 0, + spillDir: os.tmpdir(), + }); + for (let i = 0; i < 4; i++) { + ingestCachedEmbeddingRow(builder, row(`n${i}`, i + 1), true); + } + const snapshot = finalizeCachedEmbeddingsSnapshot(builder); + if (snapshot.spill) spills.push(snapshot.spill); + const reader = new EmbeddingSpillReader(snapshot.spill!); + try { + const first = materializeCachedEmbeddings(snapshot, snapshot.rows.slice(0, 2), reader); + const second = materializeCachedEmbeddings(snapshot, snapshot.rows.slice(2, 4), reader); + expect(first[0]?.embedding[0]).toBeCloseTo(1); + expect(second[1]?.embedding[0]).toBeCloseTo(4); + } finally { + reader.close(); + } + }); + + it('scoped discard unlinks only spills created in that analyze run', async () => { + const other = createCachedEmbeddingsBuilder({ + inMemoryRowLimit: 0, + spillDir: os.tmpdir(), + }); + ingestCachedEmbeddingRow(other, row('other', 1), true); + const otherSnapshot = finalizeCachedEmbeddingsSnapshot(other); + if (otherSnapshot.spill) spills.push(otherSnapshot.spill); + expect(existsSync(otherSnapshot.spill!.path)).toBe(true); + + await withEmbeddingSpillScope(async () => { + const builder = createCachedEmbeddingsBuilder({ + inMemoryRowLimit: 0, + spillDir: os.tmpdir(), + }); + ingestCachedEmbeddingRow(builder, row('scoped', 2), true); + const snapshot = finalizeCachedEmbeddingsSnapshot(builder); + expect(existsSync(snapshot.spill!.path)).toBe(true); + discardScopedEmbeddingSpills(); + expect(existsSync(snapshot.spill!.path)).toBe(false); + expect(existsSync(otherSnapshot.spill!.path)).toBe(true); + }); + }); + + it('unlinks a finished spill that was not disposed', () => { + const builder = createCachedEmbeddingsBuilder({ + inMemoryRowLimit: 0, + spillDir: os.tmpdir(), + }); + ingestCachedEmbeddingRow(builder, row('n1', 1), true); + const snapshot = finalizeCachedEmbeddingsSnapshot(builder); + expect(snapshot.spill).toBeDefined(); + expect(existsSync(snapshot.spill!.path)).toBe(true); + discardLiveEmbeddingSpills(); + expect(existsSync(snapshot.spill!.path)).toBe(false); + }); + + it('throws when materializing a meta row with no matching vector', () => { + const snapshot = normalizeCachedEmbeddings({ + embeddings: [ + { + nodeId: 'Function:a:foo', + chunkIndex: 0, + startLine: 0, + endLine: 3, + embedding: vector(0.1), + contentHash: 'stub', + }, + ], + }); + expect(() => + materializeCachedEmbeddings(snapshot, [ + { + nodeId: 'Function:missing:bar', + chunkIndex: 0, + startLine: 0, + endLine: 1, + contentHash: 'x', + vectorIndex: 99, + }, + ]), + ).toThrow(/missing cached embedding Function:missing:bar:0/); + }); +}); diff --git a/gitnexus/test/unit/incremental-dirty-recovery.test.ts b/gitnexus/test/unit/incremental-dirty-recovery.test.ts index eafb933ce..0192cac1c 100644 --- a/gitnexus/test/unit/incremental-dirty-recovery.test.ts +++ b/gitnexus/test/unit/incremental-dirty-recovery.test.ts @@ -19,7 +19,7 @@ */ import { writeFile, readFile } from 'fs/promises'; -import { describe, it, expect } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { getStoragePaths, saveMeta, @@ -35,92 +35,109 @@ import { seedEmbeddingsForFiles } from '../helpers/embedding-seed.js'; const setupMiniRepo = () => setupSharedMiniRepo('gitnexus-incr-dirty-rec-'); +async function parkCrashSidecarsAndRebuild(options?: { alwaysSpill?: boolean }) { + vi.stubEnv('GITNEXUS_WORKER_READY_TIMEOUT_MS', '60000'); + if (options?.alwaysSpill) { + vi.stubEnv('GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT', '0'); + } + const repo = await setupMiniRepo(); + try { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + + // Seed real embeddings BEFORE the tamper (tri-review 4669518496 / U5): + // with meta.stats.embeddings = 0 the recovery run derived + // shouldLoadCache=false and never opened the DB pre-wipe — this test + // was vacuous about the exact open the parking protects. Seeded rows + + // a stats stamp route the recovery (which runs force:true internally, + // so forceRegenerate → shouldLoadCache) through the REAL + // embedding-cache preservation open on the just-parked DB. + const { storagePath, lbugPath } = getStoragePaths(repo.dbPath); + const seededIdsByFile = await seedEmbeddingsForFiles( + repo.dbPath, + ['src/handler.ts', 'src/logger.ts'], + 1, + ); + const seededNodeIds = [...seededIdsByFile.values()].flat(); + expect(seededNodeIds.length).toBeGreaterThan(0); + + // Simulate a crashed incremental writeback: dirty flag in meta plus + // leftover sidecars whose bytes must never be replayed. 8KB puts the + // WAL above the tiny-orphan threshold — the state the sidecar + // preflight deliberately leaves in place for engine replay. + const meta = await loadMeta(storagePath); + const tampered: RepoMeta = { + ...meta!, + stats: { ...meta!.stats, embeddings: seededNodeIds.length }, + incrementalInProgress: { + startedAt: Date.now() - 60_000, + toWriteCount: 12, + phase: 'load-graph', + }, + }; + await saveMeta(storagePath, tampered); + const walGarbage = Buffer.alloc(8192, 0xab); + const shadowGarbage = Buffer.alloc(4096, 0xcd); + await writeFile(`${lbugPath}.wal`, walGarbage); + await writeFile(`${lbugPath}.shadow`, shadowGarbage); + + const logs: string[] = []; + // embeddingsNodeLimit: 1 (KTD9): the recovery runs force:true + // internally, and the seeded stats would otherwise route Phase 4 into + // a real embedder in CI — the 1-node cap suppresses generation while + // leaving the preserve/restore path fully live. On linux the + // wipe-and-restore vector-index seam then fires for real (statically + // linked VECTOR): a CREATE_VECTOR_INDEX over the restored rows is + // expected and harmless here. + const recovered = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true, embeddingsNodeLimit: 1 }, + { onProgress: () => {}, onLog: (m) => logs.push(m) }, + ); + expect(recovered.alreadyUpToDate).toBeUndefined(); + + // Both sidecars were parked verbatim (renamed, never deleted) before + // any open could replay them… + expect(Buffer.compare(await readFile(`${lbugPath}.wal.dirty-recovery`), walGarbage)).toBe(0); + expect(Buffer.compare(await readFile(`${lbugPath}.shadow.dirty-recovery`), shadowGarbage)).toBe( + 0, + ); + const joinedLogs = logs.join('\n'); + expect(joinedLogs).toContain('Parked lbug.wal.dirty-recovery, lbug.shadow.dirty-recovery'); + + // …the run traversed the REAL pre-wipe preservation open — recovery's + // internal force on an embedded repo upgrades to regenerate mode, whose + // banner only prints when existingEmbeddingCount was read from the + // seeded stats and the cache-load path engaged… + expect(joinedLogs).toContain( + `--force on a repo with ${seededNodeIds.length} existing embeddings`, + ); + // …with generation itself cap-suppressed (no embedder in CI): + expect(joinedLogs).toContain('exceeds the 1-node safety cap'); + + // …and the rebuild completed into a clean index: dirty flag cleared, + // and the seeded embeddings survived the park → open → wipe → restore + // round-trip (the strongest signal the preservation open really ran: + // the DB was wiped, so these rows can only come from the cache load). + const after = await loadMeta(storagePath); + expect(after!.incrementalInProgress).toBeUndefined(); + expect(after!.stats?.embeddings).toBe(seededNodeIds.length); + } finally { + vi.unstubAllEnvs(); + await repo.cleanup(); + } +} + describe('runFullAnalysis — dirty-flag recovery sidecar parking (#2409)', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('parks the crashed run WAL/shadow sidecars before reopening, then rebuilds clean', async () => { - const repo = await setupMiniRepo(); - try { - const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); - await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + await parkCrashSidecarsAndRebuild(); + }, 300_000); - // Seed real embeddings BEFORE the tamper (tri-review 4669518496 / U5): - // with meta.stats.embeddings = 0 the recovery run derived - // shouldLoadCache=false and never opened the DB pre-wipe — this test - // was vacuous about the exact open the parking protects. Seeded rows + - // a stats stamp route the recovery (which runs force:true internally, - // so forceRegenerate → shouldLoadCache) through the REAL - // embedding-cache preservation open on the just-parked DB. - const { storagePath, lbugPath } = getStoragePaths(repo.dbPath); - const seededIdsByFile = await seedEmbeddingsForFiles( - repo.dbPath, - ['src/handler.ts', 'src/logger.ts'], - 1, - ); - const seededNodeIds = [...seededIdsByFile.values()].flat(); - expect(seededNodeIds.length).toBeGreaterThan(0); - - // Simulate a crashed incremental writeback: dirty flag in meta plus - // leftover sidecars whose bytes must never be replayed. 8KB puts the - // WAL above the tiny-orphan threshold — the state the sidecar - // preflight deliberately leaves in place for engine replay. - const meta = await loadMeta(storagePath); - const tampered: RepoMeta = { - ...meta!, - stats: { ...meta!.stats, embeddings: seededNodeIds.length }, - incrementalInProgress: { - startedAt: Date.now() - 60_000, - toWriteCount: 12, - phase: 'load-graph', - }, - }; - await saveMeta(storagePath, tampered); - const walGarbage = Buffer.alloc(8192, 0xab); - const shadowGarbage = Buffer.alloc(4096, 0xcd); - await writeFile(`${lbugPath}.wal`, walGarbage); - await writeFile(`${lbugPath}.shadow`, shadowGarbage); - - const logs: string[] = []; - // embeddingsNodeLimit: 1 (KTD9): the recovery runs force:true - // internally, and the seeded stats would otherwise route Phase 4 into - // a real embedder in CI — the 1-node cap suppresses generation while - // leaving the preserve/restore path fully live. On linux the - // wipe-and-restore vector-index seam then fires for real (statically - // linked VECTOR): a CREATE_VECTOR_INDEX over the restored rows is - // expected and harmless here. - const recovered = await runFullAnalysis( - repo.dbPath, - { skipAgentsMd: true, embeddingsNodeLimit: 1 }, - { onProgress: () => {}, onLog: (m) => logs.push(m) }, - ); - expect(recovered.alreadyUpToDate).toBeUndefined(); - - // Both sidecars were parked verbatim (renamed, never deleted) before - // any open could replay them… - expect(Buffer.compare(await readFile(`${lbugPath}.wal.dirty-recovery`), walGarbage)).toBe(0); - expect( - Buffer.compare(await readFile(`${lbugPath}.shadow.dirty-recovery`), shadowGarbage), - ).toBe(0); - const joinedLogs = logs.join('\n'); - expect(joinedLogs).toContain('Parked lbug.wal.dirty-recovery, lbug.shadow.dirty-recovery'); - - // …the run traversed the REAL pre-wipe preservation open — recovery's - // internal force on an embedded repo upgrades to regenerate mode, whose - // banner only prints when existingEmbeddingCount was read from the - // seeded stats and the cache-load path engaged… - expect(joinedLogs).toContain( - `--force on a repo with ${seededNodeIds.length} existing embeddings`, - ); - // …with generation itself cap-suppressed (no embedder in CI): - expect(joinedLogs).toContain('exceeds the 1-node safety cap'); - - // …and the rebuild completed into a clean index: dirty flag cleared, - // and the seeded embeddings survived the park → open → wipe → restore - // round-trip (the strongest signal the preservation open really ran: - // the DB was wiped, so these rows can only come from the cache load). - const after = await loadMeta(storagePath); - expect(after!.incrementalInProgress).toBeUndefined(); - expect(after!.stats?.embeddings).toBe(seededNodeIds.length); - } finally { - await repo.cleanup(); - } + it('restores seeded embeddings from a spill after wipe when the in-memory limit is 0', async () => { + await parkCrashSidecarsAndRebuild({ alwaysSpill: true }); }, 300_000); }); diff --git a/gitnexus/test/unit/run-analyze-fts-repair.test.ts b/gitnexus/test/unit/run-analyze-fts-repair.test.ts index 875e7dc47..2f0304ef4 100644 --- a/gitnexus/test/unit/run-analyze-fts-repair.test.ts +++ b/gitnexus/test/unit/run-analyze-fts-repair.test.ts @@ -7,7 +7,7 @@ import { saveMeta, type RepoMeta, } from '../../src/storage/repo-manager.js'; -import { EMBEDDING_DIMS } from '../../src/core/lbug/schema.js'; +import { EMBEDDING_DIMS, STALE_HASH_SENTINEL } from '../../src/core/lbug/schema.js'; import { getIndexIncompleteReasons } from '../../src/core/index-freshness.js'; import type { EmbeddingPipelineOptions, @@ -1225,6 +1225,313 @@ describe('runFullAnalysis wipe-and-restore vector-index stamp (tri-review 466951 await tmpRepo.cleanup(); } }); + + it('warns and continues without restore when loadCachedEmbeddings rejects (R11)', async () => { + const RESTORED_NODE_ID = 'Function:src/app.ts:handler:1'; + const stubNode = { + id: RESTORED_NODE_ID, + label: 'Function', + name: 'handler', + properties: { filePath: 'src/app.ts' }, + }; + const executeWithReusedStatement = vi.fn(async () => []); + vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({ + initLbug: vi.fn(async () => undefined), + loadGraphToLbug: vi.fn(async () => undefined), + getLbugStats: vi.fn(async () => ({ nodes: 2, edges: 0, communities: 0, processes: 0 })), + executeQuery: vi.fn(async () => []), + executeWithReusedStatement, + closeLbug: vi.fn(async () => undefined), + wipeLbugDbFiles: vi.fn(async () => undefined), + tryFlushWAL: vi.fn(async () => true), + loadCachedEmbeddings: vi.fn(async () => { + throw new Error('spill write failed'); + }), + deleteNodesForFile: vi.fn(async () => undefined), + deleteNodesForFiles: vi.fn(async () => undefined), + deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined), + queryImporters: vi.fn(async () => []), + queryImportersBatch: vi.fn(async () => []), + loadFTSExtension: vi.fn(async () => false), + })); + vi.doMock('../../src/core/search/fts-indexes.js', () => ({ + initialiseSearchFTSStemmer: vi.fn(() => 'porter'), + createSearchFTSIndexes: vi.fn(async () => []), + verifySearchFTSIndexes: vi.fn(async () => []), + })); + vi.doMock('../../src/core/ingestion/pipeline.js', () => ({ + runPipelineFromRepo: vi.fn(async (repoPath: string) => ({ + repoPath, + totalFileCount: 1, + graph: { + forEachNode: (fn: (node: typeof stubNode) => void) => fn(stubNode), + getNode: (id: string) => (id === RESTORED_NODE_ID ? stubNode : undefined), + }, + })), + })); + vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({ + ...(await importActual()), + registerRepo: vi.fn(async () => 'cache-load-reject-repo'), + ensureGitNexusIgnored: vi.fn(async () => undefined), + })); + vi.doMock('../../src/core/embeddings/embedding-pipeline.js', async (importActual) => ({ + ...(await importActual()), + batchInsertEmbeddings: vi.fn(async () => { + throw new Error('restore must not run after a cache-load failure'); + }), + })); + + const tmpRepo = await createTempDir('gitnexus-run-analyze-cache-reject-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await fs.mkdir(storagePath, { recursive: true }); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: '', + indexedAt: new Date().toISOString(), + stats: { embeddings: 1 }, + }); + + const logs: string[] = []; + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis( + tmpRepo.dbPath, + { force: true, embeddingsNodeLimit: 1 }, + { onProgress: () => {}, onLog: (m) => logs.push(m) }, + ); + + expect(logs.some((m) => m.includes('Warning: could not load cached embeddings'))).toBe(true); + expect(logs.some((m) => m.includes('spill write failed'))).toBe(true); + expect( + executeWithReusedStatement.mock.calls.some((call) => + String(call[0]).includes('CREATE (e:CodeEmbedding'), + ), + ).toBe(false); + } finally { + await tmpRepo.cleanup(); + } + }); + + it('omits failed restore rows from the Phase 4 skip-set so they can be re-embedded', async () => { + const RESTORED_NODE_ID = 'Function:src/app.ts:handler:1'; + const CACHED_HASH = 'cached-stable-hash'; + const stubNode = { + id: RESTORED_NODE_ID, + label: 'Function', + name: 'handler', + properties: { filePath: 'src/app.ts' }, + }; + let existingEmbeddings: Map | undefined; + vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({ + initLbug: vi.fn(async () => undefined), + loadGraphToLbug: vi.fn(async () => undefined), + getLbugStats: vi.fn(async () => ({ nodes: 2, edges: 0, communities: 0, processes: 0 })), + executeQuery: vi.fn(async () => []), + executeWithReusedStatement: vi.fn(async () => []), + closeLbug: vi.fn(async () => undefined), + wipeLbugDbFiles: vi.fn(async () => undefined), + tryFlushWAL: vi.fn(async () => true), + loadCachedEmbeddings: vi.fn(async () => ({ + embeddingNodeIds: new Set([RESTORED_NODE_ID]), + embeddings: [ + { + nodeId: RESTORED_NODE_ID, + chunkIndex: 0, + startLine: 0, + endLine: 3, + embedding: new Array(EMBEDDING_DIMS).fill(0), + contentHash: CACHED_HASH, + }, + ], + })), + deleteNodesForFile: vi.fn(async () => undefined), + deleteNodesForFiles: vi.fn(async () => undefined), + deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined), + queryImporters: vi.fn(async () => []), + queryImportersBatch: vi.fn(async () => []), + loadFTSExtension: vi.fn(async () => false), + })); + vi.doMock('../../src/core/search/fts-indexes.js', () => ({ + initialiseSearchFTSStemmer: vi.fn(() => 'porter'), + createSearchFTSIndexes: vi.fn(async () => []), + verifySearchFTSIndexes: vi.fn(async () => []), + })); + vi.doMock('../../src/core/ingestion/pipeline.js', () => ({ + runPipelineFromRepo: vi.fn(async (repoPath: string) => ({ + repoPath, + totalFileCount: 1, + graph: { + forEachNode: (fn: (node: typeof stubNode) => void) => fn(stubNode), + getNode: (id: string) => (id === RESTORED_NODE_ID ? stubNode : undefined), + }, + })), + })); + vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({ + ...(await importActual()), + registerRepo: vi.fn(async () => 'ktd7-skip-set-repo'), + ensureGitNexusIgnored: vi.fn(async () => undefined), + })); + const runEmbeddingPipeline = vi.fn( + async ( + _executeQuery: unknown, + _executeWithReusedStatement: unknown, + _onProgress: unknown, + _config: unknown, + _cachedNodeIds: unknown, + embeddings: Map | undefined, + ) => { + existingEmbeddings = embeddings; + return { + nodesProcessed: 0, + chunksProcessed: 0, + vectorIndexReady: false, + semanticMode: 'exact-scan' as const, + failedNodeIds: [], + }; + }, + ); + vi.doMock('../../src/core/embeddings/embedding-pipeline.js', () => ({ + runEmbeddingPipeline, + buildVectorIndex: vi.fn(async () => false), + batchInsertEmbeddings: vi.fn(async () => { + throw new Error('nth batch insert failed'); + }), + })); + + const tmpRepo = await createTempDir('gitnexus-run-analyze-ktd7-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await fs.mkdir(storagePath, { recursive: true }); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: '', + indexedAt: new Date().toISOString(), + stats: { embeddings: 1 }, + }); + + const logs: string[] = []; + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis( + tmpRepo.dbPath, + { force: true }, + { onProgress: () => {}, onLog: (m) => logs.push(m) }, + ); + + expect(runEmbeddingPipeline).toHaveBeenCalled(); + expect(existingEmbeddings?.get(RESTORED_NODE_ID)).toBe(STALE_HASH_SENTINEL); + expect(logs.some((m) => m.includes('Warning: could not restore'))).toBe(true); + } finally { + await tmpRepo.cleanup(); + } + }); + + it('marks a node stale when only some of its restore batches succeed', async () => { + const RESTORED_NODE_ID = 'Function:src/app.ts:handler:1'; + const CACHED_HASH = 'cached-stable-hash'; + const stubNode = { + id: RESTORED_NODE_ID, + label: 'Function', + name: 'handler', + properties: { filePath: 'src/app.ts' }, + }; + const embeddings = Array.from({ length: 201 }, (_, chunkIndex) => ({ + nodeId: RESTORED_NODE_ID, + chunkIndex, + startLine: chunkIndex, + endLine: chunkIndex + 1, + embedding: new Array(EMBEDDING_DIMS).fill(0), + contentHash: CACHED_HASH, + })); + let existingEmbeddings: Map | undefined; + vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({ + initLbug: vi.fn(async () => undefined), + loadGraphToLbug: vi.fn(async () => undefined), + getLbugStats: vi.fn(async () => ({ nodes: 2, edges: 0, communities: 0, processes: 0 })), + executeQuery: vi.fn(async () => []), + executeWithReusedStatement: vi.fn(async () => []), + closeLbug: vi.fn(async () => undefined), + wipeLbugDbFiles: vi.fn(async () => undefined), + tryFlushWAL: vi.fn(async () => true), + loadCachedEmbeddings: vi.fn(async () => ({ + embeddingNodeIds: new Set([RESTORED_NODE_ID]), + embeddings, + })), + deleteNodesForFile: vi.fn(async () => undefined), + deleteNodesForFiles: vi.fn(async () => undefined), + deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined), + queryImporters: vi.fn(async () => []), + queryImportersBatch: vi.fn(async () => []), + loadFTSExtension: vi.fn(async () => false), + })); + vi.doMock('../../src/core/search/fts-indexes.js', () => ({ + initialiseSearchFTSStemmer: vi.fn(() => 'porter'), + createSearchFTSIndexes: vi.fn(async () => []), + verifySearchFTSIndexes: vi.fn(async () => []), + })); + vi.doMock('../../src/core/ingestion/pipeline.js', () => ({ + runPipelineFromRepo: vi.fn(async (repoPath: string) => ({ + repoPath, + totalFileCount: 1, + graph: { + forEachNode: (fn: (node: typeof stubNode) => void) => fn(stubNode), + getNode: (id: string) => (id === RESTORED_NODE_ID ? stubNode : undefined), + }, + })), + })); + vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({ + ...(await importActual()), + registerRepo: vi.fn(async () => 'partial-restore-skip-set-repo'), + ensureGitNexusIgnored: vi.fn(async () => undefined), + })); + const runEmbeddingPipeline = vi.fn( + async ( + _executeQuery: unknown, + _executeWithReusedStatement: unknown, + _onProgress: unknown, + _config: unknown, + _cachedNodeIds: unknown, + embeddings: Map | undefined, + ) => { + existingEmbeddings = embeddings; + return { + nodesProcessed: 0, + chunksProcessed: 0, + vectorIndexReady: false, + semanticMode: 'exact-scan' as const, + failedNodeIds: [], + }; + }, + ); + let inserts = 0; + vi.doMock('../../src/core/embeddings/embedding-pipeline.js', () => ({ + runEmbeddingPipeline, + buildVectorIndex: vi.fn(async () => false), + batchInsertEmbeddings: vi.fn(async () => { + inserts += 1; + if (inserts > 1) throw new Error('second batch insert failed'); + }), + })); + + const tmpRepo = await createTempDir('gitnexus-run-analyze-partial-restore-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await fs.mkdir(storagePath, { recursive: true }); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: '', + indexedAt: new Date().toISOString(), + stats: { embeddings: 201 }, + }); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis(tmpRepo.dbPath, { force: true }, { onProgress: () => {} }); + + expect(inserts).toBe(2); + expect(existingEmbeddings?.get(RESTORED_NODE_ID)).toBe(STALE_HASH_SENTINEL); + } finally { + await tmpRepo.cleanup(); + } + }); }); /** diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 4b2625805..1239862a3 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -100,6 +100,7 @@ export default defineConfig({ 'test/integration/analyze-wal-checkpoint-failure.test.ts', 'test/integration/lbug-non-ascii-path.test.ts', 'test/integration/lbug-conn-serialization.test.ts', + 'test/integration/load-cached-embeddings-spill.test.ts', 'test/integration/group/manifest-resolve-symbol-2325.test.ts', 'test/integration/group/manifest-synthetic-impact-lbug.test.ts', 'test/integration/group/http-route-resolve-symbol.test.ts', @@ -179,6 +180,7 @@ export default defineConfig({ 'test/integration/analyze-wal-checkpoint-failure.test.ts', 'test/integration/lbug-non-ascii-path.test.ts', 'test/integration/lbug-conn-serialization.test.ts', + 'test/integration/load-cached-embeddings-spill.test.ts', 'test/integration/group/manifest-resolve-symbol-2325.test.ts', 'test/integration/group/manifest-synthetic-impact-lbug.test.ts', 'test/integration/group/http-route-resolve-symbol.test.ts', From 56feb85c97d3ebc1e15668b18c2cfb243ec01efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Thu, 17 Sep 2026 22:16:00 +0100 Subject: [PATCH 08/20] chore: compile first-party packages with TypeScript 7 (#3311) * fix(web): drop TypeScript 7-incompatible tsconfig paths Remove baseUrl and the dead ../shared include so web project references typecheck under TypeScript 7. Co-authored-by: Cursor * test(cli): parse TypeScript with a TypeScript 6 API package Keep AST guards working after the named typescript package becomes 7, which no longer ships the Compiler API. Co-authored-by: Cursor * chore(lint): pin root TypeScript to the 6 API package Give typescript-eslint a TypeScript 6 peer so syntax-only lint still installs after CLI and web move to TypeScript 7. Co-authored-by: Cursor * chore(deps): compile first-party packages with TypeScript 7.0.2 Unify CLI and web on the same native compiler line as gitnexus-shared so typecheck and emit no longer split 5.x versus 7.x. Co-authored-by: Cursor * docs(ci): describe parent TypeScript 7 as the shared compiler Stop saying web compiles shared with TypeScript 5 now that the parent lockfile is 7. Co-authored-by: Cursor * fix(ci): compile shared from parent TypeScript on Vercel and skill-evolution Stop isolated npm installs in gitnexus-shared so those paths do not pull a second TypeScript 7 optional-platform tree. Co-authored-by: Cursor * docs: record TypeScript 7 typecheck and Dependabot major-split policy Keep contributor typecheck commands, and stop Dependabot from bumping shared onto a different TypeScript major than CLI and web. Co-authored-by: Cursor * fix(lint): pin root TypeScript to 5.9 so npm ci satisfies eslint peers typescript-eslint 8 peers typescript below 6.0.0, so the typescript6 alias made quality lint npm ci fail with ERESOLVE. Co-authored-by: Cursor * test(cli): drop the TypeScript 6 Compiler API package TypeScript 7.0 has no classic createProgram surface, so parse-only guards now use Babel and Mode 4 uses the TypeScript 7 Checker. Co-authored-by: Cursor * docs: align contributor setup with parent TypeScript 7 compile Stop telling clones to npm-install gitnexus-shared; CI and Vercel already emit that package from a parent lib/tsc.js shim. Co-authored-by: Cursor * fix(web): typecheck React JSX on TypeScript 7 with explicit DOM libs TypeScript 7 no longer implies DOM or auto-includes @types, so the web app must declare React/JSX settings while Vite keeps plugin-react. Co-authored-by: Cursor * test: pin Vercel --include=dev and share parse-only string helpers Production npm ci omits the web TypeScript unless --include=dev is on that install. Move staticStringValue next to the other Babel walk helpers so CLI help and contract tests share one source. Co-authored-by: Cursor * chore(autofix): apply prettier + eslint fixes via /autofix command --------- Co-authored-by: Gergo Magyar Co-authored-by: Cursor Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/actions/setup-gitnexus-web/action.yml | 4 +- .github/dependabot.yml | 13 + .../workflows/gitnexus-skill-evolution.yml | 18 +- CONTRIBUTING.md | 21 +- DoD.md | 6 +- README.md | 7 +- TESTING.md | 2 + gitnexus-web/package-lock.json | 389 +++++++++++++++++- gitnexus-web/package.json | 2 +- gitnexus-web/test/unit/tsconfig-react.test.ts | 16 + gitnexus-web/tsconfig.app.json | 11 +- gitnexus-web/tsconfig.node.json | 4 + gitnexus-web/vercel.json | 2 +- gitnexus-web/vite.config.ts | 2 +- gitnexus-web/vitest.config.ts | 2 +- gitnexus/package-lock.json | 375 ++++++++++++++++- gitnexus/package.json | 2 +- gitnexus/scripts/build.js | 4 +- gitnexus/test/helpers/ignore-set-source.ts | 64 ++- gitnexus/test/helpers/literal-collectors.ts | 58 ++- .../test/helpers/parse-typescript-source.ts | 162 ++++++++ gitnexus/test/helpers/ts7-ast.ts | 48 +++ gitnexus/test/unit/build-web-optin.test.ts | 44 +- gitnexus/test/unit/cli-index-help.test.ts | 46 +-- .../group/sync-partial-extraction.test.ts | 107 ++--- .../test/unit/query-determinism-guard.test.ts | 98 +++-- .../unit/skill-evolution-workflow.test.ts | 23 +- package-lock.json | 362 +++++++++++----- package.json | 3 +- 29 files changed, 1548 insertions(+), 347 deletions(-) create mode 100644 gitnexus-web/test/unit/tsconfig-react.test.ts create mode 100644 gitnexus/test/helpers/parse-typescript-source.ts create mode 100644 gitnexus/test/helpers/ts7-ast.ts diff --git a/.github/actions/setup-gitnexus-web/action.yml b/.github/actions/setup-gitnexus-web/action.yml index fb734d3ca..36b0b963c 100644 --- a/.github/actions/setup-gitnexus-web/action.yml +++ b/.github/actions/setup-gitnexus-web/action.yml @@ -19,8 +19,8 @@ runs: # Browsers are installed explicitly by e2e. Typecheck only needs types. PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' - # Compile shared with the web package's TypeScript 5. Do not npm-ci - # gitnexus-shared (TypeScript 7 optional-platform install, ~7 minutes). + # Compile shared with the web package's TypeScript 7. Do not npm-ci + # gitnexus-shared (a second optional-platform install, ~7 minutes). - name: Build gitnexus-shared # node + lib/tsc.js — same on Windows/macOS/Linux. Do not use .bin/tsc # (tsc.cmd on Windows; execFileSync cannot launch .cmd without a shell). diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 535204a38..9527828ac 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -93,6 +93,9 @@ updates: # The extension version is a separate upstream constant, not derivable # from the core version (see vendor/lbug-fts/manifest.json). - dependency-name: '@ladybugdb/core' + - dependency-name: typescript + update-types: + - version-update:semver-major # gitnexus-web (thin frontend client). - package-ecosystem: npm @@ -111,6 +114,10 @@ updates: labels: - dependencies - frontend + ignore: + - dependency-name: typescript + update-types: + - version-update:semver-major # Shared types package. - package-ecosystem: npm @@ -128,3 +135,9 @@ updates: include: scope labels: - dependencies + ignore: + # Keep shared on the same TypeScript major as CLI/web. A shared-only + # major bump reintroduces a compiler split this repo unified. + - dependency-name: typescript + update-types: + - version-update:semver-major diff --git a/.github/workflows/gitnexus-skill-evolution.yml b/.github/workflows/gitnexus-skill-evolution.yml index 22f0bdfcc..03b3655ca 100644 --- a/.github/workflows/gitnexus-skill-evolution.yml +++ b/.github/workflows/gitnexus-skill-evolution.yml @@ -330,24 +330,20 @@ jobs: set -euo pipefail # The benchmark's task bindings sandbox-copy node_modules from the # monorepo root as well as gitnexus-shared and gitnexus (see the - # sandbox_copy entries in tasks.scenarios.yaml). The two steps below - # install the subpackage trees; the root tree needs its own install - # or capture_task_dependency_binding aborts at task binding on the - # missing root node_modules. + # sandbox_dependencies entries in tasks.scenarios.yaml). gitnexus + # npm ci plus build compiles shared through parent lib/tsc.js; do + # not npm ci gitnexus-shared (second TypeScript 7 optional install). npm ci - - name: Build pinned shared runtime - run: | - set -euo pipefail - npm ci - npm run build - working-directory: gitnexus-shared - - name: Install and build pinned GitNexus runtime run: | set -euo pipefail npm ci npm run build + # Task YAML still mounts gitnexus-shared/node_modules. Keep an empty + # directory so capture_task_dependency_binding does not abort, without + # installing TypeScript 7 inside shared. + mkdir -p ../gitnexus-shared/node_modules working-directory: gitnexus - name: Point the benchmark task repo at the checkout diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 23a6ae28c..b2a0c540e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,14 +16,19 @@ This project uses the [PolyForm Noncommercial License 1.0.0](https://polyformpro **Prerequisites:** Node.js — `gitnexus/` requires `^22.18.0 || >=24.11.0` and `gitnexus-web/` requires `^20.19.0 || >=22.12.0` (enforced via the `engines` field in each package). Use `nvm install` to match the local version. 1. Clone the repository. -2. **Shared package:** `cd gitnexus-shared && npm install && npm run build` -3. **CLI / MCP package:** `cd ../gitnexus && npm install && npm run build` -4. **Web UI (if needed):** `cd ../gitnexus-web && npm install` -5. Run tests as described in [TESTING.md](TESTING.md). +2. **CLI / MCP package:** `cd gitnexus && npm install && npm run build` + `prepare` / `scripts/build.js` compiles `gitnexus-shared` with + `node …/typescript/lib/tsc.js` from this package. Do not `npm install` or + `npm ci` inside `gitnexus-shared/` — that is a second TypeScript 7 + optional-platform install and is not what `setup-gitnexus` does. +3. **Web UI (if needed):** `cd gitnexus-web && npm install` + If you skipped step 2, compile shared with the web compiler first: + `cd gitnexus-shared && node ../gitnexus-web/node_modules/typescript/lib/tsc.js` +4. Run tests as described in [TESTING.md](TESTING.md). -The CLI build imports `gitnexus-shared`, so a fresh clone must install and build -the shared package before running `npm install` in `gitnexus/`. This is the same -order used by the repository's `setup-gitnexus` CI action. +The CLI build imports `gitnexus-shared`, so `gitnexus-shared/dist` must exist +before `gitnexus` typecheck. That emit uses a parent package's TypeScript 7 +`lib/tsc.js`, matching `setup-gitnexus`, `setup-gitnexus-web`, and Vercel. ### Containerized development (optional) @@ -70,7 +75,7 @@ Commits within a PR may use any style — only the **merged PR title** shows up ## Before you open a PR - [ ] Tests pass for the packages you touched (`gitnexus` and/or `gitnexus-web`). -- [ ] Typecheck passes: `npx tsc --noEmit` in `gitnexus/` and `npx tsc -b --noEmit` in `gitnexus-web/`. +- [ ] Typecheck passes: `npx tsc --noEmit` in `gitnexus/` and `npx tsc -b --noEmit` in `gitnexus-web/`. Those commands use TypeScript 7. The web app tsconfig lists `lib` `DOM`/`DOM.Iterable` and `jsx: react-jsx` so React/JSX typecheck on 7; Vite/`vitest` keep `@vitejs/plugin-react` with the automatic JSX runtime. Build `gitnexus-shared/dist` first with a parent `lib/tsc.js` (a `gitnexus` install/`npm run build` does this). Repo ESLint stays syntax-only on a TypeScript 5.x peer until typescript-eslint supports 7. - [ ] No secrets, tokens, or machine-specific paths committed. - [ ] Documentation updated if behavior or public CLI/MCP contract changes. - [ ] Every new `GITNEXUS_*` environment variable has a row in the **Environment variables** table in [README.md](README.md) — variable, default, effect, and when to tune it. diff --git a/DoD.md b/DoD.md index a0f6eed08..ff2297903 100644 --- a/DoD.md +++ b/DoD.md @@ -113,7 +113,7 @@ Run the commands relevant to the touched area. If something cannot be run in the ### 4.1 Build ordering -- [ ] `gitnexus-shared/` dist is built before consuming packages are typechecked or tested (CI uses the `setup-gitnexus` action for this — local runs must match). +- [ ] `gitnexus-shared/` dist is built before consuming packages are typechecked or tested (CI uses the `setup-gitnexus` action, which compiles shared with the parent TypeScript 7 `lib/tsc.js` — local runs must match). ### 4.2 If `gitnexus/` changed @@ -123,13 +123,13 @@ Run the commands relevant to the touched area. If something cannot be run in the ### 4.3 If `gitnexus-web/` changed -- [ ] `cd gitnexus-web && npx tsc -b --noEmit` +- [ ] `cd gitnexus-web && npx tsc -b --noEmit` (TypeScript 7 typechecks React/JSX: `jsx: react-jsx`, `lib` includes `DOM`) - [ ] `cd gitnexus-web && npm test` - [ ] `cd gitnexus-web && npm run test:e2e` when browser flows or user-facing UI behavior changed ### 4.4 If `gitnexus-shared/` changed -- [ ] Shared package builds cleanly (`npm run build` in `gitnexus-shared/`) +- [ ] Shared package builds cleanly from a parent TypeScript 7 shim after that parent is installed (`cd gitnexus-shared && node ../gitnexus/node_modules/typescript/lib/tsc.js`, or `node ../gitnexus-web/node_modules/typescript/lib/tsc.js` after a web install). Do not `npm install` / `npm ci` in `gitnexus-shared/` for this check. - [ ] Dependent packages still typecheck and test after the shared change — verify both CLI and web consumers together ### 4.5 If CI workflows or release pipelines changed diff --git a/README.md b/README.md index 80044b999..ae6849672 100644 --- a/README.md +++ b/README.md @@ -902,9 +902,10 @@ The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAs ```bash git clone https://github.com/abhigyanpatwari/gitnexus.git -cd gitnexus/gitnexus-shared && npm install && npm run build -cd ../gitnexus-web && npm install -npm run dev +cd gitnexus/gitnexus-web && npm install +# Compile sibling gitnexus-shared with this package's TypeScript 7 (do not npm ci shared). +cd ../gitnexus-shared && node ../gitnexus-web/node_modules/typescript/lib/tsc.js +cd ../gitnexus-web && npm run dev # Then in another terminal, start the backend the frontend connects to: npx gitnexus@latest serve ``` diff --git a/TESTING.md b/TESTING.md index 72833d3ca..9febb8283 100644 --- a/TESTING.md +++ b/TESTING.md @@ -39,6 +39,8 @@ From `gitnexus-web/`: ### Before opening a PR ```bash +# gitnexus-shared/dist must exist first. `npm install` / `npm run build` in +# gitnexus/ compiles it via parent `lib/tsc.js` (do not npm ci gitnexus-shared). cd gitnexus && npx tsc --noEmit && npm test cd ../gitnexus-web && npx tsc -b --noEmit && npm test ``` diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 9bff38739..4ec41926d 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -62,7 +62,7 @@ "@vitest/coverage-v8": "^4.1.11", "jsdom": "^30.0.1", "tree-sitter-wasms": "^0.1.13", - "typescript": "^5.4.5", + "typescript": "^7.0.2", "vite": "^8.1.5", "vitest": "^4.1.10", "wait-on": "^9.1.0" @@ -2550,6 +2550,346 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -2659,6 +2999,20 @@ "undici-types": "~5.26.4" } }, + "node_modules/@vercel/node/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@vercel/node/node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -8038,17 +8392,38 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "devOptional": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/ufo": { diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 11fc68b1e..a10e13ffa 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -72,7 +72,7 @@ "@vitest/coverage-v8": "^4.1.11", "jsdom": "^30.0.1", "tree-sitter-wasms": "^0.1.13", - "typescript": "^5.4.5", + "typescript": "^7.0.2", "vite": "^8.1.5", "vitest": "^4.1.10", "wait-on": "^9.1.0" diff --git a/gitnexus-web/test/unit/tsconfig-react.test.ts b/gitnexus-web/test/unit/tsconfig-react.test.ts new file mode 100644 index 000000000..d1d805ad8 --- /dev/null +++ b/gitnexus-web/test/unit/tsconfig-react.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +describe('TypeScript 7 + React frontend toolchain', () => { + it('keeps Vite and Vitest on the automatic JSX runtime that matches react-jsx', () => { + for (const configFile of ['vite.config.ts', 'vitest.config.ts'] as const) { + const source = readFileSync(path.join(webRoot, configFile), 'utf8'); + expect(source).toContain('@vitejs/plugin-react'); + expect(source).toContain("jsxRuntime: 'automatic'"); + } + }); +}); diff --git a/gitnexus-web/tsconfig.app.json b/gitnexus-web/tsconfig.app.json index 2133377fd..34864f2a0 100644 --- a/gitnexus-web/tsconfig.app.json +++ b/gitnexus-web/tsconfig.app.json @@ -1,26 +1,29 @@ { "compilerOptions": { "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], "useDefineForClassFields": true, "module": "ESNext", "moduleResolution": "Bundler", + "moduleDetection": "force", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", + "jsxImportSource": "react", "strict": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, - "baseUrl": "./", + "noUncheckedSideEffectImports": true, + "rootDir": "./src", "paths": { - "@/*": ["./src/*"], - "@shared/*": ["../shared/*"] + "@/*": ["./src/*"] }, "types": ["vite/client"] }, - "include": ["src", "../shared"], + "include": ["src"], "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] } diff --git a/gitnexus-web/tsconfig.node.json b/gitnexus-web/tsconfig.node.json index 8b6c9f152..cc7fd0674 100644 --- a/gitnexus-web/tsconfig.node.json +++ b/gitnexus-web/tsconfig.node.json @@ -2,11 +2,15 @@ "compilerOptions": { "composite": true, "skipLibCheck": true, + "target": "ESNext", + "lib": ["ESNext"], "module": "ESNext", "moduleResolution": "Bundler", + "moduleDetection": "force", "allowSyntheticDefaultImports": true, "esModuleInterop": true, "noEmit": true, + "rootDir": ".", "types": ["node"] }, "include": ["vite.config.ts"] diff --git a/gitnexus-web/vercel.json b/gitnexus-web/vercel.json index 284b15735..3cd408211 100644 --- a/gitnexus-web/vercel.json +++ b/gitnexus-web/vercel.json @@ -1,3 +1,3 @@ { - "installCommand": "cd ../gitnexus-shared && npm install && npm run build && cd ../gitnexus-web && npm ci --include=dev" + "installCommand": "npm ci --include=dev && cd ../gitnexus-shared && node ../gitnexus-web/node_modules/typescript/lib/tsc.js" } diff --git a/gitnexus-web/vite.config.ts b/gitnexus-web/vite.config.ts index a62b9e586..cdd411b16 100644 --- a/gitnexus-web/vite.config.ts +++ b/gitnexus-web/vite.config.ts @@ -8,7 +8,7 @@ const _require = createRequire(import.meta.url); const gitnexusPkg = _require('../gitnexus/package.json'); export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [react({ jsxRuntime: 'automatic' }), tailwindcss()], define: { __REQUIRED_NODE_VERSION__: JSON.stringify(gitnexusPkg.engines.node.replace(/[>=^~\s]/g, '')), }, diff --git a/gitnexus-web/vitest.config.ts b/gitnexus-web/vitest.config.ts index 1de8ef065..2b7196b2e 100644 --- a/gitnexus-web/vitest.config.ts +++ b/gitnexus-web/vitest.config.ts @@ -7,7 +7,7 @@ const _require = createRequire(import.meta.url); const gitnexusPkg = _require('../gitnexus/package.json'); export default defineConfig({ - plugins: [react()], + plugins: [react({ jsxRuntime: 'automatic' })], define: { __REQUIRED_NODE_VERSION__: JSON.stringify(gitnexusPkg.engines.node.replace(/[>=^~\s]/g, '')), }, diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 15047dc21..794d63657 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -68,7 +68,7 @@ "@vitest/coverage-v8": "^4.0.18", "gitnexus-shared": "file:../gitnexus-shared", "tsx": "^4.0.0", - "typescript": "^5.4.5", + "typescript": "^7.0.2", "vitest": "^4.0.18" }, "engines": { @@ -1331,6 +1331,346 @@ "@types/node": "*" } }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@vitest/coverage-v8": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", @@ -4671,17 +5011,38 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/typical": { diff --git a/gitnexus/package.json b/gitnexus/package.json index 1e2bc42ad..c5ab6f3c6 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -129,7 +129,7 @@ "@vitest/coverage-v8": "^4.0.18", "gitnexus-shared": "file:../gitnexus-shared", "tsx": "^4.0.0", - "typescript": "^5.4.5", + "typescript": "^7.0.2", "vitest": "^4.0.18" }, "overrides": { diff --git a/gitnexus/scripts/build.js b/gitnexus/scripts/build.js index 6cead2784..b97f6e3b6 100644 --- a/gitnexus/scripts/build.js +++ b/gitnexus/scripts/build.js @@ -54,8 +54,8 @@ if (!fs.existsSync(SHARED_ROOT)) { // Launch tsc as `node typescript/lib/tsc.js` on every OS. The `.bin/tsc` / // `tsc.cmd` shims are Windows-only wrappers; `execFileSync` cannot spawn a -// `.cmd` without a shell, and a separate `npm ci` in gitnexus-shared pulls -// TypeScript 7 optional platform packages (7+ minutes in CI). +// `.cmd` without a shell, and a separate `npm ci` in gitnexus-shared would +// pull a second TypeScript 7 optional-platform install (7+ minutes in CI). const tscJs = path.join(ROOT, 'node_modules', 'typescript', 'lib', 'tsc.js'); if (!fs.existsSync(tscJs)) { console.error( diff --git a/gitnexus/test/helpers/ignore-set-source.ts b/gitnexus/test/helpers/ignore-set-source.ts index 04db55dd6..584faf839 100644 --- a/gitnexus/test/helpers/ignore-set-source.ts +++ b/gitnexus/test/helpers/ignore-set-source.ts @@ -4,18 +4,17 @@ * Those sets are module-private, and exporting them purely to be testable would * widen a production surface to satisfy a test — the call * `receiver-twin-list-drift.test.ts` documents. So the guards read the source - * instead, through the TypeScript parser the repo already vendors and already - * uses this way (`literal-collectors.ts`, `query-determinism-guard.test.ts`, - * `cli-index-help.test.ts`). + * instead, through `@babel/parser` (`parse-typescript-source.ts`). * - * Using the real parser is what makes the guards trustworthy. A text scanner has - * to decide whether a delimiter opens a comment or sits inside a string, and it - * gets that wrong in both directions here: the ignore-list comments quote paths - * and carry an apostrophe (`Next.js's`), while a glob string such as `'** / *'` - * contains a comment-open sequence. It also has to guess which bracket belongs - * to the declaration rather than to a type annotation. Each of those is a way to - * silently read fewer members — and a guard that quietly stops seeing members is - * the exact defect these guards exist to catch. + * Using an AST parser for TypeScript syntax is what makes the guards + * trustworthy. A text scanner has to decide whether a delimiter opens a comment + * or sits inside a string, and it gets that wrong in both directions here: the + * ignore-list comments quote paths and carry an apostrophe (`Next.js's`), while + * a glob string such as `'** / *'` contains a comment-open sequence. It also + * has to guess which bracket belongs to the declaration rather than to a type + * annotation. Each of those is a way to silently read fewer members — and a + * guard that quietly stops seeing members is the exact defect these guards + * exist to catch. * * `setEntries` therefore refuses anything that is not a plain list of string * literals, rather than skipping the members it cannot resolve. @@ -23,7 +22,8 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import ts from 'typescript'; +import * as t from '@babel/types'; +import { forEachChild, nodeText, parseTypeScript } from './parse-typescript-source.js'; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); @@ -55,46 +55,44 @@ export const readSource = (file: string): string => readFileSync(file, 'utf8'); * concatenation, a computed value). */ export const setEntries = (source: string, setName: string): string[] => { - const sourceFile = ts.createSourceFile( - 'ignore-set-source.ts', - source, - ts.ScriptTarget.Latest, - true, - ); + const { ast } = parseTypeScript('ignore-set-source.ts', source); - let elements: ts.NodeArray | undefined; - const visit = (node: ts.Node): void => { + let elements: t.ArrayExpression['elements'] | undefined; + const visit = (node: t.Node): void => { if ( elements === undefined && - ts.isVariableDeclaration(node) && - ts.isIdentifier(node.name) && - node.name.text === setName && - node.initializer !== undefined && - ts.isNewExpression(node.initializer) && - node.initializer.arguments?.length === 1 && - ts.isArrayLiteralExpression(node.initializer.arguments[0]) + t.isVariableDeclarator(node) && + t.isIdentifier(node.id) && + node.id.name === setName && + node.init !== undefined && + node.init !== null && + t.isNewExpression(node.init) && + node.init.arguments.length === 1 && + t.isArrayExpression(node.init.arguments[0]) ) { - elements = node.initializer.arguments[0].elements; + elements = node.init.arguments[0].elements; return; } - ts.forEachChild(node, visit); + forEachChild(node, visit); }; - visit(sourceFile); + visit(ast); if (elements === undefined) { throw new Error(`${setName} is not declared as \`new Set([...])\` — update this test`); } - const unresolvable = elements.filter((element) => !ts.isStringLiteral(element)); + const unresolvable = elements.filter((element) => !t.isStringLiteral(element)); if (unresolvable.length > 0) { + const first = unresolvable[0]; + const excerpt = first && typeof first === 'object' ? nodeText(source, first) : String(first); throw new Error( `${setName} holds ${unresolvable.length} member(s) that are not plain string literals ` + - `(first: \`${unresolvable[0].getText(sourceFile)}\`). A source-reading guard cannot resolve ` + + `(first: \`${excerpt}\`). A source-reading guard cannot resolve ` + `those, so switch this set to a runtime assertion rather than letting the guard see fewer members.`, ); } - return elements.map((element) => (element as ts.StringLiteral).text); + return elements.map((element) => (element as t.StringLiteral).value); }; /** diff --git a/gitnexus/test/helpers/literal-collectors.ts b/gitnexus/test/helpers/literal-collectors.ts index a6bb41b29..6172b739b 100644 --- a/gitnexus/test/helpers/literal-collectors.ts +++ b/gitnexus/test/helpers/literal-collectors.ts @@ -9,7 +9,7 @@ * FOUR modes (plan KTD3): * 1. Config reflection — import `*-extractors/configs/*.ts`, read each * config-shaped export's node-type-array keys. Exact `config.language`. - * 2. AST scan (`typescript` parser, no type-checker) over the EXTRACTION + * 2. AST scan (TypeScript 7 program SourceFile; no Checker gating) over the EXTRACTION * surface — `*-extractors/**`, every `languages//captures.ts`, and * `export-detection.ts`. Collected BY CONSUMPTION SITE: `.type === '..'`, * `childForFieldName('..')` (capturing the receiver node type when an @@ -26,7 +26,7 @@ * `ingestion/` (e.g. `type-env.ts`). These files MIX SyntaxNode `.type` with * resolved-symbol `.type` (kinds like 'Class'), so a literal is collected * ONLY when its `.type` / `childForFieldName` receiver resolves to a - * tree-sitter SyntaxNode (via the TS TypeChecker). Per-`languages//` + * tree-sitter SyntaxNode (via the TypeScript 7 Checker). Per-`languages//` * files tag to that one grammar; shared (non-`languages//`) files tag * to the full gated set (valid-if-any). * @@ -35,8 +35,8 @@ * * Test-only file: allowed to name languages. */ -import ts from 'typescript'; -import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'; +import * as ts from './ts7-ast.js'; +import { readdirSync, existsSync, statSync, realpathSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { SupportedLanguages } from '../../src/config/supported-languages.js'; @@ -449,11 +449,16 @@ function receiverNodeTypeOf(call: ts.CallExpression, sf: ts.SourceFile): string return receiverMutatedIn(recvText, scope) ? undefined : found; } -function scanFile(file: string): ScanResult { +function scanFile( + file: string, + built: { program: ts.Program; checker: ts.Checker } | null, +): ScanResult { const relPath = rel(file); const langs = fileLanguages(relPath); - const src = readFileSync(file, 'utf8'); - const sf = ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true); + const empty: ScanResult = { nodeTypes: [], fields: [] }; + if (!built) return empty; + const sf = programSourceFile(built.program, file); + if (!sf) return empty; const nodeTypes: CollectedNodeType[] = []; const fields: CollectedField[] = []; @@ -558,8 +563,9 @@ function scanFile(file: string): ScanResult { function collectInCodeLiterals(): ScanResult { const nodeTypes: CollectedNodeType[] = []; const fields: CollectedField[] = []; + const built = buildProgram(); for (const file of mode2Files()) { - const r = scanFile(file); + const r = scanFile(file, built); nodeTypes.push(...r.nodeTypes); fields.push(...r.fields); } @@ -571,7 +577,7 @@ function collectInCodeLiterals(): ScanResult { // binding/interpret/arity/import-decomposer/...), the production path for // migrated languages. These files mix SyntaxNode `.type` (grammar nodes) with // resolved-symbol `.type` (kinds like 'Class'); a naive scan would false- -// positive on the latter. So this mode uses the TS TypeChecker to collect a +// positive on the latter. So this mode uses the TypeScript 7 Checker to collect a // literal ONLY when its `.type` receiver / childForFieldName target resolves to // a tree-sitter SyntaxNode. Per-language dir => grammar (no cross-lang ambiguity). // --------------------------------------------------------------------------- @@ -613,25 +619,35 @@ function resolutionLayerFiles(): { file: string; langs: SupportedLanguages[] }[] } let _program: ts.Program | null = null; -let _checker: ts.TypeChecker | null = null; -function buildProgram( - rootFiles: string[], -): { program: ts.Program; checker: ts.TypeChecker } | null { +let _checker: ts.Checker | null = null; +function buildProgram(): { program: ts.Program; checker: ts.Checker } | null { if (_program && _checker) return { program: _program, checker: _checker }; try { - const cfg = ts.readConfigFile(join(REPO_ROOT, 'tsconfig.json'), ts.sys.readFile); - const parsed = ts.parseJsonConfigFileContent(cfg.config ?? {}, ts.sys, REPO_ROOT); - const options: ts.CompilerOptions = { ...parsed.options, noEmit: true, skipLibCheck: true }; - _program = ts.createProgram(rootFiles, options); - _checker = _program.getTypeChecker(); + const api = new ts.API({ cwd: REPO_ROOT }); + const snapshot = api.updateSnapshot({ + openProjects: [join(REPO_ROOT, 'tsconfig.json')], + }); + const project = snapshot.getProjects()[0]; + if (!project) return null; + _program = project.program; + _checker = project.checker; return { program: _program, checker: _checker }; } catch { return null; } } +const sourceFileByPath = new Map(); + +function programSourceFile(program: ts.Program, file: string): ts.SourceFile | undefined { + if (sourceFileByPath.has(file)) return sourceFileByPath.get(file); + const sf = program.getSourceFile(file) ?? program.getSourceFile(realpathSync(file)); + sourceFileByPath.set(file, sf); + return sf; +} + /** True when `node`'s resolved type is (or includes) a tree-sitter SyntaxNode. */ -function isSyntaxNodeReceiver(checker: ts.TypeChecker, node: ts.Node): boolean { +function isSyntaxNodeReceiver(checker: ts.Checker, node: ts.Node): boolean { try { const s = checker.typeToString(checker.getTypeAtLocation(node)); return /\bSyntaxNode\b/.test(s); @@ -647,7 +663,7 @@ function collectResolutionLayerLiterals(): ScanResult { const nodeTypes: CollectedNodeType[] = []; const fields: CollectedField[] = []; const entries = resolutionLayerFiles(); - const built = buildProgram(entries.map((e) => e.file)); + const built = buildProgram(); if (!built) { resolutionLayerProgramOk = false; return { nodeTypes, fields }; @@ -655,7 +671,7 @@ function collectResolutionLayerLiterals(): ScanResult { const { program, checker } = built; for (const { file, langs } of entries) { - const sf = program.getSourceFile(file); + const sf = programSourceFile(program, file); if (!sf) continue; const relPath = rel(file); const constMembers = new Map(); diff --git a/gitnexus/test/helpers/parse-typescript-source.ts b/gitnexus/test/helpers/parse-typescript-source.ts new file mode 100644 index 000000000..d324d84d8 --- /dev/null +++ b/gitnexus/test/helpers/parse-typescript-source.ts @@ -0,0 +1,162 @@ +/** + * Parse-only TypeScript AST walks for tests. + * + * TypeScript 7.0 does not ship the classic Compiler API (`createSourceFile`). + * These guards only need syntax, so they parse with `@babel/parser` (already a + * CLI dependency) rather than spawning the native TypeScript 7 program API. + */ +import { parse } from '@babel/parser'; +import { + VISITOR_KEYS, + isBinaryExpression, + isNode, + isStringLiteral, + isTemplateLiteral, + type Comment, + type File, + type MemberExpression, + type Node, + type OptionalMemberExpression, + type TemplateLiteral, +} from '@babel/types'; + +export type AstNode = Node & { parent?: AstNode }; + +export interface ParsedSource { + ast: File & { parent?: AstNode }; + source: string; + fileName: string; +} + +const PARSE_PLUGINS: NonNullable[1]>['plugins'] = [ + 'typescript', + 'explicitResourceManagement', + 'importAttributes', + 'decoratorAutoAccessors', + ['decorators', { decoratorsBeforeExport: true }], +]; + +export function forEachChild(node: Node, visit: (child: AstNode) => void): void { + const keys = VISITOR_KEYS[node.type] ?? []; + for (const key of keys) { + const value = (node as unknown as Record)[key]; + if (Array.isArray(value)) { + for (const item of value) { + if (isNode(item)) visit(item); + } + } else if (isNode(value)) { + visit(value); + } + } +} + +/** Direct descendants in source order (does not include `node` itself). */ +export function collectDescendants(node: Node): AstNode[] { + const out: AstNode[] = []; + const visit = (child: AstNode): void => { + out.push(child); + forEachChild(child, visit); + }; + forEachChild(node, visit); + return out; +} + +export function staticMemberName( + node: MemberExpression | OptionalMemberExpression, +): string | undefined { + return node.computed || node.property.type !== 'Identifier' ? undefined : node.property.name; +} + +function templateLiteralText(node: TemplateLiteral): string | undefined { + if (node.expressions.length > 0) return undefined; + return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join(''); +} + +/** Compile-time string from a literal, template without holes, or `+` chain. */ +export function staticStringValue(node: Node | undefined | null): string | undefined { + if (!node) return undefined; + if (isStringLiteral(node)) return node.value; + if (isTemplateLiteral(node)) return templateLiteralText(node); + if (isBinaryExpression(node) && node.operator === '+') { + const left = staticStringValue(node.left); + const right = staticStringValue(node.right); + if (left !== undefined && right !== undefined) return `${left}${right}`; + } + return undefined; +} + +function attachParents(node: AstNode, parent?: AstNode): void { + node.parent = parent; + forEachChild(node, (child) => attachParents(child, node)); +} + +export function parseTypeScript(fileName: string, source: string): ParsedSource { + const ast = parse(source, { + sourceFilename: fileName, + sourceType: 'unambiguous', + plugins: PARSE_PLUGINS, + errorRecovery: true, + attachComment: true, + ranges: true, + }) as File & { parent?: AstNode }; + attachParents(ast); + return { ast, source, fileName }; +} + +export function nodeStart(node: Node): number { + return node.start ?? 0; +} + +export function nodeEnd(node: Node): number { + return node.end ?? 0; +} + +export function nodeText(source: string, node: Node): string { + return source.slice(nodeStart(node), nodeEnd(node)); +} + +let lineAtSource = ''; +let lineAtStarts: number[] = [0]; + +function lineStarts(source: string): number[] { + if (source === lineAtSource) return lineAtStarts; + const starts = [0]; + for (let i = 0; i < source.length; i++) { + if (source[i] === '\n') starts.push(i + 1); + } + lineAtSource = source; + lineAtStarts = starts; + return starts; +} + +export function lineAt(source: string, position: number): number { + if (position <= 0) return 1; + const starts = lineStarts(source); + const pos = Math.min(position, source.length); + let lo = 0; + let hi = starts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (starts[mid] <= pos) lo = mid; + else hi = mid - 1; + } + return lo + 1; +} + +export interface CommentRange { + pos: number; + end: number; +} + +function toRange(comment: Comment): CommentRange | undefined { + if (comment.start == null || comment.end == null) return undefined; + return { pos: comment.start, end: comment.end }; +} + +export function leadingCommentRanges(node: Node): CommentRange[] { + return (node.leadingComments ?? []).map(toRange).filter((range) => range !== undefined); +} + +export function trailingCommentRanges(node: Node): CommentRange[] { + return (node.trailingComments ?? []).map(toRange).filter((range) => range !== undefined); +} diff --git a/gitnexus/test/helpers/ts7-ast.ts b/gitnexus/test/helpers/ts7-ast.ts new file mode 100644 index 000000000..c2d077042 --- /dev/null +++ b/gitnexus/test/helpers/ts7-ast.ts @@ -0,0 +1,48 @@ +/** + * TypeScript 7 AST + Checker surface for test-only walks that need types. + * + * TypeScript 7.0 dropped the classic Compiler API (`createProgram` / + * `getTypeChecker`). The native `typescript/unstable/sync` client exposes the + * same Checker methods against the Go compiler, and `typescript/unstable/ast` + * is the matching syntax tree. + */ +export { SyntaxKind } from 'typescript/unstable/ast'; +export type { + CallExpression, + Expression, + Node, + PropertyAccessExpression, + SourceFile, + StringLiteral, +} from 'typescript/unstable/ast'; +export { + isArrayLiteralExpression, + isArrowFunction, + isAsExpression, + isBinaryExpression, + isCallExpression, + isCaseClause, + isConstructorDeclaration, + isFunctionDeclaration, + isFunctionExpression, + isGetAccessorDeclaration, + isIdentifier, + isIfStatement, + isMethodDeclaration, + isNewExpression, + isPostfixUnaryExpression, + isPrefixUnaryExpression, + isPropertyAccessExpression, + isSetAccessorDeclaration, + isStringLiteral, + isStringLiteralLikeNode as isStringLiteralLike, + isSwitchStatement, + isVariableDeclaration, +} from 'typescript/unstable/ast/is'; +export { API, type Checker, type Program } from 'typescript/unstable/sync'; + +import type { Node } from 'typescript/unstable/ast'; + +export function forEachChild(node: Node, visitor: (child: Node) => void): void { + node.forEachChild(visitor); +} diff --git a/gitnexus/test/unit/build-web-optin.test.ts b/gitnexus/test/unit/build-web-optin.test.ts index 68e8cd464..efb07190e 100644 --- a/gitnexus/test/unit/build-web-optin.test.ts +++ b/gitnexus/test/unit/build-web-optin.test.ts @@ -8,6 +8,8 @@ import { runWebBuild, shouldBuildWeb, shouldPreserveWebOutput } from '../../scri /** Default prepare/build stay CLI-only; the web UI ships only via prepack --web. */ const REPO_ROOT = path.resolve(__dirname, '../../..'); +const CLI_TSC_JS = 'node ../gitnexus/node_modules/typescript/lib/tsc.js'; +const WEB_TSC_JS = 'node ../gitnexus-web/node_modules/typescript/lib/tsc.js'; const PACKAGE_JSON = JSON.parse( readFileSync(path.join(REPO_ROOT, 'gitnexus/package.json'), 'utf8'), ) as { scripts?: Record }; @@ -283,7 +285,7 @@ describe('workflows that need the web UI install it themselves', () => { describe('setup-gitnexus job budget', () => { it('does not npm-ci gitnexus-shared (TypeScript 7 optional-platform install stalls CI)', () => { const shared = setupGitnexus.runs?.steps?.find((step) => step.name === 'Build gitnexus-shared'); - expect(String(shared?.run)).toBe('node ../gitnexus/node_modules/typescript/lib/tsc.js'); + expect(String(shared?.run)).toBe(CLI_TSC_JS); expect(String(shared?.run)).not.toContain('.bin'); expect(shared?.if).toContain("lifecycle-scripts == 'false'"); expect( @@ -314,12 +316,28 @@ describe('setup-gitnexus job budget', () => { expect(String(setupNode?.with?.['cache-dependency-path'])).toBe( 'gitnexus-web/package-lock.json', ); - expect(String(shared?.run)).toBe('node ../gitnexus-web/node_modules/typescript/lib/tsc.js'); + expect(String(shared?.run)).toBe(WEB_TSC_JS); expect(String(shared?.run)).not.toContain('.bin'); expect(String(shared?.run)).not.toContain('npm ci'); expect(webInstall?.env?.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD).toBe('1'); }); + it('Vercel installs web first and compiles shared with the web TypeScript', () => { + const vercel = JSON.parse( + readFileSync(path.join(REPO_ROOT, 'gitnexus-web/vercel.json'), 'utf8'), + ) as { installCommand?: string }; + const install = String(vercel.installCommand); + // Vercel runs installCommand with NODE_ENV=production, so npm ci drops + // typescript unless --include=dev is on that install (not a later step). + expect(install).toContain('npm ci --include=dev'); + expect(install).toContain('gitnexus-shared'); + expect(install.indexOf('npm ci --include=dev')).toBeLessThan( + install.indexOf('gitnexus-shared'), + ); + expect(install).toContain(WEB_TSC_JS); + expect(install).not.toMatch(/gitnexus-shared[^&]*npm (?:ci|install)/); + }); + it('quality typecheck skips prepare/postinstall so tsc --noEmit fits in 10 minutes', () => { const job = qualityJobs.typecheck; const setup = job?.steps?.find((step) => step.uses === './.github/actions/setup-gitnexus'); @@ -327,6 +345,28 @@ describe('setup-gitnexus job budget', () => { expect(setup?.with?.['lifecycle-scripts']).toBe('false'); }); + it('web app tsconfig typechecks React JSX on TypeScript 7 without baseUrl', () => { + const tsconfig = JSON.parse( + readFileSync(path.join(REPO_ROOT, 'gitnexus-web/tsconfig.app.json'), 'utf8'), + ) as { + compilerOptions?: { + baseUrl?: string; + jsx?: string; + jsxImportSource?: string; + lib?: string[]; + rootDir?: string; + types?: string[]; + }; + }; + const options = tsconfig.compilerOptions ?? {}; + expect(options.baseUrl).toBeUndefined(); + expect(options.jsx).toBe('react-jsx'); + expect(options.jsxImportSource).toBe('react'); + expect(options.lib).toEqual(expect.arrayContaining(['ESNext', 'DOM', 'DOM.Iterable'])); + expect(options.rootDir).toBe('./src'); + expect(options.types).toEqual(['vite/client']); + }); + it('quality typecheck-web can finish a cold web install instead of canceling before cache save', () => { expect(qualityJobs['typecheck-web']?.['timeout-minutes']).toBe(15); }); diff --git a/gitnexus/test/unit/cli-index-help.test.ts b/gitnexus/test/unit/cli-index-help.test.ts index 417ecb53d..9229f5444 100644 --- a/gitnexus/test/unit/cli-index-help.test.ts +++ b/gitnexus/test/unit/cli-index-help.test.ts @@ -4,8 +4,14 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { Command, Option } from 'commander'; -import * as ts from 'typescript'; +import * as t from '@babel/types'; import { afterEach, describe, expect, it } from 'vitest'; +import { + forEachChild, + parseTypeScript, + staticMemberName, + staticStringValue, +} from '../helpers/parse-typescript-source.js'; import { CLI_SPAWN_PREFIX } from '../helpers/cli-entry.js'; import { localizeCliHelp } from '../../src/cli/help-i18n.js'; import { setCliLanguage, type SupportedCliLanguage } from '../../src/cli/i18n/index.js'; @@ -69,17 +75,6 @@ const allHelpCommands = [ ['group', 'contracts'], ]; -function staticStringValue(node: ts.Node | undefined): string | undefined { - if (!node) return undefined; - if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; - if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) { - const left = staticStringValue(node.left); - const right = staticStringValue(node.right); - if (left !== undefined && right !== undefined) return `${left}${right}`; - } - return undefined; -} - function extractRegisteredHelpDescriptions(): string[] { const descriptions = new Set(); const sourceFiles = ['src/cli/index.ts', 'src/cli/group.ts']; @@ -87,27 +82,30 @@ function extractRegisteredHelpDescriptions(): string[] { for (const relativePath of sourceFiles) { const filePath = path.join(repoRoot, relativePath); const source = fs.readFileSync(filePath, 'utf8'); - const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true); + const { ast } = parseTypeScript(filePath, source); - function visit(node: ts.Node): void { - if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { - const method = node.expression.name.text; - const description = - method === 'description' - ? staticStringValue(node.arguments[0]) - : method === 'option' || method === 'requiredOption' - ? staticStringValue(node.arguments[1]) - : undefined; + function visit(node: t.Node): void { + if ( + t.isCallExpression(node) && + (t.isMemberExpression(node.callee) || t.isOptionalMemberExpression(node.callee)) + ) { + const method = staticMemberName(node.callee); + let description: string | undefined; + if (method === 'description') { + description = staticStringValue(node.arguments[0]); + } else if (method === 'option' || method === 'requiredOption') { + description = staticStringValue(node.arguments[1]); + } if (description && /[A-Za-z]/.test(description)) { descriptions.add(description.replace(/\s+/g, ' ').trim()); } } - ts.forEachChild(node, visit); + forEachChild(node, visit); } - visit(sourceFile); + visit(ast); } return [...descriptions].filter((description) => description.length > 0).sort(); diff --git a/gitnexus/test/unit/group/sync-partial-extraction.test.ts b/gitnexus/test/unit/group/sync-partial-extraction.test.ts index 0e7d41228..e1588be1c 100644 --- a/gitnexus/test/unit/group/sync-partial-extraction.test.ts +++ b/gitnexus/test/unit/group/sync-partial-extraction.test.ts @@ -4,7 +4,16 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { fileURLToPath } from 'node:url'; -import ts from 'typescript'; +import * as t from '@babel/types'; +import { + type AstNode, + collectDescendants, + lineAt, + nodeStart, + nodeText, + parseTypeScript, + staticMemberName, +} from '../../helpers/parse-typescript-source.js'; import type { ContractRegistry, ExtractedContract, @@ -182,65 +191,63 @@ describe('syncGroup when one extractor fails partway through a repo', () => { */ const SYNC_SOURCE_PATH = fileURLToPath(new URL('../../../src/core/group/sync.ts', import.meta.url)); -/** Every node under `node`, in source order. No branching, so nothing is skippable. */ -function descendants(node: ts.Node): ts.Node[] { - const out: ts.Node[] = []; - const visit = (n: ts.Node): void => { - out.push(n); - n.forEachChild(visit); - }; - node.forEachChild(visit); - return out; -} - /** `const : StoredContract[] = []` — the per-repo staging buffer. */ -function isStagingBufferDeclaration(node: ts.Node): node is ts.VariableDeclaration { +function isStagingBufferDeclaration(node: t.Node): node is t.VariableDeclarator { + if (!t.isVariableDeclarator(node) || !t.isIdentifier(node.id)) return false; + const annotation = node.id.typeAnnotation; + if ( + !annotation || + !t.isTSTypeAnnotation(annotation) || + !t.isTSArrayType(annotation.typeAnnotation) + ) { + return false; + } + const elementType = annotation.typeAnnotation.elementType; return ( - ts.isVariableDeclaration(node) && - node.type !== undefined && - ts.isArrayTypeNode(node.type) && - ts.isTypeReferenceNode(node.type.elementType) && - ts.isIdentifier(node.type.elementType.typeName) && - node.type.elementType.typeName.text === 'StoredContract' && - node.initializer !== undefined && - ts.isArrayLiteralExpression(node.initializer) && - node.initializer.elements.length === 0 && - ts.isVariableDeclarationList(node.parent) && - (node.parent.flags & ts.NodeFlags.Const) !== 0 + t.isTSTypeReference(elementType) && + t.isIdentifier(elementType.typeName) && + elementType.typeName.name === 'StoredContract' && + node.init !== undefined && + node.init !== null && + t.isArrayExpression(node.init) && + node.init.elements.length === 0 && + t.isVariableDeclaration((node as AstNode).parent) && + ((node as AstNode).parent as t.VariableDeclaration).kind === 'const' ); } /** `x.apply(dest, args)` — an argument-limited append in non-spread clothing. */ -function isApplyCall(call: ts.CallExpression): boolean { - return ts.isPropertyAccessExpression(call.expression) && call.expression.name.text === 'apply'; +function isApplyCall(call: t.CallExpression): boolean { + return ( + (t.isMemberExpression(call.callee) || t.isOptionalMemberExpression(call.callee)) && + staticMemberName(call.callee) === 'apply' + ); } -function describeCall(sourceFile: ts.SourceFile, call: ts.CallExpression): string { - const { line } = sourceFile.getLineAndCharacterOfPosition(call.getStart(sourceFile)); - return `${line + 1}: ${call.getText(sourceFile).replace(/\s+/g, ' ')}`; +function describeCall(source: string, call: t.CallExpression): string { + const line = lineAt(source, nodeStart(call)); + return `${line}: ${nodeText(source, call).replace(/\s+/g, ' ')}`; } describe('the per-repo staging append in sync.ts', () => { it('appends the staged contracts without spreading them into a call', () => { const source = fs.readFileSync(SYNC_SOURCE_PATH, 'utf-8'); - const sourceFile = ts.createSourceFile( - SYNC_SOURCE_PATH, - source, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS, - ); + const { ast } = parseTypeScript(SYNC_SOURCE_PATH, source); - const allNodes = descendants(sourceFile); + const allNodes = collectDescendants(ast); const stagingBuffers = allNodes.filter(isStagingBufferDeclaration); // One staging buffer, or this gate no longer knows which code it guards. - expect(stagingBuffers.map((d) => d.name.getText(sourceFile))).toHaveLength(1); - const stagingNames = stagingBuffers.map((d) => d.name.getText(sourceFile)); + const stagingNames = stagingBuffers.map((d) => + t.isIdentifier(d.id) ? d.id.name : nodeText(source, d.id), + ); + expect(stagingNames).toHaveLength(1); // The block the buffer is declared in — the per-repo loop body. + // VariableDeclarator → VariableDeclaration → BlockStatement (Babel has no + // extra VariableStatement wrapper). const declaringBlocks = stagingBuffers - .map((d) => d.parent.parent.parent) // declaration → list → statement → block - .filter(ts.isBlock); + .map((d) => (d as AstNode).parent?.parent) + .filter((node): node is t.BlockStatement => t.isBlockStatement(node)); expect(declaringBlocks).toHaveLength(1); // The extractor try-block: a DIRECT statement of that block whose `try` reads @@ -249,22 +256,22 @@ describe('the per-repo staging append in sync.ts', () => { // ancestor reads the buffer too. Widening to "any try that mentions it" pulls // in the entire function body, manifest-window spreads and all. const extractorTryBlocks = declaringBlocks.flatMap((block) => - block.statements - .filter(ts.isTryStatement) + block.body + .filter((statement): statement is t.TryStatement => t.isTryStatement(statement)) .filter((statement) => - descendants(statement.tryBlock).some( - (n) => ts.isIdentifier(n) && stagingNames.includes(n.text), + collectDescendants(statement.block).some( + (n) => t.isIdentifier(n) && stagingNames.includes(n.name), ), ) - .map((statement) => statement.tryBlock), + .map((statement) => statement.block), ); expect(extractorTryBlocks).toHaveLength(1); const unboundedAppends = extractorTryBlocks.flatMap((block) => - descendants(block) - .filter(ts.isCallExpression) - .filter((call) => call.arguments.some(ts.isSpreadElement) || isApplyCall(call)) - .map((call) => describeCall(sourceFile, call)), + collectDescendants(block) + .filter((node): node is t.CallExpression => t.isCallExpression(node)) + .filter((call) => call.arguments.some((arg) => t.isSpreadElement(arg)) || isApplyCall(call)) + .map((call) => describeCall(source, call)), ); // Every staged contract must reach `autoContracts` through a bounded loop: diff --git a/gitnexus/test/unit/query-determinism-guard.test.ts b/gitnexus/test/unit/query-determinism-guard.test.ts index e6f7acd72..ebad4efb9 100644 --- a/gitnexus/test/unit/query-determinism-guard.test.ts +++ b/gitnexus/test/unit/query-determinism-guard.test.ts @@ -47,10 +47,21 @@ * and `detect-changes-local-id-stability.test.ts`. */ import { describe, expect, it } from 'vitest'; -import ts from 'typescript'; +import * as t from '@babel/types'; import { readdirSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + type AstNode, + type CommentRange, + forEachChild, + leadingCommentRanges, + lineAt, + nodeStart, + nodeText, + parseTypeScript, + trailingCommentRanges, +} from '../helpers/parse-typescript-source.js'; const SRC_DIR = fileURLToPath(new URL('../../src/', import.meta.url)); @@ -135,18 +146,18 @@ function walkTs(dir: string, relBase: string, out: string[]): string[] { * helpers (`seedBlockQuery`, `buildFtsQueryCypher`), and anything built from * those. Resolved to a fixpoint so an ordering constant can be composed. */ -function collectOrderingNames(sf: ts.SourceFile): Set { +function collectOrderingNames(source: string, ast: t.Node): Set { const initializers = new Map(); - const visit = (node: ts.Node): void => { - if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) { - initializers.set(node.name.text, node.initializer.getText(sf)); + const visit = (node: t.Node): void => { + if (t.isVariableDeclarator(node) && t.isIdentifier(node.id) && node.init) { + initializers.set(node.id.name, nodeText(source, node.init)); } - if (ts.isFunctionDeclaration(node) && node.name !== undefined) { - initializers.set(node.name.text, node.getText(sf)); + if (t.isFunctionDeclaration(node) && t.isIdentifier(node.id)) { + initializers.set(node.id.name, nodeText(source, node)); } - ts.forEachChild(node, visit); + forEachChild(node, visit); }; - visit(sf); + visit(ast); const ordering = new Set(); const referencesOrdering = (text: string): boolean => @@ -166,38 +177,38 @@ function collectOrderingNames(sf: ts.SourceFile): Set { // --------------------------------------------------------------------------- // Query units // --------------------------------------------------------------------------- -const isLiteralUnit = (node: ts.Node): boolean => - ts.isStringLiteralLike(node) || ts.isTemplateExpression(node); +const isLiteralUnit = (node: t.Node): boolean => + t.isStringLiteral(node) || t.isTemplateLiteral(node); /** Climb `+` concatenation so a query split across fragments is ONE unit. */ -function concatenationRoot(node: ts.Node): ts.Node { - let current = node; +function concatenationRoot(node: AstNode): AstNode { + let current: AstNode = node; while ( current.parent !== undefined && - ts.isBinaryExpression(current.parent) && - current.parent.operatorToken.kind === ts.SyntaxKind.PlusToken + t.isBinaryExpression(current.parent) && + current.parent.operator === '+' ) { current = current.parent; } return current; } -function collectQueryUnits(sf: ts.SourceFile): ts.Node[] { - const byPosition = new Map(); - const visit = (node: ts.Node): void => { +function collectQueryUnits(source: string, ast: t.Node): AstNode[] { + const byPosition = new Map(); + const visit = (node: AstNode): void => { if (!isLiteralUnit(node)) { - ts.forEachChild(node, visit); + forEachChild(node, visit); return; } // Never descend INTO a literal: a nested template inside `${...}` is // already covered by the outer unit's text. const unit = concatenationRoot(node); - const text = unit.getText(sf); + const text = nodeText(source, unit); if (LIMIT_RE.test(text) && (CYPHER_CLAUSE_RE.test(text) || BARE_LIMIT_FRAGMENT_RE.test(text))) { - byPosition.set(unit.getStart(sf), unit); + byPosition.set(nodeStart(unit), unit); } }; - visit(sf); + visit(ast as AstNode); return [...byPosition.entries()].sort((a, b) => a[0] - b[0]).map(([, node]) => node); } @@ -216,14 +227,14 @@ interface CommentBlock { end: number; } -const isFunctionLike = (node: ts.Node): boolean => - ts.isFunctionDeclaration(node) || - ts.isFunctionExpression(node) || - ts.isArrowFunction(node) || - ts.isMethodDeclaration(node) || - ts.isConstructorDeclaration(node) || - ts.isGetAccessorDeclaration(node) || - ts.isSetAccessorDeclaration(node); +const isFunctionLike = (node: t.Node): boolean => + t.isFunctionDeclaration(node) || + t.isFunctionExpression(node) || + t.isArrowFunctionExpression(node) || + t.isClassMethod(node) || + t.isClassPrivateMethod(node) || + t.isObjectMethod(node) || + t.isTSDeclareMethod(node); /** * Comment blocks a marker for `node` may live in, NEAREST FIRST: the query's @@ -239,22 +250,21 @@ const isFunctionLike = (node: ts.Node): boolean => * Consecutive `//` lines arrive as one range EACH, so they are merged back into * a block: a reason may then wrap across lines and still fit `printWidth: 100`. */ -function markerBlocks(source: string, node: ts.Node): CommentBlock[] { - const nearestFirst: ts.CommentRange[] = []; +function markerBlocks(source: string, node: AstNode): CommentBlock[] { + const nearestFirst: CommentRange[] = []; const seen = new Set(); - let current: ts.Node | undefined = node; + let current: AstNode | undefined = node; for (let depth = 0; current !== undefined && depth < 20; depth++) { - for (const range of [ - ...(ts.getLeadingCommentRanges(source, current.getFullStart()) ?? []), - ...(ts.getTrailingCommentRanges(source, current.getEnd()) ?? []), - ]) { + for (const range of [...leadingCommentRanges(current), ...trailingCommentRanges(current)]) { if (!seen.has(range.pos)) { seen.add(range.pos); nearestFirst.push(range); } } - const parent: ts.Node | undefined = current.parent; - if (parent === undefined || isFunctionLike(parent) || ts.isSourceFile(parent)) break; + const parent: AstNode | undefined = current.parent; + if (parent === undefined || isFunctionLike(parent) || t.isFile(parent) || t.isProgram(parent)) { + break; + } current = parent; } @@ -318,15 +328,15 @@ const reasonIsSubstantive = (reason: string): boolean => // --------------------------------------------------------------------------- export function analyzeSource(file: string, source: string): QueryFinding[] { if (!LIMIT_RE.test(source)) return []; - const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true); - const ordering = collectOrderingNames(sf); + const { ast } = parseTypeScript(file, source); + const ordering = collectOrderingNames(source, ast); /** A marker declares ONE query: a claimed range cannot also cover the next. */ const claimed = new Map(); - return collectQueryUnits(sf).map((unit) => { - const text = unit.getText(sf); + return collectQueryUnits(source, ast).map((unit) => { + const text = nodeText(source, unit); const limitOffset = Math.max(text.search(LIMIT_RE), 0); - const line = sf.getLineAndCharacterOfPosition(unit.getStart(sf) + limitOffset).line + 1; + const line = lineAt(source, nodeStart(unit) + limitOffset); const excerpt = text.replace(/\s+/g, ' ').slice(0, 160); const base = { file, line, excerpt }; diff --git a/gitnexus/test/unit/skill-evolution-workflow.test.ts b/gitnexus/test/unit/skill-evolution-workflow.test.ts index 6916c7028..0b46e2736 100644 --- a/gitnexus/test/unit/skill-evolution-workflow.test.ts +++ b/gitnexus/test/unit/skill-evolution-workflow.test.ts @@ -39,6 +39,7 @@ const workflowDocument = load(workflow) as { 'timeout-minutes'?: unknown; with?: Record; env?: Record; + 'working-directory'?: string; }>; } >; @@ -476,16 +477,26 @@ exit 1`); expect(containment).toContain('gitnexus-cursor-integration/skills/gitnexus-review/*'); }); - it('installs node_modules for the monorepo root, gitnexus-shared, and gitnexus', () => { - // The benchmark sandbox-copies node_modules from all three (tasks.scenarios.yaml). - // The root tree was absent on the first real run because only the two subpackage - // steps ran, so capture_task_dependency_binding aborted at task binding. + it('installs node_modules for the monorepo root and gitnexus, and compiles shared from the parent', () => { + // The benchmark sandbox-copies node_modules from root and gitnexus + // (tasks.scenarios.yaml). Shared is compiled by gitnexus `npm run build` + // (scripts/build.js); a dedicated npm ci in gitnexus-shared stalls CI. const rootStep = findStep('Install monorepo root dependencies'); expect(rootStep).toBeDefined(); expect(rootStep).not.toHaveProperty('working-directory'); // installs at the repo root expect(String(rootStep?.run)).toContain('npm ci'); - expect(stepRun('Build pinned shared runtime')).toContain('npm ci'); - expect(stepRun('Install and build pinned GitNexus runtime')).toContain('npm ci'); + expect(findStep('Build pinned shared runtime')).toBeUndefined(); + const gitnexusRun = stepRun('Install and build pinned GitNexus runtime'); + expect(gitnexusRun).toContain('npm ci'); + expect(gitnexusRun).toContain('npm run build'); + expect(gitnexusRun).toContain('mkdir -p ../gitnexus-shared/node_modules'); + expect( + evolveJob?.steps?.some( + (step) => + step['working-directory'] === 'gitnexus-shared' && + String(step.run ?? '').includes('npm ci'), + ), + ).toBe(false); }); it('waits for the runner boot-time package lock before installing containment tools', () => { diff --git a/package-lock.json b/package-lock.json index f3d31e09a..f34ffa491 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "husky": "^9.1.7", "lint-staged": "^15.5.0", "prettier": "^3.8.0", - "prettier-plugin-tailwindcss": "^0.7.0" + "prettier-plugin-tailwindcss": "^0.7.0", + "typescript": "^5.9.3" } }, "node_modules/@babel/code-frame": { @@ -626,6 +627,189 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/@typescript-eslint/parser": { "version": "8.57.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", @@ -651,7 +835,35 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/project-service": { + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { "version": "8.57.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", @@ -673,6 +885,23 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/@typescript-eslint/scope-manager": { "version": "8.57.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", @@ -691,48 +920,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", - "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", - "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/utils": "8.57.2", - "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, "node_modules/@typescript-eslint/types": { "version": "8.57.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", @@ -747,58 +934,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", - "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.57.2", - "@typescript-eslint/tsconfig-utils": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", - "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, "node_modules/@typescript-eslint/visitor-keys": { "version": "8.57.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", @@ -943,9 +1078,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2154,13 +2289,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -2520,9 +2655,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -2669,14 +2804,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.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -2704,9 +2839,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -2761,7 +2896,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 8a732cb14..ff2620523 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "husky": "^9.1.7", "lint-staged": "^15.5.0", "prettier": "^3.8.0", - "prettier-plugin-tailwindcss": "^0.7.0" + "prettier-plugin-tailwindcss": "^0.7.0", + "typescript": "^5.9.3" }, "lint-staged": { "*.{ts,tsx}": [ From 4fd0e8c5d1bf49a92c57df58be6918d740a3f046 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 05:30:49 +0100 Subject: [PATCH 09/20] chore(deps)(deps-dev): bump @playwright/test in /gitnexus-web (#3317) --- gitnexus-web/package-lock.json | 62 +++++++--------------------------- gitnexus-web/package.json | 2 +- 2 files changed, 13 insertions(+), 51 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 4ec41926d..38359917c 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -48,7 +48,7 @@ }, "devDependencies": { "@babel/types": "^8.0.5", - "@playwright/test": "^1.62.1", + "@playwright/test": "^1.63.0", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", @@ -1371,13 +1371,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", - "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.62.1" + "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" @@ -2557,7 +2557,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2574,7 +2573,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2591,7 +2589,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2608,7 +2605,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2625,7 +2621,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2642,7 +2637,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2659,7 +2653,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2676,7 +2669,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2693,7 +2685,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2710,7 +2701,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2727,7 +2717,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2744,7 +2733,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2761,7 +2749,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2778,7 +2765,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2795,7 +2781,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2812,7 +2797,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2829,7 +2813,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2846,7 +2829,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2863,7 +2845,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2880,7 +2861,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -7440,28 +7420,25 @@ } }, "node_modules/playwright": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", - "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.62.1" + "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" }, "engines": { "node": ">=20" - }, - "optionalDependencies": { - "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", - "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -7471,21 +7448,6 @@ "node": ">=20" } }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index a10e13ffa..52b443fcd 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -58,7 +58,7 @@ }, "devDependencies": { "@babel/types": "^8.0.5", - "@playwright/test": "^1.62.1", + "@playwright/test": "^1.63.0", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", From 4efe3e34f5c05a7eb18902f412265456b90bbfb8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 06:09:21 +0100 Subject: [PATCH 10/20] chore(deps)(deps-dev): bump @testing-library/jest-dom in /gitnexus-web (#3318) Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 7.0.0 to 7.0.1. - [Release notes](https://github.com/testing-library/jest-dom/releases) - [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md) - [Commits](https://github.com/testing-library/jest-dom/compare/v7.0.0...v7.0.1) --- updated-dependencies: - dependency-name: "@testing-library/jest-dom" dependency-version: 7.0.1 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus-web/package-lock.json | 16 +++++++++++----- gitnexus-web/package.json | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 38359917c..3302b75a8 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -49,7 +49,7 @@ "devDependencies": { "@babel/types": "^8.0.5", "@playwright/test": "^1.63.0", - "@testing-library/jest-dom": "^7.0.0", + "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", "@types/dompurify": "^3.2.0", @@ -2017,9 +2017,9 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", - "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2036,7 +2036,13 @@ "yarn": ">=1" }, "peerDependencies": { - "@testing-library/dom": ">=10 <11" + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } } }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 52b443fcd..a4d01f7e6 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -59,7 +59,7 @@ "devDependencies": { "@babel/types": "^8.0.5", "@playwright/test": "^1.63.0", - "@testing-library/jest-dom": "^7.0.0", + "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", "@types/dompurify": "^3.2.0", From 9a692d4957d6e82005d1a91bf6ecc80fdb3d86e1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 06:09:53 +0100 Subject: [PATCH 11/20] chore(deps)(deps): bump lucide-react in /gitnexus-web (#3319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 1.31.0 to 1.44.0. - [Release notes](https://github.com/lucide-icons/lucide/releases) - [Commits](https://github.com/lucide-icons/lucide/commits/1.44.0/packages/lucide-react) --- updated-dependencies: - dependency-name: lucide-react dependency-version: 1.44.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- gitnexus-web/package-lock.json | 8 ++++---- gitnexus-web/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 3302b75a8..d00069047 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -30,7 +30,7 @@ "i18next-browser-languagedetector": "^8.2.1", "langchain": "^1.5.11", "lru-cache": "^11.5.2", - "lucide-react": "^1.31.0", + "lucide-react": "^1.44.0", "mermaid": "^11.17.2", "mnemonist": "^0.40.4", "pandemonium": "^2.4.0", @@ -5922,9 +5922,9 @@ } }, "node_modules/lucide-react": { - "version": "1.31.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz", - "integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==", + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.44.0.tgz", + "integrity": "sha512-2egNApH4hX4j/qdCgRublh88+9u3mEhz9iSlW5ckm4kaQEqZbXbMr0l5u5JZLy8nmWRx2dbHGQkEDYz6C9aCgw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index a4d01f7e6..05a06a278 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -40,7 +40,7 @@ "i18next-browser-languagedetector": "^8.2.1", "langchain": "^1.5.11", "lru-cache": "^11.5.2", - "lucide-react": "^1.31.0", + "lucide-react": "^1.44.0", "mermaid": "^11.17.2", "mnemonist": "^0.40.4", "pandemonium": "^2.4.0", From e3f8fefd5f5ad49b31805dc1192a2b8618b26ec7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 06:10:05 +0100 Subject: [PATCH 12/20] chore(deps)(deps-dev): bump vite from 8.1.5 to 8.3.0 in /gitnexus-web (#3320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.1.5 to 8.3.0. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/create-vite@8.3.0/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 8.3.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- gitnexus-web/package-lock.json | 542 +++++++++++++++++++++++---------- gitnexus-web/package.json | 2 +- 2 files changed, 381 insertions(+), 163 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index d00069047..0dbe901ba 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -63,7 +63,7 @@ "jsdom": "^30.0.1", "tree-sitter-wasms": "^0.1.13", "typescript": "^7.0.2", - "vite": "^8.1.5", + "vite": "^8.3.0", "vitest": "^4.1.10", "wait-on": "^9.1.0" }, @@ -494,37 +494,6 @@ "node": ">=16" } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", @@ -1305,24 +1274,6 @@ "@chevrotain/types": "~11.1.2" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1362,12 +1313,12 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@playwright/test": { @@ -1386,10 +1337,26 @@ "node": ">=20" } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", "cpu": [ "arm64" ], @@ -1403,9 +1370,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", "cpu": [ "arm64" ], @@ -1419,9 +1386,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", "cpu": [ "x64" ], @@ -1435,9 +1402,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", "cpu": [ "x64" ], @@ -1451,9 +1418,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", "cpu": [ "arm" ], @@ -1467,12 +1434,15 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1483,12 +1453,15 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1499,12 +1472,15 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1515,12 +1491,15 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1531,12 +1510,15 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1547,12 +1529,15 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1563,9 +1548,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", "cpu": [ "arm64" ], @@ -1578,28 +1563,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", "cpu": [ "arm64" ], @@ -1613,9 +1580,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", "cpu": [ "x64" ], @@ -2138,16 +2105,6 @@ "node": "*" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -7092,9 +7049,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "funding": [ { "type": "github", @@ -7403,9 +7360,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { "node": ">=12" @@ -7471,9 +7428,9 @@ } }, "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "funding": [ { "type": "opencollective", @@ -7490,7 +7447,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7850,12 +7807,12 @@ "license": "Unlicense" }, "node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.139.0", + "@oxc-project/types": "=0.149.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -7865,21 +7822,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" } }, "node_modules/roughjs": { @@ -8336,7 +8293,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "devOptional": true, + "dev": true, "license": "0BSD" }, "node_modules/tsx": { @@ -8555,15 +8512,15 @@ } }, "node_modules/vite": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", - "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.17", - "rolldown": "~1.1.5", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "bin": { @@ -8580,7 +8537,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -8631,6 +8588,267 @@ } } }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/vitest": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 05a06a278..a93978923 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -73,7 +73,7 @@ "jsdom": "^30.0.1", "tree-sitter-wasms": "^0.1.13", "typescript": "^7.0.2", - "vite": "^8.1.5", + "vite": "^8.3.0", "vitest": "^4.1.10", "wait-on": "^9.1.0" }, From 9a68183c9970bb6b49541e36f12280ef11416568 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:43:50 +0100 Subject: [PATCH 13/20] chore(deps)(deps): bump react and @types/react in /gitnexus-web (#3299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps)(deps): bump react and @types/react in /gitnexus-web Bumps [react](https://github.com/react/react/tree/HEAD/packages/react) and [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react). These dependencies needed to be updated together. Updates `react` from 19.2.8 to 19.3.0 - [Release notes](https://github.com/react/react/releases) - [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/react/react/commits/v19.3.0/packages/react) Updates `@types/react` from 19.2.18 to 19.3.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react) --- updated-dependencies: - dependency-name: "@types/react" dependency-version: 19.3.0 dependency-type: direct:development update-type: version-update:semver-minor - dependency-name: react dependency-version: 19.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * chore(deps): align react-dom and @types/react-dom to 19.3.0 react-dom@19.2.8 peers react@^19.2.8, which excludes 19.3.0. Pair the remaining packages so the Dependabot bump is installable. Co-authored-by: Cursor --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar Co-authored-by: Gergo Magyar Co-authored-by: Cursor --- gitnexus-web/package-lock.json | 94 +++++++++++++++------------------- gitnexus-web/package.json | 8 +-- 2 files changed, 46 insertions(+), 56 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 0dbe901ba..8795c6141 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -34,8 +34,8 @@ "mermaid": "^11.17.2", "mnemonist": "^0.40.4", "pandemonium": "^2.4.0", - "react": "^19.2.5", - "react-dom": "^19.2.8", + "react": "^19.3.0", + "react-dom": "^19.3.0", "react-i18next": "^17.0.13", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", @@ -54,8 +54,8 @@ "@testing-library/user-event": "^14.6.7", "@types/dompurify": "^3.2.0", "@types/node": "^26.5.1", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.4", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "@types/react-syntax-highlighter": "^15.5.13", "@vercel/node": "^5.10.2", "@vitejs/plugin-react": "^6.1.1", @@ -1440,9 +1440,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1459,9 +1456,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1478,9 +1472,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1497,9 +1488,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1516,9 +1504,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1535,9 +1520,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2472,22 +2454,22 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", - "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.2.0" + "@types/react": "^19.3.0" } }, "node_modules/@types/react-syntax-highlighter": { @@ -2520,6 +2502,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2536,6 +2519,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2552,6 +2536,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2568,6 +2553,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2584,6 +2570,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2600,6 +2587,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2616,6 +2604,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2632,6 +2621,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2648,6 +2638,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2664,6 +2655,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2680,6 +2672,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2696,6 +2689,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2712,6 +2706,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2728,6 +2723,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2744,6 +2740,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2760,6 +2757,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2776,6 +2774,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2792,6 +2791,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2808,6 +2808,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2824,6 +2825,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -7547,24 +7549,24 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", - "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", - "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "scheduler": "^0.28.0" }, "peerDependencies": { - "react": "^19.2.8" + "react": "^19.3.0" } }, "node_modules/react-i18next": { @@ -7911,9 +7913,9 @@ } }, "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, "node_modules/semver": { @@ -8724,9 +8726,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8747,9 +8746,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8770,9 +8766,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8793,9 +8786,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index a93978923..50b58ab71 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -44,8 +44,8 @@ "mermaid": "^11.17.2", "mnemonist": "^0.40.4", "pandemonium": "^2.4.0", - "react": "^19.2.5", - "react-dom": "^19.2.8", + "react": "^19.3.0", + "react-dom": "^19.3.0", "react-i18next": "^17.0.13", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", @@ -64,8 +64,8 @@ "@testing-library/user-event": "^14.6.7", "@types/dompurify": "^3.2.0", "@types/node": "^26.5.1", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.4", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "@types/react-syntax-highlighter": "^15.5.13", "@vercel/node": "^5.10.2", "@vitejs/plugin-react": "^6.1.1", From 9d95af9fc385aed870861b5998bbfc5cca39096c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Fri, 18 Sep 2026 11:03:24 +0100 Subject: [PATCH 14/20] refactor(analyze): move detected-branch sanitization off CLI config (#3325) * refactor(analyze): load detected-branch sanitization from core git-ref Keep the never-throw helper next to validateBranchName so run-analyze no longer imports CLI config parsing. Co-authored-by: Cursor * fix(analyze): warn once when a checkout name cannot label the index After the write lock settles, emit a single onLog warning and keep writing the workspace slot. Pin that run-analyze does not import CLI analyze-config. Co-authored-by: Cursor * fix(analyze): escape hidden checkout names in the detect-reject warning Keep the rejected ref visible in onLog without replaying bidi or quote characters, and document that sanitizeDetectedBranch rethrows unexpected errors. Co-authored-by: Cursor * fix(analyze): keep detect-reject warnings on one line (#3325) Git-legal U+2028/U+2029 checkout names were rejected as whitespace but left raw in the new onLog warning, so the message split across two lines. Escape those code points in the formatter without changing validateBranchName. Co-authored-by: Cursor * chore(autofix): apply prettier + eslint fixes via /autofix command * fix(analyze): keep C1 and Unicode spaces in detect-reject warnings Escape NEL and remaining whitespace as \uXXXX so stripControlCharacters cannot drop or disguise the rejected checkout name. Co-authored-by: Cursor --------- Co-authored-by: Gergo Magyar Co-authored-by: Cursor Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- gitnexus/src/cli/analyze-config.ts | 16 +- gitnexus/src/core/git-ref.ts | 54 +++- gitnexus/src/core/run-analyze.ts | 21 +- .../test/helpers/detect-reject-warning.ts | 6 + .../integration/multi-branch-analyze.test.ts | 11 +- gitnexus/test/unit/git-ref.test.ts | 49 +++- gitnexus/test/unit/run-analyze.test.ts | 247 +++++++++++++++++- 7 files changed, 377 insertions(+), 27 deletions(-) create mode 100644 gitnexus/test/helpers/detect-reject-warning.ts diff --git a/gitnexus/src/cli/analyze-config.ts b/gitnexus/src/cli/analyze-config.ts index 49896538e..43c368a73 100644 --- a/gitnexus/src/cli/analyze-config.ts +++ b/gitnexus/src/cli/analyze-config.ts @@ -33,8 +33,10 @@ import path from 'node:path'; import { readRepoControlFile } from '../config/repo-control-file.js'; import { InvalidBranchError, + sanitizeDetectedBranch, validateBranchName as validateBranchNameCore, } from '../core/git-ref.js'; +export { sanitizeDetectedBranch }; import type { AnalyzeOptions } from './analyze-options.js'; export const GITNEXUS_RC_FILENAME = '.gitnexusrc'; @@ -172,20 +174,6 @@ export function validateBranchName(value: string, source: string): string { } } -/** - * Best-effort validation for an auto-detected branch (from git). Never throws — - * returns `undefined` for anything unusable so the resolver falls back to the - * next precedence tier. - */ -export function sanitizeDetectedBranch(value: string | null | undefined): string | undefined { - if (!value) return undefined; - try { - return validateBranchName(value, 'detected branch'); - } catch { - return undefined; - } -} - const normalizeValue = (kind: ValueKind, value: unknown, key: string): unknown => { const source = `${GITNEXUS_RC_FILENAME} "${key}"`; switch (kind) { diff --git a/gitnexus/src/core/git-ref.ts b/gitnexus/src/core/git-ref.ts index e7d101eed..e712dd3a2 100644 --- a/gitnexus/src/core/git-ref.ts +++ b/gitnexus/src/core/git-ref.ts @@ -1,10 +1,10 @@ /** * Git ref-name validation used by both the CLI and the HTTP analyze route. * - * Lives in `core/` so `server/api.ts` does not import `cli/analyze-config` - * (that import closed a cli → server → cli cycle: `cli/serve.ts` already - * imports `createServer`). The CLI keeps a thin wrapper that rethrows - * {@link InvalidBranchError} as `GitNexusRcError`. + * Lives in `core/` so `server/api.ts` and `run-analyze.ts` do not import + * `cli/analyze-config` (that import closed a cli → server → cli cycle: + * `cli/serve.ts` already imports `createServer`). The CLI keeps a thin + * wrapper that rethrows {@link InvalidBranchError} as `GitNexusRcError`. */ /** Git refs longer than this are almost certainly a mistake / injection attempt. */ @@ -126,3 +126,49 @@ export function validateBranchName(value: string, source: string): string { } return trimmed; } + +/** + * Best-effort validation for an auto-detected branch (from git). Returns the + * trimmed name, or `undefined` for anything unusable so callers fall back to + * the next precedence tier or leave the index unlabeled. Swallows + * {@link InvalidBranchError} only; unexpected errors are rethrown. + */ +export function sanitizeDetectedBranch(value: string | null | undefined): string | undefined { + if (!value) return undefined; + try { + return validateBranchName(value, 'detected branch'); + } catch (err) { + if (err instanceof InvalidBranchError) return undefined; + throw err; + } +} + +/** + * Render a rejected checkout name for `onLog`. Hidden / bidi / control code + * points become `\uXXXX` so a git-legal U+202E name cannot reverse the + * warning in a terminal. C1 controls (U+0080–U+009F, including NEL U+0085) + * and remaining Unicode whitespace (`/\s/` — NBSP, U+2028/U+2029, ideographic + * space, etc.) are not all in {@link isHiddenOrControl}; escape them the same + * way so the ASCII escape survives `stripControlCharacters` and the warning + * stays one line. ASCII `"` is escaped; other characters (including backticks) + * stay visible. + */ +export function formatRejectedBranchForLog(value: string): string { + const shouldEscapeRejectedBranchChar = (cp: number, ch: string): boolean => + isHiddenOrControl(cp) || (cp >= 0x80 && cp <= 0x9f) || /\s/.test(ch); + + let out = ''; + for (const ch of value) { + const cp = ch.codePointAt(0); + if (cp !== undefined && shouldEscapeRejectedBranchChar(cp, ch)) { + out += `\\u${cp.toString(16).padStart(4, '0')}`; + continue; + } + if (ch === '"') { + out += '\\"'; + continue; + } + out += ch; + } + return out; +} diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 8ea0b0f66..d8d16484a 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -234,7 +234,7 @@ import { type CachedEmbeddingsSnapshot, } from './embeddings/embedding-restore-spill.js'; import { generateAIContextFiles } from '../cli/ai-context.js'; -import { sanitizeDetectedBranch } from '../cli/analyze-config.js'; +import { formatRejectedBranchForLog, sanitizeDetectedBranch } from './git-ref.js'; import { EMBEDDING_TABLE_NAME, EMBEDDING_DIMS, @@ -1052,12 +1052,16 @@ export const pdgModeMismatch = (recorded: RepoMeta['pdg'], options: PdgOptions): * directory (#2658). `metaDir` — not `getStoragePaths(repoPath, options.branch)` * — is the lock scope: a `--branch X` that owns the flat slot resolves to the * flat `.gitnexus`, so scoping off the raw option would lock the wrong dir. + * `rejectedDetectedBranch` is log-only (the detect-reject warning after lock + * settle); it does not change placement. */ interface WriteTarget { storagePath: string; repoHasGit: boolean; currentCommit: string; checkedOutBranch: string | null; + /** Raw checkout name when git returned one the branch-name rules reject. */ + rejectedDetectedBranch: string | null; branchLabel: string | null; placement: { branch?: string }; lbugPath: string; @@ -1089,9 +1093,12 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr // validated (#2106 R1): a git ref the branch-name rules forbid becomes `null` // → the flat slot, matching that a later `--branch ` query would // also be rejected. A normal ref round-trips index-time/query-time labels. - const checkedOutBranch = repoHasGit - ? (sanitizeDetectedBranch(getCurrentBranch(repoPath)) ?? null) - : null; + // Keep the raw rejected name so `runFullAnalysis` can warn once after the + // lock settles. Detached / non-git / empty detect stay `null` here and silent. + const rawDetectedBranch = repoHasGit ? getCurrentBranch(repoPath) : null; + const checkedOutBranch = sanitizeDetectedBranch(rawDetectedBranch) ?? null; + const rejectedDetectedBranch = + rawDetectedBranch != null && checkedOutBranch === null ? rawDetectedBranch : null; // Analyze indexes the working tree, not an arbitrary ref. An explicit // `--branch X` while a DIFFERENT branch Y is checked out would write Y's // content into X's slot, corrupting X (#2106). Refuse the mismatch. Detached @@ -1112,6 +1119,7 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr repoHasGit, currentCommit, checkedOutBranch, + rejectedDetectedBranch, branchLabel, placement, lbugPath, @@ -1222,6 +1230,11 @@ export async function runFullAnalysis( ); } } + if (writeTarget.rejectedDetectedBranch) { + log( + `Warning: checkout "${formatRejectedBranchForLog(writeTarget.rejectedDetectedBranch)}" is not a usable index label; continuing.`, + ); + } return await runFullAnalysisInner( repoPath, options, diff --git a/gitnexus/test/helpers/detect-reject-warning.ts b/gitnexus/test/helpers/detect-reject-warning.ts new file mode 100644 index 000000000..abbea7852 --- /dev/null +++ b/gitnexus/test/helpers/detect-reject-warning.ts @@ -0,0 +1,6 @@ +/** + * Matches the detect-reject warning emitted by `run-analyze` after lock settle. + * Shared so unit and integration tests keep one regex. + */ +export const isDetectRejectWarning = (message: string): boolean => + /^Warning:.*not a usable index label.*continuing\.$/.test(message); diff --git a/gitnexus/test/integration/multi-branch-analyze.test.ts b/gitnexus/test/integration/multi-branch-analyze.test.ts index bf2fabc9b..c61428059 100644 --- a/gitnexus/test/integration/multi-branch-analyze.test.ts +++ b/gitnexus/test/integration/multi-branch-analyze.test.ts @@ -5,6 +5,7 @@ import path from 'path'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { getStoragePaths, loadMeta, listRegisteredRepos } from '../../src/storage/repo-manager.js'; import { createTempDir } from '../helpers/test-db.js'; +import { isDetectRejectWarning } from '../helpers/detect-reject-warning.js'; /** * #2106/#2354 — branch handling end-to-end. Proves that a plain analyze @@ -220,7 +221,12 @@ describe('multi-branch analyze (#2106)', () => { execFileSync('git', ['branch', '-M', 'feat`x'], { cwd: repo, stdio: 'pipe' }); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); - await runFullAnalysis(repo, {}, { onProgress: () => {} }); + const logs: string[] = []; + await runFullAnalysis( + repo, + {}, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); // The forbidden ref was normalized to null → flat slot, no branch field, // and no branches/ sub-directory created for an unqueryable slug. @@ -228,6 +234,9 @@ describe('multi-branch analyze (#2106)', () => { expect(existsSync(flat.lbugPath)).toBe(true); expect((await loadMeta(flat.storagePath))?.branch).toBeUndefined(); expect(existsSync(path.join(flat.storagePath, 'branches'))).toBe(false); + const warnings = logs.filter(isDetectRejectWarning); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('feat`x'); } finally { await tmp.cleanup(); } diff --git a/gitnexus/test/unit/git-ref.test.ts b/gitnexus/test/unit/git-ref.test.ts index af2492926..7ecf54679 100644 --- a/gitnexus/test/unit/git-ref.test.ts +++ b/gitnexus/test/unit/git-ref.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { InvalidBranchError, validateBranchName } from '../../src/core/git-ref.js'; +import { + InvalidBranchError, + formatRejectedBranchForLog, + sanitizeDetectedBranch, + validateBranchName, +} from '../../src/core/git-ref.js'; describe('core/git-ref', () => { it('throws InvalidBranchError with name "InvalidBranchError"', () => { @@ -12,4 +17,46 @@ describe('core/git-ref', () => { expect((err as Error).name).toBe('InvalidBranchError'); } }); + + it('sanitizeDetectedBranch returns the trimmed name for a legal branch', () => { + expect(sanitizeDetectedBranch('develop')).toBe('develop'); + expect(sanitizeDetectedBranch(' feature/foo-bar ')).toBe('feature/foo-bar'); + }); + + it('sanitizeDetectedBranch returns undefined for null, empty, or whitespace', () => { + expect(sanitizeDetectedBranch(null)).toBeUndefined(); + expect(sanitizeDetectedBranch(undefined)).toBeUndefined(); + expect(sanitizeDetectedBranch('')).toBeUndefined(); + expect(sanitizeDetectedBranch(' ')).toBeUndefined(); + }); + + it('sanitizeDetectedBranch swallows InvalidBranchError and does not throw', () => { + expect(sanitizeDetectedBranch('feat`x')).toBeUndefined(); + expect(sanitizeDetectedBranch('main`evil')).toBeUndefined(); + expect(sanitizeDetectedBranch('HEAD')).toBeUndefined(); + expect(() => sanitizeDetectedBranch('feat`x')).not.toThrow(); + }); + + it('formatRejectedBranchForLog keeps backticks visible and escapes bidi, quotes, and line separators', () => { + expect(formatRejectedBranchForLog('feat`x')).toBe('feat`x'); + expect(formatRejectedBranchForLog('a"b')).toBe('a\\"b'); + expect(formatRejectedBranchForLog(`ok${'\u202e'}bad`)).toBe('ok\\u202ebad'); + expect(formatRejectedBranchForLog(`zw${'\u200b'}sp`)).toBe('zw\\u200bsp'); + expect(formatRejectedBranchForLog(`foo${'\u2028'}bar`)).toBe('foo\\u2028bar'); + expect(formatRejectedBranchForLog(`foo${'\u2029'}bar`)).toBe('foo\\u2029bar'); + }); + + it('formatRejectedBranchForLog escapes NBSP, NEL, and other C1 as \\uXXXX', () => { + expect(formatRejectedBranchForLog(`foo${'\u00a0'}bar`)).toBe('foo\\u00a0bar'); + const nelBacktick = formatRejectedBranchForLog(`feat${'\u0085'}\``); + expect(nelBacktick).toContain('\\u0085'); + expect(nelBacktick).toContain('`'); + expect(nelBacktick).not.toContain('\u0085'); + expect(formatRejectedBranchForLog(`x${'\u009f'}y`)).toBe('x\\u009fy'); + }); + + it('sanitizeDetectedBranch rejects git-legal U+2028/U+2029 as whitespace', () => { + expect(sanitizeDetectedBranch(`foo${'\u2028'}bar`)).toBeUndefined(); + expect(sanitizeDetectedBranch(`foo${'\u2029'}bar`)).toBeUndefined(); + }); }); diff --git a/gitnexus/test/unit/run-analyze.test.ts b/gitnexus/test/unit/run-analyze.test.ts index 3dbf0e3f5..fe1dc8660 100644 --- a/gitnexus/test/unit/run-analyze.test.ts +++ b/gitnexus/test/unit/run-analyze.test.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process'; +import { execFileSync, execSync } from 'child_process'; import fs from 'fs/promises'; import path from 'path'; import { pathToFileURL } from 'url'; @@ -21,6 +21,7 @@ import { import { SCHEMA_FINGERPRINT } from '../../src/core/lbug/schema.js'; import { taintModelVersion } from '../../src/core/ingestion/taint/typescript-model.js'; import { createTempDir } from '../helpers/test-db.js'; +import { isDetectRejectWarning } from '../helpers/detect-reject-warning.js'; import { readEmbeddingNodeIds } from '../helpers/embedding-seed.js'; import { getIndexIncompleteReasons } from '../../src/core/index-freshness.js'; import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from '../../src/core/analysis-features.js'; @@ -40,6 +41,14 @@ describe('run-analyze module', () => { expect(typeof mod.runFullAnalysis).toBe('function'); }); + it('does not import cli/analyze-config', async () => { + const source = await fs.readFile( + path.resolve(__dirname, '../../src/core/run-analyze.ts'), + 'utf8', + ); + expect(source).not.toMatch(/cli\/analyze-config/); + }); + it('exports PHASE_LABELS', async () => { const mod = await import('../../src/core/run-analyze.js'); expect(mod.PHASE_LABELS).toBeDefined(); @@ -1015,18 +1024,26 @@ describe('run-analyze module', () => { // Detached HEAD → branchLabel is null → the restamp block must not // fire: the existing stamp survives, mirroring the end-of-run write. + // Null detect (detached / getCurrentBranch null / non-git) must stay + // silent — it is not a rejected checkout name. const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); - const result = await runFullAnalysis(tmpRepo.dbPath, {}, { onProgress: () => {} }); + const logs: string[] = []; + const result = await runFullAnalysis( + tmpRepo.dbPath, + {}, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); expect(result.alreadyUpToDate).toBe(true); const flatMeta = await loadMeta(flat.storagePath); expect(flatMeta?.branch).toBe('main'); + expect(logs.filter(isDetectRejectWarning)).toEqual([]); } finally { if (savedHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = savedHome; await tmpHome.cleanup(); await tmpRepo.cleanup(); } - }); + }, 180_000); it('reports isPrimaryBranch false for an up-to-date explicit --branch run (#2106 R2)', async () => { const tmpRepo = await createTempDir('gitnexus-run-analyze-nonprimary-'); @@ -1102,6 +1119,230 @@ describe('run-analyze module', () => { await tmpRepo.cleanup(); } }); + + it('warns once when the checkout name is not a usable index label', async () => { + const tmpRepo = await createTempDir('gitnexus-run-analyze-detect-reject-'); + try { + execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + execFileSync('git', ['branch', '-M', 'feat`x'], { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + const currentCommit = execSync('git rev-parse HEAD', { + cwd: tmpRepo.dbPath, + encoding: 'utf-8', + }).trim(); + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: currentCommit, + indexedAt: new Date().toISOString(), + schemaFingerprint: SCHEMA_FINGERPRINT, + analysisFeatures: CURRENT_ANALYSIS_FEATURES, + runnerIdentity: currentRunnerIdentity(), + }); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const logs: string[] = []; + const result = await runFullAnalysis( + tmpRepo.dbPath, + {}, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); + + expect(result.alreadyUpToDate).toBe(true); + expect((await loadMeta(storagePath))?.branch).toBeUndefined(); + await expect(fs.access(path.join(storagePath, 'branches'))).rejects.toThrow(); + const warnings = logs.filter(isDetectRejectWarning); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('feat`x'); + expect(warnings[0]).toMatch(/^Warning:.*continuing\.$/); + expect(warnings[0]).not.toMatch(/stamp|cleared|unlabeled/i); + } finally { + await tmpRepo.cleanup(); + } + }, 180_000); + + it('applies an explicit --branch on a rejected checkout and still warns', async () => { + const tmpRepo = await createTempDir('gitnexus-run-analyze-detect-reject-pin-'); + try { + execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + execFileSync('git', ['branch', '-M', 'feat`x'], { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + const currentCommit = execSync('git rev-parse HEAD', { + cwd: tmpRepo.dbPath, + encoding: 'utf-8', + }).trim(); + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: currentCommit, + indexedAt: new Date().toISOString(), + schemaFingerprint: SCHEMA_FINGERPRINT, + analysisFeatures: CURRENT_ANALYSIS_FEATURES, + runnerIdentity: currentRunnerIdentity(), + }); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const logs: string[] = []; + const result = await runFullAnalysis( + tmpRepo.dbPath, + { branch: 'main' }, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); + + expect(result.alreadyUpToDate).toBe(true); + expect((await loadMeta(storagePath))?.branch).toBe('main'); + const warnings = logs.filter(isDetectRejectWarning); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('feat`x'); + } finally { + await tmpRepo.cleanup(); + } + }, 180_000); + + it('keeps the detect-reject warning on one line for U+2028 checkout names', async () => { + const tmpRepo = await createTempDir('gitnexus-run-analyze-detect-reject-ls-'); + try { + execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + execFileSync('git', ['branch', '-M', 'foo\u2028bar'], { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + const currentCommit = execSync('git rev-parse HEAD', { + cwd: tmpRepo.dbPath, + encoding: 'utf-8', + }).trim(); + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: currentCommit, + indexedAt: new Date().toISOString(), + schemaFingerprint: SCHEMA_FINGERPRINT, + analysisFeatures: CURRENT_ANALYSIS_FEATURES, + runnerIdentity: currentRunnerIdentity(), + }); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const logs: string[] = []; + const result = await runFullAnalysis( + tmpRepo.dbPath, + {}, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); + + expect(result.alreadyUpToDate).toBe(true); + const warnings = logs.filter(isDetectRejectWarning); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('\\u2028'); + expect(warnings[0]).not.toContain('\u2028'); + expect(warnings[0].split(/\n|\r|\u2028|\u2029/)).toHaveLength(1); + expect(warnings[0]).toMatch(/^Warning:.*continuing\.$/); + } finally { + await tmpRepo.cleanup(); + } + }, 180_000); + + it('keeps the detect-reject warning on one line for NEL+backtick checkout names', async () => { + const tmpRepo = await createTempDir('gitnexus-run-analyze-detect-reject-nel-'); + try { + execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + execFileSync('git', ['branch', '-M', 'feat\u0085`'], { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + const currentCommit = execSync('git rev-parse HEAD', { + cwd: tmpRepo.dbPath, + encoding: 'utf-8', + }).trim(); + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: currentCommit, + indexedAt: new Date().toISOString(), + schemaFingerprint: SCHEMA_FINGERPRINT, + analysisFeatures: CURRENT_ANALYSIS_FEATURES, + runnerIdentity: currentRunnerIdentity(), + }); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const logs: string[] = []; + const result = await runFullAnalysis( + tmpRepo.dbPath, + {}, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); + + expect(result.alreadyUpToDate).toBe(true); + const warnings = logs.filter(isDetectRejectWarning); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('\\u0085'); + expect(warnings[0]).not.toContain('\u0085'); + expect(warnings[0]).toContain('`'); + expect(warnings[0].split(/\n|\r|\u2028|\u2029/)).toHaveLength(1); + expect(warnings[0]).toMatch(/^Warning:.*continuing\.$/); + } finally { + await tmpRepo.cleanup(); + } + }, 180_000); + + it('keeps the detect-reject warning on one line for NBSP checkout names', async () => { + const tmpRepo = await createTempDir('gitnexus-run-analyze-detect-reject-nbsp-'); + try { + execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + execFileSync('git', ['branch', '-M', 'foo\u00a0bar'], { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + const currentCommit = execSync('git rev-parse HEAD', { + cwd: tmpRepo.dbPath, + encoding: 'utf-8', + }).trim(); + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: currentCommit, + indexedAt: new Date().toISOString(), + schemaFingerprint: SCHEMA_FINGERPRINT, + analysisFeatures: CURRENT_ANALYSIS_FEATURES, + runnerIdentity: currentRunnerIdentity(), + }); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const logs: string[] = []; + const result = await runFullAnalysis( + tmpRepo.dbPath, + {}, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); + + expect(result.alreadyUpToDate).toBe(true); + const warnings = logs.filter(isDetectRejectWarning); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('\\u00a0'); + expect(warnings[0]).not.toContain('\u00a0'); + expect(warnings[0].split(/\n|\r|\u2028|\u2029/)).toHaveLength(1); + expect(warnings[0]).toMatch(/^Warning:.*continuing\.$/); + } finally { + await tmpRepo.cleanup(); + } + }, 180_000); }); describe('collectBranchCacheKeys (#2106 R6)', () => { From ba39d5c00993330141348fce37263f93a15d45b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Fri, 18 Sep 2026 13:37:15 +0100 Subject: [PATCH 15/20] feat(analyze): expose process-detection budget overrides (#3324) * feat(analyze): expose process-detection budget overrides (#3313) Operators can raise or lower process count, branching, trace depth, and the entry-point candidate pool via CLI, .gitnexusrc, or GITNEXUS_* without changing shipped defaults. A budget-only change re-detects flows on the next analyze without --force. Co-authored-by: Cursor * fix(review): say invalid budget flags still honor env A rejected --max-processes value was described as falling back to the built-in default even when GITNEXUS_MAX_* still won the next precedence tier. Co-authored-by: Cursor * refactor(analyze): share process-detection defaults and skip unused walks Keep DEFAULT_CONFIG aligned with the budget resolver and count symbols only when maxProcesses is still dynamic. Co-authored-by: Cursor * style(analyze): wrap process-detection budget files for prettier Co-authored-by: Cursor * docs(analyze): name the real process-detection default formula Co-authored-by: Cursor * docs(analyze): stop calling maxProcesses*2 a hard trace quota Co-authored-by: Cursor * fix(analyze): say invalid env budget tokens fall back to defaults Co-authored-by: Cursor * fix(analyze): recertify process-detection after in-place FTS abort (#3324) Persist processDetection.uncertified on the in-place FTS dirty stamp when the budget mismatched so a flagless retry cannot keep rewritten flows. Qualify .gitnexusrc fail-fast copy and tighten related tests. Co-authored-by: Cursor * fix(analyze): skip live dirty stamp on atomic incremental (#3324) POSIX atomic incremental mutates a staging copy, so stamping live incrementalInProgress before swap made a crash force-rebuild a healthy index. Align analyze --help with CLI > .gitnexusrc > env > default. Co-authored-by: Cursor * docs(changelog): drop the atomic-incremental dirty-stamp note The code fix stays; Unreleased no longer lists that recovery change. Co-authored-by: Cursor * test(cli): survive FTS SIGSEGV in --limit e2e CREATE_FTS_INDEX can kill the setup analyze on some WSL hosts (status null). Rebuild with --skip-fts and skip BM25-only query --limit cases unless GITNEXUS_REQUIRE_FTS=1. Refs #3324 Co-authored-by: Cursor * test(cli): mark update-check child at import Writing refresh-started from fetch() raced a 30s poll against cold tsx boot on a loaded default-project worker. Refs #3324 Co-authored-by: Cursor * Address PR review feedback (#3324) Isolate default-budget FTS crash-marker tests from GITNEXUS_MAX_* env, assert uncertify-before-FTS order and deferred flow detection on park recovery, drop the dangling "then" from entry-point help, and correct stale streamGraphEmit docs without skipping the process-detection stamp. Co-authored-by: Cursor * docs(changelog): drop Unreleased process-detection notes Keep the #3313 / #3322 code; Unreleased changelog matches main until release. Co-authored-by: Cursor --------- Co-authored-by: Gergo Magyar Co-authored-by: Cursor --- README.md | 15 +- gitnexus/README.md | 21 +- gitnexus/src/cli/analyze-config.ts | 4 + gitnexus/src/cli/analyze-options.ts | 8 + gitnexus/src/cli/analyze-watch.ts | 19 ++ gitnexus/src/cli/analyze.ts | 28 ++ gitnexus/src/cli/help-i18n.ts | 4 + gitnexus/src/cli/i18n/en.ts | 10 +- gitnexus/src/cli/i18n/zh-CN.ts | 8 +- gitnexus/src/cli/index.ts | 16 + .../ingestion/pipeline-phases/processes.ts | 53 ++- gitnexus/src/core/ingestion/pipeline.ts | 13 +- .../ingestion/process-detection-budget.ts | 304 ++++++++++++++++++ .../src/core/ingestion/process-processor.ts | 59 ++-- gitnexus/src/core/run-analyze.ts | 122 ++++++- gitnexus/src/storage/repo-meta.ts | 16 + .../test/integration/cli-limit-e2e.test.ts | 71 +++- .../integration/cli/update-notice.test.ts | 19 +- gitnexus/test/unit/analyze-config.test.ts | 27 ++ gitnexus/test/unit/analyze-gitnexusrc.test.ts | 14 + .../test/unit/analyze-process-budget.test.ts | 133 ++++++++ gitnexus/test/unit/cli-index-help.test.ts | 9 +- .../unit/process-detection-budget.test.ts | 235 ++++++++++++++ gitnexus/test/unit/process-processor.test.ts | 28 +- .../unit/processes-phase-sink-wiring.test.ts | 83 ++++- .../unit/run-analyze-fts-crash-marker.test.ts | 255 ++++++++++++++- gitnexus/test/unit/watch-paths.test.ts | 21 ++ 27 files changed, 1515 insertions(+), 80 deletions(-) create mode 100644 gitnexus/src/core/ingestion/process-detection-budget.ts create mode 100644 gitnexus/test/unit/analyze-process-budget.test.ts create mode 100644 gitnexus/test/unit/process-detection-budget.test.ts diff --git a/README.md b/README.md index ae6849672..f673bb145 100644 --- a/README.md +++ b/README.md @@ -407,7 +407,8 @@ backoff. Invalid `.gitnexusrc` or ignore-file reloads pause ordinary refreshes until the control file is fixed. Stop the watcher with Ctrl+C. Watch mode accepts `--debounce`, `--workers`, `--worker-timeout`, -`--max-file-size`, `--branch`, `--pdg`, `--skip-fts`, `--name`, `--allow-duplicate-name`, and +`--max-file-size`, `--max-processes`, `--max-process-branching`, +`--max-process-trace-depth`, `--max-entry-point-candidates`, `--branch`, `--pdg`, `--skip-fts`, `--name`, `--allow-duplicate-name`, and `--verbose`. Explicit one-shot options such as `--force`, `--repair-fts`, embedding flags, `--skills`, `--self-commit`, `--index-only`, and `--skip-git` are rejected. Unsupported defaults from `.gitnexusrc` are ignored with a @@ -453,6 +454,8 @@ gitnexus analyze --verbose # Log skipped files when parsers are unavailabl gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses gitnexus analyze --workers # Parse worker pool size (>=1; default: cores-1, capped at 16, # auto-sized to the repo). 0 is rejected — there is no sequential mode. +gitnexus analyze --max-processes # Process-detection process cap (replaces dynamic max(20, round(symbols/10))) +gitnexus analyze --max-entry-point-candidates # Ranked entry-point pool (default 200; raise when the warning names it) gitnexus analyze --spring-actuator ./actuator # Enrich with local Spring Boot Actuator JSON snapshots gitnexus analyze --asyncapi-spec ./docs/asyncapi # Resolve broker addresses from AsyncAPI 3.x documents gitnexus analyze --wal-checkpoint-threshold 67108864 # LadybugDB WAL auto-checkpoint threshold in bytes @@ -576,15 +579,15 @@ Notes: - The default branch is resolved as: `--default-branch` > `.gitnexusrc` `defaultBranch`/`branch` > auto-detected `origin/HEAD` > `main`. - `skipContextFiles` / `skipAiContext` are aliases for `skipAgentsMd` — they skip the `AGENTS.md` / `CLAUDE.md` block only. They do **not** imply `skipSkills`. `indexOnly` is the stronger option that skips all file injection. -- Supported keys: `defaultBranch` (`branch`), `skipAgentsMd` (`skipContextFiles`, `skipAiContext`), `skipSkills`, `indexOnly`, `stats`/`noStats`, `embeddings`, `dropEmbeddings`, `name`, `allowDuplicateName`, `maxFileSize`, `workerTimeout`, `walCheckpointThreshold`, `workers`, `springActuator`, `embeddingThreads`, `embeddingBatchSize`, `embeddingSubBatchSize`, `embeddingDevice`. -- The file is JSON only. Unknown keys and invalid values fail fast with an actionable error before analysis starts. +- Supported keys: `defaultBranch` (`branch`), `skipAgentsMd` (`skipContextFiles`, `skipAiContext`), `skipSkills`, `indexOnly`, `stats`/`noStats`, `embeddings`, `dropEmbeddings`, `name`, `allowDuplicateName`, `maxFileSize`, `workerTimeout`, `walCheckpointThreshold`, `workers`, `maxProcesses`, `maxProcessBranching`, `maxProcessTraceDepth`, `maxEntryPointCandidates`, `springActuator`, `embeddingThreads`, `embeddingBatchSize`, `embeddingSubBatchSize`, `embeddingDevice`. +- The file is JSON only. Unknown keys and wrong JSON types fail fast with an actionable error before analysis starts. Process-detection knobs (`maxProcesses`, `maxProcessBranching`, `maxProcessTraceDepth`, `maxEntryPointCandidates`) that are not a positive integer warn and fall through to env, then the built-in default.
Environment variables -Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max-file-size`, `--verbose`). Use the env-var form when you'd otherwise repeat the same flag every run, or when invoking GitNexus from a long-running host (MCP server, eval-server, CI shell) that already manages its own environment. CLI flags take precedence over env vars; env vars take precedence over built-in defaults. +Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max-file-size`, `--verbose`). Use the env-var form when you'd otherwise repeat the same flag every run, or when invoking GitNexus from a long-running host (MCP server, eval-server, CI shell) that already manages its own environment. CLI flags take precedence over `.gitnexusrc`, which takes precedence over env vars, which take precedence over built-in defaults. | Variable | Default | Effect | Tune when… | | ----------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -601,6 +604,10 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max | `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. | | `PROF_LBUG_LOAD` | unset | When `1`, emits one `[lbug-load prof]` summary line per `loadGraphToLbug` call breaking the graph-DB persistence wall into stages (`csv-emit` / `copy-nodes` / `copy-rels` / `fallback` / `total`) plus node & edge counts. Zero-cost when unset. | Attributing large-repo analyze wall time across CSV generation vs. LadybugDB `COPY` (issue #2203) — the analyze "emit" timing is the scope-resolution bucket, not this DB-write path. | | `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size `. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. | +| `GITNEXUS_MAX_PROCESSES` | dynamic (`max(20, round(symbols/10))`) | Analyze-time process-detection process cap. Equivalent to `--max-processes ` / `.gitnexusrc` `maxProcesses`. Explicit values replace the dynamic formula (not a multiplier). `0` is invalid, not unlimited. Changing this re-detects flows on the next analyze without `--force`. Distinct from query-time `IMPACT_MAX_CHUNKS`. | `[processes] … whole flows are MISSING` names `--max-processes` after entry points were never traced or flows were dropped. Tracing does not start the next entry once collected traces already reach `maxProcesses * 2`; a started entry can still emit every trace that entry produces. | +| `GITNEXUS_MAX_PROCESS_BRANCHING` | `4` | Analyze-time per-node branching cap during flow tracing. Equivalent to `--max-process-branching `. Shape-only: raising it shortens fewer traces; it does not restore whole missing flows. | A flow is present but `calleesDropped` is high at debug. | +| `GITNEXUS_MAX_PROCESS_TRACE_DEPTH` | `10` | Analyze-time DFS depth cap during flow tracing. Equivalent to `--max-process-trace-depth `. Shape-only. | A reported flow is shorter than the code path (`tracesDepthCapped` at debug). | +| `GITNEXUS_MAX_ENTRY_POINT_CANDIDATES` | `200` | Ranked entry-point candidate pool. Equivalent to `--max-entry-point-candidates `. Raising `--max-processes` alone does not clear `entryPointCandidatesDropped`. Doubling the current cap is the usual first raise; setting it to the full remaining candidate count can exhaust CPU and memory. | The `[processes]` warning reports candidate entry points that never ranked in. | | `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout ` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. | | `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget in milliseconds for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. | Slow or heavily loaded hosts where a full pool cold-starting concurrently needs more than 5s, and analyze aborts with "did not report ready within 5000ms". | | `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. | diff --git a/gitnexus/README.md b/gitnexus/README.md index b0ea3e5d5..393bef867 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -245,6 +245,8 @@ gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexu gitnexus analyze --skip-skills # Skip installing standard .claude/skills/gitnexus-* skill files gitnexus analyze --skip-git # Index folders that are not Git repositories gitnexus analyze --workers # Parse worker pool size (>=1; default: cores-1, capped at 16) +gitnexus analyze --max-processes # Process-detection process cap (replaces dynamic max(20, round(symbols/10))) +gitnexus analyze --max-entry-point-candidates # Ranked entry-point pool (default 200; raise when the warning names it) gitnexus analyze --spring-actuator ./actuator # Enrich with local Spring Boot Actuator JSON snapshots gitnexus analyze --verbose # Log skipped files when parsers are unavailable gitnexus analyze --max-file-size 1024 # Skip files larger than N KB (default: 512, cap: 32768) @@ -298,7 +300,8 @@ installation. Run a one-shot `gitnexus analyze` when those generated files need updating. Stop watch mode with Ctrl+C. Watch mode accepts `--debounce`, `--workers`, `--worker-timeout`, -`--max-file-size`, `--branch`, `--pdg`, `--name`, `--allow-duplicate-name`, and +`--max-file-size`, `--max-processes`, `--max-process-branching`, +`--max-process-trace-depth`, `--max-entry-point-candidates`, `--branch`, `--pdg`, `--name`, `--allow-duplicate-name`, and `--verbose`. Explicit one-shot options such as `--force`, `--repair-fts`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git` @@ -764,6 +767,22 @@ npx gitnexus analyze Values above **32768 KB (32 MB)** are clamped to the tree-sitter parser ceiling; invalid values fall back to the 512 KB default with a one-time warning. When an override is active, `analyze` prints the effective threshold in its startup banner (e.g. `GITNEXUS_MAX_FILE_SIZE: effective threshold 2048KB (default 512KB)`). +### Process detection reports missing flows + +On a large repository, `analyze` may warn that `[processes] … whole flows are MISSING`. That means ranked entry points or completed flows were sampled away by the analyze-time detection budget — not that the code path is absent, and not the query-time `IMPACT_MAX_CHUNKS` cap. + +Defaults stay in place when nothing is set: dynamic `maxProcesses = max(20, round(non-File symbols / 10))`, branching `4`, trace depth `10`, entry-point candidate pool `200`. Raise a knob only when the warning names it: + +```bash +# Usual first move when entryPointCandidatesDropped is the loud counter +npx gitnexus analyze --max-entry-point-candidates 400 + +# When ranked entry points were never traced, or flows were dropped at maxProcesses +npx gitnexus analyze --max-processes 80 +``` + +Equivalent `.gitnexusrc` keys: `maxProcesses`, `maxProcessBranching`, `maxProcessTraceDepth`, `maxEntryPointCandidates`. Equivalent env vars: `GITNEXUS_MAX_PROCESSES`, `GITNEXUS_MAX_PROCESS_BRANCHING`, `GITNEXUS_MAX_PROCESS_TRACE_DEPTH`, `GITNEXUS_MAX_ENTRY_POINT_CANDIDATES`. Precedence is CLI > `.gitnexusrc` > env > default. `0` is invalid, not unlimited. Changing these knobs re-runs process detection on the next `analyze` without `--force`. Raising them increases CPU and memory; this is not a heap-OOM fix. + ### Analyze reports a worker timeout Worker parse timeouts are recoverable. GitNexus retries stalled worker jobs with backoff, splits large jobs to isolate slow files, and quarantines a file that repeatedly crashes its worker (respawning the slot so the pool keeps going). If a large repository needs more time per worker job, use either: diff --git a/gitnexus/src/cli/analyze-config.ts b/gitnexus/src/cli/analyze-config.ts index 43c368a73..3d2219db2 100644 --- a/gitnexus/src/cli/analyze-config.ts +++ b/gitnexus/src/cli/analyze-config.ts @@ -102,6 +102,10 @@ const KEY_SPECS: Record = { workerTimeout: { target: 'workerTimeout', kind: 'numeric-string' }, walCheckpointThreshold: { target: 'walCheckpointThreshold', kind: 'numeric-string' }, workers: { target: 'workers', kind: 'numeric-string' }, + maxProcesses: { target: 'maxProcesses', kind: 'numeric-string' }, + maxProcessBranching: { target: 'maxProcessBranching', kind: 'numeric-string' }, + maxProcessTraceDepth: { target: 'maxProcessTraceDepth', kind: 'numeric-string' }, + maxEntryPointCandidates: { target: 'maxEntryPointCandidates', kind: 'numeric-string' }, embeddingThreads: { target: 'embeddingThreads', kind: 'numeric-string' }, embeddingBatchSize: { target: 'embeddingBatchSize', kind: 'numeric-string' }, embeddingSubBatchSize: { target: 'embeddingSubBatchSize', kind: 'numeric-string' }, diff --git a/gitnexus/src/cli/analyze-options.ts b/gitnexus/src/cli/analyze-options.ts index ffa439dab..97e969a25 100644 --- a/gitnexus/src/cli/analyze-options.ts +++ b/gitnexus/src/cli/analyze-options.ts @@ -115,6 +115,14 @@ export interface AnalyzeOptions { walCheckpointThreshold?: string; /** Parse worker pool size (>=1); 0 is rejected (no sequential mode). */ workers?: string; + /** Process-detection process cap. Positive integer string; `0` is invalid. */ + maxProcesses?: string; + /** Process-detection per-node branching cap. Positive integer string. */ + maxProcessBranching?: string; + /** Process-detection DFS depth cap. Positive integer string. */ + maxProcessTraceDepth?: string; + /** Ranked entry-point candidate pool. Positive integer string. */ + maxEntryPointCandidates?: string; embeddingThreads?: string; embeddingBatchSize?: string; embeddingSubBatchSize?: string; diff --git a/gitnexus/src/cli/analyze-watch.ts b/gitnexus/src/cli/analyze-watch.ts index 32a4de2f1..2c3186a24 100644 --- a/gitnexus/src/cli/analyze-watch.ts +++ b/gitnexus/src/cli/analyze-watch.ts @@ -22,6 +22,10 @@ import { import type { AnalyzeOptions } from './analyze-options.js'; import { ensureHeap } from './analyze.js'; import { cliError, cliInfo, cliWarn } from './cli-message.js'; +import { + formatInvalidProcessDetectionOverride, + parseProcessDetectionBudgetStrings, +} from '../core/ingestion/process-detection-budget.js'; import { WATCH_FULL_REFRESH_PATH, WatchRefreshQueue, @@ -164,6 +168,17 @@ export async function resolveWatchOptions( const workerPoolSize = positiveInteger(merged.workers, '--workers'); const workerTimeoutSeconds = positiveInteger(merged.workerTimeout, 'workerTimeout'); const maxFileSize = positiveInteger(merged.maxFileSize, 'maxFileSize', MAX_FILE_SIZE_KB); + const processDetection = parseProcessDetectionBudgetStrings( + { + maxProcesses: merged.maxProcesses, + maxProcessBranching: merged.maxProcessBranching, + maxProcessTraceDepth: merged.maxProcessTraceDepth, + maxEntryPointCandidates: merged.maxEntryPointCandidates, + }, + (flag, raw) => { + cliWarn(formatInvalidProcessDetectionOverride(flag, raw)); + }, + ); setEnvironment( 'GITNEXUS_MAX_FILE_SIZE', @@ -183,6 +198,10 @@ export async function resolveWatchOptions( registryName: merged.name, allowDuplicateName: merged.allowDuplicateName, workerPoolSize, + maxProcesses: processDetection.maxProcesses, + maxProcessBranching: processDetection.maxProcessBranching, + maxProcessTraceDepth: processDetection.maxProcessTraceDepth, + maxEntryPointCandidates: processDetection.maxEntryPointCandidates, fetchWrappers: merged.fetchWrappers, skipAgentsMd: true, skipSkills: true, diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 04efddfd8..dcaf55934 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -54,6 +54,12 @@ import type { AnalyzeOptions } from './analyze-options.js'; import { runFullAnalysis } from '../core/run-analyze.js'; import { getRuntimeFingerprint } from '../core/platform/capabilities.js'; import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js'; +import { + formatInvalidProcessDetectionOverride, + formatProcessDetectionBudgetBanner, + parseProcessDetectionBudgetStrings, + resolveProcessDetectionBudget, +} from '../core/ingestion/process-detection-budget.js'; import { warnMissingOptionalGrammars, getOptionalGrammarExtensions } from './optional-grammars.js'; import { glob } from 'glob'; import fs from 'fs/promises'; @@ -947,6 +953,18 @@ const analyzeCommandImpl = async ( workerPoolSize = parsedWorkers; } + const processDetectionFromFlags = parseProcessDetectionBudgetStrings( + { + maxProcesses: options.maxProcesses, + maxProcessBranching: options.maxProcessBranching, + maxProcessTraceDepth: options.maxProcessTraceDepth, + maxEntryPointCandidates: options.maxEntryPointCandidates, + }, + (flag, raw) => { + cliWarn(` ${formatInvalidProcessDetectionOverride(flag, raw)}\n`); + }, + ); + // Parse `--embeddings [limit]`: `true` → default cap, string → numeric cap // (0 disables the cap entirely). Validated up here so failures match the // sibling-validation pattern (exit before bar.start() — otherwise @@ -1236,6 +1254,12 @@ const analyzeCommandImpl = async ( if (maxFileSizeBanner) { console.log(`${maxFileSizeBanner}\n`); } + const processDetectionBanner = formatProcessDetectionBudgetBanner( + resolveProcessDetectionBudget(processDetectionFromFlags), + ); + if (processDetectionBanner) { + console.log(`${processDetectionBanner}\n`); + } // ── CLI progress bar setup ───────────────────────────────────────── const barOptions: cliProgress.Options & { terminal?: CliProgressTerminal } = { @@ -1376,6 +1400,10 @@ const analyzeCommandImpl = async ( // GITNEXUS_WORKER_POOL_SIZE env mutation. `undefined` defers to the // env / auto-formula fallback inside the pipeline. workerPoolSize, + maxProcesses: processDetectionFromFlags.maxProcesses, + maxProcessBranching: processDetectionFromFlags.maxProcessBranching, + maxProcessTraceDepth: processDetectionFromFlags.maxProcessTraceDepth, + maxEntryPointCandidates: processDetectionFromFlags.maxEntryPointCandidates, // Extra fetch-wrapper names from `.gitnexusrc` (#1589/#1852 residual); // forwarded to the routes phase consumer scan. fetchWrappers: options.fetchWrappers, diff --git a/gitnexus/src/cli/help-i18n.ts b/gitnexus/src/cli/help-i18n.ts index ede409763..10d69263f 100644 --- a/gitnexus/src/cli/help-i18n.ts +++ b/gitnexus/src/cli/help-i18n.ts @@ -72,6 +72,10 @@ const OPTION_DESCRIPTION_KEYS = { 'analyze|--worker-timeout ': 'help.option.analyze.workerTimeout', 'analyze|--wal-checkpoint-threshold ': 'help.option.analyze.walCheckpointThreshold', 'analyze|--workers ': 'help.option.analyze.workers', + 'analyze|--max-processes ': 'help.option.analyze.maxProcesses', + 'analyze|--max-process-branching ': 'help.option.analyze.maxProcessBranching', + 'analyze|--max-process-trace-depth ': 'help.option.analyze.maxProcessTraceDepth', + 'analyze|--max-entry-point-candidates ': 'help.option.analyze.maxEntryPointCandidates', 'analyze|--embedding-threads ': 'help.option.analyze.embeddingThreads', 'analyze|--embedding-batch-size ': 'help.option.analyze.embeddingBatchSize', 'analyze|--embedding-sub-batch-size ': 'help.option.analyze.embeddingSubBatchSize', diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 8dc7f45d7..1198fdafd 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -254,6 +254,14 @@ export const en = { 'LadybugDB WAL auto-checkpoint threshold in bytes during analyze (integer >= -1; default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).', 'help.option.analyze.workers': 'Parse worker pool size (>=1). Default: cores-1 capped at 16, auto-sized to the repo.', + 'help.option.analyze.maxProcesses': + 'Process-detection process cap (positive integer). Replaces the dynamic max(20, round(symbols/10)) formula. Default: dynamic.', + 'help.option.analyze.maxProcessBranching': + 'Process-detection per-node branching cap (positive integer). Default: 4.', + 'help.option.analyze.maxProcessTraceDepth': + 'Process-detection DFS depth cap (positive integer). Default: 10.', + 'help.option.analyze.maxEntryPointCandidates': + 'Ranked entry-point candidate pool (positive integer). Default: 200. Raise when the warning names this knob; doubling is the usual first raise.', 'help.option.analyze.embeddingThreads': 'Limit local ONNX embedding CPU threads', 'help.option.analyze.embeddingBatchSize': 'Number of nodes per embedding batch', 'help.option.analyze.embeddingSubBatchSize': 'Number of chunks per embedding model call', @@ -358,5 +366,5 @@ export const en = { 'help.identityCache.environment': '\nAnalyzer identity cache:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n Operator-trusted persistent cache for warm cross-process status. The directory must pre-exist, be outside the GitNexus package/build roots, and contain no symlink or junction components. Defaults remain fail-closed on platforms without POSIX ownership APIs.', 'help.analyze.environment': - '\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_STORAGE_PATH=/absolute/index Complete external index directory. Preserves the existing configuration semantics and overrides GITNEXUS_STORAGE_ROOT when both are set.\n GITNEXUS_STORAGE_ROOT=/absolute/root External index root; each repository uses an isolated -/ slot.\n GITNEXUS_CONTENT_RETENTION=full Source-text retention profile: full, symbol, or none. Default full.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 Retry per-attempt HTTP embedding timeouts through GITNEXUS_EMBEDDING_MAX_ATTEMPTS (default off; timeouts stay terminal).\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).', + '\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_STORAGE_PATH=/absolute/index Complete external index directory. Preserves the existing configuration semantics and overrides GITNEXUS_STORAGE_ROOT when both are set.\n GITNEXUS_STORAGE_ROOT=/absolute/root External index root; each repository uses an isolated -/ slot.\n GITNEXUS_CONTENT_RETENTION=full Source-text retention profile: full, symbol, or none. Default full.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 Retry per-attempt HTTP embedding timeouts through GITNEXUS_EMBEDDING_MAX_ATTEMPTS (default off; timeouts stay terminal).\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n GITNEXUS_MAX_PROCESSES=N Process-detection process cap (positive integer). Replaces the dynamic max(20, round(symbols/10)) formula. Distinct from query-time IMPACT_MAX_CHUNKS.\n GITNEXUS_MAX_PROCESS_BRANCHING=N Process-detection per-node branching cap. Default 4.\n GITNEXUS_MAX_PROCESS_TRACE_DEPTH=N Process-detection DFS depth cap. Default 10.\n GITNEXUS_MAX_ENTRY_POINT_CANDIDATES=N Ranked entry-point candidate pool. Default 200. Raise when the warning names this knob; doubling is the usual first raise.\n\nCLI flags take precedence over `.gitnexusrc`, which takes precedence over env vars, which take precedence over built-in defaults.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).', } as const; diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index bef315752..03f48adba 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -235,6 +235,12 @@ export const zhCN = { 'analyze 期间 LadybugDB WAL 自动 checkpoint 阈值(字节,整数 >= -1;默认:67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。', 'help.option.analyze.workers': '解析 worker 池大小(>=1)。默认:cores-1,最多 16,按仓库规模自适应。', + 'help.option.analyze.maxProcesses': + '流程检测的流程数量上限(正整数)。覆盖动态的 max(20, round(symbols/10)) 公式。默认:动态。', + 'help.option.analyze.maxProcessBranching': '流程检测的单节点分支上限(正整数)。默认:4。', + 'help.option.analyze.maxProcessTraceDepth': '流程检测的 DFS 深度上限(正整数)。默认:10。', + 'help.option.analyze.maxEntryPointCandidates': + '排序后的入口点候选池(正整数)。默认:200。仅在警告点名该上限时提高;那时通常先翻倍。', 'help.option.analyze.embeddingThreads': '限制本地 ONNX 嵌入 CPU 线程数', 'help.option.analyze.embeddingBatchSize': '每个嵌入批次的节点数', 'help.option.analyze.embeddingSubBatchSize': '每次嵌入模型调用的分块数', @@ -331,5 +337,5 @@ export const zhCN = { 'help.identityCache.environment': '\n分析器身份缓存:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n 由操作员明确信任的持久缓存,用于跨进程快速查询状态。目录必须预先存在、位于 GitNexus 包/构建根目录之外,且路径中不得包含符号链接或 junction。缺少 POSIX 所有权 API 的平台默认保持故障关闭。', 'help.analyze.environment': - '\n环境变量:\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值(KB)。默认 512,最大 32768。\n GITNEXUS_STORAGE_PATH=/absolute/index 完整外部索引目录。保留既有配置语义;与 GITNEXUS_STORAGE_ROOT 同时设置时优先使用。\n GITNEXUS_STORAGE_ROOT=/absolute/root 外部索引根目录;每个仓库使用独立的 <仓库名>-<规范路径哈希>/ 子目录。\n GITNEXUS_CONTENT_RETENTION=full 源码文本保留策略:full、symbol 或 none。默认 full。\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir 由操作员明确信任的持久分析器身份缓存;目录必须预先存在、位于包/构建根目录之外,且路径中不得包含符号链接或 junction。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1,最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N 线程池关闭时等待仍在原生代码中的已退役 worker 的最长时间(到达安全点后再终止,避免进程级 abort)。默认 30000。\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N C++ 捕获提取的每文件墙钟预算;超出后该文件保留部分捕获并输出警告。默认 20000。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 将单次 HTTP 嵌入超时纳入 GITNEXUS_EMBEDDING_MAX_ATTEMPTS 重试(默认关闭,超时仍为终止错误)。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n GITNEXUS_VECTOR_MAX_DISTANCE=N 语义/向量搜索接受的最大余弦距离(0 < N <= 2;超出则钳制为 2)。MCP 默认 0.6,其他路径默认 0.5。\n\n当参数和对应环境变量同时提供时,参数优先。\n\n提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771)。', + '\n环境变量:\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值(KB)。默认 512,最大 32768。\n GITNEXUS_STORAGE_PATH=/absolute/index 完整外部索引目录。保留既有配置语义;与 GITNEXUS_STORAGE_ROOT 同时设置时优先使用。\n GITNEXUS_STORAGE_ROOT=/absolute/root 外部索引根目录;每个仓库使用独立的 <仓库名>-<规范路径哈希>/ 子目录。\n GITNEXUS_CONTENT_RETENTION=full 源码文本保留策略:full、symbol 或 none。默认 full。\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir 由操作员明确信任的持久分析器身份缓存;目录必须预先存在、位于包/构建根目录之外,且路径中不得包含符号链接或 junction。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1,最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N 线程池关闭时等待仍在原生代码中的已退役 worker 的最长时间(到达安全点后再终止,避免进程级 abort)。默认 30000。\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N C++ 捕获提取的每文件墙钟预算;超出后该文件保留部分捕获并输出警告。默认 20000。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 将单次 HTTP 嵌入超时纳入 GITNEXUS_EMBEDDING_MAX_ATTEMPTS 重试(默认关闭,超时仍为终止错误)。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n GITNEXUS_VECTOR_MAX_DISTANCE=N 语义/向量搜索接受的最大余弦距离(0 < N <= 2;超出则钳制为 2)。MCP 默认 0.6,其他路径默认 0.5。\n GITNEXUS_MAX_PROCESSES=N 流程检测的流程数量上限(正整数)。覆盖动态的 max(20, round(symbols/10)) 公式。与查询时的 IMPACT_MAX_CHUNKS 无关。\n GITNEXUS_MAX_PROCESS_BRANCHING=N 流程检测的单节点分支上限。默认 4。\n GITNEXUS_MAX_PROCESS_TRACE_DEPTH=N 流程检测的 DFS 深度上限。默认 10。\n GITNEXUS_MAX_ENTRY_POINT_CANDIDATES=N 排序后的入口点候选池。默认 200。仅在警告点名该上限时提高;那时通常先翻倍。\n\nCLI 参数优先于 `.gitnexusrc`,后者优先于环境变量,环境变量优先于内置默认值。\n\n提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771)。', } satisfies EnglishMessages; diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 22bb3c0dd..a46606eca 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -163,6 +163,22 @@ program '--workers ', 'Parse worker pool size (>=1). Default: cores-1 capped at 16, auto-sized to the repo.', ) + .option( + '--max-processes ', + 'Process-detection process cap (positive integer). Replaces the dynamic max(20, round(symbols/10)) formula. Default: dynamic.', + ) + .option( + '--max-process-branching ', + 'Process-detection per-node branching cap (positive integer). Default: 4.', + ) + .option( + '--max-process-trace-depth ', + 'Process-detection DFS depth cap (positive integer). Default: 10.', + ) + .option( + '--max-entry-point-candidates ', + 'Ranked entry-point candidate pool (positive integer). Default: 200. Raise when the warning names this knob; doubling is the usual first raise.', + ) .option( '--spring-actuator ', 'Import local Spring Boot Actuator JSON snapshots (mappings, beans, conditions, ' + diff --git a/gitnexus/src/core/ingestion/pipeline-phases/processes.ts b/gitnexus/src/core/ingestion/pipeline-phases/processes.ts index e34e79d35..6be8839c3 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/processes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/processes.ts @@ -19,6 +19,12 @@ import type { ToolsOutput } from './tools.js'; import type { StructureOutput } from './structure.js'; import type { ParseOutput } from './parse.js'; import { processProcesses, type ProcessDetectionResult } from '../process-processor.js'; +import { + buildProcessDetectionPhaseConfig, + formatWholeFlowsMissingRemedies, + processDetectionEffectiveLimits, + resolveProcessDetectionBudget, +} from '../process-detection-budget.js'; import { generateId } from '../../../lib/utils.js'; import { routeNodeKey } from '../route-extractors/route-path.js'; import { isDev } from '../utils/env.js'; @@ -79,11 +85,29 @@ export const processesPhase: PipelinePhase = { stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: ctx.graph.nodeCount }, }); + const resolvedBudget = resolveProcessDetectionBudget( + { + maxProcesses: ctx.options?.maxProcesses, + maxProcessBranching: ctx.options?.maxProcessBranching, + maxProcessTraceDepth: ctx.options?.maxProcessTraceDepth, + maxEntryPointCandidates: ctx.options?.maxEntryPointCandidates, + }, + // Env is resolved in `runFullAnalysis` and threaded on PipelineOptions. + // The phase reads only those fields so unit tests stay isolated from + // the host environment. + {}, + ); let symbolCount = 0; - ctx.graph.forEachNode((n) => { - if (n.label !== 'File') symbolCount++; - }); - const dynamicMaxProcesses = computeDynamicMaxProcesses(symbolCount); + if (resolvedBudget.maxProcesses === undefined) { + ctx.graph.forEachNode((n) => { + if (n.label !== 'File') symbolCount++; + }); + } + const detectionConfig = buildProcessDetectionPhaseConfig( + resolvedBudget, + symbolCount, + computeDynamicMaxProcesses, + ); // R3-6: where the program reaches outward. Already collected by the parse // phase for FILE-level FETCHES/QUERIES edges; reused here at function @@ -123,7 +147,7 @@ export const processesPhase: PipelinePhase = { stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: ctx.graph.nodeCount }, }); }, - { maxProcesses: dynamicMaxProcesses, minSteps: 3 }, + detectionConfig, outwardActionSites, ); @@ -143,8 +167,8 @@ export const processesPhase: PipelinePhase = { // "unexplored entry points mean whole flows are missing, while a // depth-capped trace means a flow is present but shorter than it really is" // — and it is what keeps the line worth reading. Warning on every counter - // meant warning on every run: this phase overrides only `maxProcesses`, so - // at the shipped defaults (`maxBranching: 4`, `maxTraceDepth: 10`, + // meant warning on every run: at the shipped defaults (`maxBranching: 4`, + // `maxTraceDepth: 10`, // per-entry trace budget 12) `calleesDropped` fires for any function with // five callees, `tracesDepthCapped` for any chain deeper than ten, and // `walksCutByBudget` for any entry point with twelve paths under it. All @@ -171,6 +195,15 @@ export const processesPhase: PipelinePhase = { truncation.entryPointCandidatesDropped > 0 || truncation.entryPointsUnexplored > 0 || truncation.processesDropped > 0; + const effectiveLimits = processDetectionEffectiveLimits( + detectionConfig.maxProcesses, + resolvedBudget, + ); + const remedies = formatWholeFlowsMissingRemedies( + truncation, + effectiveLimits, + entryPointCandidates, + ); const shape = `${truncation.entryPointCandidatesDropped} of ${entryPointCandidates} candidate entry point(s) never ranked in, ` + `${truncation.entryPointsUnexplored} ranked entry point(s) never traced, ` + @@ -180,13 +213,13 @@ export const processesPhase: PipelinePhase = { `${truncation.walksCutByBudget} walk(s) cut by the per-entry trace budget.`; if (flowsMissing) { logger.warn( - { truncation }, + { truncation, effectiveLimits }, `[processes] ${processResult.stats.totalProcesses} flows reported, but whole flows are MISSING: ` + - `${shape} An absent flow does NOT mean the code path does not exist.`, + `${shape}${remedies} An absent flow does NOT mean the code path does not exist.`, ); } else if (truncation.truncated) { logger.debug( - { truncation }, + { truncation, effectiveLimits }, `[processes] ${processResult.stats.totalProcesses} flows reported; every flow found is present, ` + `but some are shorter than the code path they describe: ${shape}`, ); diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index d81b7116e..6a4f3456e 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -250,6 +250,15 @@ export interface PipelineOptions { * analyze invocations. */ workerPoolSize?: number; + /** + * Process-detection budget (#3313). Explicit `maxProcesses` replaces the + * dynamic `symbols / 10` formula; the other three replace compiled defaults. + * Unset fields keep shipped behavior. `0` is rejected upstream — not unlimited. + */ + maxProcesses?: number; + maxProcessBranching?: number; + maxProcessTraceDepth?: number; + maxEntryPointCandidates?: number; /** * Number of chunks whose file contents may be read into memory in * parallel while the worker pool is busy dispatching the current @@ -443,8 +452,8 @@ export const runPipelineFromRepo = async ( const propertyInference = scopeResolutionOutput.propertyInference; // Presence check, not `!skipGraphPhases`: phases can now be filtered out by - // any `enabledWhen` predicate (streamGraphEmit disables communities/processes - // too), and `getPhaseOutput` THROWS on a phase that was never resolved. Keying + // any `enabledWhen` predicate (`skipGraphPhases` drops communities/processes), + // and `getPhaseOutput` THROWS on a phase that was never resolved. Keying // off the options flag alone made every filtered-out combination crash here // rather than return undefined results. if (results.has('communities') && results.has('processes')) { diff --git a/gitnexus/src/core/ingestion/process-detection-budget.ts b/gitnexus/src/core/ingestion/process-detection-budget.ts new file mode 100644 index 000000000..12ce1c0d3 --- /dev/null +++ b/gitnexus/src/core/ingestion/process-detection-budget.ts @@ -0,0 +1,304 @@ +/** + * Analyze-time process-detection budget (#3313). + * + * Four knobs control how many execution flows `processProcesses` keeps: + * process count, per-node branching, trace depth, and the ranked entry-point + * pool. Precedence is CLI / explicit `AnalyzeOptions` > `.gitnexusrc` (already + * merged into those fields) > `GITNEXUS_*` env > built-in defaults. Unset + * `maxProcesses` keeps the dynamic `symbols / 10` formula. `0` is invalid, not + * unlimited. + */ + +import type { ProcessDetectionConfig, ProcessTruncationStats } from './process-processor.js'; +import { parsePositiveIntEnv } from './utils/env.js'; + +export const PROCESS_DETECTION_BUDGET_DEFAULTS = { + maxProcessBranching: 4, + maxProcessTraceDepth: 10, + maxEntryPointCandidates: 200, + minSteps: 3, +} as const; + +export const PROCESS_DETECTION_ENV = { + maxProcesses: 'GITNEXUS_MAX_PROCESSES', + maxProcessBranching: 'GITNEXUS_MAX_PROCESS_BRANCHING', + maxProcessTraceDepth: 'GITNEXUS_MAX_PROCESS_TRACE_DEPTH', + maxEntryPointCandidates: 'GITNEXUS_MAX_ENTRY_POINT_CANDIDATES', +} as const; + +export const PROCESS_DETECTION_CLI_FLAGS = { + maxProcesses: '--max-processes', + maxProcessBranching: '--max-process-branching', + maxProcessTraceDepth: '--max-process-trace-depth', + maxEntryPointCandidates: '--max-entry-point-candidates', +} as const; + +const BUDGET_KEYS = [ + 'maxProcesses', + 'maxProcessBranching', + 'maxProcessTraceDepth', + 'maxEntryPointCandidates', +] as const; + +export type ProcessDetectionBudgetKey = (typeof BUDGET_KEYS)[number]; + +export type ProcessDetectionBudgetFields = { + maxProcesses?: number; + maxProcessBranching?: number; + maxProcessTraceDepth?: number; + maxEntryPointCandidates?: number; +}; + +export type ProcessDetectionBudgetStrings = { + maxProcesses?: string; + maxProcessBranching?: string; + maxProcessTraceDepth?: string; + maxEntryPointCandidates?: string; +}; + +export type ProcessDetectionStamp = { + /** Explicit override, or `null` when this run used the dynamic formula. */ + maxProcesses: number | null; + maxProcessBranching: number; + maxProcessTraceDepth: number; + maxEntryPointCandidates: number; + /** + * In-place FTS park after a derived-layer rewrite (#3322). Missing stamp + + * defaults is a match, so recovery must persist a complete stamp that still + * mismatches until a successful analyze certifies the live Community/Process + * rows. Success writes omit this flag. + */ + uncertified?: true; +}; + +export type ResolvedProcessDetectionBudget = { + /** Set only when an explicit override won. */ + maxProcesses?: number; + maxProcessBranching: number; + maxProcessTraceDepth: number; + maxEntryPointCandidates: number; + overridden: { + maxProcesses: boolean; + maxProcessBranching: boolean; + maxProcessTraceDepth: boolean; + maxEntryPointCandidates: boolean; + }; +}; + +export type ProcessDetectionEffectiveLimits = { + maxProcesses: number; + maxProcessBranching: number; + maxProcessTraceDepth: number; + maxEntryPointCandidates: number; + /** + * Pre-entry gate: `processProcesses` does not start the next entry once + * collected traces already reach `maxProcesses * 2`. One started entry can + * still append every trace `traceFromEntryPoint` returns. + */ + maxProcessTraces: number; +}; + +export type InvalidBudgetHandler = (knob: string, raw: string) => void; + +/** Operator copy when a CLI/rc/env token is rejected. Next precedence still applies. */ +export const formatInvalidProcessDetectionOverride = (knob: string, raw: string): string => { + const next = knob.startsWith('GITNEXUS_') + ? 'the built-in default' + : 'the next source (env, then the built-in default)'; + return `${knob} must be a positive integer (got ${JSON.stringify(raw)}); ignoring it so ${next} applies.`; +}; + +export const parsePositiveIntegerOverride = ( + raw: string | number | undefined | null, + onInvalid?: (raw: string) => void, +): number | undefined => { + if (raw === undefined || raw === null) return undefined; + const text = typeof raw === 'number' ? String(raw) : raw; + const parsed = parsePositiveIntEnv(text); + if (parsed === undefined) onInvalid?.(typeof raw === 'number' ? text : text.trim()); + return parsed; +}; + +export const parseProcessDetectionBudgetStrings = ( + raw: ProcessDetectionBudgetStrings, + onInvalid?: InvalidBudgetHandler, +): ProcessDetectionBudgetFields => { + const out: ProcessDetectionBudgetFields = {}; + for (const key of BUDGET_KEYS) { + if (raw[key] === undefined) continue; + const parsed = parsePositiveIntegerOverride(raw[key], (invalid) => + onInvalid?.(PROCESS_DETECTION_CLI_FLAGS[key], invalid), + ); + if (parsed !== undefined) out[key] = parsed; + } + return out; +}; + +const readOverride = ( + options: ProcessDetectionBudgetFields, + env: NodeJS.ProcessEnv, + key: ProcessDetectionBudgetKey, + onInvalid?: InvalidBudgetHandler, +): number | undefined => { + const fromOptions = options[key]; + if (fromOptions !== undefined) { + const parsed = parsePositiveIntegerOverride(fromOptions, (invalid) => + onInvalid?.(PROCESS_DETECTION_CLI_FLAGS[key], invalid), + ); + if (parsed !== undefined) return parsed; + } + const envName = PROCESS_DETECTION_ENV[key]; + const fromEnv = env[envName]; + if (fromEnv === undefined) return undefined; + return parsePositiveIntegerOverride(fromEnv, (invalid) => onInvalid?.(envName, invalid)); +}; + +export const resolveProcessDetectionBudget = ( + options: ProcessDetectionBudgetFields = {}, + env: NodeJS.ProcessEnv = process.env, + onInvalid?: InvalidBudgetHandler, +): ResolvedProcessDetectionBudget => { + const maxProcesses = readOverride(options, env, 'maxProcesses', onInvalid); + const branching = readOverride(options, env, 'maxProcessBranching', onInvalid); + const depth = readOverride(options, env, 'maxProcessTraceDepth', onInvalid); + const entryPoints = readOverride(options, env, 'maxEntryPointCandidates', onInvalid); + return { + ...(maxProcesses === undefined ? {} : { maxProcesses }), + maxProcessBranching: branching ?? PROCESS_DETECTION_BUDGET_DEFAULTS.maxProcessBranching, + maxProcessTraceDepth: depth ?? PROCESS_DETECTION_BUDGET_DEFAULTS.maxProcessTraceDepth, + maxEntryPointCandidates: + entryPoints ?? PROCESS_DETECTION_BUDGET_DEFAULTS.maxEntryPointCandidates, + overridden: { + maxProcesses: maxProcesses !== undefined, + maxProcessBranching: branching !== undefined, + maxProcessTraceDepth: depth !== undefined, + maxEntryPointCandidates: entryPoints !== undefined, + }, + }; +}; + +export const hasProcessDetectionOverride = (resolved: ResolvedProcessDetectionBudget): boolean => + resolved.overridden.maxProcesses || + resolved.overridden.maxProcessBranching || + resolved.overridden.maxProcessTraceDepth || + resolved.overridden.maxEntryPointCandidates; + +export const toProcessDetectionStamp = ( + resolved: ResolvedProcessDetectionBudget, +): ProcessDetectionStamp => ({ + maxProcesses: resolved.maxProcesses ?? null, + maxProcessBranching: resolved.maxProcessBranching, + maxProcessTraceDepth: resolved.maxProcessTraceDepth, + maxEntryPointCandidates: resolved.maxEntryPointCandidates, +}); + +/** Complete stamp that always mismatches until the next successful analyze. */ +export const uncertifyProcessDetectionStamp = ( + recorded: ProcessDetectionStamp | undefined, +): ProcessDetectionStamp => ({ + maxProcesses: recorded?.maxProcesses ?? null, + maxProcessBranching: + recorded?.maxProcessBranching ?? PROCESS_DETECTION_BUDGET_DEFAULTS.maxProcessBranching, + maxProcessTraceDepth: + recorded?.maxProcessTraceDepth ?? PROCESS_DETECTION_BUDGET_DEFAULTS.maxProcessTraceDepth, + maxEntryPointCandidates: + recorded?.maxEntryPointCandidates ?? PROCESS_DETECTION_BUDGET_DEFAULTS.maxEntryPointCandidates, + uncertified: true, +}); + +const isCompleteStamp = ( + recorded: ProcessDetectionStamp | undefined, +): recorded is ProcessDetectionStamp => + recorded !== undefined && + (recorded.maxProcesses === null || + (typeof recorded.maxProcesses === 'number' && Number.isInteger(recorded.maxProcesses))) && + Number.isInteger(recorded.maxProcessBranching) && + Number.isInteger(recorded.maxProcessTraceDepth) && + Number.isInteger(recorded.maxEntryPointCandidates); + +export const processDetectionBudgetMismatch = ( + recorded: ProcessDetectionStamp | undefined, + resolved: ResolvedProcessDetectionBudget, +): boolean => { + if (recorded?.uncertified === true) return true; + if (!isCompleteStamp(recorded)) { + // Legacy meta: same defaults as today's shipped behavior stay a match so + // an upgrade backfills the stamp instead of re-detecting. Any explicit + // override is a mismatch — otherwise a budget-only raise on a pre-#3313 + // index would preserve the sampled Community/Process layer. + return hasProcessDetectionOverride(resolved); + } + const stamp = toProcessDetectionStamp(resolved); + return ( + recorded.maxProcesses !== stamp.maxProcesses || + recorded.maxProcessBranching !== stamp.maxProcessBranching || + recorded.maxProcessTraceDepth !== stamp.maxProcessTraceDepth || + recorded.maxEntryPointCandidates !== stamp.maxEntryPointCandidates + ); +}; + +export const buildProcessDetectionPhaseConfig = ( + resolved: ResolvedProcessDetectionBudget, + symbolCount: number, + computeDynamicMaxProcesses: (n: number) => number, +): Pick< + ProcessDetectionConfig, + 'maxProcesses' | 'maxBranching' | 'maxTraceDepth' | 'maxEntryPointCandidates' | 'minSteps' +> => ({ + maxProcesses: resolved.maxProcesses ?? computeDynamicMaxProcesses(symbolCount), + maxBranching: resolved.maxProcessBranching, + maxTraceDepth: resolved.maxProcessTraceDepth, + maxEntryPointCandidates: resolved.maxEntryPointCandidates, + minSteps: PROCESS_DETECTION_BUDGET_DEFAULTS.minSteps, +}); + +export const processDetectionEffectiveLimits = ( + maxProcesses: number, + resolved: ResolvedProcessDetectionBudget, +): ProcessDetectionEffectiveLimits => ({ + maxProcesses, + maxProcessBranching: resolved.maxProcessBranching, + maxProcessTraceDepth: resolved.maxProcessTraceDepth, + maxEntryPointCandidates: resolved.maxEntryPointCandidates, + maxProcessTraces: maxProcesses * 2, +}); + +export const formatWholeFlowsMissingRemedies = ( + truncation: Pick< + ProcessTruncationStats, + 'entryPointCandidatesDropped' | 'entryPointsUnexplored' | 'processesDropped' + >, + limits: ProcessDetectionEffectiveLimits, + observedEntryPointCandidates: number, +): string => { + const parts: string[] = []; + if (truncation.entryPointCandidatesDropped > 0) { + parts.push( + `${PROCESS_DETECTION_CLI_FLAGS.maxEntryPointCandidates} ` + + `(this run ranked ${observedEntryPointCandidates} candidate(s))`, + ); + } + if (truncation.entryPointsUnexplored > 0 || truncation.processesDropped > 0) { + parts.push( + `${PROCESS_DETECTION_CLI_FLAGS.maxProcesses} ` + + `(this run used ${limits.maxProcesses}; next entry is skipped once ` + + `collected traces reach ${limits.maxProcessTraces})`, + ); + } + return parts.length === 0 ? '' : ` Raise ${parts.join('; ')}.`; +}; + +export const formatProcessDetectionBudgetBanner = ( + resolved: ResolvedProcessDetectionBudget, +): string | null => { + if (!hasProcessDetectionOverride(resolved)) return null; + const maxProcesses = resolved.overridden.maxProcesses + ? String(resolved.maxProcesses) + : 'dynamic (max(20, round(symbols/10)))'; + return ( + ` Process-detection budget: maxProcesses=${maxProcesses}, ` + + `maxProcessBranching=${resolved.maxProcessBranching}, ` + + `maxProcessTraceDepth=${resolved.maxProcessTraceDepth}, ` + + `maxEntryPointCandidates=${resolved.maxEntryPointCandidates}` + ); +}; diff --git a/gitnexus/src/core/ingestion/process-processor.ts b/gitnexus/src/core/ingestion/process-processor.ts index 6a7cc4a10..fd75652c7 100644 --- a/gitnexus/src/core/ingestion/process-processor.ts +++ b/gitnexus/src/core/ingestion/process-processor.ts @@ -17,6 +17,7 @@ import { CommunityMembership } from './community-processor.js'; import { calculateEntryPointScore, isTestFile } from './entry-point-scoring.js'; import { SupportedLanguages } from 'gitnexus-shared'; import { isDev } from './utils/env.js'; +import { PROCESS_DETECTION_BUDGET_DEFAULTS } from './process-detection-budget.js'; import { logger } from '../logger.js'; // ============================================================================ @@ -25,16 +26,18 @@ import { logger } from '../logger.js'; export interface ProcessDetectionConfig { maxTraceDepth: number; // Maximum steps to trace (default: 10) - maxBranching: number; // Max branches to follow per node (default: 3) - maxProcesses: number; // Maximum processes to detect (default: 50) - minSteps: number; // Minimum steps for a valid process (default: 2) + maxBranching: number; // Max branches to follow per node (default: 4) + maxProcesses: number; // Maximum processes to detect (default: 75) + minSteps: number; // Minimum steps for a valid process (default: 3) + maxEntryPointCandidates: number; // Ranked entry-point pool (default: 200) } -const DEFAULT_CONFIG: ProcessDetectionConfig = { - maxTraceDepth: 10, - maxBranching: 4, +export const DEFAULT_CONFIG: ProcessDetectionConfig = { + maxTraceDepth: PROCESS_DETECTION_BUDGET_DEFAULTS.maxProcessTraceDepth, + maxBranching: PROCESS_DETECTION_BUDGET_DEFAULTS.maxProcessBranching, maxProcesses: 75, - minSteps: 3, // 3+ steps = genuine multi-hop flow (2-step is just "A calls B") + minSteps: PROCESS_DETECTION_BUDGET_DEFAULTS.minSteps, // 3+ steps = genuine multi-hop flow (2-step is just "A calls B") + maxEntryPointCandidates: PROCESS_DETECTION_BUDGET_DEFAULTS.maxEntryPointCandidates, }; // ============================================================================ @@ -89,7 +92,7 @@ export interface ProcessTruncationStats { truncated: boolean; /** * Scoring candidates that never reached the trace loop because - * `findEntryPoints` keeps only the top `ENTRY_POINT_CANDIDATE_LIMIT`. + * `findEntryPoints` keeps only the top `maxEntryPointCandidates`. * Counted BEFORE the slice, so it sees what `entryPointsFound` cannot. */ entryPointCandidatesDropped: number; @@ -166,11 +169,17 @@ export const processProcesses = async ( for (const n of knowledgeGraph.iterNodes()) nodeMap.set(n.id, n); // Declared before Step 1 because `findEntryPoints` has a ceiling of its own - // (see `ENTRY_POINT_CANDIDATE_LIMIT`) and reports it through the same record. + // (`cfg.maxEntryPointCandidates`, default 200) and reports it through the same record. const truncation = emptyTruncation(); // Step 1: Find entry points (functions that call others but have few callers) - const entryPoints = findEntryPoints(knowledgeGraph, reverseCallsEdges, callsEdges, truncation); + const entryPoints = findEntryPoints( + knowledgeGraph, + reverseCallsEdges, + callsEdges, + truncation, + cfg.maxEntryPointCandidates, + ); onProgress?.(`Found ${entryPoints.length} entry points, tracing flows...`, 20); @@ -199,7 +208,7 @@ export const processProcesses = async ( // the remainder are not "no flows found" — they were never looked at. // // Counted over the list `findEntryPoints` RETURNS, which is already capped at - // `ENTRY_POINT_CANDIDATE_LIMIT`; candidates beyond that cap are invisible here + // `maxEntryPointCandidates`; candidates beyond that cap are invisible here // by construction and are reported separately as // `entryPointCandidatesDropped`. truncation.entryPointsUnexplored = entryPoints.length - tracedEntryPoints; @@ -480,13 +489,6 @@ const buildReverseCallsGraph = (graph: KnowledgeGraph): AdjacencyList => { return adj; }; -/** - * How many ranked candidates survive to be traced. Everything below this line - * is discarded — see `ProcessTruncationStats.entryPointCandidatesDropped`, the - * counter that exists because this cap spent a release being invisible. - */ -const ENTRY_POINT_CANDIDATE_LIMIT = 200; - /** * Find functions/methods that are good entry points for tracing. * @@ -495,7 +497,9 @@ const ENTRY_POINT_CANDIDATE_LIMIT = 200; * 2. Export status (exported/public functions rank higher) * 3. Name patterns (handle*, on*, *Controller, etc.) * - * Test files are excluded entirely. + * Test files are excluded entirely. How many ranked candidates survive to be + * traced is `maxEntryPointCandidates` (default 200) — see + * `ProcessTruncationStats.entryPointCandidatesDropped`. */ const findEntryPoints = ( graph: KnowledgeGraph, @@ -509,6 +513,7 @@ const findEntryPoints = ( * to unwrap a counter to ask for entry points. */ truncation?: ProcessTruncationStats, + maxEntryPointCandidates: number = DEFAULT_CONFIG.maxEntryPointCandidates, ): string[] => { const symbolTypes = new Set(['Function', 'Method']); const entryPointCandidates: { @@ -576,15 +581,15 @@ const findEntryPoints = ( // Limit to prevent explosion — and SAY SO. This is the ceiling that decides // how much of a repository process detection ever looks at: on anything with - // more than 200 scoring candidates the reported flows are a sample of the - // top-ranked ones, and every downstream count (`entryPointsFound`, - // `entryPointsUnexplored`) is computed over the survivors, so none of them can - // see what was cut here. - if (truncation !== undefined && sorted.length > ENTRY_POINT_CANDIDATE_LIMIT) { - truncation.entryPointCandidatesDropped = sorted.length - ENTRY_POINT_CANDIDATE_LIMIT; + // more scoring candidates than `maxEntryPointCandidates` the reported flows + // are a sample of the top-ranked ones, and every downstream count + // (`entryPointsFound`, `entryPointsUnexplored`) is computed over the + // survivors, so none of them can see what was cut here. + if (truncation !== undefined && sorted.length > maxEntryPointCandidates) { + truncation.entryPointCandidatesDropped = sorted.length - maxEntryPointCandidates; } - return sorted.slice(0, ENTRY_POINT_CANDIDATE_LIMIT).map((c) => c.id); + return sorted.slice(0, maxEntryPointCandidates).map((c) => c.id); }; // ============================================================================ @@ -822,7 +827,7 @@ const compareOrderKeys = (a: string, b: string): number => (a < b ? -1 : a > b ? * joined strings, so an O(n log n) sort performs O(n log n) joins of * O(depth x id-length) characters each. * - * `n` is bounded here (`ENTRY_POINT_CANDIDATE_LIMIT` entry points x the + * `n` is bounded here (`maxEntryPointCandidates` entry points x the * per-entry trace budget), so the cost is small and once-per-analyze: measured * at the ceiling, 23,851 comparisons performed 70,524 joins, and end-to-end * `processProcesses` at 80,000 functions / 640k CALLS went 456 -> 555 ms. It is diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index d8d16484a..c2d7b485d 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -198,6 +198,13 @@ import { nodeTablesForIncrementalDelete, shouldPreservePersistedDerivedGraph, } from './incremental/derived-writeback.js'; +import { + formatInvalidProcessDetectionOverride, + processDetectionBudgetMismatch, + resolveProcessDetectionBudget, + toProcessDetectionStamp, + uncertifyProcessDetectionStamp, +} from './ingestion/process-detection-budget.js'; import { NODE_TABLES } from './lbug/schema.js'; import { loadParseCache, @@ -485,8 +492,9 @@ export interface AnalyzeOptions { pdgEmitChunkSize?: number; /** Streamed structural graph emit (#2680). Honored only on a full rebuild * (`force === true`). May also be enabled via `GITNEXUS_STREAM_GRAPH_EMIT`. - * Trades community detection, process extraction and PDG taint summaries for - * a ~2.9x reduction of in-memory graph heap. */ + * The sink answers a complete relationship read, so community detection, + * process extraction, and PDG taint summaries still run; streaming reduces + * in-memory graph heap (~2.9x) by keeping those edges on disk. */ streamGraphEmit?: boolean; /** * Default branch threaded into generated AGENTS.md / CLAUDE.md so the @@ -528,6 +536,16 @@ export interface AnalyzeOptions { * removed); `undefined` defers to the env / auto-formula fallback. */ workerPoolSize?: number; + /** + * Process-detection budget overrides (#3313). Threaded to + * `PipelineOptions` without mutating `process.env`. Unset fields fall + * back to `GITNEXUS_*` env, then shipped defaults / the dynamic + * `maxProcesses` formula. + */ + maxProcesses?: number; + maxProcessBranching?: number; + maxProcessTraceDepth?: number; + maxEntryPointCandidates?: number; /** * Extra fetch-wrapper function names to treat as HTTP consumers, forwarded to * `PipelineOptions.fetchWrappers` (#1589/#1852 residual). Sourced from the CLI @@ -2066,13 +2084,37 @@ async function runFullAnalysisInner( options = { ...options, force: true }; } + // Process-detection budget (#3313). Resolve CLI/options then env here so + // MCP/server jobs honor GITNEXUS_* without a CLI merge. Compare against + // the persisted stamp BEFORE the already-up-to-date fast path: a clean + // same-commit raise must re-detect flows rather than return the sampled + // index. Does NOT set force — incremental empty-diff + skip derived + // preserve is enough. + const processDetectionBudget = resolveProcessDetectionBudget( + { + maxProcesses: options.maxProcesses, + maxProcessBranching: options.maxProcessBranching, + maxProcessTraceDepth: options.maxProcessTraceDepth, + maxEntryPointCandidates: options.maxEntryPointCandidates, + }, + process.env, + (knob, raw) => { + log(formatInvalidProcessDetectionOverride(knob, raw)); + }, + ); + const processDetectionMismatch = processDetectionBudgetMismatch( + existingMeta?.processDetection, + processDetectionBudget, + ); + // ── Early-return: already up to date ────────────────────────────── if ( existingMeta && !existingMeta.embeddingCheckpoint && !options.force && existingMeta.lastCommit === currentCommit && - !ftsModeChanged + !ftsModeChanged && + !processDetectionMismatch ) { // Non-git folders have currentCommit = '' — always rebuild since we can't detect changes if (currentCommit !== '') { @@ -2119,6 +2161,8 @@ async function runFullAnalysisInner( // later read on a host where it loads — which is a legitimate, common // state, and the invariant `analyzer-identity-cli.test.ts` pins. if (!dirty && !healUnregistered) { + const processDetectionStamp = + existingMeta.processDetection ?? toProcessDetectionStamp(processDetectionBudget); if (options.registryName) { await registerRepo(repoPath, existingMeta, { name: options.registryName, @@ -2170,7 +2214,11 @@ async function runFullAnalysisInner( // documented Docker :ro workflow (#1549) — degrades to a warning. try { await adoptFlatBranchLabel(repoPath, branchLabel, storagePath); - await saveMeta(metaDir, { ...existingMeta, branch: branchLabel }); + await saveMeta(metaDir, { + ...existingMeta, + branch: branchLabel, + processDetection: processDetectionStamp, + }); } catch (err) { // EACCES/EPERM also arise from ownership problems and transient // Windows locks, so keep the real error visible alongside the @@ -2183,12 +2231,26 @@ async function runFullAnalysisInner( // Discriminator-only restamp (flag↔env). `existingMeta` already // carries the folded skipReason; persist it without a write plan. try { - await saveMeta(metaDir, existingMeta); + await saveMeta(metaDir, { + ...existingMeta, + processDetection: processDetectionStamp, + }); } catch (err) { log( `Warning: could not restamp the FTS skip reason (${formatMetaWriteFailureReason(err)}); will retry on the next run.`, ); } + } else if (!existingMeta.processDetection) { + try { + await saveMeta(metaDir, { + ...existingMeta, + processDetection: processDetectionStamp, + }); + } catch (err) { + log( + `Warning: could not backfill the process-detection stamp (${formatMetaWriteFailureReason(err)}); will retry on the next run.`, + ); + } } await ensureGitNexusIgnored(repoPath, storagePath); return { @@ -2375,6 +2437,16 @@ async function runFullAnalysisInner( { parseCache, workerPoolSize: options.workerPoolSize, + maxProcesses: processDetectionBudget.maxProcesses, + maxProcessBranching: processDetectionBudget.overridden.maxProcessBranching + ? processDetectionBudget.maxProcessBranching + : undefined, + maxProcessTraceDepth: processDetectionBudget.overridden.maxProcessTraceDepth + ? processDetectionBudget.maxProcessTraceDepth + : undefined, + maxEntryPointCandidates: processDetectionBudget.overridden.maxEntryPointCandidates + ? processDetectionBudget.maxEntryPointCandidates + : undefined, // CFG/PDG opt-in (#2081 M1). PipelineOptions.pdg fans out to the worker // build gate (workerData.pdg) and the scope-resolution emit gate. pdg: options.pdg === true, @@ -2506,7 +2578,8 @@ async function runFullAnalysisInner( skipDerivedGraphPhases && isIncremental && !!hashDiff && - shouldPreservePersistedDerivedGraph(hashDiff); + shouldPreservePersistedDerivedGraph(hashDiff) && + !processDetectionMismatch; if (skipDerivedGraphPhases && !preserveDerivedLayer) { progress('communities', 58, 'Detecting code communities and flows...'); await pipelineResult.runDeferredDerivedPhases?.(); @@ -2589,17 +2662,21 @@ async function runFullAnalysisInner( ); // Set the dirty flag BEFORE any destructive DB mutation. Cleared on // success at the meta-save step. Scoped to this branch's meta.json. - const now = Date.now(); - await saveMeta(metaDir, { - ...existingMeta!, - incrementalInProgress: { - startedAt: now, - updatedAt: now, - phase: 'pre-write', - toWriteCount: hashDiff.toWrite.length, - directWriteCount: hashDiff.toWrite.length, - }, - }); + // POSIX atomic incremental mutates the copy, so a live dirty stamp would + // force-rebuild a healthy index after a crash before swap. + if (!atomicIncremental) { + const now = Date.now(); + await saveMeta(metaDir, { + ...existingMeta!, + incrementalInProgress: { + startedAt: now, + updatedAt: now, + phase: 'pre-write', + toWriteCount: hashDiff.toWrite.length, + directWriteCount: hashDiff.toWrite.length, + }, + }); + } if (atomicIncremental) { // Stage the live index into the temp so the in-place delete/writeback // below mutates the COPY, and the end-of-run swap publishes it atomically. @@ -3516,6 +3593,11 @@ async function runFullAnalysisInner( lastCommit: '', indexedAt: new Date().toISOString(), }; + // #3322: persist uncertified *before* CREATE_FTS_INDEX. Park keeps this + // stamp; it must not invent one on every FTS-only crash. Missing stamp + + // shipped defaults is a match, so a budget-mismatch derived rewrite that + // dies in FTS would otherwise recertify the rewritten Community/Process + // rows on a flagless retry. await saveMeta(metaDir, { ...base, incrementalInProgress: buildFtsDirtyStamp({ @@ -3523,6 +3605,11 @@ async function runFullAnalysisInner( writePlan: 'in-place', checkpointSucceeded: boundaryCheckpointSucceeded, }), + ...(processDetectionMismatch + ? { + processDetection: uncertifyProcessDetectionStamp(base.processDetection), + } + : {}), }); } @@ -4529,6 +4616,7 @@ async function runFullAnalysisInner( // stamp after an on→off flip; the next pdgModeMismatch then compares // off==off and incremental eligibility is restored. pdg: resolvePdgConfig(options), + processDetection: toProcessDetectionStamp(processDetectionBudget), }; // Re-resolve at the commit boundary. Long analyses can overlap an npm // upgrade, rebuilt dist tree, or native dependency replacement; stamping diff --git a/gitnexus/src/storage/repo-meta.ts b/gitnexus/src/storage/repo-meta.ts index 159e6a4a8..db7b2a7a7 100644 --- a/gitnexus/src/storage/repo-meta.ts +++ b/gitnexus/src/storage/repo-meta.ts @@ -617,6 +617,22 @@ export interface RepoMeta { */ hasCallSummary?: boolean; }; + /** + * The process-detection budget this index's Community/Process rows were + * built under (#3313). Compared on the next analyze so a budget-only + * config change re-detects flows without `--force`. `maxProcesses: null` + * means the dynamic `symbols / 10` formula. Absent on pre-#3313 metas: + * that absence matches default/dynamic knobs (backfill) and mismatches + * any explicit override. + */ + processDetection?: { + maxProcesses: number | null; + maxProcessBranching: number; + maxProcessTraceDepth: number; + maxEntryPointCandidates: number; + /** Live Community/Process rows are not certified under this stamp (#3322). */ + uncertified?: true; + }; } /** diff --git a/gitnexus/test/integration/cli-limit-e2e.test.ts b/gitnexus/test/integration/cli-limit-e2e.test.ts index 65652260b..ec7a0d08c 100644 --- a/gitnexus/test/integration/cli-limit-e2e.test.ts +++ b/gitnexus/test/integration/cli-limit-e2e.test.ts @@ -32,12 +32,20 @@ const FIXTURE_SRC = path.resolve(testDir, '..', 'fixtures', 'mini-repo'); let MINI_REPO: string; let tmpParent: string; let suiteGitnexusHome: string; +/** False when setup analyze fell back to `--skip-fts` after CREATE_FTS_INDEX native-aborted. */ +let ftsIndexed = false; function cliEnv(extraEnv: Record = {}) { return { ...process.env, GITNEXUS_HOME: suiteGitnexusHome, NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), + // Cold parse-worker loads every tree-sitter grammar before the ready + // handshake. The default 5s budget classifies that as a deterministic + // crash-loop on a loaded WSL/CI host (status 1) or the 60s spawnSync + // timeout kills the child first (status null). Sibling integration + // suites pin 60s. + GITNEXUS_WORKER_READY_TIMEOUT_MS: process.env.GITNEXUS_WORKER_READY_TIMEOUT_MS || '60000', ...extraEnv, }; } @@ -52,6 +60,22 @@ function runCliRaw(extraArgs: string[], cwd: string, timeoutMs = 30000) { }); } +function isNativeAbort(result: ReturnType): boolean { + return ( + result.signal === 'SIGSEGV' || + result.signal === 'SIGABRT' || + result.signal === 'SIGBUS' || + result.status === 139 + ); +} + +function isFatalAnalyzeHarness(result: ReturnType): boolean { + return ( + result.stderr?.includes('Worker script not found') === true || + result.stderr?.includes('deterministic crash-loop') === true + ); +} + /** * Parse stdout as JSON, returning null on failure (e.g., text output). */ @@ -116,14 +140,39 @@ beforeAll(() => { }, }); - // Run analyze to populate .gitnexus/ index (required for all tool commands) - const analyzeResult = runCliRaw(['analyze', '--force'], MINI_REPO, 60000); - if (analyzeResult.status !== 0) { + // Index once so every --limit command has a registered repo. Match cli-e2e: + // a tiny fixture analyzes in seconds on a quiet machine, but spawnSync + // status null is SIGTERM from the timeout under load (not an analyze + // exit). Retry timeouts; alreadyUpToDate makes a repeat cheap. + // + // CREATE_FTS_INDEX can SIGSEGV the analyze process on some WSL/native + // hosts (status null, signal SIGSEGV) even when `doctor` reports FTS + // LOAD-able. Do not retry that path — rebuild with --skip-fts so graph + // tools still run. query --limit needs BM25 and is skipped in that case. + let analyzeResult: ReturnType | undefined; + for (let attempt = 0; attempt < 3; attempt++) { + analyzeResult = runCliRaw(['analyze', '--force'], MINI_REPO, 90_000); + if (analyzeResult.status === 0) { + ftsIndexed = true; + break; + } + if (isFatalAnalyzeHarness(analyzeResult) || isNativeAbort(analyzeResult)) break; + } + if ( + analyzeResult && + !ftsIndexed && + isNativeAbort(analyzeResult) && + !isFatalAnalyzeHarness(analyzeResult) + ) { + analyzeResult = runCliRaw(['analyze', '--force', '--skip-fts'], MINI_REPO, 90_000); + } + if (!analyzeResult || analyzeResult.status !== 0) { + const err = analyzeResult?.error; throw new Error( - `Analyze failed (status ${analyzeResult.status}):\nstdout: ${analyzeResult.stdout}\nstderr: ${analyzeResult.stderr}`, + `Analyze failed (status ${analyzeResult?.status}, signal ${analyzeResult?.signal}, error ${err?.message ?? 'none'}):\nstdout: ${analyzeResult?.stdout}\nstderr: ${analyzeResult?.stderr}`, ); } -}); +}, 300_000); afterAll(() => { if (tmpParent) cleanupTempDirSync(tmpParent); @@ -357,6 +406,18 @@ describe('CLI --limit flag E2E', () => { // ─── query ────────────────────────────────────────────────────────────── describe('query --limit', () => { + beforeEach((ctx) => { + if (ftsIndexed) return; + if (process.env.GITNEXUS_REQUIRE_FTS === '1') { + throw new Error( + 'GITNEXUS_REQUIRE_FTS=1 but setup analyze native-aborted during CREATE_FTS_INDEX; ' + + 'query --limit cannot be verified without BM25.', + ); + } + ctx.skip( + 'query --limit needs BM25; CREATE_FTS_INDEX native-aborted and analyze fell back to --skip-fts', + ); + }); it('truncates processes to --limit 1', () => { // "message" matches logMessage / createLogEntry / formatLogEntry → 4 processes const limited = runJson([ diff --git a/gitnexus/test/integration/cli/update-notice.test.ts b/gitnexus/test/integration/cli/update-notice.test.ts index 0887d1c46..7e6171549 100644 --- a/gitnexus/test/integration/cli/update-notice.test.ts +++ b/gitnexus/test/integration/cli/update-notice.test.ts @@ -139,11 +139,16 @@ describe('CLI update notice subprocess behavior', () => { preload, `import fs from 'node:fs'; Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true }); -globalThis.fetch = async () => { +// Mark the detached child at --import time, before tsx compiles the CLI. +// Writing this from fetch() raced a 30s poll against cold boot + lock +// acquisition on a loaded default-project worker (status: poll timeout). +if (process.argv.includes('__update-check')) { fs.writeFileSync(${JSON.stringify(started)}, ''); +} +globalThis.fetch = async () => { // Bounded so an abandoned child (test failed before releasing, temp home // already deleted) still exits instead of spinning forever. - const deadline = Date.now() + 60_000; + const deadline = Date.now() + 90_000; while (!fs.existsSync(${JSON.stringify(release)}) && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 25)); } @@ -176,19 +181,21 @@ globalThis.fetch = async () => { }); }); - // The parent already exited above, so reaching a still-parked child proves - // the refresh outlived it and was never awaited. + // The parent already exited above. `started` is written by the child's + // --import hook, so this wait is "did the detached process actually + // start?" not "did tsx finish compiling the CLI?" The fetch mock still + // parks until `release` so the cache cannot appear before we unblock it. const cache = path.join(home, 'update-check.json'); await expect.poll(() => fs.existsSync(started), { timeout: 30_000, interval: 50 }).toBe(true); expect(fs.existsSync(cache)).toBe(false); fs.writeFileSync(release, ''); - await expect.poll(() => fs.existsSync(cache), { timeout: 30_000, interval: 50 }).toBe(true); + await expect.poll(() => fs.existsSync(cache), { timeout: 60_000, interval: 50 }).toBe(true); expect(JSON.parse(fs.readFileSync(cache, 'utf8'))).toMatchObject({ latestVersion: '99.0.0', registry: 'https://registry.npmjs.org', }); - }, 90_000); + }, 120_000); it('prints the localized notice on a forced-TTY stderr and keeps stdout clean', () => { const home = tempHome(); diff --git a/gitnexus/test/unit/analyze-config.test.ts b/gitnexus/test/unit/analyze-config.test.ts index 890877835..a4245f6c5 100644 --- a/gitnexus/test/unit/analyze-config.test.ts +++ b/gitnexus/test/unit/analyze-config.test.ts @@ -133,6 +133,33 @@ describe('analyze-config (.gitnexusrc support, #243)', () => { expect(() => loadAnalyzeConfig(dir)).toThrow(/Unknown key "defalutBranch"/); }); + it('parses process-detection budget keys as numeric strings (#3313)', async () => { + await writeRc( + JSON.stringify({ + maxProcesses: 40, + maxProcessBranching: '2', + maxProcessTraceDepth: 8, + maxEntryPointCandidates: 400, + }), + ); + expect(loadAnalyzeConfig(dir)).toEqual({ + maxProcesses: '40', + maxProcessBranching: '2', + maxProcessTraceDepth: '8', + maxEntryPointCandidates: '400', + }); + }); + + it('lets a nested analyze block override flat process-detection keys (#3313)', async () => { + await writeRc( + JSON.stringify({ + maxProcesses: 80, + analyze: { maxProcesses: 25 }, + }), + ); + expect(loadAnalyzeConfig(dir)).toEqual({ maxProcesses: '25' }); + }); + it('accepts embeddingBaseUrl / embeddingModel but rejects embeddingDims (CLI/env-only)', async () => { // URL + MODEL are read lazily at runtime, so they are valid config keys. await writeRc(JSON.stringify({ embeddingBaseUrl: 'http://h/v1', embeddingModel: 'm' })); diff --git a/gitnexus/test/unit/analyze-gitnexusrc.test.ts b/gitnexus/test/unit/analyze-gitnexusrc.test.ts index 1909a284c..081b3664c 100644 --- a/gitnexus/test/unit/analyze-gitnexusrc.test.ts +++ b/gitnexus/test/unit/analyze-gitnexusrc.test.ts @@ -237,4 +237,18 @@ describe('analyzeCommand .gitnexusrc wiring (#243)', () => { expect(refreshBaseRefLineMock).toHaveBeenCalledTimes(1); expect(refreshBaseRefLineMock).toHaveBeenCalledWith(dir, 'develop', expect.any(Object)); }); + + it('threads .gitnexusrc process-detection knobs and lets CLI win (AE3)', async () => { + await writeRc({ maxProcesses: '40', maxEntryPointCandidates: 300 }); + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(dir, { maxProcesses: '25' }); + + expect(runFullAnalysisMock.mock.calls[0][1]).toEqual( + expect.objectContaining({ + maxProcesses: 25, + maxEntryPointCandidates: 300, + }), + ); + }); }); diff --git a/gitnexus/test/unit/analyze-process-budget.test.ts b/gitnexus/test/unit/analyze-process-budget.test.ts new file mode 100644 index 000000000..c186ab3f4 --- /dev/null +++ b/gitnexus/test/unit/analyze-process-budget.test.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const runFullAnalysisMock = vi.fn(); + +vi.mock('../../src/core/run-analyze.js', () => ({ + runFullAnalysis: runFullAnalysisMock, +})); + +vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ + closeLbug: vi.fn(async () => undefined), + closeLbugBeforeExit: vi.fn(async () => undefined), + isLbugReady: vi.fn(() => false), +})); + +vi.mock('../../src/storage/repo-manager.js', () => ({ + getStoragePaths: vi.fn(() => ({ storagePath: '.gitnexus', lbugPath: '.gitnexus/lbug' })), + getGlobalRegistryPath: vi.fn(() => 'registry.json'), + RegistryNameCollisionError: class RegistryNameCollisionError extends Error {}, + AnalysisNotFinalizedError: class AnalysisNotFinalizedError extends Error {}, + assertAnalysisFinalized: vi.fn(async () => undefined), +})); + +vi.mock('../../src/storage/git.js', () => ({ + getGitRoot: vi.fn(() => '/repo'), + hasGitDir: vi.fn(() => true), +})); + +vi.mock('../../src/core/ingestion/utils/max-file-size.js', () => ({ + getMaxFileSizeBannerMessage: vi.fn(() => null), +})); + +describe('analyzeCommand process-detection budget (#3313)', () => { + const ORIGINAL_NODE_OPTIONS = process.env.NODE_OPTIONS; + + beforeEach(() => { + vi.resetModules(); + runFullAnalysisMock.mockReset(); + process.exitCode = undefined; + process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim(); + }); + + afterEach(() => { + if (ORIGINAL_NODE_OPTIONS === undefined) { + delete process.env.NODE_OPTIONS; + } else { + process.env.NODE_OPTIONS = ORIGINAL_NODE_OPTIONS; + } + vi.unstubAllEnvs(); + }); + + const upToDate = { + repoName: 'repo', + repoPath: '/repo', + stats: {}, + alreadyUpToDate: true, + }; + + it('threads the four CLI flags through runFullAnalysis without env mutation', async () => { + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + runFullAnalysisMock.mockResolvedValue(upToDate); + const before = { + processes: process.env.GITNEXUS_MAX_PROCESSES, + branching: process.env.GITNEXUS_MAX_PROCESS_BRANCHING, + depth: process.env.GITNEXUS_MAX_PROCESS_TRACE_DEPTH, + entries: process.env.GITNEXUS_MAX_ENTRY_POINT_CANDIDATES, + }; + + await analyzeCommand(undefined, { + maxProcesses: '25', + maxProcessBranching: '2', + maxProcessTraceDepth: '8', + maxEntryPointCandidates: '400', + }); + + expect(runFullAnalysisMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + maxProcesses: 25, + maxProcessBranching: 2, + maxProcessTraceDepth: 8, + maxEntryPointCandidates: 400, + }), + expect.any(Object), + ); + expect(process.env.GITNEXUS_MAX_PROCESSES).toBe(before.processes); + expect(process.env.GITNEXUS_MAX_PROCESS_BRANCHING).toBe(before.branching); + expect(process.env.GITNEXUS_MAX_PROCESS_TRACE_DEPTH).toBe(before.depth); + expect(process.env.GITNEXUS_MAX_ENTRY_POINT_CANDIDATES).toBe(before.entries); + }); + + it.each(['0', 'abc', '-4'])( + 'warns and continues when --max-processes is %s (AE5)', + async (value) => { + const { _captureLogger } = await import('../../src/core/logger.js'); + const cap = _captureLogger(); + try { + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + runFullAnalysisMock.mockResolvedValue(upToDate); + + await analyzeCommand(undefined, { maxProcesses: value }); + + expect(process.exitCode).toBeUndefined(); + expect(runFullAnalysisMock).toHaveBeenCalledWith( + expect.any(String), + expect.not.objectContaining({ maxProcesses: expect.any(Number) }), + expect.any(Object), + ); + expect( + cap.records().some((r) => { + const msg = String(r.msg ?? ''); + return ( + msg.includes('--max-processes must be a positive integer') && + msg.includes('next source (env, then the built-in default)') + ); + }), + ).toBe(true); + } finally { + cap.restore(); + } + }, + ); + + it('leaves option fields unset so runFullAnalysis can honor env-only overrides', async () => { + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + runFullAnalysisMock.mockResolvedValue(upToDate); + vi.stubEnv('GITNEXUS_MAX_PROCESSES', '80'); + + await analyzeCommand(undefined, {}); + + const opts = runFullAnalysisMock.mock.calls[0][1] as { maxProcesses?: number }; + expect(opts.maxProcesses).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/cli-index-help.test.ts b/gitnexus/test/unit/cli-index-help.test.ts index 9229f5444..b9862b87f 100644 --- a/gitnexus/test/unit/cli-index-help.test.ts +++ b/gitnexus/test/unit/cli-index-help.test.ts @@ -196,10 +196,13 @@ describe('CLI help surface', () => { expect(result.stdout).toContain('外部索引根目录'); expect(result.stdout).toContain('GITNEXUS_CONTENT_RETENTION=full'); expect(result.stdout).toContain('源码文本保留策略'); - expect(result.stdout).toContain('当参数和对应环境变量同时提供时,参数优先。'); + expect(result.stdout).toContain( + 'CLI 参数优先于 `.gitnexusrc`,后者优先于环境变量,环境变量优先于内置默认值。', + ); expect(result.stdout).toContain('提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。'); expect(result.stdout).not.toContain('Environment variables:'); expect(result.stdout).not.toContain('Flags override the corresponding env vars'); + expect(result.stdout).not.toContain('当参数和对应环境变量同时提供时,参数优先。'); }); it('analyze help documents the external storage root layout', () => { @@ -213,6 +216,10 @@ describe('CLI help surface', () => { expect(result.stdout).toContain('GITNEXUS_CONTENT_RETENTION=full'); expect(result.stdout).toContain('Source-text retention profile'); expect(result.stdout).toContain('-/'); + expect(result.stdout).toContain( + 'CLI flags take precedence over `.gitnexusrc`, which takes precedence over env vars, which take precedence over built-in defaults.', + ); + expect(result.stdout).not.toContain('Flags override the corresponding env vars'); }); it('query help keeps advanced search options without importing analyze deps', () => { diff --git a/gitnexus/test/unit/process-detection-budget.test.ts b/gitnexus/test/unit/process-detection-budget.test.ts new file mode 100644 index 000000000..bb0719e88 --- /dev/null +++ b/gitnexus/test/unit/process-detection-budget.test.ts @@ -0,0 +1,235 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + buildProcessDetectionPhaseConfig, + formatInvalidProcessDetectionOverride, + formatProcessDetectionBudgetBanner, + formatWholeFlowsMissingRemedies, + parsePositiveIntegerOverride, + parseProcessDetectionBudgetStrings, + processDetectionBudgetMismatch, + processDetectionEffectiveLimits, + resolveProcessDetectionBudget, + toProcessDetectionStamp, + uncertifyProcessDetectionStamp, +} from '../../src/core/ingestion/process-detection-budget.js'; + +describe('parsePositiveIntegerOverride', () => { + it('accepts positive integers and rejects 0 / non-integers', () => { + const invalid: string[] = []; + expect(parsePositiveIntegerOverride('25')).toBe(25); + expect(parsePositiveIntegerOverride(40)).toBe(40); + expect(parsePositiveIntegerOverride('0', (raw) => invalid.push(raw))).toBeUndefined(); + expect(parsePositiveIntegerOverride('abc', (raw) => invalid.push(raw))).toBeUndefined(); + expect(parsePositiveIntegerOverride('-3', (raw) => invalid.push(raw))).toBeUndefined(); + expect(parsePositiveIntegerOverride('', (raw) => invalid.push(raw))).toBeUndefined(); + expect(invalid).toEqual(['0', 'abc', '-3', '']); + }); +}); + +describe('resolveProcessDetectionBudget (#3313)', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('keeps shipped defaults when nothing is set', () => { + const resolved = resolveProcessDetectionBudget({}, {}); + expect(resolved.maxProcesses).toBeUndefined(); + expect(resolved.maxProcessBranching).toBe(4); + expect(resolved.maxProcessTraceDepth).toBe(10); + expect(resolved.maxEntryPointCandidates).toBe(200); + expect(resolved.overridden).toEqual({ + maxProcesses: false, + maxProcessBranching: false, + maxProcessTraceDepth: false, + maxEntryPointCandidates: false, + }); + }); + + it('lets an invalid option fall through to env instead of claiming a hard default', () => { + const invalid: Array<[string, string]> = []; + const resolved = resolveProcessDetectionBudget( + { maxProcesses: 0 }, + { GITNEXUS_MAX_PROCESSES: '80' }, + (knob, raw) => invalid.push([knob, raw]), + ); + expect(resolved.maxProcesses).toBe(80); + expect(invalid).toEqual([['--max-processes', '0']]); + expect(formatInvalidProcessDetectionOverride('--max-processes', '0')).toContain( + 'next source (env, then the built-in default)', + ); + expect(formatInvalidProcessDetectionOverride('GITNEXUS_MAX_PROCESSES', '0')).toContain( + 'the built-in default', + ); + expect(formatInvalidProcessDetectionOverride('GITNEXUS_MAX_PROCESSES', '0')).not.toContain( + 'next source (env, then the built-in default)', + ); + }); + + it('lets explicit options beat env (AE3 remainder)', () => { + const resolved = resolveProcessDetectionBudget( + { maxProcesses: 25 }, + { GITNEXUS_MAX_PROCESSES: '80' }, + ); + expect(resolved.maxProcesses).toBe(25); + expect(resolved.overridden.maxProcesses).toBe(true); + }); + + it('reads env when option fields are unset', () => { + const resolved = resolveProcessDetectionBudget( + {}, + { + GITNEXUS_MAX_PROCESSES: '40', + GITNEXUS_MAX_PROCESS_BRANCHING: '2', + GITNEXUS_MAX_PROCESS_TRACE_DEPTH: '8', + GITNEXUS_MAX_ENTRY_POINT_CANDIDATES: '400', + }, + ); + expect(resolved.maxProcesses).toBe(40); + expect(resolved.maxProcessBranching).toBe(2); + expect(resolved.maxProcessTraceDepth).toBe(8); + expect(resolved.maxEntryPointCandidates).toBe(400); + }); + + it('warns and falls back on invalid env (AE5)', () => { + const invalid: Array<[string, string]> = []; + const zero = resolveProcessDetectionBudget({}, { GITNEXUS_MAX_PROCESSES: '0' }, (knob, raw) => + invalid.push([knob, raw]), + ); + const garbage = resolveProcessDetectionBudget( + {}, + { GITNEXUS_MAX_PROCESSES: 'abc' }, + (knob, raw) => invalid.push([knob, raw]), + ); + expect(zero.maxProcesses).toBeUndefined(); + expect(garbage.maxProcesses).toBeUndefined(); + expect(invalid).toEqual([ + ['GITNEXUS_MAX_PROCESSES', '0'], + ['GITNEXUS_MAX_PROCESSES', 'abc'], + ]); + }); + + it('replaces the dynamic formula only when maxProcesses is explicit', () => { + const unset = buildProcessDetectionPhaseConfig( + resolveProcessDetectionBudget({}, {}), + 1000, + (n) => Math.max(20, Math.round(n / 10)), + ); + const explicit = buildProcessDetectionPhaseConfig( + resolveProcessDetectionBudget({ maxProcesses: 5 }, {}), + 1000, + (n) => Math.max(20, Math.round(n / 10)), + ); + expect(unset.maxProcesses).toBe(100); + expect(explicit.maxProcesses).toBe(5); + expect(explicit.minSteps).toBe(3); + }); +}); + +describe('processDetectionBudgetMismatch (KTD4)', () => { + const defaults = resolveProcessDetectionBudget({}, {}); + + it('matches a missing stamp against default/dynamic knobs', () => { + expect(processDetectionBudgetMismatch(undefined, defaults)).toBe(false); + }); + + it('mismatches a missing stamp when any override is set', () => { + expect( + processDetectionBudgetMismatch( + undefined, + resolveProcessDetectionBudget({ maxEntryPointCandidates: 400 }, {}), + ), + ).toBe(true); + }); + + it('mismatches when a present stamp differs', () => { + const recorded = toProcessDetectionStamp(defaults); + expect( + processDetectionBudgetMismatch({ ...recorded, maxEntryPointCandidates: 400 }, defaults), + ).toBe(true); + }); + + it('mismatches explicit maxProcesses vs later dynamic even when the integer equals the formula', () => { + const explicit = resolveProcessDetectionBudget({ maxProcesses: 100 }, {}); + expect(processDetectionBudgetMismatch(toProcessDetectionStamp(explicit), defaults)).toBe(true); + expect(toProcessDetectionStamp(explicit).maxProcesses).toBe(100); + expect(toProcessDetectionStamp(defaults).maxProcesses).toBe(null); + }); + + it('matches an identical present stamp', () => { + const resolved = resolveProcessDetectionBudget( + { maxProcesses: 25, maxEntryPointCandidates: 400 }, + {}, + ); + expect(processDetectionBudgetMismatch(toProcessDetectionStamp(resolved), resolved)).toBe(false); + }); + + it('mismatches an uncertified stamp even when the numeric fields match defaults', () => { + const recorded = uncertifyProcessDetectionStamp(toProcessDetectionStamp(defaults)); + expect(recorded.uncertified).toBe(true); + expect(recorded.maxProcesses).toBe(null); + expect(processDetectionBudgetMismatch(recorded, defaults)).toBe(true); + expect(processDetectionBudgetMismatch(toProcessDetectionStamp(defaults), defaults)).toBe(false); + }); +}); + +describe('warning copy and banner', () => { + it('maps loud counters to the matching knobs and names the pre-entry trace gate', () => { + const limits = processDetectionEffectiveLimits(20, resolveProcessDetectionBudget({}, {})); + expect(limits.maxProcessTraces).toBe(40); + expect( + formatWholeFlowsMissingRemedies( + { entryPointCandidatesDropped: 210, entryPointsUnexplored: 0, processesDropped: 0 }, + limits, + 410, + ), + ).toContain('--max-entry-point-candidates'); + expect( + formatWholeFlowsMissingRemedies( + { entryPointCandidatesDropped: 210, entryPointsUnexplored: 0, processesDropped: 0 }, + limits, + 410, + ), + ).not.toContain('--max-process-branching'); + expect( + formatWholeFlowsMissingRemedies( + { entryPointCandidatesDropped: 0, entryPointsUnexplored: 3, processesDropped: 1 }, + limits, + 10, + ), + ).toContain('--max-processes'); + expect( + formatWholeFlowsMissingRemedies( + { entryPointCandidatesDropped: 0, entryPointsUnexplored: 3, processesDropped: 1 }, + limits, + 10, + ), + ).toContain('40'); + expect( + formatWholeFlowsMissingRemedies( + { entryPointCandidatesDropped: 0, entryPointsUnexplored: 3, processesDropped: 1 }, + limits, + 10, + ), + ).toContain('next entry is skipped'); + }); + + it('prints a banner only when an override is active', () => { + expect(formatProcessDetectionBudgetBanner(resolveProcessDetectionBudget({}, {}))).toBeNull(); + expect( + formatProcessDetectionBudgetBanner(resolveProcessDetectionBudget({ maxProcesses: 25 }, {})), + ).toContain('maxProcesses=25'); + expect( + formatProcessDetectionBudgetBanner( + resolveProcessDetectionBudget({ maxProcessBranching: 6 }, {}), + ), + ).toContain('maxProcesses=dynamic (max(20, round(symbols/10)))'); + }); + + it('parses CLI/rc numeric strings without treating 0 as unlimited', () => { + const parsed = parseProcessDetectionBudgetStrings({ + maxProcesses: '25', + maxProcessBranching: '0', + }); + expect(parsed).toEqual({ maxProcesses: 25 }); + }); +}); diff --git a/gitnexus/test/unit/process-processor.test.ts b/gitnexus/test/unit/process-processor.test.ts index 69785034c..2891704fc 100644 --- a/gitnexus/test/unit/process-processor.test.ts +++ b/gitnexus/test/unit/process-processor.test.ts @@ -585,7 +585,13 @@ describe('process depth (D1/D2)', () => { // `findEntryPoints` returns several starting points, so the deep chain is // traced from inside it whatever the traversal order does — a test there // passes under BOTH traversals and guards nothing. - const cfg = { maxTraceDepth: 10, maxBranching: 4, maxProcesses: 75, minSteps: 3 }; + const cfg = { + maxTraceDepth: 10, + maxBranching: 4, + maxProcesses: 75, + minSteps: 3, + maxEntryPointCandidates: 200, + }; const deepAndShallow = (order: readonly string[]): Map => { // Fan-out is capped at maxBranching (4), so the budget is exhausted BELOW @@ -1214,6 +1220,26 @@ describe('the entry-point candidate cap is disclosed too', () => { expect(result.stats.truncation.entryPointCandidatesDropped).toBe(0); expect(result.stats.truncation.truncated).toBe(false); }); + + it('honors maxEntryPointCandidates instead of the compiled 200 (#3313)', async () => { + const result = await processProcesses(manyCandidates(), [], undefined, { + maxProcesses: 1000, + maxEntryPointCandidates: 410, + }); + + expect(result.stats.entryPointsFound).toBe(410); + expect(result.stats.truncation.entryPointCandidatesDropped).toBe(0); + }); + + it('drops one candidate when the override is one below the list length (#3313)', async () => { + const result = await processProcesses(manyCandidates(), [], undefined, { + maxProcesses: 1000, + maxEntryPointCandidates: 409, + }); + + expect(result.stats.entryPointsFound).toBe(409); + expect(result.stats.truncation.entryPointCandidatesDropped).toBe(1); + }); }); // The three trace sorts in this file each joined the path inside the COMPARATOR diff --git a/gitnexus/test/unit/processes-phase-sink-wiring.test.ts b/gitnexus/test/unit/processes-phase-sink-wiring.test.ts index 8f91e79a4..196ec813d 100644 --- a/gitnexus/test/unit/processes-phase-sink-wiring.test.ts +++ b/gitnexus/test/unit/processes-phase-sink-wiring.test.ts @@ -24,11 +24,12 @@ import type { PhaseResult, PipelineContext, } from '../../src/core/ingestion/pipeline-phases/types.js'; +import type { PipelineOptions } from '../../src/core/ingestion/pipeline.js'; import type { KnowledgeGraph } from '../../src/core/graph/types.js'; import type { GraphNode, NodeLabel } from 'gitnexus-shared'; -function makeCtx(graph: KnowledgeGraph): PipelineContext { - return { repoPath: '/tmp/repo', graph, onProgress: () => {}, pipelineStart: 0 }; +function makeCtx(graph: KnowledgeGraph, options?: PipelineOptions): PipelineContext { + return { repoPath: '/tmp/repo', graph, onProgress: () => {}, pipelineStart: 0, options }; } function phaseResult(phaseName: string, output: T): PhaseResult { @@ -219,12 +220,16 @@ describe('processes phase — truncation is disclosed proportionately (#2899)', const runCaptured = async ( graph: KnowledgeGraph, + options?: PipelineOptions, ): Promise<{ output: ProcessesOutput; records: ReturnType }> => { // Captured at `debug` so an ABSENT warn can be distinguished from a silent // phase: the debug line has to be there instead. const capture = _captureLogger('debug'); try { - const output = (await processesPhase.execute(makeCtx(graph), baseDeps())) as ProcessesOutput; + const output = (await processesPhase.execute( + makeCtx(graph, options), + baseDeps(), + )) as ProcessesOutput; return { output, records: capture.records() }; } finally { capture.restore(); @@ -297,5 +302,77 @@ describe('processes phase — truncation is disclosed proportionately (#2899)', const lines = records.filter((r) => PROCESS_LINES.test(String(r.msg))); expect(lines.map((r) => r.level)).toEqual([40]); expect(String(lines[0]?.msg)).toContain('210 of 410 candidate entry point(s) never ranked in'); + expect(String(lines[0]?.msg)).toContain('--max-entry-point-candidates'); + expect(String(lines[0]?.msg)).not.toContain('--max-process-branching'); + expect(lines[0]?.effectiveLimits).toEqual( + expect.objectContaining({ + maxEntryPointCandidates: 200, + maxProcessTraces: expect.any(Number), + }), + ); + }); + + it('honors an explicit maxProcesses instead of the dynamic formula (#3313)', async () => { + const { output } = await runCaptured(flowsMissing(), { maxProcesses: 40 }); + expect(output.processResult.stats.truncation.processesDropped).toBe(0); + expect(output.processResult.stats.truncation.entryPointsUnexplored).toBe(0); + }); + + it('clears the entry-point drop when the candidate cap is raised (#3313 AE2)', async () => { + const graph = createKnowledgeGraph(); + for (let c = 0; c < 205; c++) { + for (let i = 0; i < 3; i++) addFn(graph, `func:r${c}_${i}`); + for (let i = 0; i < 2; i++) addCallEdge(graph, `func:r${c}_${i}`, `func:r${c}_${i + 1}`); + } + const { output } = await runCaptured(graph, { maxEntryPointCandidates: 410 }); + expect(output.processResult.stats.truncation.entryPointCandidatesDropped).toBe(0); + }); + + it('still warns when only maxProcesses is raised on a 410-candidate fixture (#3313 AE2)', async () => { + const graph = createKnowledgeGraph(); + for (let c = 0; c < 205; c++) { + for (let i = 0; i < 3; i++) addFn(graph, `func:s${c}_${i}`); + for (let i = 0; i < 2; i++) addCallEdge(graph, `func:s${c}_${i}`, `func:s${c}_${i + 1}`); + } + const { output, records } = await runCaptured(graph, { maxProcesses: 1000 }); + expect(output.processResult.stats.truncation.entryPointCandidatesDropped).toBe(210); + const lines = records.filter((r) => PROCESS_LINES.test(String(r.msg))); + expect(lines.map((r) => r.level)).toEqual([40]); + }); + + it('increases process count when both binders are raised (#3313 AE6)', async () => { + const graph = createKnowledgeGraph(); + for (let c = 0; c < 210; c++) { + for (let i = 0; i < 3; i++) addFn(graph, `func:ae6_${c}_${i}`); + for (let i = 0; i < 2; i++) + addCallEdge(graph, `func:ae6_${c}_${i}`, `func:ae6_${c}_${i + 1}`); + } + // 210 chains × 3 = 630 symbols. Dynamic maxProcesses needs ≥ 210, so pad. + for (let i = 0; i < 1470; i++) addFn(graph, `func:pad_${i}`); + + const baseline = await runCaptured(graph); + const raised = await runCaptured(graph, { + maxEntryPointCandidates: 420, + maxProcesses: 300, + }); + expect( + baseline.output.processResult.stats.truncation.entryPointCandidatesDropped, + ).toBeGreaterThan(0); + expect(raised.output.processResult.stats.truncation.entryPointCandidatesDropped).toBe(0); + expect(raised.output.processResult.processes.length).toBeGreaterThan( + baseline.output.processResult.processes.length, + ); + }); + + it('keeps shape-only caps at debug when branching or depth is raised (#3313 AE7)', async () => { + const { output, records } = await runCaptured(shortenedOnly(), { + maxProcessBranching: 8, + maxProcessTraceDepth: 20, + }); + expect(output.processResult.stats.truncation.calleesDropped).toBe(0); + expect(output.processResult.stats.truncation.tracesDepthCapped).toBe(0); + const lines = records.filter((r) => PROCESS_LINES.test(String(r.msg))); + expect(lines.some((r) => r.level === 40)).toBe(false); + expect(lines.some((r) => String(r.msg).includes('whole flows are MISSING'))).toBe(false); }); }); diff --git a/gitnexus/test/unit/run-analyze-fts-crash-marker.test.ts b/gitnexus/test/unit/run-analyze-fts-crash-marker.test.ts index 387e85a75..7164caada 100644 --- a/gitnexus/test/unit/run-analyze-fts-crash-marker.test.ts +++ b/gitnexus/test/unit/run-analyze-fts-crash-marker.test.ts @@ -9,7 +9,7 @@ import { readFileSync } from 'node:fs'; import fs from 'fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getStoragePaths, loadMeta, @@ -17,11 +17,16 @@ import { type RepoMeta, } from '../../src/storage/repo-manager.js'; import { createTempDir } from '../helpers/test-db.js'; +import { computeFileHash } from '../../src/storage/file-hash.js'; import { ANALYSIS_FEATURES } from '../../src/core/analysis-feature-registry.js'; import { resolveAnalysisFeatureVersions } from '../../src/core/analysis-features.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; import { resolveAnalyzerRunnerIdentity } from '../../src/core/analyzer-identity.js'; import { EMBEDDING_DIMS, SCHEMA_FINGERPRINT } from '../../src/core/lbug/schema.js'; +import { + PROCESS_DETECTION_BUDGET_DEFAULTS, + PROCESS_DETECTION_ENV, +} from '../../src/core/ingestion/process-detection-budget.js'; import { getSearchFTSCjkSegmentation } from '../../src/core/search/cjk-segmentation.js'; import { FTS_DIRTY_PHASE, @@ -301,6 +306,11 @@ describe('FTS crash-marker policy (characterization)', () => { }); describe('runFullAnalysis FTS crash marker', () => { + beforeEach(() => { + for (const key of Object.values(PROCESS_DETECTION_ENV)) { + vi.stubEnv(key, undefined); + } + }); afterEach(() => { vi.doUnmock('../../src/core/lbug/lbug-adapter.js'); vi.doUnmock('../../src/core/search/fts-indexes.js'); @@ -376,6 +386,7 @@ describe('runFullAnalysis FTS crash marker', () => { writePlan: 'in-place', checkpointSucceeded: true, }); + expect(midBuild?.processDetection?.uncertified).toBeUndefined(); expect(sequence.indexOf('checkpoint')).toBeLessThan(sequence.indexOf('stamp-fts')); expect(sequence.indexOf('stamp-fts')).toBeLessThan(sequence.indexOf('build')); expect(checkpointOnce).toHaveBeenCalled(); @@ -387,6 +398,73 @@ describe('runFullAnalysis FTS crash marker', () => { } }); + it('stamps processDetection.uncertified before in-place FTS when the budget mismatched', async () => { + const sequence: string[] = []; + let midBuild: RepoMeta | null = null; + vi.doMock('../../src/core/lbug/wal-checkpoint-driver.js', async (importActual) => ({ + ...(await importActual()), + checkpointOnce: vi.fn(async () => true), + })); + vi.doMock('../../src/core/lbug/lbug-adapter.js', mockLbugAdapter); + vi.doMock('../../src/core/search/fts-indexes.js', async (importActual) => ({ + ...(await importActual()), + initialiseSearchFTSStemmer: vi.fn(() => 'porter'), + missingSearchFTSIndexTables: vi.fn(async () => []), + dropSearchFTSIndexes: vi.fn(async () => undefined), + buildSearchIndexesOrDegrade: vi.fn(async () => { + sequence.push('build'); + return { ok: true }; + }), + })); + vi.doMock('../../src/core/ingestion/pipeline.js', () => ({ + runPipelineFromRepo: vi.fn(async (repoPath: string) => ({ + repoPath, + graph: fileGraph(), + })), + })); + vi.doMock('../../src/storage/repo-manager.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + saveMeta: async (...args: Parameters) => { + if (args[1].incrementalInProgress?.phase === FTS_DIRTY_PHASE) { + sequence.push('stamp-fts'); + midBuild = args[1]; + } + return actual.saveMeta(...args); + }, + }; + }); + + const tmpRepo = await createTempDir('gitnexus-fts-crash-uncertify-'); + try { + await seedGitFile(tmpRepo.dbPath); + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await fs.mkdir(storagePath, { recursive: true }); + await saveMeta(storagePath, incrementalMeta(tmpRepo.dbPath)); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis( + tmpRepo.dbPath, + { maxProcesses: 25, skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {}, onLog: () => {} }, + ); + + expect(midBuild?.processDetection).toMatchObject({ + uncertified: true, + maxProcesses: null, + }); + expect(sequence.indexOf('stamp-fts')).toBeGreaterThan(-1); + expect(sequence.indexOf('build')).toBeGreaterThan(-1); + expect(sequence.indexOf('stamp-fts')).toBeLessThan(sequence.indexOf('build')); + const finalMeta = await loadMeta(storagePath); + expect(finalMeta?.processDetection?.uncertified).toBeUndefined(); + expect(finalMeta?.processDetection?.maxProcesses).toBe(25); + } finally { + await tmpRepo.cleanup(); + } + }); + it.skipIf(process.platform === 'win32')( 'does not stamp an FTS phase on a staging plan', async () => { @@ -440,6 +518,123 @@ describe('runFullAnalysis FTS crash marker', () => { }, ); + it.skipIf(process.platform === 'win32')( + 'does not persist live incrementalInProgress when atomic incremental dies during staging copy', + async () => { + vi.doMock('../../src/core/lbug/lbug-adapter.js', mockLbugAdapter); + vi.doMock('../../src/core/search/fts-indexes.js', async (importActual) => ({ + ...(await importActual()), + initialiseSearchFTSStemmer: vi.fn(() => 'porter'), + missingSearchFTSIndexTables: vi.fn(async () => []), + dropSearchFTSIndexes: vi.fn(async () => undefined), + buildSearchIndexesOrDegrade: vi.fn(async () => ({ ok: true })), + })); + vi.doMock('../../src/core/ingestion/pipeline.js', () => ({ + runPipelineFromRepo: vi.fn(async (repoPath: string) => ({ + repoPath, + graph: fileGraph(), + })), + })); + + const tmpRepo = await createTempDir('gitnexus-atomic-incr-copy-crash-'); + try { + await seedGitFile(tmpRepo.dbPath); + const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); + await fs.mkdir(storagePath, { recursive: true }); + const fileHash = await computeFileHash(path.join(tmpRepo.dbPath, REL_FILE)); + await saveMeta(storagePath, { + ...incrementalMeta(tmpRepo.dbPath), + lastCommit: headCommit(tmpRepo.dbPath), + fileHashes: { [REL_FILE]: fileHash! }, + processDetection: { + maxProcesses: 80, + maxProcessBranching: 4, + maxProcessTraceDepth: 10, + maxEntryPointCandidates: 200, + }, + }); + await createPlaceholderGraphStore(lbugPath); + + const originalCopyFile: typeof fs.copyFile = fs.copyFile.bind(fs); + const copyFile = vi.spyOn(fs, 'copyFile').mockImplementation(async (src, dest, mode) => { + if (String(dest).includes('.staging.')) { + throw new Error('simulated staging copy crash'); + } + return originalCopyFile(src, dest, mode); + }); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await expect( + runFullAnalysis( + tmpRepo.dbPath, + { atomicIncremental: true, maxProcesses: 25, skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {}, onLog: () => {} }, + ), + ).rejects.toThrow('simulated staging copy crash'); + expect(copyFile).toHaveBeenCalled(); + + const liveMeta = await loadMeta(storagePath); + expect(liveMeta?.incrementalInProgress).toBeUndefined(); + expect(liveMeta?.processDetection?.maxProcesses).toBe(80); + expect(liveMeta?.processDetection?.uncertified).toBeUndefined(); + } finally { + await tmpRepo.cleanup(); + } + }, + ); + + it('stamps phase pre-write on live meta before in-place incremental writeback', async () => { + vi.doMock('../../src/core/lbug/lbug-adapter.js', mockLbugAdapter); + vi.doMock('../../src/core/search/fts-indexes.js', async (importActual) => ({ + ...(await importActual()), + initialiseSearchFTSStemmer: vi.fn(() => 'porter'), + missingSearchFTSIndexTables: vi.fn(async () => []), + dropSearchFTSIndexes: vi.fn(async () => undefined), + buildSearchIndexesOrDegrade: vi.fn(async () => ({ ok: true })), + })); + vi.doMock('../../src/core/ingestion/pipeline.js', () => ({ + runPipelineFromRepo: vi.fn(async (repoPath: string) => ({ + repoPath, + graph: fileGraph(), + })), + })); + vi.doMock('../../src/storage/repo-manager.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + saveMeta: async (...args: Parameters) => { + const result = await actual.saveMeta(...args); + if (args[1].incrementalInProgress?.phase === 'pre-write') { + throw new Error('stop after in-place dirty stamp'); + } + return result; + }, + }; + }); + + const tmpRepo = await createTempDir('gitnexus-inplace-pre-write-stamp-'); + try { + await seedGitFile(tmpRepo.dbPath); + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await fs.mkdir(storagePath, { recursive: true }); + await saveMeta(storagePath, incrementalMeta(tmpRepo.dbPath)); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await expect( + runFullAnalysis( + tmpRepo.dbPath, + { skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {}, onLog: () => {} }, + ), + ).rejects.toThrow('stop after in-place dirty stamp'); + + const liveMeta = await loadMeta(storagePath); + expect(liveMeta?.incrementalInProgress).toMatchObject({ phase: 'pre-write' }); + } finally { + await tmpRepo.cleanup(); + } + }); + it('clears the FTS phase on the degrade path as well as on success', async () => { vi.doMock('../../src/core/lbug/lbug-adapter.js', mockLbugAdapter); vi.doMock('../../src/core/search/fts-indexes.js', async (importActual) => ({ @@ -906,6 +1101,64 @@ describe('runFullAnalysis FTS crash marker', () => { } }); + it('re-detects flows after FTS park when processDetection is uncertified', async () => { + const wipeLbugDbFiles = vi.fn(async () => undefined); + const runDeferredDerivedPhases = vi.fn(async () => undefined); + const runPipelineFromRepo = vi.fn(async (repoPath: string) => ({ + repoPath, + graph: fileGraph(), + runDeferredDerivedPhases, + })); + vi.doMock('../../src/core/lbug/lbug-adapter.js', async () => ({ + ...(await mockLbugAdapter()), + wipeLbugDbFiles, + })); + vi.doMock('../../src/core/search/fts-indexes.js', async (importActual) => ({ + ...(await importActual()), + initialiseSearchFTSStemmer: vi.fn(() => 'porter'), + missingSearchFTSIndexTables: vi.fn(async () => []), + dropSearchFTSIndexes: vi.fn(async () => undefined), + buildSearchIndexesOrDegrade: vi.fn(async () => ({ ok: true })), + })); + vi.doMock('../../src/core/ingestion/pipeline.js', () => ({ runPipelineFromRepo })); + + const tmpRepo = await createTempDir('gitnexus-fts-crash-uncertified-park-'); + try { + await seedGitFile(tmpRepo.dbPath); + const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); + await fs.mkdir(storagePath, { recursive: true }); + await saveMeta(storagePath, { + ...incrementalMeta(tmpRepo.dbPath), + lastCommit: headCommit(tmpRepo.dbPath), + processDetection: { + maxProcesses: null, + maxProcessBranching: PROCESS_DETECTION_BUDGET_DEFAULTS.maxProcessBranching, + maxProcessTraceDepth: PROCESS_DETECTION_BUDGET_DEFAULTS.maxProcessTraceDepth, + maxEntryPointCandidates: PROCESS_DETECTION_BUDGET_DEFAULTS.maxEntryPointCandidates, + uncertified: true, + }, + incrementalInProgress: ftsInPlaceDirty, + }); + await fs.writeFile(lbugPath, GRAPH_BYTES); + await fs.writeFile(`${lbugPath}.wal`, WAL_PATTERN); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const result = await runFullAnalysis( + tmpRepo.dbPath, + { skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {}, onLog: () => {} }, + ); + + expect(result.alreadyUpToDate).not.toBe(true); + expect(runPipelineFromRepo).toHaveBeenCalled(); + expect(runDeferredDerivedPhases).toHaveBeenCalled(); + const finalMeta = await loadMeta(storagePath); + expect(finalMeta?.processDetection?.uncertified).toBeUndefined(); + } finally { + await tmpRepo.cleanup(); + } + }); + it('does not keep the graph for a non-FTS dirty flag', async () => { const wipeLbugDbFiles = vi.fn(async () => undefined); vi.doMock('../../src/core/lbug/lbug-adapter.js', async () => ({ diff --git a/gitnexus/test/unit/watch-paths.test.ts b/gitnexus/test/unit/watch-paths.test.ts index eae53a1fd..5dc5ce3f9 100644 --- a/gitnexus/test/unit/watch-paths.test.ts +++ b/gitnexus/test/unit/watch-paths.test.ts @@ -171,6 +171,27 @@ describe('watch path selection', () => { } }); + it('applies process-detection budget keys from rc and CLI without throwing (#3313)', async () => { + await fs.writeFile( + path.join(repoPath, '.gitnexusrc'), + JSON.stringify({ maxProcesses: '40', maxEntryPointCandidates: 400 }), + ); + const baseline = { maxFileSize: undefined, workerTimeout: undefined, verbose: undefined }; + await expect(resolveWatchOptions(repoPath, {}, baseline)).resolves.toMatchObject({ + maxProcesses: 40, + maxEntryPointCandidates: 400, + }); + await expect( + resolveWatchOptions(repoPath, { maxProcesses: '25' }, baseline), + ).resolves.toMatchObject({ + maxProcesses: 25, + maxEntryPointCandidates: 400, + }); + const zeroBudget = await resolveWatchOptions(repoPath, { maxProcesses: '0' }, baseline); + expect(zeroBudget).toMatchObject({ maxEntryPointCandidates: 400 }); + expect(zeroBudget.maxProcesses).toBeUndefined(); + }); + it('rejects a watch file-size threshold above the parser ceiling', async () => { await expect( resolveWatchOptions( From f465f6fb92e36e9d44a1748fea06956c8a5c4c58 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:43:35 +0100 Subject: [PATCH 16/20] chore(deps)(deps): bump proxy-addr from 2.0.7 to 2.0.8 in /gitnexus (#3329) Bumps [proxy-addr](https://github.com/jshttp/proxy-addr) from 2.0.7 to 2.0.8. - [Release notes](https://github.com/jshttp/proxy-addr/releases) - [Changelog](https://github.com/jshttp/proxy-addr/blob/master/HISTORY.md) - [Commits](https://github.com/jshttp/proxy-addr/compare/v2.0.7...v2.0.8) --- updated-dependencies: - dependency-name: proxy-addr dependency-version: 2.0.8 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus/package-lock.json | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 794d63657..75a343058 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -4149,9 +4149,9 @@ "license": "MIT" }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -4159,6 +4159,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/pump": { From 795cf0e151d6e363d792e2a0b4507b9cfd5ff84d Mon Sep 17 00:00:00 2001 From: Parafee41 Date: Sat, 19 Sep 2026 14:44:35 +0800 Subject: [PATCH 17/20] fix(swift): resolve inherited protocol extension calls (#3309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(swift): resolve inherited protocol extension calls * fix(scope): gate inherited implicit receiver lookup * fix(swift): resolve call result types by exact callee * test(swift): align cache and local call expectations * fix(scope): reconcile replay diagnostics * fix(swift): preserve exact callable return types * fix(swift): capture throwing async call results * test(swift): refresh capture golden * test(swift): refresh scope capture baseline * fix(swift): require explicit callable returns * fix(scope): preserve duplicate return metadata * chore(scope): align index documentation * Address PR review feedback (#3309) Stamp only protocol/class-extension members (nested QN + SPM buckets), arity-narrow implicit-this across MRO, and keep Swift type peeling out of shared workspace-index via stripTypePreservingDecoration. Co-authored-by: Cursor * Address PR review feedback (#3309) Stamp extension members even when the extension declares a nested type, keep inherited class members ahead of protocol-extension defaults, and report replay-only interface-dispatch fan-out drops. Note: pre-existing failure in gitnexus tsc against an older gitnexus-shared dist not addressed by this PR. Co-authored-by: Cursor * Address PR review feedback (#3309) Walk inherited implicit-this owners nearest-first so a nearer override wins, and tighten Swift owner-stamp tests. Co-authored-by: Cursor * chore(autofix): apply prettier + eslint fixes via /autofix command --------- Co-authored-by: Gergő Magyar Co-authored-by: Gergo Magyar Co-authored-by: Cursor Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- gitnexus-shared/src/index.ts | 1 + .../call-result-assignment-site.ts | 14 + .../src/scope-resolution/parsed-file.ts | 3 + gitnexus/bench/scope-capture/baselines.json | 6 +- .../ingestion/languages/swift/captures.ts | 45 ++- .../core/ingestion/languages/swift/index.ts | 7 +- .../ingestion/languages/swift/interpret.ts | 18 +- .../core/ingestion/languages/swift/query.ts | 26 ++ .../languages/swift/scope-resolver.ts | 101 ++++++- .../src/core/ingestion/scope-extractor.ts | 90 +++++- .../contract/scope-resolver.ts | 10 +- .../passes/free-call-fallback.ts | 130 ++++++++- .../scope-resolution/pipeline/run.ts | 139 +++++++++- .../scope-resolution/workspace-index-types.ts | 8 +- .../scope-resolution/workspace-index.ts | 39 ++- gitnexus/src/storage/parse-cache.ts | 4 +- gitnexus/src/storage/parsedfile-store.ts | 33 ++- .../AUnrelated.swift | 13 + .../Scenario.swift | 8 + .../Support.swift | 13 + .../expected-captures.json | 140 +++++----- .../swift-local-vs-method-label-split.test.ts | 16 +- .../test/integration/resolvers/swift.test.ts | 46 ++++ .../test/unit/incremental-parse-cache.test.ts | 8 +- gitnexus/test/unit/parsedfile-store.test.ts | 49 ++++ .../pick-implicit-this-overload.test.ts | 256 +++++++++++++++++- .../scope-resolution/scope-extractor.test.ts | 65 +++++ .../swift-extension-owners.test.ts | 198 ++++++++++++++ .../scope-resolution/workspace-index.test.ts | 197 ++++++++++++++ 29 files changed, 1550 insertions(+), 133 deletions(-) create mode 100644 gitnexus-shared/src/scope-resolution/call-result-assignment-site.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/AUnrelated.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Scenario.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Support.swift create mode 100644 gitnexus/test/unit/scope-resolution/swift-extension-owners.test.ts diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 7958cfa9d..de49223bf 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -98,6 +98,7 @@ export type { ResolveTypeRefContext } from './scope-resolution/resolve-type-ref. // ScopeExtractor output contracts (RFC §3.2 Phase 1; Ring 2 PKG #919) export type { ParsedFile } from './scope-resolution/parsed-file.js'; +export type { CallResultAssignmentSite } from './scope-resolution/call-result-assignment-site.js'; export type { ReferenceSite, ReferenceKind, diff --git a/gitnexus-shared/src/scope-resolution/call-result-assignment-site.ts b/gitnexus-shared/src/scope-resolution/call-result-assignment-site.ts new file mode 100644 index 000000000..1b9347101 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/call-result-assignment-site.ts @@ -0,0 +1,14 @@ +import type { Range, ScopeId } from './types.js'; + +/** + * Compact extraction-time identity for `lhs = callee()`. + * + * The call-site range uses the same call-expression anchor as reference + * resolution, allowing downstream passes to join this fact to the exact + * resolved callee id without relying on a possibly polluted type binding. + */ +export interface CallResultAssignmentSite { + readonly callSite: Range; + readonly inScope: ScopeId; + readonly lhs: string; +} diff --git a/gitnexus-shared/src/scope-resolution/parsed-file.ts b/gitnexus-shared/src/scope-resolution/parsed-file.ts index db0833967..140193afc 100644 --- a/gitnexus-shared/src/scope-resolution/parsed-file.ts +++ b/gitnexus-shared/src/scope-resolution/parsed-file.ts @@ -56,6 +56,7 @@ import type { ParsedImport } from './types.js'; import type { SymbolDefinition } from './symbol-definition.js'; import type { ReferenceSite } from './reference-site.js'; import type { CallableFlowSite } from './callable-flow-site.js'; +import type { CallResultAssignmentSite } from './call-result-assignment-site.js'; export interface ParsedFile { readonly filePath: string; @@ -81,6 +82,8 @@ export interface ParsedFile { * syntax remain source-compatible; consumers normalize absence to `[]`. */ readonly callableFlowSites?: readonly CallableFlowSite[]; + /** Exact call-expression → assigned local identity for return-type replay. */ + readonly callResultAssignmentSites?: readonly CallResultAssignmentSite[]; /** * Opaque, language-private serialization of capture-time side-channel * state that a provider's `emitScopeCaptures` populates into module-level diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 94fc262f6..74e4588e9 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -117,7 +117,7 @@ "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83 -> 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57." }, "swift": { - "fingerprint": "adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9", + "fingerprint": "724553c91e8ebcf872d7302b14105d59476c230f9b623acdacc0b58e39912329", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725 -> 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d; scaling 1.042 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Swift function-value callable flow facts with invocation-result suppression. Prior 180ac68e780bdf6f9089d53f51cbb9a66aed3e7774631cc3fcbaae5020213998 -> 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725; scaling 1.043 < 1.5.", @@ -125,7 +125,9 @@ "_rebaselined_2522_review_fixes": "PR #2522 review fixes: assignment target:/result: fields join the shared fallback. Prior 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d -> 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248; scaling ratio re-verified within budget.", "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248 -> a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b.", "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b -> 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7.", - "_rebaselined_inferred_field_receiver_2807": "#2807: optional property annotations (`var a: Outer?`) now emit a type binding. The prior pattern required the `user_type` to be a DIRECT child of the annotation, so an `optional_type` wrapper meant an optional field was never typed at all and its receiver could not resolve. ADDS @type-binding.annotation captures on the optional form only; no capture is removed. Prior 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7 -> adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9; scaling 1.023 < 1.5." + "_rebaselined_inferred_field_receiver_2807": "#2807: optional property annotations (`var a: Outer?`) now emit a type binding. The prior pattern required the `user_type` to be a DIRECT child of the annotation, so an `optional_type` wrapper meant an optional field was never typed at all and its receiver could not resolve. ADDS @type-binding.annotation captures on the optional form only; no capture is removed. Prior 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7 -> adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9; scaling 1.023 < 1.5.", + "_rebaselined_3309_call_result_assignment": "#3309: the Swift protocol-extension regression fixture adds one file with an untyped local initialized from a helper call, plus a compound-field receiver call. The capture query is unchanged; this is fixture-corpus growth only. capture_groups_fp 1235 -> 1272 and fixture_count 62 -> 64; synthetic scale counts remain 5012/16012. Prior adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9 -> 4d32535d454cd79086f0fcaec3b75de855c1314e73630321460e569f19aa484f; measured scaling 1.020 < 1.5.", + "_rebaselined_3309_exact_return_replay": "#3309 follow-up: Swift callable declarations now emit exact return-type captures, and untyped local initializers emit call-result assignment captures through direct, try, await, and try-await forms. This is intentional capture-set growth covered by exact-callable collision and wrapper regressions. Prior 4d32535d454cd79086f0fcaec3b75de855c1314e73630321460e569f19aa484f -> 724553c91e8ebcf872d7302b14105d59476c230f9b623acdacc0b58e39912329; hosted benchmark scaling remained within the existing 1.5 budget." }, "dart": { "fingerprint": "3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687", diff --git a/gitnexus/src/core/ingestion/languages/swift/captures.ts b/gitnexus/src/core/ingestion/languages/swift/captures.ts index 736c6e962..56f165b21 100644 --- a/gitnexus/src/core/ingestion/languages/swift/captures.ts +++ b/gitnexus/src/core/ingestion/languages/swift/captures.ts @@ -48,6 +48,7 @@ import { synthesizeSwiftReceiverBinding, } from './receiver-binding.js'; import { synthesizeSwiftSignatureBindings } from './signature-bindings.js'; +import { swiftMethodConfig } from '../../method-extractors/configs/swift.js'; import { getSwiftParser, getSwiftScopeQuery } from './query.js'; import { preprocessSwiftConditionalDirectives } from './conditional-directive-preprocess.js'; import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; @@ -198,6 +199,26 @@ export function emitSwiftScopeCaptures( continue; } + // The query deliberately recognizes the compact `lhs = call()` shape; + // enforce "untyped lhs" here because tree-sitter queries cannot express + // absence of Swift's sibling type_annotation robustly. A typed declaration + // remains authoritative and must never enter return-type replay. + if (grouped['@call-result-assignment.call'] !== undefined) { + const callNode = nodeIfType(nodeMap['@call-result-assignment.call'], 'call_expression'); + let property = callNode?.parent; + while (property?.type === 'await_expression' || property?.type === 'try_expression') { + property = property.parent; + } + if ( + property?.type !== 'property_declaration' || + property.namedChildren.some((child) => child.type === 'type_annotation') + ) { + continue; + } + out.push(grouped); + continue; + } + // ── Field accesses: a `navigation_expression` (`obj.field`) is one of // three things. Drop it when it's a call's callee (`u.save` in // `u.save()` — the @reference.call.member query already covers that). @@ -314,7 +335,17 @@ export function emitSwiftScopeCaptures( nodeMap['@declaration.constructor'], ...FUNCTION_NODE_TYPES, ); - if (fnNodeForArity !== null) attachArityMetadata(grouped, fnNodeForArity); + if (fnNodeForArity !== null) { + attachArityMetadata(grouped, fnNodeForArity); + const returnType = swiftMethodConfig.extractReturnType?.(fnNodeForArity); + if (returnType !== undefined && returnType !== '') { + grouped['@declaration.return-type'] = syntheticCapture( + '@declaration.return-type', + fnNodeForArity, + returnType, + ); + } + } // Structural receiver chain for a call whose receiver is itself an // expression, so resolution can type it by folding over structure // instead of re-parsing the receiver's source text. Self-gating: a @@ -338,7 +369,17 @@ export function emitSwiftScopeCaptures( const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined); if (declTag !== undefined) { const fnNode = nodeIfType(nodeMap[declTag], ...FUNCTION_NODE_TYPES); - if (fnNode !== null) attachArityMetadata(grouped, fnNode); + if (fnNode !== null) { + attachArityMetadata(grouped, fnNode); + const returnType = swiftMethodConfig.extractReturnType?.(fnNode); + if (returnType !== undefined && returnType !== '') { + grouped['@declaration.return-type'] = syntheticCapture( + '@declaration.return-type', + fnNode, + returnType, + ); + } + } } // ── Constructor calls: Swift has no `new`, so `Foo()` is a free call diff --git a/gitnexus/src/core/ingestion/languages/swift/index.ts b/gitnexus/src/core/ingestion/languages/swift/index.ts index 4e4e7ef52..f0bcf5ad6 100644 --- a/gitnexus/src/core/ingestion/languages/swift/index.ts +++ b/gitnexus/src/core/ingestion/languages/swift/index.ts @@ -27,7 +27,12 @@ export { emitSwiftScopeCaptures } from './captures.js'; export { getSwiftCaptureCacheStats, resetSwiftCaptureCacheStats } from './cache-stats.js'; -export { interpretSwiftImport, interpretSwiftTypeBinding } from './interpret.js'; +export { + interpretSwiftImport, + interpretSwiftTypeBinding, + normalizeSwiftTypeName, + stripSwiftTypePreservingDecoration, +} from './interpret.js'; export { swiftMergeBindings } from './merge-bindings.js'; export { swiftArityCompatibility } from './arity.js'; export { resolveSwiftImportTarget, type SwiftResolveContext } from './import-target.js'; diff --git a/gitnexus/src/core/ingestion/languages/swift/interpret.ts b/gitnexus/src/core/ingestion/languages/swift/interpret.ts index 5536c4a1b..a12816843 100644 --- a/gitnexus/src/core/ingestion/languages/swift/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/swift/interpret.ts @@ -46,7 +46,7 @@ export function interpretSwiftTypeBinding(captures: CaptureMatch): ParsedTypeBin // `[User]` → User (array sugar) // `Array` / `Optional` → User (single-arg generic) // `Foundation.URL` → URL (qualifier) - const rawType = stripQualifier(stripGeneric(stripArraySugar(stripOptional(typeCap.text.trim())))); + const rawType = normalizeSwiftTypeName(typeCap.text); let source: TypeRef['source'] = 'parameter-annotation'; if (captures['@type-binding.self'] !== undefined) source = 'self'; @@ -58,6 +58,22 @@ export function interpretSwiftTypeBinding(captures: CaptureMatch): ParsedTypeBin return { boundName: nameCap.text, rawTypeName: rawType, source }; } +export function normalizeSwiftTypeName(text: string): string { + return stripQualifier(stripGeneric(stripArraySugar(stripOptional(text.trim())))); +} + +/** + * Type-preserving decoration only — used by `stripTypePreservingDecoration` + * for class lookup and declared-return replay. `User?` / `User!` → `User`. + * Arrays, generics, and nested `Foo.Bar` are left intact so a binding used + * for member lookup does not follow the element type or the trailing ident. + */ +export function stripSwiftTypePreservingDecoration(typeName: string): string | undefined { + const trimmed = typeName.trim(); + if (trimmed.endsWith('?') || trimmed.endsWith('!')) return trimmed.slice(0, -1).trim(); + return undefined; +} + /** `User?` / `User!` → `User`. */ function stripOptional(text: string): string { if (text.endsWith('?') || text.endsWith('!')) return text.slice(0, -1).trim(); diff --git a/gitnexus/src/core/ingestion/languages/swift/query.ts b/gitnexus/src/core/ingestion/languages/swift/query.ts index 1d50be2df..805329903 100644 --- a/gitnexus/src/core/ingestion/languages/swift/query.ts +++ b/gitnexus/src/core/ingestion/languages/swift/query.ts @@ -188,6 +188,32 @@ const SWIFT_SCOPE_QUERY = ` (call_expression (simple_identifier) @reference.name) @reference.call.free +;; Exact identity for \`let lhs = callee()\`. The call-expression anchor is +;; byte-identical to @reference.call.free / member for downstream position join. +(property_declaration + name: (pattern + bound_identifier: (simple_identifier) @call-result-assignment.lhs) + value: (call_expression) @call-result-assignment.call) + +(property_declaration + name: (pattern + bound_identifier: (simple_identifier) @call-result-assignment.lhs) + value: (await_expression + (call_expression) @call-result-assignment.call)) + +(property_declaration + name: (pattern + bound_identifier: (simple_identifier) @call-result-assignment.lhs) + value: (try_expression + (call_expression) @call-result-assignment.call)) + +(property_declaration + name: (pattern + bound_identifier: (simple_identifier) @call-result-assignment.lhs) + value: (try_expression + (await_expression + (call_expression) @call-result-assignment.call))) + ;; ── References — member / method calls: \`obj.method(...)\` ─────────── ;; navigation_expression carries the receiver (target:) and the member ;; (suffix > navigation_suffix > simple_identifier). \`self\` is a diff --git a/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts index 83a8e6ce7..4b8f974c5 100644 --- a/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts @@ -14,7 +14,8 @@ * re-keys an `extension Foo { … }` to a `class_declaration`-style def * named `Foo`, so its members land on `Foo`'s scope and the shared * `populateClassOwnedMembers` stamps them with `Foo`'s ownerId — the - * same mechanism C# uses for `partial class`. No separate hoist pass. + * same mechanism C# uses for `partial class`. Cross-file extensions + * that mint no type def are reconciled by `populateWorkspaceOwners`. * - **Labeled arguments** narrow by ARITY only (count-primary, labels * soft) — see `arity.ts`. Label-precise dispatch is deferred to the * type-binding layer. @@ -46,7 +47,7 @@ * 4. **`@_exported import` re-exports** are treated as plain imports. */ -import type { ParsedFile } from 'gitnexus-shared'; +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; import { loadSwiftPackageConfig } from '../../language-config.js'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; @@ -66,6 +67,8 @@ import { mirrorSwiftSiblingTypeBindings, type SwiftResolveContext, } from './index.js'; +import { stripSwiftTypePreservingDecoration } from './interpret.js'; +import { coerceSwiftTargets, groupSwiftFilesBySpmTarget } from './target-grouping.js'; import { swiftIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js'; const ZERO_RANGE = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 } as const; @@ -101,6 +104,8 @@ const swiftScopeResolver: ScopeResolver = { arityCompatibility: (callsite, def) => swiftArityCompatibility(def, callsite), buildMro: (graph, parsedFiles, nodeLookup) => buildSwiftMro(graph, parsedFiles, nodeLookup), + implicitThisWalksMro: true, + stripTypePreservingDecoration: stripSwiftTypePreservingDecoration, // Methods/properties/init are owned by their enclosing class/struct/ // extension(→extended type)/protocol. Extension members hoist for free @@ -108,6 +113,13 @@ const swiftScopeResolver: ScopeResolver = { // the extended type. populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed), + // An extension has its own Class scope but deliberately does not mint a + // second type def. Its members therefore leave the per-file owner walk with + // a qualified name (`ExtendedType.member`) but no ownerId. Reconcile those + // members after all files are available so extensions declared in sibling + // files work as well as extensions beside the original type. + populateWorkspaceOwners: populateSwiftExtensionOwners, + // `super.method()` dispatches through the superclass chain. isSuperReceiver: (text) => text.trim() === 'super', @@ -209,6 +221,91 @@ function buildSwiftMro( return mro; } +function populateSwiftExtensionOwners( + parsedFiles: readonly ParsedFile[], + ctx?: { readonly fileContents: ReadonlyMap; readonly resolutionConfig?: unknown }, +): void { + const filesByTarget = groupSwiftFilesBySpmTarget( + parsedFiles, + (parsed) => parsed.filePath, + coerceSwiftTargets(ctx?.resolutionConfig), + ); + for (const files of filesByTarget.values()) { + stampSwiftExtensionOwnersInTarget(files); + } +} + +function stampSwiftExtensionOwnersInTarget(parsedFiles: readonly ParsedFile[]): void { + const ownersByName = new Map(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + if (!isClassLike(def.type) || def.qualifiedName === undefined) continue; + const bucket = ownersByName.get(def.qualifiedName); + if (bucket === undefined) ownersByName.set(def.qualifiedName, [def]); + else bucket.push(def); + } + } + + // Extension members are Function-owned defs whose parent Class minted no + // type def. Nested locals are Function-owned defs whose parent is another + // Function — leave those ownerless so they cannot enter implicit-self CALLS. + for (const parsed of parsedFiles) { + const byId = new Map(parsed.scopes.map((scope) => [scope.id, scope])); + for (const scope of parsed.scopes) { + if (scope.kind !== 'Function') continue; + const parent = scope.parent === null ? undefined : byId.get(scope.parent); + if (parent?.kind !== 'Class') continue; + // A type-decl Class owns the type that opened it (same start line). + // Nested types inside an `extension` are also class-like and live on + // that Class scope — they are not the extended type, so they must + // not suppress stamping `func added` onto `Foo`. + if (parent.ownedDefs.some((d) => isClassLike(d.type) && defDeclaresThisClassScope(parent, d))) + continue; + for (const def of scope.ownedDefs) { + if (def.ownerId !== undefined || def.qualifiedName === undefined) continue; + const dot = def.qualifiedName.lastIndexOf('.'); + if (dot <= 0) continue; + const owner = uniqueOwnerForExtensionPrefix(ownersByName, def.qualifiedName.slice(0, dot)); + if (owner !== undefined) { + (def as { ownerId?: string }).ownerId = owner.nodeId; + } + } + } + } +} + +function defDeclaresThisClassScope( + parent: { readonly range: { readonly startLine: number } }, + def: SymbolDefinition, +): boolean { + const line = /#(\d+):/.exec(def.nodeId); + if (line === null) return true; + return Number(line[1]) === parent.range.startLine; +} + +function uniqueOwnerForExtensionPrefix( + ownersByName: ReadonlyMap, + prefix: string, +): SymbolDefinition | undefined { + const seen = new Set(); + const matches: SymbolDefinition[] = []; + const consider = (defs: readonly SymbolDefinition[] | undefined): void => { + if (defs === undefined) return; + for (const def of defs) { + if (seen.has(def.nodeId)) continue; + seen.add(def.nodeId); + matches.push(def); + } + }; + consider(ownersByName.get(prefix)); + if (!prefix.includes('.')) { + for (const [qn, defs] of ownersByName) { + if (qn !== prefix && qn.endsWith('.' + prefix)) consider(defs); + } + } + return matches.length === 1 ? matches[0] : undefined; +} + function closeProtocols( seeds: readonly string[], directImpls: ReadonlyMap, diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 898282b5e..22d1f2572 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -4,7 +4,7 @@ * (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 + * Runs a seven-pass pipeline over the matches. Each pass is internal; the * public contract is the output `ParsedFile`. * * ## Design principles @@ -22,7 +22,7 @@ * don't overlap) are enforced by `buildScopeTree` from Ring 2 SHARED * (#912). Malformed inputs throw `ScopeTreeInvariantError`. * - * ## The five passes + * ## The seven passes * * 1. **Build scope tree.** Walk `@scope.*` matches. For each, consult * `provider.resolveScopeKind` (default: suffix of the capture name). @@ -60,6 +60,10 @@ * one `ReferenceSite` per match. Classify call form via * `provider.classifyCallForm` (default: the capture's sub-tag if * present; else `'free'`). + * 6. **Collect callable-value-flow facts.** Independent of Pass 5 so + * existing reference-site extraction stays byte-identical. + * 7. **Preserve call-result assignment identity.** Untyped + * `let lhs = call()` facts used by exact-callee return-type replay. * * ## What gets attached where * @@ -80,6 +84,7 @@ import type { CallableFlowOperand, CallableFlowPassingMode, CallableFlowSite, + CallResultAssignmentSite, Capture, CaptureMatch, ImportEdge, @@ -134,7 +139,7 @@ export type ScopeExtractorHooks = Pick< // ─── Public entry point ───────────────────────────────────────────────────── /** - * Drive the five extraction passes and return a `ParsedFile`. + * Drive the seven extraction passes and return a `ParsedFile`. * * Throws `ScopeTreeInvariantError` (from #912) when the provider emits * captures that violate structural scope invariants (e.g., overlapping @@ -240,6 +245,15 @@ export function extract( const callableFlowSites: CallableFlowSite[] = []; pass6CollectCallableFlows(partitioned.callableFlow, positionIndex, filePath, callableFlowSites); + // ── Pass 7: preserve call-result assignment identity ─────────────── + const callResultAssignmentSites: CallResultAssignmentSite[] = []; + pass7CollectCallResultAssignments( + partitioned.callResultAssignment, + positionIndex, + filePath, + callResultAssignmentSites, + ); + // Freeze Scope drafts into final shape and return. const frozenScopes = scopeDrafts.map(draftToScope); return Object.freeze({ @@ -252,6 +266,9 @@ export function extract( ...(callableFlowSites.length > 0 ? { callableFlowSites: Object.freeze(callableFlowSites.slice()) } : {}), + ...(callResultAssignmentSites.length > 0 + ? { callResultAssignmentSites: Object.freeze(callResultAssignmentSites.slice()) } + : {}), }); } @@ -264,6 +281,7 @@ interface Partitioned { readonly typeBinding: readonly CaptureMatch[]; readonly reference: readonly CaptureMatch[]; readonly callableFlow: readonly CaptureMatch[]; + readonly callResultAssignment: readonly CaptureMatch[]; } /** @@ -283,6 +301,7 @@ function partitionByTopic(matches: readonly CaptureMatch[]): Partitioned { const typeBinding: CaptureMatch[] = []; const reference: CaptureMatch[] = []; const callableFlow: CaptureMatch[] = []; + const callResultAssignment: CaptureMatch[] = []; for (const match of matches) { for (const topic of topicsOf(match)) { @@ -305,14 +324,32 @@ function partitionByTopic(matches: readonly CaptureMatch[]): Partitioned { case 'callable-flow': callableFlow.push(match); break; + case 'call-result-assignment': + callResultAssignment.push(match); + break; } } } - return { scope, declaration, import_, typeBinding, reference, callableFlow }; + return { + scope, + declaration, + import_, + typeBinding, + reference, + callableFlow, + callResultAssignment, + }; } -type Topic = 'scope' | 'declaration' | 'import' | 'type-binding' | 'reference' | 'callable-flow'; +type Topic = + | 'scope' + | 'declaration' + | 'import' + | 'type-binding' + | 'reference' + | 'callable-flow' + | 'call-result-assignment'; function topicsOf(match: CaptureMatch): ReadonlySet { const topics = new Set(); @@ -323,6 +360,7 @@ function topicsOf(match: CaptureMatch): ReadonlySet { else if (name.startsWith('@type-binding.')) topics.add('type-binding'); else if (name.startsWith('@reference.')) topics.add('reference'); else if (name.startsWith('@callable-flow.')) topics.add('callable-flow'); + else if (name.startsWith('@call-result-assignment.')) topics.add('call-result-assignment'); } return topics; } @@ -598,17 +636,25 @@ function pass2AttachDeclarations( // fact is the failure mode this subsystem rejects everywhere else. // // Copying the field onto BOTH twins makes the outcome identical whichever - // one wins. Deliberately narrow — only `typeParameters`, the one field with - // an asymmetric twin today. Widening this to "merge all metadata" would - // change what every existing duplicate resolves to, which is a different - // change with a different blast radius and no evidence behind it yet. + // one wins. Deliberately narrow — only metadata whose asymmetric twins have + // executable regressions (`typeParameters` and `returnType`). Widening this + // to "merge all metadata" would change what every existing duplicate + // resolves to, which is a different change with a different blast radius + // and no evidence behind it yet. const first = firstDefByNodeId.get(def.nodeId); if (first === undefined) { firstDefByNodeId.set(def.nodeId, def); - } else if (first.typeParameters === undefined && def.typeParameters !== undefined) { - first.typeParameters = def.typeParameters; - } else if (def.typeParameters === undefined && first.typeParameters !== undefined) { - def.typeParameters = first.typeParameters; + } else { + if (first.typeParameters === undefined && def.typeParameters !== undefined) { + first.typeParameters = def.typeParameters; + } else if (def.typeParameters === undefined && first.typeParameters !== undefined) { + def.typeParameters = first.typeParameters; + } + if (first.returnType === undefined && def.returnType !== undefined) { + first.returnType = def.returnType; + } else if (def.returnType === undefined && first.returnType !== undefined) { + def.returnType = first.returnType; + } } // Find the innermost scope that contains the declaration's anchor range. @@ -1652,6 +1698,24 @@ function pass6CollectCallableFlows( } } +// ─── Pass 7: collect call-result assignment identity ────────────────────── + +function pass7CollectCallResultAssignments( + matches: readonly CaptureMatch[], + positionIndex: ReturnType, + filePath: string, + out: CallResultAssignmentSite[], +): void { + for (const match of matches) { + const call = match['@call-result-assignment.call']; + const lhs = match['@call-result-assignment.lhs']; + if (call === undefined || lhs === undefined || !nonEmpty(lhs.text)) continue; + const inScope = positionIndex.atPosition(filePath, call.range.startLine, call.range.startCol); + if (inScope === undefined) continue; + out.push({ callSite: call.range, inScope, lhs: lhs.text }); + } +} + function callableFlowKind(match: CaptureMatch): CallableFlowKind | undefined { return CALLABLE_FLOW_KINDS.find((kind) => match[`@callable-flow.${kind}`] !== undefined); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 71385a41f..5fed0ea15 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -692,7 +692,10 @@ export interface ScopeResolver { */ readonly populateWorkspaceOwners?: ( parsedFiles: readonly ParsedFile[], - ctx: { readonly fileContents: ReadonlyMap }, + ctx: { + readonly fileContents: ReadonlyMap; + readonly resolutionConfig?: unknown; + }, ) => void; /** @@ -965,6 +968,11 @@ export interface ScopeResolver { */ readonly freeCallsRequireInstanceOwnership?: boolean; + /** Whether an unqualified call inside a type may dispatch to an inherited + * instance method. Languages such as Swift allow implicit-self lookup, + * while Python/JavaScript/PHP require an explicit receiver. */ + readonly implicitThisWalksMro?: boolean; + /** * When true, a constructor-form call `Type(...)` links to the Class def * itself rather than its explicit Constructor def. Default diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index 3e7c3013c..6693a2bc6 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -121,6 +121,7 @@ export function emitFreeCallFallback( * contains the method's owner. See * `ScopeResolver.freeCallsRequireInstanceOwnership`. */ readonly freeCallsRequireInstanceOwnership?: boolean; + readonly implicitThisWalksMro?: boolean; readonly recordResolutionOutcome?: ResolutionOutcomeRecorder; /** Call sites owned by a later precise pass (for example callable-value-flow). */ readonly skipSites?: ReadonlySet; @@ -314,6 +315,7 @@ export function emitFreeCallFallback( conversionRankFn: options.conversionRankFn, conversionOnlyArgTypePrefixes: options.conversionOnlyArgTypePrefixes, constraintCompatibility: options.constraintCompatibility, + implicitThisWalksMro: options.implicitThisWalksMro, }); fnDefFromImplicitThis = fnDef !== undefined; } @@ -1167,11 +1169,14 @@ export function pickUniqueGlobalClass( * pick a method member by name with overload narrowing on arity + * argument types. Returns undefined if there's no enclosing class, * no matching method, OR narrowing leaves multiple compatible - * candidates — in the multi-candidate case, picking - * `candidates[0]` would emit a high-confidence CALLS edge whose - * target depends on registration order rather than a defensible - * resolution. Mirrors `pickUniqueGlobalCallable`'s uniqueness check - * in the same file (Codex PR #1497 review, finding 2). + * candidates — except when those survivors are a protocol/interface + * requirement plus exactly one extension witness, in which case the + * witness (the default body) is returned. An inherited class, struct, + * or enum member still wins over a protocol-extension default. + * Picking `candidates[0]` would emit a high-confidence CALLS edge + * whose target depends on registration order rather than a + * defensible resolution. Mirrors `pickUniqueGlobalCallable`'s + * uniqueness check in the same file (Codex PR #1497 review, finding 2). * * Exported for unit testing — language-agnostic logic, exercised * via synthetic stubs in `pick-implicit-this-overload.test.ts`. The @@ -1191,6 +1196,7 @@ export function pickImplicitThisOverload( readonly conversionRankFn?: ConversionRankFn; readonly conversionOnlyArgTypePrefixes?: readonly string[]; readonly constraintCompatibility?: ScopeResolver['constraintCompatibility']; + readonly implicitThisWalksMro?: boolean; }, ): SymbolDefinition | undefined { // Find the enclosing Class scope by walking parents. @@ -1211,22 +1217,118 @@ export function pickImplicitThisOverload( const classDefId = workspaceIndex.classScopeIdToDefId.get(classScopeId); if (classDefId === undefined) return undefined; - const overloads = model.methods.lookupAllByOwner(classDefId, site.name); - if (overloads.length === 0) return undefined; - if (overloads.length === 1) return overloads[0]; + // Bare calls in an instance method use the same implicit receiver as + // `self.member()`. Prefer declarations on the enclosing type; when the + // language opts into MRO implicit-this, walk inherited owners + // nearest-first and arity-narrow per owner. Compatible-but-ambiguous + // on a nearer ancestor fail-closes — do not fall through to a farther + // override. Falling through to the global name lookup makes inherited + // defaults depend on file order. + const own = model.methods.lookupAllByOwner(classDefId, site.name); + const ownPicked = pickUniqueImplicitThisCandidate(own, site, hookCtx, workspaceIndex); + if (ownPicked !== undefined) return ownPicked; + if (own.length > 0) { + const ownCompatible = narrowOverloadCandidates(own, site.arity, site.argumentTypes, { + argumentTypeClasses: site.argumentTypeClasses, + conversionRankFn: hookCtx?.conversionRankFn, + conversionOnlyArgTypePrefixes: hookCtx?.conversionOnlyArgTypePrefixes, + constraintCompatibility: hookCtx?.constraintCompatibility, + }); + if (ownCompatible.length > 0) return undefined; + } + if (hookCtx?.implicitThisWalksMro !== true) return undefined; - // Narrow on arity + argument types. Require a UNIQUE survivor — - // ambiguous narrowing (multiple compatible candidates with no - // disambiguating signal) leaves the call unresolved rather than - // routing to an arbitrary first overload by registration order. + for (const ownerId of scopes.methodDispatch?.mroFor(classDefId) ?? []) { + const inherited = model.methods.lookupAllByOwner(ownerId, site.name); + const inheritedPicked = pickUniqueImplicitThisCandidate( + inherited, + site, + hookCtx, + workspaceIndex, + ); + if (inheritedPicked !== undefined) return inheritedPicked; + if (inherited.length > 0) { + const inheritedCompatible = narrowOverloadCandidates( + inherited, + site.arity, + site.argumentTypes, + { + argumentTypeClasses: site.argumentTypeClasses, + conversionRankFn: hookCtx?.conversionRankFn, + conversionOnlyArgTypePrefixes: hookCtx?.conversionOnlyArgTypePrefixes, + constraintCompatibility: hookCtx?.constraintCompatibility, + }, + ); + if (inheritedCompatible.length > 0) return undefined; + } + } + return undefined; +} + +function pickUniqueImplicitThisCandidate( + overloads: readonly SymbolDefinition[], + site: { + readonly arity?: number; + readonly argumentTypes?: readonly string[]; + readonly argumentTypeClasses?: readonly import('gitnexus-shared').ParameterTypeClass[]; + }, + hookCtx: + | { + readonly conversionRankFn?: ConversionRankFn; + readonly conversionOnlyArgTypePrefixes?: readonly string[]; + readonly constraintCompatibility?: ScopeResolver['constraintCompatibility']; + } + | undefined, + workspaceIndex: WorkspaceResolutionIndex, +): SymbolDefinition | undefined { + if (overloads.length === 0) return undefined; const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes, { argumentTypeClasses: site.argumentTypeClasses, conversionRankFn: hookCtx?.conversionRankFn, conversionOnlyArgTypePrefixes: hookCtx?.conversionOnlyArgTypePrefixes, constraintCompatibility: hookCtx?.constraintCompatibility, }); - if (candidates.length !== 1) return undefined; - return candidates[0]; + if (candidates.length === 0) return undefined; + if (candidates.length === 1) return candidates[0]; + const witnesses = preferExtensionWitnesses(candidates, workspaceIndex); + return witnesses.length === 1 ? witnesses[0] : undefined; +} + +function preferExtensionWitnesses( + candidates: readonly SymbolDefinition[], + workspaceIndex: WorkspaceResolutionIndex, +): readonly SymbolDefinition[] { + const onOwnerType: SymbolDefinition[] = []; + const extensionWitnesses: SymbolDefinition[] = []; + for (const def of candidates) { + const ownerId = def.ownerId; + if (ownerId === undefined) { + extensionWitnesses.push(def); + continue; + } + const ownerScope = workspaceIndex.classScopeByDefId?.get(ownerId); + const livesOnOwner = + ownerScope?.ownedDefs.some((owned) => owned.nodeId === def.nodeId) === true; + if (livesOnOwner) onOwnerType.push(def); + else extensionWitnesses.push(def); + } + if (extensionWitnesses.length === 0 || onOwnerType.length === 0) return candidates; + const concrete = onOwnerType.filter((def) => !isProtocolLikeOwner(def.ownerId, workspaceIndex)); + if (concrete.length > 0) return concrete; + return extensionWitnesses; +} + +function isProtocolLikeOwner( + ownerId: string | undefined, + workspaceIndex: WorkspaceResolutionIndex, +): boolean { + if (ownerId === undefined) return false; + const ownerScope = workspaceIndex.classScopeByDefId?.get(ownerId); + if (ownerScope === undefined) return false; + return ownerScope.ownedDefs.some( + (owned) => + owned.nodeId === ownerId && (owned.type === 'Protocol' || owned.type === 'Interface'), + ); } /** diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 58dd19248..27a96d599 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -24,6 +24,7 @@ */ import type { ParsedFile, RegistryProviders } from 'gitnexus-shared'; +import type { TypeRef } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../../graph/types.js'; import { generateId } from '../../../../lib/utils.js'; import { lookupOwnedMembersByOwner } from '../../model/owned-members-lookup.js'; @@ -84,6 +85,7 @@ import { import { emitReturnShapeMemberAccesses } from '../passes/return-shape-members.js'; import { emitImportedValueReferences } from '../passes/imported-value-refs.js'; import { + calleeIdPosKey, createCalleeIdAccumulator, type CalleeIdAccumulator, } from '../graph-bridge/callee-id-sink.js'; @@ -103,6 +105,58 @@ import { buildWorkspaceResolutionIndex } from '../workspace-index.js'; import type { ResolutionOutcome, ResolutionOutcomeRecorder } from '../resolution-outcome.js'; import { logHeapProbe } from '../../utils/heap-probe.js'; import { parseTruthyEnv } from '../../utils/env.js'; + +/** + * Join extraction-time assignment identity to Phase-4's exact resolved callee. + * Ambiguous dispatch and missing return annotations deliberately produce no + * binding. Extraction emits these facts only for untyped declarations, so an + * existing entry here is necessarily inference/mirroring and may be corrected; + * explicit annotations never enter this join. + */ +export function applyPreciseCallResultBindings( + parsedFiles: readonly ParsedFile[], + indexes: ReturnType, + workspaceIndex: ReturnType, + calleeIds: CalleeIdAccumulator, + nodeLookup: ReturnType, +): number { + let updated = 0; + const returnTypeByGraphId = new Map(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + const returnType = workspaceIndex.declaredReturnTypeByCallableId.get(def.nodeId); + if (returnType === undefined) continue; + const graphId = resolveDefGraphId(def.filePath, def, nodeLookup); + if (graphId !== undefined) returnTypeByGraphId.set(graphId, returnType); + } + } + for (const parsed of parsedFiles) { + const resolvedByPosition = calleeIds.get(parsed.filePath); + if (resolvedByPosition === undefined) continue; + for (const assignment of parsed.callResultAssignmentSites ?? []) { + const targets = resolvedByPosition.get( + calleeIdPosKey(assignment.callSite.startLine, assignment.callSite.startCol), + ); + if (targets === undefined || targets.size !== 1) continue; + const targetId = targets.values().next().value as string | undefined; + if (targetId === undefined) continue; + const returnType = returnTypeByGraphId.get(targetId); + if (returnType === undefined) continue; + const scope = indexes.scopeTree.getScope(assignment.inScope); + if (scope === undefined) continue; + (scope.typeBindings as Map).set(assignment.lhs, { + rawName: returnType.rawName, + ...(returnType.declaredSpelling !== undefined + ? { declaredSpelling: returnType.declaredSpelling } + : {}), + declaredAtScope: assignment.inScope, + source: 'assignment-inferred', + }); + updated++; + } + } + return updated; +} import { isValueDefinitionLabel } from '../../utils/ast-helpers.js'; import { TransitionalScopeTree } from '../../../../storage/scope-index-store.js'; import { forceGc } from '../../../../storage/parsedfile-store.js'; @@ -551,7 +605,6 @@ export function runScopeResolution( const undecidedSatisfaction: UndecidedSatisfaction[] = []; const recordResolutionOutcome: ResolutionOutcomeRecorder = (outcome) => { resolutionOutcomes.push(outcome); - input.recordResolutionOutcome?.(outcome); }; const PROF = process.env.PROF_SCOPE_RESOLUTION === '1'; const tStart = PROF ? process.hrtime.bigint() : 0n; @@ -638,7 +691,10 @@ export function runScopeResolution( 'sr-extract-end', `lang=${provider.language} parsedFiles=${parsedFiles.length} preExtractedHits=${preExtractedHits} skipped=${filesSkipped}`, ); - provider.populateWorkspaceOwners?.(parsedFiles, { fileContents: getFileContents() }); + provider.populateWorkspaceOwners?.(parsedFiles, { + fileContents: getFileContents(), + resolutionConfig: input.resolutionConfig, + }); provider.populateWorkspaceReferences?.(parsedFiles, { fileContents: getFileContents(), treeCache, @@ -844,7 +900,9 @@ export function runScopeResolution( // views that delegate to it (out-of-core scope index) — the index pins no Scope objects, so the // disk seal can reclaim them. Byte-identical: the view returns the same Scope // the resident tree holds (or a value-identical revived one in disk mode). - const workspaceIndex = buildWorkspaceResolutionIndex(parsedFiles, indexes.scopeTree); + const workspaceIndex = buildWorkspaceResolutionIndex(parsedFiles, indexes.scopeTree, { + stripTypePreservingDecoration: provider.stripTypePreservingDecoration, + }); logHeapProbe('sr-post-workspaceIndex', `lang=${provider.language}`); // Cross-file implicit-namespace visibility (C#). Must run before @@ -1011,6 +1069,12 @@ export function runScopeResolution( const deferredIndirectCollection = collectDeferredIndirectCollection(emitParsedFiles, indexes); const deferredIndirectSites = deferredIndirectCollection.sites; const callableArgumentSites = new Set(); + const callResultAssignmentSites = new Set(); + for (const parsed of emitParsedFiles) { + for (const site of parsed.callResultAssignmentSites ?? []) { + callResultAssignmentSites.add(callableFlowSiteKey(parsed.filePath, site.callSite)); + } + } if (input.pdg !== true && deferredIndirectSites.size > 0) { for (const parsed of emitParsedFiles) { for (const site of parsed.callableFlowSites ?? []) { @@ -1025,11 +1089,14 @@ export function runScopeResolution( // propagation. Populated below at every CALLS emit path before dedup; the CFG // join still consumes it only inside the `input.pdg` block. const calleeIdAccumulator: CalleeIdAccumulator | undefined = - input.pdg === true || deferredIndirectSites.size > 0 + input.pdg === true || deferredIndirectSites.size > 0 || callResultAssignmentSites.size > 0 ? createCalleeIdAccumulator( input.pdg === true ? undefined - : (filePath, line, col) => callableArgumentSites.has(`${filePath}:${line}:${col}`), + : (filePath, line, col) => { + const key = `${filePath}:${line}:${col}`; + return callableArgumentSites.has(key) || callResultAssignmentSites.has(key); + }, ) : undefined; const receiverBound = callableFlowOnly @@ -1060,7 +1127,7 @@ export function runScopeResolution( heritageTypeArguments, }, ); - const receiverExtras = receiverBound.emitted; + let receiverExtras = receiverBound.emitted; if (receiverBound.dispatchFanoutSkipped > 0) { // Never drop dispatch coverage silently (#2829) — same contract as the // property-dispatch cap below. An interface member over the cap loses real @@ -1112,6 +1179,7 @@ export function runScopeResolution( isFileLocalDef: provider.isFileLocalDef, isBuiltInName: provider.languageProvider.isBuiltInName, freeCallsRequireInstanceOwnership: provider.freeCallsRequireInstanceOwnership === true, + implicitThisWalksMro: provider.implicitThisWalksMro === true, isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller, resolveAdlCandidates: provider.resolveAdlCandidates, resolveQualifiedFreeCall: provider.resolveQualifiedFreeCall, @@ -1123,6 +1191,63 @@ export function runScopeResolution( skipSites: deferredIndirectSites, }, ); + const replayedCallResultBindings = + callableFlowOnly || calleeIdAccumulator === undefined + ? 0 + : applyPreciseCallResultBindings( + emitParsedFiles, + indexes, + workspaceIndex, + calleeIdAccumulator, + postHeritageNodeLookup, + ); + if (replayedCallResultBindings > 0) { + const handledBeforeReplay = new Set(handledSites); + const replayedReceiverBound = emitReceiverBoundCalls( + graph, + indexes, + emitParsedFiles, + postHeritageNodeLookup, + handledSites, + provider, + workspaceIndex, + readonlyModel, + { + calleeIdSink: calleeIdAccumulator, + isBuiltInName: provider.languageProvider.isBuiltInName, + heritageTypeArguments, + }, + ); + receiverExtras += replayedReceiverBound.emitted; + if (replayedReceiverBound.dispatchFanoutSkipped > 0) { + logger.warn( + { + lang: provider.language, + dispatchFanoutSkipped: replayedReceiverBound.dispatchFanoutSkipped, + dispatchFanoutSkippedNames: replayedReceiverBound.dispatchFanoutSkippedNames, + fanoutCap: MAX_INTERFACE_DISPATCH_FANOUT, + replay: true, + }, + 'interface-dispatch: members over the fan-out cap dropped implementors (their CALLS edges were not emitted)', + ); + } + const resolvedOnReplay = new Set(); + for (const key of handledSites) { + if (!handledBeforeReplay.has(key)) resolvedOnReplay.add(key); + } + if (resolvedOnReplay.size > 0) { + for (let i = resolutionOutcomes.length - 1; i >= 0; i--) { + const outcome = resolutionOutcomes[i]; + if ( + outcome.kind === 'suppressed' && + outcome.reason === 'receiver-unresolved' && + resolvedOnReplay.has(callableFlowSiteKey(outcome.filePath, outcome.range)) + ) { + resolutionOutcomes.splice(i, 1); + } + } + } + } const referenceSkipSites = new Set(handledSites); for (const key of deferredIndirectSites) referenceSkipSites.add(key); const { emitted, skipped } = callableFlowOnly @@ -1688,6 +1813,8 @@ export function runScopeResolution( logHeapProbe('sr-end', `lang=${provider.language} parsedFiles=${parsedFiles.length}`); + for (const outcome of resolutionOutcomes) input.recordResolutionOutcome?.(outcome); + return { filesProcessed: parsedFiles.length, filesSkipped, diff --git a/gitnexus/src/core/ingestion/scope-resolution/workspace-index-types.ts b/gitnexus/src/core/ingestion/scope-resolution/workspace-index-types.ts index 408e5ca42..012562ce7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/workspace-index-types.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/workspace-index-types.ts @@ -13,7 +13,7 @@ * on `SemanticModel`, and for the builder itself. */ -import type { Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { Scope, ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared'; export interface WorkspaceResolutionIndex { /** Class def `nodeId` → that class's `Scope`. */ @@ -36,4 +36,10 @@ export interface WorkspaceResolutionIndex { * killer). "First module-local callable in `moduleScopeByFile` order" is the * exact semantics the old scan returned, so it is byte-identical. */ readonly exportedCallableByName: ReadonlyMap; + + /** Exact callable def `nodeId` → its declared return type binding. + * Built from definitions owned by each callable's Function scope, so methods + * declared in extension/partial scopes remain addressable even when those + * scopes deliberately own no separate class-like definition. */ + readonly declaredReturnTypeByCallableId: ReadonlyMap; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts b/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts index 597819f58..683ed0972 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts @@ -39,7 +39,14 @@ * Build cost is O(totalScopes). Read-only after construction. */ -import type { ParsedFile, Scope, ScopeId, ScopeTree, SymbolDefinition } from 'gitnexus-shared'; +import type { + ParsedFile, + Scope, + ScopeId, + ScopeTree, + SymbolDefinition, + TypeRef, +} from 'gitnexus-shared'; import type { WorkspaceResolutionIndex } from './workspace-index-types.js'; import { isClassLike } from './scope/walkers.js'; @@ -107,11 +114,15 @@ class ScopeByKeyView implements ReadonlyMap { export function buildWorkspaceResolutionIndex( parsedFiles: readonly ParsedFile[], scopeTree?: ScopeTree, + options?: { + readonly stripTypePreservingDecoration?: (typeName: string) => string | undefined; + }, ): WorkspaceResolutionIndex { const classScopeIdByDefId = new Map(); const classScopeIdToDefId = new Map(); const moduleScopeIdByFile = new Map(); const exportedCallableByName = new Map(); + const declaredReturnTypeByCallableId = new Map(); // Back-compat (no scopeTree): keep the direct Scope-object maps. const classScopeByDefIdDirect = scopeTree === undefined ? new Map() : undefined; const moduleScopeByFileDirect = scopeTree === undefined ? new Map() : undefined; @@ -138,6 +149,24 @@ export function buildWorkspaceResolutionIndex( } for (const scope of parsed.scopes) { + if (scope.kind === 'Function' && scope.parent !== null) { + for (const def of scope.ownedDefs) { + if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') { + continue; + } + if (def.returnType !== undefined) { + const stripped = options?.stripTypePreservingDecoration?.(def.returnType); + const rawName = stripped ?? def.returnType; + declaredReturnTypeByCallableId.set(def.nodeId, { + rawName, + ...(rawName !== def.returnType ? { declaredSpelling: def.returnType } : {}), + declaredAtScope: scope.id, + source: 'return-annotation', + }); + continue; + } + } + } if (scope.kind !== 'Class') continue; const cd = scope.ownedDefs.find((d) => isClassLike(d.type)); if (cd !== undefined) { @@ -157,5 +186,11 @@ export function buildWorkspaceResolutionIndex( ? moduleScopeByFileDirect! : new ScopeByKeyView(moduleScopeIdByFile, scopeTree); - return { classScopeByDefId, classScopeIdToDefId, moduleScopeByFile, exportedCallableByName }; + return { + classScopeByDefId, + classScopeIdToDefId, + moduleScopeByFile, + exportedCallableByName, + declaredReturnTypeByCallableId, + }; } diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index e49044b7e..a29b17273 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -769,7 +769,9 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // v101 (#3294 review): Rust bare-keyword glob imports retain crate/self/super // instead of an empty target path; restricted pub(...) imports are no longer // captured as unrestricted reexports. Re-extract both facts on warm indexes. -const SCHEMA_BUMP = 101; +// v102: ParsedFile gained callResultAssignmentSites; old durable shards do +// not carry the exact assignment identity required by return-type replay. +const SCHEMA_BUMP = 102; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/src/storage/parsedfile-store.ts b/gitnexus/src/storage/parsedfile-store.ts index 58c336c95..4233850d8 100644 --- a/gitnexus/src/storage/parsedfile-store.ts +++ b/gitnexus/src/storage/parsedfile-store.ts @@ -53,6 +53,7 @@ import path from 'node:path'; import v8 from 'node:v8'; import vm from 'node:vm'; import type { + CallResultAssignmentSite, CallableFlowSite, ParsedFile, ReferenceSite, @@ -352,20 +353,26 @@ export const loadParsedFilesForPaths = async ( rejectedFiles++; continue; } + const assignments = sanitizeCallResultAssignmentSites(pf.callResultAssignmentSites); + if (assignments === undefined) { + rejectedFiles++; + continue; + } const chains = sanitizeReceiverChains(pf.referenceSites); if (chains === undefined) { rejectedFiles++; continue; } - if (flow.dropped === 0 && chains.dropped === 0) { + if (flow.dropped === 0 && assignments.dropped === 0 && chains.dropped === 0) { out.set(pf.filePath, pf); } else { - droppedSites += flow.dropped; + droppedSites += flow.dropped + assignments.dropped; droppedChains += chains.dropped; filesWithDroppedSites++; out.set(pf.filePath, { ...pf, ...(flow.dropped === 0 ? {} : { callableFlowSites: flow.sites }), + ...(assignments.dropped === 0 ? {} : { callResultAssignmentSites: assignments.sites }), ...(chains.dropped === 0 ? {} : { referenceSites: chains.sites }), }); } @@ -401,6 +408,19 @@ function sanitizeCallableFlowSites( return { sites, dropped: value.length - sites.length }; } +function sanitizeCallResultAssignmentSites( + value: unknown, +): { sites: readonly CallResultAssignmentSite[] | undefined; dropped: number } | undefined { + if (value === undefined) return { sites: undefined, dropped: 0 }; + if (!Array.isArray(value)) return undefined; + const bounded = + value.length > MAX_CALLABLE_FLOW_SITES_PER_FILE + ? value.slice(0, MAX_CALLABLE_FLOW_SITES_PER_FILE) + : value; + const sites = bounded.filter(isValidCallResultAssignmentSite); + return { sites, dropped: value.length - sites.length }; +} + /** * Untrusted-boundary handling for the compact `receiverChain` on a reference site. * @@ -503,6 +523,15 @@ function isValidCallableFlowSite(value: unknown): value is CallableFlowSite { } } +function isValidCallResultAssignmentSite(value: unknown): value is CallResultAssignmentSite { + return ( + isRecord(value) && + isValidRange(value.callSite) && + isBoundedString(value.inScope) && + isBoundedString(value.lhs) + ); +} + function isValidOperand(value: unknown): boolean { if (!isRecord(value)) return false; return ( diff --git a/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/AUnrelated.swift b/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/AUnrelated.swift new file mode 100644 index 000000000..da849ee4d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/AUnrelated.swift @@ -0,0 +1,13 @@ +struct OtherStore { let value: Int } + +struct OtherScenario { + private func makeStore(currentValue: Int) -> OtherStore { OtherStore(value: currentValue) } +} + +enum OtherValue { + private static func makeValue(year: Int, month: Int, day: Int) -> Int { -1 } +} + +enum OtherItems { + private static func insertItem(into store: Store) -> String { "decoy" } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Scenario.swift b/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Scenario.swift new file mode 100644 index 000000000..d6d8fedd0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Scenario.swift @@ -0,0 +1,8 @@ +struct Scenario: ScenarioSupport { + func run() -> String { + let store = makeStore() + let value = makeValue(year: 2, month: 3, day: 4) + let item = insertItem(into: store, count: 1) + return "\(store.worker.execute()):\(value):\(item)" + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Support.swift b/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Support.swift new file mode 100644 index 000000000..596ba1472 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Support.swift @@ -0,0 +1,13 @@ +protocol ScenarioSupport {} + +struct Worker { + func execute() -> String { "worker" } +} + +struct Store { let worker: Worker } + +extension ScenarioSupport { + func makeStore(observer: Int? = nil) -> Store { Store(worker: Worker()) } + func makeValue(year: Int, month: Int, day: Int) -> Int { year + month + day } + func insertItem(into store: Store, count: Int = 1) -> String { "item" } +} diff --git a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json index dd1540ec7..c7ccea7ee 100644 --- a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json @@ -1,31 +1,31 @@ { "swift-abstract-dispatch/Sources/App.swift": { - "captureGroups": 10, - "digest": "0ec4bb27184ce68dca2ef6bef58c23353b363be9aa483d0bbea5321bccf85ae2" + "captureGroups": 12, + "digest": "29e6acb3afba30eca11eb947812e834196a01dceac7eff131710405b9f5963fa" }, "swift-abstract-dispatch/Sources/Repository.swift": { "captureGroups": 26, - "digest": "d8a8c59b7236d6b25a063b8584637d5c89a86cf022776272f80aeb4f1e4826b9" + "digest": "6978cdb9fc70bdba0b640d547e2fb0c4e21c96463303e91612ef30ba1597b3c4" }, "swift-await-try/App.swift": { - "captureGroups": 13, - "digest": "7ba2042b0cb0a6e5c5d459dc460c509ace477b0ce97bec0cc8aaf981f320e69a" + "captureGroups": 15, + "digest": "6d3bef6d2c98dfaf64c6cb728a54aad87efcc16d7a8ce5aeb1cc2e1207b4d1a8" }, "swift-await-try/Models.swift": { "captureGroups": 21, - "digest": "df2a06182275e023c6f40e978f60a0ff933fccc711d7c15f4a2434e8e282a653" + "digest": "c00efbc5b57cce03a5847b304f0956a424ccc345309c8907cd12e5c32449ced1" }, "swift-call-result-binding/App.swift": { - "captureGroups": 7, - "digest": "979534a46ee420a7a5dfd6b029c9fe5d67ca1b00f3bdc5e8977d72a1c316b75d" + "captureGroups": 8, + "digest": "ae788e37585c57c13bdb29cce48a0fbd1bcdbfa91ba415bdbc675945e500919d" }, "swift-call-result-binding/Models.swift": { "captureGroups": 15, - "digest": "d312b4003cbe7f3eec8e1b6f924b0e1afb7023ca086ec26ef0ad69078cc67925" + "digest": "f192ae885ea3de848f3b93acf7672d91b0118800cb0c6513628a6c09c26fadbf" }, "swift-child-extends-parent/Sources/App.swift": { - "captureGroups": 10, - "digest": "9532b3989cd104dd468deb8d671e727cdf944ebb858e002072875519a014e968" + "captureGroups": 11, + "digest": "4841a7a147f5cac64ed70af845534845bf34701af4bc55a6a9f02fb09ad4b716" }, "swift-child-extends-parent/Sources/Child.swift": { "captureGroups": 4, @@ -33,55 +33,55 @@ }, "swift-child-extends-parent/Sources/Parent.swift": { "captureGroups": 7, - "digest": "09d26410736d5ca5ec8dfe598212e1eefc64075bc2199fda170bb08a478392a4" + "digest": "705670271274ef3f484a4974aba743d8871c8e0ba41afd8bcf07a48225c12880" }, "swift-class-func-receiver/Service.swift": { "captureGroups": 27, "digest": "5d7e5b278bdc94cc2014ebf2f8c975afb10aa80bc2e1bd62ec5559a102fc8e41" }, "swift-constructor-fallback/App.swift": { - "captureGroups": 7, - "digest": "782cf85fcfe24b2baea6226f09226d23adc2bab591380bd33600cfe8509dbb83" + "captureGroups": 8, + "digest": "c5b2e7f8ea76636a45d46ecee6b44f0687fc6fe2f983bab0d98f8ce2a5c16828" }, "swift-constructor-fallback/Service.swift": { "captureGroups": 7, - "digest": "5744bba4c9b821b2264dea36de3e864723f8ca1be9face21ec9de55117a9e7a5" + "digest": "083dd54d6999b18bd96ee51503d5b63957e49f981199022d27112cf9478f89e0" }, "swift-constructor-type-inference/Models/Repo.swift": { "captureGroups": 15, - "digest": "e8ea96d49fc2113e29b88961686e39472b92481e929b3f887d53b65201e2e7bb" + "digest": "e5f29bb46ce39f7fcf98a53378ca00825134350afb6c81dd9fe4ff07e1201348" }, "swift-constructor-type-inference/Models/User.swift": { "captureGroups": 15, - "digest": "fe1039891719c640bfa7d362040460addcf744eed1350521cbcc7c9399395087" + "digest": "349b1dc04ef00d74058c7e8e7431cd19529c77ffeac5dac76ee7fe5a4ff39f53" }, "swift-constructor-type-inference/Services/App.swift": { - "captureGroups": 12, - "digest": "a9c40a2dcb51c6e6a9adbaa869183eed90a81302729d4d7a0c300084ea2978e1" + "captureGroups": 14, + "digest": "54cf81184c5017a96c983ee6c5e0e46efa99b1fd4eb67a77d911a5303094173a" }, "swift-enum-members/Direction.swift": { "captureGroups": 18, - "digest": "bd0bf779b08305fcd7dfadfcad45319123393aadc17044784314a7b34cb9e6ae" + "digest": "9eb1209d4e89040515e21c0efa17490c58232aa8f5f853ca210e6c1e0470d4cc" }, "swift-export-visibility/App.swift": { - "captureGroups": 10, - "digest": "6be49190e8a120ed1c3c3812afaa717f60fc12dae82d4b1b7158a358c53ea75a" + "captureGroups": 11, + "digest": "a0ef5984650c2220865999fba38d0803533f1c41d9941031d2b8c25e894ae84f" }, "swift-export-visibility/Visible.swift": { "captureGroups": 15, - "digest": "c8515702b94de2f9e3ad009daec61bad6f9328fb39796e57ec03348438446c4c" + "digest": "a0ef7c53a9cc19836afbe94c9bd5f32cabf1ce2756bee4e36267b5a64d1b66f0" }, "swift-extension-dedup/App.swift": { - "captureGroups": 7, - "digest": "f7062b53f33ec548a2ba5836ef5507b4c662d74ac4935dc654e6f95081f6fcbd" + "captureGroups": 8, + "digest": "4c9583519283afe6f7b80261cc2105781b86ea90bae69882468a71276bbe162c" }, "swift-extension-dedup/Product.swift": { "captureGroups": 15, - "digest": "c0f08c41829db294b2c89876a329cfffd7f3fb0a4b2bca315ae867a1d263a068" + "digest": "aaa046017c54533f938ec03669a80ef4b541fd32dad968ec7a339a6438f860b3" }, "swift-extension-dedup/ProductExtensions.swift": { "captureGroups": 8, - "digest": "228f2f32494a51fb371c754d9ae11a94e7181a14d875a339c631e26f82df542e" + "digest": "faa5f45d1bbad8478902814d100d776681b2e9015c7bfd519b432effdfb6f915" }, "swift-field-types/App.swift": { "captureGroups": 7, @@ -89,7 +89,7 @@ }, "swift-field-types/Models.swift": { "captureGroups": 20, - "digest": "cad215dd5ce4d0c93056ec5600663b8d17eebbefa06d09675fb64392196531ab" + "digest": "cb40d6eee9315b77bb6f1b1f82644a3636e7ef9aa30cf5edddd55d845b832486" }, "swift-for-loop-inference/App.swift": { "captureGroups": 5, @@ -105,39 +105,39 @@ }, "swift-if-let-guard-let/Models.swift": { "captureGroups": 19, - "digest": "51d6986620c07de553b2c8d848d908899a96c7174a7ae56bf68c7de930c8d5c5" + "digest": "9cf6d8e36976de137d378e2822e0883c7c33a869bc6615b632ee1b7433fde89e" }, "swift-implicit-imports/App.swift": { - "captureGroups": 7, - "digest": "5a423f851ee81956f48209e618c71668394e2a7312d812656a7f326233bc1387" + "captureGroups": 8, + "digest": "c2a0718ef2ef0f686a36c247bc7a37a64dbfc38f49c400dcd41462050a2e75f4" }, "swift-implicit-imports/Models.swift": { "captureGroups": 7, - "digest": "995e604bfd03b42eaeabff130fd1671e524832c64ee52f01cd193a88283d3764" + "digest": "aff5d14d229b15b0806531ebd6db27600b358a6a6fd57a481893e702e3839eb7" }, "swift-init-cross-file/User.swift": { "captureGroups": 18, - "digest": "13d60237fb85ddeba695f3e5f0a202c327309bf159a04e696531c419c42e8cf1" + "digest": "005c808719efd792c7ee8f73b863047db27ba57dd7c3c94e0e550186c4274c4e" }, "swift-init-cross-file/main.swift": { - "captureGroups": 8, - "digest": "3007e4849cf77cc6cb360a1dbf70abec414a408076020f4730d01b8a80c1ddd3" + "captureGroups": 9, + "digest": "c4d442b67247406de3b1158b958f6f3e294f11c0bbb360ed32489582769a1187" }, "swift-member-write-access/App.swift": { "captureGroups": 14, - "digest": "f3c28a41c270cea237598bd20c688ba6ab791e3701e85c66bfe3aff7ecfb1ec8" + "digest": "7be68b7e510fefac99c94a4ebab3eb96556a0e587155b6c82b7602705e270987" }, "swift-member-write-access/Models.swift": { "captureGroups": 26, - "digest": "376359897f8706015f702b2f3346ad4ca4e78e9f11ae6425746ae57116c2cc51" + "digest": "0eb01f35af949541bf63f781c0d23d2b890a09a858575d2d0922093657bb7661" }, "swift-method-enrichment/Sources/Animal.swift": { "captureGroups": 24, - "digest": "48bd53d1e69d4a48569985296f5aa44d4b41ff474566445f4e3d33390165c84d" + "digest": "49eec3df81637274541eb67fd2a640d22ec2166a17854d3f7be179294eee1386" }, "swift-method-enrichment/Sources/App.swift": { - "captureGroups": 10, - "digest": "906c1f026eb183306a19eff5fafd888fc8982434e2ab07bc886100b99653359b" + "captureGroups": 13, + "digest": "af60edc088ece4298751041cd8cb3fdf3c2a3c03f14f78fcead821b275260f7c" }, "swift-multi-if-let/App.swift": { "captureGroups": 17, @@ -145,19 +145,19 @@ }, "swift-multi-if-let/Models.swift": { "captureGroups": 24, - "digest": "e6add0cb3679cb2800094e41d4afc66c5d714c19ffefc38edfb5686ed9d46d51" + "digest": "b681c53b823b275dd517d29244b8f1b70094e69159d9e49193b6d9fd117385cc" }, "swift-multidir-target/Package.swift": { - "captureGroups": 5, - "digest": "7c27b1db8b34bf3e81963c29f9f51f1226c1012b7a028c6207209914412b9d5f" + "captureGroups": 6, + "digest": "b7401530c522ff647a7a1d958a35314f669d5dfad2b39578fb9866d2f2d76e58" }, "swift-multidir-target/Sources/Alpha/Core/User.swift": { "captureGroups": 7, "digest": "a67cb60787680595b43af3fdd371ad84237e17fa7de928610a8c14e8ac062043" }, "swift-multidir-target/Sources/Alpha/Entry/App.swift": { - "captureGroups": 7, - "digest": "3f5df4e88a54d05032cb00c999551cb4f41e0782616ec0cdbf353f3b80a4404d" + "captureGroups": 8, + "digest": "6670c64ae61885694d980ab798e5bab0664bf6b95727ce8a70b08749790b8052" }, "swift-multidir-target/Sources/Beta/Core/User.swift": { "captureGroups": 7, @@ -168,8 +168,8 @@ "digest": "356dcd75eb93636607d406e26ce0e62dfa1fe9c78dcd1f950adca304a14d3ad1" }, "swift-multifolder-nopackage/Services/App.swift": { - "captureGroups": 7, - "digest": "7ed3ccfa724fd58d3c9e10a176101364f2a01f20a7a87f9e3d76b715e4a307d8" + "captureGroups": 8, + "digest": "469f895fc72297a08eb0c7ddbf7fdbb812c9296076b4797cd9262e2b7ab59a94" }, "swift-nested-extension/Extension.swift": { "captureGroups": 7, @@ -180,28 +180,40 @@ "digest": "2738a4d77473a2641166b95bc1107f99afca55e9b9d4186eb102e053d378d8ab" }, "swift-overload-dispatch/App.swift": { - "captureGroups": 7, - "digest": "db8515d0d61419e827767ced61b6bb1949e672b8df11c35cc96882d60f007e07" + "captureGroups": 8, + "digest": "da644f9163f61ef3ec0a000d31dd93b5f0dd11b33def9d96ffe425ff4288b922" }, "swift-overload-dispatch/Repository.swift": { "captureGroups": 11, - "digest": "07c6509becf6ae01ae336c53ddeeff38b486bea4a7facad0edf129eb67401408" + "digest": "0dadccb34bc2cc5150bc7d2ac878c2d8f6e1526738e5b5a6301161ff2fe6ae3f" }, "swift-overload-dispatch/SqlRepository.swift": { "captureGroups": 28, - "digest": "59d11755ba6796355751608bbf74bd03b99edd6c601c51dcd3e46ff33a4af659" + "digest": "891d106fd673247c89ffbcaf7fe4f6103e2ed79cac09bcf5c3933385c77c287f" }, "swift-parent-resolution/Sources/Models/BaseModel.swift": { "captureGroups": 7, - "digest": "0d9056d5dbd480f04ad915c628c81a71e9c1b4ec8981d8027d3a4edcd0f06427" + "digest": "0dbf9f9aff651e5231d87b31d74d0c9d37b30ad6ecdf9e1913cb0da3539bbe00" }, "swift-parent-resolution/Sources/Models/Serializable.swift": { "captureGroups": 6, - "digest": "502ce9136303235cb8e1cdd459afeb4bb76c658d186c63731a3dbb3c42fadac9" + "digest": "5551bfbb93cacb230bb635880af1d87fcaffdff6c54173ff1db5aa84079cef36" }, "swift-parent-resolution/Sources/Models/User.swift": { "captureGroups": 10, - "digest": "9bc07345adb74d5e2b4711354175825ef00918dbd61785e472d091f14567f9d5" + "digest": "014f2ea08b5a45692b8fbd2dc6cc3e16004908fe6aa229535f59c59c7a4c6a93" + }, + "swift-protocol-extension-implicit-self/AUnrelated.swift": { + "captureGroups": 33, + "digest": "2df68574e940bc9d384517dc8ab845525e05a0cd8612973360e4c5c2619622dc" + }, + "swift-protocol-extension-implicit-self/Scenario.swift": { + "captureGroups": 23, + "digest": "e0713e3117f5c668b2a96fde7c1e62457e433b9072b8ed4ad9b96af501f0cf39" + }, + "swift-protocol-extension-implicit-self/Support.swift": { + "captureGroups": 41, + "digest": "60b645833f81961448817fad0c4c5431b0cb2d19e5d37aa711a65aa600e7438d" }, "swift-protocol-property/Repository.swift": { "captureGroups": 7, @@ -213,34 +225,34 @@ }, "swift-qualified-base/Sources/Outer.swift": { "captureGroups": 9, - "digest": "1ff59349dc4d45a5e1f6fdebf19caf65427d3904eddbc50efeb0879e31574a17" + "digest": "0c6af8f37f15553ed2d6199689994606ae580026e7a608d534cea5834021c89d" }, "swift-return-type-inference/App.swift": { - "captureGroups": 21, - "digest": "ec0a14090e0240bdd52a955cf7c95c2bc994085ca1482911dd882c5cdaa17c81" + "captureGroups": 23, + "digest": "6e342715fd1d2fa576a713fdf3bdd4f1367566182442b8398f6b35b861946a8d" }, "swift-return-type-inference/Models.swift": { "captureGroups": 29, - "digest": "bc539da3c97ac77543b7a1cce457c6ec0f8072c1e5861bfc027cf8fe8f4b541c" + "digest": "1616d99b6c529274c0fe33645b58c69a507162353de96694d90d7539e6c69569" }, "swift-return-type/App.swift": { - "captureGroups": 7, - "digest": "979534a46ee420a7a5dfd6b029c9fe5d67ca1b00f3bdc5e8977d72a1c316b75d" + "captureGroups": 8, + "digest": "ae788e37585c57c13bdb29cce48a0fbd1bcdbfa91ba415bdbc675945e500919d" }, "swift-return-type/Models.swift": { "captureGroups": 21, - "digest": "dc230a5f6085165fb717632f62b65fd3c99825b5fcc7a3a597a8d9143627729e" + "digest": "f3daaadcce524d8c44ef4077bb858a1124e3f9ef9525d5c84fb1288f79e97c4a" }, "swift-self-this-resolution/Sources/Models/Repo.swift": { "captureGroups": 7, - "digest": "61fe84aa9826726ee02bce54c62507cc4f27e56e9be2d57c10f55dd8043e13d9" + "digest": "44f6a8907accee7b3dfc6d35de6a70d3e8a154e1bc602f8545aec3bcd2f9af9c" }, "swift-self-this-resolution/Sources/Models/User.swift": { "captureGroups": 11, - "digest": "1841837c805e19746138dbb80ccdfccbce483d68d1e7ea08d92daec255cfbdc7" + "digest": "133a8a1186742b19b7408703262fed82ebdf2aa48b9728ae78aa3b26b5b06428" }, "synthetic:dao-20": { "captureGroups": 361, - "digest": "aa1747e2f297f5a5a71bd0361273f5505adf93db3d4a2ae3111916bc109c1c7b" + "digest": "56dde1e182164a4ad86de0254e7b683596d9b0340ec941fc09cdff598f16c6dc" } } diff --git a/gitnexus/test/integration/resolvers/swift-local-vs-method-label-split.test.ts b/gitnexus/test/integration/resolvers/swift-local-vs-method-label-split.test.ts index ff7c0fff2..4a29528cd 100644 --- a/gitnexus/test/integration/resolvers/swift-local-vs-method-label-split.test.ts +++ b/gitnexus/test/integration/resolvers/swift-local-vs-method-label-split.test.ts @@ -116,19 +116,7 @@ describe.skipIf(!swiftAvailable)('a function-local callable keeps its own node ( }); }); - /** - * KNOWN GAP, pinned so it cannot be mistaken for part of the fix above. - * - * `helper(x, x)` inside `run` still targets the one-argument METHOD instead of - * the two-argument local. That is decided upstream of the graph bridge: the - * free-call binding hands `emitFreeCallFallback` the class-member def - * (`def:src/app.swift#5:4:Method:helper`), never the local's - * (`def:src/app.swift#9:8:Method:helper`), so no def→node mapping can correct - * it — both defs carry the same `qualifiedName` and the same label, and the - * binding walk picks the member. Recorded here rather than fixed because it - * lives in the scope walk, not in `resolveDefGraphId`. - */ - it('KNOWN GAP: the call to the local still binds to the same-named method', () => { - expect(targetsFrom(RUN)).toEqual([METHOD_HELPER]); + it('binds the call in run to the function-local rather than the same-named method', () => { + expect(targetsFrom(RUN)).toEqual([LOCAL_HELPER]); }); }); diff --git a/gitnexus/test/integration/resolvers/swift.test.ts b/gitnexus/test/integration/resolvers/swift.test.ts index 9b80d92a2..568cf3346 100644 --- a/gitnexus/test/integration/resolvers/swift.test.ts +++ b/gitnexus/test/integration/resolvers/swift.test.ts @@ -305,6 +305,52 @@ describe.skipIf(!swiftAvailable)('Swift extension deduplication', () => { }); }); +// --------------------------------------------------------------------------- +// Protocol-extension implicit self (issue #3273): a conforming type may call +// default implementation methods without an explicit receiver. Resolution +// must traverse the protocol's extension surface instead of falling back to +// unrelated same-named private methods elsewhere in the module. +// --------------------------------------------------------------------------- + +describe.skipIf(!swiftAvailable)('Swift protocol-extension implicit self (#3273)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'swift-protocol-extension-implicit-self'), + () => {}, + ); + }, 60000); + + it('resolves unqualified helper calls to the protocol extension', () => { + const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'run'); + for (const target of ['makeStore', 'makeValue', 'insertItem']) { + const call = calls.find((c) => c.target === target); + expect(call?.targetFilePath).toBe('Support.swift'); + } + }); + + it('keeps unrelated private same-name methods unreachable', () => { + const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'run'); + expect(calls.some((c) => c.targetFilePath === 'AUnrelated.swift')).toBe(false); + }); + + it('preserves the downstream call through the extension helper return type', () => { + const calls = getRelationships(result, 'CALLS'); + const execute = calls.find((c) => c.source === 'run' && c.target === 'execute'); + expect(execute?.targetFilePath).toBe('Support.swift'); + expect( + result.resolutionOutcomes.some( + (outcome) => + outcome.kind === 'suppressed' && + outcome.reason === 'receiver-unresolved' && + outcome.filePath === 'Scenario.swift' && + outcome.name === 'execute', + ), + ).toBe(false); + }); +}); + // --------------------------------------------------------------------------- // Constructor fallback: Swift constructors look like free function calls // (no `new` keyword). The resolver retries with constructor form when diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index f02c3879e..b52cb30a5 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -279,8 +279,10 @@ describe('PARSE_CACHE_VERSION', () => { // Moved 99 -> 100 for #3253: retain absolute Rust import qualifiers. // Moved 100 -> 101 for #3294 review: retain keyword glob paths and distinguish // restricted pub(...) imports from unrestricted reexports. - it('pins SCHEMA_BUMP to 101 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(101); + // Moved 101 -> 102 for #3273: preserve exact call-result assignment facts + // required by post-resolution Swift return-type replay. + it('pins SCHEMA_BUMP to 102 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(102); expect(PARSE_CACHE_BUCKET_COUNT).toBe(128); // The PREVIOUS version must fail the reuse gate, not merely differ from the // current one — a hardcoded number outside the conflict hunk rebases cleanly @@ -288,7 +290,7 @@ describe('PARSE_CACHE_VERSION', () => { // Every nearby historical or in-flight value is rejected. for (const taken of [ 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, - 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, + 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, ]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } diff --git a/gitnexus/test/unit/parsedfile-store.test.ts b/gitnexus/test/unit/parsedfile-store.test.ts index 79187f73a..7834a9d3c 100644 --- a/gitnexus/test/unit/parsedfile-store.test.ts +++ b/gitnexus/test/unit/parsedfile-store.test.ts @@ -218,6 +218,55 @@ describe('parsedfile-store', () => { } }); + it('round-trips exact call-result assignment identities', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-')); + try { + const pf = makeStoreEntry('Scenario.swift', { + callResultAssignmentSites: [ + { + callSite: { startLine: 3, startCol: 14, endLine: 3, endCol: 25 }, + inScope: 'scope:run', + lhs: 'store', + }, + ], + }); + await persistParsedFileChunk(dir, 'assignment', [pf]); + + const loaded = await loadParsedFilesForPaths(dir, new Set(['Scenario.swift'])); + expect(loaded.get('Scenario.swift')?.callResultAssignmentSites).toEqual( + pf.callResultAssignmentSites, + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('drops malformed call-result assignments per site and rejects a non-array field', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-')); + try { + const mixed = makeStoreEntry('mixed.swift', { + callResultAssignmentSites: [ + { + callSite: { startLine: 3, startCol: 14, endLine: 3, endCol: 25 }, + inScope: 'scope:run', + lhs: 'store', + }, + { callSite: '3:14', inScope: 'scope:run', lhs: 'poison' }, + ], + }); + const garbage = makeStoreEntry('garbage.swift', { + callResultAssignmentSites: 'not-an-array', + }); + await persistParsedFileChunk(dir, 'assignment-invalid', [mixed, garbage]); + + const loaded = await loadParsedFilesForPaths(dir, new Set(['mixed.swift', 'garbage.swift'])); + expect(loaded.get('mixed.swift')?.callResultAssignmentSites).toHaveLength(1); + expect(loaded.has('garbage.swift')).toBe(false); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + it('drops a malformed callable-flow site but retains the file and its other sites (per-site sanitation, #2522)', async () => { const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-')); try { diff --git a/gitnexus/test/unit/scope-resolution/pick-implicit-this-overload.test.ts b/gitnexus/test/unit/scope-resolution/pick-implicit-this-overload.test.ts index 26f526382..3302485f5 100644 --- a/gitnexus/test/unit/scope-resolution/pick-implicit-this-overload.test.ts +++ b/gitnexus/test/unit/scope-resolution/pick-implicit-this-overload.test.ts @@ -47,25 +47,38 @@ const mkClassScope = (): Scope => ownedDefs: [], }) as unknown as Scope; -const mkScopes = (scope: Scope): ScopeResolutionIndexes => +const mkScopes = ( + scope: Scope, + mroByOwner: ReadonlyMap = new Map(), +): ScopeResolutionIndexes => ({ scopeTree: { getScope: (id: ScopeId) => (id === scope.id ? scope : undefined), }, + methodDispatch: { + mroFor: (classDefId: string) => mroByOwner.get(classDefId) ?? [], + }, }) as unknown as ScopeResolutionIndexes; -const mkWorkspaceIndex = (mapping: ReadonlyMap): WorkspaceResolutionIndex => +const mkWorkspaceIndex = ( + mapping: ReadonlyMap, + classScopeByDefId: ReadonlyMap = new Map(), +): WorkspaceResolutionIndex => ({ classScopeIdToDefId: mapping, + classScopeByDefId, }) as unknown as WorkspaceResolutionIndex; const mkModel = ( overloadsByName: ReadonlyMap, + overloadsByOwnerAndName: ReadonlyMap = new Map(), ): SemanticModel => ({ methods: { - lookupAllByOwner: (_classDefId: string, name: string) => - overloadsByName.get(name) ?? ([] as readonly SymbolDefinition[]), + lookupAllByOwner: (classDefId: string, name: string) => + overloadsByOwnerAndName.get(`${classDefId}::${name}`) ?? + overloadsByName.get(name) ?? + ([] as readonly SymbolDefinition[]), }, }) as unknown as SemanticModel; @@ -154,3 +167,238 @@ describe('pickImplicitThisOverload — uniqueness guard (Codex #1497 finding 2)' expect(result).toBeUndefined(); }); }); + +describe('pickImplicitThisOverload — inherited implicit-this', () => { + const PROTO_A = 'def:P.swift:A'; + const PROTO_B = 'def:P.swift:B'; + const site0 = { + inScope: CLASS_SCOPE_ID, + name: 'foo', + arity: 0, + argumentTypes: undefined, + }; + + it('arity-narrows a singleton on the enclosing type instead of returning it blindly', () => { + const oneArg = mkMethod({ + nodeId: 'm:own-1', + parameterCount: 1, + requiredParameterCount: 1, + }); + const scopes = mkScopes(mkClassScope()); + const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]])); + const model = mkModel(new Map([['foo', [oneArg]]])); + + expect(pickImplicitThisOverload(site0, scopes, workspace, model)?.nodeId).toBeUndefined(); + }); + + it('unions inherited owners and arity-narrows instead of taking the first MRO name hit', () => { + const aFoo = mkMethod({ + nodeId: 'm:A.foo', + ownerId: PROTO_A, + parameterCount: 1, + requiredParameterCount: 1, + }); + const bFoo = mkMethod({ + nodeId: 'm:B.foo', + ownerId: PROTO_B, + parameterCount: 0, + requiredParameterCount: 0, + }); + const scopes = mkScopes(mkClassScope(), new Map([[CLASS_DEF_ID, [PROTO_A, PROTO_B]]])); + const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]])); + const model = mkModel( + new Map(), + new Map([ + [`${CLASS_DEF_ID}::foo`, []], + [`${PROTO_A}::foo`, [aFoo]], + [`${PROTO_B}::foo`, [bFoo]], + ]), + ); + + const result = pickImplicitThisOverload(site0, scopes, workspace, model, { + implicitThisWalksMro: true, + }); + expect(result?.nodeId).toBe('m:B.foo'); + }); + + it('picks the nearest inherited override when two MRO ancestors share a signature', () => { + const MID = 'def:Mid.swift:Mid'; + const GRAND = 'def:Grand.swift:Grand'; + const midFoo = mkMethod({ + nodeId: 'm:Mid.foo', + ownerId: MID, + parameterCount: 0, + requiredParameterCount: 0, + }); + const grandFoo = mkMethod({ + nodeId: 'm:Grand.foo', + ownerId: GRAND, + parameterCount: 0, + requiredParameterCount: 0, + }); + const scopes = mkScopes(mkClassScope(), new Map([[CLASS_DEF_ID, [MID, GRAND]]])); + const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]])); + const model = mkModel( + new Map(), + new Map([ + [`${CLASS_DEF_ID}::foo`, []], + [`${MID}::foo`, [midFoo]], + [`${GRAND}::foo`, [grandFoo]], + ]), + ); + + const result = pickImplicitThisOverload(site0, scopes, workspace, model, { + implicitThisWalksMro: true, + }); + expect(result?.nodeId).toBe('m:Mid.foo'); + }); + + it('prefers a protocol-extension default over the protocol requirement of the same arity', () => { + const requirement = mkMethod({ + nodeId: 'm:P.req', + ownerId: PROTO_A, + parameterCount: 0, + requiredParameterCount: 0, + }); + const body = mkMethod({ + nodeId: 'm:P.ext', + ownerId: PROTO_A, + parameterCount: 0, + requiredParameterCount: 0, + }); + const protocolScope = { + id: 'scope:P.swift#1:1-20:1:Class' as ScopeId, + parent: null, + kind: 'Class', + range: { startLine: 1, startCol: 1, endLine: 20, endCol: 1 }, + filePath: 'P.swift', + bindings: new Map(), + typeBindings: new Map(), + ownedDefs: [ + { + nodeId: PROTO_A, + filePath: 'P.swift', + type: 'Protocol', + qualifiedName: 'A', + } as SymbolDefinition, + requirement, + ], + } as unknown as Scope; + const scopes = mkScopes(mkClassScope(), new Map([[CLASS_DEF_ID, [PROTO_A]]])); + const workspace = mkWorkspaceIndex( + new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]]), + new Map([[PROTO_A, protocolScope]]), + ); + const model = mkModel( + new Map(), + new Map([ + [`${CLASS_DEF_ID}::foo`, []], + [`${PROTO_A}::foo`, [requirement, body]], + ]), + ); + + const result = pickImplicitThisOverload(site0, scopes, workspace, model, { + implicitThisWalksMro: true, + }); + expect(result?.nodeId).toBe('m:P.ext'); + }); + + it('does not prefer a protocol-extension witness over an inherited class member', () => { + const BASE = 'def:Base.swift:Base'; + const baseType = { + nodeId: BASE, + filePath: 'Base.swift', + type: 'Class', + qualifiedName: 'Base', + } as SymbolDefinition; + const baseFoo = mkMethod({ + nodeId: 'm:Base.foo', + ownerId: BASE, + parameterCount: 0, + requiredParameterCount: 0, + }); + const extFoo = mkMethod({ + nodeId: 'm:P.ext', + ownerId: PROTO_A, + parameterCount: 0, + requiredParameterCount: 0, + }); + const baseScope = { + id: 'scope:Base.swift#1:1-20:1:Class' as ScopeId, + parent: null, + kind: 'Class', + range: { startLine: 1, startCol: 1, endLine: 20, endCol: 1 }, + filePath: 'Base.swift', + bindings: new Map(), + typeBindings: new Map(), + ownedDefs: [baseType, baseFoo], + } as unknown as Scope; + const protocolScope = { + id: 'scope:P.swift#1:1-20:1:Class' as ScopeId, + parent: null, + kind: 'Class', + range: { startLine: 1, startCol: 1, endLine: 20, endCol: 1 }, + filePath: 'P.swift', + bindings: new Map(), + typeBindings: new Map(), + ownedDefs: [ + { + nodeId: PROTO_A, + filePath: 'P.swift', + type: 'Protocol', + qualifiedName: 'A', + } as SymbolDefinition, + ], + } as unknown as Scope; + const scopes = mkScopes(mkClassScope(), new Map([[CLASS_DEF_ID, [BASE, PROTO_A]]])); + const workspace = mkWorkspaceIndex( + new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]]), + new Map([ + [BASE, baseScope], + [PROTO_A, protocolScope], + ]), + ); + const model = mkModel( + new Map(), + new Map([ + [`${CLASS_DEF_ID}::foo`, []], + [`${BASE}::foo`, [baseFoo]], + [`${PROTO_A}::foo`, [extFoo]], + ]), + ); + + const result = pickImplicitThisOverload(site0, scopes, workspace, model, { + implicitThisWalksMro: true, + }); + expect(result?.nodeId).toBe('m:Base.foo'); + }); + + it('falls through to inherited when the enclosing type only has an arity-incompatible decoy', () => { + const decoy = mkMethod({ + nodeId: 'm:own-decoy', + ownerId: CLASS_DEF_ID, + parameterCount: 1, + requiredParameterCount: 1, + }); + const inherited = mkMethod({ + nodeId: 'm:ext', + ownerId: PROTO_A, + parameterCount: 0, + requiredParameterCount: 0, + }); + const scopes = mkScopes(mkClassScope(), new Map([[CLASS_DEF_ID, [PROTO_A]]])); + const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]])); + const model = mkModel( + new Map(), + new Map([ + [`${CLASS_DEF_ID}::foo`, [decoy]], + [`${PROTO_A}::foo`, [inherited]], + ]), + ); + + const result = pickImplicitThisOverload(site0, scopes, workspace, model, { + implicitThisWalksMro: true, + }); + expect(result?.nodeId).toBe('m:ext'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts b/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts index 33c8cab51..d2061cfe0 100644 --- a/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts +++ b/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts @@ -261,6 +261,22 @@ describe('Pass 2: declarations + local bindings', () => { expect(result.localDefs[0]!.type).toBe('Function'); }); + it('backfills return types across duplicate declaration captures', () => { + const plain = declMatch('function', 'makeStore', 5, 0, 10, 0); + const annotated = declMatch('function', 'makeStore', 5, 0, 10, 0, { + '@declaration.return-type': cap('@declaration.return-type', 5, 0, 10, 0, 'Store'), + }); + const result = extract( + [scopeMatch('module', 1, 0, 100, 0), scopeMatch('function', 5, 0, 10, 0), plain, annotated], + 'Support.swift', + mockProvider(), + ); + + expect(result.localDefs).toHaveLength(2); + expect(new Set(result.localDefs.map((def) => def.nodeId)).size).toBe(1); + expect(result.localDefs.map((def) => def.returnType)).toEqual(['Store', 'Store']); + }); + it('preserves a synthetic declaration marker on the definition', () => { const result = extract( [ @@ -869,6 +885,55 @@ describe('Pass 6: callable-value-flow facts', () => { }); }); +describe('Pass 7: call-result assignment identity', () => { + it('keeps same-name calls isolated by exact call-expression position', () => { + const result = extract( + [ + scopeMatch('module', 1, 0, 100, 0), + scopeMatch('function', 10, 0, 40, 0), + { + '@call-result-assignment.call': cap( + '@call-result-assignment.call', + 20, + 14, + 20, + 25, + 'makeStore()', + ), + '@call-result-assignment.lhs': cap('@call-result-assignment.lhs', 20, 6, 20, 11, 'store'), + }, + { + '@call-result-assignment.call': cap( + '@call-result-assignment.call', + 21, + 14, + 21, + 25, + 'makeStore()', + ), + '@call-result-assignment.lhs': cap('@call-result-assignment.lhs', 21, 6, 21, 11, 'other'), + }, + ], + 'a.swift', + mockProvider(), + ); + + const fnScope = result.scopes.find((scope) => scope.kind === 'Function')!; + expect(result.callResultAssignmentSites).toEqual([ + { + callSite: { startLine: 20, startCol: 14, endLine: 20, endCol: 25 }, + inScope: fnScope.id, + lhs: 'store', + }, + { + callSite: { startLine: 21, startCol: 14, endLine: 21, endCol: 25 }, + inScope: fnScope.id, + lhs: 'other', + }, + ]); + }); +}); + // ─── §End-to-end fixture ────────────────────────────────────────────────── describe('end-to-end fixture (all 5 passes together)', () => { diff --git a/gitnexus/test/unit/scope-resolution/swift-extension-owners.test.ts b/gitnexus/test/unit/scope-resolution/swift-extension-owners.test.ts new file mode 100644 index 000000000..45d5ef408 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift-extension-owners.test.ts @@ -0,0 +1,198 @@ +/** + * `populateWorkspaceOwners` for Swift must stamp extension members onto a + * unique owner in the same SPM target, skip nested locals, and fail closed + * when `Bar` and `Foo.Bar` both exist. + */ +import { describe, it, expect } from 'vitest'; +import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js'; +import { swiftScopeResolver } from '../../../src/core/ingestion/languages/swift/scope-resolver.js'; +import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js'; +import { SupportedLanguages } from '../../../src/config/supported-languages.js'; + +const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift); + +function parseSwift(source: string, filePath: string) { + const parsed = extractParsedFile(swiftScopeResolver.languageProvider, source, filePath, () => {}); + if (parsed === undefined) throw new Error('scope extraction failed'); + swiftScopeResolver.populateOwners(parsed); + return parsed; +} + +function stamp( + files: Parameters>[0], + resolutionConfig?: unknown, +) { + swiftScopeResolver.populateWorkspaceOwners?.(files, { + fileContents: new Map(), + resolutionConfig, + }); +} + +describe.skipIf(!swiftAvailable)('populateSwiftExtensionOwners', () => { + it('stamps protocol-extension methods onto the unique protocol', () => { + const parsed = parseSwift( + ` +protocol ScenarioSupport {} +struct Store {} +extension ScenarioSupport { + func makeStore() -> Store { Store() } +} +`, + 'Support.swift', + ); + const protocol = parsed.localDefs.find((d) => d.qualifiedName === 'ScenarioSupport'); + const method = parsed.localDefs.find( + (d) => d.qualifiedName?.split('.').at(-1) === 'makeStore' && d.type === 'Method', + ); + expect(protocol).toBeDefined(); + expect(method?.ownerId).toBeUndefined(); + stamp([parsed]); + expect(method?.ownerId).toBe(protocol?.nodeId); + }); + + it('does not stamp a nested local onto the enclosing type', () => { + const parsed = parseSwift( + ` +class Host { + func helper(_ a: Int) -> Int { a } + func run(_ x: Int) -> Int { + func helper(_ v: Int, _ w: Int) -> Int { v + w } + return helper(x, x) + } +} +`, + 'Host.swift', + ); + stamp([parsed]); + const twoArg = parsed.localDefs.filter( + (d) => d.type === 'Method' && d.qualifiedName?.endsWith('helper') && d.parameterCount === 2, + ); + expect(twoArg).toHaveLength(1); + expect(twoArg[0]?.ownerId).toBeUndefined(); + }); + + it('stamps extension Foo.Bar members onto Foo.Bar when no top-level Bar exists', () => { + const parsed = parseSwift( + ` +enum Foo { + struct Bar {} +} +extension Foo.Bar { + func added() {} +} +`, + 'Nested.swift', + ); + stamp([parsed]); + const added = parsed.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added'); + const bar = parsed.localDefs.find( + (d) => d.qualifiedName === 'Foo.Bar' || d.qualifiedName === 'Bar', + ); + expect(added?.ownerId).toBeDefined(); + expect(added?.ownerId).toBe(bar?.nodeId); + }); + + it('fails closed when top-level Bar and nested Foo.Bar both exist', () => { + const parsed = parseSwift( + ` +struct Bar {} +enum Foo { + struct Bar {} +} +extension Foo.Bar { + func added() {} +} +`, + 'Collide.swift', + ); + stamp([parsed]); + const added = parsed.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added'); + expect(added).toBeDefined(); + expect(added?.ownerId).toBeUndefined(); + }); + + it('stamps extension members when the extension also declares a nested type', () => { + const parsed = parseSwift( + ` +struct Foo {} +extension Foo { + struct Helper {} + func added() {} +} +`, + 'NestedInExt.swift', + ); + stamp([parsed]); + const added = parsed.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added'); + const foo = parsed.localDefs.find( + (d) => d.qualifiedName === 'Foo' && (d.type === 'Struct' || d.type === 'Class'), + ); + expect(added).toBeDefined(); + expect(foo).toBeDefined(); + expect(added?.ownerId).toBe(foo?.nodeId); + }); + + it('does not let one target’s Foo own another target’s extension', () => { + const app = parseSwift( + ` +protocol Foo {} +extension Foo { + func added() {} +} +`, + 'Sources/App/A.swift', + ); + const lib = parseSwift( + ` +protocol Foo {} +`, + 'Sources/Lib/B.swift', + ); + stamp([app, lib], { + targets: new Map([ + ['App', 'Sources/App'], + ['Lib', 'Sources/Lib'], + ]), + }); + const added = app.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added'); + const appFoo = app.localDefs.find((d) => d.qualifiedName === 'Foo'); + expect(added).toBeDefined(); + expect(appFoo).toBeDefined(); + expect(added?.ownerId).toBe(appFoo?.nodeId); + const libFoo = lib.localDefs.find((d) => d.qualifiedName === 'Foo'); + expect(added?.ownerId).not.toBe(libFoo?.nodeId); + }); + + it('stamps a one-line extension whose synthetic class shares the Class start line', () => { + const parsed = parseSwift( + ` +struct Foo {} +extension Foo { func added() {} } +`, + 'OneLine.swift', + ); + const added = parsed.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added'); + const foo = parsed.localDefs.find( + (d) => d.qualifiedName === 'Foo' && (d.type === 'Struct' || d.type === 'Class'), + ); + expect(added).toBeDefined(); + expect(foo).toBeDefined(); + expect(added?.ownerId).toBeUndefined(); + stamp([parsed]); + expect(added?.ownerId).toBe(foo?.nodeId); + }); + + it('stamps a cross-file extension onto the real type, not the synthetic class', () => { + const typeFile = parseSwift('struct Foo {}', 'Foo.swift'); + const extFile = parseSwift('extension Foo { func added() {} }', 'Foo+Added.swift'); + const added = extFile.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added'); + const foo = typeFile.localDefs.find( + (d) => d.qualifiedName === 'Foo' && (d.type === 'Struct' || d.type === 'Class'), + ); + expect(added).toBeDefined(); + expect(foo).toBeDefined(); + expect(added?.ownerId).toBeUndefined(); + stamp([typeFile, extFile]); + expect(added?.ownerId).toBe(foo?.nodeId); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/workspace-index.test.ts b/gitnexus/test/unit/scope-resolution/workspace-index.test.ts index 234c8d733..f0ea5d290 100644 --- a/gitnexus/test/unit/scope-resolution/workspace-index.test.ts +++ b/gitnexus/test/unit/scope-resolution/workspace-index.test.ts @@ -18,6 +18,9 @@ import { describe, it, expect } from 'vitest'; import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js'; import { pythonScopeResolver } from '../../../src/core/ingestion/languages/python/scope-resolver.js'; +import { swiftScopeResolver } from '../../../src/core/ingestion/languages/swift/scope-resolver.js'; +import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js'; +import { SupportedLanguages } from '../../../src/config/supported-languages.js'; import { buildWorkspaceResolutionIndex } from '../../../src/core/ingestion/scope-resolution/workspace-index.js'; import { findExportedDef, @@ -39,6 +42,14 @@ function parsePython(source: string, filePath: string) { return parsed; } +const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift); + +function parseSwift(source: string, filePath: string) { + const parsed = extractParsedFile(swiftScopeResolver.languageProvider, source, filePath, () => {}); + if (parsed === undefined) throw new Error('scope extraction failed'); + return parsed; +} + describe('WorkspaceResolutionIndex — scope-only maps', () => { it('exposes classScopeByDefId, classScopeIdToDefId, and moduleScopeByFile', () => { const parsed = parsePython( @@ -87,6 +98,192 @@ def helper() -> int: }); }); +describe.skipIf(!swiftAvailable)('declaredReturnTypeByCallableId — exact callable identity', () => { + it('keeps same-file methods and parameter names from overwriting callable returns', () => { + const parsed = parseSwift( + ` +struct AResult {} +struct BResult {} +struct OtherStore {} +struct Store {} +struct A { + func make() -> AResult { AResult() } +} +struct B { + func make() -> BResult { BResult() } +} +func makeStore(makeStore: OtherStore) -> Store { Store() } +`, + 'Collisions.swift', + ); + const index = buildWorkspaceResolutionIndex([parsed]); + const callableDefs = parsed.localDefs.filter((def) => def.returnType !== undefined); + expect( + callableDefs + .map((def) => index.declaredReturnTypeByCallableId.get(def.nodeId)?.rawName) + .sort(), + ).toEqual(['AResult', 'BResult', 'Store']); + }); + + it('keeps extension and decoy return types separate despite the same method name', () => { + const extension = parseSwift( + ` +protocol ScenarioSupport {} +struct Store {} +extension ScenarioSupport { + func makeStore() -> Store { Store() } +} +`, + 'Support.swift', + ); + const decoy = parseSwift( + ` +struct OtherStore {} +struct OtherScenario { + private func makeStore() -> OtherStore { OtherStore() } +} +`, + 'AUnrelated.swift', + ); + const index = buildWorkspaceResolutionIndex([extension, decoy]); + const methods = [...extension.localDefs, ...decoy.localDefs].filter( + (def) => def.qualifiedName?.split('.').at(-1) === 'makeStore', + ); + + expect(methods).toHaveLength(2); + const byFile = new Map(methods.map((def) => [def.filePath, def])); + expect( + index.declaredReturnTypeByCallableId.get(byFile.get('Support.swift')!.nodeId)?.rawName, + ).toBe('Store'); + expect( + index.declaredReturnTypeByCallableId.get(byFile.get('AUnrelated.swift')!.nodeId)?.rawName, + ).toBe('OtherStore'); + expect(index.declaredReturnTypeByCallableId.has('makeStore')).toBe(false); + }); + + it('does not infer a return type from a same-named parameter', () => { + const parsed = parseSwift( + ` +struct OtherStore {} +func makeStore(makeStore: OtherStore) {} +`, + 'Unannotated.swift', + ); + const index = buildWorkspaceResolutionIndex([parsed]); + const makeStore = parsed.localDefs.find( + (def) => def.qualifiedName?.split('.').at(-1) === 'makeStore', + ); + + expect(makeStore).toBeDefined(); + expect(index.declaredReturnTypeByCallableId.has(makeStore!.nodeId)).toBe(false); + }); + + it('does not peel array return types without a language stripper', () => { + const parsed = parseSwift( + ` +struct User {} +func makeUsers() -> [User] { [] } +`, + 'Peel.swift', + ); + const index = buildWorkspaceResolutionIndex([parsed]); + const makeUsers = parsed.localDefs.find( + (def) => def.qualifiedName?.split('.').at(-1) === 'makeUsers', + ); + expect(makeUsers?.returnType).toBeDefined(); + expect(index.declaredReturnTypeByCallableId.get(makeUsers!.nodeId)?.rawName).toBe( + makeUsers!.returnType, + ); + }); + + it('applies stripTypePreservingDecoration to optional returns only', () => { + const parsed = parseSwift( + ` +struct Store {} +func makeStore() -> Store? { nil } +func makeUsers() -> [User] { [] } +`, + 'Optional.swift', + ); + const index = buildWorkspaceResolutionIndex([parsed], undefined, { + stripTypePreservingDecoration: (typeName) => + typeName.trim().endsWith('?') ? typeName.trim().slice(0, -1).trim() : undefined, + }); + const byName = new Map( + parsed.localDefs + .filter((def) => def.returnType !== undefined) + .map((def) => [def.qualifiedName?.split('.').at(-1), def]), + ); + expect(index.declaredReturnTypeByCallableId.get(byName.get('makeStore')!.nodeId)?.rawName).toBe( + 'Store', + ); + expect(index.declaredReturnTypeByCallableId.get(byName.get('makeUsers')!.nodeId)?.rawName).toBe( + byName.get('makeUsers')!.returnType, + ); + }); +}); + +describe.skipIf(!swiftAvailable)('Swift call-result assignment extraction', () => { + it('aligns each lhs with its own same-name call-resolution anchor', () => { + const parsed = parseSwift( + ` +func run() { + let store = makeStore() + let other = makeStore() +} +`, + 'Scenario.swift', + ); + + const assignments = parsed.callResultAssignmentSites ?? []; + expect(assignments.map(({ lhs }) => lhs)).toEqual(['store', 'other']); + expect( + new Set(assignments.map(({ callSite }) => `${callSite.startLine}:${callSite.startCol}`)).size, + ).toBe(2); + + const callAnchors = parsed.referenceSites + .filter((site) => site.name === 'makeStore') + .map(({ atRange }) => `${atRange.startLine}:${atRange.startCol}`); + expect(assignments.map(({ callSite }) => `${callSite.startLine}:${callSite.startCol}`)).toEqual( + callAnchors, + ); + expect(new Set(assignments.map(({ inScope }) => inScope)).size).toBe(1); + }); + + it('does not emit replay facts for explicitly typed declarations', () => { + const parsed = parseSwift( + ` +func run() { + let explicit: Store = makeStore() +} +`, + 'Typed.swift', + ); + expect(parsed.callResultAssignmentSites).toBeUndefined(); + }); + + it('unwraps await and try expressions to the exact call position', () => { + const parsed = parseSwift( + ` +func run() async throws { + let awaited = await makeStore() + let tried = try makeStore() + let triedAwaited = try await makeStore() +} +`, + 'Wrapped.swift', + ); + const assignments = parsed.callResultAssignmentSites ?? []; + expect(assignments.map(({ lhs }) => lhs)).toEqual(['awaited', 'tried', 'triedAwaited']); + const callAnchors = parsed.referenceSites + .filter((site) => site.name === 'makeStore') + .map(({ atRange }) => `${atRange.startLine}:${atRange.startCol}`); + expect(assignments.map(({ callSite }) => `${callSite.startLine}:${callSite.startCol}`)).toEqual( + callAnchors, + ); + }); +}); + describe('findExportedDef — module-export visibility filter', () => { it('keeps top-level class and function defs', () => { const parsed = parsePython( From 8fcc54e15f2e3f56c79664f40722749874645271 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:44:33 +0100 Subject: [PATCH 18/20] chore(deps): bump the uv group across 1 directory with 2 updates (#3330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the uv group with 2 updates in the /eval directory: [anyio](https://github.com/agronholm/anyio) and [pygments](https://github.com/pygments/pygments). Updates `anyio` from 4.12.1 to 4.14.2 - [Release notes](https://github.com/agronholm/anyio/releases) - [Commits](https://github.com/agronholm/anyio/compare/4.12.1...4.14.2) Updates `pygments` from 2.19.2 to 2.20.0 - [Release notes](https://github.com/pygments/pygments/releases) - [Changelog](https://github.com/pygments/pygments/blob/master/CHANGES) - [Commits](https://github.com/pygments/pygments/compare/2.19.2...2.20.0) --- updated-dependencies: - dependency-name: anyio dependency-version: 4.14.2 dependency-type: indirect dependency-group: uv - dependency-name: pygments dependency-version: 2.20.0 dependency-type: indirect dependency-group: uv ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- eval/uv.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/eval/uv.lock b/eval/uv.lock index 45c13a3ef..ff06e6830 100644 --- a/eval/uv.lock +++ b/eval/uv.lock @@ -170,15 +170,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.12.1" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] @@ -2513,11 +2513,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -2574,7 +2574,7 @@ name = "pyroscope-io" version = "0.8.16" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "sys_platform != 'win32'" }, + { name = "cffi" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a8/50/607b38b120ba8adad954119ba512c53590c793f0cf7f009ba6549e4e1d77/pyroscope_io-0.8.16-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:e07edcfd59f5bdce42948b92c9b118c824edbd551730305f095a6b9af401a9e8", size = 3138869, upload-time = "2026-01-22T06:23:24.664Z" }, From a578747455f377f2073178c38c35189990cd7026 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Sat, 19 Sep 2026 09:46:53 +0100 Subject: [PATCH 19/20] fix(swift): resolve nested constructors in extensions (#3308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(swift): resolve nested constructors in extensions * fix(swift): preserve qualified extension owners * Address PR review feedback (#3308) Recover qualified Swift extension owners through public / attribute prefixes, and stop last-dot-guessing when source text is present. Co-authored-by: Cursor * fix(swift): keep attribute text from stealing extension owners Bound header recovery so @available messages cannot rekey a fragment, and still inject nested types when the Class scope has no bindings. Co-authored-by: Cursor * fix(swift): nest comments and keep the public extension fixture valid Review follow-up: skip nested /* */ in the header scan, put the Inner.Entry decoy in a parsed file, and mark Outer/Container/Entry public so the live fixture is valid Swift. Co-authored-by: Cursor * fix(swift): recover Unicode identifiers as extension owners The header regex was ASCII-only, so extension Café.Container keyed as Caf and dropped nested-type siblings. Match ID_Start/ID_Continue segments instead. Co-authored-by: Cursor * fix(swift): skip raw strings and decode UTF-8 scope columns Header recovery treated #"..."# as an ordinary quote and sliced Tree-sitter byte columns as JS offsets, so a same-line Café prefix or a raw attribute message could steal or drop the extension owner. Co-authored-by: Cursor --------- Co-authored-by: Gergő Magyar Co-authored-by: Gergo Magyar Co-authored-by: Cursor --- gitnexus/bench/scope-capture/baselines.json | 8 +- .../languages/swift/target-siblings.ts | 329 ++++++++++++++- .../Builder.swift | 5 + .../Standalone.swift | 3 + .../Types.swift | 8 + .../expected-captures.json | 12 + .../test/integration/resolvers/swift.test.ts | 30 ++ .../swift/target-siblings.test.ts | 391 ++++++++++++++++++ 8 files changed, 775 insertions(+), 11 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Builder.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Standalone.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Types.swift create mode 100644 gitnexus/test/unit/scope-resolution/swift/target-siblings.test.ts diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 74e4588e9..c715f59b7 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -117,7 +117,7 @@ "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83 -> 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57." }, "swift": { - "fingerprint": "724553c91e8ebcf872d7302b14105d59476c230f9b623acdacc0b58e39912329", + "fingerprint": "decf74c01af0c7f403e203b19c2bd92dbf95b4562f6da2b0ac5117ef872204da", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725 -> 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d; scaling 1.042 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Swift function-value callable flow facts with invocation-result suppression. Prior 180ac68e780bdf6f9089d53f51cbb9a66aed3e7774631cc3fcbaae5020213998 -> 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725; scaling 1.043 < 1.5.", @@ -126,8 +126,12 @@ "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248 -> a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b.", "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b -> 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7.", "_rebaselined_inferred_field_receiver_2807": "#2807: optional property annotations (`var a: Outer?`) now emit a type binding. The prior pattern required the `user_type` to be a DIRECT child of the annotation, so an `optional_type` wrapper meant an optional field was never typed at all and its receiver could not resolve. ADDS @type-binding.annotation captures on the optional form only; no capture is removed. Prior 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7 -> adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9; scaling 1.023 < 1.5.", + "_rebaselined_nested_constructor_3262": "#3262: the existing nested-constructor fixture now uses the qualified legal form `extension Outer.Container`, covering full extension-owner recovery. Fixture-only capture drift; no capture logic changed. Prior adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9 -> 3ac80b64776969effb75c8d32aa0bc38fc6a41c6c115a33829c502559fa404f9; scaling 1.025 < 1.5.", "_rebaselined_3309_call_result_assignment": "#3309: the Swift protocol-extension regression fixture adds one file with an untyped local initialized from a helper call, plus a compound-field receiver call. The capture query is unchanged; this is fixture-corpus growth only. capture_groups_fp 1235 -> 1272 and fixture_count 62 -> 64; synthetic scale counts remain 5012/16012. Prior adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9 -> 4d32535d454cd79086f0fcaec3b75de855c1314e73630321460e569f19aa484f; measured scaling 1.020 < 1.5.", - "_rebaselined_3309_exact_return_replay": "#3309 follow-up: Swift callable declarations now emit exact return-type captures, and untyped local initializers emit call-result assignment captures through direct, try, await, and try-await forms. This is intentional capture-set growth covered by exact-callable collision and wrapper regressions. Prior 4d32535d454cd79086f0fcaec3b75de855c1314e73630321460e569f19aa484f -> 724553c91e8ebcf872d7302b14105d59476c230f9b623acdacc0b58e39912329; hosted benchmark scaling remained within the existing 1.5 budget." + "_rebaselined_3309_exact_return_replay": "#3309 follow-up: Swift callable declarations now emit exact return-type captures, and untyped local initializers emit call-result assignment captures through direct, try, await, and try-await forms. This is intentional capture-set growth covered by exact-callable collision and wrapper regressions. Prior 4d32535d454cd79086f0fcaec3b75de855c1314e73630321460e569f19aa484f -> 724553c91e8ebcf872d7302b14105d59476c230f9b623acdacc0b58e39912329; hosted benchmark scaling remained within the existing 1.5 budget.", + "_rebaselined_3308_merge_main": "Merge origin/main into #3308: combine #3262 fixture-owner recovery with #3309 protocol-extension capture growth. Prior 3ac80b64776969effb75c8d32aa0bc38fc6a41c6c115a33829c502559fa404f9 + 724553c91e8ebcf872d7302b14105d59476c230f9b623acdacc0b58e39912329 -> b0c89f8de1a4ce30409182e79c1c14cbc49a509d18d853c0d7c5f22f1f66be06; capture_groups_fp 1297, fixture_count 67.", + "_rebaselined_3308_public_extension": "#3308 review: the nested-constructor fixture now uses `public extension Outer.Container` so owner recovery is gated on modifier-prefixed headers. Fixture-only digest drift. Prior b0c89f8de1a4ce30409182e79c1c14cbc49a509d18d853c0d7c5f22f1f66be06 -> a6d61e3749c9c02b9a54611e42d03db02f8fabfded3d7ce53a1b7229d3d38d59; capture_groups_fp 1297, fixture_count 67.", + "_rebaselined_3308_public_enclosing_types": "#3308 review: `Outer` / `Container` / `Entry` in the nested-constructor fixture are now `public` so the `public extension` is valid Swift. Fixture-only digest drift (Types.swift capture text). Prior a6d61e3749c9c02b9a54611e42d03db02f8fabfded3d7ce53a1b7229d3d38d59 -> decf74c01af0c7f403e203b19c2bd92dbf95b4562f6da2b0ac5117ef872204da; capture_groups_fp 1297." }, "dart": { "fingerprint": "3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687", diff --git a/gitnexus/src/core/ingestion/languages/swift/target-siblings.ts b/gitnexus/src/core/ingestion/languages/swift/target-siblings.ts index 47ccc4109..6cf7c858b 100644 --- a/gitnexus/src/core/ingestion/languages/swift/target-siblings.ts +++ b/gitnexus/src/core/ingestion/languages/swift/target-siblings.ts @@ -23,8 +23,9 @@ * finalize and must not be mutated. */ -import type { BindingRef, ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { BindingRef, ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { isClassLike } from '../../scope-resolution/scope/walkers.js'; import { coerceSwiftTargets, groupSwiftFilesBySpmTarget } from './target-grouping.js'; export function populateSwiftTargetSiblings( @@ -35,8 +36,6 @@ export function populateSwiftTargetSiblings( readonly resolutionConfig?: unknown; }, ): void { - // Group files by SPM target subtree (the module). No-source-dir → all - // files in one `__default__` bucket. const targets = coerceSwiftTargets(ctx.resolutionConfig); const filesByTarget = groupSwiftFilesBySpmTarget( parsedFiles, @@ -47,7 +46,8 @@ export function populateSwiftTargetSiblings( const augmentations = indexes.bindingAugmentations as Map>; for (const [, group] of filesByTarget) { - if (group.length < 2) continue; // no siblings to share + populateNestedTypeFragments(group, indexes, augmentations, ctx.fileContents); + if (group.length < 2) continue; // no file siblings to share const siblings = group.map((parsed) => ({ filePath: parsed.filePath, defs: [...parsed.localDefs] as SymbolDefinition[], @@ -59,17 +59,328 @@ export function populateSwiftTargetSiblings( if (receiverModule === undefined) continue; for (const def of target.defs) { - const name = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; - if (name === '') continue; - const bucket = getAugmentationBucket(augmentations, receiverModule, name); - if (bucket.some((b) => b.def.nodeId === def.nodeId)) continue; - bucket.push({ def, origin: 'namespace' }); + addNamespaceBinding(augmentations, receiverModule, def); } } } } } +/** + * A Swift extension is a second lexical fragment of its extended type. Make + * nested types declared by the primary fragment visible from every same-target + * fragment with the same logical owner. Keeping this on class scopes preserves + * lexical precedence when an unrelated top-level type has the same simple name. + */ +function populateNestedTypeFragments( + group: readonly ParsedFile[], + indexes: ScopeResolutionIndexes, + augmentations: Map>, + fileContents: ReadonlyMap, +): void { + const scopesByOwner = new Map(); + const lineStartsByFile = new Map(); + for (const parsed of group) { + const source = fileContents.get(parsed.filePath); + let lineStarts: readonly number[] | undefined; + if (source !== undefined) { + lineStarts = lineStartsByFile.get(parsed.filePath); + if (lineStarts === undefined) { + lineStarts = lineStartsOf(source); + lineStartsByFile.set(parsed.filePath, lineStarts); + } + } + for (const scope of parsed.scopes) { + if (scope.kind !== 'Class') continue; + const key = scopeOwnerKey(scope, source, lineStarts); + if (key === undefined) continue; + let scopes = scopesByOwner.get(key); + if (scopes === undefined) { + scopes = []; + scopesByOwner.set(key, scopes); + } + scopes.push(scope.id); + } + } + + for (const parsed of group) { + for (const def of parsed.localDefs) { + if (!isClassLike(def.type) || def.ownerId === undefined) continue; + const owner = indexes.defs.byId.get(def.ownerId); + if (owner === undefined) continue; + const targetScopes = scopesByOwner.get(logicalOwnerKey(owner)); + if (targetScopes === undefined) continue; + for (const scopeId of targetScopes) { + addNamespaceBinding(augmentations, scopeId, def); + } + } + } +} + +function scopeOwnerKey( + scope: Scope, + source: string | undefined, + lineStarts?: readonly number[], +): string | undefined { + const owner = scope.ownedDefs.find((def) => isClassLike(def.type)); + if (owner !== undefined) return logicalOwnerKey(owner); + + // Extension scopes carry no synthetic class def. Capture generation keeps + // only the trailing owner on members (`Inner.f` for `extension Outer.Inner`), + // so recover the full owner from this scope's declaration text first. + if (source !== undefined) { + const sourceOwner = swiftExtensionOwner(source, scope, lineStarts); + // Do not last-dot-guess: member qualified names are trailing-only, so + // `Inner.make` would key `Inner` instead of `Outer.Inner`. + if (sourceOwner === undefined) return undefined; + const representative = firstBoundDefinition(scope); + return logicalOwnerKey({ + ...(representative ?? { + nodeId: sourceOwner, + filePath: scope.filePath, + type: 'Class', + qualifiedName: sourceOwner, + }), + qualifiedName: sourceOwner, + }); + } + + // Hand-built fixtures and old cached shapes may have no source text. Keep + // the conservative member-prefix fallback, rejecting inconsistent owners. + let inferredOwner: string | undefined; + for (const refs of scope.bindings.values()) { + for (const { def } of refs) { + const qualifiedName = def.qualifiedName; + if (qualifiedName === undefined) continue; + const separator = qualifiedName.lastIndexOf('.'); + if (separator <= 0) continue; + const candidate = logicalOwnerKey({ + ...def, + qualifiedName: qualifiedName.slice(0, separator), + }); + if (inferredOwner !== undefined && inferredOwner !== candidate) return undefined; + inferredOwner = candidate; + } + } + return inferredOwner; +} + +function firstBoundDefinition(scope: Scope): SymbolDefinition | undefined { + for (const refs of scope.bindings.values()) { + const first = refs[0]?.def; + if (first !== undefined) return first; + } + return undefined; +} + +/** + * Read `extension Outer.Inner` from the class-scope source range. + * Access modifiers and attributes (`public`, `@MainActor`, `@available`) + * may precede the keyword, so the match is not start-anchored. Attribute + * message strings and comments must not supply a false `extension Type`. + * Owner segments use Unicode identifier characters so `Café.Container` + * is not truncated to `Caf`. + */ +const SWIFT_TYPE_IDENT = String.raw`[\p{ID_Start}_][\p{ID_Continue}]*`; +const EXTENSION_OWNER = new RegExp( + String.raw`\bextension\s+(${SWIFT_TYPE_IDENT}(?:\s*\.\s*${SWIFT_TYPE_IDENT})*)`, + 'u', +); + +function swiftExtensionOwner( + source: string, + scope: Scope, + lineStarts?: readonly number[], +): string | undefined { + const declaration = sliceScopeRange(source, scope.range, lineStarts ?? lineStartsOf(source)); + if (declaration === undefined) return undefined; + const cleaned = cleanExtensionHeader(declaration); + return EXTENSION_OWNER.exec(cleaned)?.[1]?.replace(/\s+/g, ''); +} + +/** `Scope.range` is 1-based on lines. Columns are Tree-sitter UTF-8 bytes. */ +function lineStartsOf(source: string): number[] { + const starts = [0, 0]; + for (let index = 0; index < source.length; index += 1) { + if (source[index] === '\n') starts.push(index + 1); + } + return starts; +} + +function sliceScopeRange( + source: string, + range: Scope['range'], + starts: readonly number[], +): string | undefined { + const start = starts[range.startLine]; + const end = starts[range.endLine]; + if (start === undefined || end === undefined) return undefined; + return source.slice( + start + jsOffsetForUtf8Column(source, start, range.startCol), + end + jsOffsetForUtf8Column(source, end, range.endCol), + ); +} + +/** Convert a Tree-sitter UTF-8 column into a JS string offset on that line. */ +function jsOffsetForUtf8Column(source: string, lineStart: number, utf8Column: number): number { + let bytes = 0; + let index = lineStart; + while (index < source.length && bytes < utf8Column) { + if (source[index] === '\n') break; + const codePoint = source.codePointAt(index); + if (codePoint === undefined) break; + bytes += utf8ByteLength(codePoint); + index += codePoint > 0xffff ? 2 : 1; + } + return index - lineStart; +} + +function utf8ByteLength(codePoint: number): number { + if (codePoint <= 0x7f) return 1; + if (codePoint <= 0x7ff) return 2; + if (codePoint <= 0xffff) return 3; + return 4; +} + +/** Header through the first unquoted `{`, with strings and comments blanked. */ +function cleanExtensionHeader(declaration: string): string { + let index = 0; + let cleaned = ''; + const blank = (from: number, to: number): void => { + for (let cursor = from; cursor < to; cursor += 1) { + cleaned += declaration[cursor] === '\n' ? '\n' : ' '; + } + }; + while (index < declaration.length) { + const current = declaration[index]; + if (current === '/' && declaration[index + 1] === '/') { + const end = skipLineComment(declaration, index); + blank(index, end); + index = end; + continue; + } + if (current === '/' && declaration[index + 1] === '*') { + const end = skipBlockComment(declaration, index); + blank(index, end); + index = end; + continue; + } + const pounds = current === '#' ? leadingPounds(declaration, index) : 0; + const quoteAt = index + pounds; + if (declaration.startsWith('"""', quoteAt) || declaration[quoteAt] === '"') { + const end = skipSwiftString(declaration, index); + blank(index, end); + index = end; + continue; + } + if (current === "'") { + const end = skipQuoted(declaration, index, current); + blank(index, end); + index = end; + continue; + } + if (current === '{') break; + cleaned += current; + index += 1; + } + return cleaned; +} + +function skipLineComment(text: string, start: number): number { + const newline = text.indexOf('\n', start); + return newline === -1 ? text.length : newline + 1; +} + +function skipBlockComment(text: string, start: number): number { + let index = start + 2; + let depth = 1; + while (index < text.length && depth > 0) { + if (text.startsWith('/*', index)) { + depth += 1; + index += 2; + continue; + } + if (text.startsWith('*/', index)) { + depth -= 1; + index += 2; + continue; + } + index += 1; + } + return index; +} + +function leadingPounds(text: string, start: number): number { + let count = 0; + while (text[start + count] === '#') count += 1; + return count; +} + +function skipSwiftString(text: string, start: number): number { + const pounds = leadingPounds(text, start); + const quoteAt = start + pounds; + if (text.startsWith('"""', quoteAt)) { + return skipDelimitedString(text, quoteAt + 3, `"""${'#'.repeat(pounds)}`, pounds === 0); + } + if (text[quoteAt] === '"') { + return skipDelimitedString(text, quoteAt + 1, `"${'#'.repeat(pounds)}`, pounds === 0); + } + return start + Math.max(pounds, 1); +} + +function skipDelimitedString( + text: string, + bodyStart: number, + closer: string, + escapes: boolean, +): number { + let index = bodyStart; + while (index < text.length) { + if (escapes && text[index] === '\\') { + index += 2; + continue; + } + if (text.startsWith(closer, index)) return index + closer.length; + index += 1; + } + return text.length; +} + +function skipQuoted(text: string, start: number, quote: string): number { + let index = start + 1; + while (index < text.length) { + if (text[index] === '\\') { + index += 2; + continue; + } + if (text[index] === quote) return index + 1; + index += 1; + } + return text.length; +} + +function logicalOwnerKey(def: SymbolDefinition): string { + const qualifiedName = def.qualifiedName ?? def.nodeId; + const namespacePrefix = def.namespacePrefix ?? ''; + return `${namespacePrefix.length}:${namespacePrefix}:${qualifiedName}`; +} + +function simpleName(def: SymbolDefinition): string { + return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; +} + +function addNamespaceBinding( + augmentations: Map>, + scopeId: ScopeId, + def: SymbolDefinition, +): void { + const name = simpleName(def); + if (name === '') return; + const bucket = getAugmentationBucket(augmentations, scopeId, name); + if (bucket.some((binding) => binding.def.nodeId === def.nodeId)) return; + bucket.push({ def, origin: 'namespace' }); +} + function getAugmentationBucket( augmentations: Map>, scopeId: ScopeId, diff --git a/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Builder.swift b/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Builder.swift new file mode 100644 index 000000000..5b15f19cb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Builder.swift @@ -0,0 +1,5 @@ +public extension Outer.Container { + static func makeEntry() -> Entry { + Entry(id: 1, text: "sample") + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Standalone.swift b/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Standalone.swift new file mode 100644 index 000000000..7f0d964f0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Standalone.swift @@ -0,0 +1,3 @@ +struct Entry { + let enabled: Bool +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Types.swift b/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Types.swift new file mode 100644 index 000000000..bd7f0806d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Types.swift @@ -0,0 +1,8 @@ +public enum Outer { + public struct Container { + public struct Entry { + let id: Int + let text: String + } + } +} diff --git a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json index c7ccea7ee..1d67db04f 100644 --- a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json @@ -171,6 +171,18 @@ "captureGroups": 8, "digest": "469f895fc72297a08eb0c7ddbf7fdbb812c9296076b4797cd9262e2b7ab59a94" }, + "swift-nested-constructor-extension/Builder.swift": { + "captureGroups": 7, + "digest": "8e4e01966ad669ef2e8ea6fc032053540588507e75ecabe142ab5f0b35211cb2" + }, + "swift-nested-constructor-extension/Standalone.swift": { + "captureGroups": 5, + "digest": "4ac71fb5f08cea3ab32a23ae41dd3bf3a215880cbb826b83d1c7427fa837d3c2" + }, + "swift-nested-constructor-extension/Types.swift": { + "captureGroups": 11, + "digest": "489630892c43db4b9440e6de387d91f3b84ed82702cfffab11db2e6afcce5da1" + }, "swift-nested-extension/Extension.swift": { "captureGroups": 7, "digest": "c69b554d6a1372a449198db7581a2713a1850bded67874101d3cf2faa784f646" diff --git a/gitnexus/test/integration/resolvers/swift.test.ts b/gitnexus/test/integration/resolvers/swift.test.ts index 568cf3346..9805abf28 100644 --- a/gitnexus/test/integration/resolvers/swift.test.ts +++ b/gitnexus/test/integration/resolvers/swift.test.ts @@ -1297,6 +1297,36 @@ describe.skipIf(!swiftAvailable)('Swift nested-type extension (extension Foo.Bar }); }); +// --------------------------------------------------------------------------- +// A bare constructor inside an extension must prefer a nested type owned by +// the extended type over an unrelated top-level type with the same short name. +// --------------------------------------------------------------------------- + +describe.skipIf(!swiftAvailable)( + 'Swift nested constructor lookup in a public qualified extension (#3262)', + () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'swift-nested-constructor-extension'), + () => {}, + ); + }, 60000); + + it('resolves Entry(id:text:) to Outer.Container.Entry and not the top-level Entry', () => { + const entryCalls = getRelationships(result, 'CALLS').filter( + (call) => call.source === 'makeEntry' && call.target === 'Entry', + ); + + expect(entryCalls.map((call) => call.rel.targetId)).toEqual(['Struct:Types.swift:Entry']); + expect(entryCalls.some((call) => call.rel.targetId === 'Struct:Standalone.swift:Entry')).toBe( + false, + ); + }); + }, +); + // --------------------------------------------------------------------------- // F75: protocol property requirements (`var title: String { get }`) are // extracted as Property symbols owned by the protocol. Before the fix these diff --git a/gitnexus/test/unit/scope-resolution/swift/target-siblings.test.ts b/gitnexus/test/unit/scope-resolution/swift/target-siblings.test.ts new file mode 100644 index 000000000..eef725014 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift/target-siblings.test.ts @@ -0,0 +1,391 @@ +import { + buildDefIndex, + type ParsedFile, + type ScopeId, + type SymbolDefinition, +} from 'gitnexus-shared'; +import { describe, expect, it } from 'vitest'; +import { populateSwiftTargetSiblings } from '../../../../src/core/ingestion/languages/swift/target-siblings.js'; +import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js'; + +const moduleId = (filePath: string) => `scope:${filePath}:module` as ScopeId; +const classId = (filePath: string) => `scope:${filePath}:class` as ScopeId; + +function parsedFile( + filePath: string, + classOwnedDefs: readonly SymbolDefinition[], + classBindings: ReadonlyMap, + localDefs: readonly SymbolDefinition[], + classRange = { startLine: 1, startCol: 0, endLine: 10, endCol: 0 }, +): ParsedFile { + return { + filePath, + moduleScope: moduleId(filePath), + scopes: [ + { + id: moduleId(filePath), + parent: null, + kind: 'Module', + range: { startLine: 1, startCol: 0, endLine: 10, endCol: 0 }, + filePath, + bindings: new Map(), + ownedDefs: [], + imports: [], + typeBindings: new Map(), + }, + { + id: classId(filePath), + parent: moduleId(filePath), + kind: 'Class', + range: classRange, + filePath, + bindings: classBindings, + ownedDefs: classOwnedDefs, + imports: [], + typeBindings: new Map(), + }, + ], + parsedImports: [], + localDefs, + referenceSites: [], + }; +} + +describe('Swift target sibling visibility', () => { + it('binds a nested type into a same-target extension fragment', () => { + const container: SymbolDefinition = { + nodeId: 'def:Types.swift:Container', + filePath: 'Types.swift', + type: 'Class', + qualifiedName: 'Container', + }; + const entry: SymbolDefinition = { + nodeId: 'def:Types.swift:Container.Entry', + filePath: 'Types.swift', + type: 'Class', + qualifiedName: 'Container.Entry', + ownerId: container.nodeId, + }; + const makeEntry: SymbolDefinition = { + nodeId: 'def:Builder.swift:Container.makeEntry', + filePath: 'Builder.swift', + type: 'Method', + qualifiedName: 'Container.makeEntry', + }; + const declaration = parsedFile( + 'Types.swift', + [container], + new Map([['Entry', [{ def: entry, origin: 'local' }]]]), + [container, entry], + ); + const extension = parsedFile( + 'Builder.swift', + [], + new Map([['makeEntry', [{ def: makeEntry, origin: 'local' }]]]), + [makeEntry], + ); + const bindingAugmentations = new Map(); + const indexes = makeIndexes([container, entry, makeEntry], bindingAugmentations); + + populateSwiftTargetSiblings([declaration, extension], indexes, { + fileContents: new Map(), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + }); + + it('recovers Outer.Container from public extension source when members are trailing-only', () => { + const extensionSource = 'public extension Outer.Container {\n static func makeEntry() {}\n}\n'; + const { declaration, extension, entry, indexes, bindingAugmentations } = + qualifiedExtensionFixture(extensionSource, { ownerQualifiedName: 'Outer.Container' }); + + populateSwiftTargetSiblings([declaration, extension], indexes, { + fileContents: new Map([['Builder.swift', extensionSource]]), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + }); + + it('recovers a qualified owner when the extension Class scope has no bindings', () => { + const extensionSource = 'public extension Outer.Inner {\n subscript(i: Int) -> Int { i }\n}\n'; + const { declaration, extension, entry, indexes, bindingAugmentations } = + qualifiedExtensionFixture(extensionSource, { classBindings: new Map() }); + + populateSwiftTargetSiblings([declaration, extension], indexes, { + fileContents: new Map([['Builder.swift', extensionSource]]), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + }); + + it('does not infer an extension owner from inconsistent qualified members', () => { + const container: SymbolDefinition = { + nodeId: 'def:Types.swift:Container', + filePath: 'Types.swift', + type: 'Class', + qualifiedName: 'Container', + }; + const entry: SymbolDefinition = { + nodeId: 'def:Types.swift:Container.Entry', + filePath: 'Types.swift', + type: 'Class', + qualifiedName: 'Container.Entry', + ownerId: container.nodeId, + }; + const containerMethod: SymbolDefinition = { + nodeId: 'def:Builder.swift:Container.makeEntry', + filePath: 'Builder.swift', + type: 'Method', + qualifiedName: 'Container.makeEntry', + }; + const otherMethod: SymbolDefinition = { + nodeId: 'def:Builder.swift:Other.makeEntry', + filePath: 'Builder.swift', + type: 'Method', + qualifiedName: 'Other.makeEntry', + }; + const declaration = parsedFile( + 'Types.swift', + [container], + new Map([['Entry', [{ def: entry, origin: 'local' }]]]), + [container, entry], + ); + const ambiguousExtension = parsedFile( + 'Builder.swift', + [], + new Map([ + ['containerMethod', [{ def: containerMethod, origin: 'local' }]], + ['otherMethod', [{ def: otherMethod, origin: 'local' }]], + ]), + [containerMethod, otherMethod], + ); + const bindingAugmentations = new Map(); + const indexes = makeIndexes( + [container, entry, containerMethod, otherMethod], + bindingAugmentations, + ); + + populateSwiftTargetSiblings([declaration, ambiguousExtension], indexes, { + fileContents: new Map(), + }); + + expect(bindingAugmentations.get(classId('Builder.swift'))?.get('Entry')).toBeUndefined(); + }); + + it('preserves the qualified owner of a nested-type extension', () => { + // Capture generation keeps only the trailing owner on members (`Inner.f`), + // so source text must recover `Outer.Inner`. + const extensionSource = 'extension Outer.Inner {\n static func makeEntry() {}\n}\n'; + const { declaration, extension, entry, indexes, bindingAugmentations } = + qualifiedExtensionFixture(extensionSource); + + populateSwiftTargetSiblings([declaration, extension], indexes, { + fileContents: new Map([['Builder.swift', extensionSource]]), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + }); + + it.each([ + ['public extension Outer.Inner {\n static func makeEntry() {}\n}\n', 0], + ['@MainActor\nextension Outer.Inner {\n static func makeEntry() {}\n}\n', 0], + ['@available(iOS 15, *)\npublic extension Outer.Inner {\n static func makeEntry() {}\n}\n', 0], + [ + '@available(*, deprecated, message: "Use extension Other.Inner")\npublic extension Outer.Inner {\n static func makeEntry() {}\n}\n', + 0, + ], + [' public extension Outer.Inner {\n static func makeEntry() {}\n}\n', 2], + [ + '/* outer /* inner */ extension Wrong */ extension Outer.Inner {\n static func makeEntry() {}\n}\n', + 0, + ], + [ + '@available(*, deprecated, message: #"Use extension Wrong"#)\npublic extension Outer.Inner {\n static func makeEntry() {}\n}\n', + 0, + ], + ])( + 'recovers a qualified owner through modifiers and attributes: %j', + (extensionSource, startCol = 0) => { + const { declaration, extension, entry, indexes, bindingAugmentations } = + qualifiedExtensionFixture(extensionSource, { startCol }); + + populateSwiftTargetSiblings([declaration, extension], indexes, { + fileContents: new Map([['Builder.swift', extensionSource]]), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + }, + ); + + it('converts Tree-sitter UTF-8 columns before slicing JS source', () => { + const prefix = 'struct Café {}; '; + const extensionSource = `${prefix}extension Outer.Inner {\n static func makeEntry() {}\n}\n`; + const { declaration, extension, entry, indexes, bindingAugmentations } = + qualifiedExtensionFixture(extensionSource, { + startCol: Buffer.byteLength(prefix, 'utf8'), + }); + + populateSwiftTargetSiblings([declaration, extension], indexes, { + fileContents: new Map([['Builder.swift', extensionSource]]), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + }); + + it('recovers a Unicode qualified extension owner', () => { + const extensionSource = 'public extension Café.Container {\n static func makeEntry() {}\n}\n'; + const { declaration, extension, entry, indexes, bindingAugmentations } = + qualifiedExtensionFixture(extensionSource, { ownerQualifiedName: 'Café.Container' }); + + populateSwiftTargetSiblings([declaration, extension], indexes, { + fileContents: new Map([['Builder.swift', extensionSource]]), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + }); + + it('prefers Outer.Inner.Entry over a colliding top-level Inner.Entry', () => { + const { collision, topInner, wrongEntry } = collidingTopLevelInner(); + const extensionSource = 'public extension Outer.Inner {\n static func makeEntry() {}\n}\n'; + const { declaration, extension, entry, indexes, bindingAugmentations } = + qualifiedExtensionFixture(extensionSource, { extraDefs: [topInner, wrongEntry] }); + + populateSwiftTargetSiblings([declaration, extension, collision], indexes, { + fileContents: new Map([['Builder.swift', extensionSource]]), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + expect(bindingAugmentations.get(classId('TopInner.swift'))?.get('Entry')).toEqual([ + { def: wrongEntry, origin: 'namespace' }, + ]); + }); + + it('does not last-dot-guess Inner when source is present but not an extension', () => { + const { collision, topInner, wrongEntry } = collidingTopLevelInner(); + const { declaration, extension, indexes, bindingAugmentations } = qualifiedExtensionFixture( + 'struct Unrelated {}\n', + { extraDefs: [topInner, wrongEntry] }, + ); + + populateSwiftTargetSiblings([declaration, extension, collision], indexes, { + fileContents: new Map([['Builder.swift', 'struct Unrelated {}\n']]), + }); + + expect(bindingAugmentations.get(classId('Builder.swift'))?.get('Entry')).toBeUndefined(); + expect(bindingAugmentations.get(classId('TopInner.swift'))?.get('Entry')).toEqual([ + { def: wrongEntry, origin: 'namespace' }, + ]); + }); +}); + +function collidingTopLevelInner() { + const topInner: SymbolDefinition = { + nodeId: 'def:TopInner.swift:Inner', + filePath: 'TopInner.swift', + type: 'Class', + qualifiedName: 'Inner', + }; + const wrongEntry: SymbolDefinition = { + nodeId: 'def:TopInner.swift:Inner.Entry', + filePath: 'TopInner.swift', + type: 'Class', + qualifiedName: 'Inner.Entry', + ownerId: topInner.nodeId, + }; + const collision = parsedFile( + 'TopInner.swift', + [topInner], + new Map([['Entry', [{ def: wrongEntry, origin: 'local' }]]]), + [topInner, wrongEntry], + ); + return { collision, topInner, wrongEntry }; +} + +function expectAugmentedEntry( + bindingAugmentations: Map>, + entry: SymbolDefinition, +) { + expect(bindingAugmentations.get(classId('Builder.swift'))?.get('Entry')).toEqual([ + { def: entry, origin: 'namespace' }, + ]); +} + +function makeIndexes( + defs: readonly SymbolDefinition[], + bindingAugmentations: Map, +): ScopeResolutionIndexes { + return { + defs: buildDefIndex(defs), + moduleScopes: { + byFilePath: new Map([ + ['Types.swift', moduleId('Types.swift')], + ['Builder.swift', moduleId('Builder.swift')], + ]), + }, + bindingAugmentations, + } as unknown as ScopeResolutionIndexes; +} + +function rangeForSource(source: string, startCol = 0) { + const lines = source.split('\n'); + const last = source.endsWith('\n') ? lines.length - 2 : lines.length - 1; + const lastLine = Math.max(last, 0); + return { + startLine: 1, + startCol, + endLine: lastLine + 1, + endCol: (lines[lastLine] ?? '').length, + }; +} + +function qualifiedExtensionFixture( + extensionSource: string, + options: { + extraDefs?: readonly SymbolDefinition[]; + classBindings?: ReadonlyMap; + startCol?: number; + ownerQualifiedName?: string; + } = {}, +) { + const ownerQualifiedName = options.ownerQualifiedName ?? 'Outer.Inner'; + const ownerSimple = ownerQualifiedName.split('.').pop() ?? ownerQualifiedName; + const owner: SymbolDefinition = { + nodeId: `def:Types.swift:${ownerQualifiedName}`, + filePath: 'Types.swift', + type: 'Class', + qualifiedName: ownerQualifiedName, + }; + const entry: SymbolDefinition = { + nodeId: `def:Types.swift:${ownerQualifiedName}.Entry`, + filePath: 'Types.swift', + type: 'Class', + qualifiedName: `${ownerQualifiedName}.Entry`, + ownerId: owner.nodeId, + }; + const makeEntry: SymbolDefinition = { + nodeId: `def:Builder.swift:${ownerSimple}.makeEntry`, + filePath: 'Builder.swift', + type: 'Method', + qualifiedName: `${ownerSimple}.makeEntry`, + }; + const extraDefs = options.extraDefs ?? []; + const classBindings = + options.classBindings ?? + new Map([['makeEntry', [{ def: makeEntry, origin: 'local' as const }]]]); + const declaration = parsedFile( + 'Types.swift', + [owner], + new Map([['Entry', [{ def: entry, origin: 'local' }]]]), + [owner, entry], + ); + const extension = parsedFile( + 'Builder.swift', + [], + classBindings, + [makeEntry], + rangeForSource(extensionSource, options.startCol ?? 0), + ); + const bindingAugmentations = new Map(); + const indexes = makeIndexes([owner, entry, makeEntry, ...extraDefs], bindingAugmentations); + return { declaration, extension, entry, indexes, bindingAugmentations }; +} From dcb2eb5cb4a395c8c2a34fca068ea859b7563073 Mon Sep 17 00:00:00 2001 From: Twisted_Arrow Date: Sat, 19 Sep 2026 20:20:31 +0530 Subject: [PATCH 20/20] fix(swift): resolve imports from Package.swift targets, not path segments (#3105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(swift): match repeated SPM target prefixes * test(swift): cover repeated SPM target prefixes * test(swift): cover valid prefix before later partial match * docs(swift): clarify target grouping parity scope * docs(swift): clarify target grouping parity scope * docs(swift): clarify target grouping parity scope * fix(swift): resolve imports from Package.swift targets, not path segments Stop fabricating IMPORTS from import Foundation onto a same-named folder. Declare modules from Package.swift when the manifest is usable; keep Sources/* for grouping and fail-open folder resolve minus SDK names. Co-authored-by: Cursor * fix(swift): keep empty Package.swift declarations and nested .target() deps external An inferred Sources/* folder is grouping-only. A dependency .target(name:) is not a module. Treat both as unresolved so import Foundation cannot bind to a decoy folder. Co-authored-by: Cursor * fix(swift): keep implicit IMPORTS intra-group and gate linear Package.swift resolve @_exported must not paint sibling files as implicit imports. A dedicated bench pins declaration-only resolve and (t_4n/t_n)/4 linearity. Co-authored-by: Cursor * Address PR review feedback (#3105) Honor member-only @_exported imports, skip comments while scanning Package.swift factories, fail-open mixed helper-built target lists, and block CoreData/CoreGraphics decoy folders on the inferred path. Co-authored-by: Cursor * Address PR review feedback (#3105) Match path: "." as the package root, skip block-commented Package.swift factories, read import kind from the clause only, and skip capture tests when the optional Swift grammar is missing. Co-authored-by: Cursor * fix(ci): scan Swift import-kind without nested regex backtracking CodeQL js/redos flagged the comment-skipping IMPORT_KIND_RE; a linear walk keeps the same kind tokens. Co-authored-by: Cursor * fix(ci): linear Package.swift factory scan and gate Swift context parseSwiftPackageManifest re-walked every prefix for comments (O(n²) in factory count). Resume the scan and cover nested factories in one pass. Wire Swift into the import-target context arm now that resolveImportTarget is 5-arg. Co-authored-by: Cursor * Address PR review feedback (#3105) Tighten Package.swift and import-text scanners: skip comments/strings, reject escapes, treat ident + [ as incomplete, and drop the unused factory-comment wrapper. Co-authored-by: Cursor * fix(ci): prettier the @_exported availability fixture Co-authored-by: Cursor * Address PR review feedback (#3105) Judge Package.swift completeness from Package(...)'s own targets: argument instead of raw-text regexes, nest block comments when reading an import kind, and strip leading ./ from declared target paths. Co-authored-by: Cursor * Address PR review feedback (#3105) Collect Package.swift factories only from Package(targets: [...]), fail-open on computed array elements, and treat // after a label colon as a comment. Co-authored-by: Cursor * Address PR review feedback (#3105) Ignore stray factories when Package() exists but omits targets:. Co-authored-by: Cursor * Address PR review feedback (#3105) Require the Package-scan seen box so the always-true undefined guard goes away. Co-authored-by: Cursor --------- Co-authored-by: Gergo Magyar Co-authored-by: Cursor --- .github/workflows/ci-tests.yml | 11 + gitnexus/bench/import-target/baselines.json | 5 + gitnexus/bench/import-target/measure.mjs | 26 +- .../swift-package-imports/baselines.json | 52 ++ .../bench/swift-package-imports/measure.mjs | 475 ++++++++++ .../import-resolvers/configs/swift.ts | 58 +- .../src/core/ingestion/language-config.ts | 849 +++++++++++++++++- .../languages/swift/implicit-imports.ts | 26 +- .../languages/swift/import-decomposer.ts | 234 ++++- .../languages/swift/import-target.ts | 191 +++- .../ingestion/languages/swift/interpret.ts | 20 +- .../languages/swift/scope-resolver.ts | 31 +- .../ingestion/languages/swift/sdk-modules.ts | 29 + .../languages/swift/target-grouping.ts | 45 +- .../languages/swift/target-siblings.ts | 11 +- .../test/unit/import-resolver-factory.test.ts | 25 + .../external-import-conformance.test.ts | 3 +- .../swift-target-grouping.test.ts | 47 + .../swift/implicit-imports.test.ts | 74 ++ .../swift/import-decomposer.test.ts | 154 ++++ .../swift/import-target.test.ts | 129 +++ .../swift/package-config.test.ts | 452 ++++++++++ .../swift/reexport-visibility.test.ts | 131 +++ .../swift/target-grouping.test.ts | 55 +- 24 files changed, 2959 insertions(+), 174 deletions(-) create mode 100644 gitnexus/bench/swift-package-imports/baselines.json create mode 100644 gitnexus/bench/swift-package-imports/measure.mjs create mode 100644 gitnexus/src/core/ingestion/languages/swift/sdk-modules.ts create mode 100644 gitnexus/test/unit/scope-resolution/swift-target-grouping.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/swift/implicit-imports.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/swift/import-decomposer.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/swift/import-target.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/swift/package-config.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/swift/reexport-visibility.test.ts diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 6f4bfdbec..bbf5d01af 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -618,6 +618,17 @@ jobs: run: node --import tsx bench/rust-cargo-targets/measure.mjs --check working-directory: gitnexus + - name: Swift Package.swift import-resolve guards (#2964, #2931) + if: ${{ !cancelled() }} + # Build-free: same baseline approach as parse-dispatch-rounds — + # exact declared/SDK/undeclared floors plus a fingerprint, then + # ratio timing only (resolveSwiftImportTarget, swiftPackageStrategy, + # parseSwiftPackageManifest 4n/n). Pins declaration-only resolve, + # https:// factory survival, and #2931 segment-boundary membership. + # See bench/swift-package-imports/measure.mjs. + run: node --import tsx bench/swift-package-imports/measure.mjs --check + working-directory: gitnexus + - name: MCP tools/list countRepos vs listRepos guards (#3259, #3184) if: ${{ !cancelled() }} # Build-free: exact registry cardinality + tool-roster + schema-flag diff --git a/gitnexus/bench/import-target/baselines.json b/gitnexus/bench/import-target/baselines.json index 37fa78535..7b9a9932d 100644 --- a/gitnexus/bench/import-target/baselines.json +++ b/gitnexus/bench/import-target/baselines.json @@ -659,6 +659,11 @@ "path_segments": 13, "probe": "ExternalPkg0" }, + "context": { + "target": "App", + "with_context": "Sources/App/Lib.swift,Sources/Models/User.swift", + "without_context": "Sources/App/Lib.swift" + }, "_measured": { "collide_ms": 0.819, "collide_scaling_ratio": 3.454, diff --git a/gitnexus/bench/import-target/measure.mjs b/gitnexus/bench/import-target/measure.mjs index 35c8361a1..b66932d77 100644 --- a/gitnexus/bench/import-target/measure.mjs +++ b/gitnexus/bench/import-target/measure.mjs @@ -627,7 +627,7 @@ const HEAP_BUDGETED = [ * their hooks declare three or four parameters — so their numbers stay exactly * where they were. */ -const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python']; +const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python', 'swift']; /** * Needs `node --expose-gc` to force collection for a clean delta; without it @@ -1869,7 +1869,7 @@ function resolveOne(lang, from, target, pass) { if (lang === 'swift') { return resolveSwiftImportTarget( { kind: 'namespace', localName: 'X', importedName: 'X', targetRaw: target }, - { fromFile: from, allFilePaths }, + { fromFile: from, allFilePaths, parsedFiles: pass.parsedFiles }, ); } if (lang === 'rust') return resolveRustImportTarget(target, from, allFilePaths, undefined); @@ -2326,6 +2326,26 @@ const CONTEXT_PROBE = { probeFile('app/main.py', [['Function', 'app.main.run']]), ], }, + /** + * `import App` where App `@_exported import`s Models. With parsedFiles the + * re-export closure unions Models' files; without it the adapter returns + * only App's own file. Two distinct non-null answers, so a dropped + * `parsedFiles` cannot look like a miss. + */ + swift: { + from: 'Sources/Client/Main.swift', + target: 'App', + parsedFiles: [ + { + ...probeFile('Sources/App/Lib.swift', [['Class', 'App.Lib']]), + parsedImports: [ + { kind: 'reexport', localName: 'Models', importedName: 'Models', targetRaw: 'Models' }, + ], + }, + probeFile('Sources/Models/User.swift', [['Class', 'Models.User']]), + probeFile('Sources/Client/Main.swift', [['Class', 'Client.Main']]), + ], + }, }; /** Resolve the probe twice through `resolveOne` — once with the pass's parsed @@ -3065,7 +3085,7 @@ expectNoOrphanKeys( // against a claim in a comment. `run.ts` passes the fifth argument to every // provider; which ones can OBSERVE it is decided by how many parameters each // hook declares, and that is a number the registry can be asked for. Today -// exactly four answer 5 (php, java, kotlin, python) and the other thirteen answer 3 or 4 — +// exactly five answer 5 (php, java, kotlin, python, swift) and the other twelve answer 3 or 4 — // which is why thirteen arms can ignore this whole question and their numbers // did not move when it was fixed. // diff --git a/gitnexus/bench/swift-package-imports/baselines.json b/gitnexus/bench/swift-package-imports/baselines.json new file mode 100644 index 000000000..2163b9f72 --- /dev/null +++ b/gitnexus/bench/swift-package-imports/baselines.json @@ -0,0 +1,52 @@ +{ + "_what": "Baselines for bench/swift-package-imports/measure.mjs --check. Guards Swift Package.swift declaration resolve after #2964 / #2931: declared-target hits, SDK and undeclared-folder misses, empty declaredTargets fail-closed, @_exported union, #2931 nested-prefix membership, first-wins grouping, and https:// factory survival. Same approach as bench/parse-dispatch-rounds/baselines.json — exact floors and a fingerprint first; the only timing arms are ratios.", + + "_triage": "READ THIS BEFORE RE-RUNNING. targets, files, imports, declared_resolved, sdk_external, undeclared_external, empty_declared_external, reexport_extra, nested_repeat_resolved, first_wins, parse_targets, parse_binary_skipped, url_comment_targets, parse_complete and layout_fingerprint are DETERMINISTIC: a re-run never changes them, and none may be re-baselined to make CI green. resolve_scaling_ratio, strategy_scaling_ratio and parse_scaling_ratio are the only timing arms; runner contention dominates them, so re-run on an idle machine before investigating and read the reported `reps` first. If exactly one arm fails and it is a timing arm, suspect the machine.", + + "targets": 16, + "files": 260, + "imports": 12288, + "_shape_note": "THE FLOOR. Without these three, every arm below is a ceiling over nothing. declared_resolved only asserts something while the corpus still walks many files and imports. Shrink it to one happy-path module and declared_resolved still reads 32 and still passes, asserting a property the corpus no longer has. Files-per-target is 16 and does not scale — 4x grows target count so a hit returns a constant-size file list. Scaling files-per-target instead makes the return-list copy look quadratic even when the index is linear (the same trap bench/import-target documents for Swift collide_scaling).", + + "declared_resolved": 32, + "sdk_external": 32, + "undeclared_external": 16, + "empty_declared_external": 1, + "reexport_extra": 1, + "nested_repeat_resolved": 1, + "first_wins": 1, + "_membership_note": "Exact resolve/grouping counts on the unique query set (one File0 per target). declared_resolved=32 is two declared spellings (Mod{t+1} and Mod{t+1}.Model) across 16 targets. sdk_external=32 is Foundation+UIKit. undeclared_external=16 is the in-repo CoreUI folder that Package.swift does not declare — the hole bench/import-target's Swift arm cannot see. empty_declared_external=1 pins an empty declaredTargets map. reexport_extra=1 pins import Mod0 including Mod1 after @_exported. nested_repeat_resolved=1 pins vendor/Sources/Mod0/Sources/Mod0/Nested.swift (#2931). first_wins=1 pins a two-prefix file into Mod0 only.", + + "parse_targets": 3, + "parse_binary_skipped": 1, + "url_comment_targets": 1, + "parse_complete": 1, + "_parse_note": "Exact parse floors on a fixed manifest, not the scaling corpus. parse_targets=3 is Models+App+AppTests. parse_binary_skipped=1 means Lib/Gen/CFoo never entered the map. url_comment_targets=1 pins https:// on the same line as a later .target. parse_complete=1 pins both manifests readable.", + + "layout_fingerprint": "2b220baa1df63778489949f9596879ba1c89a49e62c57bd9d2336fd8d74c17bd", + "_layout_fingerprint_note": "sha256 over sorted from|target->files rows on the unique query set. A change here is a BEHAVIOUR change — the resolver returned a different target set. Explain it, never re-baseline it alone.", + + "resolve_scaling_budget": 1.9, + "strategy_scaling_budget": 1.6, + "parse_scaling_budget": 1.9, + "_resolve_scaling_note": "(t_4n / t_n) / 4 for resolveSwiftImportTarget over the declared corpus; ~1.0 is linear. A RATIO rather than a millisecond ceiling, deliberately: wall-clock is runner-speed-dependent, and this repo has already been bitten by a fixed ms budget. A per-import allFilePaths scan scores ~4. Budget is 1.9, i.e. 1.51x the measured maximum 1.261 — this file's siblings use ~1.5x on ratios. min-of-15 estimator.", + "_strategy_scaling_note": "(t_4n / t_n) / 4 for swiftPackageStrategy (the import-config WeakMap index). Measured maximum 1.030; budget 1.6 is 1.55x, matching parse-dispatch-rounds' pack_scaling_budget.", + "_parse_scaling_note": "(t_4n / t_n) / 4 for parseSwiftPackageManifest over 256 vs 1024 factories. Measured maximum 1.276; budget 1.9 is 1.49x. The small cell is still sub-millisecond, so this budget is looser than strategy on purpose.", + + "_measured": { + "resolve_scaling_ratio": 1.261, + "resolve_scaling_ratio_samples": [1.261, 1.201, 1.182, 1.241, 1.212], + "strategy_scaling_ratio": 1.03, + "strategy_scaling_ratio_samples": [1.019, 0.994, 0.97, 1.015, 1.03], + "parse_scaling_ratio": 1.276, + "parse_scaling_ratio_samples": [1.25, 1.276, 1.215, 1.224, 1.227], + "resolve_ms": 3.5, + "resolve_ms_4x": 17.16, + "strategy_ms": 0.67, + "strategy_ms_4x": 2.65, + "parse_ms": 0.41, + "parse_ms_4x": 2.01, + "reps": 15 + }, + "_measured_note": "Maxima (and sample lists) over 5 consecutive local runs using the min-of-15 estimator from parse-dispatch-rounds. Milliseconds are diagnostic context only — nothing gates on them." +} diff --git a/gitnexus/bench/swift-package-imports/measure.mjs b/gitnexus/bench/swift-package-imports/measure.mjs new file mode 100644 index 000000000..6d41728b6 --- /dev/null +++ b/gitnexus/bench/swift-package-imports/measure.mjs @@ -0,0 +1,475 @@ +/** + * Build-free bench for Swift Package.swift declaration resolve (#2964 / #2931). + * + * WHY THIS EXISTS. `bench/import-target`'s Swift arm never threads a + * `resolutionConfig`. It times the no-manifest directory-segment index + * (`getSwiftModuleIndex`) and cannot see a revert that starts scanning + * `allFilePaths` per `import`, drops declaration-only resolve, treats + * `https://` as a comment, or matches a target dir inside another path + * segment (#2931). Graph output on a tiny fixture is identical either way. + * This file is the same shape as `bench/parse-dispatch-rounds`: exact floors + * first, ratio timing only, never a millisecond ceiling. + * + * ARMS: + * + * - `targets` / `files` / `imports` — EXACT, and they are the FLOOR. + * `declared_resolved` only asserts something while the corpus still has + * many files and imports to walk. Shrink it to one happy-path module and + * the resolve arm still passes, gating a property the corpus no longer has. + * + * - `declared_resolved` / `sdk_external` / `undeclared_external` / + * `empty_declared_external` / `reexport_extra` / `nested_repeat_resolved` / + * `first_wins` — EXACT. Declaration map hits stay hits. Foundation / UIKit + * and an in-repo `CoreUI` folder that is NOT in Package.swift stay + * external. An empty `declaredTargets` map fails every name closed. Import + * of a module that `@_exported import`s another unions that module's files. + * A `#2931` nested `Sources/Mod0` path still belongs to Mod0. Grouping + * assigns a two-prefix file to the FIRST target only. + * + * - `parse_targets` / `parse_binary_skipped` / `url_comment_targets` / + * `parse_complete` — EXACT. Source factories are kept; binary/plugin + * factories are not modules; `https://` on the same line does not hide a + * later `.target`. + * + * - `layout_fingerprint` — EXACT. sha256 over sorted `from|target->files` + * rows on the unique query set. Catches a target-set change that leaves + * the counts intact. + * + * - `resolve_scaling_ratio` / `strategy_scaling_ratio` / `parse_scaling_ratio` + * — the only timing arms, RATIOS not millisecond ceilings. + * `(t_4n / t_n) / 4` divides the machine out; ~1.0 is linear. + * Files-per-target is FIXED while target count (and therefore files and + * imports) grow 4x, so a hit returns a constant-size file list. A correct + * once-per-pass index is then linear in imports; a per-import scan of + * allFilePaths scores ~4. Parse scales factory count 4x on a fixed-shape + * manifest. + * + * Usage: + * node --import tsx bench/swift-package-imports/measure.mjs + * node --import tsx bench/swift-package-imports/measure.mjs --check + */ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import { parseSwiftPackageManifest } from '../../src/core/ingestion/language-config.ts'; +import { swiftPackageStrategy } from '../../src/core/ingestion/import-resolvers/configs/swift.ts'; +import { resolveSwiftImportTarget } from '../../src/core/ingestion/languages/swift/import-target.ts'; +import { groupSwiftFilesBySpmTarget } from '../../src/core/ingestion/languages/swift/target-grouping.ts'; + +const baselines = JSON.parse(readFileSync(new URL('./baselines.json', import.meta.url), 'utf8')); + +const REPS = 15; +const TARGETS_SMALL = 16; +const FILES_PER_TARGET = 16; +const QUERY_REPEATS = 8; +const PARSE_TARGETS = 256; +const SCALE = 4; + +function queryKinds(targetCount) { + return [ + (t) => ({ raw: `Mod${(t + 1) % targetCount}`, expect: 'declared' }), + () => ({ raw: 'Foundation', expect: 'sdk' }), + () => ({ raw: 'CoreUI', expect: 'undeclared' }), + (t) => ({ raw: `Mod${(t + 1) % targetCount}.Model`, expect: 'declared' }), + () => ({ raw: 'UIKit', expect: 'sdk' }), + () => ({ raw: 'Ghost', expect: 'miss' }), + ]; +} + +function parsedImport(targetRaw) { + return { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw }; +} + +function stubFile(filePath, parsedImports = []) { + return { + filePath, + moduleScope: `module:${filePath}`, + scopes: [], + parsedImports, + localDefs: [], + referenceSites: [], + }; +} + +function targetMap(targetCount) { + const targets = new Map(); + for (let t = 0; t < targetCount; t++) targets.set(`Mod${t}`, `Sources/Mod${t}`); + return targets; +} + +function declaredConfig(targets) { + return { origin: 'package.swift', targets, declaredTargets: targets }; +} + +function buildCorpus(targetCount) { + const targets = targetMap(targetCount); + const files = []; + const parsedFiles = []; + for (let t = 0; t < targetCount; t++) { + for (let i = 0; i < FILES_PER_TARGET; i++) { + const filePath = `Sources/Mod${t}/File${i}.swift`; + files.push(filePath); + const imports = + t === 0 && i === 0 + ? [{ kind: 'reexport', localName: 'Mod1', importedName: 'Mod1', targetRaw: 'Mod1' }] + : []; + parsedFiles.push(stubFile(filePath, imports)); + } + } + + const foundation = 'Sources/Foundation/Thing.swift'; + const coreUi = 'Sources/CoreUI/View.swift'; + const nested = 'vendor/Sources/Mod0/Sources/Mod0/Nested.swift'; + const clash = 'Sources/Mod0/Vendor/Sources/Mod1/Clash.swift'; + for (const extra of [foundation, coreUi, nested, clash]) { + files.push(extra); + parsedFiles.push(stubFile(extra)); + } + + const kinds = queryKinds(targetCount); + const queries = []; + for (let t = 0; t < targetCount; t++) { + for (let i = 0; i < FILES_PER_TARGET; i++) { + const from = `Sources/Mod${t}/File${i}.swift`; + for (let r = 0; r < QUERY_REPEATS; r++) { + for (const kind of kinds) { + const { raw, expect } = kind(t); + queries.push({ from, raw, expect, unique: r === 0 && i === 0 }); + } + } + } + } + + return { + files, + parsedFiles, + queries, + targets, + config: declaredConfig(targets), + extras: { foundation, coreUi, nested, clash }, + targetCount, + }; +} + +function outcomeKey(from, raw, result) { + if (result == null) return `${from}|${raw}->`; + const list = typeof result === 'string' ? [result] : [...result]; + return `${from}|${raw}->${list.sort().join(',')}`; +} + +function resultFiles(result) { + if (result == null) return []; + return typeof result === 'string' ? [result] : [...result]; +} + +function resolveOne(query, allFilePaths, config, parsedFiles) { + return resolveSwiftImportTarget(parsedImport(query.raw), { + fromFile: query.from, + allFilePaths, + resolutionConfig: config, + parsedFiles, + }); +} + +function strategyCtx(files, config) { + return { + allFilePaths: new Set(files), + allFileList: files, + normalizedFileList: files.map((p) => p.replace(/\\/g, '/')), + index: {}, + resolveCache: new Map(), + configs: { + tsconfigPaths: null, + goModule: null, + composerConfig: null, + swiftPackageConfig: config, + csharpConfigs: [], + }, + }; +} + +function resolvePass(corpus) { + const allFilePaths = new Set(corpus.files); + let resolved = 0; + for (const query of corpus.queries) { + if (resolveOne(query, allFilePaths, corpus.config, corpus.parsedFiles) != null) resolved++; + } + return resolved; +} + +function strategyPass(corpus) { + const ctx = strategyCtx(corpus.files, corpus.config); + let resolved = 0; + for (const query of corpus.queries) { + if (swiftPackageStrategy(query.raw, query.from, ctx) != null) resolved++; + } + return resolved; +} + +function manifestFor(targetCount) { + const rows = []; + for (let i = 0; i < targetCount; i++) { + rows.push( + ` .target(name: "Mod${i}", dependencies: [.product(name: "X", package: "https://example.com/x")]),`, + ); + if (i % 8 === 0) { + rows.push( + ` .binaryTarget(name: "Bin${i}", url: "https://example.com/b${i}.xcframework"),`, + ); + } + } + return `let package = Package(\n name: "Demo",\n targets: [\n${rows.join('\n')}\n ]\n)\n`; +} + +function fastest(fn, reps) { + fn(); + let best = Infinity; + for (let r = 0; r < reps; r++) { + const t0 = performance.now(); + fn(); + best = Math.min(best, performance.now() - t0); + } + return best; +} + +function correctness(corpus) { + const allFilePaths = new Set(corpus.files); + const records = []; + let declaredResolved = 0; + let sdkExternal = 0; + let undeclaredExternal = 0; + + const unique = corpus.queries.filter((q) => q.unique); + for (const query of unique) { + const result = resolveOne(query, allFilePaths, corpus.config, corpus.parsedFiles); + records.push(outcomeKey(query.from, query.raw, result)); + if (query.expect === 'declared' && result != null) declaredResolved++; + if (query.expect === 'sdk' && result == null) sdkExternal++; + if (query.expect === 'undeclared' && result == null) undeclaredExternal++; + } + + const fromOther = 'Sources/Mod2/File0.swift'; + const reexport = resultFiles( + resolveOne({ from: fromOther, raw: 'Mod0' }, allFilePaths, corpus.config, corpus.parsedFiles), + ); + const reexportExtra = + reexport.includes('Sources/Mod0/File0.swift') && reexport.includes('Sources/Mod1/File0.swift') + ? 1 + : 0; + const nestedRepeatResolved = reexport.includes(corpus.extras.nested) ? 1 : 0; + + const empty = resolveOne( + { from: fromOther, raw: 'Mod0' }, + allFilePaths, + { origin: 'package.swift', targets: corpus.targets, declaredTargets: new Map() }, + corpus.parsedFiles, + ); + const emptyDeclaredExternal = empty == null ? 1 : 0; + + const groups = groupSwiftFilesBySpmTarget(corpus.files, (p) => p, corpus.targets); + const firstWins = + groups.get('Mod0')?.includes(corpus.extras.clash) === true && + groups.get('Mod1')?.includes(corpus.extras.clash) !== true + ? 1 + : 0; + + const parseFixed = parseSwiftPackageManifest(` +let package = Package( + name: "Demo", + dependencies: [.package(url: "https://example.com/foo.git", from: "1.0.0")], + targets: [ + .target(name: "Models"), + .target(name: "App"), + .binaryTarget(name: "Lib", url: "https://example.com/Lib.xcframework", checksum: "abc"), + .testTarget(name: "AppTests"), + .plugin(name: "Gen"), + .systemLibrary(name: "CFoo"), + ] +) +`); + const urlComment = parseSwiftPackageManifest( + 'let package = Package(name: "Demo", dependencies: [.package(url: "https://example.com/foo.git", from: "1.0.0")], targets: [.target(name: "T")])', + ); + + return { + declaredResolved, + sdkExternal, + undeclaredExternal, + emptyDeclaredExternal, + reexportExtra, + nestedRepeatResolved, + firstWins, + parseTargets: parseFixed.targets.size, + parseBinarySkipped: + parseFixed.targets.has('Lib') || + parseFixed.targets.has('Gen') || + parseFixed.targets.has('CFoo') + ? 0 + : 1, + urlCommentTargets: urlComment.targets.get('T') === 'Sources/T' ? 1 : 0, + parseComplete: parseFixed.complete && urlComment.complete ? 1 : 0, + fingerprint: createHash('sha256').update(records.sort().join('\n')).digest('hex'), + }; +} + +const small = buildCorpus(TARGETS_SMALL); +const large = buildCorpus(TARGETS_SMALL * SCALE); +const shape = correctness(small); + +const smallResolveMs = fastest(() => resolvePass(small), REPS); +const largeResolveMs = fastest(() => resolvePass(large), REPS); +const resolveScaling = largeResolveMs / smallResolveMs / SCALE; + +const smallStrategyMs = fastest(() => strategyPass(small), REPS); +const largeStrategyMs = fastest(() => strategyPass(large), REPS); +const strategyScaling = largeStrategyMs / smallStrategyMs / SCALE; + +const parseSmallSrc = manifestFor(PARSE_TARGETS); +const parseLargeSrc = manifestFor(PARSE_TARGETS * SCALE); +const parseSmall = parseSwiftPackageManifest(parseSmallSrc); +const parseLarge = parseSwiftPackageManifest(parseLargeSrc); +const smallParseMs = fastest(() => parseSwiftPackageManifest(parseSmallSrc), REPS); +const largeParseMs = fastest(() => parseSwiftPackageManifest(parseLargeSrc), REPS); +const parseScaling = largeParseMs / smallParseMs / SCALE; + +const files = small.files.length; +const imports = small.queries.length; + +console.log(`targets : ${small.targetCount} (expect ${baselines.targets})`); +console.log(`files : ${files} (expect ${baselines.files})`); +console.log(`imports : ${imports} (expect ${baselines.imports})`); +console.log( + `declared_resolved : ${shape.declaredResolved} (expect ${baselines.declared_resolved})`, +); +console.log(`sdk_external : ${shape.sdkExternal} (expect ${baselines.sdk_external})`); +console.log( + `undeclared_external : ${shape.undeclaredExternal} (expect ${baselines.undeclared_external})`, +); +console.log( + `empty_declared_external: ${shape.emptyDeclaredExternal} (expect ${baselines.empty_declared_external})`, +); +console.log( + `reexport_extra : ${shape.reexportExtra} (expect ${baselines.reexport_extra})`, +); +console.log( + `nested_repeat_resolved : ${shape.nestedRepeatResolved} (expect ${baselines.nested_repeat_resolved})`, +); +console.log(`first_wins : ${shape.firstWins} (expect ${baselines.first_wins})`); +console.log(`parse_targets : ${shape.parseTargets} (expect ${baselines.parse_targets})`); +console.log( + `parse_binary_skipped : ${shape.parseBinarySkipped} (expect ${baselines.parse_binary_skipped})`, +); +console.log( + `url_comment_targets : ${shape.urlCommentTargets} (expect ${baselines.url_comment_targets})`, +); +console.log( + `parse_complete : ${shape.parseComplete} (expect ${baselines.parse_complete})`, +); +console.log(`layout_fingerprint : ${shape.fingerprint}`); +console.log( + `resolve_scaling_ratio : ${resolveScaling.toFixed(3)} (budget <= ${baselines.resolve_scaling_budget}; ~1.0 is linear)`, +); +console.log( + `strategy_scaling_ratio : ${strategyScaling.toFixed(3)} (budget <= ${baselines.strategy_scaling_budget}; ~1.0 is linear)`, +); +console.log( + `parse_scaling_ratio : ${parseScaling.toFixed(3)} (budget <= ${baselines.parse_scaling_budget}; ~1.0 is linear)`, +); +console.log( + `reps : ${REPS} resolve ${smallResolveMs.toFixed(2)}ms / 4x ${largeResolveMs.toFixed(2)}ms strategy ${smallStrategyMs.toFixed(2)}ms / 4x ${largeStrategyMs.toFixed(2)}ms parse ${smallParseMs.toFixed(2)}ms / 4x ${largeParseMs.toFixed(2)}ms`, +); +console.log( + `parse_scale_shape : ${parseSmall.targets.size} -> ${parseLarge.targets.size} targets (expect ${PARSE_TARGETS} -> ${PARSE_TARGETS * SCALE})`, +); + +if (process.argv.includes('--check')) { + let failed = false; + + if (shape.fingerprint !== baselines.layout_fingerprint) { + failed = true; + console.error( + `\nFAIL layout_fingerprint: ${shape.fingerprint}\n` + + ` expected ${baselines.layout_fingerprint}\n` + + ` Unique-query target set moved. Explain it; do not re-baseline alone.`, + ); + } + + const exact = [ + ['targets', small.targetCount], + ['files', files], + ['imports', imports], + ['declared_resolved', shape.declaredResolved], + ['sdk_external', shape.sdkExternal], + ['undeclared_external', shape.undeclaredExternal], + ['empty_declared_external', shape.emptyDeclaredExternal], + ['reexport_extra', shape.reexportExtra], + ['nested_repeat_resolved', shape.nestedRepeatResolved], + ['first_wins', shape.firstWins], + ['parse_targets', shape.parseTargets], + ['parse_binary_skipped', shape.parseBinarySkipped], + ['url_comment_targets', shape.urlCommentTargets], + ['parse_complete', shape.parseComplete], + ]; + for (const [name, value] of exact) { + if (value !== baselines[name]) { + failed = true; + console.error(`\nFAIL ${name}: ${value}, expected exactly ${baselines[name]}.`); + } + } + + if ( + small.targetCount !== baselines.targets || + files !== baselines.files || + imports !== baselines.imports + ) { + failed = true; + console.error( + `\nFAIL shape: the corpus must stay large enough that declared_resolved still measures a walk.`, + ); + } + + if ( + parseSmall.targets.size !== PARSE_TARGETS || + parseLarge.targets.size !== PARSE_TARGETS * SCALE + ) { + failed = true; + console.error( + `\nFAIL parse scale shape: ${parseSmall.targets.size} -> ${parseLarge.targets.size}, ` + + `expected ${PARSE_TARGETS} -> ${PARSE_TARGETS * SCALE}.`, + ); + } + + if (resolveScaling > baselines.resolve_scaling_budget) { + failed = true; + console.error( + `\nFAIL resolve_scaling_ratio: ${resolveScaling.toFixed(3)} exceeds ` + + `${baselines.resolve_scaling_budget} (~1.0 is linear).\n` + + ` resolveSwiftImportTarget grew superlinearly in file+import count — a\n` + + ` per-import scan of allFilePaths scores ~4 here. Re-run on an idle\n` + + ` machine before investigating, and check \`reps\` first.`, + ); + } + + if (strategyScaling > baselines.strategy_scaling_budget) { + failed = true; + console.error( + `\nFAIL strategy_scaling_ratio: ${strategyScaling.toFixed(3)} exceeds ` + + `${baselines.strategy_scaling_budget} (~1.0 is linear).\n` + + ` swiftPackageStrategy grew superlinearly — usually the WeakMap target\n` + + ` index falling back to O(imports × files). Re-run idle; check \`reps\`.`, + ); + } + + if (parseScaling > baselines.parse_scaling_budget) { + failed = true; + console.error( + `\nFAIL parse_scaling_ratio: ${parseScaling.toFixed(3)} exceeds ` + + `${baselines.parse_scaling_budget} (~1.0 is linear).\n` + + ` parseSwiftPackageManifest grew superlinearly in factory count.\n` + + ` Re-run on an idle machine before investigating, and check \`reps\` first.`, + ); + } + + if (failed) process.exit(1); + console.log('\nOK — within budget.'); +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts index 05e4b13be..67e88dd32 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts @@ -13,18 +13,22 @@ * every strategy invocation, per `import-processor`'s build-once * context). Lookup per import is then O(1). * - * Behavior is preserved bit-for-bit: a file is attributed to a target - * iff its **forward-slash (backslash-normalized), case-sensitive** path - * starts with `/`, matching the old - * `normalizedFileList[i].startsWith(targetDir + '/')` comparison - * (`normalizedFileList` is only backslash→forward-slash normalized — NOT - * lowercased — so the match is case-sensitive); the returned paths are - * the original-case `allFileList` entries; and the per-target file ORDER - * follows `allFileList`, so the emitted `{ kind: 'files', files }` set and - * ordering are identical to the old scan. + * A file is attributed to a target iff its **forward-slash + * (backslash-normalized), case-sensitive** path matches at a + * **segment boundary**: it starts with `/` or contains + * `//`. `normalizedFileList` is + * only backslash→forward-slash normalized — NOT lowercased — so the + * match is case-sensitive. The returned paths are the original-case + * `allFileList` entries, and the per-target file ORDER follows + * `allFileList`. + * + * Import-config fans a file to every matching declared target. Grouping + * (`groupSwiftFilesBySpmTarget`) is first-target-wins. That divergence + * is intentional. */ import { SupportedLanguages } from 'gitnexus-shared'; +import { coerceDeclaredSwiftTargets, swiftDeclaredTargetPrefix } from '../../language-config.js'; import type { ImportResolutionConfig, ImportResolverStrategy, ResolveCtx } from '../types.js'; interface SwiftTargetIndex { @@ -66,10 +70,10 @@ function getSwiftTargetIndex( // Pre-compute each target's directory prefix once (original case, to // match the legacy comparison against the forward-slash-normalized, // case-sensitive file list — see module docstring). - const targetPrefixes: { name: string; prefix: string }[] = []; + const targetDirs: { name: string; prefix: string }[] = []; const byTarget = new Map(); for (const [name, dir] of targets) { - targetPrefixes.push({ name, prefix: dir + '/' }); + targetDirs.push({ name, prefix: swiftDeclaredTargetPrefix(dir) }); byTarget.set(name, []); } @@ -82,10 +86,10 @@ function getSwiftTargetIndex( for (let i = 0; i < ctx.allFileList.length; i++) { const norm = ctx.normalizedFileList[i]; if (!norm.endsWith('.swift')) continue; - for (const { name, prefix } of targetPrefixes) { - if (norm.startsWith(prefix)) { - byTarget.get(name)!.push(ctx.allFileList[i]); - } + for (const { name, prefix } of targetDirs) { + if (!norm.startsWith(prefix) && !norm.includes(`/${prefix}`)) continue; + const bucket = byTarget.get(name); + if (bucket !== undefined) bucket.push(ctx.allFileList[i]); } } @@ -97,19 +101,19 @@ function getSwiftTargetIndex( /** Swift Package.swift target map resolution strategy. */ export const swiftPackageStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => { const swiftPackageConfig = ctx.configs.swiftPackageConfig; - if (swiftPackageConfig) { - // Only the targets map is needed; build the index lazily so repos - // without a Package.swift config pay nothing. - if (swiftPackageConfig.targets.has(rawImportPath)) { - const index = getSwiftTargetIndex(ctx, swiftPackageConfig.targets); - const files = index.byTarget.get(rawImportPath); - if (files !== undefined && files.length > 0) { - // Copy so callers can't mutate the cached index bucket. - return { kind: 'files', files: [...files] }; - } - } + if (swiftPackageConfig == null) return null; + const declared = coerceDeclaredSwiftTargets(swiftPackageConfig); + if (declared == null) return null; + const moduleName = rawImportPath.split('.')[0]; + if (moduleName === '' || !declared.has(moduleName)) { + return null; } - return null; // External framework (Foundation, UIKit, etc.) + const index = getSwiftTargetIndex(ctx, declared); + const files = index.byTarget.get(moduleName); + if (files !== undefined && files.length > 0) { + return { kind: 'files', files: [...files] }; + } + return null; }; export const swiftImportConfig: ImportResolutionConfig = { diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index 390e6a32f..759eee48f 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -175,9 +175,55 @@ export function csharpScanToEvidence(scan: CSharpProjectScan): CSharpNamespaceEv } /** Swift Package Manager module config */ +export type SwiftPackageConfigOrigin = 'package.swift' | 'directories'; + export interface SwiftPackageConfig { /** Map of target name -> source directory path (e.g., "SiuperModel" -> "Package/Sources/SiuperModel") */ targets: Map; + /** + * `package.swift` — extracted from a readable Package.swift with no + * completeness hazards. Explicit import resolve may treat this as a + * declaration map (empty means every name is external). + * `directories` — inferred from `Sources/*` (or Package/Sources / src) + * when no usable declaration exists. Grouping uses this; import resolve + * must not. + * Omitted on hand-built test configs: treated as a declaration map so + * existing `{ targets }` fixtures stay valid. + */ + origin?: SwiftPackageConfigOrigin; + /** + * Declaration map when `origin` is `package.swift` (may be empty). + * Grouping uses `targets`, which is this map when it is non-empty and the + * inferred `Sources/*` map when the declaration is empty — so a + * binary-only Package.swift does not collapse every file into `__default__`. + */ + declaredTargets?: Map; +} + +/** + * Declaration view for explicit import resolve. `origin: 'directories'` + * is grouping-only. A hand-built `{ targets }` with no origin stays a + * declaration so existing fixtures keep working. + */ +export function coerceDeclaredSwiftTargets( + resolutionConfig: unknown, +): ReadonlyMap | null { + const config = resolutionConfig as Partial | null | undefined; + if (config == null) return null; + if (config.origin === 'directories') return null; + if (config.declaredTargets instanceof Map) return config.declaredTargets; + if (config.targets instanceof Map) return config.targets; + return null; +} + +/** Segment-boundary prefix for a Package.swift `path:`. `"."` / `"./"` is the package root. */ +export function swiftDeclaredTargetPrefix(dir: string): string { + let norm = dir.replace(/\\/g, '/'); + while (norm.startsWith('./')) { + norm = norm.slice(2); + } + norm = norm.replace(/\/+$/, ''); + return norm === '' || norm === '.' ? '' : `${norm}/`; } /** Zig package config parsed from build.zig.zon and the root build.zig */ @@ -616,12 +662,768 @@ async function collectDeclaredNamespaces( return structure.incomplete ? 'truncated' : 'ok'; } -export async function loadSwiftPackageConfig(repoRoot: string): Promise { - // Swift imports are module-name based (e.g., `import SiuperModel`) - // SPM convention: Sources// or Package/Sources// - // We scan for these directories to build a target map - const targets = new Map(); +const SWIFT_SOURCE_FACTORY_NAMES = ['target', 'executableTarget', 'testTarget', 'macro'] as const; +const SWIFT_SKIP_FACTORY_NAMES = ['binaryTarget', 'plugin', 'systemLibrary'] as const; +const SWIFT_SKIP_FACTORIES = new Set(SWIFT_SKIP_FACTORY_NAMES); +const SWIFT_FACTORY_RE = new RegExp( + `\\.(${[...SWIFT_SOURCE_FACTORY_NAMES, ...SWIFT_SKIP_FACTORY_NAMES].join('|')})\\s*\\(`, + 'g', +); +function extractBalancedParen(source: string, openIndex: number): string | null { + let depth = 0; + let inString: '"' | "'" | null = null; + let escape = false; + let inLineComment = false; + let blockCommentDepth = 0; + for (let i = openIndex; i < source.length; i++) { + const ch = source[i]; + const next = source[i + 1]; + if (inLineComment) { + if (ch === '\n') inLineComment = false; + continue; + } + if (blockCommentDepth > 0) { + if (ch === '*' && next === '/') { + blockCommentDepth--; + i++; + } else if (ch === '/' && next === '*') { + blockCommentDepth++; + i++; + } + continue; + } + if (inString !== null) { + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + continue; + } + if (ch === '/' && next === '/') { + // `https://` lives inside a string, already excluded above. + inLineComment = true; + i++; + continue; + } else if (ch === '/' && next === '*') { + blockCommentDepth = 1; + i++; + continue; + } + if (ch === '(') depth++; + else if (ch === ')') { + depth--; + if (depth === 0) return source.slice(openIndex + 1, i); + } + } + return null; +} +function isSwiftIdentCont(ch: string | undefined): boolean { + return ch !== undefined && /[A-Za-z0-9_]/.test(ch); +} + +function skipSwiftWsAndComments(source: string, start: number): number | null { + let i = start; + while (i < source.length) { + const ch = source[i]; + const next = source[i + 1]; + if (/\s/.test(ch)) { + i++; + continue; + } + if (ch === '/' && next === '/') { + const nl = source.indexOf('\n', i + 2); + if (nl === -1) return null; + i = nl + 1; + continue; + } + if (ch === '/' && next === '*') { + let depth = 1; + i += 2; + while (i < source.length && depth > 0) { + if (source[i] === '/' && source[i + 1] === '*') { + depth++; + i += 2; + } else if (source[i] === '*' && source[i + 1] === '/') { + depth--; + i += 2; + } else { + i++; + } + } + if (depth !== 0) return null; + continue; + } + return i; + } + return null; +} + +/** First `name:` / `path:` string outside comments. Escapes and interpolations are unreadable. */ +function readSwiftFactoryField( + block: string, + field: 'name' | 'path', +): { value: string | undefined; keyPresent: boolean } { + let inString: '"' | "'" | null = null; + let escape = false; + let inLineComment = false; + let blockCommentDepth = 0; + for (let i = 0; i < block.length; i++) { + const ch = block[i]; + const next = block[i + 1]; + if (inLineComment) { + if (ch === '\n') inLineComment = false; + continue; + } + if (blockCommentDepth > 0) { + if (ch === '*' && next === '/') { + blockCommentDepth--; + i++; + } else if (ch === '/' && next === '*') { + blockCommentDepth++; + i++; + } + continue; + } + if (inString !== null) { + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + continue; + } + if (ch === '/' && next === '/') { + inLineComment = true; + i++; + continue; + } + if (ch === '/' && next === '*') { + blockCommentDepth = 1; + i++; + continue; + } + if (!/[A-Za-z_]/.test(ch)) continue; + let j = i + 1; + while (j < block.length && isSwiftIdentCont(block[j])) j++; + if (block.slice(i, j) !== field) { + i = j - 1; + continue; + } + const colonAt = skipSwiftWsAndComments(block, j); + if (colonAt === null || block[colonAt] !== ':') { + i = j - 1; + continue; + } + const valueAt = skipSwiftWsAndComments(block, colonAt + 1); + if (valueAt === null) return { value: undefined, keyPresent: true }; + const quote = block[valueAt]; + if (quote !== '"' && quote !== "'") return { value: undefined, keyPresent: true }; + const parsed = readSwiftSimpleQuotedString(block, valueAt); + if (parsed === null) return { value: undefined, keyPresent: true }; + return { value: parsed, keyPresent: true }; + } + return { value: undefined, keyPresent: false }; +} + +/** Quoted literal with no escapes. Any `\` (including `\u{…}` and `\(`) is unreadable. */ +function readSwiftSimpleQuotedString(source: string, openIndex: number): string | null { + const quote = source[openIndex]; + let i = openIndex + 1; + while (i < source.length) { + const ch = source[i]; + if (ch === '\\') return null; + if (ch === quote) return source.slice(openIndex + 1, i); + if (ch === '\n') return null; + i++; + } + return null; +} + +function swiftManifestHasCompletenessHazard(source: string): boolean { + let inString: '"' | "'" | null = null; + let escape = false; + let inLineComment = false; + let blockCommentDepth = 0; + let atLineStart = true; + for (let i = 0; i < source.length; i++) { + const ch = source[i]; + const next = source[i + 1]; + if (inLineComment) { + if (ch === '\n') { + inLineComment = false; + atLineStart = true; + } + continue; + } + if (blockCommentDepth > 0) { + if (ch === '*' && next === '/') { + blockCommentDepth--; + i++; + } else if (ch === '/' && next === '*') { + blockCommentDepth++; + i++; + } else if (ch === '\n') { + atLineStart = true; + } + continue; + } + if (inString !== null) { + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === inString) inString = null; + else if (ch === '\n') atLineStart = true; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + atLineStart = false; + continue; + } + if (ch === '/' && next === '/') { + inLineComment = true; + i++; + atLineStart = false; + continue; + } + if (ch === '/' && next === '*') { + blockCommentDepth = 1; + i++; + atLineStart = false; + continue; + } + if (ch === '\n') { + atLineStart = true; + continue; + } + if (atLineStart && /\s/.test(ch)) continue; + if (atLineStart && ch === '#') { + if (source.startsWith('if', i + 1) && !isSwiftIdentCont(source[i + 3])) return true; + if (source.startsWith('elseif', i + 1) && !isSwiftIdentCont(source[i + 7])) return true; + } + atLineStart = false; + } + return false; +} + +interface SwiftCommentScan { + i: number; + inString: '"' | "'" | null; + escape: boolean; + inLineComment: boolean; + blockCommentDepth: number; +} + +function newSwiftCommentScan(): SwiftCommentScan { + return { i: 0, inString: null, escape: false, inLineComment: false, blockCommentDepth: 0 }; +} + +/** Resume the comment/string walk up to `upTo`. Matches are left-to-right, so this is O(n) over the file. */ +function advanceSwiftCommentScan(source: string, state: SwiftCommentScan, upTo: number): void { + let { i, inString, escape, inLineComment, blockCommentDepth } = state; + for (; i < upTo; i++) { + const ch = source[i]; + const next = source[i + 1]; + if (inLineComment) { + if (ch === '\n') inLineComment = false; + continue; + } + if (blockCommentDepth > 0) { + if (ch === '*' && next === '/') { + blockCommentDepth--; + i++; + } else if (ch === '/' && next === '*') { + blockCommentDepth++; + i++; + } + continue; + } + if (inString !== null) { + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + continue; + } + if (ch === '/' && next === '/') { + inLineComment = true; + i++; + continue; + } + if (ch === '/' && next === '*') { + blockCommentDepth = 1; + i++; + } + } + state.i = i; + state.inString = inString; + state.escape = escape; + state.inLineComment = inLineComment; + state.blockCommentDepth = blockCommentDepth; +} + +function swiftPathIsUnreadable(customPath: string | undefined, hasPathKey: boolean): boolean { + if (!hasPathKey) return false; + return customPath === undefined || customPath === '' || customPath.includes('\\('); +} + +/** Heuristic Package.swift scan. Never shells out to `swift package dump-package`. */ +export function parseSwiftPackageManifest(source: string): { + targets: Map; + complete: boolean; +} { + const targets = new Map(); + if (swiftManifestHasCompletenessHazard(source)) { + return { targets, complete: false }; + } + + const packageTargets = inspectSwiftPackageTargets(source); + + SWIFT_FACTORY_RE.lastIndex = 0; + let match: RegExpExecArray | null; + let sawUnreadableFactory = false; + const commentScan = newSwiftCommentScan(); + let coveredEnd = -1; + while ((match = SWIFT_FACTORY_RE.exec(source)) !== null) { + advanceSwiftCommentScan(source, commentScan, match.index); + if ( + commentScan.inLineComment || + commentScan.blockCommentDepth > 0 || + commentScan.inString !== null + ) { + continue; + } + if ( + packageTargets.sawPackage && + !packageTargets.arraySpans.some(([lo, hi]) => match.index >= lo && match.index <= hi) + ) { + continue; + } + if (match.index > 0 && match.index < coveredEnd) continue; + const kind = match[1]; + const paren = source.indexOf('(', match.index); + const block = extractBalancedParen(source, paren); + if (block === null) { + sawUnreadableFactory = true; + continue; + } + coveredEnd = Math.max(coveredEnd, paren + 1 + block.length + 1); + if (SWIFT_SKIP_FACTORIES.has(kind)) continue; + const nameField = readSwiftFactoryField(block, 'name'); + if (nameField.value === undefined || nameField.value === '') { + sawUnreadableFactory = true; + continue; + } + const name = nameField.value; + const pathField = readSwiftFactoryField(block, 'path'); + const customPath = pathField.value; + if (swiftPathIsUnreadable(customPath, pathField.keyPresent)) { + sawUnreadableFactory = true; + continue; + } + const dir = customPath ?? (kind === 'testTarget' ? `Tests/${name}` : `Sources/${name}`); + const existing = targets.get(name); + if (existing === undefined) { + targets.set(name, dir); + } else if (customPath !== undefined && existing === `Sources/${name}`) { + // A later `.target(name:path:)` wins over an earlier same-name + // factory that only implied the default path. + targets.set(name, customPath); + } + } + + return { + targets, + complete: !sawUnreadableFactory && !packageTargets.helperBuilt, + }; +} + +interface SwiftPackageTargetsInspection { + helperBuilt: boolean; + sawPackage: boolean; + arraySpans: Array<[number, number]>; +} + +const SWIFT_ALL_FACTORY_NAMES = new Set([ + ...SWIFT_SOURCE_FACTORY_NAMES, + ...SWIFT_SKIP_FACTORY_NAMES, +]); + +/** Locate `Package(...)`'s `targets:` argument. Product `targets:` stay nested. */ +function inspectSwiftPackageTargets(source: string): SwiftPackageTargetsInspection { + const arraySpans: Array<[number, number]> = []; + let helperBuilt = false; + const seen = { package: false }; + const unreadable = forEachSwiftPackageArgs( + source, + (args, argsStart) => { + const found = inspectPackageTargetsArg(args); + if (found.helperBuilt) { + helperBuilt = true; + return true; + } + if (found.arrayStart !== null && found.arrayEnd !== null) { + arraySpans.push([argsStart + found.arrayStart, argsStart + found.arrayEnd]); + } + return false; + }, + seen, + ); + return { helperBuilt: helperBuilt || unreadable, sawPackage: seen.package, arraySpans }; +} + +/** Walk `Package(` calls outside comments/strings. Unclosed `Package(` is incomplete. */ +function forEachSwiftPackageArgs( + source: string, + visit: (args: string, argsStart: number) => boolean, + seen: { package: boolean }, +): boolean { + let inString: '"' | "'" | null = null; + let escape = false; + let inLineComment = false; + let blockCommentDepth = 0; + for (let i = 0; i < source.length; i++) { + const ch = source[i]; + const next = source[i + 1]; + if (inLineComment) { + if (ch === '\n') inLineComment = false; + continue; + } + if (blockCommentDepth > 0) { + if (ch === '*' && next === '/') { + blockCommentDepth--; + i++; + } else if (ch === '/' && next === '*') { + blockCommentDepth++; + i++; + } + continue; + } + if (inString !== null) { + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + continue; + } + if (ch === '/' && next === '/') { + inLineComment = true; + i++; + continue; + } + if (ch === '/' && next === '*') { + blockCommentDepth = 1; + i++; + continue; + } + if ( + !source.startsWith('Package', i) || + isSwiftIdentCont(source[i + 7]) || + (i > 0 && isSwiftIdentCont(source[i - 1])) + ) { + continue; + } + const parenAt = skipSwiftWsAndComments(source, i + 7); + if (parenAt === null || source[parenAt] !== '(') continue; + seen.package = true; + const args = extractBalancedParen(source, parenAt); + if (args === null) return true; + if (visit(args, parenAt + 1)) return true; + i = parenAt + args.length + 1; + } + return false; +} + +function inspectPackageTargetsArg(args: string): { + helperBuilt: boolean; + arrayStart: number | null; + arrayEnd: number | null; +} { + let inString: '"' | "'" | null = null; + let escape = false; + let inLineComment = false; + let blockCommentDepth = 0; + let paren = 0; + for (let i = 0; i < args.length; i++) { + const ch = args[i]; + const next = args[i + 1]; + if (inLineComment) { + if (ch === '\n') inLineComment = false; + continue; + } + if (blockCommentDepth > 0) { + if (ch === '*' && next === '/') { + blockCommentDepth--; + i++; + } else if (ch === '/' && next === '*') { + blockCommentDepth++; + i++; + } + continue; + } + if (inString !== null) { + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + continue; + } + if (ch === '/' && next === '/') { + inLineComment = true; + i++; + continue; + } + if (ch === '/' && next === '*') { + blockCommentDepth = 1; + i++; + continue; + } + if (ch === '(') { + paren++; + continue; + } + if (ch === ')') { + paren--; + continue; + } + if (paren !== 0) continue; + if ( + !args.startsWith('targets', i) || + isSwiftIdentCont(args[i + 7]) || + (i > 0 && isSwiftIdentCont(args[i - 1])) + ) { + continue; + } + const colonAt = skipSwiftWsAndComments(args, i + 7); + if (colonAt === null || args[colonAt] !== ':') { + i += 6; + continue; + } + return classifyPackageTargetsValue(args, colonAt + 1); + } + return { helperBuilt: false, arrayStart: null, arrayEnd: null }; +} + +function classifyPackageTargetsValue( + args: string, + afterColon: number, +): { + helperBuilt: boolean; + arrayStart: number | null; + arrayEnd: number | null; +} { + const start = skipSwiftWsAndComments(args, afterColon); + if (start === null) return { helperBuilt: true, arrayStart: null, arrayEnd: null }; + if (args[start] === '[') { + const close = matchSwiftSquare(args, start); + if (close === null) return { helperBuilt: true, arrayStart: null, arrayEnd: null }; + const next = skipSwiftWsAndComments(args, close + 1); + if (next !== null && args[next] === '+') { + return { helperBuilt: true, arrayStart: start, arrayEnd: close }; + } + if (packageTargetsArrayHasComputed(args, start, close)) { + return { helperBuilt: true, arrayStart: start, arrayEnd: close }; + } + return { helperBuilt: false, arrayStart: start, arrayEnd: close }; + } + return { helperBuilt: true, arrayStart: null, arrayEnd: null }; +} + +function packageTargetsArrayHasComputed(source: string, open: number, close: number): boolean { + let inString: '"' | "'" | null = null; + let escape = false; + let inLineComment = false; + let blockCommentDepth = 0; + let paren = 0; + let bracket = 0; + for (let i = open; i < close; i++) { + const ch = source[i]; + const next = source[i + 1]; + if (inLineComment) { + if (ch === '\n') inLineComment = false; + continue; + } + if (blockCommentDepth > 0) { + if (ch === '*' && next === '/') { + blockCommentDepth--; + i++; + } else if (ch === '/' && next === '*') { + blockCommentDepth++; + i++; + } + continue; + } + if (inString !== null) { + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + continue; + } + if (ch === '/' && next === '/') { + inLineComment = true; + i++; + continue; + } + if (ch === '/' && next === '*') { + blockCommentDepth = 1; + i++; + continue; + } + if (ch === '[') { + bracket++; + continue; + } + if (ch === ']') { + bracket--; + continue; + } + if (ch === '(') { + paren++; + continue; + } + if (ch === ')') { + paren--; + continue; + } + if (bracket !== 1 || paren !== 0) continue; + if (ch === ',' || /\s/.test(ch)) continue; + if (ch === '.') { + let j = i + 1; + while (j < close && isSwiftIdentCont(source[j])) j++; + const name = source.slice(i + 1, j); + const after = skipSwiftWsAndComments(source, j); + if (after !== null && source[after] === '(' && SWIFT_ALL_FACTORY_NAMES.has(name)) { + const block = extractBalancedParen(source, after); + if (block === null) return true; + i = after + block.length + 1; + continue; + } + return true; + } + return true; + } + return false; +} + +function matchSwiftSquare(source: string, openIndex: number): number | null { + let depth = 0; + let inString: '"' | "'" | null = null; + let escape = false; + let inLineComment = false; + let blockCommentDepth = 0; + for (let i = openIndex; i < source.length; i++) { + const ch = source[i]; + const next = source[i + 1]; + if (inLineComment) { + if (ch === '\n') inLineComment = false; + continue; + } + if (blockCommentDepth > 0) { + if (ch === '*' && next === '/') { + blockCommentDepth--; + i++; + } else if (ch === '/' && next === '*') { + blockCommentDepth++; + i++; + } + continue; + } + if (inString !== null) { + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + continue; + } + if (ch === '/' && next === '/') { + inLineComment = true; + i++; + continue; + } else if (ch === '/' && next === '*') { + blockCommentDepth = 1; + i++; + continue; + } + if (ch === '[') depth++; + else if (ch === ']') { + depth--; + if (depth === 0) return i; + } + } + return null; +} + +async function inferSwiftDirectoryTargets(repoRoot: string): Promise> { + const targets = new Map(); const sourceDirs = ['Sources', 'Package/Sources', 'src']; for (const sourceDir of sourceDirs) { try { @@ -636,12 +1438,41 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise 0) { - if (isDev) { - logger.info(`📦 Loaded ${targets.size} Swift package targets`); +export async function loadSwiftPackageConfig(repoRoot: string): Promise { + try { + const source = await fs.readFile(path.join(repoRoot, 'Package.swift'), 'utf-8'); + const parsed = parseSwiftPackageManifest(source); + if (parsed.complete) { + if (isDev) { + logger.info(`📦 Loaded ${parsed.targets.size} Swift package targets from Package.swift`); + } + if (parsed.targets.size > 0) { + return { + targets: parsed.targets, + origin: 'package.swift', + declaredTargets: parsed.targets, + }; + } + const inferred = await inferSwiftDirectoryTargets(repoRoot); + return { + targets: inferred, + origin: 'package.swift', + declaredTargets: parsed.targets, + }; } - return { targets }; + } catch { + // Missing or unreadable — fall through to inferred folders. + } + + const inferred = await inferSwiftDirectoryTargets(repoRoot); + if (inferred.size > 0) { + if (isDev) { + logger.info(`📦 Inferred ${inferred.size} Swift source folders`); + } + return { targets: inferred, origin: 'directories' }; } return null; } diff --git a/gitnexus/src/core/ingestion/languages/swift/implicit-imports.ts b/gitnexus/src/core/ingestion/languages/swift/implicit-imports.ts index a850045b8..b11f416cf 100644 --- a/gitnexus/src/core/ingestion/languages/swift/implicit-imports.ts +++ b/gitnexus/src/core/ingestion/languages/swift/implicit-imports.ts @@ -14,12 +14,15 @@ * Module identity: Swift has no in-source `package X` marker. Module * membership is the SPM target *subtree* (`Sources//…`), threaded * in via the SPM target map (`resolutionConfig` → `coerceSwiftTargets`) - * and grouped by `groupSwiftFilesBySpmTarget` — replicating legacy - * `groupSwiftFilesByTarget`. With no scanned source dir the map is null + * and grouped by `groupSwiftFilesBySpmTarget`. The helper preserves the + * legacy `groupSwiftFilesByTarget` bucketing contract for ordinary layouts + * while intentionally fixing #2931's repeated-prefix edge case. With no + * target map (no Package.swift and no scanned `Sources/*`) the map is null * and all files form one `__default__` module (single-Xcode-project - * assumption). Every pair of distinct `.swift` files in the same module - * gets a directed IMPORTS edge in both directions (whole-module - * visibility is symmetric). + * assumption). An inferred folder map must keep sibling folders isolated. + * Every pair of distinct `.swift` + * files in the same module gets a directed IMPORTS edge in both directions + * (whole-module visibility is symmetric). * * Node identity + edge construction mirror the generic `emitImportEdges` * convention (`graph-bridge/imports-to-edges.ts`): `generateId('File', path)` @@ -41,8 +44,6 @@ export function emitSwiftImplicitImportEdges( _nodeLookup: GraphNodeLookup, resolutionConfig?: unknown, ): void { - // Group files by SPM target subtree (the module). No-source-dir → all - // files in one `__default__` bucket. const targets = coerceSwiftTargets(resolutionConfig); const filesByTarget = groupSwiftFilesBySpmTarget( parsedFiles, @@ -51,16 +52,15 @@ export function emitSwiftImplicitImportEdges( ); for (const [, group] of filesByTarget) { - if (group.length < 2) continue; // no siblings to import + if (group.length < 2) continue; for (const source of group) { - for (const target of group) { - if (source.filePath === target.filePath) continue; // no self-import - const dedupKey = `${source.filePath}->${target.filePath}`; - + for (const dest of group) { + if (source.filePath === dest.filePath) continue; + const dedupKey = `${source.filePath}->${dest.filePath}`; graph.addRelationship({ id: generateId('IMPORTS', dedupKey), sourceId: generateId('File', source.filePath), - targetId: generateId('File', target.filePath), + targetId: generateId('File', dest.filePath), type: 'IMPORTS', confidence: 1.0, reason: 'swift-scope: implicit module visibility', diff --git a/gitnexus/src/core/ingestion/languages/swift/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/swift/import-decomposer.ts index b0cdfdd9c..a1fc03d2c 100644 --- a/gitnexus/src/core/ingestion/languages/swift/import-decomposer.ts +++ b/gitnexus/src/core/ingestion/languages/swift/import-decomposer.ts @@ -4,32 +4,30 @@ * `@import.name` / `@import.testable` that `interpretSwiftImport` * consumes. * - * Swift imports are whole-module (no named members), so this is 1:1 — - * one `import` produces exactly one import. The split layer exposes the - * module name and the `@testable` flag without pushing raw-text parsing - * into `interpret.ts`. + * import Foundation → kind=namespace, source=Foundation + * import Foo.Bar → kind=namespace, source=Foo + * import struct Foo.Bar → kind=named, source=Foo, name=Bar + * @testable import MyApp → kind=namespace, source=MyApp, testable=1 + * @_exported import Foo → kind=reexport, source=Foo, name=Foo + * @_exported import struct Foo.Bar → kind=reexport, source=Foo, name=Bar * - * import Foundation → kind=namespace, source=Foundation - * import Foo.Bar → kind=namespace, source=Foo (SPM target), - * name=Foo.Bar (full path, for reference) - * @testable import MyApp → kind=namespace, source=MyApp, testable=1 - * - * Verified against tree-sitter-swift 0.7.1: - * (import_declaration - * (modifiers (attribute (user_type (type_identifier))))? ; @testable / @_exported - * (identifier (simple_identifier)+)) ; one per dotted segment + * Import-kind (`struct`/`class`/…) is not a named tree-sitter child + * (hidden `_import_kind` in 0.7.1). Read it from the statement text. + * `@_exported` / `@testable` live on `modifiers`. */ import type { Capture, CaptureMatch } from 'gitnexus-shared'; import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +const IMPORT_KIND_TOKEN_RE = /^(struct|class|enum|protocol|func|let|var|typealias)\b/; + interface SwiftImportSpec { - /** SPM target name — the first dotted segment (`Foo` in `import Foo.Bar`). */ readonly source: string; - /** Full dotted module path (`Foo.Bar`). */ + readonly memberName: string; readonly fullPath: string; - /** True for `@testable import` (test-scope visibility; resolves identically). */ readonly testable: boolean; + readonly exported: boolean; + readonly importKind: string | null; readonly atNode: SyntaxNode; } @@ -42,14 +40,15 @@ export function splitSwiftImport(stmtNode: SyntaxNode): CaptureMatch | null { function parseSwiftImport(node: SyntaxNode): SwiftImportSpec | null { let testable = false; + let exported = false; let identifierNode: SyntaxNode | null = null; for (let i = 0; i < node.namedChildCount; i++) { const child = node.namedChild(i); if (child === null) continue; if (child.type === 'modifiers') { - // Any attribute whose text mentions `testable` flips the flag. - if (/\btestable\b/.test(child.text)) testable = true; + if (swiftModifiersHaveAttribute(child.text, 'testable')) testable = true; + if (swiftModifiersHaveAttribute(child.text, '_exported')) exported = true; } else if (child.type === 'identifier') { identifierNode = child; } @@ -57,36 +56,219 @@ function parseSwiftImport(node: SyntaxNode): SwiftImportSpec | null { if (identifierNode === null) return null; - // The module path is one or more simple_identifier children, one per - // dotted segment. The SPM target is the FIRST segment. + const importKind = importKindFromClause(node, identifierNode); + const segments: string[] = []; for (let i = 0; i < identifierNode.namedChildCount; i++) { const seg = identifierNode.namedChild(i); if (seg !== null && seg.type === 'simple_identifier') segments.push(seg.text); } if (segments.length === 0) { - // Fall back to the raw identifier text (e.g. a grammar shape we didn't - // anticipate). Split on `.` to recover the target segment. const raw = identifierNode.text.trim(); if (raw === '') return null; - const parts = raw.split('.'); - return { source: parts[0], fullPath: raw, testable, atNode: node }; + segments.push(...raw.split('.')); } return { source: segments[0], + memberName: segments.length > 1 ? segments[segments.length - 1] : segments[0], fullPath: segments.join('.'), testable, + exported, + importKind, atNode: node, }; } +function isSwiftIdentCont(ch: string | undefined): boolean { + return ch !== undefined && /[A-Za-z0-9_]/.test(ch); +} + +/** First `word` outside comments and strings. Linear scan. */ +function indexOfBareWord(text: string, word: string): number { + let inString: '"' | "'" | null = null; + let escape = false; + let inLineComment = false; + let blockCommentDepth = 0; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + const next = text[i + 1]; + if (inLineComment) { + if (ch === '\n') inLineComment = false; + continue; + } + if (blockCommentDepth > 0) { + if (ch === '*' && next === '/') { + blockCommentDepth--; + i++; + } else if (ch === '/' && next === '*') { + blockCommentDepth++; + i++; + } + continue; + } + if (inString !== null) { + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + continue; + } + if (ch === '/' && next === '/') { + inLineComment = true; + i++; + continue; + } + if (ch === '/' && next === '*') { + blockCommentDepth = 1; + i++; + continue; + } + if ( + text.startsWith(word, i) && + !isSwiftIdentCont(text[i + word.length]) && + (i === 0 || !isSwiftIdentCont(text[i - 1])) + ) { + return i; + } + } + return -1; +} + +/** `@name` token in modifier text — not `_exported` / `testable` inside a string. */ +function swiftModifiersHaveAttribute(text: string, name: 'testable' | '_exported'): boolean { + let inString: '"' | "'" | null = null; + let escape = false; + let inLineComment = false; + let blockCommentDepth = 0; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + const next = text[i + 1]; + if (inLineComment) { + if (ch === '\n') inLineComment = false; + continue; + } + if (blockCommentDepth > 0) { + if (ch === '*' && next === '/') { + blockCommentDepth--; + i++; + } else if (ch === '/' && next === '*') { + blockCommentDepth++; + i++; + } + continue; + } + if (inString !== null) { + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + continue; + } + if (ch === '/' && next === '/') { + inLineComment = true; + i++; + continue; + } + if (ch === '/' && next === '*') { + blockCommentDepth = 1; + i++; + continue; + } + if (ch !== '@') continue; + let j = i + 1; + while (j < text.length && /\s/.test(text[j])) j++; + if (text.startsWith(name, j) && !isSwiftIdentCont(text[j + name.length])) return true; + } + return false; +} + +/** Kind token from the import clause only — skip `@available(..., message: "import struct")`. */ +function importKindFromClause(node: SyntaxNode, identifierNode: SyntaxNode): string | null { + const identRel = identifierNode.startIndex - node.startIndex; + const before = identRel >= 0 ? node.text.slice(0, identRel) : node.text; + const importAt = indexOfBareWord(before, 'import'); + const clause = importAt === -1 ? before : before.slice(importAt); + return kindAfterImportKeyword(clause); +} + +/** After `import`, skip whitespace and comments, then read a kind token. Linear: no nested-quantifier backtracking. */ +function kindAfterImportKeyword(clause: string): string | null { + const start = indexOfBareWord(clause, 'import'); + if (start === -1) return null; + let i = start + 'import'.length; + let skipped = false; + while (i < clause.length) { + const ch = clause[i]; + const next = clause[i + 1]; + if (/\s/.test(ch)) { + skipped = true; + i += 1; + continue; + } + if (ch === '/' && next === '/') { + const nl = clause.indexOf('\n', i + 2); + if (nl === -1) return null; + skipped = true; + i = nl + 1; + continue; + } + if (ch === '/' && next === '*') { + let depth = 1; + i += 2; + while (i < clause.length && depth > 0) { + if (clause[i] === '/' && clause[i + 1] === '*') { + depth++; + i += 2; + } else if (clause[i] === '*' && clause[i + 1] === '/') { + depth--; + i += 2; + } else { + i++; + } + } + if (depth !== 0) return null; + skipped = true; + continue; + } + break; + } + if (!skipped) return null; + return IMPORT_KIND_TOKEN_RE.exec(clause.slice(i))?.[1] ?? null; +} + +function bindingKind(spec: SwiftImportSpec): 'namespace' | 'named' | 'reexport' { + if (spec.exported) return 'reexport'; + if (spec.importKind !== null && spec.fullPath.includes('.')) return 'named'; + return 'namespace'; +} + function buildImportMatch(stmtNode: SyntaxNode, spec: SwiftImportSpec): CaptureMatch { + const kind = bindingKind(spec); + const nameText = kind === 'namespace' ? spec.fullPath : spec.memberName; const m: Record = { '@import.statement': nodeToCapture('@import.statement', stmtNode), - '@import.kind': syntheticCapture('@import.kind', spec.atNode, 'namespace'), + '@import.kind': syntheticCapture('@import.kind', spec.atNode, kind), '@import.source': syntheticCapture('@import.source', spec.atNode, spec.source), - '@import.name': syntheticCapture('@import.name', spec.atNode, spec.fullPath), + '@import.name': syntheticCapture('@import.name', spec.atNode, nameText), }; if (spec.testable) { m['@import.testable'] = syntheticCapture('@import.testable', spec.atNode, '1'); diff --git a/gitnexus/src/core/ingestion/languages/swift/import-target.ts b/gitnexus/src/core/ingestion/languages/swift/import-target.ts index e0d5e8219..603f2e6f7 100644 --- a/gitnexus/src/core/ingestion/languages/swift/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/swift/import-target.ts @@ -1,37 +1,27 @@ /** * `resolveImportTarget` adapter for the Swift `ScopeResolver`. * - * Swift's `import ModuleName` brings in a whole SPM target / framework - * module. The scope-resolution contract passes only `allFilePaths` (no - * `SwiftPackageConfig`), so we resolve a module name to the `.swift` - * files under a directory segment named after the module — the SPM - * convention `Sources//*.swift` (and the common - * `/*.swift` layout). This needs no manifest parsing. + * A Package.swift declaration map (`origin: 'package.swift'`) resolves + * only declared target names. Otherwise refuse well-known SDK module + * names and fall back to the memoized directory-segment index so local + * folder modules still resolve without a manifest. * - * Same-module (intra-target) visibility — the bulk of Swift cross-file - * resolution, which needs NO `import` statement — is handled separately - * by `populateSwiftTargetSiblings` (see `target-siblings.ts`). This - * adapter only resolves EXPLICIT `import` statements (cross-module). - * - * Returns all matching files (one ImportEdge per file, like Go's - * package resolver) so every exported symbol in the module materializes - * a binding. Returns `null` for external frameworks (Foundation, UIKit, - * …) that have no in-repo directory. - * - * Performance: the directory→files grouping is memoized on the stable - * `allFilePaths` Set identity (the same Set is threaded to every import - * in a run), so it is built once per run — NOT once per import. Mirrors - * Python's `getPythonFileIndex` WeakMap pattern (PR #1918). + * Same-module visibility without `import` is `populateSwiftTargetSiblings`. + * This adapter only resolves EXPLICIT cross-module `import`s. */ -import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; +import type { ParsedFile, ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; import { perFileSet } from '../../import-resolvers/per-file-set.js'; +import { coerceDeclaredSwiftTargets, swiftDeclaredTargetPrefix } from '../../language-config.js'; +import { isSwiftSdkModule } from './sdk-modules.js'; export interface SwiftResolveContext { readonly fromFile: string; /** `ReadonlySet` so the orchestrator's stable run-level set flows * straight through to the memoized index key. */ readonly allFilePaths: ReadonlySet; + readonly resolutionConfig?: unknown; + readonly parsedFiles?: readonly ParsedFile[]; } interface SwiftModuleIndex { @@ -40,16 +30,17 @@ interface SwiftModuleIndex { readonly byModule: Map; } +interface SwiftDeclaredFileIndex { + readonly declared: ReadonlyMap; + readonly byName: ReadonlyMap; +} + const getSwiftModuleIndex = perFileSet((allFilePaths: ReadonlySet): SwiftModuleIndex => { const byModule = new Map(); for (const raw of allFilePaths) { const norm = raw.replace(/\\/g, '/'); if (!norm.endsWith('.swift')) continue; - // Each interior directory segment is a candidate module name. A file - // `Sources/Models/User.swift` is attributed to module `Sources` and - // module `Models`; an `import Models` then resolves to it. const segments = norm.split('/'); - // Drop the filename (last segment); the rest are directory segments. for (let i = 0; i < segments.length - 1; i++) { const seg = segments[i]; if (seg === '') continue; @@ -65,12 +56,70 @@ const getSwiftModuleIndex = perFileSet((allFilePaths: ReadonlySet): Swif return { byModule }; }); -export function resolveSwiftImportTarget( - parsedImport: ParsedImport, - workspaceIndex: WorkspaceIndex, -): string | readonly string[] | null { +const SWIFT_DECLARED_INDEX = new WeakMap, SwiftDeclaredFileIndex>(); + +function getDeclaredFilesByName( + allFilePaths: ReadonlySet, + declared: ReadonlyMap, +): ReadonlyMap { + const hit = SWIFT_DECLARED_INDEX.get(allFilePaths); + if (hit !== undefined && hit.declared === declared) return hit.byName; + + const dirs = [...declared.entries()].map(([name, dir]) => ({ + name, + prefix: swiftDeclaredTargetPrefix(dir), + })); + const byName = new Map(); + for (const { name } of dirs) byName.set(name, []); + + for (const raw of allFilePaths) { + const norm = raw.replace(/\\/g, '/'); + if (!norm.endsWith('.swift')) continue; + for (const { name, prefix } of dirs) { + if (!norm.startsWith(prefix) && !norm.includes(`/${prefix}`)) continue; + const bucket = byName.get(name); + if (bucket !== undefined) bucket.push(raw); + } + } + + const index = { declared, byName }; + SWIFT_DECLARED_INDEX.set(allFilePaths, index); + return byName; +} + +const getSwiftReexportFlag = perFileSet( + (parsedFiles: readonly ParsedFile[]): { hasReexport: boolean } => { + for (const parsed of parsedFiles) { + for (const imp of parsed.parsedImports) { + if (imp.kind === 'reexport') return { hasReexport: true }; + } + } + return { hasReexport: false }; + }, +); + +const getSwiftParsedByPath = perFileSet( + (parsedFiles: readonly ParsedFile[]): ReadonlyMap => { + const byPath = new Map(); + for (const parsed of parsedFiles) { + byPath.set(parsed.filePath, parsed); + } + return byPath; + }, +); + +function excludeImporter(files: readonly string[], fromFile: string): string[] { + return files.filter((f) => f !== fromFile); +} + +function firstSwiftModuleSegment(targetRaw: string): string | null { + if (targetRaw === '') return null; + const moduleName = targetRaw.split('.')[0]; + return moduleName === '' ? null : moduleName; +} + +function narrowContext(workspaceIndex: WorkspaceIndex): SwiftResolveContext | null { const ctx = workspaceIndex as SwiftResolveContext | undefined; - // Duck-type the set (PR #1918 P2: don't `instanceof Set`). const allFilePaths = (ctx as { allFilePaths?: unknown } | undefined)?.allFilePaths; if ( ctx === undefined || @@ -80,18 +129,84 @@ export function resolveSwiftImportTarget( ) { return null; } + return ctx; +} - // Swift import target is the SPM module name (first dotted segment). - const targetRaw = parsedImport.targetRaw; - if (targetRaw === null || targetRaw === '') return null; - const moduleName = targetRaw.split('.')[0]; +/** Module files only — no @_exported closure. Null means external / unknown. */ +function resolveSwiftModuleFiles(moduleName: string, ctx: SwiftResolveContext): string[] | null { if (moduleName === '') return null; + const declared = coerceDeclaredSwiftTargets(ctx.resolutionConfig); + if (declared !== null) { + if (!declared.has(moduleName)) return null; + const files = getDeclaredFilesByName(ctx.allFilePaths, declared).get(moduleName); + if (files === undefined) return null; + const out = excludeImporter(files, ctx.fromFile); + return out.length > 0 ? out : null; + } + + if (isSwiftSdkModule(moduleName)) return null; + const index = getSwiftModuleIndex(ctx.allFilePaths); const files = index.byModule.get(moduleName); - if (files === undefined || files.length === 0) return null; // external framework - - // Exclude the importer itself (a file under `Foo/` importing `Foo`). - const out = files.filter((f) => f !== ctx.fromFile); + if (files === undefined || files.length === 0) return null; + const out = excludeImporter(files, ctx.fromFile); return out.length > 0 ? out : null; } + +function expandSwiftReexportFiles(seed: readonly string[], ctx: SwiftResolveContext): string[] { + const parsedFiles = ctx.parsedFiles; + if (parsedFiles === undefined || parsedFiles.length === 0) return [...seed]; + if (!getSwiftReexportFlag(parsedFiles).hasReexport) return [...seed]; + const byPath = getSwiftParsedByPath(parsedFiles); + + const seenModules = new Set(); + const out = new Set(seed); + const queue = [...seed]; + let head = 0; + + while (head < queue.length) { + const file = queue[head++]; + const parsed = byPath.get(file); + if (parsed === undefined) continue; + for (const imp of parsed.parsedImports) { + if (imp.kind !== 'reexport') continue; + const targetRaw = imp.targetRaw; + if (targetRaw === null) continue; + const moduleName = firstSwiftModuleSegment(targetRaw); + if (moduleName === null || seenModules.has(moduleName)) continue; + // `@_exported import struct Models.User` re-exports User, not Models. + // File-level resolve cannot attribute a member to a file, so skip + // the whole-module enqueue rather than painting every Models file. + if (imp.importedName !== moduleName) continue; + seenModules.add(moduleName); + const more = resolveSwiftModuleFiles(moduleName, ctx); + if (more === null) continue; + for (const next of more) { + if (out.has(next)) continue; + out.add(next); + queue.push(next); + } + } + } + + return [...out]; +} + +export function resolveSwiftImportTarget( + parsedImport: ParsedImport, + workspaceIndex: WorkspaceIndex, +): string | readonly string[] | null { + const ctx = narrowContext(workspaceIndex); + if (ctx === null) return null; + + const targetRaw = parsedImport.targetRaw; + if (targetRaw === null) return null; + const moduleName = firstSwiftModuleSegment(targetRaw); + if (moduleName === null) return null; + + const files = resolveSwiftModuleFiles(moduleName, ctx); + if (files === null) return null; + const expanded = expandSwiftReexportFiles(files, ctx); + return expanded.length > 0 ? expanded : null; +} diff --git a/gitnexus/src/core/ingestion/languages/swift/interpret.ts b/gitnexus/src/core/ingestion/languages/swift/interpret.ts index a12816843..c7accca32 100644 --- a/gitnexus/src/core/ingestion/languages/swift/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/swift/interpret.ts @@ -19,17 +19,23 @@ export function interpretSwiftImport(captures: CaptureMatch): ParsedImport | nul const sourceCap = captures['@import.source']; if (sourceCap === undefined) return null; - // Swift imports are whole-module (wildcard semantics): `import Foundation` - // brings the entire module into scope, no named members. The SPM target - // (first dotted segment) is the resolution target; the full path is kept - // as importedName for reference. `@testable` resolves identically to a - // plain import (same module is visible in test scope). const source = sourceCap.text; - const fullPath = captures['@import.name']?.text ?? source; + const name = captures['@import.name']?.text ?? source; + const kindText = captures['@import.kind']?.text; + + if (kindText === 'reexport' || kindText === 'named') { + return { + kind: kindText, + localName: name, + importedName: name, + targetRaw: source, + }; + } + return { kind: 'namespace', localName: source, - importedName: fullPath, + importedName: name, targetRaw: source, }; } diff --git a/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts index 4b8f974c5..f0475f1cb 100644 --- a/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts @@ -37,14 +37,15 @@ * Array where Element: Equatable`) are not narrowed — the `Self` * type of a protocol method resolves to the protocol, not the * conforming type. - * 2. **Cross-module `import` resolution** is still directory-segment - * based (`import Foo` → files under a `Foo/` dir); explicit imports do - * not yet consult the SPM target map (follow-up, tracked under #1935). - * Same-target visibility (the common case) IS SPM-target-subtree - * accurate — handled by sibling augmentation grouped via - * `groupSwiftFilesBySpmTarget`, not by explicit imports. + * 2. **Cross-module `import` resolution** uses a Package.swift + * declaration map when one is present, otherwise the directory-segment + * index minus well-known SDK module names (#2964). Same-target + * visibility (the common case) is SPM-target-subtree grouping via + * `groupSwiftFilesBySpmTarget`, not explicit imports. * 3. **Operator / subscript overloads** dispatch by name only. - * 4. **`@_exported import` re-exports** are treated as plain imports. + * 4. **`@_exported import`** is `ParsedImport` `kind: 'reexport'` and + * in-repo modules are closed transitively at resolve time. `public + * import` is not a re-export. */ import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; @@ -87,12 +88,18 @@ const swiftScopeResolver: ScopeResolver = { // `goScopeResolver`'s `loadGoModulePath`. loadResolutionConfig: (repoPath: string) => loadSwiftPackageConfig(repoPath), - resolveImportTarget: (targetRaw, fromFile, allFilePaths) => { - const ws: SwiftResolveContext = { fromFile, allFilePaths }; + resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig, context) => { + const ws: SwiftResolveContext = { + fromFile, + allFilePaths, + resolutionConfig, + parsedFiles: context?.parsedFiles, + }; return resolveSwiftImportTarget( - interpretSwiftImport({ - '@import.source': { name: '@import.source', text: targetRaw, range: ZERO_RANGE }, - }) ?? { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw }, + context?.parsedImport ?? + interpretSwiftImport({ + '@import.source': { name: '@import.source', text: targetRaw, range: ZERO_RANGE }, + }) ?? { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw }, ws, ); }, diff --git a/gitnexus/src/core/ingestion/languages/swift/sdk-modules.ts b/gitnexus/src/core/ingestion/languages/swift/sdk-modules.ts new file mode 100644 index 000000000..8d7f052e5 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/swift/sdk-modules.ts @@ -0,0 +1,29 @@ +/** + * Well-known Apple / Swift SDK module names that must not bind to a + * same-named in-repo folder on the no-manifest path (#2964). + * + * Modeled on `CSHARP_EXTERNAL_ROOTS`. A Package.swift target that uses + * one of these names still wins — this set is only the inferred/null path. + */ +export const SWIFT_SDK_MODULES: ReadonlySet = new Set([ + 'Foundation', + 'UIKit', + 'SwiftUI', + 'AppKit', + 'Combine', + 'Dispatch', + 'Darwin', + 'ObjectiveC', + 'Swift', + 'CoreFoundation', + 'CoreData', + 'CoreGraphics', + 'XCTest', + 'Testing', + 'Observation', + 'os', +]); + +export function isSwiftSdkModule(name: string): boolean { + return SWIFT_SDK_MODULES.has(name); +} diff --git a/gitnexus/src/core/ingestion/languages/swift/target-grouping.ts b/gitnexus/src/core/ingestion/languages/swift/target-grouping.ts index 5f26d3595..a0572202c 100644 --- a/gitnexus/src/core/ingestion/languages/swift/target-grouping.ts +++ b/gitnexus/src/core/ingestion/languages/swift/target-grouping.ts @@ -9,36 +9,31 @@ * drops cross-directory same-module edges and can mis-resolve a * constructor call to a wrong same-simple-named type in another target. * - * This module duplicates the legacy `groupSwiftFilesByTarget` - * (`languages/swift.ts`) semantics **verbatim** so the registry-primary - * path matches legacy SPM-subtree grouping without touching the legacy - * pipeline (hard constraint: legacy stays byte-identical). The SPM target - * map is threaded in via the `resolutionConfig` channel + * The SPM target map is threaded in via the `resolutionConfig` channel * (`loadSwiftPackageConfig` → `resolutionConfig` → these hooks); see * `scope-resolver.ts` and `scope-resolution/pipeline/run.ts`. * - * NOTE: This intentionally differs from the import-config module's - * leading-`startsWith` (`import-resolvers/configs/swift.ts`): that module - * fans a file out to EVERY matching target (a nested file can belong to - * multiple configured target dirs there), whereas legacy module grouping - * assigns each file to the FIRST matching target only (legacy `break`s) — - * one bucket per file. Do not copy the import-config behavior here. + * Path matching is the same segment-boundary rule as import-config + * (`fileMatchesSwiftTargetDir`). Grouping assigns each file to the FIRST + * matching target; import-config fans a file out to every matching target. */ -import type { SwiftPackageConfig } from '../../language-config.js'; +import { swiftDeclaredTargetPrefix, type SwiftPackageConfig } from '../../language-config.js'; +export { coerceDeclaredSwiftTargets } from '../../language-config.js'; const DEFAULT_TARGET = '__default__'; /** - * Group `items` by SPM target subtree, replicating legacy - * `groupSwiftFilesByTarget` semantics exactly: + * Group `items` by SPM target subtree: * * - `targets` null/empty (no scanned source dir found) → ALL items go to * a single `__default__` bucket (single-Xcode-project assumption). * - Otherwise: a file matches a target when its normalized path either - * starts with `/` (`indexOf === 0`) OR contains it at a `/` - * boundary (`norm[idx - 1] === '/'`). Each file is assigned to the - * FIRST matching target only (one bucket per file, no fan-out). + * starts with `/` or contains `//` at a segment + * boundary. Using a segment-aware suffix search matters when an earlier, + * non-boundary occurrence of the same text appears in the path (#2931). + * - Each file is assigned to the FIRST matching target only (one bucket per + * file, no fan-out). * - Files matching no target fall into the `__default__` bucket. * * `targets` is `name → directory` (the `SwiftPackageConfig.targets` map). @@ -53,10 +48,9 @@ export function groupSwiftFilesBySpmTarget( return new Map([[DEFAULT_TARGET, [...items]]]); } - // Pre-convert target dirs to normalized prefix format once. const targetPrefixes = [...targets.entries()].map(([name, dir]) => ({ name, - prefix: dir.replace(/\\/g, '/') + '/', + prefix: swiftDeclaredTargetPrefix(dir), })); const groups = new Map(); @@ -67,8 +61,7 @@ export function groupSwiftFilesBySpmTarget( const normalized = rawPath.includes('\\') ? rawPath.replace(/\\/g, '/') : rawPath; let assigned = false; for (const { name, prefix } of targetPrefixes) { - const idx = normalized.indexOf(prefix); - if (idx === 0 || (idx > 0 && normalized[idx - 1] === '/')) { + if (pathMatchesTargetPrefix(normalized, prefix)) { let group = groups.get(name); if (group === undefined) { group = []; @@ -102,3 +95,13 @@ export function coerceSwiftTargets(resolutionConfig: unknown): ReadonlyMap/…`). Module membership * is threaded in via the SPM target map (`ctx.resolutionConfig` → - * `coerceSwiftTargets`) and grouped by `groupSwiftFilesBySpmTarget`, - * replicating legacy `wireSwiftImplicitImports`'s `groupSwiftFilesByTarget`: - * files are grouped by SPM target subtree when a package config is present, - * else ALL Swift files form one module (`__default__`, + * `coerceSwiftTargets`) and grouped by `groupSwiftFilesBySpmTarget`. + * The helper preserves the legacy `wireSwiftImplicitImports` bucketing + * contract for ordinary layouts (first target wins and unmatched files use + * `__default__`) but intentionally fixes #2931's repeated-prefix edge case. + * When a target map is present (declared Package.swift or inferred + * `Sources/*` folders), files are grouped by SPM target subtree; + * otherwise ALL Swift files form one module (`__default__`, * single-Xcode-project assumption). Every `.swift` file in the same target * sees its siblings' top-level defs. * diff --git a/gitnexus/test/unit/import-resolver-factory.test.ts b/gitnexus/test/unit/import-resolver-factory.test.ts index 2e22790e0..90c0c5d95 100644 --- a/gitnexus/test/unit/import-resolver-factory.test.ts +++ b/gitnexus/test/unit/import-resolver-factory.test.ts @@ -485,6 +485,31 @@ describe('swiftPackageStrategy', () => { const result = swiftPackageStrategy('Foundation', 'App.swift', ctx); expect(result).toBeNull(); }); + + it('does not resolve inferred grouping folders when the declaration is empty', () => { + const ctx = makeCtx(['Sources/Foundation/Thing.swift', 'Sources/App/main.swift'], { + swiftPackageConfig: { + origin: 'package.swift', + targets: new Map([ + ['Foundation', 'Sources/Foundation'], + ['App', 'Sources/App'], + ]), + declaredTargets: new Map(), + }, + }); + expect(swiftPackageStrategy('Foundation', 'Sources/App/main.swift', ctx)).toBeNull(); + expect(swiftPackageStrategy('App', 'Sources/App/main.swift', ctx)).toBeNull(); + }); + + it('ignores an inferred directories origin', () => { + const ctx = makeCtx(['Sources/Models/User.swift'], { + swiftPackageConfig: { + origin: 'directories', + targets: new Map([['Models', 'Sources/Models']]), + }, + }); + expect(swiftPackageStrategy('Models', 'Sources/App/main.swift', ctx)).toBeNull(); + }); }); describe('rubyRequireStrategy', () => { diff --git a/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts b/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts index c1932e02c..3e56563f1 100644 --- a/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts +++ b/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts @@ -336,7 +336,7 @@ const CASES: ReadonlyMap = new Map([ resolutionConfig: undefined, external: 'Foundation', decoy: 'Sources/Foundation/Thing.swift', - reachesDecoy: 'Models', + reachesDecoy: 'Sources', }, ], [ @@ -417,7 +417,6 @@ const CASES: ReadonlyMap = new Map([ */ const KNOWN_GAPS: ReadonlyMap = new Map([ [SupportedLanguages.Dart, '`package:http/http.dart` -> `lib/http.dart`'], - [SupportedLanguages.Swift, '`Foundation` -> `Sources/Foundation/Thing.swift`'], [SupportedLanguages.C, '`stdio.h` -> `src/stdio.h`'], [SupportedLanguages.CPlusPlus, '`cstdio.h` -> `src/cstdio.h`'], ]); diff --git a/gitnexus/test/unit/scope-resolution/swift-target-grouping.test.ts b/gitnexus/test/unit/scope-resolution/swift-target-grouping.test.ts new file mode 100644 index 000000000..f8e139968 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift-target-grouping.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { groupSwiftFilesBySpmTarget } from '../../../src/core/ingestion/languages/swift/target-grouping.js'; + +describe('groupSwiftFilesBySpmTarget', () => { + it('matches a later segment-aligned target prefix after an earlier partial occurrence', () => { + const targets = new Map([['Core', 'Modules/Core']]); + const items = ['vendor/SubModules/Core/shim/Modules/Core/Thing.swift']; + + const groups = groupSwiftFilesBySpmTarget(items, (item) => item, targets); + + expect(groups.get('Core')).toEqual(items); + expect(groups.get('__default__')).toBeUndefined(); + }); + + it('keeps an earlier segment-aligned match when a later occurrence is partial', () => { + const targets = new Map([['Core', 'Modules/Core']]); + const items = ['Modules/Core/Thing/SubModules/Core/Thing.swift']; + + const groups = groupSwiftFilesBySpmTarget(items, (item) => item, targets); + + expect(groups.get('Core')).toEqual(items); + expect(groups.get('__default__')).toBeUndefined(); + }); + + it('does not treat a partial path segment as a target match', () => { + const targets = new Map([['Core', 'Modules/Core']]); + const item = 'vendor/SubModules/Core/Thing.swift'; + + const groups = groupSwiftFilesBySpmTarget([item], (value) => value, targets); + + expect(groups.get('Core')).toBeUndefined(); + expect(groups.get('__default__')).toEqual([item]); + }); + + it('keeps first-target-wins behavior when target paths overlap', () => { + const targets = new Map([ + ['Outer', 'Sources'], + ['Inner', 'Sources/Feature'], + ]); + const item = 'Sources/Feature/Thing.swift'; + + const groups = groupSwiftFilesBySpmTarget([item], (value) => value, targets); + + expect(groups.get('Outer')).toEqual([item]); + expect(groups.get('Inner')).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/implicit-imports.test.ts b/gitnexus/test/unit/scope-resolution/swift/implicit-imports.test.ts new file mode 100644 index 000000000..50cb49099 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift/implicit-imports.test.ts @@ -0,0 +1,74 @@ +/** + * Intra-group implicit IMPORTS. @_exported is client-facing + * (`resolveSwiftImportTarget`); siblings in the exporting target + * must not gain edges to the reexported module. + */ +import { describe, expect, it } from 'vitest'; +import type { ParsedFile, ParsedImport, ScopeId } from 'gitnexus-shared'; +import { createKnowledgeGraph } from '../../../../src/core/graph/graph.js'; +import { generateId } from '../../../../src/lib/utils.js'; +import { emitSwiftImplicitImportEdges } from '../../../../src/core/ingestion/languages/swift/implicit-imports.js'; +import { resolveSwiftImportTarget } from '../../../../src/core/ingestion/languages/swift/import-target.js'; + +const DECLARED = { + origin: 'package.swift' as const, + targets: new Map([ + ['A', 'Sources/A'], + ['B', 'Sources/B'], + ]), +}; + +function stubFile(filePath: string, parsedImports: ParsedImport[] = []): ParsedFile { + return { + filePath, + moduleScope: `module:${filePath}` as ScopeId, + scopes: [], + parsedImports, + localDefs: [], + referenceSites: [], + }; +} + +function reexport(targetRaw: string): ParsedImport { + return { kind: 'reexport', localName: targetRaw, importedName: targetRaw, targetRaw }; +} + +function ns(targetRaw: string): ParsedImport { + return { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw }; +} + +function importPair(rels: readonly { sourceId: string; targetId: string; type: string }[]) { + return rels + .filter((rel) => rel.type === 'IMPORTS') + .map((rel) => `${rel.sourceId}->${rel.targetId}`) + .sort(); +} + +describe('emitSwiftImplicitImportEdges', () => { + it('does not paint sibling files into a @_exported module', () => { + const a = 'Sources/A/A.swift'; + const other = 'Sources/A/Other.swift'; + const b = 'Sources/B/B.swift'; + const parsed = [stubFile(a, [reexport('B')]), stubFile(other), stubFile(b)]; + const graph = createKnowledgeGraph(); + + emitSwiftImplicitImportEdges(graph, parsed, new Map(), DECLARED); + + const pairs = importPair(graph.relationships); + expect(pairs).toEqual( + [ + `${generateId('File', a)}->${generateId('File', other)}`, + `${generateId('File', other)}->${generateId('File', a)}`, + ].sort(), + ); + expect(pairs.some((pair) => pair.includes(generateId('File', b)))).toBe(false); + + const fromApp = resolveSwiftImportTarget(ns('A'), { + fromFile: 'Sources/App/main.swift', + allFilePaths: new Set([a, other, b, 'Sources/App/main.swift']), + resolutionConfig: DECLARED, + parsedFiles: parsed, + }); + expect(fromApp).toEqual(expect.arrayContaining([a, other, b])); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/import-decomposer.test.ts b/gitnexus/test/unit/scope-resolution/swift/import-decomposer.test.ts new file mode 100644 index 000000000..aeded6eaf --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift/import-decomposer.test.ts @@ -0,0 +1,154 @@ +/** + * Swift import capture: import-kind, @_exported, and module path (R5, R6). + */ +import { describe, expect, it } from 'vitest'; +import { SupportedLanguages } from '../../../../src/config/supported-languages.js'; +import { emitSwiftScopeCaptures } from '../../../../src/core/ingestion/languages/swift/index.js'; +import { interpretSwiftImport } from '../../../../src/core/ingestion/languages/swift/interpret.js'; +import { isLanguageAvailable } from '../../../../src/core/tree-sitter/parser-loader.js'; + +function importsOf(src: string) { + return emitSwiftScopeCaptures(src, 'Probe.swift') + .map((match) => interpretSwiftImport(match)) + .filter((imp): imp is NonNullable => imp !== null); +} + +const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift); + +describe.skipIf(!swiftAvailable)('interpretSwiftImport via emitSwiftScopeCaptures', () => { + it('import Foundation is a namespace, not exported', () => { + expect(importsOf('import Foundation')).toEqual([ + { + kind: 'namespace', + localName: 'Foundation', + importedName: 'Foundation', + targetRaw: 'Foundation', + }, + ]); + }); + + it('import struct Models.User is a named binding of User', () => { + expect(importsOf('import struct Models.User')).toEqual([ + { + kind: 'named', + localName: 'User', + importedName: 'User', + targetRaw: 'Models', + }, + ]); + }); + + it('allows a block comment between import and its kind', () => { + expect(importsOf('import /* selected API */ struct Models.User')).toEqual([ + { + kind: 'named', + localName: 'User', + importedName: 'User', + targetRaw: 'Models', + }, + ]); + }); + + it('allows a line comment between import and its kind', () => { + expect(importsOf('import // selected API\nstruct Models.User')).toEqual([ + { + kind: 'named', + localName: 'User', + importedName: 'User', + targetRaw: 'Models', + }, + ]); + }); + + it('reads the kind after a long block comment that repeats star-slash-slash-star', () => { + const noise = '*//*'.repeat(80); + expect(importsOf(`import /* ${noise} */ struct Models.User`)).toEqual([ + { + kind: 'named', + localName: 'User', + importedName: 'User', + targetRaw: 'Models', + }, + ]); + }); + + it('does not take import kind from an @available message string', () => { + expect( + importsOf('@available(*, deprecated, message: "import struct") import Foo.Bar'), + ).toMatchObject([{ kind: 'namespace', targetRaw: 'Foo' }]); + }); + + it('preserves @testable as the same module', () => { + const [imp] = importsOf('@testable import App'); + expect(imp).toMatchObject({ + kind: 'namespace', + targetRaw: 'App', + }); + }); + + it('@_exported import Models is a reexport of the module handle', () => { + expect(importsOf('@_exported import Models')).toEqual([ + { + kind: 'reexport', + localName: 'Models', + importedName: 'Models', + targetRaw: 'Models', + }, + ]); + }); + + it('@_exported import struct Models.User is a reexport of User', () => { + expect(importsOf('@_exported import struct Models.User')).toEqual([ + { + kind: 'reexport', + localName: 'User', + importedName: 'User', + targetRaw: 'Models', + }, + ]); + }); + + it('public import Models is not a reexport', () => { + expect(importsOf('public import Models')).toEqual([ + { + kind: 'namespace', + localName: 'Models', + importedName: 'Models', + targetRaw: 'Models', + }, + ]); + }); + + it('does not treat _exported inside an @available message as @_exported', () => { + expect(importsOf('@available(*, deprecated, message: "_exported") import Models')).toEqual([ + { + kind: 'namespace', + localName: 'Models', + importedName: 'Models', + targetRaw: 'Models', + }, + ]); + }); + + it('reads the kind after a comment that itself contains import', () => { + expect(importsOf('import /* import */ struct Models.User')).toEqual([ + { + kind: 'named', + localName: 'User', + importedName: 'User', + targetRaw: 'Models', + }, + ]); + }); + + it('reads the kind after nested block comments', () => { + expect(importsOf('import /* outer /* inner */ */ struct Models.User')).toEqual([ + { + kind: 'named', + localName: 'User', + importedName: 'User', + targetRaw: 'Models', + }, + ]); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/import-target.test.ts b/gitnexus/test/unit/scope-resolution/swift/import-target.test.ts new file mode 100644 index 000000000..4bbe2f732 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift/import-target.test.ts @@ -0,0 +1,129 @@ +/** + * Registry-primary Swift import resolve (PR 3105 / #2964). + */ +import { describe, expect, it } from 'vitest'; +import type { ParsedImport } from 'gitnexus-shared'; +import { resolveSwiftImportTarget } from '../../../../src/core/ingestion/languages/swift/import-target.js'; + +const AE1_FILES = [ + 'Sources/Foundation/Thing.swift', + 'Sources/Models/User.swift', + 'Sources/App/main.swift', +] as const; + +function ns(targetRaw: string): ParsedImport { + return { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw }; +} + +function resolve( + targetRaw: string, + files: readonly string[] = AE1_FILES, + fromFile = 'Sources/App/main.swift', + resolutionConfig?: unknown, +): string | readonly string[] | null { + return resolveSwiftImportTarget(ns(targetRaw), { + fromFile, + allFilePaths: new Set(files), + resolutionConfig, + }); +} + +describe('resolveSwiftImportTarget — inferred / no config (R7)', () => { + it('AE1: Foundation is null; Models reaches User.swift', () => { + expect(resolve('Foundation')).toBeNull(); + expect(resolve('Models')).toEqual(['Sources/Models/User.swift']); + }); + + it('AE6: UIKit / CoreData / CoreGraphics are null on inferred decoys', () => { + const files = [ + ...AE1_FILES, + 'Sources/UIKit/Thing.swift', + 'Sources/CoreData/Thing.swift', + 'Sources/CoreGraphics/Thing.swift', + ]; + const inferred = { + origin: 'directories' as const, + targets: new Map([ + ['App', 'Sources/App'], + ['Models', 'Sources/Models'], + ['Foundation', 'Sources/Foundation'], + ['UIKit', 'Sources/UIKit'], + ['CoreData', 'Sources/CoreData'], + ['CoreGraphics', 'Sources/CoreGraphics'], + ]), + }; + expect(resolve('UIKit', files, 'Sources/App/main.swift', inferred)).toBeNull(); + expect(resolve('CoreData', files, 'Sources/App/main.swift', inferred)).toBeNull(); + expect(resolve('CoreGraphics', files, 'Sources/App/main.swift', inferred)).toBeNull(); + expect(resolve('Models', files, 'Sources/App/main.swift', inferred)).toEqual([ + 'Sources/Models/User.swift', + ]); + }); +}); + +describe('resolveSwiftImportTarget — declared Package.swift (R3, R4)', () => { + const declared = { + origin: 'package.swift' as const, + targets: new Map([ + ['Models', 'Sources/Models'], + ['App', 'Sources/App'], + ]), + }; + + it('AE2: Models reaches files; Foundation is null', () => { + expect(resolve('Models', AE1_FILES, 'Sources/App/main.swift', declared)).toEqual([ + 'Sources/Models/User.swift', + ]); + expect(resolve('Foundation', AE1_FILES, 'Sources/App/main.swift', declared)).toBeNull(); + }); + + it('empty declared map makes every name external, including Foundation', () => { + const empty = { origin: 'package.swift' as const, targets: new Map() }; + expect(resolve('Foundation', AE1_FILES, 'Sources/App/main.swift', empty)).toBeNull(); + expect(resolve('Models', AE1_FILES, 'Sources/App/main.swift', empty)).toBeNull(); + }); + + it('empty declaredTargets with inferred grouping folders stays external', () => { + const loadedShape = { + origin: 'package.swift' as const, + targets: new Map([ + ['Foundation', 'Sources/Foundation'], + ['App', 'Sources/App'], + ['Models', 'Sources/Models'], + ]), + declaredTargets: new Map(), + }; + expect(resolve('Foundation', AE1_FILES, 'Sources/App/main.swift', loadedShape)).toBeNull(); + expect(resolve('Models', AE1_FILES, 'Sources/App/main.swift', loadedShape)).toBeNull(); + }); + + it('a declared target literally named Foundation still resolves', () => { + const namedFoundation = { + origin: 'package.swift' as const, + targets: new Map([['Foundation', 'Sources/Foundation']]), + }; + expect(resolve('Foundation', AE1_FILES, 'Sources/App/main.swift', namedFoundation)).toEqual([ + 'Sources/Foundation/Thing.swift', + ]); + }); + + it('a declared path: "." target reaches root-level Swift files', () => { + const files = ['Lib.swift', 'Sources/Other/X.swift']; + expect( + resolve('Lib', files, 'Sources/App/main.swift', { + origin: 'package.swift', + targets: new Map([['Lib', '.']]), + }), + ).toEqual(['Lib.swift', 'Sources/Other/X.swift']); + }); + + it('excludes the importer from its own module file list', () => { + const files = ['Sources/Models/User.swift', 'Sources/Models/Other.swift']; + expect( + resolve('Models', files, 'Sources/Models/User.swift', { + origin: 'package.swift', + targets: new Map([['Models', 'Sources/Models']]), + }), + ).toEqual(['Sources/Models/Other.swift']); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/package-config.test.ts b/gitnexus/test/unit/scope-resolution/swift/package-config.test.ts new file mode 100644 index 000000000..03ef37247 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift/package-config.test.ts @@ -0,0 +1,452 @@ +/** + * Package.swift target loading (PR 3105 / #2964). + * + * `loadSwiftPackageConfig` must distinguish a declaration map + * (`origin: 'package.swift'`) from an inferred `Sources/*` folder map + * (`origin: 'directories'`). Grouping uses either; explicit import + * resolve uses only the declared origin. + */ +import { afterAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + loadSwiftPackageConfig, + parseSwiftPackageManifest, + swiftDeclaredTargetPrefix, +} from '../../../../src/core/ingestion/language-config.js'; +import { coerceDeclaredSwiftTargets } from '../../../../src/core/ingestion/languages/swift/target-grouping.js'; + +const roots: string[] = []; + +function repo(files: Readonly>): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-swift-pkg-')); + roots.push(root); + for (const [rel, contents] of Object.entries(files)) { + const full = path.join(root, rel); + if (contents === null) { + fs.mkdirSync(full, { recursive: true }); + continue; + } + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, contents); + } + return root; +} + +afterAll(() => { + for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); +}); + +const MODELS_APP = ` +let package = Package( + name: "Demo", + targets: [ + .target(name: "Models"), + .target(name: "App"), + ] +) +`; + +describe('parseSwiftPackageManifest', () => { + it('maps .target(name:) with no path to Sources/', () => { + const parsed = parseSwiftPackageManifest(MODELS_APP); + expect(parsed.complete).toBe(true); + expect(parsed.targets.get('Models')).toBe('Sources/Models'); + expect(parsed.targets.get('App')).toBe('Sources/App'); + }); + + it('honors an explicit path:', () => { + const src = ` + .target(name: "Core", path: "Modules/Core") + `; + const parsed = parseSwiftPackageManifest(src); + expect({ complete: parsed.complete, entries: [...parsed.targets] }).toEqual({ + complete: true, + entries: [['Core', 'Modules/Core']], + }); + }); + + it('maps .testTarget to Tests/', () => { + const parsed = parseSwiftPackageManifest(`.testTarget(name: "AppTests")`); + expect(parsed.complete).toBe(true); + expect(parsed.targets.get('AppTests')).toBe('Tests/AppTests'); + }); + + it('skips binary / plugin / systemLibrary targets', () => { + const parsed = parseSwiftPackageManifest(` + .binaryTarget(name: "Lib", path: "Lib.xcframework") + .plugin(name: "Gen") + .systemLibrary(name: "CFoo") + `); + expect(parsed.complete).toBe(true); + expect(parsed.targets.size).toBe(0); + }); + + it('treats #if as a completeness hazard', () => { + const parsed = parseSwiftPackageManifest(` +#if os(macOS) + .target(name: "MacOnly") +#endif + .target(name: "Models") +`); + expect(parsed.complete).toBe(false); + }); + + it('treats a helper-built targets: list as incomplete', () => { + const parsed = parseSwiftPackageManifest(` +let package = Package(name: "Demo", targets: makeTargets()) +`); + expect(parsed.complete).toBe(false); + }); + + it('treats a computed name: as incomplete', () => { + const parsed = parseSwiftPackageManifest(`.target(name: targetName)`); + expect(parsed.complete).toBe(false); + }); + + it('treats a computed path: as incomplete', () => { + const parsed = parseSwiftPackageManifest(`.target(name: "Core", path: corePath)`); + expect(parsed.complete).toBe(false); + }); + + it('ignores a block-commented factory', () => { + const parsed = parseSwiftPackageManifest(` + /* .target(name: "Ghost") */ + .target(name: "Models") + `); + expect(parsed.complete).toBe(true); + expect(parsed.targets.has('Ghost')).toBe(false); + expect(parsed.targets.get('Models')).toBe('Sources/Models'); + }); + + it('records path: "." as the package root', () => { + const parsed = parseSwiftPackageManifest(`.target(name: "Lib", path: ".")`); + expect({ complete: parsed.complete, entries: [...parsed.targets] }).toEqual({ + complete: true, + entries: [['Lib', '.']], + }); + }); + + it('ignores a // commented factory', () => { + const parsed = parseSwiftPackageManifest(` + // .target(name: "Ghost") + .target(name: "Models") + `); + expect(parsed.complete).toBe(true); + expect(parsed.targets.has('Ghost')).toBe(false); + expect(parsed.targets.get('Models')).toBe('Sources/Models'); + }); + + it('does not treat a dependency .target(name:) as a declared target', () => { + const parsed = parseSwiftPackageManifest(` + .target(name: "App", dependencies: [.target(name: "Core")]) + `); + expect(parsed.complete).toBe(true); + expect(parsed.targets.get('App')).toBe('Sources/App'); + expect(parsed.targets.has('Core')).toBe(false); + }); + + it('prefers an explicit path over an earlier same-name factory', () => { + const parsed = parseSwiftPackageManifest(` + .target(name: "Core") + .target(name: "Core", path: "Modules/Core") + `); + expect(parsed.complete).toBe(true); + expect(parsed.targets.get('Core')).toBe('Modules/Core'); + }); + + it('treats a string-interpolated path as incomplete', () => { + const parsed = parseSwiftPackageManifest(`.target(name: "Core", path: "Modules/\\(name)")`); + expect(parsed.complete).toBe(false); + }); + + it('treats a parenthesized name string as a complete factory', () => { + const parsed = parseSwiftPackageManifest(`.target(name: "Foo (experimental)")`); + expect(parsed.complete).toBe(true); + expect(parsed.targets.get('Foo (experimental)')).toBe('Sources/Foo (experimental)'); + }); + + it('ignores a commented parenthesis while balancing a factory', () => { + const parsed = parseSwiftPackageManifest(` + .target(name: "Core", // ) + path: "Modules/Core") + `); + expect({ complete: parsed.complete, entries: [...parsed.targets] }).toEqual({ + complete: true, + entries: [['Core', 'Modules/Core']], + }); + }); + + it('ignores a block-comment parenthesis while balancing a factory', () => { + const parsed = parseSwiftPackageManifest(`.target(name: "Core", /* ) */ path: "Modules/Core")`); + expect({ complete: parsed.complete, entries: [...parsed.targets] }).toEqual({ + complete: true, + entries: [['Core', 'Modules/Core']], + }); + }); + + it('treats a mixed literal + helper-built targets: list as incomplete', () => { + const parsed = parseSwiftPackageManifest(` +let package = Package(name: "Demo", targets: [.target(name: "Core")] + makeTargets()) +`); + expect(parsed.complete).toBe(false); + }); + + it('treats a helper-built list concatenated before literals as incomplete', () => { + const parsed = parseSwiftPackageManifest(` +let package = Package(name: "Demo", targets: makeTargets() + [.target(name: "Core")]) +`); + expect(parsed.complete).toBe(false); + }); + + it('does not treat .library(..., targets: names) as a helper-built list', () => { + const parsed = parseSwiftPackageManifest(` + .library(name: "Demo", targets: libTargets) + .target(name: "Models") + `); + expect(parsed.complete).toBe(true); + expect(parsed.targets.get('Models')).toBe('Sources/Models'); + }); + + it('ignores a factory-like spelling inside a string literal', () => { + const parsed = parseSwiftPackageManifest(` + let example = ".target(name: 'Ghost')" + .target(name: "Models") + `); + expect(parsed.complete).toBe(true); + expect(parsed.targets.has('Ghost')).toBe(false); + expect(parsed.targets.get('Models')).toBe('Sources/Models'); + }); + + it('treats a variable-prefix targets: concatenation as incomplete', () => { + const parsed = parseSwiftPackageManifest(` +let package = Package(name: "Demo", targets: extraTargets + [.target(name: "Core")]) +`); + expect(parsed.complete).toBe(false); + }); + + it('skips a commented name: field and uses the real one', () => { + const parsed = parseSwiftPackageManifest(`.target(/* name: "Ghost" */ name: "Models")`); + expect(parsed.complete).toBe(true); + expect(parsed.targets.has('Ghost')).toBe(false); + expect(parsed.targets.get('Models')).toBe('Sources/Models'); + }); + + it('does not treat a commented #if as a completeness hazard', () => { + const parsed = parseSwiftPackageManifest(` + // #if os(macOS) + .target(name: "Models") + `); + expect(parsed.complete).toBe(true); + expect(parsed.targets.get('Models')).toBe('Sources/Models'); + }); + + it('does not treat a block-commented #if as a completeness hazard', () => { + const parsed = parseSwiftPackageManifest(` +/* +#if os(macOS) + .target(name: "MacOnly") +#endif +*/ + .target(name: "Models") + `); + expect(parsed.complete).toBe(true); + expect(parsed.targets.has('MacOnly')).toBe(false); + expect(parsed.targets.get('Models')).toBe('Sources/Models'); + }); + + it('rejects a name string with a Swift escape', () => { + const parsed = parseSwiftPackageManifest(`.target(name: "\\u{43}ore")`); + expect(parsed.complete).toBe(false); + expect(parsed.targets.size).toBe(0); + }); + + it('does not treat https:// on the same line as a commented factory', () => { + const parsed = parseSwiftPackageManifest( + 'let package = Package(name: "Demo", dependencies: [.package(url: "https://example.com/foo.git", from: "1.0.0")], targets: [.target(name: "T")])', + ); + expect(parsed.complete).toBe(true); + expect(parsed.targets.get('T')).toBe('Sources/T'); + }); + + it('ignores a commented helper-built targets: list', () => { + const parsed = parseSwiftPackageManifest(` +let package = Package( + name: "Demo", + targets: [ + .target(name: "Models"), + ] +) +// targets: makeTargets() +`); + expect(parsed.complete).toBe(true); + expect(parsed.targets.get('Models')).toBe('Sources/Models'); + }); + + it('ignores a helper-built targets: spelling inside a string', () => { + const parsed = parseSwiftPackageManifest(` +let note = "targets: makeTargets()" +let package = Package(name: "Demo", targets: [.target(name: "Models")]) +`); + expect(parsed.complete).toBe(true); + expect(parsed.targets.get('Models')).toBe('Sources/Models'); + }); + + it('treats Package(targets: variable) as incomplete even when a factory was collected', () => { + const parsed = parseSwiftPackageManifest(` +func unused() { _ = Target.target(name: "Ghost") } +let package = Package(name: "Demo", targets: actualTargets) +.target(name: "Incidental") +`); + expect(parsed.complete).toBe(false); + expect(parsed.targets.size).toBe(0); + }); + + it('does not collect a factory outside Package(targets: [...])', () => { + const parsed = parseSwiftPackageManifest(` +func unused() { _ = Target.target(name: "Ghost") } +let package = Package(name: "Demo", targets: [.target(name: "Incidental")]) +`); + expect(parsed.complete).toBe(true); + expect(parsed.targets.has('Ghost')).toBe(false); + expect(parsed.targets.get('Incidental')).toBe('Sources/Incidental'); + }); + + it('treats a computed element inside the targets: array as incomplete', () => { + const parsed = parseSwiftPackageManifest(` +let package = Package(name: "Demo", targets: [makeTargets()]) +`); + expect(parsed.complete).toBe(false); + expect(parsed.targets.size).toBe(0); + }); + + it('does not collect factories when Package omits targets:', () => { + const parsed = parseSwiftPackageManifest(` +let unused = Target.target(name: "Ghost") +let package = Package(name: "Empty") +`); + expect(parsed.complete).toBe(true); + expect(parsed.targets.has('Ghost')).toBe(false); + expect(parsed.targets.size).toBe(0); + }); + + it('does not treat a line comment after a label colon as live source', () => { + const parsed = parseSwiftPackageManifest(` +let package = Package( + name: "Demo", + targets: [ + .target(name: // .target(name: "Ghost") + "Models"), + ] +) +`); + expect(parsed.complete).toBe(true); + expect(parsed.targets.has('Ghost')).toBe(false); + expect(parsed.targets.get('Models')).toBe('Sources/Models'); + }); +}); + +describe('swiftDeclaredTargetPrefix', () => { + it('treats . and ./ as the package root', () => { + expect(swiftDeclaredTargetPrefix('.')).toBe(''); + expect(swiftDeclaredTargetPrefix('./')).toBe(''); + expect(swiftDeclaredTargetPrefix('./.')).toBe(''); + }); + + it('strips a leading ./ from a relative target path', () => { + expect(swiftDeclaredTargetPrefix('./Sources/Core')).toBe('Sources/Core/'); + expect(swiftDeclaredTargetPrefix('Sources/Core')).toBe('Sources/Core/'); + }); +}); + +describe('loadSwiftPackageConfig', () => { + it('returns a declared map from Package.swift and ignores undeclared Sources/* folders', async () => { + const root = repo({ + 'Package.swift': MODELS_APP, + 'Sources/Models/User.swift': '', + 'Sources/App/main.swift': '', + 'Sources/Foundation/Thing.swift': '', + }); + + const cfg = await loadSwiftPackageConfig(root); + expect(cfg?.origin).toBe('package.swift'); + expect([...cfg!.targets.keys()].sort()).toEqual(['App', 'Models']); + expect(cfg!.targets.has('Foundation')).toBe(false); + }); + + it('returns an empty declared map when the manifest only has skipped target kinds', async () => { + const root = repo({ + 'Package.swift': ` +let package = Package( + name: "OnlyBinary", + targets: [.binaryTarget(name: "Lib", path: "Lib.xcframework")] +) +`, + 'Sources/Foundation/Thing.swift': '', + }); + + const cfg = await loadSwiftPackageConfig(root); + expect(cfg?.origin).toBe('package.swift'); + expect(cfg!.declaredTargets?.size).toBe(0); + // Grouping still uses inferred folders so App/Foundation stay isolated. + expect(cfg!.targets.get('Foundation')).toBe('Sources/Foundation'); + expect(coerceDeclaredSwiftTargets(cfg)?.size).toBe(0); + }); + + it('infers Sources/* folders when Package.swift is missing', async () => { + const root = repo({ + 'Sources/App/main.swift': '', + 'Sources/Models/User.swift': '', + }); + + const cfg = await loadSwiftPackageConfig(root); + expect(cfg?.origin).toBe('directories'); + expect(cfg!.targets.get('App')).toBe('Sources/App'); + expect(cfg!.targets.get('Models')).toBe('Sources/Models'); + }); + + it('infers directories when Package.swift is unreadable (is a directory)', async () => { + const root = repo({ + 'Package.swift': null, + 'Sources/App/main.swift': '', + }); + + const cfg = await loadSwiftPackageConfig(root); + expect(cfg?.origin).toBe('directories'); + expect(cfg!.targets.get('App')).toBe('Sources/App'); + }); + + it('infers directories when the manifest has completeness hazards', async () => { + const root = repo({ + 'Package.swift': ` +#if os(Linux) + .target(name: "LinuxOnly") +#endif +`, + 'Sources/App/main.swift': '', + }); + + const cfg = await loadSwiftPackageConfig(root); + expect(cfg?.origin).toBe('directories'); + expect(cfg!.targets.get('App')).toBe('Sources/App'); + expect(cfg!.targets.has('LinuxOnly')).toBe(false); + }); + + it('returns null when there is no manifest and no source folders', async () => { + const root = repo({ 'README.md': '' }); + expect(await loadSwiftPackageConfig(root)).toBeNull(); + }); + + it('declares a one-line manifest that includes an https:// dependency URL', async () => { + const root = repo({ + 'Package.swift': + 'let package = Package(name: "Demo", dependencies: [.package(url: "https://example.com/foo.git", from: "1.0.0")], targets: [.target(name: "T")])', + 'Sources/T/T.swift': '', + }); + const cfg = await loadSwiftPackageConfig(root); + expect(cfg?.origin).toBe('package.swift'); + expect(cfg!.declaredTargets?.get('T')).toBe('Sources/T'); + expect(cfg!.targets.get('T')).toBe('Sources/T'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/reexport-visibility.test.ts b/gitnexus/test/unit/scope-resolution/swift/reexport-visibility.test.ts new file mode 100644 index 000000000..8b901e387 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift/reexport-visibility.test.ts @@ -0,0 +1,131 @@ +/** + * In-repo @_exported visibility (R5 / AE4). + */ +import { describe, expect, it } from 'vitest'; +import type { ParsedFile, ParsedImport, ScopeId } from 'gitnexus-shared'; +import { resolveSwiftImportTarget } from '../../../../src/core/ingestion/languages/swift/import-target.js'; + +const DECLARED = { + origin: 'package.swift' as const, + targets: new Map([ + ['A', 'Sources/A'], + ['B', 'Sources/B'], + ['C', 'Sources/C'], + ]), +}; + +function stubFile(filePath: string, parsedImports: ParsedImport[] = []): ParsedFile { + return { + filePath, + moduleScope: `module:${filePath}` as ScopeId, + scopes: [], + parsedImports, + localDefs: [], + referenceSites: [], + }; +} + +function reexport(targetRaw: string, name = targetRaw): ParsedImport { + return { kind: 'reexport', localName: name, importedName: name, targetRaw }; +} + +function ns(targetRaw: string): ParsedImport { + return { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw }; +} + +function resolve( + targetRaw: string, + files: readonly string[], + parsedFiles: ParsedFile[], + fromFile = 'Sources/App/main.swift', + resolutionConfig: unknown = DECLARED, +): string | readonly string[] | null { + return resolveSwiftImportTarget(ns(targetRaw), { + fromFile, + allFilePaths: new Set(files), + resolutionConfig, + parsedFiles, + }); +} + +describe('Swift @_exported visibility', () => { + it('AE4: import A includes B when A @_exported import B', () => { + const files = ['Sources/A/A.swift', 'Sources/B/B.swift', 'Sources/App/main.swift']; + const parsed = [ + stubFile('Sources/A/A.swift', [reexport('B')]), + stubFile('Sources/B/B.swift'), + stubFile('Sources/App/main.swift'), + ]; + expect(resolve('A', files, parsed)).toEqual( + expect.arrayContaining(['Sources/A/A.swift', 'Sources/B/B.swift']), + ); + }); + + it('transitive A → B → C unions C', () => { + const files = ['Sources/A/A.swift', 'Sources/B/B.swift', 'Sources/C/C.swift']; + const parsed = [ + stubFile('Sources/A/A.swift', [reexport('B')]), + stubFile('Sources/B/B.swift', [reexport('C')]), + stubFile('Sources/C/C.swift'), + ]; + expect(resolve('A', files, parsed, 'Sources/App/main.swift')).toEqual( + expect.arrayContaining(['Sources/A/A.swift', 'Sources/B/B.swift', 'Sources/C/C.swift']), + ); + }); + + it('cycle A → B → A terminates', () => { + const files = ['Sources/A/A.swift', 'Sources/B/B.swift']; + const parsed = [ + stubFile('Sources/A/A.swift', [reexport('B')]), + stubFile('Sources/B/B.swift', [reexport('A')]), + ]; + expect(resolve('A', files, parsed)).toEqual( + expect.arrayContaining(['Sources/A/A.swift', 'Sources/B/B.swift']), + ); + }); + + it('@_exported import Foundation adds no files', () => { + const files = ['Sources/A/A.swift', 'Sources/Foundation/Thing.swift', 'Sources/App/main.swift']; + const parsed = [ + stubFile('Sources/A/A.swift', [reexport('Foundation')]), + stubFile('Sources/Foundation/Thing.swift'), + ]; + expect(resolve('A', files, parsed)).toEqual(['Sources/A/A.swift']); + }); + + it('member-only @_exported import does not paint the rest of the module', () => { + const files = [ + 'Sources/A/A.swift', + 'Sources/Models/User.swift', + 'Sources/Models/Other.swift', + 'Sources/App/main.swift', + ]; + const parsed = [ + stubFile('Sources/A/A.swift', [reexport('Models', 'User')]), + stubFile('Sources/Models/User.swift'), + stubFile('Sources/Models/Other.swift'), + ]; + const declared = { + origin: 'package.swift' as const, + targets: new Map([ + ['A', 'Sources/A'], + ['Models', 'Sources/Models'], + ]), + }; + expect(resolve('A', files, parsed, 'Sources/App/main.swift', declared)).toEqual([ + 'Sources/A/A.swift', + ]); + }); + + it('a sibling file that does not write @_exported still contributes the reexport', () => { + const files = ['Sources/A/A.swift', 'Sources/A/Other.swift', 'Sources/B/B.swift']; + const parsed = [ + stubFile('Sources/A/A.swift', [reexport('B')]), + stubFile('Sources/A/Other.swift'), + stubFile('Sources/B/B.swift'), + ]; + expect(resolve('A', files, parsed)).toEqual( + expect.arrayContaining(['Sources/A/A.swift', 'Sources/A/Other.swift', 'Sources/B/B.swift']), + ); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/target-grouping.test.ts b/gitnexus/test/unit/scope-resolution/swift/target-grouping.test.ts index f661dd21f..95d53bb43 100644 --- a/gitnexus/test/unit/scope-resolution/swift/target-grouping.test.ts +++ b/gitnexus/test/unit/scope-resolution/swift/target-grouping.test.ts @@ -3,18 +3,19 @@ * KTD2). * * `groupSwiftFilesBySpmTarget` (`languages/swift/target-grouping.ts`) - * DUPLICATES the legacy `groupSwiftFilesByTarget` (`languages/swift.ts`) - * SPM-subtree semantics so the registry-primary same-module hooks group by - * the SPM target subtree without touching the legacy pipeline (hard - * constraint: legacy stays byte-identical). Because the duplication can - * silently drift if legacy is later changed, this test pins the exact - * bucketing for representative inputs so a future divergence surfaces - * loudly: + * preserves the legacy `groupSwiftFilesByTarget` (`languages/swift.ts`) + * bucketing contract for ordinary SPM layouts: one target bucket per file, + * first-target-wins ordering, and the same `__default__` fallback. It now + * intentionally differs for issue #2931's repeated-prefix edge case by + * accepting a later segment-boundary occurrence when an earlier textual + * occurrence is embedded inside a longer path segment. These tests pin the + * shared ordinary-layout contract plus that documented #2931 fix. * * 1. A multi-subdir single target buckets into ONE group. * 2. A file matching two overlapping same-named target prefixes is * assigned to the FIRST target only (legacy `break`s — no fan-out). - * 3. Unmatched files AND the no-targets case route to `__default__` = all. + * 3. Target dirs match only at path-segment boundaries. + * 4. Unmatched files AND the no-targets case route to `__default__` = all. * * `coerceSwiftTargets` is also covered: it duck-types `{ targets: Map }` * (no `instanceof` on the config object) and returns `null` otherwise. @@ -27,7 +28,7 @@ import { const id = (s: string) => s; -describe('groupSwiftFilesBySpmTarget — legacy SPM-subtree parity (drift guard)', () => { +describe('groupSwiftFilesBySpmTarget — shared SPM bucketing contract', () => { it('buckets a multi-subdir single target into ONE group', () => { const files = [ 'Sources/Alpha/Core/User.swift', @@ -45,7 +46,7 @@ describe('groupSwiftFilesBySpmTarget — legacy SPM-subtree parity (drift guard) it('assigns a file matching two overlapping same-named prefixes to the FIRST target only', () => { // Both targets are prefixes of the file's path (Beta dir nested under - // Alpha). Legacy `break`s on the first match → one bucket per file. + // Alpha). The first configured match wins → one bucket per file. const files = ['Sources/Alpha/Beta/User.swift']; const targets = new Map([ ['Alpha', 'Sources/Alpha'], @@ -58,9 +59,19 @@ describe('groupSwiftFilesBySpmTarget — legacy SPM-subtree parity (drift guard) expect(groups.has('Beta')).toBe(false); }); + it('assigns root-level files to a path: "." target', () => { + const files = ['Lib.swift', 'Sources/Other/X.swift']; + const targets = new Map([['Lib', '.']]); + + const groups = groupSwiftFilesBySpmTarget(files, id, targets); + + expect(groups.get('Lib')).toEqual(files); + expect(groups.has('__default__')).toBe(false); + }); + it('matches a target dir only at a `/` boundary, not a substring', () => { - // "Sources/Alpha" must NOT match "Sources/AlphaBeta/..." — the legacy - // predicate requires idx===0 or a preceding `/`. + // "Sources/Alpha" must NOT match "Sources/AlphaBeta/...". The matcher + // accepts only a path-start or slash-delimited target occurrence. const files = ['Sources/AlphaBeta/User.swift']; const targets = new Map([['Alpha', 'Sources/Alpha']]); @@ -114,6 +125,26 @@ describe('groupSwiftFilesBySpmTarget — legacy SPM-subtree parity (drift guard) expect(groups.get('Beta')).toEqual([items[1]]); }); + it('keeps inferred Sources/* folders in separate buckets', () => { + const targets = new Map([ + ['App', 'Sources/App'], + ['Models', 'Sources/Models'], + ['Foundation', 'Sources/Foundation'], + ]); + const items = [ + 'Sources/App/main.swift', + 'Sources/Models/User.swift', + 'Sources/Foundation/Thing.swift', + ]; + + const groups = groupSwiftFilesBySpmTarget(items, id, targets); + + expect(groups.get('App')).toEqual(['Sources/App/main.swift']); + expect(groups.get('Models')).toEqual(['Sources/Models/User.swift']); + expect(groups.get('Foundation')).toEqual(['Sources/Foundation/Thing.swift']); + expect(groups.get('__default__')).toBeUndefined(); + }); + it('normalizes backslash paths to forward-slash before matching', () => { const files = ['Sources\\Alpha\\Core\\User.swift']; const targets = new Map([['Alpha', 'Sources/Alpha']]);