Merge branch 'main' into dependabot/npm_and_yarn/gitnexus-web/wait-on-9.0.10

This commit is contained in:
Gergő Magyar 2026-07-09 06:51:56 +01:00 • committed by GitHub
commit 2348760bb1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 854 additions and 265 deletions

View file

@ -561,7 +561,7 @@ jobs:
NODE
- name: Attest build provenance (SLSA)
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: 'gitnexus/vendor/tree-sitter-*/prebuilds/**/*.node'

View file

@ -190,7 +190,7 @@ jobs:
# its TOTAL drives this list and the /N in the job name + --shard arg.
shard: ${{ fromJSON(needs.shard-plan.outputs.shards) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20
timeout-minutes: 25
# Same guarantee on the platform-sensitive runners: FTS-dependent suites in
# the cross-platform subset must run, not silently skip.
#

View file

@ -256,7 +256,7 @@ jobs:
# pulling from either GHCR or Docker Hub see the same provenance.
- name: Generate build provenance attestation (GHCR)
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: ghcr.io/${{ github.repository_owner }}/${{ matrix.image.slug }}
subject-digest: ${{ steps.build.outputs.digest }}
@ -264,7 +264,7 @@ jobs:
- name: Generate build provenance attestation (Docker Hub)
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: docker.io/akonlabs/${{ matrix.image.slug }}
subject-digest: ${{ steps.build.outputs.digest }}

View file

@ -108,7 +108,7 @@ jobs:
# Pinned to v7.2.0. Verify SHA via:
# gh api repos/release-drafter/release-drafter/git/refs/tags/v7.2.0
# v7 removed `disable-releaser`; use `dry-run: true` to only autolabel.
- uses: release-drafter/release-drafter@ed4bc48ec97379be2258e7b7ac2624a3e26ab809 # v7.4.0
- uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
with:
config-name: release-drafter.yml
dry-run: true

View file

@ -53,7 +53,7 @@ jobs:
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Build image (load locally for scan)
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ${{ matrix.image.dockerfile }}

View file

@ -40,7 +40,7 @@ jobs:
# The action wraps the upstream `rhysd/actionlint` binary and emits
# GitHub-annotation-formatted findings on PRs.
- name: Run actionlint
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2.1.2
uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0
with:
fail-on-error: true

View file

@ -15,7 +15,7 @@
"@langchain/ollama": "^1.2.7",
"@langchain/openai": "^1.5.3",
"@sigma/edge-curve": "^3.1.0",
"@tailwindcss/vite": "^4.3.0",
"@tailwindcss/vite": "^4.3.2",
"axios": "^1.16.1",
"d3": "^7.9.0",
"dompurify": "^3.4.11",
@ -43,7 +43,7 @@
"remark-gfm": "^4.0.1",
"sigma": "^3.0.3",
"tailwindcss": "^4.2.4",
"uuid": "^14.0.0",
"uuid": "^14.0.1",
"zod": "^4.4.3"
},
"devDependencies": {
@ -1936,47 +1936,47 @@
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
"integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
"integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.21.0",
"jiti": "^2.6.1",
"enhanced-resolve": "5.21.6",
"jiti": "^2.7.0",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.3.0"
"tailwindcss": "4.3.2"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz",
"integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz",
"integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==",
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.3.0",
"@tailwindcss/oxide-darwin-arm64": "4.3.0",
"@tailwindcss/oxide-darwin-x64": "4.3.0",
"@tailwindcss/oxide-freebsd-x64": "4.3.0",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0",
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.0",
"@tailwindcss/oxide-linux-arm64-musl": "4.3.0",
"@tailwindcss/oxide-linux-x64-gnu": "4.3.0",
"@tailwindcss/oxide-linux-x64-musl": "4.3.0",
"@tailwindcss/oxide-wasm32-wasi": "4.3.0",
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.0",
"@tailwindcss/oxide-win32-x64-msvc": "4.3.0"
"@tailwindcss/oxide-android-arm64": "4.3.2",
"@tailwindcss/oxide-darwin-arm64": "4.3.2",
"@tailwindcss/oxide-darwin-x64": "4.3.2",
"@tailwindcss/oxide-freebsd-x64": "4.3.2",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2",
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.2",
"@tailwindcss/oxide-linux-arm64-musl": "4.3.2",
"@tailwindcss/oxide-linux-x64-gnu": "4.3.2",
"@tailwindcss/oxide-linux-x64-musl": "4.3.2",
"@tailwindcss/oxide-wasm32-wasi": "4.3.2",
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.2",
"@tailwindcss/oxide-win32-x64-msvc": "4.3.2"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz",
"integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz",
"integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==",
"cpu": [
"arm64"
],
@ -1990,9 +1990,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz",
"integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz",
"integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==",
"cpu": [
"arm64"
],
@ -2006,9 +2006,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz",
"integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz",
"integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==",
"cpu": [
"x64"
],
@ -2022,9 +2022,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz",
"integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz",
"integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==",
"cpu": [
"x64"
],
@ -2038,9 +2038,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz",
"integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz",
"integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==",
"cpu": [
"arm"
],
@ -2054,12 +2054,15 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz",
"integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz",
"integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2070,12 +2073,15 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz",
"integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz",
"integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2086,12 +2092,15 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz",
"integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz",
"integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2102,12 +2111,15 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz",
"integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz",
"integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2118,9 +2130,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz",
"integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz",
"integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@ -2135,11 +2147,11 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.10.0",
"@emnapi/runtime": "^1.10.0",
"@emnapi/wasi-threads": "^1.2.1",
"@emnapi/core": "^1.11.1",
"@emnapi/runtime": "^1.11.1",
"@emnapi/wasi-threads": "^1.2.2",
"@napi-rs/wasm-runtime": "^1.1.4",
"@tybys/wasm-util": "^0.10.1",
"@tybys/wasm-util": "^0.10.2",
"tslib": "^2.8.1"
},
"engines": {
@ -2147,17 +2159,17 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.10.0",
"version": "1.11.1",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.10.0",
"version": "1.11.1",
"inBundle": true,
"license": "MIT",
"optional": true,
@ -2166,7 +2178,7 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"version": "1.2.2",
"inBundle": true,
"license": "MIT",
"optional": true,
@ -2192,7 +2204,7 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"version": "0.10.2",
"inBundle": true,
"license": "MIT",
"optional": true,
@ -2207,9 +2219,9 @@
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
"integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
"integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==",
"cpu": [
"arm64"
],
@ -2223,9 +2235,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz",
"integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz",
"integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==",
"cpu": [
"x64"
],
@ -2239,14 +2251,14 @@
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz",
"integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz",
"integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==",
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.3.0",
"@tailwindcss/oxide": "4.3.0",
"tailwindcss": "4.3.0"
"@tailwindcss/node": "4.3.2",
"@tailwindcss/oxide": "4.3.2",
"tailwindcss": "4.3.2"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7 || ^8"
@ -5465,9 +5477,9 @@
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
@ -8262,9 +8274,9 @@
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
"integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==",
"license": "MIT"
},
"node_modules/tapable": {
@ -8673,9 +8685,9 @@
}
},
"node_modules/uuid": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
"integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"

