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 01/58] 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 02/58] 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 03/58] 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 04/58] 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 05/58] 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 06/58] 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 07/58] 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 08/58] 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 09/58] 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 10/58] 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 11/58] 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 12/58] 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']]); From ee2feb7a5b1e7c4a66acb0dd70dee6aad88c56e5 Mon Sep 17 00:00:00 2001 From: Parafee41 Date: Sun, 20 Sep 2026 15:28:28 +0800 Subject: [PATCH 13/58] fix(cli): announce explicit registry alias changes (#3334) * fix(cli): announce explicit registry alias changes * fix: handle async rename observer failures * Address PR review feedback (#3334) - Invoke onRename after withRegistryLock releases so observer I/O cannot stall the registry - Spy the rejecting observer and prove lock release via re-entrant registerRepo 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> --- gitnexus/src/core/run-analyze.ts | 6 ++++ gitnexus/src/storage/repo-manager.ts | 32 +++++++++++++++++++-- gitnexus/test/unit/repo-manager.test.ts | 37 +++++++++++++++++++++++++ gitnexus/test/unit/run-analyze.test.ts | 4 ++- 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index c2d7b485d..f0c20e737 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -1597,6 +1597,8 @@ async function runFullAnalysisInner( if (options.registryName) { await registerRepo(repoPath, existingMeta, { name: options.registryName, + onRename: (previousName, nextName) => + log(`Registry name changed: "${previousName}" -> "${nextName}".`), allowDuplicateName: options.allowDuplicateName, branch: placement.branch, storagePath, @@ -2166,6 +2168,8 @@ async function runFullAnalysisInner( if (options.registryName) { await registerRepo(repoPath, existingMeta, { name: options.registryName, + onRename: (previousName, nextName) => + log(`Registry name changed: "${previousName}" -> "${nextName}".`), allowDuplicateName: options.allowDuplicateName, branch: placement.branch, storagePath, @@ -4644,6 +4648,8 @@ async function runFullAnalysisInner( // will look up (#979). const projectName = await registerRepo(repoPath, meta, { name: options.registryName, + onRename: (previousName, nextName) => + log(`Registry name changed: "${previousName}" -> "${nextName}".`), allowDuplicateName: options.allowDuplicateName, // Non-primary branch runs upsert into the entry's branches[]; the // primary/flat run (placement.branch === undefined) refreshes the diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index a4db22b46..aee05358f 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -838,6 +838,13 @@ export interface RegisterRepoOptions { * re-analyses of the same path without `--name` preserve the alias. */ name?: string; + /** + * Best-effort notification after an explicit alias change has been committed + * to the registry. Invoked after the registry lock is released. Callback + * failures are ignored: reporting must not turn a successful registry write + * into an apparent transaction failure. + */ + onRename?: (previousName: string, nextName: string) => void | Promise; /** * Allow two DIFFERENT repo paths to register under the same alias * (#829). Mapped from the `--allow-duplicate-name` CLI flag. @@ -926,6 +933,11 @@ const hasCustomAlias = (entry: RegistryEntry, inferredName: string | null): bool return true; }; +type RegisterRepoUnlockedResult = { + name: string; + rename?: { previousName: string; nextName: string }; +}; + /** * Register (add or update) a repo in the global registry. * Called after `gitnexus analyze` completes. @@ -954,7 +966,7 @@ const registerRepoUnlocked = async ( repoPath: string, meta: RepoMeta, opts?: RegisterRepoOptions, -): Promise => { +): Promise => { // Preserve the caller's chosen path form in the registry — don't // canonicalise at write time. This matters for two reasons: // 1. `list` and error messages show the path the user actually @@ -1151,14 +1163,28 @@ const registerRepoUnlocked = async ( } await writeRegistry(fresh); - return name; + const rename = + opts?.name !== undefined && freshExisting && freshExisting.name !== name + ? { previousName: freshExisting.name, nextName: name } + : undefined; + return { name, ...(rename ? { rename } : {}) }; }; export const registerRepo = async ( repoPath: string, meta: RepoMeta, opts?: RegisterRepoOptions, -): Promise => withRegistryLock(() => registerRepoUnlocked(repoPath, meta, opts)); +): Promise => { + const { name, rename } = await withRegistryLock(() => registerRepoUnlocked(repoPath, meta, opts)); + if (rename) { + try { + await opts?.onRename?.(rename.previousName, rename.nextName); + } catch { + // The rename is already durable; observer failures cannot roll it back. + } + } + return name; +}; /** * Remove a repo from the global registry. diff --git a/gitnexus/test/unit/repo-manager.test.ts b/gitnexus/test/unit/repo-manager.test.ts index cdf1d6fa2..fddafbe39 100644 --- a/gitnexus/test/unit/repo-manager.test.ts +++ b/gitnexus/test/unit/repo-manager.test.ts @@ -963,6 +963,43 @@ describe('registerRepo name override + collision guard (#829)', () => { expect(entries[0].name).toBe('new-alias'); }); + it('keeps an alias rename committed when an async observer rejects', async () => { + await registerRepo(tmpRepoA.dbPath, meta, { name: 'old-alias' }); + + const onRename = vi.fn(async () => { + throw new Error('observer failed'); + }); + + await expect( + registerRepo(tmpRepoA.dbPath, meta, { + name: 'new-alias', + onRename, + }), + ).resolves.toBe('new-alias'); + + expect(onRename).toHaveBeenCalledTimes(1); + expect(onRename).toHaveBeenCalledWith('old-alias', 'new-alias'); + expect(await listRegisteredRepos()).toMatchObject([{ name: 'new-alias' }]); + }); + + it('releases the registry lock before invoking onRename', async () => { + await registerRepo(tmpRepoA.dbPath, meta, { name: 'old-alias' }); + + await registerRepo(tmpRepoA.dbPath, meta, { + name: 'new-alias', + onRename: async () => { + await registerRepo(tmpRepoB.dbPath, meta, { name: 'observer-reentry' }); + }, + }); + + expect(await listRegisteredRepos()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'new-alias' }), + expect.objectContaining({ name: 'observer-reentry' }), + ]), + ); + }); + it('registerRepo throws RegistryNameCollisionError when another path uses the name', async () => { await registerRepo(tmpRepoA.dbPath, meta, { name: 'shared' }); diff --git a/gitnexus/test/unit/run-analyze.test.ts b/gitnexus/test/unit/run-analyze.test.ts index fe1dc8660..7b6ac9552 100644 --- a/gitnexus/test/unit/run-analyze.test.ts +++ b/gitnexus/test/unit/run-analyze.test.ts @@ -188,10 +188,11 @@ describe('run-analyze module', () => { await registerRepo(tmpRepo.dbPath, meta, { name: 'old' }); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const logs: string[] = []; const result = await runFullAnalysis( tmpRepo.dbPath, { registryName: 'new' }, - { onProgress: () => {} }, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, ); expect(result.alreadyUpToDate).toBe(true); @@ -199,6 +200,7 @@ describe('run-analyze module', () => { const entries = await readRegistry(); expect(entries).toHaveLength(1); expect(entries[0].name).toBe('new'); + expect(logs).toContain('Registry name changed: "old" -> "new".'); const agents = await fs.readFile(path.join(tmpRepo.dbPath, 'AGENTS.md'), 'utf-8'); expect(agents).toContain('**new**'); } finally { From 5d32e345bbacd0a20fa60696ca8fd62eef44697f Mon Sep 17 00:00:00 2001 From: Ahmad Othman Ammar Adi <78882424+OthmanAdi@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:12:54 +0200 Subject: [PATCH 14/58] feat(web): drop a folder onto the analyzer to upload it (#3315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): drop a folder onto the analyzer to upload it The Local Folder panel of RepoAnalyzer is now a drop target. A dropped folder is walked with the File and Directory Entries API (DataTransferItem.webkitGetAsEntry), directories on the shared exclusion list are pruned before they are read, and the resulting File objects are handed to the existing filterRepoFiles -> uploadFolder -> trackJob path with webkitRelativePath set to /, so the server receives the same manifest shape the webkitdirectory picker produces. Compared with the picker, the walk never enumerates node_modules or .git, stops at the server's 20000 file cap and 64 path segments, skips unreadable entries instead of failing, and reports progress while it runs. Loose files and several folders at once are refused with a message (the server accepts one top-level folder). The walk runs under the request controller, so a mode switch or unmount aborts it; Analyze and the picker are blocked while it runs. The panel-wide target also stops the browser from navigating to a file dropped a few pixels off the button. New strings in en and zh-CN; browsers without webkitGetAsEntry keep the picker button and get an explanation. * Address PR review feedback (#3315) Clear readingCount only when this drop still owns the request controller, and skip oversized files before they count toward the 20k drop cap. Co-authored-by: Cursor * Address PR review feedback (#3315) Count oversized files the drop walk skips in the summary droppedCount, and correct the 250-file batch-read comment. 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-web/src/components/RepoAnalyzer.tsx | 209 +++++++++-- gitnexus-web/src/lib/folder-drop.test.ts | 283 +++++++++++++++ gitnexus-web/src/lib/folder-drop.ts | 214 +++++++++++ gitnexus-web/src/locales/en/onboarding.json | 9 +- .../src/locales/zh-CN/onboarding.json | 9 +- .../unit/repo-analyzer-folder-drop.test.tsx | 334 ++++++++++++++++++ 6 files changed, 1035 insertions(+), 23 deletions(-) create mode 100644 gitnexus-web/src/lib/folder-drop.test.ts create mode 100644 gitnexus-web/src/lib/folder-drop.ts create mode 100644 gitnexus-web/test/unit/repo-analyzer-folder-drop.test.tsx diff --git a/gitnexus-web/src/components/RepoAnalyzer.tsx b/gitnexus-web/src/components/RepoAnalyzer.tsx index 9d44cdb91..560dbcf99 100644 --- a/gitnexus-web/src/components/RepoAnalyzer.tsx +++ b/gitnexus-web/src/components/RepoAnalyzer.tsx @@ -3,10 +3,11 @@ * * Two input modes: * - "github" → GitHub URL (https://github.com/owner/repo) - * - "local" → Select a local folder via the browser's native directory picker + * - "local" → Select a local folder via the browser's native directory picker, + * or drop one onto the upload control */ -import { useState, useRef, useEffect, useId } from 'react'; +import { useState, useRef, useEffect, useId, type DragEvent as ReactDragEvent } from 'react'; import { Github, Gitlab, @@ -27,7 +28,14 @@ import { type JobProgress, } from '../services/backend-client'; import { AnalyzeProgress } from './AnalyzeProgress'; -import { filterRepoFiles } from '@/lib/upload-filter'; +import { filterRepoFiles, type FilterResult } from '@/lib/upload-filter'; +import { + collectDropEntries, + isFolderDropSupported, + readDroppedFolder, + DropRejection, + type DropRejectionReason, +} from '@/lib/folder-drop'; import { useTranslation } from 'react-i18next'; import { formatBackendError } from '../i18n/error-messages'; @@ -55,6 +63,23 @@ function isValidAzureUrl(value: string): boolean { return AZURE_RE.test(value.trim()); } +/** i18n key (under onboarding:repoAnalyzer.upload) for a refused folder drop. */ +function dropRejectionKey(reason: DropRejectionReason): string { + switch (reason) { + case 'unsupported': + return 'dropUnsupported'; + case 'tooManyFiles': + return 'dropTooManyFiles'; + case 'notSingleFolder': + return 'dropSingleFolder'; + } +} + +/** True for a drag that carries files or folders from the OS, not text or links. */ +function isFileDrag(e: ReactDragEvent): boolean { + return Array.from(e.dataTransfer.types).includes('Files'); +} + // ── Mode tabs ──────────────────────────────────────────────────────────────── function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode) => void }) { @@ -198,9 +223,16 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp const inputId = useId(); const [mode, setMode] = useState('github'); const [uploading, setUploading] = useState(false); - const [uploadSummary, setUploadSummary] = useState<{ count: number; dropped: number } | null>( - null, - ); + // Files found so far while walking a dropped folder; null when not reading. + const [readingCount, setReadingCount] = useState(null); + const [dragActive, setDragActive] = useState(false); + // `skippedDirs` is set only for a dropped folder: directories the walk left + // out without enumerating them (a directory count, unlike `dropped`). + const [uploadSummary, setUploadSummary] = useState<{ + count: number; + dropped: number; + skippedDirs?: number; + } | null>(null); const [githubUrl, setGithubUrl] = useState(''); const [githubToken, setGithubToken] = useState(''); const [gitlabUrl, setGitlabUrl] = useState(''); @@ -224,6 +256,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp const requestControllerRef = useRef(null); const completeTimerRef = useRef | null>(null); const folderInputRef = useRef(null); + // dragenter/dragleave fire for every child boundary crossed; count them so + // the highlight does not flicker while the cursor moves over the button. + const dragDepthRef = useRef(0); useEffect(() => { return () => { @@ -277,6 +312,10 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp setValidationError(null); setUploadSummary(null); setUploading(false); + // The aborted controller also stops a folder walk that is still running. + setReadingCount(null); + setDragActive(false); + dragDepthRef.current = 0; // An aborted request no longer resolves to move `phase` off 'starting'; // reset so the new mode's form is immediately usable (also clears a stale // 'error' phase). Only reachable while showInput is true. @@ -287,14 +326,17 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp // exposes an absolute path, so the old typed-path/browse approach couldn't // work — see handleFolderUpload). A typed server path is also still accepted. + // A folder walk in progress (readingCount) blocks Analyze as well: starting a + // request would abort the walk through renewRequestController. const canSubmit = - mode === 'github' + readingCount === null && + (mode === 'github' ? isValidGithubUrl(githubUrl) && (phase === 'input' || phase === 'error') : mode === 'gitlab' ? isValidGitlabUrl(gitlabUrl) && (phase === 'input' || phase === 'error') : mode === 'azure' ? isValidAzureUrl(azureUrl) && (phase === 'input' || phase === 'error') - : localPath.trim().length > 1 && (phase === 'input' || phase === 'error'); + : localPath.trim().length > 1 && (phase === 'input' || phase === 'error')); const handleAnalyze = async () => { if (mode === 'github' && !isValidGithubUrl(githubUrl)) { @@ -398,20 +440,33 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp // Upload a browser-selected folder (webkitdirectory) and start analysis. The // upload endpoint returns a jobId, which then joins the normal SSE flow. const handleFolderUpload = async (fileList: FileList) => { - if (uploading || isLoading) return; // guard against a concurrent upload - const { files, manifest, droppedCount } = filterRepoFiles(fileList); + // guard against a concurrent upload or a folder walk still running + if (uploading || isLoading || readingCount !== null) return; + await startFolderUpload(filterRepoFiles(fileList), renewRequestController()); + }; + + // Shared tail of the picker and drop paths: `filtered` is the client-side + // filter result, `controller` owns the request, `skippedDirs` is set for a + // drop and counts the directories the walk left out before reading them + // (the picker enumerates everything and lets filterRepoFiles drop the files + // instead, so its count arrives inside `filtered.droppedCount`). + const startFolderUpload = async ( + filtered: FilterResult, + controller: AbortController, + skippedDirs?: number, + ) => { + const { files, manifest, droppedCount } = filtered; if (files.length === 0) { setValidationError(t('onboarding:repoAnalyzer.upload.empty')); return; } setValidationError(null); - setUploadSummary({ count: files.length, dropped: droppedCount }); + setUploadSummary({ count: files.length, dropped: droppedCount, skippedDirs }); setUploading(true); setPhase('starting'); // The selected folder's name (manifest entries are `/`) is a // sensible fallback if the server's complete event omits repoName. const folderName = manifest[0]?.split('/')[0] ?? null; - const controller = renewRequestController(); try { const { jobId } = await uploadFolder(files, manifest, controller.signal); if (controller.signal.aborted) { @@ -436,6 +491,76 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp } }; + // Drag and drop a folder onto the upload control. The entries have to be + // taken from dataTransfer synchronously inside the drop handler (browsers + // empty `items` once the handler yields). Only a single folder is accepted, + // mirroring the server's one-top-level-directory rule, and the walk runs + // under the same request controller as the upload so a mode switch or an + // unmount aborts both. + const isDropBlocked = () => uploading || phase === 'starting' || readingCount !== null; + + const handleDragEnter = (e: ReactDragEvent) => { + if (!isFileDrag(e)) return; + e.preventDefault(); + if (isDropBlocked()) return; + dragDepthRef.current += 1; + setDragActive(true); + }; + + const handleDragOver = (e: ReactDragEvent) => { + if (!isFileDrag(e)) return; + // Without preventDefault the drop never fires and the browser navigates + // to the dropped file instead, so it is called even while blocked. + e.preventDefault(); + e.dataTransfer.dropEffect = isDropBlocked() ? 'none' : 'copy'; + }; + + const handleDragLeave = () => { + // No type check here: some engines hand dragleave an empty `types` list, + // and a stray decrement is harmless because the depth is clamped at 0. + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setDragActive(false); + }; + + const handleDrop = async (e: ReactDragEvent) => { + if (!isFileDrag(e)) return; + e.preventDefault(); + dragDepthRef.current = 0; + setDragActive(false); + if (isDropBlocked()) return; + const controller = renewRequestController(); + setValidationError(null); + setUploadSummary(null); + setReadingCount(0); + try { + const entries = collectDropEntries(e.dataTransfer); // sync, before any await + const { files, skipped, oversized } = await readDroppedFolder(entries, { + signal: controller.signal, + onProgress: setReadingCount, + }); + // Only the walk that still owns the request controller may clear the + // reading mutex. An aborted drop that settles after a later drop started + // must not steal the live walk's lock (readingCount === null is what + // unblocks Analyze and a second drop). + if (requestControllerRef.current === controller) setReadingCount(null); + if (controller.signal.aborted) return; + const filtered = filterRepoFiles(files); + await startFolderUpload( + { ...filtered, droppedCount: filtered.droppedCount + oversized }, + controller, + skipped, + ); + } catch (err) { + if (requestControllerRef.current === controller) setReadingCount(null); + if (controller.signal.aborted) return; + setValidationError( + err instanceof DropRejection + ? t(`onboarding:repoAnalyzer.upload.${dropRejectionKey(err.reason)}`, { max: err.max }) + : formatBackendError(err, t), + ); + } + }; + const handleCancel = async () => { sseControllerRef.current?.abort(); sseControllerRef.current = null; @@ -453,6 +578,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp setPhase('input'); setProgress({ phase: 'queued', percent: 0, message: t('common:analyzePhases.queued') }); setUploading(false); + setReadingCount(null); setUploadSummary(null); }; @@ -658,9 +784,19 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp )} - {/* Local folder input */} + {/* Local folder input. The whole panel is the drop target for a folder: + a drop that lands on the path input or the label is caught too, and + the browser's default (navigating to the dropped file) never fires. */} {showInput && mode === 'local' && ( -
+