View file

@ -25,7 +25,7 @@
"@langchain/ollama": "^1.2.7",
"@langchain/openai": "^1.5.3",
"@sigma/edge-curve": "^3.1.0",
"@tailwindcss/vite": "^4.3.0",
"@tailwindcss/vite": "^4.3.2",
"axios": "^1.16.1",
"d3": "^7.9.0",
"dompurify": "^3.4.11",
@ -53,7 +53,7 @@
"remark-gfm": "^4.0.1",
"sigma": "^3.0.3",
"tailwindcss": "^4.2.4",
"uuid": "^14.0.0",
"uuid": "^14.0.1",
"zod": "^4.4.3"
},
"devDependencies": {

View file

@ -163,6 +163,16 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
The result is a **LadybugDB graph database** stored locally in `.gitnexus/` with full-text search and semantic embeddings.
### Experimental community detection engine
Community detection uses the bundled Graphology Leiden implementation by default. To test the #2337 Icebug migration path without changing default analyze behavior, set:
```bash
GITNEXUS_COMMUNITY_ENGINE=icebug npx gitnexus analyze
```
Supported values are `graphology`, `icebug`, and `auto`. The Icebug path is an experimental probe: GitNexus does not bundle an Icebug native package yet, and if a separately resolvable module is unavailable or its API does not match the expected `Graph.fromCSR` / `ParallelLeidenView` shape, analyze falls back to Graphology and reports the fallback in progress output. Today `auto` is behaviorally identical to `icebug`: both try Icebug and fall back to Graphology, while `graphology` skips the Icebug probe entirely.
## MCP Tools
Your AI agent gets **17 tools** (15 per-repo + 2 group) automatically:
@ -450,10 +460,11 @@ Configure the behavior with these environment variables:
| Variable | Values | Default | Effect |
| -------------------------------------------- | ---------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GITNEXUS_LBUG_EXTENSION_INSTALL` | `auto`, `load-only`, `never` | `auto` | `auto` runs one bounded install if LOAD fails — a plain `INSTALL`, escalating to `FORCE INSTALL` only when the LOAD error shows the present extension file is broken. `load-only` only uses already-installed extensions (recommended for offline / firewalled environments). `never` skips optional extensions entirely. |
| `GITNEXUS_LBUG_EXTENSION_INSTALL` | `auto`, `load-only`, `never` | `auto` | `auto` runs one bounded install if LOAD fails — a plain `INSTALL`, escalating to `FORCE INSTALL` only when the LOAD error shows the present extension file is broken. `load-only` only uses already-installed extensions (recommended for offline / firewalled environments). `never` skips optional extensions entirely. |
| `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process extension-install child before it is killed. |
| `GITNEXUS_FTS_STEMMER` | supported LadybugDB 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` when that better matches repository comments and identifiers. Re-run `gitnexus analyze --repair-fts` after changing it. |
| `GITNEXUS_FTS_CJK_SEGMENTATION` | `none`, `bigram` | `none` | `bigram` inserts overlapping character-bigram boundaries into Chinese/Japanese Han-ideograph spans in `content`/`description` before FTS indexing, so LadybugDB's space-only tokenizer can see sub-phrase word boundaries. Scoped to CJK Unified Ideographs only — Japanese Hiragana/Katakana and Korean Hangul are not currently segmented. Unlike `GITNEXUS_FTS_STEMMER`, this rewrites stored text — enabling it on an already-indexed repo requires a full `gitnexus analyze --force`; neither `--repair-fts` nor a plain incremental `analyze` applies it to previously-indexed files. Set the same value wherever `analyze` and search-serving processes (CLI query, MCP server, web server) run. |
| `GITNEXUS_COMMUNITY_ENGINE` | `graphology`, `icebug`, `auto` | `graphology` | Community-detection engine used during analyze. `graphology` uses the bundled default path. `icebug` and `auto` currently behave identically: both try the experimental Icebug CSR path and fall back to Graphology if the optional native module is unavailable or incompatible. |
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. |
```bash

View file

@ -46,12 +46,21 @@ try {
process.exit(1);
}
// Per-shard watchdog, 15 min. Sharding splits the file list by COUNT, not
// Per-shard watchdog, default 15 min. Sharding splits the file list by COUNT, not
// runtime, so the heaviest spawn suites can cluster on one shard — what this
// bounds is the *busiest* shard, not an even 1/n of wall-clock. With 3 shards
// even that shard clears the watchdog, where the whole unsharded Windows run
// used to trip it.
const TIMEOUT_MIN = 15;
// used to trip it. Allow CI/manual runs to add headroom without editing the
// script again.
const DEFAULT_TIMEOUT_MIN = 15;
const timeoutMinutes = Number.parseInt(
process.env.GITNEXUS_CROSS_PLATFORM_TIMEOUT_MINUTES ?? String(DEFAULT_TIMEOUT_MIN),
10,
);
const timeoutMs =
Number.isFinite(timeoutMinutes) && timeoutMinutes > 0
? timeoutMinutes * 60 * 1000
: DEFAULT_TIMEOUT_MIN * 60 * 1000;
console.log(
`Running ${ALL_CROSS_PLATFORM.length} platform-sensitive tests` +
@ -62,14 +71,14 @@ try {
execFileSync('npx', ['vitest', 'run', ...ALL_CROSS_PLATFORM, ...(shardArg ? [shardArg] : [])], {
cwd: ROOT,
stdio: 'inherit',
timeout: TIMEOUT_MIN * 60 * 1000,
timeout: timeoutMs,
shell: true,
});
} catch (err) {
// execFileSync sets `killed`/`signal` when the watchdog above kills vitest.
const e = err as { killed?: boolean; signal?: NodeJS.Signals | null };
if (e.killed || e.signal) {
console.error(`vitest timed out after ${TIMEOUT_MIN} minutes`);
console.error(`vitest timed out after ${Math.round(timeoutMs / 60_000)} minutes`);
}
process.exit(1);
}

View file

@ -16,7 +16,8 @@ import type { AbstractGraph, Attributes } from 'graphology-types';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import type { NodeLabel } from 'gitnexus-shared';
import { Worker } from 'node:worker_threads';
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
import { KnowledgeGraph } from '../graph/types.js';
const __filename = fileURLToPath(import.meta.url);
@ -41,6 +42,61 @@ interface LeidenDetailedResult {
modularity: number;
}
type CommunityEngine = 'graphology' | 'icebug';
export type CommunityDetectionEngine = CommunityEngine | 'auto';
export interface CommunityDetectionOptions {
/**
* Graphology remains the default. `icebug`/`auto` are guarded prototype
* paths for #2337 and fall back to Graphology if the optional native module
* is not available or does not expose the expected API.
*/
engine?: CommunityDetectionEngine;
icebug?: {
threads?: number;
seed?: number;
iterations?: number;
gamma?: number;
randomize?: boolean;
};
}
export interface CommunityProjectionNode {
id: string;
name: unknown;
filePath: unknown;
type: NodeLabel;
}
export interface CommunityProjection {
nodes: CommunityProjectionNode[];
edges: Array<readonly [number, number]>;
symbolCount: number;
isLarge: boolean;
}
export interface CommunityCsr {
indptr: BigUint64Array;
indices: BigUint64Array;
}
interface CommunityEngineResult extends LeidenDetailedResult {
engine: CommunityEngine;
engineRequested: CommunityDetectionEngine;
fallbackReason?: string;
}
interface IcebugWorkerSuccess {
ok: true;
partition: number[];
modularity: number;
}
interface IcebugWorkerFailure {
ok: false;
error: string;
}
/**
* Deterministic PRNG (mulberry32) seed for the vendored Leiden algorithm.
* Vendored Leiden defaults `rng: Math.random`, which makes community
@ -59,6 +115,25 @@ function createSeededRng(seed: number): () => number {
};
}
const COMMUNITY_ENGINE_ENV = 'GITNEXUS_COMMUNITY_ENGINE';
const DEFAULT_COMMUNITY_ENGINE: CommunityEngine = 'graphology';
const LEIDEN_TIMEOUT_MS = 60_000;
const ICEBUG_TIMEOUT_MS = 60_000;
const MIN_CONFIDENCE_LARGE = 0.5;
export const resolveCommunityDetectionEngine = (
raw = process.env[COMMUNITY_ENGINE_ENV],
): CommunityDetectionEngine => {
if (raw === undefined || raw.trim() === '') return DEFAULT_COMMUNITY_ENGINE;
const normalized = raw.trim().toLowerCase();
if (normalized === 'graphology' || normalized === 'icebug' || normalized === 'auto') {
return normalized;
}
return DEFAULT_COMMUNITY_ENGINE;
};
// ============================================================================
// TYPES
// ============================================================================
@ -83,6 +158,9 @@ export interface CommunityDetectionResult {
totalCommunities: number;
modularity: number;
nodesProcessed: number;
engine?: CommunityEngine;
engineRequested?: CommunityDetectionEngine;
fallbackReason?: string;
};
}
@ -122,30 +200,25 @@ export const getCommunityColor = (communityIndex: number): string => {
export const processCommunities = async (
knowledgeGraph: KnowledgeGraph,
onProgress?: (message: string, progress: number) => void,
options: CommunityDetectionOptions = {},
): Promise<CommunityDetectionResult> => {
onProgress?.('Building graph for community detection...', 0);
// Pre-check total symbol count to determine large-graph mode before building
let symbolCount = 0;
knowledgeGraph.forEachNode((node) => {
if (
node.label === 'Function' ||
node.label === 'Class' ||
node.label === 'Method' ||
node.label === 'Interface'
) {
symbolCount++;
}
});
const isLarge = symbolCount > 10_000;
const graph = buildGraphologyGraph(knowledgeGraph, isLarge);
const engineRequested = options.engine ?? resolveCommunityDetectionEngine();
const projection = buildCommunityProjection(knowledgeGraph);
const graph = buildGraphologyGraph(projection);
if (graph.order === 0) {
return {
communities: [],
memberships: [],
stats: { totalCommunities: 0, modularity: 0, nodesProcessed: 0 },
stats: {
totalCommunities: 0,
modularity: 0,
nodesProcessed: 0,
engine: DEFAULT_COMMUNITY_ENGINE,
engineRequested,
},
};
}
@ -153,41 +226,11 @@ export const processCommunities = async (
const edgeCount = graph.size;
onProgress?.(
`Running Leiden on ${nodeCount} nodes, ${edgeCount} edges${isLarge ? ` (filtered from ${symbolCount} symbols)` : ''}...`,
`Running Leiden on ${nodeCount} nodes, ${edgeCount} edges${projection.isLarge ? ` (filtered from ${projection.symbolCount} symbols)` : ''}...`,
30,
);
// Large graphs: higher resolution + capped iterations (matching Python leidenalg default of 2).
// The first 2 iterations capture ~95%+ of modularity; additional iterations have diminishing returns.
// Timeout: abort after 60s for pathological graph structures.
const LEIDEN_TIMEOUT_MS = 60_000;
let details: LeidenDetailedResult;
try {
details = await Promise.race([
Promise.resolve(
leiden.detailed(graph, {
resolution: isLarge ? 2.0 : 1.0,
maxIterations: isLarge ? 3 : 0,
rng: createSeededRng(LEIDEN_SEED),
}),
),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Leiden timeout')), LEIDEN_TIMEOUT_MS),
),
]);
} catch (e: any) {
if (e.message === 'Leiden timeout') {
onProgress?.('Community detection timed out, using fallback...', 60);
// Fallback: assign all nodes to community 0
const communities: Record<string, number> = {};
graph.forEachNode((node: string) => {
communities[node] = 0;
});
details = { communities, count: 1, modularity: 0 };
} else {
throw e;
}
}
const details = await runCommunityEngine(graph, projection, engineRequested, options, onProgress);
onProgress?.(`Found ${details.count} communities...`, 60);
@ -219,35 +262,36 @@ export const processCommunities = async (
totalCommunities: details.count,
modularity: details.modularity,
nodesProcessed: graph.order,
engine: details.engine,
engineRequested: details.engineRequested,
fallbackReason: details.fallbackReason,
},
};
};
// ============================================================================
// HELPER: Build graphology graph from knowledge graph
// HELPER: Build community projection from knowledge graph
// ============================================================================
/**
* Build a graphology graph containing only symbol nodes and clustering edges.
* Build a community projection containing only symbol nodes and clustering edges.
* For large graphs (>10K symbols), filter out low-confidence fuzzy-global edges
* and degree-1 nodes that add noise and massively increase Leiden runtime.
*/
const MIN_CONFIDENCE_LARGE = 0.5;
export const buildCommunityProjection = (knowledgeGraph: KnowledgeGraph): CommunityProjection => {
let symbolCount = 0;
knowledgeGraph.forEachNode((node) => {
if (isCommunitySymbol(node)) {
symbolCount++;
}
});
const isLarge = symbolCount > 10_000;
const buildGraphologyGraph = (knowledgeGraph: KnowledgeGraph, isLarge: boolean): GraphInstance => {
const GraphCtor = Graph as unknown as new (options: {
type: string;
allowSelfLoops: boolean;
}) => GraphInstance;
const graph = new GraphCtor({ type: 'undirected', allowSelfLoops: false });
const symbolTypes = new Set<NodeLabel>(['Function', 'Class', 'Method', 'Interface']);
const clusteringRelTypes = new Set(['CALLS', 'EXTENDS', 'IMPLEMENTS']);
const connectedNodes = new Set<string>();
const nodeDegree = new Map<string, number>();
knowledgeGraph.forEachRelationship((rel) => {
if (!clusteringRelTypes.has(rel.type) || rel.sourceId === rel.targetId) return;
if (!isClusteringRelationship(rel.type) || rel.sourceId === rel.targetId) return;
if (isLarge && rel.confidence < MIN_CONFIDENCE_LARGE) return;
connectedNodes.add(rel.sourceId);
@ -256,36 +300,363 @@ const buildGraphologyGraph = (knowledgeGraph: KnowledgeGraph, isLarge: boolean):
nodeDegree.set(rel.targetId, (nodeDegree.get(rel.targetId) || 0) + 1);
});
const nodes: CommunityProjectionNode[] = [];
const nodeIndexById = new Map<string, number>();
knowledgeGraph.forEachNode((node) => {
if (!symbolTypes.has(node.label) || !connectedNodes.has(node.id)) return;
if (!isCommunitySymbol(node) || !connectedNodes.has(node.id)) return;
// For large graphs, skip degree-1 nodes — they just become singletons or
// get absorbed into their single neighbor's community, but cost iteration time.
if (isLarge && (nodeDegree.get(node.id) || 0) < 2) return;
graph.addNode(node.id, {
nodeIndexById.set(node.id, nodes.length);
nodes.push({
id: node.id,
name: node.properties.name,
filePath: node.properties.filePath,
type: node.label,
});
});
const seenEdges = new Set<string>();
const edges: Array<readonly [number, number]> = [];
knowledgeGraph.forEachRelationship((rel) => {
if (!clusteringRelTypes.has(rel.type)) return;
if (!isClusteringRelationship(rel.type) || rel.sourceId === rel.targetId) return;
if (isLarge && rel.confidence < MIN_CONFIDENCE_LARGE) return;
if (
graph.hasNode(rel.sourceId) &&
graph.hasNode(rel.targetId) &&
rel.sourceId !== rel.targetId
) {
if (!graph.hasEdge(rel.sourceId, rel.targetId)) {
graph.addEdge(rel.sourceId, rel.targetId);
}
}
const sourceIndex = nodeIndexById.get(rel.sourceId);
const targetIndex = nodeIndexById.get(rel.targetId);
if (sourceIndex === undefined || targetIndex === undefined || sourceIndex === targetIndex)
return;
const [a, b] =
sourceIndex < targetIndex ? [sourceIndex, targetIndex] : [targetIndex, sourceIndex];
const edgeKey = `${a}:${b}`;
if (seenEdges.has(edgeKey)) return;
seenEdges.add(edgeKey);
edges.push([a, b]);
});
return { nodes, edges, symbolCount, isLarge };
};
export const buildCommunityCsr = (projection: CommunityProjection): CommunityCsr => {
const adjacency = Array.from({ length: projection.nodes.length }, () => new Set<number>());
for (const [sourceIndex, targetIndex] of projection.edges) {
adjacency[sourceIndex].add(targetIndex);
adjacency[targetIndex].add(sourceIndex);
}
const edgeTraversalCount = adjacency.reduce((count, neighbors) => count + neighbors.size, 0);
const indptr = new BigUint64Array(projection.nodes.length + 1);
const indices = new BigUint64Array(edgeTraversalCount);
let cursor = 0;
for (let nodeIndex = 0; nodeIndex < adjacency.length; nodeIndex++) {
indptr[nodeIndex] = BigInt(cursor);
const neighbors = [...adjacency[nodeIndex]].sort((a, b) => a - b);
for (const neighbor of neighbors) {
indices[cursor++] = BigInt(neighbor);
}
}
indptr[projection.nodes.length] = BigInt(cursor);
return { indptr, indices };
};
export const buildGraphologyGraph = (projection: CommunityProjection): GraphInstance => {
const GraphCtor = Graph as unknown as new (options: {
type: string;
allowSelfLoops: boolean;
}) => GraphInstance;
const graph = new GraphCtor({ type: 'undirected', allowSelfLoops: false });
for (const node of projection.nodes) {
graph.addNode(node.id, {
name: node.name,
filePath: node.filePath,
type: node.type,
});
}
for (const [sourceIndex, targetIndex] of projection.edges) {
graph.addEdge(projection.nodes[sourceIndex].id, projection.nodes[targetIndex].id);
}
return graph;
};
const isCommunitySymbol = (node: GraphNode): boolean =>
node.label === 'Function' ||
node.label === 'Class' ||
node.label === 'Method' ||
node.label === 'Interface';
const isClusteringRelationship = (type: string): boolean =>
type === 'CALLS' || type === 'EXTENDS' || type === 'IMPLEMENTS';
const runCommunityEngine = async (
graph: GraphInstance,
projection: CommunityProjection,
engineRequested: CommunityDetectionEngine,
options: CommunityDetectionOptions,
onProgress?: (message: string, progress: number) => void,
): Promise<CommunityEngineResult> => {
if (engineRequested === 'graphology') {
return runGraphologyLeiden(graph, projection.isLarge, engineRequested);
}
try {
return await runIcebugLeiden(projection, engineRequested, options);
} catch (error) {
const fallbackReason = error instanceof Error ? error.message : String(error);
onProgress?.(
`Icebug community engine unavailable, falling back to Graphology: ${fallbackReason}`,
35,
);
const fallback = await runGraphologyLeiden(graph, projection.isLarge, engineRequested);
return { ...fallback, fallbackReason };
}
};
const runGraphologyLeiden = async (
graph: GraphInstance,
isLarge: boolean,
engineRequested: CommunityDetectionEngine,
): Promise<CommunityEngineResult> => {
try {
const details = await Promise.race([
Promise.resolve(
leiden.detailed(graph, {
resolution: isLarge ? 2.0 : 1.0,
maxIterations: isLarge ? 3 : 0,
rng: createSeededRng(LEIDEN_SEED),
}),
),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Leiden timeout')), LEIDEN_TIMEOUT_MS),
),
]);
return { ...details, engine: 'graphology', engineRequested };
} catch (e: any) {
if (e.message !== 'Leiden timeout') {
throw e;
}
// Fallback: assign all nodes to community 0
const communities: Record<string, number> = {};
graph.forEachNode((node: string) => {
communities[node] = 0;
});
return {
communities,
count: 1,
modularity: 0,
engine: 'graphology',
engineRequested,
fallbackReason: 'Graphology Leiden timeout',
};
}
};
const runIcebugLeiden = async (
projection: CommunityProjection,
engineRequested: CommunityDetectionEngine,
options: CommunityDetectionOptions,
): Promise<CommunityEngineResult> => {
const csr = buildCommunityCsr(projection);
const nativeResult = await runIcebugWorker(projection.nodes.length, csr, options);
const partition = nativeResult.partition;
if (!Number.isFinite(nativeResult.modularity)) {
throw new Error('optional icebug modularity was not finite');
}
if (
partition.length !== projection.nodes.length ||
partition.some((community) => !Number.isSafeInteger(community))
) {
throw new Error(
`optional icebug partition was malformed for ${projection.nodes.length} projected nodes`,
);
}
const communities = normalizePartition(projection, partition);
return {
communities,
count: new Set(Object.values(communities)).size,
modularity: nativeResult.modularity,
engine: 'icebug',
engineRequested,
};
};
const runIcebugWorker = (
nodeCount: number,
csr: CommunityCsr,
options: CommunityDetectionOptions,
): Promise<IcebugWorkerSuccess> => {
const threads = options.icebug?.threads ?? 1;
if (!Number.isSafeInteger(threads) || threads !== 1) {
throw new Error('optional icebug engine currently requires deterministic threads=1');
}
if (options.icebug?.randomize === true) {
throw new Error('optional icebug engine currently requires randomize=false');
}
const worker = new Worker(ICEBUG_WORKER_SOURCE, {
eval: true,
workerData: {
nodeCount,
indices: csr.indices,
indptr: csr.indptr,
threads,
seed: options.icebug?.seed ?? LEIDEN_SEED,
iterations: options.icebug?.iterations ?? 4,
gamma: options.icebug?.gamma ?? 1.0,
randomize: options.icebug?.randomize ?? false,
},
});
return new Promise((resolve, reject) => {
let settled = false;
const timeout = setTimeout(() => {
settled = true;
void worker.terminate();
reject(new Error(`optional icebug community engine timed out after ${ICEBUG_TIMEOUT_MS}ms`));
}, ICEBUG_TIMEOUT_MS);
worker.once('message', (message: IcebugWorkerSuccess | IcebugWorkerFailure) => {
settled = true;
clearTimeout(timeout);
void worker.terminate();
if (message.ok === true) {
resolve(message);
} else {
reject(new Error(message.error));
}
});
worker.once('error', (error) => {
settled = true;
clearTimeout(timeout);
void worker.terminate();
reject(error);
});
worker.once('exit', (code) => {
if (settled) return;
clearTimeout(timeout);
if (code === 0) {
reject(new Error('optional icebug worker exited before returning a partition'));
return;
}
reject(new Error(`optional icebug worker exited with code ${code}`));
});
});
};
const ICEBUG_WORKER_SOURCE = `
const { parentPort, workerData } = require('node:worker_threads');
const isNumericArrayLike = (value) =>
typeof value === 'object' &&
value !== null &&
'length' in value &&
typeof value.length === 'number';
const readPartition = (runner) => {
const candidates = [
typeof runner.getPartition === 'function' ? runner.getPartition() : runner.partition,
typeof runner.getCommunities === 'function' ? runner.getCommunities() : undefined,
typeof runner.getMembership === 'function' ? runner.getMembership() : undefined,
typeof runner.getMemberships === 'function' ? runner.getMemberships() : undefined,
];
for (const candidate of candidates) {
if (isNumericArrayLike(candidate)) {
return Array.from(candidate, Number);
}
}
throw new Error('optional icebug ParallelLeidenView did not expose a partition array');
};
const readModularity = (runner) => {
if (typeof runner.getModularity === 'function') return runner.getModularity();
if (typeof runner.modularity === 'function') return runner.modularity();
if (typeof runner.modularity === 'number') return runner.modularity;
return 0;
};
(async () => {
const imported = await import('icebug');
const icebug = imported.default ?? imported;
const fromCSR = icebug.Graph?.fromCSR;
const ParallelLeidenView = icebug.community?.ParallelLeidenView;
if (!fromCSR || !ParallelLeidenView) {
throw new Error('optional icebug module does not expose Graph.fromCSR/ParallelLeidenView');
}
if (typeof icebug.setNumberOfThreads !== 'function' || typeof icebug.setSeed !== 'function') {
throw new Error('optional icebug module does not expose deterministic thread/seed controls');
}
icebug.setNumberOfThreads(workerData.threads);
icebug.setSeed(workerData.seed, false);
const nativeGraph = fromCSR(workerData.nodeCount, false, workerData.indices, workerData.indptr);
let runner;
try {
runner = new ParallelLeidenView(nativeGraph, {
iterations: workerData.iterations,
gamma: workerData.gamma,
randomize: workerData.randomize,
});
} catch {
runner = new ParallelLeidenView(
nativeGraph,
workerData.iterations,
workerData.gamma,
workerData.randomize,
);
}
if (typeof runner.run !== 'function') {
throw new Error('optional icebug ParallelLeidenView does not expose run()');
}
runner.run();
parentPort.postMessage({
ok: true,
partition: readPartition(runner),
modularity: readModularity(runner),
});
})().catch((error) => {
parentPort.postMessage({ ok: false, error: error instanceof Error ? error.message : String(error) });
});
`;
const normalizePartition = (
projection: CommunityProjection,
partition: ArrayLike<number>,
): Record<string, number> => {
const remap = new Map<string, number>();
const communities: Record<string, number> = {};
for (let index = 0; index < projection.nodes.length; index++) {
const rawCommunity = String(partition[index]);
let communityId = remap.get(rawCommunity);
if (communityId === undefined) {
communityId = remap.size;
remap.set(rawCommunity, communityId);
}
communities[projection.nodes[index].id] = communityId;
}
return communities;
};
// ============================================================================
// HELPER: Create community nodes with heuristic labels
// ============================================================================

View file

@ -302,6 +302,10 @@ describe('unhappy path — extension missing entirely', () => {
describe('self-heal over the network — FORCE INSTALL replaces a broken file (auto)', () => {
beforeEach((ctx) => {
// The platform matrix already exercises offline FTS load/diagnostic paths
// against real macOS/Windows binaries. Keep network redownload coverage on
// Ubuntu, where the full test job has the most stable extension fetch path.
if (process.platform !== 'linux') ctx.skip();
if (!networkAvailable) ctx.skip();
});

View file

@ -126,107 +126,114 @@ const DEADLOCK_TIMEOUT_MS = 60_000;
// three platforms, matching its LBUG_NATIVE registration in
// cross-platform-tests.ts and vitest.config.ts.
describe('concurrent multi-connection writes do not deadlock (#2338, LadybugDB #605)', () => {
it(
'writer + reader connections on one Database complete without deadlock, forcing a real checkpoint-vs-reader race',
async () => {
const tmp = await createTempDir('gitnexus-lbug-multiwriter-');
const dbPath = path.join(tmp.dbPath, 'lbug');
const previousThreshold = process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD;
process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD = String(CHECKPOINT_THRESHOLD_BYTES);
// The native checkpoint/reader race is intentionally timing-sensitive; retry
// once to absorb transient LadybugDB native exceptions while still failing a
// persistent deadlock or correctness regression.
describe(
'concurrent multi-connection writes do not deadlock (#2338, LadybugDB #605)',
{ retry: 1 },
() => {
it(
'writer + reader connections on one Database complete without deadlock, forcing a real checkpoint-vs-reader race',
async () => {
const tmp = await createTempDir('gitnexus-lbug-multiwriter-');
const dbPath = path.join(tmp.dbPath, 'lbug');
const previousThreshold = process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD;
process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD = String(CHECKPOINT_THRESHOLD_BYTES);
let db: LbugDatabase | undefined;
let writers: LbugConnection[] = [];
let readers: LbugConnection[] = [];
let timeoutHandle: NodeJS.Timeout | undefined;
let shadowWatcher: NodeJS.Timeout | undefined;
let db: LbugDatabase | undefined;
let writers: LbugConnection[] = [];
let readers: LbugConnection[] = [];
let timeoutHandle: NodeJS.Timeout | undefined;
let shadowWatcher: NodeJS.Timeout | undefined;
try {
const lbug = (await import('@ladybugdb/core')).default;
try {
const lbug = (await import('@ladybugdb/core')).default;
db = createLbugDatabase(lbug, dbPath);
const dbHandle = db;
db = createLbugDatabase(lbug, dbPath);
const dbHandle = db;
const setupConn = new lbug.Connection(dbHandle);
const setupResult = await setupConn.query(
'CREATE NODE TABLE T(id INT64 PRIMARY KEY, val STRING)',
);
await closeQueryResults(setupResult);
await setupConn.close();
const setupConn = new lbug.Connection(dbHandle);
const setupResult = await setupConn.query(
'CREATE NODE TABLE T(id INT64 PRIMARY KEY, val STRING)',
);
await closeQueryResults(setupResult);
await setupConn.close();
const shadowPath = `${dbPath}.shadow`;
let shadowSeen = false;
shadowWatcher = setInterval(() => {
if (fs.existsSync(shadowPath)) shadowSeen = true;
}, 5);
const shadowPath = `${dbPath}.shadow`;
let shadowSeen = false;
shadowWatcher = setInterval(() => {
if (fs.existsSync(shadowPath)) shadowSeen = true;
}, 5);
writers = Array.from({ length: WRITER_COUNT }, () => new lbug.Connection(dbHandle));
readers = Array.from({ length: READER_COUNT }, () => new lbug.Connection(dbHandle));
writers = Array.from({ length: WRITER_COUNT }, () => new lbug.Connection(dbHandle));
readers = Array.from({ length: READER_COUNT }, () => new lbug.Connection(dbHandle));
const writeLoops = writers.map((conn, writerIdx) =>
(async () => {
for (let i = 0; i < ROWS_PER_WRITER; i++) {
const id = writerIdx * ROWS_PER_WRITER + i;
await writeWithRetry(conn, `CREATE (:T {id: ${id}, val: '${'x'.repeat(200)}'})`);
}
})(),
);
const readLoops = readers.map((conn) =>
(async () => {
for (let i = 0; i < ROWS_PER_WRITER; i++) {
const res = await conn.query('MATCH (n:T) RETURN count(n) AS c');
await closeQueryResults(res);
}
})(),
);
const writeLoops = writers.map((conn, writerIdx) =>
(async () => {
for (let i = 0; i < ROWS_PER_WRITER; i++) {
const id = writerIdx * ROWS_PER_WRITER + i;
await writeWithRetry(conn, `CREATE (:T {id: ${id}, val: '${'x'.repeat(200)}'})`);
}
})(),
);
const readLoops = readers.map((conn) =>
(async () => {
for (let i = 0; i < ROWS_PER_WRITER; i++) {
const res = await conn.query('MATCH (n:T) RETURN count(n) AS c');
await closeQueryResults(res);
}
})(),
);
const raceResult = await Promise.race([
Promise.all([...writeLoops, ...readLoops]).then(() => 'completed' as const),
new Promise<'timeout'>((resolve) => {
timeoutHandle = setTimeout(() => resolve('timeout'), DEADLOCK_TIMEOUT_MS);
}),
]);
const raceResult = await Promise.race([
Promise.all([...writeLoops, ...readLoops]).then(() => 'completed' as const),
new Promise<'timeout'>((resolve) => {
timeoutHandle = setTimeout(() => resolve('timeout'), DEADLOCK_TIMEOUT_MS);
}),
]);
expect(
raceResult,
`deadlock suspected — concurrent writers/readers did not complete within ${DEADLOCK_TIMEOUT_MS}ms`,
).toBe('completed');
expect(
raceResult,
`deadlock suspected — concurrent writers/readers did not complete within ${DEADLOCK_TIMEOUT_MS}ms`,
).toBe('completed');
// The interleaving #605 fixes is checkpoint-vs-concurrent-transaction;
// if a checkpoint never actually raced a reader, this test could pass
// without ever exercising that race.
expect(
shadowSeen,
'expected a .shadow checkpoint sidecar to appear during the run — the checkpoint/reader race this test targets was never entered',
).toBe(true);
// The interleaving #605 fixes is checkpoint-vs-concurrent-transaction;
// if a checkpoint never actually raced a reader, this test could pass
// without ever exercising that race.
expect(
shadowSeen,
'expected a .shadow checkpoint sidecar to appear during the run — the checkpoint/reader race this test targets was never entered',
).toBe(true);
const verifyConn = new lbug.Connection(db);
readers.push(verifyConn); // closed by the outer finally even if the query below throws
const countRes = await verifyConn.query('MATCH (n:T) RETURN count(n) AS c');
// `query()` types as QueryResult | QueryResult[] (array only for
// multi-statement scripts); this is a single statement, so narrow to
// the single-result case rather than calling `.getAll()` on a type
// that doesn't declare it.
const singleCountRes = Array.isArray(countRes) ? countRes[0] : countRes;
const rows = await singleCountRes.getAll();
await closeQueryResults(countRes);
const verifyConn = new lbug.Connection(db);
readers.push(verifyConn); // closed by the outer finally even if the query below throws
const countRes = await verifyConn.query('MATCH (n:T) RETURN count(n) AS c');
// `query()` types as QueryResult | QueryResult[] (array only for
// multi-statement scripts); this is a single statement, so narrow to
// the single-result case rather than calling `.getAll()` on a type
// that doesn't declare it.
const singleCountRes = Array.isArray(countRes) ? countRes[0] : countRes;
const rows = await singleCountRes.getAll();
await closeQueryResults(countRes);
expect(rows[0].c).toBe(WRITER_COUNT * ROWS_PER_WRITER);
} finally {
clearTimeout(timeoutHandle);
clearInterval(shadowWatcher);
for (const conn of [...writers, ...readers]) {
await conn.close().catch(() => {});
expect(rows[0].c).toBe(WRITER_COUNT * ROWS_PER_WRITER);
} finally {
clearTimeout(timeoutHandle);
clearInterval(shadowWatcher);
for (const conn of [...writers, ...readers]) {
await conn.close().catch(() => {});
}
await db?.close().catch(() => {});
if (previousThreshold === undefined) {
delete process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD;
} else {
process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD = previousThreshold;
}
await tmp.cleanup();
}
await db?.close().catch(() => {});
if (previousThreshold === undefined) {
delete process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD;
} else {
process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD = previousThreshold;
}
await tmp.cleanup();
}
},
DEADLOCK_TIMEOUT_MS + 10_000,
);
});
},
DEADLOCK_TIMEOUT_MS + 10_000,
);
},
);

View file

@ -1,9 +1,38 @@
import { describe, it, expect } from 'vitest';
import { EventEmitter } from 'node:events';
import { describe, it, expect, vi } from 'vitest';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import type { GraphNode, GraphRelationship } from '../../src/core/graph/types.js';
import {
getCommunityColor,
COMMUNITY_COLORS,
buildCommunityCsr,
buildCommunityProjection,
processCommunities,
resolveCommunityDetectionEngine,
} from '../../src/core/ingestion/community-processor.js';
function makeNode(
id: string,
name: string,
label: GraphNode['label'] = 'Function',
filePath = `/src/${name}.ts`,
): GraphNode {
return {
id,
label,
properties: { name, filePath, startLine: 1, endLine: 10, isExported: false },
};
}
function makeRel(
id: string,
sourceId: string,
targetId: string,
type: GraphRelationship['type'] = 'CALLS',
): GraphRelationship {
return { id, sourceId, targetId, type, confidence: 1.0, reason: '' };
}
describe('community-processor', () => {
describe('COMMUNITY_COLORS', () => {
it('has 12 colors', () => {
@ -38,4 +67,148 @@ describe('community-processor', () => {
expect(c0).not.toBe(c1);
});
});
describe('community engine selection', () => {
it('defaults unknown engine values to graphology', () => {
expect(resolveCommunityDetectionEngine(undefined)).toBe('graphology');
expect(resolveCommunityDetectionEngine('')).toBe('graphology');
expect(resolveCommunityDetectionEngine('native')).toBe('graphology');
});
it('accepts graphology, icebug, and auto engine values', () => {
expect(resolveCommunityDetectionEngine('graphology')).toBe('graphology');
expect(resolveCommunityDetectionEngine('icebug')).toBe('icebug');
expect(resolveCommunityDetectionEngine('auto')).toBe('auto');
expect(resolveCommunityDetectionEngine(' ICEBUG ')).toBe('icebug');
});
});
describe('community projection and CSR', () => {
it('projects only connected community symbols and deduplicates undirected edges', () => {
const graph = createKnowledgeGraph();
graph.addNode(makeNode('fn:a', 'a'));
graph.addNode(makeNode('fn:b', 'b', 'Method'));
graph.addNode(makeNode('file:a', 'file', 'File'));
graph.addNode(makeNode('fn:isolated', 'isolated'));
graph.addRelationship(makeRel('rel:ab', 'fn:a', 'fn:b'));
graph.addRelationship(makeRel('rel:ba', 'fn:b', 'fn:a'));
graph.addRelationship(makeRel('rel:file', 'fn:a', 'file:a'));
const projection = buildCommunityProjection(graph);
expect(projection.nodes.map((node) => node.id)).toEqual(['fn:a', 'fn:b']);
expect(projection.edges).toEqual([[0, 1]]);
expect(projection.symbolCount).toBe(3);
});
it('exports a deterministic undirected CSR adjacency', () => {
const projection = {
nodes: [
{ id: 'a', name: 'a', filePath: '/a.ts', type: 'Function' as const },
{ id: 'b', name: 'b', filePath: '/b.ts', type: 'Function' as const },
{ id: 'c', name: 'c', filePath: '/c.ts', type: 'Function' as const },
],
edges: [
[0, 2],
[0, 1],
] as Array<readonly [number, number]>,
symbolCount: 3,
isLarge: false,
};
const csr = buildCommunityCsr(projection);
expect([...csr.indptr].map(Number)).toEqual([0, 2, 3, 4]);
expect([...csr.indices].map(Number)).toEqual([1, 2, 0, 0]);
});
});
describe('processCommunities engine fallback', () => {
it('falls back to graphology when explicit icebug engine is unavailable', async () => {
const graph = createKnowledgeGraph();
graph.addNode(makeNode('fn:a', 'a', 'Function', '/src/group/a.ts'));
graph.addNode(makeNode('fn:b', 'b', 'Function', '/src/group/b.ts'));
graph.addRelationship(makeRel('rel:ab', 'fn:a', 'fn:b'));
const progress: string[] = [];
const result = await processCommunities(graph, (message) => progress.push(message), {
engine: 'icebug',
});
expect(result.stats.engineRequested).toBe('icebug');
expect(result.stats.engine).toBe('graphology');
expect(result.stats.fallbackReason).toBeTruthy();
expect(progress.some((message) => message.includes('falling back to Graphology'))).toBe(true);
expect(result.communities).toHaveLength(1);
expect(result.memberships).toHaveLength(2);
});
it('falls back to graphology when icebug returns invalid modularity', async () => {
vi.resetModules();
vi.doMock('node:worker_threads', () => {
class MockWorker extends EventEmitter {
constructor() {
super();
queueMicrotask(() => {
this.emit('message', { ok: true, partition: [0, 0], modularity: Number.NaN });
});
}
terminate(): Promise<number> {
return Promise.resolve(0);
}
}
return { Worker: MockWorker };
});
try {
const { processCommunities: processCommunitiesWithMockWorker } =
await import('../../src/core/ingestion/community-processor.js');
const graph = createKnowledgeGraph();
graph.addNode(makeNode('fn:a', 'a', 'Function', '/src/group/a.ts'));
graph.addNode(makeNode('fn:b', 'b', 'Function', '/src/group/b.ts'));
graph.addRelationship(makeRel('rel:ab', 'fn:a', 'fn:b'));
const progress: string[] = [];
const result = await processCommunitiesWithMockWorker(
graph,
(message) => progress.push(message),
{ engine: 'icebug' },
);
expect(result.stats.engineRequested).toBe('icebug');
expect(result.stats.engine).toBe('graphology');
expect(result.stats.fallbackReason).toContain('modularity');
expect(progress.some((message) => message.includes('falling back to Graphology'))).toBe(
true,
);
} finally {
vi.doUnmock('node:worker_threads');
vi.resetModules();
}
});
it('falls back before icebug worker launch for nondeterministic options', async () => {
const graph = createKnowledgeGraph();
graph.addNode(makeNode('fn:a', 'a', 'Function', '/src/group/a.ts'));
graph.addNode(makeNode('fn:b', 'b', 'Function', '/src/group/b.ts'));
graph.addRelationship(makeRel('rel:ab', 'fn:a', 'fn:b'));
const threadResult = await processCommunities(graph, undefined, {
engine: 'icebug',
icebug: { threads: 2 },
});
expect(threadResult.stats.engine).toBe('graphology');
expect(threadResult.stats.fallbackReason).toContain('threads=1');
const randomizeResult = await processCommunities(graph, undefined, {
engine: 'icebug',
icebug: { randomize: true },
});
expect(randomizeResult.stats.engine).toBe('graphology');
expect(randomizeResult.stats.fallbackReason).toContain('randomize=false');
});
});
});

View file

@ -681,7 +681,9 @@ describe('PreToolUse concurrency guard', () => {
// ─── Integration: concurrency guard skips when slots are full ──────
describe('PreToolUse concurrency guard (integration)', () => {
// The burst tests spawn real child processes; under CI load a child can exit
// before printing its decision even though the slot hard cap still holds.
describe('PreToolUse concurrency guard (integration)', { retry: 1 }, () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],