Compare commits
166
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53e464d72b | ||
|
|
ad1b9227c4 | ||
|
|
2ec00b8952 | ||
|
|
7316503ebc | ||
|
|
df0110b06f | ||
|
|
a500f70d6f | ||
|
|
1e764cd475 | ||
|
|
d3d4fa31bb | ||
|
|
450cebc268 | ||
|
|
4af6fe8587 | ||
|
|
e34967eed5 | ||
|
|
91b22676ce | ||
|
|
bd9889cdec | ||
|
|
170805647c | ||
|
|
76f9f70183 | ||
|
|
4f59831324 | ||
|
|
f37c126f0c | ||
|
|
f812f709b6 | ||
|
|
437c2bb4b5 | ||
|
|
39e9dc8b25 | ||
|
|
fc21e40b64 | ||
|
|
768161ceb2 | ||
|
|
adaafa3c4f | ||
|
|
0d2cf725e7 | ||
|
|
ac163d76a7 | ||
|
|
ebc281066f | ||
|
|
c145833518 | ||
|
|
28d50cb958 | ||
|
|
a5b24f7bd8 | ||
|
|
2c1bd0d74a | ||
|
|
55fb0456c3 | ||
|
|
415d916bde | ||
|
|
cdbdf219dc | ||
|
|
e50c49949c | ||
|
|
47f3932c8c | ||
|
|
450a22aaa5 | ||
|
|
dbce222310 | ||
|
|
51f8ec0a60 | ||
|
|
60a2267b1f | ||
|
|
16f3f01085 | ||
|
|
9538be957d | ||
|
|
0eeecb37f3 | ||
|
|
e814e28f1f | ||
|
|
7f7255aef8 | ||
|
|
a84e029066 | ||
|
|
735289e399 | ||
|
|
13095bc4bc | ||
|
|
c35403b59d | ||
|
|
6150a793e8 | ||
|
|
38d0256474 | ||
|
|
7bcf35c3f5 | ||
|
|
7e6a4ef3e8 | ||
|
|
50b0f2f775 | ||
|
|
2d4e24811e | ||
|
|
9efc6bfcad | ||
|
|
a259ec6c5a | ||
|
|
382801790c | ||
|
|
9ac87ae60f | ||
|
|
eb116c8a07 | ||
|
|
0f016dc467 | ||
|
|
a4a79ac920 | ||
|
|
8a67acb9dd | ||
|
|
5c1c6c69a6 | ||
|
|
5893de1194 | ||
|
|
322e05a6be | ||
|
|
aa8a441202 | ||
|
|
3a8b369171 | ||
|
|
7b43257863 | ||
|
|
9f57984372 | ||
|
|
e18b4416c6 | ||
|
|
a7bfe819eb | ||
|
|
00141d0da2 | ||
|
|
66f11badaf | ||
|
|
54c44d91de | ||
|
|
aaefbda226 | ||
|
|
bba25b2103 | ||
|
|
67d55d7e59 | ||
|
|
881c6bccc7 | ||
|
|
052319c9cc | ||
|
|
4dd16ea8c9 | ||
|
|
902186c4f8 | ||
|
|
5b906c3189 | ||
|
|
4e97a278d1 | ||
|
|
57db7bc166 | ||
|
|
3375beec89 | ||
|
|
e50a44125a | ||
|
|
70e0a7766c | ||
|
|
ced02df06f | ||
|
|
5549403082 | ||
|
|
2a85425ad8 | ||
|
|
d9437e6d74 | ||
|
|
c111dfd4ae | ||
|
|
7666a009f0 | ||
|
|
a45f05e48b | ||
|
|
694048a987 | ||
|
|
bb23d2998a | ||
|
|
1415bd5c2f | ||
|
|
eea9ac92dc | ||
|
|
c26b78d153 | ||
|
|
86cde93652 | ||
|
|
33d7c03329 | ||
|
|
a2cce72fa0 | ||
|
|
41b03d30ca | ||
|
|
c7701613ec | ||
|
|
450f641b36 | ||
|
|
786e0d7841 | ||
|
|
522d1ee62a | ||
|
|
8791e95ccc | ||
|
|
dac2b770a0 | ||
|
|
10545a52f0 | ||
|
|
993abbb7ea | ||
|
|
f460157470 | ||
|
|
5099e8ff1e | ||
|
|
bbd1c4cd47 | ||
|
|
6c4c93533b | ||
|
|
1864309872 | ||
|
|
b028cc0212 | ||
|
|
cdd0ce9b8e | ||
|
|
2601506be3 | ||
|
|
d7a4f47580 | ||
|
|
c548652eca | ||
|
|
1fd1f14cee | ||
|
|
1595a90a13 | ||
|
|
0b933aa43f | ||
|
|
1e190e6fdd | ||
|
|
a638867400 | ||
|
|
30ec68fa7a | ||
|
|
7c22975048 | ||
|
|
cdeb9b59a5 | ||
|
|
180ba27ec9 | ||
|
|
281664eb0a | ||
|
|
b751418985 | ||
|
|
ba0cfed18c | ||
|
|
41e590fed7 | ||
|
|
2dabdd391e | ||
|
|
ce6cefe696 | ||
|
|
84b4402cd1 | ||
|
|
7534f53c27 | ||
|
|
2c4e0af64a | ||
|
|
5935921079 | ||
|
|
3f93bf22d6 | ||
|
|
2cc3a96d9a | ||
|
|
9096f6924c | ||
|
|
52b06b2642 | ||
|
|
9c87030edf | ||
|
|
8fd1f8a8d8 | ||
|
|
ecf6a94a1e | ||
|
|
3dfe113179 | ||
|
|
05dadb7950 | ||
|
|
7f03a4ed2d | ||
|
|
087b44a865 | ||
|
|
ad5ff42804 | ||
|
|
8e95b58b49 | ||
|
|
c723d420d5 | ||
|
|
e3ed82d162 | ||
|
|
cd93b8da23 | ||
|
|
497f117075 | ||
|
|
325fee71a2 | ||
|
|
1c98e7c6dd | ||
|
|
6182231cd4 | ||
|
|
7ba5483f61 | ||
|
|
fb2910a05c | ||
|
|
a5ec631f09 | ||
|
|
f16b28a4c5 | ||
|
|
2c5b390d96 | ||
|
|
c7a9b7efc2 |
@@ -6,7 +6,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.9",
|
||||
"version": "1.6.10-rc.106",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./gitnexus-claude-plugin"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.9",
|
||||
"version": "1.6.10-rc.106",
|
||||
"source": "./gitnexus-claude-plugin",
|
||||
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase."
|
||||
}
|
||||
|
||||
@@ -81,6 +81,18 @@ list_repos { offset: 400 } → repos 401–437, hasMore false
|
||||
|
||||
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
|
||||
|
||||
### Inline staleness signal (`query` / `context` / `impact` / `cypher`)
|
||||
|
||||
These four hot read tools attach a non-blocking `staleness` field to their response when the index is behind the checkout's current HEAD — the same `{ commitsBehind, hint }` shape `list_repos` already reports — so a direct tool call surfaces a behind-HEAD index without a separate `list_repos` call:
|
||||
|
||||
```jsonc
|
||||
{ /* …the tool's normal result… */
|
||||
"staleness": { "commitsBehind": 3, "hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update." }
|
||||
}
|
||||
```
|
||||
|
||||
The field is **absent when the index is current** (or when the freshness check can't run), so its presence is the signal. It is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). When you see it, the graph may be behind the working tree — re-run `analyze` before trusting blast-radius or dependence answers.
|
||||
|
||||
### Taint findings (`explain`)
|
||||
|
||||
`explain` returns taint findings recorded by `gitnexus analyze --pdg` — intra-procedural `TAINTED` edges plus cross-function `TAINT_PATH` hops where the interprocedural taint phase found a function-level source→sink chain. Each finding includes a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
|
||||
|
||||
@@ -39,6 +39,7 @@ ENV BUN_VERSION=${BUN_VERSION} \
|
||||
TZ=${TZ} \
|
||||
DEVCONTAINER=true \
|
||||
NODE_OPTIONS=--max-old-space-size=4096 \
|
||||
GITNEXUS_AUTO_HEAP=0 \
|
||||
POWERLEVEL9K_DISABLE_GITSTATUS=true
|
||||
|
||||
# Native build toolchain that gitnexus/postinstall needs. It compiles
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Custom self-hosted runner labels actionlint can't discover on its own.
|
||||
# gitnexus-evolution: the skill-evolution EC2 runner (infra/gitnexus-evolution/).
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- gitnexus-evolution
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
"@anthropic-ai/claude-code": "2.1.214"
|
||||
},
|
||||
"engines": {
|
||||
"node": "22.16.0"
|
||||
"node": "22.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": "22.16.0"
|
||||
"node": "22.18.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "2.1.214"
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
"gitnexus": "1.6.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": "22.16.0"
|
||||
"node": "22.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"engines": {
|
||||
"node": "22.16.0"
|
||||
"node": "22.18.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"gitnexus": "1.6.9"
|
||||
|
||||
@@ -352,7 +352,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false # this job uploads artifacts (artipacked)
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Unit-test the host->container config transforms
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
# Builds the image the same way a developer's "Reopen in Container" does.
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
with:
|
||||
path: ~/.lbdb/extension
|
||||
key: lbug-fts-${{ runner.os }}-${{ hashFiles('gitnexus/package-lock.json') }}
|
||||
- name: Ensure FTS extension installed
|
||||
- name: Ensure FTS + VECTOR extensions installed
|
||||
run: npx tsx scripts/ensure-fts.ts
|
||||
working-directory: gitnexus
|
||||
- name: Run sharded tests with coverage (blob)
|
||||
@@ -205,6 +205,10 @@ jobs:
|
||||
# tsx-on-source path in CI (both entry points stay covered).
|
||||
env:
|
||||
GITNEXUS_REQUIRE_FTS: '1'
|
||||
# #2623: the win32 VECTOR gate is gone, so the vector suites genuinely
|
||||
# run here — require the extension so an unavailable VECTOR is a loud
|
||||
# failure, never a silent skip (same contract as GITNEXUS_REQUIRE_FTS).
|
||||
GITNEXUS_REQUIRE_VECTOR: '1'
|
||||
GITNEXUS_E2E_CLI: dist
|
||||
# #2449: hosted Windows runners intermittently push the busiest shard past
|
||||
# the default 15-minute watchdog. 20 minutes restores real headroom while
|
||||
@@ -219,19 +223,21 @@ jobs:
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
# Warm-cache the installed LadybugDB FTS extension (~/.lbdb/extension) per
|
||||
# OS + lockfile so a warm run skips the network install entirely, and the
|
||||
# parallel shards share one download across runs. Pure reliability/speed:
|
||||
# on a cache miss the tests self-install FTS on demand (see
|
||||
# test/helpers/fts-availability.ts), so a miss just falls back to install —
|
||||
# never a correctness dependency. Keyed by lockfile hash so a LadybugDB
|
||||
# version bump re-installs; per-OS because the extension is a native binary.
|
||||
# Warm-cache the installed LadybugDB FTS + VECTOR extensions
|
||||
# (~/.lbdb/extension) per OS + lockfile so a warm run skips the network
|
||||
# install entirely, and the parallel shards share one download across
|
||||
# runs. Pure reliability/speed: on a cache miss the tests self-install on
|
||||
# demand (see test/helpers/fts-availability.ts), so a miss just falls
|
||||
# back to install — never a correctness dependency. Keyed by lockfile
|
||||
# hash so a LadybugDB version bump re-installs; per-OS because the
|
||||
# extensions are native binaries. (Key name kept as lbug-fts for cache
|
||||
# continuity — the path covers every extension in the shared home.)
|
||||
- name: Cache LadybugDB FTS extension
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
|
||||
with:
|
||||
path: ~/.lbdb/extension
|
||||
key: lbug-fts-${{ runner.os }}-${{ hashFiles('gitnexus/package-lock.json') }}
|
||||
- name: Ensure FTS extension installed
|
||||
- name: Ensure FTS + VECTOR extensions installed
|
||||
run: npx tsx scripts/ensure-fts.ts
|
||||
working-directory: gitnexus
|
||||
- name: Run platform-sensitive tests
|
||||
@@ -378,15 +384,16 @@ jobs:
|
||||
"$PREFIX/bin/gitnexus" --version
|
||||
fi
|
||||
|
||||
# Node engines-floor gate (#2372). The embedding resolvers statically named
|
||||
# `module.registerHooks`, which only exists on Node >= 22.15 / >= 23.5, so on
|
||||
# the supported floor (engines: >=22.0.0) those ESM modules failed to LINK —
|
||||
# a class vitest/tsx transforms structurally mask, and the default
|
||||
# `node-version: 22` (resolves to latest) never hits. Build the dist on 22.x,
|
||||
# then import-link every module R1 names as a load surface on a pinned 22.14
|
||||
# so a regression fails here instead of shipping to users on that Node range.
|
||||
# Node engines-floor gate (#2372). A module that statically names an API
|
||||
# newer than the supported floor (e.g. `module.registerHooks`, added in
|
||||
# 22.15) fails to LINK on the floor — a class vitest/tsx transforms
|
||||
# structurally mask, and the default `node-version: 22` (resolves to latest)
|
||||
# never hits. Build the dist on 22.x, then import-link every module R1 names
|
||||
# as a load surface on the pinned engines floor (22.18.0, per package.json
|
||||
# `engines: ^22.18.0 || >=24.11.0`) so a regression fails here instead of
|
||||
# shipping to users on the minimum supported Node.
|
||||
node-floor-compat:
|
||||
name: node floor compat (22.14)
|
||||
name: node floor compat (22.18)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -395,7 +402,7 @@ jobs:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -413,16 +420,16 @@ jobs:
|
||||
# Switch to the engines-floor Node AFTER building — native deps built on
|
||||
# 22.x load across the whole 22.x ABI line, and nothing installs after this
|
||||
# (so no package-manager cache is needed).
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
node-version: '22.18.0'
|
||||
package-manager-cache: false
|
||||
- name: Import-link the built dist on Node 22.14
|
||||
- name: Import-link the built dist on Node 22.18
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node --version
|
||||
node --version | grep -q '^v22\.14\.' || { echo "expected Node 22.14.x" >&2; exit 1; }
|
||||
node --version | grep -q '^v22\.18\.' || { echo "expected Node 22.18.x" >&2; exit 1; }
|
||||
for m in \
|
||||
core/embeddings/runtime-install \
|
||||
core/embeddings/onnxruntime-node-resolver \
|
||||
@@ -516,6 +523,7 @@ jobs:
|
||||
npx vitest run --no-file-parallelism
|
||||
test/integration/cobol-pipeline-benchmark.test.ts
|
||||
test/integration/csharp-pipeline-benchmark.test.ts
|
||||
test/integration/instance-ownership-pipeline-benchmark.test.ts
|
||||
test/integration/rust-pipeline-benchmark.test.ts
|
||||
test/integration/php-pipeline-benchmark.test.ts
|
||||
test/integration/ruby-pipeline-benchmark.test.ts
|
||||
@@ -554,9 +562,9 @@ jobs:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22.16.0'
|
||||
node-version: '22.18.0'
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
gitnexus/package-lock.json
|
||||
|
||||
@@ -323,9 +323,9 @@ jobs:
|
||||
- name: Set up pinned Node.js
|
||||
id: setup-node
|
||||
if: steps.context.outputs.ready == 'true'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22.16.0'
|
||||
node-version: '22.18.0'
|
||||
|
||||
- name: Install and preflight Claude subprocess isolation
|
||||
id: isolation
|
||||
@@ -377,7 +377,7 @@ jobs:
|
||||
.github/claude-canary-runtime/package-lock.json \
|
||||
"${runtime_dir}/package-lock.json"
|
||||
printf '%s\n' 'registry=https://registry.npmjs.org/' 'audit=false' 'fund=false' > "${npmrc}"
|
||||
test "$(node --version)" = 'v22.16.0'
|
||||
test "$(node --version)" = 'v22.18.0'
|
||||
test "$(uname -m)" = 'x86_64'
|
||||
|
||||
# The trusted lock and these independent receipts pin both the thin
|
||||
@@ -398,7 +398,7 @@ jobs:
|
||||
if (
|
||||
lock.lockfileVersion !== 3 ||
|
||||
lock.packages?.['']?.dependencies?.['@anthropic-ai/claude-code'] !== '2.1.214' ||
|
||||
lock.packages?.['']?.engines?.node !== '22.16.0'
|
||||
lock.packages?.['']?.engines?.node !== '22.18.0'
|
||||
) {
|
||||
throw new Error('Claude runtime lock root is not exact');
|
||||
}
|
||||
@@ -506,7 +506,7 @@ jobs:
|
||||
install -m 0600 .github/gitnexus-review-runtime/package.json "${runtime_dir}/package.json"
|
||||
install -m 0600 .github/gitnexus-review-runtime/package-lock.json "${runtime_dir}/package-lock.json"
|
||||
printf '%s\n' 'registry=https://registry.npmjs.org/' 'audit=false' 'fund=false' > "${npmrc}"
|
||||
test "$(node --version)" = 'v22.16.0'
|
||||
test "$(node --version)" = 'v22.18.0'
|
||||
npm ci \
|
||||
--prefix "${runtime_dir}" \
|
||||
--userconfig "${npmrc}" \
|
||||
@@ -1241,7 +1241,7 @@ jobs:
|
||||
CLAUDE_CONFIG_DIR: ${{ runner.temp }}/gitnexus-review-claude-config
|
||||
CLAUDE_WORKING_DIR: ${{ runner.temp }}/gitnexus-review-control
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: 'true'
|
||||
NODE_VERSION: '22.16.0'
|
||||
NODE_VERSION: '22.18.0'
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
path_to_claude_code_executable: ${{ runner.temp }}/gitnexus-review-claude-runtime/node_modules/@anthropic-ai/claude-code/bin/claude.exe
|
||||
|
||||
@@ -12,12 +12,34 @@
|
||||
# App that opens the promotion PR). The Mint-App-Token step hard-fails
|
||||
# without them once a promotion is detected. Verify the App installation
|
||||
# is scoped to this repo with only Contents: RW + Pull requests: RW.
|
||||
# [ ] Create the protected Environment `gitnexus-evolution` with a
|
||||
# [x] Create the protected Environment `gitnexus-evolution` with a
|
||||
# deployment-branch rule restricting it to `main`, and ideally scope the
|
||||
# three secrets above to that Environment. workflow_dispatch runs this
|
||||
# workflow (and eval/workflow_bench/evolve.py) from the *dispatched ref*,
|
||||
# so this server-side rule — not a code-side guard the branch could edit
|
||||
# away — is what stops a non-main branch from running with the secrets.
|
||||
# [x] Register a self-hosted runner labeled `gitnexus-evolution` (a dedicated
|
||||
# EC2 box works well). GitHub-hosted runners hard-cap job execution at 6
|
||||
# hours, non-configurable — too short once a benchmark session actually
|
||||
# invokes Skill/MCP tools for real. Self-hosted runners cap at 5 days
|
||||
# instead. This job only ever runs on schedule/workflow_dispatch, never
|
||||
# on fork-PR content, so the usual public-repo self-hosted-runner risk
|
||||
# doesn't apply — still keep the box dedicated to this workflow, with
|
||||
# outbound-only network access, and prefer on-demand over Spot (a Spot
|
||||
# reclaim mid-run loses the same way a 6-hour timeout does). Instance,
|
||||
# security group, and IAM setup are documented privately, not in this
|
||||
# repo — publishing the exact topology of a real, live AWS account
|
||||
# isn't safe to do in a public repo even without literal secrets.
|
||||
# Accepted tradeoff: the box is stopped between runs (an EventBridge
|
||||
# schedule starts it ~15min before the Saturday cron and stops it 24h
|
||||
# later) but is not destroyed/recreated per run, so it isn't fully
|
||||
# ephemeral — a compromise between the review-flagged ideal (re-image
|
||||
# between runs, bounding how long the injected model API key could
|
||||
# matter if the box were ever compromised some other way) and the added
|
||||
# complexity of per-job ephemeral provisioning for a job that runs at
|
||||
# most weekly. Revisit if run frequency increases or the threat model
|
||||
# changes; stopping already bounds the exposure window to the job's own
|
||||
# runtime on 1 day out of 7.
|
||||
# [ ] Run workflow_dispatch once and confirm: containment preflight passes,
|
||||
# the benchmark completes inside the job timeout, the results artifact
|
||||
# uploads, and a promotion (if any) opens a well-formed PR.
|
||||
@@ -77,13 +99,13 @@ jobs:
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
vars.GITNEXUS_EVOLUTION_ENABLED == 'true'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, linux, x64, gitnexus-evolution]
|
||||
# Gate promotion runs on a protected Environment. An admin must attach a
|
||||
# deployment-branch rule (main only) and ideally scope the three secrets to
|
||||
# it — server-side enforcement a dispatched non-main ref cannot bypass by
|
||||
# editing its own workflow copy. See the activation checklist above.
|
||||
environment: gitnexus-evolution
|
||||
timeout-minutes: 355 # ceiling just under GitHub's 360-minute hard cap
|
||||
timeout-minutes: 1440 # self-hosted ceiling is 5 days (7200min); 24h is a generous margin over a single-generation serial run
|
||||
permissions:
|
||||
contents: read # The promotion PR uses a short-lived App token minted below.
|
||||
env:
|
||||
@@ -108,9 +130,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22.16.0'
|
||||
node-version: '22.18.0'
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
gitnexus/package-lock.json
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
@@ -369,7 +369,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
# Node 24 ships with npm >= 11.5.x, which is the minimum that
|
||||
# supports npm Trusted Publishing OIDC. Node 22 ships with npm
|
||||
@@ -828,7 +828,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v2
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v2
|
||||
with:
|
||||
tag_name: ${{ steps.vtag-gate.outputs.vtag }}
|
||||
name: >-
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
|
||||
+1
-2
@@ -68,9 +68,8 @@ gitnexus-web/test-results/
|
||||
eval/.coverage
|
||||
eval/.hypothesis/
|
||||
|
||||
# Local docs (docs/plans/ stays tracked — gitnexus-plan output travels with the work)
|
||||
# Local docs — planning output (gitnexus-plan / gitnexus-work) stays local, not tracked
|
||||
docs/*
|
||||
!docs/plans/
|
||||
|
||||
gitnexus/test/fixtures/mini-repo/*.md
|
||||
gitnexus/test/fixtures/mini-repo/.claude
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ This project uses the [PolyForm Noncommercial License 1.0.0](https://polyformpro
|
||||
|
||||
## Development setup
|
||||
|
||||
**Prerequisites:** Node.js — `gitnexus/` requires `>=22.0.0` and `gitnexus-web/` requires `^20.19.0 || >=22.12.0` (enforced via the `engines` field in each package). Use `nvm install` to match the local version.
|
||||
**Prerequisites:** Node.js — `gitnexus/` requires `^22.18.0 || >=24.11.0` and `gitnexus-web/` requires `^20.19.0 || >=22.12.0` (enforced via the `engines` field in each package). Use `nvm install` to match the local version.
|
||||
|
||||
1. Clone the repository.
|
||||
2. **Shared package:** `cd gitnexus-shared && npm install && npm run build`
|
||||
|
||||
@@ -181,7 +181,7 @@ flowchart TB
|
||||
| `detect_impact` | Pre-commit change analysis — scope, affected processes, risk level |
|
||||
| `generate_map` | Architecture documentation from the knowledge graph with mermaid diagrams |
|
||||
|
||||
### Agent skills installed to `.claude/skills/` automatically
|
||||
### Agent skills installed to `.claude/skills/` and `.agents/skills/` (if `.agents/` exists) automatically
|
||||
|
||||
- **Exploring** — navigate unfamiliar code using the knowledge graph
|
||||
- **Debugging** — trace bugs through call chains
|
||||
@@ -198,6 +198,8 @@ flowchart TB
|
||||
|
||||
**Repo-specific skills** — run `gitnexus analyze --skills` and GitNexus detects the functional areas of your codebase (via Leiden community detection) and generates each one as a direct project skill under `.claude/skills/gitnexus-area-<name>/`. Each skill describes a module's key files, entry points, execution flows, and cross-area connections, and is regenerated on each `--skills` run to stay current.
|
||||
|
||||
When a repo contains an `.agents/` directory, the standard and generated skills are also mirrored to `.agents/skills/` (e.g. `.agents/skills/gitnexus-cli/`, `.agents/skills/gitnexus-area-<name>/`) so agents that read repo-local `.agents/skills/` (like Codex) stay in sync.
|
||||
|
||||
## Editor Setup
|
||||
|
||||
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. Run it once. To configure only selected integrations, pass `--coding-agent`/`-c` with a comma-separated list, e.g. `gitnexus setup -c cursor,codex`.
|
||||
@@ -395,7 +397,7 @@ gitnexus analyze --skills # Generate repo-specific skill files from detec
|
||||
gitnexus analyze --skip-embeddings # Skip embedding generation (faster)
|
||||
gitnexus analyze --embeddings [limit] # Enable embedding generation (slower, better search)
|
||||
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
|
||||
gitnexus analyze --skip-skills # Skip installing standard .claude/skills/gitnexus-* skill files
|
||||
gitnexus analyze --skip-skills # Skip installing standard skill files under .claude/skills/ and .agents/skills/
|
||||
gitnexus analyze --skip-git # Index folders that are not Git repositories
|
||||
gitnexus analyze --default-branch develop # Branch used in the generated regression-compare example (base_ref)
|
||||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
@@ -451,7 +453,7 @@ Commit a `.gitnexusrc` JSON file at the repo root to preconfigure recurring `ana
|
||||
// over its fix on every analyze. (Alias: "branch".)
|
||||
"defaultBranch": "develop",
|
||||
"skipContextFiles": true, // alias of skipAgentsMd: keep your own AGENTS.md/CLAUDE.md
|
||||
"skipSkills": true, // don't install standard .claude/skills/gitnexus-* skills
|
||||
"skipSkills": true, // don't install standard skill files under .claude/skills/ and .agents/skills/
|
||||
"embeddings": true, // generate embeddings by default
|
||||
"workerTimeout": 60,
|
||||
}
|
||||
@@ -488,9 +490,10 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
|
||||
| `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 <kb>`. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout <seconds>` × 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. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
|
||||
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. |
|
||||
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). During `analyze` the pool is right-sized to the graph, scaled on non-4 KiB-page hosts by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. |
|
||||
| `GITNEXUS_LBUG_MAX_DB_SIZE` | `17179869184` (16 GiB) | Maximum size in bytes of a single LadybugDB database file — an mmap/disk-address-space ceiling, not a memory limit (it does not constrain the buffer pool). Invalid values silently fall back to the default. | Indexing a genuinely huge monorepo whose on-disk graph index approaches 16 GiB. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per worker slot before the slot is dropped from the active rotation. Bounds respawn loops on a chronically-crashing slot. | Hosts where a flaky worker should retry more (raise) or fail-fast (lower) before the slot is dropped. |
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -19,10 +20,16 @@ from workflow_bench.process_control import ManagedProcessResult, run_managed
|
||||
from workflow_bench.proposer_sandbox import (
|
||||
MAX_BUNDLE_BYTES,
|
||||
MAX_EVIDENCE_FILE_BYTES,
|
||||
SANDBOX_NODE,
|
||||
SANDBOX_NODE_PREFIX,
|
||||
VITE_TEMP_DIR,
|
||||
SANDBOX_PATH,
|
||||
SANDBOX_PYTHON3,
|
||||
SANDBOX_SHELL_PREFIX,
|
||||
SANDBOX_USER_SKILLS,
|
||||
ReadOnlyMount,
|
||||
SandboxError,
|
||||
_runtime_mount_args,
|
||||
build_claude_settings,
|
||||
build_sandbox_environment,
|
||||
prepare_sandbox,
|
||||
@@ -161,7 +168,28 @@ def test_sandbox_command_has_minimal_mounts_and_no_host_root_bind(tmp_path: Path
|
||||
check=False,
|
||||
)
|
||||
assert probe.returncode == 0, probe.stderr
|
||||
assert probe.stdout == "/home/agent|/opt/claude:/usr/local/bin:/usr/bin:/bin"
|
||||
assert probe.stdout == f"/home/agent|{SANDBOX_PATH}"
|
||||
|
||||
# The evidence-provenance.mjs plan-writer's PATH-scan trusts a Python 3
|
||||
# candidate only if it (and its directory) is owned by root or by the
|
||||
# current process — real /usr/bin/python3 is root-owned on the host,
|
||||
# which surfaces as the kernel's overflow uid inside this
|
||||
# --unshare-user sandbox (root itself is never mapped in). This wrapper
|
||||
# is freshly created by the host process instead, so it's trusted, and
|
||||
# it must still exec through to a real, working Python 3.
|
||||
python3_index = argv.index(SANDBOX_PYTHON3)
|
||||
assert argv[python3_index - 2] == "--ro-bind"
|
||||
python3_wrapper = Path(argv[python3_index - 1])
|
||||
assert stat.S_IMODE(python3_wrapper.stat().st_mode) == 0o500
|
||||
version = subprocess.run(
|
||||
[str(python3_wrapper), "-I", "-S", "-c", "import sys; print(sys.version_info[0])"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert version.returncode == 0, version.stderr
|
||||
assert version.stdout.strip() == "3"
|
||||
|
||||
assert SANDBOX_USER_SKILLS in argv
|
||||
user_skills_index = argv.index(SANDBOX_USER_SKILLS)
|
||||
assert argv[user_skills_index - 2] == "--ro-bind"
|
||||
@@ -169,6 +197,223 @@ def test_sandbox_command_has_minimal_mounts_and_no_host_root_bind(tmp_path: Path
|
||||
assert not private_root.exists()
|
||||
|
||||
|
||||
def test_runtime_mounts_bind_the_resolved_node_to_a_fresh_sandbox_path(monkeypatch) -> None:
|
||||
# sanitized_graph.py and runner_sessions.py invoke the sandboxed graph CLI
|
||||
# via SANDBOX_NODE. node's real host location varies (GitHub-hosted
|
||||
# runner images happen to have one under /usr/local/bin; a self-hosted
|
||||
# runner's actions/setup-node installs into its own tool-cache directory
|
||||
# instead), so this must bind to a FRESH sandbox path like /opt/claude/...
|
||||
# rather than anywhere under /usr, /bin, /lib, or /lib64: those are
|
||||
# already read-only bound by this same function, and bwrap can't create
|
||||
# a new mount-point file inside an already-read-only tree when the real
|
||||
# path doesn't already exist there on the host (observed empirically:
|
||||
# "bwrap: Can't create file at /usr/local/bin/node: Read-only file
|
||||
# system" when this bind first targeted that path on a self-hosted
|
||||
# runner where node isn't really there).
|
||||
monkeypatch.setattr(
|
||||
"workflow_bench.proposer_sandbox.shutil.which",
|
||||
lambda name: "/opt/hostedtoolcache/node/22.18.0/x64/bin/node" if name == "node" else None,
|
||||
)
|
||||
args = _runtime_mount_args()
|
||||
node_index = args.index("/opt/hostedtoolcache/node/22.18.0/x64/bin/node")
|
||||
assert args[node_index - 1] == "--ro-bind"
|
||||
assert args[node_index + 1] == SANDBOX_NODE
|
||||
assert not any(SANDBOX_NODE.startswith(bound + "/") for bound in ("/usr", "/bin", "/lib", "/lib64"))
|
||||
|
||||
|
||||
def test_runtime_mounts_bind_the_node_prefix_so_npx_and_npm_resolve(monkeypatch, tmp_path) -> None:
|
||||
# npx and npm are not standalone binaries -- they are symlinks into
|
||||
# ../lib/node_modules/npm/bin/*-cli.js -- so binding the sibling files is
|
||||
# not enough; the install prefix carrying both bin/ and lib/node_modules
|
||||
# has to be mounted. Without this, a self-hosted runner (where
|
||||
# actions/setup-node installs into its own tool cache, outside /usr) gets
|
||||
# a sandbox with node but no npx, and every task verify command dies with
|
||||
# "/bin/sh: 1: npx: not found" -- all 18 runs of skill-evolution run
|
||||
# 29861768554 did exactly that.
|
||||
prefix = tmp_path / "hostedtoolcache" / "node" / "22.18.0" / "x64"
|
||||
(prefix / "bin").mkdir(parents=True)
|
||||
(prefix / "bin" / "node").write_text("#!/bin/sh\nexit 0\n")
|
||||
(prefix / "lib" / "node_modules" / "npm" / "bin").mkdir(parents=True)
|
||||
(prefix / "lib" / "node_modules" / "npm" / "bin" / "npx-cli.js").write_text("")
|
||||
(prefix / "bin" / "npx").symlink_to("../lib/node_modules/npm/bin/npx-cli.js")
|
||||
monkeypatch.setattr(
|
||||
"workflow_bench.proposer_sandbox.shutil.which",
|
||||
lambda name: str(prefix / "bin" / "node") if name == "node" else None,
|
||||
)
|
||||
args = _runtime_mount_args()
|
||||
prefix_index = args.index(str(prefix))
|
||||
assert args[prefix_index - 1] == "--ro-bind"
|
||||
assert args[prefix_index + 1] == SANDBOX_NODE_PREFIX
|
||||
# the single-binary bind stays: sanitized_graph.py and runner_sessions.py
|
||||
# invoke SANDBOX_NODE directly.
|
||||
node_index = args.index(str(prefix / "bin" / "node"))
|
||||
assert args[node_index + 1] == SANDBOX_NODE
|
||||
# and the prefix's bin/ must actually be on PATH for npx to resolve.
|
||||
assert f"{SANDBOX_NODE_PREFIX}/bin" in SANDBOX_PATH.split(":")
|
||||
|
||||
|
||||
def test_runtime_mounts_skip_the_prefix_bind_for_an_unrecognized_node_layout(monkeypatch, tmp_path) -> None:
|
||||
# The prefix is derived from the node binary's path, so it must only be
|
||||
# trusted when the layout really is <prefix>/bin/node carrying npm.
|
||||
# Otherwise parent.parent names an unrelated ancestor: /opt/bin/node would
|
||||
# bind ALL of /opt (every tool cache on a hosted runner) and a bare
|
||||
# <dir>/node would bind <dir>'s parent -- an over-broad mount into a
|
||||
# sandbox that runs untrusted model-authored code. The pre-existing
|
||||
# real-Bubblewrap node canary builds exactly this bare <dir>/node shape.
|
||||
bare = tmp_path / "toolcache"
|
||||
bare.mkdir()
|
||||
(bare / "node").write_text("#!/bin/sh\nexit 0\n")
|
||||
monkeypatch.setattr(
|
||||
"workflow_bench.proposer_sandbox.shutil.which",
|
||||
lambda name: str(bare / "node") if name == "node" else None,
|
||||
)
|
||||
args = _runtime_mount_args()
|
||||
assert SANDBOX_NODE_PREFIX not in args
|
||||
assert str(tmp_path) not in args
|
||||
# the node bind itself is unaffected -- SANDBOX_NODE still works.
|
||||
assert args[args.index(str(bare / "node")) + 1] == SANDBOX_NODE
|
||||
|
||||
|
||||
def test_runtime_mounts_skip_the_prefix_bind_without_npx_beside_node(monkeypatch, tmp_path) -> None:
|
||||
# Right <prefix>/bin/node shape, but no working npx beside it: binding the
|
||||
# prefix would widen the mount surface without making npx resolvable.
|
||||
prefix = tmp_path / "x64"
|
||||
(prefix / "bin").mkdir(parents=True)
|
||||
(prefix / "bin" / "node").write_text("#!/bin/sh\nexit 0\n")
|
||||
monkeypatch.setattr(
|
||||
"workflow_bench.proposer_sandbox.shutil.which",
|
||||
lambda name: str(prefix / "bin" / "node") if name == "node" else None,
|
||||
)
|
||||
args = _runtime_mount_args()
|
||||
assert SANDBOX_NODE_PREFIX not in args
|
||||
|
||||
|
||||
def test_runtime_mounts_bind_a_real_tool_cache_layout(monkeypatch, tmp_path) -> None:
|
||||
# The positive counterpart: a genuine <prefix>/bin/node install carrying
|
||||
# npm, outside the system trees, is bound so npx resolves.
|
||||
prefix = tmp_path / "node" / "22.18.0" / "x64"
|
||||
(prefix / "bin").mkdir(parents=True)
|
||||
(prefix / "bin" / "node").write_text("#!/bin/sh\nexit 0\n")
|
||||
(prefix / "lib" / "node_modules" / "npm" / "bin").mkdir(parents=True)
|
||||
(prefix / "lib" / "node_modules" / "npm" / "bin" / "npx-cli.js").write_text("")
|
||||
(prefix / "bin" / "npx").symlink_to("../lib/node_modules/npm/bin/npx-cli.js")
|
||||
monkeypatch.setattr(
|
||||
"workflow_bench.proposer_sandbox.shutil.which",
|
||||
lambda name: str(prefix / "bin" / "node") if name == "node" else None,
|
||||
)
|
||||
args = _runtime_mount_args()
|
||||
prefix_index = args.index(SANDBOX_NODE_PREFIX)
|
||||
assert args[prefix_index - 2] == "--ro-bind"
|
||||
assert args[prefix_index - 1] == str(prefix)
|
||||
|
||||
|
||||
def test_runtime_mounts_skip_the_prefix_bind_when_it_is_already_bound(monkeypatch) -> None:
|
||||
# On an image where node genuinely lives in /usr/local/bin, the prefix is
|
||||
# /usr/local -- already inside the wholesale /usr read-only bind. Binding
|
||||
# it again would be redundant and would needlessly widen the argv, so the
|
||||
# containment surface stays minimal.
|
||||
monkeypatch.setattr(
|
||||
"workflow_bench.proposer_sandbox.shutil.which",
|
||||
lambda name: "/usr/local/bin/node" if name == "node" else None,
|
||||
)
|
||||
args = _runtime_mount_args()
|
||||
assert SANDBOX_NODE_PREFIX not in args
|
||||
assert args[args.index("/usr/local/bin/node") + 1] == SANDBOX_NODE
|
||||
|
||||
|
||||
def test_runtime_mounts_skip_the_node_bind_when_node_is_unresolvable(monkeypatch) -> None:
|
||||
monkeypatch.setattr("workflow_bench.proposer_sandbox.shutil.which", lambda name: None)
|
||||
args = _runtime_mount_args()
|
||||
assert SANDBOX_NODE not in args
|
||||
|
||||
|
||||
def test_node_modules_mounts_get_a_writable_vite_temp_overlay(tmp_path: Path) -> None:
|
||||
# vite writes <node_modules>/.vite-temp/<config>.timestamp-*.mjs before
|
||||
# loading a TypeScript config, so a read-only dependency mount makes vitest
|
||||
# fail with EROFS before any test runs -- and every task verify command and
|
||||
# every hidden oracle ends in "npx vitest run <test>". Reproduced on the
|
||||
# self-hosted runner with npx bypassed entirely, proving it is independent
|
||||
# of the node-prefix mount.
|
||||
clone = tmp_path / "clone"
|
||||
clone.mkdir()
|
||||
deps = tmp_path / "deps"
|
||||
deps.mkdir()
|
||||
# task_assets.py captures this directory into the dependency snapshot; the
|
||||
# overlay is gated on the mount source actually carrying it.
|
||||
(deps / VITE_TEMP_DIR).mkdir()
|
||||
executable = tmp_path / "executable"
|
||||
executable.write_text("#!/bin/sh\nexit 0\n")
|
||||
executable.chmod(0o755)
|
||||
|
||||
with prepare_sandbox(
|
||||
clone=clone,
|
||||
claude_bin=executable,
|
||||
bwrap_bin=executable,
|
||||
preflight=False,
|
||||
read_only_mounts=(ReadOnlyMount(source=deps, target="/workspace/gitnexus/node_modules"),),
|
||||
) as sandbox:
|
||||
argv = sandbox.command_prefix
|
||||
|
||||
bind_index = argv.index("/workspace/gitnexus/node_modules")
|
||||
assert argv[bind_index - 2 : bind_index + 1] == ["--ro-bind", str(deps), "/workspace/gitnexus/node_modules"]
|
||||
overlay = f"/workspace/gitnexus/node_modules/{VITE_TEMP_DIR}"
|
||||
overlay_index = argv.index(overlay)
|
||||
assert argv[overlay_index - 1] == "--tmpfs"
|
||||
# the overlay must come AFTER the read-only bind, or the bind would mask it
|
||||
assert overlay_index > bind_index
|
||||
|
||||
|
||||
def test_node_modules_mount_without_a_captured_vite_temp_gets_no_overlay(tmp_path: Path) -> None:
|
||||
# The trusted GitNexus runtime mounts /opt/gitnexus/node_modules, whose
|
||||
# source is the built runtime and does NOT carry a .vite-temp. bwrap cannot
|
||||
# mkdir a mount point inside a read-only bind, so overlaying it would fail
|
||||
# with "Can't mkdir .../node_modules/.vite-temp: Read-only file system".
|
||||
# Regression for that CI failure: the overlay must fire only where the
|
||||
# source actually contains the directory, not for every node_modules mount.
|
||||
clone = tmp_path / "clone"
|
||||
clone.mkdir()
|
||||
runtime = tmp_path / "runtime-node-modules"
|
||||
runtime.mkdir() # deliberately no .vite-temp
|
||||
executable = tmp_path / "executable"
|
||||
executable.write_text("#!/bin/sh\nexit 0\n")
|
||||
executable.chmod(0o755)
|
||||
|
||||
with prepare_sandbox(
|
||||
clone=clone,
|
||||
claude_bin=executable,
|
||||
bwrap_bin=executable,
|
||||
preflight=False,
|
||||
read_only_mounts=(ReadOnlyMount(source=runtime, target="/opt/gitnexus/node_modules"),),
|
||||
) as sandbox:
|
||||
argv = sandbox.command_prefix
|
||||
|
||||
assert "/opt/gitnexus/node_modules" in argv
|
||||
assert not any(str(item).endswith(f"/{VITE_TEMP_DIR}") for item in argv)
|
||||
|
||||
|
||||
def test_non_node_modules_mounts_get_no_vite_temp_overlay(tmp_path: Path) -> None:
|
||||
# Scoped to dependency mounts: a hidden-oracle or skill mount stays wholly
|
||||
# read-only, with no writable island inside it.
|
||||
clone = tmp_path / "clone"
|
||||
clone.mkdir()
|
||||
other = tmp_path / "oracle"
|
||||
other.mkdir()
|
||||
executable = tmp_path / "executable"
|
||||
executable.write_text("#!/bin/sh\nexit 0\n")
|
||||
executable.chmod(0o755)
|
||||
|
||||
with prepare_sandbox(
|
||||
clone=clone,
|
||||
claude_bin=executable,
|
||||
bwrap_bin=executable,
|
||||
preflight=False,
|
||||
read_only_mounts=(ReadOnlyMount(source=other, target="/workspace/.wfbench-oracle-abc"),),
|
||||
) as sandbox:
|
||||
argv = sandbox.command_prefix
|
||||
|
||||
assert not any(str(item).endswith(f"/{VITE_TEMP_DIR}") for item in argv)
|
||||
|
||||
|
||||
def test_stricter_prefix_freezes_evaluated_skills_and_can_unshare_network(tmp_path: Path) -> None:
|
||||
clone = tmp_path / "clone"
|
||||
skill = clone / ".claude" / "skills" / "gitnexus-work"
|
||||
@@ -197,6 +442,78 @@ def test_stricter_prefix_freezes_evaluated_skills_and_can_unshare_network(tmp_pa
|
||||
assert prefix[user_index - 2] == "--ro-bind"
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
|
||||
reason="real Bubblewrap canary is mandatory in the named Ubuntu CI job",
|
||||
)
|
||||
def test_real_bubblewrap_runs_node_from_outside_the_bound_trees(tmp_path: Path, monkeypatch) -> None:
|
||||
# Reproduces the self-hosted-runner failure directly: node resolved from
|
||||
# a path outside /usr, /bin, /lib, /lib64 (actions/setup-node's own
|
||||
# tool-cache convention) must still be reachable inside the sandbox at
|
||||
# SANDBOX_NODE. A real node copied to a fresh, non-system location stands
|
||||
# in for the tool-cache install; argv-construction tests alone can't
|
||||
# catch a bwrap-level "Can't create file ...: Read-only file system"
|
||||
# (the actual error this fix resolves), only a real bwrap invocation can.
|
||||
real_node = shutil.which("node")
|
||||
if not real_node:
|
||||
pytest.skip("no node on PATH to relocate for this canary")
|
||||
toolcache = tmp_path / "toolcache"
|
||||
toolcache.mkdir()
|
||||
relocated_node = toolcache / "node"
|
||||
shutil.copy2(real_node, relocated_node)
|
||||
relocated_node.chmod(0o755)
|
||||
# Only fake "node"'s resolution -- prepare_sandbox's own bwrap/claude
|
||||
# lookups (_resolve_executable) also go through shutil.which, and must
|
||||
# keep resolving for real or preflight fails before the sandbox is even
|
||||
# built.
|
||||
real_which = shutil.which
|
||||
monkeypatch.setattr(
|
||||
"workflow_bench.proposer_sandbox.shutil.which",
|
||||
lambda name: str(relocated_node) if name == "node" else real_which(name),
|
||||
)
|
||||
|
||||
clone = tmp_path / "clone"
|
||||
clone.mkdir()
|
||||
with prepare_sandbox(clone=clone, claude_bin=Path(sys.executable), preflight=True) as sandbox:
|
||||
result = sandbox.run([SANDBOX_NODE, "--version"], timeout=10)
|
||||
assert result.ok, result.stderr_tail
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
|
||||
reason="real Bubblewrap canary is mandatory in the named Ubuntu CI job",
|
||||
)
|
||||
def test_real_bubblewrap_runs_npx_from_outside_the_bound_trees(tmp_path: Path, monkeypatch) -> None:
|
||||
# The npx half of the self-hosted-runner failure. Relocating a real node
|
||||
# INSTALL (bin/ + lib/node_modules, not just the binary) to a fresh path
|
||||
# outside /usr, /bin, /lib and /lib64 reproduces actions/setup-node's
|
||||
# tool-cache convention. Every task verify command is
|
||||
# "cd gitnexus && npx tsc ... && npx vitest ...", so npx must resolve
|
||||
# inside the sandbox; argv assertions cannot prove a bwrap-level mount
|
||||
# actually works, only a real invocation can.
|
||||
real_node = shutil.which("node")
|
||||
if not real_node:
|
||||
pytest.skip("no node on PATH to relocate for this canary")
|
||||
real_prefix = Path(real_node).resolve().parent.parent
|
||||
if not (real_prefix / "lib" / "node_modules" / "npm").is_dir():
|
||||
pytest.skip(f"node at {real_node} has no npm under its install prefix")
|
||||
toolcache = tmp_path / "toolcache" / "node" / "22.18.0" / "x64"
|
||||
shutil.copytree(real_prefix, toolcache, symlinks=True)
|
||||
relocated_node = toolcache / "bin" / "node"
|
||||
assert relocated_node.exists()
|
||||
real_which = shutil.which
|
||||
monkeypatch.setattr(
|
||||
"workflow_bench.proposer_sandbox.shutil.which",
|
||||
lambda name: str(relocated_node) if name == "node" else real_which(name),
|
||||
)
|
||||
|
||||
clone = tmp_path / "clone"
|
||||
clone.mkdir()
|
||||
with prepare_sandbox(clone=clone, claude_bin=Path(sys.executable), preflight=True) as sandbox:
|
||||
result = sandbox.run(["/bin/sh", "-c", "command -v npx && npx --version"], timeout=60)
|
||||
assert result.ok, result.stderr_tail
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
|
||||
reason="real Bubblewrap canary is mandatory in the named Ubuntu CI job",
|
||||
@@ -750,4 +1067,3 @@ for line in sys.stdin:
|
||||
assert bash_result.get("is_error") is not True, bash_result
|
||||
assert (clone / "bash-called").read_text() == "canary"
|
||||
assert (clone / "mcp-called").read_text() == "ok"
|
||||
|
||||
|
||||
@@ -262,3 +262,122 @@ def test_phase_workspace_accepts_new_regular_review_output(tmp_path):
|
||||
artifact.write_text("new review")
|
||||
|
||||
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
|
||||
|
||||
|
||||
def test_phase_workspace_ignores_claude_sandbox_bootstrap_noise(tmp_path):
|
||||
# Reproduced empirically: Claude Code's own enableWeakerNestedSandbox
|
||||
# bootstrap creates this exact set of paths on every session regardless
|
||||
# of task or model output (a trivial "say OK" prompt was enough). None
|
||||
# of it is something the model decided to write, so it must not read as
|
||||
# an unauthorized planning-phase change.
|
||||
before = runner_artifacts.workspace_snapshot(tmp_path)
|
||||
(tmp_path / ".claude" / "agents").mkdir(parents=True)
|
||||
(tmp_path / ".claude" / "commands").mkdir(parents=True)
|
||||
(tmp_path / ".claude" / ".cc-writes").write_text("{}")
|
||||
(tmp_path / ".env").write_text("")
|
||||
(tmp_path / ".env.development.local").write_text("")
|
||||
(tmp_path / ".npmrc").write_text("")
|
||||
(tmp_path / "package.json").write_text("{}")
|
||||
(tmp_path / "node_modules").mkdir()
|
||||
(tmp_path / "node_modules" / ".bin").mkdir()
|
||||
artifact = tmp_path / "review-output.md"
|
||||
artifact.write_text("new review")
|
||||
|
||||
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
|
||||
|
||||
|
||||
def test_phase_workspace_still_rejects_a_genuinely_unauthorized_change(tmp_path):
|
||||
# The bootstrap-noise exclusion must stay narrow: an actual source-file
|
||||
# edit outside the allowed artifact still has to be caught.
|
||||
before = runner_artifacts.workspace_snapshot(tmp_path)
|
||||
(tmp_path / "src.py").write_text("changed")
|
||||
artifact = tmp_path / "review-output.md"
|
||||
artifact.write_text("new review")
|
||||
|
||||
with pytest.raises(ValueError, match="unauthorized workspace path"):
|
||||
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
|
||||
|
||||
|
||||
def test_phase_workspace_ignores_nested_claude_sandbox_bootstrap_noise(tmp_path):
|
||||
# Claude Code bootstraps into whatever directory it is running in, not just
|
||||
# the workspace root. The benchmark's task prompts cd into gitnexus/, so the
|
||||
# same noise lands one level down -- observed verbatim in skill-evolution run
|
||||
# 29861768554, where 13 of 18 sessions failed with
|
||||
# "phase changed unauthorized workspace path(s): gitnexus/.claude/.cc-writes".
|
||||
nested = tmp_path / "gitnexus" / ".claude"
|
||||
nested.mkdir(parents=True)
|
||||
(nested / "settings.local.json").write_text("{}")
|
||||
before = runner_artifacts.workspace_snapshot(tmp_path)
|
||||
(nested / ".cc-writes").write_text("{}")
|
||||
artifact = tmp_path / "review-output.md"
|
||||
artifact.write_text("new review")
|
||||
|
||||
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
|
||||
|
||||
|
||||
def test_phase_workspace_does_not_descend_into_nested_bootstrap_directories(tmp_path):
|
||||
# The exclusion must skip an entry before it is queued for traversal, so
|
||||
# content created *inside* the ignored directory stays invisible too.
|
||||
nested = tmp_path / "gitnexus" / ".claude" / ".cc-writes"
|
||||
nested.mkdir(parents=True)
|
||||
before = runner_artifacts.workspace_snapshot(tmp_path)
|
||||
(nested / "pending.json").write_text('{"writes": 1}')
|
||||
artifact = tmp_path / "review-output.md"
|
||||
artifact.write_text("new review")
|
||||
|
||||
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
|
||||
|
||||
|
||||
def test_phase_workspace_still_rejects_nested_real_claude_config(tmp_path):
|
||||
# gitnexus/.claude/settings.local.json is real tracked repository content.
|
||||
# Excluding ".claude" wholesale at depth would blind the check to it, so the
|
||||
# exclusion must name only the entries Claude Code itself creates.
|
||||
nested = tmp_path / "gitnexus" / ".claude"
|
||||
nested.mkdir(parents=True)
|
||||
settings = nested / "settings.local.json"
|
||||
settings.write_text("{}")
|
||||
before = runner_artifacts.workspace_snapshot(tmp_path)
|
||||
settings.write_text('{"permissions": "changed"}')
|
||||
artifact = tmp_path / "review-output.md"
|
||||
artifact.write_text("new review")
|
||||
|
||||
with pytest.raises(ValueError, match="unauthorized workspace path"):
|
||||
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
|
||||
|
||||
|
||||
def test_phase_workspace_still_rejects_nested_package_json(tmp_path):
|
||||
# package.json is in WORKSPACE_SNAPSHOT_BOOTSTRAP_NOISE, but only as a
|
||||
# workspace-root entry: gitnexus/package.json is real tracked content whose
|
||||
# edits must still be caught.
|
||||
nested = tmp_path / "gitnexus"
|
||||
nested.mkdir()
|
||||
manifest = nested / "package.json"
|
||||
manifest.write_text("{}")
|
||||
before = runner_artifacts.workspace_snapshot(tmp_path)
|
||||
manifest.write_text('{"version": "9.9.9"}')
|
||||
artifact = tmp_path / "review-output.md"
|
||||
artifact.write_text("new review")
|
||||
|
||||
with pytest.raises(ValueError, match="unauthorized workspace path"):
|
||||
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
|
||||
|
||||
|
||||
def test_phase_workspace_still_sees_writes_under_a_pre_existing_nested_claude_dir(tmp_path):
|
||||
# Every excluded name is a blind spot. .claude/agents and .claude/commands
|
||||
# are deliberately NOT excluded at depth: once a .claude directory exists
|
||||
# (gitnexus/.claude/settings.local.json is tracked), anything written
|
||||
# underneath an excluded entry is invisible to this check, and Claude Code
|
||||
# loads .claude/agents relative to its cwd -- which these tasks point at
|
||||
# gitnexus/. A planning phase must not be able to plant a definition there
|
||||
# for the later work phase to read.
|
||||
nested = tmp_path / "gitnexus" / ".claude"
|
||||
nested.mkdir(parents=True)
|
||||
(nested / "settings.local.json").write_text("{}")
|
||||
before = runner_artifacts.workspace_snapshot(tmp_path)
|
||||
(nested / "agents").mkdir()
|
||||
(nested / "agents" / "planted.md").write_text("planted agent definition")
|
||||
artifact = tmp_path / "review-output.md"
|
||||
artifact.write_text("new review")
|
||||
|
||||
with pytest.raises(ValueError, match="unauthorized workspace path"):
|
||||
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
|
||||
|
||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from workflow_bench.proposer_sandbox import SandboxError
|
||||
from workflow_bench.proposer_sandbox import VITE_TEMP_DIR, SandboxError
|
||||
from workflow_bench.oracle_assets import TaskOracleSnapshot
|
||||
from workflow_bench.runner_tasks import resolve_task_bindings
|
||||
from workflow_bench.task_assets import TaskAssetCache, stage_task_assets
|
||||
@@ -113,6 +113,27 @@ def test_small_assets_use_a_bounded_buffered_fallback(monkeypatch, tmp_path: Pat
|
||||
assert (clone / "second").read_bytes() == b"def"
|
||||
|
||||
|
||||
def test_default_buffered_fallback_budget_covers_a_realistic_large_asset(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# 20 MiB exceeds the old 16 MiB default but must fit comfortably under
|
||||
# the current default, proving the real (non-monkeypatched) budget
|
||||
# constant is sized for a realistic large sandbox_copy asset such as the
|
||||
# harness's own pre-built graph index, not just tiny fixtures.
|
||||
payload = os.urandom(20 * 1024 * 1024)
|
||||
repo, task = _repo_and_task(tmp_path, {"large": payload})
|
||||
clone = tmp_path / "clone"
|
||||
clone.mkdir()
|
||||
monkeypatch.setattr(task_assets, "_try_reflink", lambda *_args: False)
|
||||
|
||||
with TaskAssetCache(tmp_path / "cache") as cache:
|
||||
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
|
||||
snapshot.materialize(clone)
|
||||
|
||||
assert (clone / "large").read_bytes() == payload
|
||||
|
||||
|
||||
def test_large_asset_without_reflink_fails_before_publish_and_cleans_staging(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
@@ -389,3 +410,36 @@ def test_resolved_task_binding_carries_dependency_digests_and_rejects_live_drift
|
||||
(repo / "dependency" / "package.json").write_bytes(b'{"version":2}')
|
||||
with pytest.raises(ValueError, match="definition drifted"):
|
||||
resolve_task_bindings([task], [binding], oracle_snapshots=[oracle])
|
||||
|
||||
|
||||
def test_node_modules_dependency_snapshot_captures_the_vite_temp_mount_point(tmp_path: Path) -> None:
|
||||
# bwrap cannot mkdir a mount point inside an already-read-only bind, so the
|
||||
# directory vite needs must exist in the captured dependency bytes. It is
|
||||
# recorded during capture, which puts it inside the manifest and both
|
||||
# dependency digests rather than leaving it an untracked mutation of a
|
||||
# digest-bound snapshot.
|
||||
repo, _ = _repo_and_task(tmp_path, {"dependency/package.json": b'{"version":1}'})
|
||||
task = {
|
||||
"sandbox_copy": [],
|
||||
"sandbox_dependencies": [{"source": "dependency", "target": "gitnexus/node_modules"}],
|
||||
}
|
||||
with TaskAssetCache(tmp_path / "cache") as cache:
|
||||
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
|
||||
captured = {entry.path.as_posix() for entry in snapshot.dependencies[0].entries}
|
||||
assert f"payload/{VITE_TEMP_DIR}" in captured
|
||||
vite_temp = next((snapshot.root / "dependencies").glob(f"*/payload/{VITE_TEMP_DIR}"))
|
||||
assert vite_temp.is_dir()
|
||||
|
||||
|
||||
def test_non_node_modules_dependency_snapshot_has_no_vite_temp(tmp_path: Path) -> None:
|
||||
# The capture is scoped to dependency mounts whose target is node_modules;
|
||||
# an unrelated vendored dependency is captured byte-for-byte as declared.
|
||||
repo, _ = _repo_and_task(tmp_path, {"dependency/package.json": b'{"version":1}'})
|
||||
task = {
|
||||
"sandbox_copy": [],
|
||||
"sandbox_dependencies": [{"source": "dependency", "target": "vendor/dependency"}],
|
||||
}
|
||||
with TaskAssetCache(tmp_path / "cache") as cache:
|
||||
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
|
||||
captured = {entry.path.as_posix() for entry in snapshot.dependencies[0].entries}
|
||||
assert not any(path.endswith(VITE_TEMP_DIR) for path in captured)
|
||||
|
||||
@@ -10,6 +10,7 @@ import yaml
|
||||
|
||||
from workflow_bench.runner import (
|
||||
aggregate,
|
||||
broken_incumbent_arms,
|
||||
build_parser,
|
||||
infra_error_record,
|
||||
normalized_model_identifier,
|
||||
@@ -64,6 +65,7 @@ def test_aggregate_takes_medians_and_counts_resolved():
|
||||
"valid_runs": 3,
|
||||
"excluded_runs": 0,
|
||||
"transcripts_missing": 0,
|
||||
"error_kinds": {},
|
||||
}
|
||||
|
||||
|
||||
@@ -172,7 +174,7 @@ def test_eval_ci_uses_locked_uv_and_blocking_native_containment_jobs():
|
||||
}
|
||||
assert containment["timeout-minutes"] == 20
|
||||
assert containment_node_setup["with"] == {
|
||||
"node-version": "22.16.0",
|
||||
"node-version": "22.18.0",
|
||||
"cache": "npm",
|
||||
"cache-dependency-path": "gitnexus/package-lock.json\ngitnexus-shared/package-lock.json\n",
|
||||
}
|
||||
@@ -333,6 +335,61 @@ def test_render_report_surfaces_excluded_and_unverified_runs():
|
||||
assert "no locatable session transcript" in report
|
||||
|
||||
|
||||
def test_render_report_surfaces_why_each_row_failed():
|
||||
results = {
|
||||
"t": {
|
||||
"workflow": aggregate(
|
||||
[record(resolved=False, error_kind="plan-evidence-invalid")],
|
||||
),
|
||||
}
|
||||
}
|
||||
report = render_report(results)
|
||||
assert "plan-evidence-invalid×1" in report
|
||||
|
||||
|
||||
def test_broken_incumbent_arms_flags_an_incumbent_that_resolved_nothing():
|
||||
results = {
|
||||
"t1": {"workflow": aggregate([record(resolved=False, error_kind="plan-evidence-invalid")])},
|
||||
"t2": {"workflow": aggregate([record(resolved=False, error_kind="plan-evidence-invalid")])},
|
||||
}
|
||||
assert broken_incumbent_arms(results, {"workflow"}) == ["workflow"]
|
||||
|
||||
|
||||
def test_broken_incumbent_arms_ignores_a_merely_underperforming_candidate():
|
||||
# The incumbent works fine; only the candidate arm fails. That's a normal,
|
||||
# expected "bad candidate" outcome and must not read as a broken harness.
|
||||
results = {
|
||||
"t1": {
|
||||
"workflow": aggregate([record(resolved=True)]),
|
||||
"candidate_workflow": aggregate([record(resolved=False, error_kind="verify-failed")]),
|
||||
},
|
||||
}
|
||||
assert broken_incumbent_arms(results, {"workflow"}) == []
|
||||
|
||||
|
||||
def test_broken_incumbent_arms_flags_an_incumbent_with_zero_valid_runs():
|
||||
# Every run excluded via an excluded-but-non-systemic error_kind
|
||||
# ("evidence-unverified"): valid_runs == 0 for every task, which the old
|
||||
# `valid_runs > 0` guard let sail through silently, and which the outage
|
||||
# streak breaker also doesn't catch (it resets rather than accumulates
|
||||
# on this exact error_kind -- see test_systemic_outage_streak_resets_on_non_outage).
|
||||
results = {
|
||||
"t1": {"workflow": aggregate([record(resolved=False, error_kind="evidence-unverified")])},
|
||||
"t2": {"workflow": aggregate([record(resolved=False, error_kind="evidence-unverified")])},
|
||||
}
|
||||
assert results["t1"]["workflow"]["valid_runs"] == 0
|
||||
assert broken_incumbent_arms(results, {"workflow"}) == ["workflow"]
|
||||
|
||||
|
||||
def test_broken_incumbent_arms_ignores_partial_incumbent_failure():
|
||||
# Resolved in at least one task — struggling, not broken.
|
||||
results = {
|
||||
"t1": {"workflow": aggregate([record(resolved=False, error_kind="verify-failed")])},
|
||||
"t2": {"workflow": aggregate([record(resolved=True)])},
|
||||
}
|
||||
assert broken_incumbent_arms(results, {"workflow"}) == []
|
||||
|
||||
|
||||
def test_infra_error_record_captures_the_failure_and_is_excluded():
|
||||
exc = subprocess.TimeoutExpired(cmd="claude -p", timeout=5)
|
||||
rec = infra_error_record(exc)
|
||||
|
||||
@@ -167,6 +167,56 @@ def test_run_claude_forwards_the_named_model_to_every_session(monkeypatch, tmp_p
|
||||
assert captured[captured.index("--model") + 1] == "claude-sonnet-4-20250514"
|
||||
|
||||
|
||||
def test_run_claude_restricts_tools_via_tools_flag_outside_bare(monkeypatch, tmp_path):
|
||||
# Outside --bare, the built-in toolset defaults to everything (subagents,
|
||||
# WebFetch, Task, ...) and --allowedTools only pre-approves within that —
|
||||
# it does not narrow it. --tools is what actually restricts the set, so a
|
||||
# non-bare arm session must pass it or it silently gets a far wider
|
||||
# toolset than intended.
|
||||
captured: list[str] = []
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
captured.extend(command)
|
||||
return fake_cli_result(VALID_REPORT)
|
||||
|
||||
monkeypatch.setattr(runner_sessions, "run_managed", fake_run)
|
||||
runner.run_claude(
|
||||
"task",
|
||||
tmp_path,
|
||||
claude_bin="claude",
|
||||
timeout=5,
|
||||
bare=False,
|
||||
allowed_tools=["Read", "Edit", "Bash", "Skill"],
|
||||
)
|
||||
tools_idx = captured.index("--tools")
|
||||
assert captured[tools_idx + 1 : tools_idx + 5] == ["Read", "Edit", "Bash", "Skill"]
|
||||
allowed_idx = captured.index("--allowedTools")
|
||||
assert captured[allowed_idx + 1 : allowed_idx + 5] == ["Read", "Edit", "Bash", "Skill"]
|
||||
|
||||
|
||||
def test_run_claude_omits_tools_flag_under_bare(monkeypatch, tmp_path):
|
||||
# --bare already hard-restricts to Bash/Edit/Read on its own (a Claude
|
||||
# Code design choice, not something --tools/--allowedTools can widen or
|
||||
# narrow further), so bare sessions must not also pass --tools.
|
||||
captured: list[str] = []
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
captured.extend(command)
|
||||
return fake_cli_result(VALID_REPORT)
|
||||
|
||||
monkeypatch.setattr(runner_sessions, "run_managed", fake_run)
|
||||
runner.run_claude(
|
||||
"task",
|
||||
tmp_path,
|
||||
claude_bin="claude",
|
||||
timeout=5,
|
||||
bare=True,
|
||||
allowed_tools=["Read", "Edit", "Bash", "Skill"],
|
||||
)
|
||||
assert "--tools" not in captured
|
||||
assert "--allowedTools" in captured
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("proc", "expected_kind"),
|
||||
[
|
||||
@@ -282,6 +332,15 @@ def test_agent_tool_grants_are_exact_and_nomcp_has_no_graph_tools(monkeypatch, t
|
||||
assert captured[3]["mcp_config_json"] == '{"mcpServers":{}}'
|
||||
assert captured[3]["disallowed_tools"] == ["Skill", "mcp__gitnexus"]
|
||||
|
||||
# --bare hard-disables the Skill tool and every mcp__* tool regardless of
|
||||
# --allowedTools (a Claude Code design choice, not something the harness
|
||||
# can override) -- every arm here except baseline_nomcp needs Skill
|
||||
# and/or MCP tools, so only baseline_nomcp may still run under --bare.
|
||||
assert captured[0]["bare"] is False # workflow: planning session
|
||||
assert captured[1]["bare"] is False # review
|
||||
assert captured[2]["bare"] is False # workflow_direct
|
||||
assert captured[3]["bare"] is True # baseline_nomcp
|
||||
|
||||
|
||||
def test_mcp_config_uses_only_the_minimal_pinned_harness_runtime(monkeypatch, tmp_path):
|
||||
runtime = tmp_path / "gitnexus"
|
||||
@@ -290,10 +349,12 @@ def test_mcp_config_uses_only_the_minimal_pinned_harness_runtime(monkeypatch, tm
|
||||
runtime / "dist" / "cli",
|
||||
runtime / "node_modules",
|
||||
runtime / "vendor",
|
||||
runtime / "hooks" / "claude",
|
||||
shared / "dist",
|
||||
):
|
||||
directory.mkdir(parents=True)
|
||||
(runtime / "dist" / "cli" / "index.js").write_text("")
|
||||
(runtime / "hooks" / "claude" / "resolve-analyze-cmd.cjs").write_text("")
|
||||
(runtime / "package.json").write_text(json.dumps({"version": runner.PINNED_GITNEXUS_VERSION}))
|
||||
(runtime / "node_modules" / "gitnexus-shared").symlink_to(shared, target_is_directory=True)
|
||||
(shared / "package.json").write_text(json.dumps({"name": "gitnexus-shared"}))
|
||||
@@ -316,6 +377,7 @@ def test_mcp_config_uses_only_the_minimal_pinned_harness_runtime(monkeypatch, tm
|
||||
(runtime / "vendor", f"{runner.SANDBOX_GITNEXUS}/vendor"),
|
||||
(shared / "dist", f"{runner.SANDBOX_GITNEXUS_SHARED}/dist"),
|
||||
(shared / "package.json", f"{runner.SANDBOX_GITNEXUS_SHARED}/package.json"),
|
||||
(runtime / "hooks" / "claude", f"{runner.SANDBOX_GITNEXUS}/hooks/claude"),
|
||||
]
|
||||
package = json.loads((runtime / "package.json").read_text())
|
||||
assert package["version"] == runner.PINNED_GITNEXUS_VERSION
|
||||
@@ -330,6 +392,12 @@ def test_mcp_config_uses_only_the_minimal_pinned_harness_runtime(monkeypatch, tm
|
||||
assert shared / forbidden not in mounted_sources
|
||||
assert f"{runner.SANDBOX_GITNEXUS_SHARED}/{forbidden}" not in mounted_targets
|
||||
|
||||
# Only hooks/claude is exposed, not the whole hooks/ directory (which also
|
||||
# has an unrelated hooks/antigravity/ tree) and not the runtime root itself.
|
||||
assert runtime / "hooks" not in mounted_sources
|
||||
assert runtime / "hooks" / "antigravity" not in mounted_sources
|
||||
assert f"{runner.SANDBOX_GITNEXUS}/hooks" not in mounted_targets
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
|
||||
@@ -347,6 +415,7 @@ def test_real_bubblewrap_runtime_mount_imports_cli_without_exposing_checkout(tmp
|
||||
f"{runner.SANDBOX_GITNEXUS}/vendor",
|
||||
f"{runner.SANDBOX_GITNEXUS_SHARED}/dist/index.js",
|
||||
f"{runner.SANDBOX_GITNEXUS_SHARED}/package.json",
|
||||
f"{runner.SANDBOX_GITNEXUS}/hooks/claude/resolve-analyze-cmd.cjs",
|
||||
]
|
||||
forbidden = [
|
||||
f"{runner.SANDBOX_GITNEXUS}/{relative}"
|
||||
@@ -369,16 +438,26 @@ def test_real_bubblewrap_runtime_mount_imports_cli_without_exposing_checkout(tmp
|
||||
preflight=True,
|
||||
) as sandbox:
|
||||
visibility = sandbox.run(
|
||||
["/usr/local/bin/node", "-e", visibility_script],
|
||||
[runner.SANDBOX_NODE, "-e", visibility_script],
|
||||
timeout=10,
|
||||
)
|
||||
imported = sandbox.run(
|
||||
["/usr/local/bin/node", runner.SANDBOX_GITNEXUS_ENTRYPOINT, "--version"],
|
||||
[runner.SANDBOX_NODE, runner.SANDBOX_GITNEXUS_ENTRYPOINT, "--version"],
|
||||
timeout=10,
|
||||
)
|
||||
# --version never reaches the `analyze` command, which is loaded via a
|
||||
# lazy dynamic import and is the only path that pulls in
|
||||
# resolve-invocation.ts's module-load-time require of hooks/claude/
|
||||
# resolve-analyze-cmd.cjs. Require the compiled analyze module
|
||||
# directly so this canary actually exercises that chain.
|
||||
analyze_imported = sandbox.run(
|
||||
[runner.SANDBOX_NODE, "-e", f"require('{runner.SANDBOX_GITNEXUS}/dist/cli/analyze.js')"],
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert visibility.ok, visibility.stderr_tail
|
||||
assert imported.ok, imported.stderr_tail
|
||||
assert analyze_imported.ok, analyze_imported.stderr_tail
|
||||
assert imported.stdout_tail.strip() == runner.PINNED_GITNEXUS_VERSION
|
||||
|
||||
|
||||
@@ -1025,3 +1104,55 @@ def test_review_phase_rejects_workspace_or_skill_mutation(
|
||||
assert rec["resolved"] is False
|
||||
assert rec["error_kind"] == "review-evidence-invalid"
|
||||
assert expected_detail in rec["error_detail"]
|
||||
|
||||
|
||||
def _git(repo, *args, check=True):
|
||||
return subprocess.run(["git", "-C", str(repo), *args], check=check, capture_output=True, text=True)
|
||||
|
||||
|
||||
def _git_commit(repo, message):
|
||||
_git(
|
||||
repo,
|
||||
"-c",
|
||||
"user.name=test",
|
||||
"-c",
|
||||
"user.email=test@invalid",
|
||||
"commit",
|
||||
"--quiet",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
message,
|
||||
)
|
||||
return _git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||
|
||||
|
||||
def test_make_worktree_clone_has_no_tags_but_keeps_all_branches(tmp_path):
|
||||
# oracle_assets.MAX_CLONE_REFS refuses to sanitize a clone with more than
|
||||
# 1024 refs; this repo's own history has 1000+ release-candidate tags, so
|
||||
# a plain `git clone` of it (inheriting every tag) trips that cap on every
|
||||
# benchmark session. make_worktree must not carry tags into its throwaway
|
||||
# clone, but callers pass a bare SHA or "HEAD" as `ref` (never a branch
|
||||
# name -- see evolve.py:476, runner.py:1037, sanitized_graph.py:345), so
|
||||
# branch-fetching itself must stay untouched: a commit reachable only from
|
||||
# a non-default branch must still resolve via the existing
|
||||
# checkout(ref) -> checkout(origin/{ref}) fallback.
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "--quiet")
|
||||
_git(repo, "checkout", "--quiet", "-b", "main")
|
||||
_git_commit(repo, "base")
|
||||
_git(repo, "tag", "v1.0.0-rc.1")
|
||||
|
||||
_git(repo, "checkout", "--quiet", "-b", "other")
|
||||
other_sha = _git_commit(repo, "only on other")
|
||||
_git(repo, "checkout", "--quiet", "main")
|
||||
|
||||
clones = tmp_path / "clones"
|
||||
clones.mkdir()
|
||||
target = runner.make_worktree(repo, other_sha, clones)
|
||||
|
||||
tags = _git(target, "tag").stdout.split()
|
||||
assert tags == [], f"clone must carry no tags, found: {tags}"
|
||||
|
||||
current = _git(target, "rev-parse", "HEAD").stdout.strip()
|
||||
assert current == other_sha
|
||||
|
||||
@@ -26,7 +26,18 @@ SANDBOX_HOME = "/home/agent"
|
||||
SANDBOX_TMP = "/tmp"
|
||||
SANDBOX_CLAUDE = "/opt/claude/claude"
|
||||
SANDBOX_SHELL_PREFIX = "/opt/claude/shell-prefix"
|
||||
SANDBOX_PATH = "/opt/claude:/usr/local/bin:/usr/bin:/bin"
|
||||
SANDBOX_PYTHON3 = "/opt/claude/python3"
|
||||
SANDBOX_NODE = "/opt/claude/node"
|
||||
SANDBOX_NODE_PREFIX = "/opt/claude/nodejs"
|
||||
# Vite transpiles a TypeScript config into <node_modules>/.vite-temp before it
|
||||
# loads anything, so a read-only dependency mount makes `vitest` die with EROFS
|
||||
# before a single test runs -- and every task verify command and every hidden
|
||||
# oracle ends in `npx vitest run <test>`. bwrap cannot create a mount point
|
||||
# inside an already-read-only bind, so the directory is captured into the
|
||||
# dependency snapshot (task_assets.py) and a tmpfs is overlaid on it here.
|
||||
VITE_TEMP_DIR = ".vite-temp"
|
||||
DEPENDENCY_MOUNT_BASENAME = "node_modules"
|
||||
SANDBOX_PATH = f"/opt/claude:{SANDBOX_NODE_PREFIX}/bin:/usr/local/bin:/usr/bin:/bin"
|
||||
SANDBOX_GITNEXUS = "/opt/gitnexus"
|
||||
SANDBOX_GITNEXUS_SHARED = "/opt/gitnexus-shared"
|
||||
SANDBOX_GITNEXUS_REGISTRY = "/opt/gitnexus-registry"
|
||||
@@ -349,10 +360,58 @@ def build_claude_settings() -> str:
|
||||
|
||||
def _runtime_mount_args() -> list[str]:
|
||||
args: list[str] = []
|
||||
for raw in ("/usr", "/bin", "/lib", "/lib64"):
|
||||
system_trees = ("/usr", "/bin", "/lib", "/lib64")
|
||||
for raw in system_trees:
|
||||
path = Path(raw)
|
||||
if path.exists():
|
||||
args += ["--ro-bind", raw, raw]
|
||||
# sanitized_graph.py and runner_sessions.py invoke the sandboxed graph
|
||||
# CLI via SANDBOX_NODE. Bind whatever `node` actually resolves to on PATH
|
||||
# there -- true node location varies by host (GitHub-hosted runner images
|
||||
# happen to have one under /usr/local/bin; a self-hosted runner's
|
||||
# actions/setup-node installs into its own tool-cache directory instead).
|
||||
# Target must be a fresh path like /opt/claude/... rather than anywhere
|
||||
# under /usr, /bin, /lib, or /lib64: those are already read-only bound
|
||||
# above, and bwrap can't create a new mount-point file inside an
|
||||
# already-read-only tree when the real path doesn't already exist there
|
||||
# (the exact case a self-hosted runner hits, and the reason this bind
|
||||
# exists at all).
|
||||
node_bin = shutil.which("node")
|
||||
if node_bin:
|
||||
args += ["--ro-bind", node_bin, SANDBOX_NODE]
|
||||
# The single-binary bind above gives SANDBOX_NODE but NOT npm or npx:
|
||||
# those are symlinks into ../lib/node_modules/npm/bin/*-cli.js, so the
|
||||
# install prefix carrying both bin/ and lib/node_modules has to be
|
||||
# mounted for them to resolve at all. When node really lives under a
|
||||
# system tree (/usr/local/bin on GitHub-hosted images) the prefix is
|
||||
# already inside the wholesale read-only binds above and npm/npx came
|
||||
# along for free -- which is exactly why this gap stayed invisible
|
||||
# until a self-hosted runner put node in actions/setup-node's tool
|
||||
# cache, outside /usr, and every task verify command
|
||||
# ("cd gitnexus && npx tsc ... && npx vitest ...") died with
|
||||
# "/bin/sh: 1: npx: not found". Skip the redundant bind in the
|
||||
# already-covered case so the mount surface stays minimal.
|
||||
#
|
||||
# The prefix is only ever derived from a real <prefix>/bin/node layout
|
||||
# that actually carries npm. Deriving it as parent.parent unconditionally
|
||||
# would mount an unrelated ancestor whenever node sits somewhere else:
|
||||
# /opt/bin/node would bind all of /opt (every tool cache on a hosted
|
||||
# runner) and a bare <dir>/node would bind <dir>'s parent. This function
|
||||
# exists to keep the sandbox surface minimal, so an unrecognized layout
|
||||
# binds nothing extra and simply leaves npx unavailable, exactly as
|
||||
# before.
|
||||
node_bin_dir = Path(node_bin).resolve().parent
|
||||
node_prefix = node_bin_dir.parent
|
||||
# Test the property actually needed -- a working npx next to node in a
|
||||
# real bin/ directory -- rather than a proxy like lib/node_modules/npm.
|
||||
# .exists() follows the symlink, so a dangling npx correctly fails: it
|
||||
# would not survive the mount either. Requiring the "bin" name keeps
|
||||
# the parent.parent derivation honest; an npx sitting directly beside
|
||||
# node in a flat directory would make that derivation name the wrong
|
||||
# prefix.
|
||||
provides_npx = node_bin_dir.name == "bin" and (node_bin_dir / "npx").exists()
|
||||
if provides_npx and not any(node_prefix.is_relative_to(tree) for tree in system_trees):
|
||||
args += ["--ro-bind", str(node_prefix), SANDBOX_NODE_PREFIX]
|
||||
for raw in (
|
||||
"/etc/ssl",
|
||||
"/etc/hosts",
|
||||
@@ -384,6 +443,24 @@ def _create_shell_prefix_wrapper(private_root: Path) -> Path:
|
||||
return wrapper
|
||||
|
||||
|
||||
def _create_python3_wrapper(private_root: Path) -> Path:
|
||||
"""A trusted, self-owned Python 3 launcher for evidence-provenance.mjs's atomic mover.
|
||||
|
||||
/usr/bin/python3 is a real system binary, but it's root-owned on the host.
|
||||
Inside this --unshare-user sandbox only the calling uid is mapped (root is
|
||||
not), so root-owned files surface as the kernel's overflow uid — which
|
||||
evidence-provenance.mjs's PATH-scan correctly refuses to trust. This
|
||||
wrapper is freshly created by the same host process that owns
|
||||
home/temp/shell-prefix, so it maps to the sandbox's own trusted uid
|
||||
instead, and simply execs the real interpreter through to do the work.
|
||||
"""
|
||||
|
||||
wrapper = private_root / "python3"
|
||||
wrapper.write_text('#!/bin/bash\nset -eu\nexec /usr/bin/python3 "$@"\n')
|
||||
wrapper.chmod(0o500)
|
||||
return wrapper
|
||||
|
||||
|
||||
def _resolve_executable(executable: Path | str | None, default: str) -> Path:
|
||||
raw = os.fspath(executable) if executable is not None else shutil.which(default)
|
||||
if not raw:
|
||||
@@ -609,6 +686,20 @@ def _sandbox_command_prefix(
|
||||
]
|
||||
for mount in mounts:
|
||||
args += ["--ro-bind", str(mount.source), mount.target]
|
||||
# Overlay an empty writable tmpfs on the one path vite must write.
|
||||
# Everything else in the mount, and the whole workspace, stays
|
||||
# read-only, and the overlay lives only inside the sandbox -- it never
|
||||
# reaches the host clone the credited patch is captured from.
|
||||
#
|
||||
# Gate on the mount SOURCE actually containing the directory, not on
|
||||
# the target name: bwrap cannot create a mount point inside an
|
||||
# already-read-only bind, so a tmpfs can only be overlaid where the
|
||||
# directory already exists in the bound bytes. task_assets.py captures
|
||||
# it into dependency-snapshot node_modules; other node_modules mounts
|
||||
# (e.g. the trusted GitNexus runtime at /opt/gitnexus/node_modules) do
|
||||
# not carry it, and overlaying them would fail with EROFS.
|
||||
if PurePosixPath(mount.target).name == DEPENDENCY_MOUNT_BASENAME and (mount.source / VITE_TEMP_DIR).is_dir():
|
||||
args += ["--tmpfs", f"{mount.target}/{VITE_TEMP_DIR}"]
|
||||
args += ["--chdir", SANDBOX_WORKSPACE, "--"]
|
||||
return args
|
||||
|
||||
@@ -641,6 +732,7 @@ def prepare_sandbox(
|
||||
directory.mkdir(mode=0o700)
|
||||
directory.chmod(0o700)
|
||||
shell_prefix = _create_shell_prefix_wrapper(private_root)
|
||||
python3_wrapper = _create_python3_wrapper(private_root)
|
||||
# Claude may discover user-level skills below HOME. Keep the rest of HOME
|
||||
# writable for normal CLI state, but overlay an immutable empty skills root
|
||||
# so a model cannot shadow the evaluated repository/plugin skill by name.
|
||||
@@ -651,6 +743,7 @@ def prepare_sandbox(
|
||||
*read_only_mounts,
|
||||
ReadOnlyMount(source=user_skills, target=SANDBOX_USER_SKILLS),
|
||||
ReadOnlyMount(source=shell_prefix, target=SANDBOX_SHELL_PREFIX),
|
||||
ReadOnlyMount(source=python3_wrapper, target=SANDBOX_PYTHON3),
|
||||
)
|
||||
primary: BaseException | None = None
|
||||
try:
|
||||
|
||||
@@ -78,6 +78,7 @@ from .proposer_sandbox import (
|
||||
SANDBOX_GITNEXUS as SANDBOX_GITNEXUS,
|
||||
SANDBOX_GITNEXUS_REGISTRY,
|
||||
SANDBOX_GITNEXUS_SHARED as SANDBOX_GITNEXUS_SHARED,
|
||||
SANDBOX_NODE as SANDBOX_NODE,
|
||||
SANDBOX_WORKSPACE,
|
||||
ReadOnlyMount,
|
||||
SandboxError,
|
||||
@@ -402,6 +403,13 @@ def run_arm(
|
||||
auth_token=args.auth_token,
|
||||
base_url=args.base_url,
|
||||
)
|
||||
# --bare hard-disables the Skill tool and every mcp__* tool — by Claude
|
||||
# Code design, not a bug (--allowedTools can't restore what --bare
|
||||
# removes). Every arm except baseline_nomcp needs Skill and/or MCP tools,
|
||||
# so only baseline_nomcp can keep --bare's tighter isolation; the rest
|
||||
# rely on ANTHROPIC_API_KEY alone (the sandboxed HOME has no OAuth/
|
||||
# keychain state to conflict with it).
|
||||
bare = arm == "baseline_nomcp"
|
||||
common = {
|
||||
"claude_bin": sandbox.claude_bin,
|
||||
"timeout": args.timeout,
|
||||
@@ -412,7 +420,7 @@ def run_arm(
|
||||
read_only_paths=_evaluated_skill_roots(worktree, arm),
|
||||
),
|
||||
"require_pid_namespace": True,
|
||||
"bare": True,
|
||||
"bare": bare,
|
||||
"settings_json": sandbox.settings_json,
|
||||
"strict_mcp_config": True,
|
||||
"mcp_config_json": sandbox_mcp_config(),
|
||||
@@ -672,13 +680,21 @@ def aggregate(records: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
# unmeasured run makes the whole median unavailable so the gate won't rank
|
||||
# a candidate on a cost that was never actually captured.
|
||||
valid_costs = [r.get("cost_usd") for r in valid]
|
||||
out["cost_usd"] = None if (not valid or any(cost is None for cost in valid_costs)) else statistics.median(valid_costs)
|
||||
out["cost_usd"] = (
|
||||
None if (not valid or any(cost is None for cost in valid_costs)) else statistics.median(valid_costs)
|
||||
)
|
||||
out["resolved"] = sum(1 for r in records if r["resolved"])
|
||||
out["runs"] = len(records)
|
||||
out["valid_runs"] = len(valid)
|
||||
out["excluded_runs"] = len(records) - len(valid)
|
||||
out["transcripts_missing"] = sum(1 for r in records if r.get("transcript_missing"))
|
||||
out["class"] = records[0].get("class", "")
|
||||
error_kinds: dict[str, int] = {}
|
||||
for r in records:
|
||||
kind = r.get("error_kind")
|
||||
if kind:
|
||||
error_kinds[kind] = error_kinds.get(kind, 0) + 1
|
||||
out["error_kinds"] = error_kinds
|
||||
return out
|
||||
|
||||
|
||||
@@ -695,6 +711,33 @@ def savings(baseline: dict[str, Any], workflow: dict[str, Any]) -> dict[str, Any
|
||||
return out
|
||||
|
||||
|
||||
def broken_incumbent_arms(
|
||||
results: dict[str, dict[str, dict[str, Any]]],
|
||||
incumbent_arms: set[str],
|
||||
) -> list[str]:
|
||||
"""Incumbent arms that resolved nothing across every task they ran.
|
||||
|
||||
An incumbent arm is the currently-shipped, presumably-working skill: if it
|
||||
resolves NOTHING across every task it ran, that reads as an environment or
|
||||
harness failure (missing trusted interpreter, stale skill fingerprint,
|
||||
sandbox misconfiguration), not a skill regression. A candidate merely
|
||||
underperforming is a normal, expected outcome and must not trip this —
|
||||
only checking incumbents keeps that distinction.
|
||||
|
||||
Deliberately does NOT require valid_runs > 0 per task: an incumbent that
|
||||
fails every run with an excluded-but-non-systemic error_kind (e.g.
|
||||
"evidence-unverified", which the outage-streak breaker explicitly resets
|
||||
on rather than accumulates) would otherwise never accumulate a single
|
||||
valid run and sail through silently — the exact "quiet no-promotion"
|
||||
outcome this guard exists to catch, and arguably worse than the
|
||||
some-runs-resolved-zero case since here nothing completed at all.
|
||||
aggregate() never marks an excluded/unverifiable row resolved=True, so
|
||||
resolved == 0 alone already covers both cases.
|
||||
"""
|
||||
present = incumbent_arms & {arm for arms in results.values() for arm in arms}
|
||||
return sorted(arm for arm in present if all(arms[arm]["resolved"] == 0 for arms in results.values() if arm in arms))
|
||||
|
||||
|
||||
def _na(value: Any) -> Any:
|
||||
"""Render an unmeasured metric as ``n/a`` instead of a misleading number."""
|
||||
return "n/a" if value is None else value
|
||||
@@ -719,8 +762,8 @@ def render_report(results: dict[str, dict[str, dict[str, Any]]]) -> str:
|
||||
"efficiency, sum usage from the session transcripts instead",
|
||||
"(dedup events sharing one message.id).",
|
||||
"",
|
||||
"| task | class | arm | resolved | input | cache_create | cache_read | output | cost $ | wall s | turns | churn |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
"| task | class | arm | resolved | input | cache_create | cache_read | output | cost $ | wall s | turns | churn | errors |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
for task_id, arms in results.items():
|
||||
for arm, agg in arms.items():
|
||||
@@ -728,12 +771,14 @@ def render_report(results: dict[str, dict[str, dict[str, Any]]]) -> str:
|
||||
resolved_cell = f"{agg['resolved']}/{agg.get('valid_runs', agg['runs'])}"
|
||||
if excluded:
|
||||
resolved_cell += f" ({excluded} excluded)"
|
||||
error_cell = ", ".join(f"{kind}×{count}" for kind, count in sorted(agg.get("error_kinds", {}).items()))
|
||||
lines.append(
|
||||
f"| {task_id} | {agg['class']} | {arm} | {resolved_cell} "
|
||||
f"| {agg['input_tokens']:.0f} | {agg['cache_creation_input_tokens']:.0f} "
|
||||
f"| {agg['cache_read_input_tokens']:.0f} | {agg['output_tokens']:.0f} "
|
||||
f"| {_cost_cell(agg['cost_usd'])} | {agg['duration_s']:.0f} | {agg['num_turns']:.0f} "
|
||||
f"| {agg['diff_files']:.0f}/+{agg['diff_insertions']:.0f}/−{agg['diff_deletions']:.0f} |"
|
||||
f"| {agg['diff_files']:.0f}/+{agg['diff_insertions']:.0f}/−{agg['diff_deletions']:.0f} "
|
||||
f"| {error_cell} |"
|
||||
)
|
||||
for arm in arms:
|
||||
if arm != "baseline" and "baseline" in arms:
|
||||
@@ -742,7 +787,7 @@ def render_report(results: dict[str, dict[str, dict[str, Any]]]) -> str:
|
||||
f"| {task_id} | {arms[arm]['class']} | **{arm} savings %** | — "
|
||||
f"| {s['input_tokens']} | {s['cache_creation_input_tokens']} "
|
||||
f"| {s['cache_read_input_tokens']} | {s['output_tokens']} "
|
||||
f"| {_na(s['cost_usd'])} | {s['duration_s']} | — | — |"
|
||||
f"| {_na(s['cost_usd'])} | {s['duration_s']} | — | — | — |"
|
||||
)
|
||||
lines.append("")
|
||||
all_aggs = [agg for arms in results.values() for agg in arms.values()]
|
||||
@@ -1326,6 +1371,17 @@ def main() -> None:
|
||||
}
|
||||
(out_dir / "promotion.json").write_text(json.dumps(promotion, indent=2) + "\n")
|
||||
print(f"\n{report}\n\nWritten to {out_dir}/")
|
||||
broken_incumbents = broken_incumbent_arms(results, set(CANDIDATE_ARMS.values()))
|
||||
if broken_incumbents:
|
||||
# Fail loudly rather than let a broken environment read as a quiet
|
||||
# "no promotion, incumbent stands."
|
||||
print(
|
||||
f"[harness-health] incumbent arm(s) {', '.join(broken_incumbents)} resolved zero "
|
||||
"tasks across every valid run — this looks like an environment/harness failure, "
|
||||
"not a normal candidate miss. See the errors column in report.md and error_detail "
|
||||
"in results.jsonl. Exiting non-zero rather than reporting a quiet no-promotion."
|
||||
)
|
||||
raise SystemExit(1)
|
||||
if outage_tripped:
|
||||
# Non-zero exit so a driver (evolve.py) treats the partial benchmark as a
|
||||
# failed run and halts instead of proposing from outage-truncated evidence.
|
||||
|
||||
@@ -21,6 +21,64 @@ MAX_WORKSPACE_SNAPSHOT_ENTRIES = 100_000
|
||||
MAX_WORKSPACE_SNAPSHOT_PATH_BYTES = 16 * 1024 * 1024
|
||||
MAX_WORKSPACE_SNAPSHOT_FILE_BYTES = 1024 * 1024 * 1024
|
||||
|
||||
# Claude Code's own enableWeakerNestedSandbox bootstrap creates these paths on
|
||||
# EVERY session regardless of task or model output -- reproduced empirically
|
||||
# with a trivial "say OK" prompt: a synthetic package.json/lockfiles/
|
||||
# node_modules, a full set of .env variants, and .claude/agents,
|
||||
# .claude/commands, .claude/.cc-writes. None of this is something the model
|
||||
# decided to write, so it must not count as an "unauthorized" workspace
|
||||
# change during the planning-phase boundary check (the one thing this
|
||||
# snapshot is used for -- see workspace_snapshot's callers). Mirrors the
|
||||
# pre-existing .git exclusion below, which is the same kind of harness/tool
|
||||
# noise rather than substantive diff.
|
||||
WORKSPACE_SNAPSHOT_BOOTSTRAP_NOISE = frozenset(
|
||||
{
|
||||
".claude",
|
||||
".env",
|
||||
".env.development",
|
||||
".env.development.local",
|
||||
".env.local",
|
||||
".env.production",
|
||||
".env.production.local",
|
||||
".env.test",
|
||||
".env.test.local",
|
||||
".gitmodules",
|
||||
".npmrc",
|
||||
".yarnrc",
|
||||
".yarnrc.yml",
|
||||
"bunfig.toml",
|
||||
"node_modules",
|
||||
"package-lock.json",
|
||||
"package.json",
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
}
|
||||
)
|
||||
|
||||
# The set above is matched at the workspace ROOT only, because most of its
|
||||
# entries (package.json, node_modules, the .env family) are also legitimate
|
||||
# repository content further down the tree -- gitnexus/package.json and
|
||||
# gitnexus/.claude/settings.local.json are both tracked files whose edits must
|
||||
# still be caught. But Claude Code bootstraps into whatever directory it is
|
||||
# running in, so a task whose prompt cd's into a subdirectory gets the same
|
||||
# noise one level down. Observed in skill-evolution run 29861768554: 13 of 18
|
||||
# sessions failed with "phase changed unauthorized workspace path(s):
|
||||
# gitnexus/.claude/.cc-writes". That entry is matched at ANY depth -- never
|
||||
# ".claude" itself, which holds real configuration.
|
||||
#
|
||||
# Deliberately only .cc-writes. Every excluded name is a blind spot: once a
|
||||
# .claude directory already exists (gitnexus/.claude/settings.local.json is
|
||||
# tracked), anything a phase writes underneath an excluded entry becomes
|
||||
# invisible to this check, and Claude Code loads .claude/agents relative to
|
||||
# its cwd -- which these tasks point at gitnexus/. Adding "agents" and
|
||||
# "commands" here on the theory that they might also appear nested would let a
|
||||
# planning phase plant a definition that the later work phase reads, with no
|
||||
# evidence in the boundary check. Only .cc-writes was ever observed nested, so
|
||||
# only .cc-writes is excluded; extend this set from an observed failure, never
|
||||
# pre-emptively.
|
||||
CLAUDE_BOOTSTRAP_DIR = ".claude"
|
||||
CLAUDE_BOOTSTRAP_ENTRIES = frozenset({".cc-writes"})
|
||||
|
||||
IMPLEMENTATION_ARMS = frozenset(
|
||||
{
|
||||
"workflow",
|
||||
@@ -52,8 +110,19 @@ class VerificationResult:
|
||||
yield self.output
|
||||
|
||||
|
||||
def _is_bootstrap_noise(relative: PurePosixPath) -> bool:
|
||||
"""Report whether a walked entry is harness noise rather than workspace change."""
|
||||
|
||||
parts = relative.parts
|
||||
if parts[0] == ".git" or parts[0] in WORKSPACE_SNAPSHOT_BOOTSTRAP_NOISE:
|
||||
return True
|
||||
return len(parts) >= 2 and parts[-2] == CLAUDE_BOOTSTRAP_DIR and parts[-1] in CLAUDE_BOOTSTRAP_ENTRIES
|
||||
|
||||
|
||||
def workspace_snapshot(worktree: Path) -> dict[str, str]:
|
||||
"""Hash the workspace without following links, excluding Git internals."""
|
||||
"""Hash the workspace without following links, excluding Git internals
|
||||
and Claude Code's own sandbox-bootstrap noise (see
|
||||
WORKSPACE_SNAPSHOT_BOOTSTRAP_NOISE)."""
|
||||
|
||||
root = worktree.expanduser().absolute()
|
||||
mode = root.lstat().st_mode
|
||||
@@ -74,7 +143,7 @@ def workspace_snapshot(worktree: Path) -> dict[str, str]:
|
||||
raise ValueError(f"workspace snapshot directory is unreadable: {directory}: {exc}") from exc
|
||||
for entry in children:
|
||||
relative = relative_dir / entry.name
|
||||
if relative.parts[0] == ".git":
|
||||
if _is_bootstrap_noise(relative):
|
||||
continue
|
||||
entry_count += 1
|
||||
path_bytes += len(relative.as_posix().encode())
|
||||
@@ -240,6 +309,7 @@ def make_worktree(repo: Path, ref: str, parent: Path) -> Path:
|
||||
"clone",
|
||||
"--no-local",
|
||||
"--no-hardlinks",
|
||||
"--no-tags",
|
||||
"--quiet",
|
||||
str(repo),
|
||||
str(target),
|
||||
|
||||
@@ -18,6 +18,7 @@ from .proposer_sandbox import (
|
||||
SANDBOX_GITNEXUS,
|
||||
SANDBOX_GITNEXUS_REGISTRY,
|
||||
SANDBOX_HOME,
|
||||
SANDBOX_NODE,
|
||||
SANDBOX_TMP,
|
||||
SANDBOX_WORKSPACE,
|
||||
SandboxError,
|
||||
@@ -50,6 +51,8 @@ def measured_cost(raw: Any) -> float | None:
|
||||
if not math.isfinite(raw) or raw < 0:
|
||||
return None
|
||||
return float(raw)
|
||||
|
||||
|
||||
SANDBOX_GITNEXUS_ENTRYPOINT = f"{SANDBOX_GITNEXUS}/dist/cli/index.js"
|
||||
SENSITIVE_EVENT_KEYS = frozenset(
|
||||
{
|
||||
@@ -113,7 +116,7 @@ def sandbox_mcp_config() -> str:
|
||||
"PATH=/usr/local/bin:/usr/bin:/bin",
|
||||
"LANG=C.UTF-8",
|
||||
"GIT_TERMINAL_PROMPT=0",
|
||||
"/usr/local/bin/node",
|
||||
SANDBOX_NODE,
|
||||
SANDBOX_GITNEXUS_ENTRYPOINT,
|
||||
"mcp",
|
||||
],
|
||||
@@ -377,6 +380,13 @@ def run_claude(
|
||||
if strict_mcp_config:
|
||||
cmd += ["--strict-mcp-config", "--mcp-config", mcp_config_json or '{"mcpServers":{}}']
|
||||
if allowed_tools:
|
||||
# --bare's own hard-coded Bash/Edit/Read ceiling already scopes bare
|
||||
# sessions; outside --bare the built-in toolset defaults to
|
||||
# everything (subagents, WebFetch, Task, ...), so --tools is needed
|
||||
# to actually restrict it — --allowedTools only pre-approves within
|
||||
# whatever set is available, it does not narrow that set.
|
||||
if not bare:
|
||||
cmd += ["--tools", *allowed_tools]
|
||||
cmd += ["--allowedTools", *allowed_tools]
|
||||
if disable_slash_commands:
|
||||
cmd.append("--disable-slash-commands")
|
||||
|
||||
@@ -217,6 +217,12 @@ def trusted_gitnexus_runtime_mounts() -> tuple[ReadOnlyMount, ...]:
|
||||
f"{SANDBOX_GITNEXUS_SHARED}/package.json",
|
||||
directory=False,
|
||||
),
|
||||
_validated_runtime_component(
|
||||
runtime,
|
||||
"hooks/claude",
|
||||
f"{SANDBOX_GITNEXUS}/hooks/claude",
|
||||
directory=True,
|
||||
),
|
||||
)
|
||||
|
||||
entrypoint = mounts[0].source / "cli" / "index.js"
|
||||
|
||||
@@ -16,6 +16,7 @@ from .process_control import ManagedProcessError, run_managed
|
||||
from .proposer_sandbox import (
|
||||
SANDBOX_GITNEXUS,
|
||||
SANDBOX_HOME,
|
||||
SANDBOX_NODE,
|
||||
SANDBOX_WORKSPACE,
|
||||
ReadOnlyMount,
|
||||
SandboxError,
|
||||
@@ -248,7 +249,7 @@ def _run_graph_cli(
|
||||
) -> bytes | None:
|
||||
command = [
|
||||
*prefix,
|
||||
"/usr/local/bin/node",
|
||||
SANDBOX_NODE,
|
||||
SANDBOX_GITNEXUS_ENTRYPOINT,
|
||||
*arguments,
|
||||
]
|
||||
|
||||
@@ -25,7 +25,9 @@ from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from .proposer_sandbox import (
|
||||
DEPENDENCY_MOUNT_BASENAME,
|
||||
SANDBOX_WORKSPACE,
|
||||
VITE_TEMP_DIR,
|
||||
ReadOnlyMount,
|
||||
SandboxError,
|
||||
_prepare_clone_target,
|
||||
@@ -39,9 +41,14 @@ MAX_TASK_ASSET_ENTRIES = 100_000
|
||||
MAX_TASK_ASSET_PATH_BYTES = 4_096
|
||||
MAX_TASK_ASSET_BYTES = 2 * 1024 * 1024 * 1024
|
||||
|
||||
# A filesystem without reflink support may still run tiny fixtures. Large
|
||||
# assets fail closed instead of silently returning to one full copy per arm.
|
||||
MAX_BUFFERED_FALLBACK_BYTES = 16 * 1024 * 1024
|
||||
# The largest known real sandbox_copy asset in this harness is the shipped
|
||||
# index above (~428 MiB estimated, ~290 MiB measured); budget comfortably
|
||||
# above that so it can still materialize via buffered copy on a filesystem
|
||||
# that cannot reflink (ext4 CI runners, 9p-backed dev mounts), while staying
|
||||
# well below MAX_TASK_ASSET_BYTES so a genuinely oversized or malformed
|
||||
# declaration still fails closed instead of silently paying for a slow full
|
||||
# copy.
|
||||
MAX_BUFFERED_FALLBACK_BYTES = 512 * 1024 * 1024
|
||||
COPY_CHUNK_BYTES = 1024 * 1024
|
||||
|
||||
# linux/fs.h: #define FICLONE _IOW(0x94, 9, int)
|
||||
@@ -155,9 +162,11 @@ class TaskAssetSnapshot:
|
||||
source = snapshot_root / Path(*dependency.snapshot_path.parts)
|
||||
metadata = source.lstat()
|
||||
expected_directory = dependency.kind == "directory"
|
||||
if stat.S_ISLNK(metadata.st_mode) or (
|
||||
expected_directory and not stat.S_ISDIR(metadata.st_mode)
|
||||
) or (not expected_directory and not stat.S_ISREG(metadata.st_mode)):
|
||||
if (
|
||||
stat.S_ISLNK(metadata.st_mode)
|
||||
or (expected_directory and not stat.S_ISDIR(metadata.st_mode))
|
||||
or (not expected_directory and not stat.S_ISREG(metadata.st_mode))
|
||||
):
|
||||
raise SandboxError(f"dependency snapshot changed: {dependency.source}")
|
||||
target = PurePosixPath(dependency.target)
|
||||
_prepare_clone_target(
|
||||
@@ -208,9 +217,7 @@ class TaskAssetCache:
|
||||
repo_identity = _real_directory(repo, label="task asset repository")
|
||||
declarations, relative_paths = _sandbox_copy_declarations(task)
|
||||
dependency_declarations = _sandbox_dependency_declarations(task)
|
||||
dependency_identity = tuple(
|
||||
(declaration.source, declaration.target) for declaration in dependency_declarations
|
||||
)
|
||||
dependency_identity = tuple((declaration.source, declaration.target) for declaration in dependency_declarations)
|
||||
definition = (str(repo_identity), resolved_sha, declarations, dependency_identity)
|
||||
existing = self._by_definition.get(definition)
|
||||
if existing is not None:
|
||||
@@ -253,6 +260,21 @@ class TaskAssetCache:
|
||||
dependency_builder.copy_descriptor(descriptor, PurePosixPath("payload"))
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
# vitest cannot start against a read-only node_modules: vite
|
||||
# writes <node_modules>/.vite-temp/<config>.timestamp-*.mjs
|
||||
# before loading a TypeScript config. bwrap cannot create
|
||||
# that mount point inside an already-read-only bind, so the
|
||||
# empty directory is captured here -- before the manifest and
|
||||
# both dependency digests are computed, so it is part of the
|
||||
# snapshot rather than an untracked mutation of it. The
|
||||
# sandbox overlays a tmpfs on it; see VITE_TEMP_DIR.
|
||||
payload_entry = dependency_builder.entries.get(PurePosixPath("payload"))
|
||||
if (
|
||||
payload_entry is not None
|
||||
and payload_entry.kind == "directory"
|
||||
and PurePosixPath(declaration.target).name == DEPENDENCY_MOUNT_BASENAME
|
||||
):
|
||||
dependency_builder.ensure_directory(PurePosixPath("payload") / VITE_TEMP_DIR)
|
||||
dependency_entries = dependency_builder.finished_entries()
|
||||
_validate_dependency_symlinks(
|
||||
container,
|
||||
@@ -457,10 +479,14 @@ class _SnapshotBuilder:
|
||||
destination = self.destination / Path(*relative.parts)
|
||||
os.symlink(target, destination)
|
||||
after = os.stat(name, dir_fd=parent_descriptor, follow_symlinks=False)
|
||||
if _mutation_identity(before) != _mutation_identity(after) or os.readlink(
|
||||
name,
|
||||
dir_fd=parent_descriptor,
|
||||
) != target:
|
||||
if (
|
||||
_mutation_identity(before) != _mutation_identity(after)
|
||||
or os.readlink(
|
||||
name,
|
||||
dir_fd=parent_descriptor,
|
||||
)
|
||||
!= target
|
||||
):
|
||||
raise SandboxError(f"dependency symlink changed while snapshotting: {relative}")
|
||||
self.total_bytes += len(target_bytes)
|
||||
self.budget.total_bytes += len(target_bytes)
|
||||
@@ -498,6 +524,15 @@ class _SnapshotBuilder:
|
||||
self.entries[entry.path] = entry
|
||||
self.budget.entries += 1
|
||||
|
||||
def ensure_directory(self, relative: PurePosixPath) -> None:
|
||||
"""Record and create one extra directory inside this snapshot.
|
||||
|
||||
Used for harness-owned mount points that must exist in the captured
|
||||
bytes rather than be created against a read-only bind at runtime.
|
||||
"""
|
||||
|
||||
self._record_directory(relative)
|
||||
|
||||
def finished_entries(self) -> tuple[AssetManifestEntry, ...]:
|
||||
return tuple(sorted(self.entries.values(), key=lambda entry: entry.path.as_posix()))
|
||||
|
||||
@@ -562,9 +597,7 @@ def _sandbox_dependency_declarations(
|
||||
or declaration.target_path in other.target_path.parents
|
||||
or other.target_path in declaration.target_path.parents
|
||||
):
|
||||
raise SandboxError(
|
||||
f"sandbox dependency targets overlap: {declaration.target} and {other.target}"
|
||||
)
|
||||
raise SandboxError(f"sandbox dependency targets overlap: {declaration.target} and {other.target}")
|
||||
return tuple(declarations)
|
||||
|
||||
|
||||
@@ -646,9 +679,7 @@ def _validate_dependency_symlinks(
|
||||
)
|
||||
if sandbox_resolved != sandbox_boundary and sandbox_boundary not in sandbox_resolved.parents:
|
||||
raise SandboxError(f"dependency symlink escapes the sandbox workspace: {entry.path}")
|
||||
manifest_resolved = PurePosixPath(
|
||||
posixpath.normpath((entry.path.parent / target).as_posix())
|
||||
)
|
||||
manifest_resolved = PurePosixPath(posixpath.normpath((entry.path.parent / target).as_posix()))
|
||||
if manifest_resolved != manifest_boundary and manifest_boundary not in manifest_resolved.parents:
|
||||
continue
|
||||
link = container / Path(*entry.path.parts)
|
||||
@@ -1016,8 +1047,7 @@ def _dependency_mounts(
|
||||
snapshot: TaskAssetSnapshot,
|
||||
) -> list[ReadOnlyMount]:
|
||||
declarations = tuple(
|
||||
(declaration.source, declaration.target)
|
||||
for declaration in _sandbox_dependency_declarations(task)
|
||||
(declaration.source, declaration.target) for declaration in _sandbox_dependency_declarations(task)
|
||||
)
|
||||
if snapshot.dependency_declarations != declarations:
|
||||
raise SandboxError("task asset snapshot does not match this dependency declaration")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
|
||||
"version": "1.6.9",
|
||||
"version": "1.6.10-rc.106",
|
||||
"author": {
|
||||
"name": "GitNexus"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
|
||||
"version": "1.6.9",
|
||||
"version": "1.6.10-rc.106",
|
||||
"skills": "./skills",
|
||||
"mcpServers": "./.mcp.json",
|
||||
"hooks": "./hooks/hooks.json",
|
||||
|
||||
@@ -81,6 +81,18 @@ list_repos { offset: 400 } → repos 401–437, hasMore false
|
||||
|
||||
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
|
||||
|
||||
### Inline staleness signal (`query` / `context` / `impact` / `cypher`)
|
||||
|
||||
These four hot read tools attach a non-blocking `staleness` field to their response when the index is behind the checkout's current HEAD — the same `{ commitsBehind, hint }` shape `list_repos` already reports — so a direct tool call surfaces a behind-HEAD index without a separate `list_repos` call:
|
||||
|
||||
```jsonc
|
||||
{ /* …the tool's normal result… */
|
||||
"staleness": { "commitsBehind": 3, "hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update." }
|
||||
}
|
||||
```
|
||||
|
||||
The field is **absent when the index is current** (or when the freshness check can't run), so its presence is the signal. It is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). When you see it, the graph may be behind the working tree — re-run `analyze` before trusting blast-radius or dependence answers.
|
||||
|
||||
### Taint findings (`explain`)
|
||||
|
||||
`explain` returns taint findings recorded by `gitnexus analyze --pdg` — intra-procedural `TAINTED` edges plus cross-function `TAINT_PATH` hops where the interprocedural taint phase found a function-level source→sink chain. Each finding includes a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
|
||||
|
||||
@@ -127,14 +127,14 @@ export type RelationshipType =
|
||||
| 'ENTRY_POINT_OF'
|
||||
| 'WRAPS'
|
||||
| 'QUERIES'
|
||||
/** Dependency-injection edge: a consumer class receives every implementer
|
||||
* of interface `T` via a container-injected collection-typed field
|
||||
* (`List<T>`, `Set<T>`, `Collection<T>`, or `Map<K,T>`). Precondition: the
|
||||
* field carries an injection annotation recognized by a per-language
|
||||
* matcher registered in `di-extractors/` (Java/Spring today: `@Autowired`
|
||||
* or `@Inject`; `@Resource` is excluded — by-name-first semantics).
|
||||
* Source = the consumer Class node (the one owning the field).
|
||||
* Target = an implementing Class node.
|
||||
/** Dependency-injection edge: a consumer class receives a likely provider
|
||||
* through constructor, field, method, or collection injection. A
|
||||
* per-language resolver identifies the site and provider metadata; the
|
||||
* shared DI phase uses type heritage, qualifier names, and preferred
|
||||
* provider markers to resolve it. Ambiguous single injection is represented
|
||||
* by multiple lower-confidence edges instead of a fabricated exact target.
|
||||
* Source = the consumer Class node (the one owning the injection site).
|
||||
* Target = a concrete provider Class node.
|
||||
* Framework specifics live in the `reason` payload (e.g.
|
||||
* `Spring DI: @Autowired List<T>`), not in this type contract.
|
||||
* Lets Cypher queries trace which beans the container injects into a given
|
||||
|
||||
@@ -351,6 +351,11 @@ export interface BindingRef {
|
||||
readonly origin: 'local' | 'import' | 'namespace' | 'wildcard' | 'reexport';
|
||||
/** Non-null for non-local origins; carries the `ImportEdge` that brought the name into this scope. */
|
||||
readonly via?: ImportEdge;
|
||||
/**
|
||||
* Optional semantic visibility evidence supplied by a language hook.
|
||||
* Shared resolution consumes this without inspecting language syntax.
|
||||
*/
|
||||
readonly visibility?: 'static-member-import';
|
||||
}
|
||||
|
||||
// ─── §2.5 TypeRef ───────────────────────────────────────────────────────────
|
||||
|
||||
Generated
+111
-53
@@ -11,14 +11,14 @@
|
||||
"@langchain/anthropic": "^1.5.1",
|
||||
"@langchain/core": "^1.2.2",
|
||||
"@langchain/google-genai": "^2.2.0",
|
||||
"@langchain/langgraph": "^1.4.7",
|
||||
"@langchain/langgraph": "^1.4.8",
|
||||
"@langchain/ollama": "^1.3.0",
|
||||
"@langchain/openai": "^1.5.3",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"axios": "^1.18.1",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.11",
|
||||
"dompurify": "^3.4.12",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
@@ -29,14 +29,14 @@
|
||||
"i18next": "^26.3.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"langchain": "^1.4.6",
|
||||
"lru-cache": "^11.5.1",
|
||||
"lru-cache": "^11.5.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"mnemonist": "^0.40.4",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-i18next": "^17.0.10",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
@@ -47,7 +47,7 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/types": "^8.0.0",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -63,7 +63,7 @@
|
||||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^8.1.4",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10",
|
||||
"wait-on": "^9.0.10"
|
||||
},
|
||||
@@ -186,13 +186,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz",
|
||||
"integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
@@ -221,16 +221,17 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"node_modules/@babel/parser/node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"node_modules/@babel/parser/node_modules/@babel/types": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
@@ -244,6 +245,39 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0.tgz",
|
||||
"integrity": "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^8.0.0",
|
||||
"@babel/helper-validator-identifier": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types/node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz",
|
||||
"integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
@@ -1138,13 +1172,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.7.tgz",
|
||||
"integrity": "sha512-2tcyf3QGC7v89kqSxMCtRvzg/3L/4yHtOaWC49A8KieCciWJs7LGaxHoPB6QRxXyUgyR+Zg9Q1ss/XJIE+JuSQ==",
|
||||
"version": "1.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.8.tgz",
|
||||
"integrity": "sha512-DN1Np1XefdBEbp1qBKlt39cwoL743AAGpR5Ipja0gY2YbWvsoQnOTIrjnj/orSAhaUYsdTKS8VSWdFzsHZo6Ig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/langgraph-checkpoint": "^1.1.3",
|
||||
"@langchain/langgraph-sdk": "~1.9.25",
|
||||
"@langchain/langgraph-sdk": "~1.9.26",
|
||||
"@langchain/protocol": "^0.0.18",
|
||||
"@standard-schema/spec": "1.1.0"
|
||||
},
|
||||
@@ -1169,9 +1203,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph-sdk": {
|
||||
"version": "1.9.25",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.25.tgz",
|
||||
"integrity": "sha512-mRKW8zyQUaHox+HirRFMRrPqOvNbQI3xeXDt6kkk4PbBg77V92bsO1WzUVNrmJ81zCkvxyOrWSK8D6ioCj0a8A==",
|
||||
"version": "1.9.28",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.28.tgz",
|
||||
"integrity": "sha512-4j3XuM0PvtmAbL8mPfBS99ez3+ytRfgbOpAR/nOeaejTRF3Q9dNw2QnaGLGng8wLPtGLoSj+SYgUOVxy9Bv9vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/protocol": "^0.0.18",
|
||||
@@ -1208,9 +1242,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@langchain/langgraph-sdk/node_modules/p-queue": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.0.tgz",
|
||||
"integrity": "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==",
|
||||
"version": "9.3.3",
|
||||
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.3.tgz",
|
||||
"integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.4",
|
||||
@@ -4075,9 +4109,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.11",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
|
||||
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
|
||||
"version": "3.4.12",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
|
||||
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -4357,9 +4391,9 @@
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
|
||||
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -5672,9 +5706,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
|
||||
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
@@ -5721,6 +5755,30 @@
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/magicast/node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magicast/node_modules/@babel/types": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
@@ -6826,9 +6884,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -7216,9 +7274,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.16",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
||||
"version": "8.5.22",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
|
||||
"integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -7235,7 +7293,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -7356,9 +7414,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
||||
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
||||
"version": "17.0.10",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
|
||||
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
@@ -7368,7 +7426,7 @@
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
@@ -7894,9 +7952,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.16",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz",
|
||||
"integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==",
|
||||
"version": "7.5.20",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz",
|
||||
"integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
@@ -8296,15 +8354,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
|
||||
"integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==",
|
||||
"version": "8.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.16",
|
||||
"rolldown": "~1.1.4",
|
||||
"postcss": "^8.5.17",
|
||||
"rolldown": "~1.1.5",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
"@langchain/anthropic": "^1.5.1",
|
||||
"@langchain/core": "^1.2.2",
|
||||
"@langchain/google-genai": "^2.2.0",
|
||||
"@langchain/langgraph": "^1.4.7",
|
||||
"@langchain/langgraph": "^1.4.8",
|
||||
"@langchain/ollama": "^1.3.0",
|
||||
"@langchain/openai": "^1.5.3",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"axios": "^1.18.1",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.11",
|
||||
"dompurify": "^3.4.12",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
@@ -39,14 +39,14 @@
|
||||
"i18next": "^26.3.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"langchain": "^1.4.6",
|
||||
"lru-cache": "^11.5.1",
|
||||
"lru-cache": "^11.5.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"mnemonist": "^0.40.4",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-i18next": "^17.0.10",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
@@ -57,7 +57,7 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/types": "^8.0.0",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -73,7 +73,7 @@
|
||||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^8.1.4",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10",
|
||||
"wait-on": "^9.0.10"
|
||||
},
|
||||
|
||||
+49
-3
@@ -284,6 +284,7 @@ Set these env vars to use a remote OpenAI-compatible `/v1/embeddings` endpoint i
|
||||
export GITNEXUS_EMBEDDING_URL=http://your-server:8080/v1
|
||||
export GITNEXUS_EMBEDDING_MODEL=BAAI/bge-large-en-v1.5
|
||||
export GITNEXUS_EMBEDDING_DIMS=1024 # optional, default 384
|
||||
export GITNEXUS_EMBEDDING_REQUEST_DIMS=omit # optional: omit "dimensions", or an integer to override it
|
||||
export GITNEXUS_EMBEDDING_API_KEY=your-key # optional, default: "unused"
|
||||
export GITNEXUS_EMBEDDING_MAX_ATTEMPTS=3 # optional, total attempts (1-20)
|
||||
export GITNEXUS_EMBEDDING_RETRY_CAP_MS=5000 # optional, maximum retry delay
|
||||
@@ -291,6 +292,15 @@ export GITNEXUS_EMBEDDING_MIN_INTERVAL_MS=0 # optional, minimum request spacing
|
||||
gitnexus analyze . --embeddings
|
||||
```
|
||||
|
||||
`GITNEXUS_EMBEDDING_REQUEST_DIMS` controls only the `dimensions` field sent in
|
||||
the request body, independently of `GITNEXUS_EMBEDDING_DIMS` (which still
|
||||
validates the returned vector's length):
|
||||
|
||||
- `omit` (or `none`, `off`, `false`, `0`) — do not send `dimensions` at all, for
|
||||
strict backends that return the right vector size but reject the field.
|
||||
- a positive integer — send that value instead of `GITNEXUS_EMBEDDING_DIMS`.
|
||||
- unset — send `GITNEXUS_EMBEDDING_DIMS` (the previous behavior).
|
||||
|
||||
Works with Infinity, vLLM, TEI, llama.cpp, Ollama, LM Studio, or OpenAI. Retry and pacing settings are provider-neutral; provider-specific limits should be supplied through configuration. When unset, local embeddings are used unchanged.
|
||||
|
||||
## Multi-Repo Support
|
||||
@@ -472,9 +482,10 @@ Configure the behavior with these environment variables:
|
||||
| `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_STREAM_GRAPH_EMIT` | `0`, `1` | `1` (on) | **On by default** on a full rebuild (`--force`); incremental runs ignore it. Holds structural relationships (CALLS, IMPORTS, ACCESSES, CONTAINS, ...) as CSV-on-disk plus compact in-memory columns instead of as objects in three overlapping indexes, cutting peak in-memory graph heap by ~1.4x at no measurable CPU cost (measured A/B on a synthetic 400k-node / 1.08M-edge graph: 819 MB -> 584 MB, iteration at parity, scaling verified linear from 100k to 800k nodes, with every edge still visible through the graph interface; no end-to-end measurement on a real repository yet). Nothing is traded away — community detection, process extraction, PDG taint summaries and the local-symbol pruner all read a complete relationship set and behave identically. Set to `0` only to bisect a suspected streaming-related fault. |
|
||||
| `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. |
|
||||
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | integer `>= 0` (bytes) | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling for every GitNexus database (analyze, MCP server, serve, group bridges). Bounded so a long-lived `gitnexus mcp` process or a large incremental `analyze` cannot grow toward LadybugDB's native 80%-of-RAM default and OOM the host (#2557). `0` restores that native unbounded default; invalid values warn and fall back to the default. |
|
||||
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | integer `>= 0` (bytes) | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling for every GitNexus database (analyze, MCP server, serve, group bridges). Bounded so a long-lived `gitnexus mcp` process or a large incremental `analyze` cannot grow toward LadybugDB's native 80%-of-RAM default and OOM the host (#2557). `0` restores that native unbounded default; invalid values warn and fall back to the default. During `analyze` the pool is right-sized to the graph and, on non-4 KiB-page hosts (Apple Silicon 16 KiB, Ascend/aarch64 64 KiB), scaled by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. |
|
||||
| `GITNEXUS_LBUG_MAX_DB_SIZE` | positive integer (bytes) | `17179869184` (16 GiB) | Upper bound for a single LadybugDB database file. This is an mmap/disk-address-space ceiling, not a memory limit — it does not constrain the buffer pool (use `GITNEXUS_LBUG_BUFFER_POOL_SIZE` for that). Raise it when indexing genuinely huge monorepos; invalid values silently fall back to the default. |
|
||||
|
||||
```bash
|
||||
@@ -495,15 +506,46 @@ GITNEXUS_FTS_CJK_SEGMENTATION=bigram npx gitnexus analyze --force
|
||||
|
||||
### Analysis runs out of memory
|
||||
|
||||
Memory management is automatic: `analyze` sizes its heap to the machine
|
||||
(always below physical RAM), caps each parse worker, and — rather than
|
||||
grinding into a GC death spiral or crash — stops early with a message telling
|
||||
you the one thing to do. Repeated
|
||||
`Replacement worker did not report ready within 5000ms` warnings on a large
|
||||
repository are part of the same picture: memory pressure starving healthy
|
||||
workers, not a worker bug (#2649).
|
||||
|
||||
If analyze says the repository doesn't fit, do what the message says:
|
||||
|
||||
- **The machine has more memory to give** (a `NODE_OPTIONS`
|
||||
`--max-old-space-size` pin from your environment is holding analyze back):
|
||||
re-run without the pin — no flags needed.
|
||||
- **The machine is the ceiling**: shrink the scope (exclude generated or
|
||||
vendored directories, below) or use a machine with more RAM.
|
||||
|
||||
Escape hatches (`GITNEXUS_MEMORY=off` to decline the autopilot,
|
||||
`GITNEXUS_WORKER_HEAP_MB` to size workers yourself) are listed in the
|
||||
environment-variable table below —
|
||||
most users never need them.
|
||||
|
||||
For very large repositories:
|
||||
|
||||
```bash
|
||||
# Increase Node.js heap size
|
||||
NODE_OPTIONS="--max-old-space-size=16384" npx gitnexus analyze
|
||||
|
||||
# Exclude large directories
|
||||
# Exclude large directories (this repo only)
|
||||
echo "vendor/" >> .gitnexusignore
|
||||
echo "dist/" >> .gitnexusignore
|
||||
|
||||
# Exclude a directory across every repo you index, without touching each
|
||||
# repo's own .gitnexusignore or needing push/commit access to it. GitNexus
|
||||
# reads the same sources `git` itself does: core.excludesFile (all repos)
|
||||
# and $GIT_DIR/info/exclude (this repo only, untracked). A repo's own
|
||||
# .gitignore/.gitnexusignore can still override either with a `!pattern`
|
||||
# negation. Skip both entirely with GITNEXUS_NO_GLOBAL_IGNORE=1.
|
||||
git config --global core.excludesFile ~/.gitignore_global # applies to every repo
|
||||
echo "docs/" >> ~/.gitignore_global
|
||||
echo "build/" >> .git/info/exclude # this repo only, untracked
|
||||
```
|
||||
|
||||
### Large files are being skipped
|
||||
@@ -538,7 +580,7 @@ For repositories with very large source files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BY
|
||||
|
||||
### Worker pool resilience tuning
|
||||
|
||||
Three env vars expose the pool's resilience layers (respawn budget, cumulative-timeout cap, circuit breaker). Defaults are tuned for typical repos; bump them when an analyze legitimately needs more retries, or lower them to fail-fast on a known-bad shape.
|
||||
Four env vars expose the pool's resilience layers (respawn budget, cumulative-timeout cap, circuit breaker, startup handshake). Defaults are tuned for typical repos; bump them when an analyze legitimately needs more retries, or lower them to fail-fast on a known-bad shape.
|
||||
|
||||
| Variable | Default | Effect |
|
||||
| ----------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
@@ -546,6 +588,10 @@ Three env vars expose the pool's resilience layers (respawn budget, cumulative-t
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Bounds exponentially-growing retry waits. |
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, dispatches require a fresh pool. |
|
||||
| `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code — terminated at its next JS-safe point instead of mid-native-call, which would abort the process (`Napi::Error`, #2432). |
|
||||
| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. Raise it on a slow or heavily loaded host where a full pool cold-starting concurrently needs more than 5s. |
|
||||
| `GITNEXUS_MEMORY` | `off` | unset (autopilot on) | `off` declines GitNexus's memory autopilot: analyze will neither re-run itself with a RAM-aware heap cap nor abort the parse before V8 enters its ineffective-mark-compact death spiral. Use it when you want to drive memory manually; to simply pin a heap size, pass Node's own `--max-old-space-size`, which is already honoured as your decision. |
|
||||
| `GITNEXUS_WORKER_HEAP_MB` | `clamp(512, RAM/2/poolSize, 4096)` | Per-worker V8 old-generation heap cap (#2649). Bounds pool RSS on large repos; a worker exceeding it dies with a real heap error handled by quarantine/respawn. |
|
||||
| `GITNEXUS_SERVER_ANALYZE_HEAP_MB` | `min(8192, auto cap)` | Heap for the web/MCP server's forked analyze worker (#2649). Defaults to the historical 8192 MB bounded by the machine/container's RAM-aware auto cap; set an absolute MB value to override. |
|
||||
| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning (#2432). `0` expires immediately. |
|
||||
|
||||
### Graph cleanup tuning
|
||||
|
||||
@@ -1 +1 @@
|
||||
a99e69ab2dfb897ed771c6a8e29c5b32843a7f734db701e0699afc07c090e4d5
|
||||
36e29abc0780bc857b6df6dd180a0b6036c8a28f927ccc2d4fe50eede24d0c99
|
||||
|
||||
@@ -39,19 +39,22 @@
|
||||
},
|
||||
"csharp": {
|
||||
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.",
|
||||
"fingerprint": "75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1",
|
||||
"fingerprint": "e05dc27456bde8175948586c9e7689033a378fa40e9ca4ce78cce41fbea0f2f8",
|
||||
"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 f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a -> 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1; scaling 1.061 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C# method-group/delegate callable flow facts with invocation-result suppression. Prior 2bb5bc8c19cb8eb08c9590545ad8a1968a7152951f7e12746e2d7901d542fed9 -> f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a; scaling 1.115 < 1.5.",
|
||||
"_note": "#2046: F35 qualified-constructor captures now emit @reference.qualified-name + a simple-name @reference.name on `new Ns.Foo()`/`new A.B.Foo()`; namespace_declaration/file_scoped_namespace_declaration now emit @declaration.namespace name captures (feeding the non-destructive namespacePrefix sidecar for `new B.Foo()` same-tail disambiguation). + csharp-interface-only-base and csharp-namespace-qualified-ctor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.11)."
|
||||
"_note": "#2046: F35 qualified-constructor captures now emit @reference.qualified-name + a simple-name @reference.name on `new Ns.Foo()`/`new A.B.Foo()`; namespace_declaration/file_scoped_namespace_declaration now emit @declaration.namespace name captures (feeding the non-destructive namespacePrefix sidecar for `new B.Foo()` same-tail disambiguation). + csharp-interface-only-base and csharp-namespace-qualified-ctor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.11).",
|
||||
"_rebaselined_2563_instance_ownership": "#2563: csharp-using-static adds same-file ownership, local-function, overload, partial-class, and cross-namespace same-name coverage. Prior 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1 -> e05dc27456bde8175948586c9e7689033a378fa40e9ca4ce78cce41fbea0f2f8; scaling 1.058 < 1.5."
|
||||
},
|
||||
"rust": {
|
||||
"fingerprint": "df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29",
|
||||
"fingerprint": "655aed01cf1b6b84fa0c64d48dfb2526ecb67f47d90f0a91edabacd269a212db",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_dyn_trait_object_2604": "#2604: RUST_SCOPE_QUERY now captures function_signature_item (abstract trait methods, no body) as a scope + declaration, so a &dyn Trait receiver can dispatch a CALLS edge to the trait's own method. Additive capture shift across every bench fixture with a required trait method. Prior df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29 -> f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846; scaling 1.033 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c -> df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29; scaling 1.065 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Rust fn-value callable flow facts with invocation/constructor-result suppression. Prior ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82 -> 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c; scaling 1.024 < 1.5.",
|
||||
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) \u2014 legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f."
|
||||
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f.",
|
||||
"_rebaselined_import_disambiguation_2514": "#2514: added rust-import-* and rust-dup-* fixtures under lang-resolution for the range-binding ambiguity latch + import-disambiguated resolution (for-loops / struct destructuring across explicit/aliased/glob use imports). emitRustScopeCaptures is unchanged; the corpus fingerprint shifts purely because the fixture set grew (130 -> 174). Prior f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846 -> 655aed01cf1b6b84fa0c64d48dfb2526ecb67f47d90f0a91edabacd269a212db; scaling 1.06 < 1.5."
|
||||
},
|
||||
"php": {
|
||||
"fingerprint": "4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd",
|
||||
@@ -89,7 +92,7 @@
|
||||
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0."
|
||||
},
|
||||
"java": {
|
||||
"fingerprint": "975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca",
|
||||
"fingerprint": "6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata; same-name lexical regions use an O(ancestor-depth) ID-set lookup. Prior d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a -> 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4; scaling 0.992 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Java method-reference/SAM callable flow facts with invocation-result suppression. Prior 062d754764aaa8a6772fb90875c710502a63e3e7a300e633942381ed914faada -> d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a; scaling 1.074 < 1.5.",
|
||||
@@ -97,7 +100,15 @@
|
||||
"_note": "#1928 / #2045: F35 adds qualified + qualified-generic constructor query captures (`new pkg.Foo()`, `new a.b.Foo()`, `new pkg.Box<T>()`); F38 synthesizes `@reference.call.constructor` on `super(...)`/`this(...)` explicit_constructor_invocation nodes; F41 generic-aware stripQualifier in interpret (type-binding normalization). + java-qualified-constructor and java-explicit-constructor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.06).",
|
||||
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: get/test dropped from callableProtocolMethods. Prior 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4 -> f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67; scaling ratio re-verified within budget.",
|
||||
"_rebaselined_2550_instance_model": "PR #2549 (#2550): anonymous class bodies emit synthesized @declaration.class/@declaration.name (Worker$N), an @reference.inherits to the constructed type, and receiver @type-binding.* captures; six new java-* fixtures joined the corpus. Prior f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67 -> d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90; scaling 1.058 < 1.5.",
|
||||
"_rebaselined_2555_enum_constant_bodies": "PR for #2555: enum constant bodies emit synthesized E$N classes + @reference.inherits to the host enum; anonymous naming follows JLS 13.1 immediately-enclosing-type chains INCLUDING anonymous enclosing types (NestHost$1$1, N$1$1); six new java-* fixtures joined the corpus. Prior d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90 -> 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca; scaling 1.05 < 1.5."
|
||||
"_rebaselined_2555_enum_constant_bodies": "PR for #2555: enum constant bodies emit synthesized E$N classes + @reference.inherits to the host enum; anonymous naming follows JLS 13.1 immediately-enclosing-type chains INCLUDING anonymous enclosing types (NestHost$1$1, N$1$1); six new java-* fixtures joined the corpus. Prior d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90 -> 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca; scaling 1.05 < 1.5.",
|
||||
"_rebaselined_2564_record_capture": "PR for #2564: JAVA_QUERIES gained a (record_declaration name: (identifier) @name) @definition.record capture, previously entirely missing (record_declaration had no structure-phase capture at all, unlike class/interface/enum) - a record's methods existed as ownerless Method nodes with no HAS_METHOD edge. Two new java-* fixtures (java-record-methods, java-new-expr-chain-call) joined the corpus. Prior 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca -> 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537; scaling 1.059 < 1.5.",
|
||||
"_rebaselined_2561_enum_constant_receiver": "PR for #2561: synthesizeJavaAnonymousClassDeclarations now emits a class-scope @type-binding.annotation/name/type per enum constant (constant simple name -> its E$N synthesized class when bodied, else the host enum) so E.CONST.method() resolves through the existing compound-receiver chain walk. Two drivers of the drift, both in the java-enum-constant-body fixture (this bench's corpus IS test/fixtures/lang-resolution): (1) one extra type-binding match per enum_constant from the capture change; (2) review follow-up added a body-less Plain.java enum + EnumConst.dispatchToConstant/dispatchInherited methods (bodied-override, inherited-via-MRO, and body-less dispatch call sites). The review's fail-safe hardening (bodied constant binds ONLY to E$N, never the host enum, when name synthesis fails on a malformed tree) is output-neutral on this well-formed corpus (verified: fingerprint identical with and without it). Prior 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537 -> d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686; scaling < 1.5.",
|
||||
"_rebaselined_2562_local_classes": "#2562: Java block-local classes, enums, records, and interfaces use source-type-relative JLS 13.1 Host$NLocal identities with javac-compatible per-(host, simple-name) numbering; anonymous numbering remains separate. Lexical aliases begin at each declaration and end with its immediate block. Expanded java-local-class-naming fixtures cover declaration order, disjoint blocks, initializers, lambdas, local type kinds, and recursive local/member/anonymous host chains. Prior d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686 -> 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197; scaling 1.204 < 1.5."
|
||||
},
|
||||
"java-local-types": {
|
||||
"fingerprint": "a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#2562 performance follow-up: co-scales same-host, same-name local classes and anonymous classes to gate JLS binary-name ordinal allocation. Precomputed per-sequence ordinals reduce the focused 100->800 workload from 176->6655ms to 141->752ms; normalized 250->800 scaling is 1.054."
|
||||
},
|
||||
"typescript": {
|
||||
"fingerprint": "3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4",
|
||||
@@ -122,7 +133,7 @@
|
||||
"_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object. Prior 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b -> f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c; scaling 1.096 < 1.5."
|
||||
},
|
||||
"kotlin": {
|
||||
"fingerprint": "a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091",
|
||||
"fingerprint": "9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195",
|
||||
"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 bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12 -> e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1; scaling 1.090 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Kotlin callable-reference flow facts with invocation-result suppression. Prior 4900431791f2b9280009deb2b82659c26ead8aa6fb8731190a7c505dec5a9041 -> bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12; scaling 0.880 < 1.5.",
|
||||
@@ -130,6 +141,7 @@
|
||||
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0.",
|
||||
"_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land \u2014 until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear).",
|
||||
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: fieldless assignment nodes decomposed positionally. Prior e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1 -> 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112; scaling ratio re-verified within budget.",
|
||||
"_rebaselined_2550_instance_model": "PR #2549 (#2545): anonymous object expressions (object_literal) emit @scope.class, and the kotlin-object-literal-scope fixture joined the corpus. Prior 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112 -> a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091; scaling 0.951 < 1.5."
|
||||
"_rebaselined_2550_instance_model": "PR #2549 (#2545): anonymous object expressions (object_literal) emit @scope.class, and the kotlin-object-literal-scope fixture joined the corpus. Prior 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112 -> a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091; scaling 0.951 < 1.5.",
|
||||
"_rebaselined_2563_instance_ownership": "#2563: kotlin-instance-ownership adds unrelated, inherited, outer-instance, and anonymous-object coverage. Prior a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091 -> 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195; scaling 1.257 < 1.5."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +264,23 @@ const LANGS = [
|
||||
` public long getId() { return this.id; }\n` +
|
||||
` public void setName(String v) { this.name = v; }\n}\n\n`,
|
||||
},
|
||||
{
|
||||
name: 'java-local-types',
|
||||
emit: emitJavaScopeCaptures,
|
||||
fixturePrefix: 'java-local',
|
||||
exts: ['.java'],
|
||||
file: 'bench-local.java',
|
||||
header:
|
||||
'package generated;\n\nclass Base {}\n\ninterface Marker {}\n\nclass Bench {\n void run() {\n',
|
||||
// Co-scale both independent ordinal sequences under one host; construction
|
||||
// and dispatch keep lexical-alias captures hot. The old per-identity
|
||||
// host-candidate filter made this combined workload quadratic.
|
||||
unit: (n) =>
|
||||
` { class Local extends Base implements Marker { long value() { return ${n}L; } } ` +
|
||||
`new Local().value(); }\n` +
|
||||
` Marker marker${n} = new Marker() {};\n`,
|
||||
footer: ' }\n}\n',
|
||||
},
|
||||
{
|
||||
name: 'typescript',
|
||||
emit: emitTsScopeCaptures,
|
||||
@@ -309,7 +326,7 @@ const LANGS = [
|
||||
function generate(lang, entityCount) {
|
||||
let src = lang.header;
|
||||
for (let i = 0; i < entityCount; i++) src += lang.unit(i);
|
||||
return src;
|
||||
return src + (lang.footer ?? '');
|
||||
}
|
||||
|
||||
// ---- timing ----
|
||||
|
||||
Generated
+78
-84
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.9",
|
||||
"version": "1.6.10-rc.106",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.9",
|
||||
"version": "1.6.10-rc.106",
|
||||
"hasInstallScript": true,
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"dependencies": {
|
||||
"@ladybugdb/core": "^0.18.0",
|
||||
"@ladybugdb/core": "^0.18.3",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"busboy": "^1.6.0",
|
||||
@@ -24,7 +24,7 @@
|
||||
"graphology-indices": "^0.17.0",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"ignore": "^7.0.5",
|
||||
"js-yaml": "^4.1.1",
|
||||
"js-yaml": "^5.0.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"mnemonist": "^0.40.3",
|
||||
"node-addon-api": "^8.0.0",
|
||||
@@ -58,9 +58,7 @@
|
||||
"@types/cli-progress": "^3.11.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@types/node": "^26.0.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"tsx": "^4.0.0",
|
||||
@@ -68,7 +66,7 @@
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@huggingface/transformers": "^4.1.0",
|
||||
@@ -1254,9 +1252,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ladybugdb/core": {
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.18.1.tgz",
|
||||
"integrity": "sha512-0c1kXDpdv7z/GB0oyFYnLEjLsXFwPHz1YD4wxtrk9hav8zJX5T1PHQMr+XRfdDI1NQjx4iNdbPQGGT7Bx/X2aw==",
|
||||
"version": "0.18.3",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.18.3.tgz",
|
||||
"integrity": "sha512-XjpPKW4MrL28D2gYGTZuIjiEcPx12L21lx58QggrdrItw8o/e9Lmg/Ejoo4Kz08lZj+rIcC1Fu9thzIYOTUlJw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1265,17 +1263,17 @@
|
||||
"node-addon-api": "^6.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@ladybugdb/core-darwin-arm64": "0.18.1",
|
||||
"@ladybugdb/core-darwin-x64": "0.18.1",
|
||||
"@ladybugdb/core-linux-arm64": "0.18.1",
|
||||
"@ladybugdb/core-linux-x64": "0.18.1",
|
||||
"@ladybugdb/core-win32-x64": "0.18.1"
|
||||
"@ladybugdb/core-darwin-arm64": "0.18.3",
|
||||
"@ladybugdb/core-darwin-x64": "0.18.3",
|
||||
"@ladybugdb/core-linux-arm64": "0.18.3",
|
||||
"@ladybugdb/core-linux-x64": "0.18.3",
|
||||
"@ladybugdb/core-win32-x64": "0.18.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@ladybugdb/core-darwin-arm64": {
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.18.1.tgz",
|
||||
"integrity": "sha512-M5YZuAONRAv3awkr+cfaibn9Da+3pgDzRiek/JabWQuz48xgzW3Vh9yQH4s8Dq/bfQo6YTsaLIBRcUCCUzCtcg==",
|
||||
"version": "0.18.3",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.18.3.tgz",
|
||||
"integrity": "sha512-DGZTOlvSS4esEb1vTekY5IDoAvZAeYzR5cXVkECtQj9BVkk05zsvCAdTPo1Rz1BuI0qvqUVF+2WlIerI67iA2g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1286,9 +1284,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core-darwin-x64": {
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-x64/-/core-darwin-x64-0.18.1.tgz",
|
||||
"integrity": "sha512-kq+pyTskfCx++Mrbk7QssE/f/CpSuU50T8lhRtv4PaOKhC2Jf8/wAUOA17UxI594wAru3ERpqVBFUBWGcPk2ag==",
|
||||
"version": "0.18.3",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-x64/-/core-darwin-x64-0.18.3.tgz",
|
||||
"integrity": "sha512-Qp6j0CM/orBlK6KD0p/s4ofkIhNUwi1hdCgMw+fj81UHugWHkVLiYV4grRBdHhyplw+snchZpTxvfpxFbkG1Cw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1299,9 +1297,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core-linux-arm64": {
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.18.1.tgz",
|
||||
"integrity": "sha512-fu7ke1haa5rPINcQn0+kxQijZ0A8ZDWP9e+X8xcDH94RagDbPWwG8yFC890cGSdc/j7mTV+xkA/y/kVHpmVI6w==",
|
||||
"version": "0.18.3",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.18.3.tgz",
|
||||
"integrity": "sha512-F9miYjBuS43I7uNG199FNMqwdHJ98WA6dU3v2SZCeLXmXCdRzmYcuHQWlbNr2Tba9CX58w2XvBZoUaXZKJ/yKQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1312,9 +1310,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core-linux-x64": {
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.18.1.tgz",
|
||||
"integrity": "sha512-qp5HilHzDGuArfOyD+VyA7lVJ7IwQDKd81NZKKTmUwIAOJtdwqniYx6JZICPnlr36zFJBx/lGYoSsEzbC+TVdw==",
|
||||
"version": "0.18.3",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.18.3.tgz",
|
||||
"integrity": "sha512-AfG5RDp/f/IDctDMpTAT5+2MYNtlWT191xiQNjSaWD4X85DhY3Dzps8Qu5VteIAPih5d6mmoaKGs8q0XIjfkFA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1325,9 +1323,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core-win32-x64": {
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.18.1.tgz",
|
||||
"integrity": "sha512-vHcXr7Df2X1dbb5ORK+SBmNstd/3tApGFImbAnaWiTuLDFlAdfY8lbiSBSp3OgFjc0BB7F3GYUUdvgDRJjK3zA==",
|
||||
"version": "0.18.3",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.18.3.tgz",
|
||||
"integrity": "sha512-bHuFk0m9cnq0WGd9I4D8or8g6cC/BS58iatMtilqM3JpDPIQIFk6MQl6exL7P4xyWbkLwQgsrv2ToDnyoQNKvg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1931,13 +1929,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/js-yaml": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsesc": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz",
|
||||
@@ -1946,13 +1937,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz",
|
||||
"integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==",
|
||||
"version": "26.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
||||
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
@@ -1990,17 +1981,6 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-11.0.0.tgz",
|
||||
"integrity": "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==",
|
||||
"deprecated": "This is a stub types definition. uuid provides its own type definitions, so you do not need this installed.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uuid": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
|
||||
@@ -2316,20 +2296,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^1.0.5",
|
||||
"content-type": "^2.0.0",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"http-errors": "^2.0.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.14.1",
|
||||
"raw-body": "^3.0.1",
|
||||
"type-is": "^2.0.1"
|
||||
"qs": "^6.15.2",
|
||||
"raw-body": "^3.0.2",
|
||||
"type-is": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -2339,10 +2319,23 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/content-type": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -3013,11 +3006,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.5.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
||||
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||
"version": "8.6.0",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz",
|
||||
"integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3049,9 +3043,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
|
||||
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -3410,9 +3404,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.26",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz",
|
||||
"integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==",
|
||||
"version": "4.12.31",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz",
|
||||
"integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -3589,9 +3583,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.0.0.tgz",
|
||||
"integrity": "sha512-GSvaPUbk1U+FMZ7rJzF+F8e5YVtu7KnD40et/5rBXXRBv2jCO9L3qCewvIDDdudC0QycTFlf6EAA+h3kxBsuUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -3607,7 +3601,7 @@
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
"js-yaml": "bin/js-yaml.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
@@ -5135,9 +5129,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.16",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz",
|
||||
"integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==",
|
||||
"version": "7.5.20",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz",
|
||||
"integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
@@ -5510,9 +5504,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.9",
|
||||
"version": "1.6.10-rc.106",
|
||||
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
|
||||
"author": "Abhigyan Patwari",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
@@ -56,7 +56,7 @@
|
||||
"version": "node scripts/sync-plugin-manifests.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ladybugdb/core": "^0.18.0",
|
||||
"@ladybugdb/core": "^0.18.3",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"busboy": "^1.6.0",
|
||||
@@ -70,7 +70,7 @@
|
||||
"graphology-indices": "^0.17.0",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"ignore": "^7.0.5",
|
||||
"js-yaml": "^4.1.1",
|
||||
"js-yaml": "^5.0.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"mnemonist": "^0.40.3",
|
||||
"node-addon-api": "^8.0.0",
|
||||
@@ -105,9 +105,7 @@
|
||||
"@types/cli-progress": "^3.11.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@types/node": "^26.0.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"tsx": "^4.0.0",
|
||||
@@ -120,6 +118,6 @@
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,19 @@ const PLATFORM_LOGIC = [
|
||||
// must exercise the Windows backslash branch, so run it on the OS matrix (#2394).
|
||||
'test/unit/cli-entry.test.ts',
|
||||
'test/unit/platform-capabilities.test.ts',
|
||||
// Windows drive-letter case variance in the analyzer runner-identity path
|
||||
// fields (#2668): normalizeAnalyzerRootPath is a POSIX no-op, so the
|
||||
// "identity path fields are normalizer-stable" fixpoint guard only bites on
|
||||
// the windows-latest matrix — it must run there, not just in the Ubuntu
|
||||
// full-suite where it's trivially green. Deliberately the split-out
|
||||
// normalization file, NOT analyzer-identity.test.ts: the latter's fixture
|
||||
// tests compare identity fields against raw temp-dir paths and fail on macOS,
|
||||
// where /var/... realpaths to /private/var/....
|
||||
'test/unit/analyzer-identity-path-normalization.test.ts',
|
||||
// `isInside` containment guard vs Windows cross-drive paths: path.relative
|
||||
// returns the absolute target across drives, so the guard needs isAbsolute.
|
||||
// Fixture-free and pathApi-injectable, so it is portable to every runner.
|
||||
'test/unit/analyzer-identity-is-inside.test.ts',
|
||||
// getconf page-size probe: explicit process.platform gate (win32 short-circuit)
|
||||
// plus a live-probe test whose only real non-4K coverage is macos-arm64's
|
||||
// 16 KiB pages — the exact hardware class #1231 targets (#2424 review).
|
||||
@@ -79,6 +92,13 @@ const PLATFORM_LOGIC = [
|
||||
// POSIX and Windows — the fail-closed path-claim semantics must hold on the
|
||||
// real windows-latest path implementation (#2419/#2420).
|
||||
'test/unit/server-api-repo-resolution.test.ts',
|
||||
// The index write-lock (#2658) selects its backend by process.platform — the
|
||||
// OS socket lock (Windows named pipe / Linux abstract socket) vs the file
|
||||
// fallback — and its socket-backend describe block is gated to linux/win32.
|
||||
// The Ubuntu suite only proves the Linux abstract-socket path, so run it here
|
||||
// to exercise the Windows named-pipe backend and the macOS file fallback on
|
||||
// their real platforms (#2658 review H3).
|
||||
'test/unit/index-lock.test.ts',
|
||||
];
|
||||
|
||||
// Native LadybugDB integration tests — exercise the @ladybugdb/core
|
||||
@@ -117,6 +137,12 @@ const LBUG_NATIVE = [
|
||||
// to a live native DB, rm-then-rename over an existing parked copy) before
|
||||
// any open — rename semantics are exactly what differs on Windows.
|
||||
'test/unit/incremental-dirty-recovery.test.ts',
|
||||
// #2623: the incremental writeback must load VECTOR before the CodeEmbedding
|
||||
// join-delete, and the blocked path must escalate instead of crashing. The
|
||||
// win32 VECTOR gate was removed in the same PR, so this ordering must be
|
||||
// proven on the windows-latest native addon, not just Ubuntu. Budget: ~25s
|
||||
// on Linux → expect ~2min on the slowest Windows shard.
|
||||
'test/unit/incremental-vector-extension-ordering.test.ts',
|
||||
];
|
||||
|
||||
// Process spawning and CLI tests — exercise child_process with real
|
||||
@@ -141,6 +167,14 @@ const SPAWN_CLI = [
|
||||
'test/integration/antigravity-hook-e2e.test.ts',
|
||||
'test/unit/local-cli-subprocess.test.ts',
|
||||
'test/unit/runner-exec-tail.test.ts',
|
||||
// Real cross-process single-writer lock coordination (#2658): child processes
|
||||
// contend for the lock and race to reclaim a dead holder. Process spawning,
|
||||
// kernel socket auto-release (Win named pipe / Linux abstract socket), and the
|
||||
// FILE-backend rename-steal reclaim (macOS/BSD default) all vary across OSes —
|
||||
// the exact behaviors the Windows/macOS matrix must prove. macOS timing first
|
||||
// exposed a file-backend double-admit race here (#2658 review); the reclaim is
|
||||
// now judgment-verified so a live holder is never displaced.
|
||||
'test/integration/analyze-index-lock-concurrency.test.ts',
|
||||
];
|
||||
|
||||
// Worker threads tests — exercise real worker_threads which have
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Install the LadybugDB FTS extension into the shared home (~/.lbdb) up front, so
|
||||
* every test in a sharded CI run finds it regardless of which shard it lands in.
|
||||
* Install the LadybugDB FTS and VECTOR extensions into the shared home (~/.lbdb)
|
||||
* up front, so every test in a sharded CI run finds them regardless of shard.
|
||||
*
|
||||
* FTS-dependent tests split two ways: the LOAD-path gate (skipUnlessFtsAvailable)
|
||||
* self-installs on miss, but the FILE-path gate (requireFtsResourceOrSkip, e.g.
|
||||
@@ -17,13 +17,24 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { initLbug, loadFTSExtension, closeLbug } from '../src/core/lbug/lbug-adapter.js';
|
||||
import {
|
||||
initLbug,
|
||||
loadFTSExtension,
|
||||
loadVectorExtension,
|
||||
closeLbug,
|
||||
} from '../src/core/lbug/lbug-adapter.js';
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gn-ensure-fts-'));
|
||||
try {
|
||||
await initLbug(join(dir, 'ensure-fts.lbug'));
|
||||
const ok = await loadFTSExtension(undefined, { policy: 'auto' });
|
||||
console.log(ok ? 'FTS extension ready.' : 'FTS extension unavailable (continuing).');
|
||||
// VECTOR rides the same pre-install (#2623): the win32 gate is gone, so the
|
||||
// vector suites genuinely run on Windows/macOS — installing once here means
|
||||
// every sharded test process LOADs from ~/.lbdb instead of racing its own
|
||||
// out-of-process INSTALL (bounded 15s each when the server is unreachable).
|
||||
const vec = await loadVectorExtension(undefined, { policy: 'auto' });
|
||||
console.log(vec ? 'VECTOR extension ready.' : 'VECTOR extension unavailable (continuing).');
|
||||
} catch (err) {
|
||||
console.warn(`ensure-fts: skipped (${err instanceof Error ? err.message : String(err)})`);
|
||||
} finally {
|
||||
|
||||
@@ -362,13 +362,32 @@ async function upsertGitNexusSection(
|
||||
}
|
||||
|
||||
/**
|
||||
* Install GitNexus skills as direct children of .claude/skills/
|
||||
* Works natively with Claude Code, Cursor, and GitHub Copilot
|
||||
* Some agents read skills from a repo-local `.agents/skills/` directory and
|
||||
* prefer it over the global `~/.agents/skills/` install. When the repo contains
|
||||
* an `.agents/` directory, skills written to `.claude/skills/` are mirrored
|
||||
* there too so those agents serve the up-to-date copies.
|
||||
*/
|
||||
async function installSkills(repoPath: string): Promise<string[]> {
|
||||
export async function shouldMirrorSkillsToAgents(repoPath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(path.join(repoPath, '.agents'));
|
||||
return stat.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install GitNexus skills as direct children of .claude/skills/
|
||||
* Works natively with Claude Code, Cursor, and GitHub Copilot.
|
||||
* Mirrored to .agents/skills/ when .agents/ exists.
|
||||
*/
|
||||
async function installSkills(
|
||||
repoPath: string,
|
||||
): Promise<{ skills: string[]; agentsMirror: boolean }> {
|
||||
const skillsDir = path.join(repoPath, '.claude', 'skills');
|
||||
const legacySkillsDir = path.join(skillsDir, 'gitnexus');
|
||||
const installedSkills: string[] = [];
|
||||
const agentsMirror = await shouldMirrorSkillsToAgents(repoPath);
|
||||
|
||||
for (const skill of STANDARD_SKILL_CATALOG.filter(
|
||||
(entry) => entry.distributions.project && entry.distributions.npm,
|
||||
@@ -402,6 +421,18 @@ Use GitNexus tools to accomplish this task.
|
||||
}
|
||||
|
||||
await fs.writeFile(skillPath, skillContent, 'utf-8');
|
||||
|
||||
// Mirror to .agents/skills/ for agents that read repo-local skills
|
||||
if (agentsMirror) {
|
||||
try {
|
||||
const agentsSkillDir = path.join(repoPath, '.agents', 'skills', skill.name);
|
||||
await fs.mkdir(agentsSkillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(agentsSkillDir, 'SKILL.md'), skillContent, 'utf-8');
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Warning: Could not mirror skill ${skill.name} to .agents/skills:`);
|
||||
}
|
||||
}
|
||||
|
||||
installedSkills.push(skill.name);
|
||||
|
||||
// Previous releases installed these known standard skills one level too
|
||||
@@ -418,7 +449,7 @@ Use GitNexus tools to accomplish this task.
|
||||
}
|
||||
}
|
||||
|
||||
return installedSkills;
|
||||
return { skills: installedSkills, agentsMirror };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -496,9 +527,14 @@ export async function generateAIContextFiles(
|
||||
|
||||
// Install standard skills directly under .claude/skills/ (unless --skip-skills)
|
||||
if (!options?.skipSkills) {
|
||||
const installedSkills = await installSkills(repoPath);
|
||||
const { skills: installedSkills, agentsMirror } = await installSkills(repoPath);
|
||||
if (installedSkills.length > 0) {
|
||||
createdFiles.push(`.claude/skills/gitnexus-*/ (${installedSkills.length} skills)`);
|
||||
if (agentsMirror) {
|
||||
createdFiles.push(
|
||||
`.agents/skills/gitnexus-*/ (${installedSkills.length} skills mirrored for .agents)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
createdFiles.push('.claude/skills/gitnexus-*/ (skipped via --skip-skills)');
|
||||
|
||||
+142
-26
@@ -18,6 +18,7 @@ import { boundedCheckpointBeforeExit } from '../core/lbug/shutdown-helpers.js';
|
||||
import {
|
||||
getOsPageSize,
|
||||
isLbugCheckpointIoError,
|
||||
isLbugCheckpointBusyError,
|
||||
isLbugPageSizeFrameError,
|
||||
isPageSizeAwareLadybug,
|
||||
isWalCorruptionError,
|
||||
@@ -32,7 +33,14 @@ import {
|
||||
assertAnalysisFinalized,
|
||||
type AnalyzerRunnerIdentity,
|
||||
} from '../storage/repo-manager.js';
|
||||
import { getGitRoot, hasGitDir, getDefaultBranch } from '../storage/git.js';
|
||||
import {
|
||||
getGitRoot,
|
||||
hasGitDir,
|
||||
getDefaultBranch,
|
||||
selfCommitContextFiles,
|
||||
snapshotSelfCommitSafety,
|
||||
} from '../storage/git.js';
|
||||
import { IndexLockTimeoutError } from '../storage/index-lock.js';
|
||||
import {
|
||||
loadAnalyzeConfig,
|
||||
mergeAnalyzeOptions,
|
||||
@@ -46,7 +54,8 @@ import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-si
|
||||
import { warnMissingOptionalGrammars, getOptionalGrammarExtensions } from './optional-grammars.js';
|
||||
import { glob } from 'glob';
|
||||
import fs from 'fs/promises';
|
||||
import { cliError } from './cli-message.js';
|
||||
import { cliError, cliWarn } from './cli-message.js';
|
||||
import { heapCapMbFor, memoryAutopilotDisabled } from '../core/ingestion/utils/effective-ram.js';
|
||||
import { EMBEDDING_DIMS_ERROR, normalizeEmbeddingDims } from './embedding-dims.js';
|
||||
import { formatElapsed } from './format-elapsed.js';
|
||||
import { isHfDownloadFailure } from '../core/embeddings/hf-env.js';
|
||||
@@ -127,25 +136,21 @@ const installFatalHandlers = (): void => {
|
||||
});
|
||||
};
|
||||
|
||||
/** Historical floor for the re-exec heap cap — the auto-sizer never goes below
|
||||
* this, so small boxes / CI never regress. */
|
||||
const DEFAULT_HEAP_MB = 16384;
|
||||
|
||||
/**
|
||||
* RAM-aware re-exec heap cap (MB): `0.75 × effective RAM`, clamped to
|
||||
* `>= DEFAULT_HEAP_MB`. Kept BELOW physical RAM on purpose — a cap `>=` RAM makes
|
||||
* V8 collect lazily and inflate the heap into swap-thrash (observed analyzing the
|
||||
* Linux kernel at a 30GB cap on a 31GB box). `constrainedBytes` is the cgroup
|
||||
* limit or `null`; it is honored only as a real, smaller-than-physical cap, because
|
||||
* RAM-aware re-exec heap cap (MB) — the formula itself is single-sourced in
|
||||
* `core/ingestion/utils/effective-ram.ts` (`heapCapMbFor`), shared with the
|
||||
* server's analyze fork. `constrainedBytes` is the cgroup limit or `null`;
|
||||
* it is honored only as a real, smaller-than-physical cap, because
|
||||
* `process.constrainedMemory()` returns a huge sentinel when UNCONSTRAINED.
|
||||
* (Observed rationale: a cap ≥ RAM made V8 collect lazily and swap-thrash —
|
||||
* the #2649 worker-timeout cascade on 16 GB boxes.)
|
||||
*/
|
||||
export function computeHeapCapMb(totalBytes: number, constrainedBytes: number | null): number {
|
||||
const effectiveBytes =
|
||||
constrainedBytes !== null && constrainedBytes > 0 && constrainedBytes < totalBytes
|
||||
? constrainedBytes
|
||||
: totalBytes;
|
||||
const effectiveMb = Math.floor(effectiveBytes / (1024 * 1024));
|
||||
return Math.max(DEFAULT_HEAP_MB, Math.floor(0.75 * effectiveMb));
|
||||
return heapCapMbFor(effectiveBytes);
|
||||
}
|
||||
|
||||
function readConstrainedBytes(): number | null {
|
||||
@@ -515,21 +520,69 @@ const forceHeapOOMForTestIfEnabled = (): void => {
|
||||
// `gitnexus/src/core/lbug/lbug-config.ts` in sync with this value.
|
||||
const RECOMMENDED_WAL_CHECKPOINT_THRESHOLD = 64 * 1024 * 1024;
|
||||
|
||||
/** Re-exec the process with the RAM-aware auto heap cap + larger semi-space/stack
|
||||
* if we're currently below that. A user-supplied NODE_OPTIONS heap wins (no re-exec). */
|
||||
async function ensureHeap(): Promise<boolean> {
|
||||
const nodeOpts = process.env.NODE_OPTIONS || '';
|
||||
if (nodeOpts.includes('--max-old-space-size')) return false;
|
||||
/**
|
||||
* Last `--max-old-space-size` value (MB) in a NODE_OPTIONS string, or `null`
|
||||
* when absent/unparseable. Last occurrence wins, matching V8's own
|
||||
* later-flag-wins semantics when NODE_OPTIONS repeats a flag.
|
||||
*/
|
||||
export function parseMaxOldSpaceMb(nodeOptions: string): number | null {
|
||||
// V8 accepts `-` and `_` interchangeably in flag names, and Node accepts a
|
||||
// space-separated value in NODE_OPTIONS — honor every spelling of the pin
|
||||
// instead of silently overriding it (#2649 review).
|
||||
const matches = [...nodeOptions.matchAll(/--max[-_]old[-_]space[-_]size(?:=|\s+)(\d+)/g)];
|
||||
if (matches.length === 0) return null;
|
||||
const mb = Number(matches[matches.length - 1][1]);
|
||||
return Number.isFinite(mb) && mb > 0 ? mb : null;
|
||||
}
|
||||
|
||||
const v8Heap = v8.getHeapStatistics().heap_size_limit;
|
||||
if (v8Heap >= HEAP_MB * 1024 * 1024 * 0.9) return false;
|
||||
/** Re-exec the process with the RAM-aware auto heap cap + larger semi-space/stack
|
||||
* if we're currently below that.
|
||||
*
|
||||
* Heap-source precedence (#2649):
|
||||
* - an explicit per-invocation `--max-old-space-size` (execArgv) always wins;
|
||||
* - `GITNEXUS_MEMORY=off` declines the memory autopilot entirely;
|
||||
* - an ambient NODE_OPTIONS heap >= the auto cap is honored as-is;
|
||||
* - an ambient NODE_OPTIONS heap BELOW the auto cap is treated as an
|
||||
* inherited environment default (devcontainers/CI export one for other
|
||||
* tooling), not a deliberate per-run choice: warn and respawn with the
|
||||
* auto cap. Pre-#2649 this returned early and large repos then OOM'd on
|
||||
* whatever heap the environment happened to specify. */
|
||||
async function ensureHeap(): Promise<boolean> {
|
||||
// Explicit opt-out disables auto-sizing ENTIRELY — both the ambient-pin
|
||||
// override and the default v8-limit respawn — and is honored SILENTLY:
|
||||
// the operator already made the call, and stderr-sensitive consumers
|
||||
// (test harnesses, scripts, supervisors that track a single PID) rely on
|
||||
// a quiet, single-process run.
|
||||
if (memoryAutopilotDisabled()) return false;
|
||||
const nodeOpts = process.env.NODE_OPTIONS || '';
|
||||
if (process.execArgv.some((a) => a.startsWith('--max-old-space-size'))) return false;
|
||||
|
||||
const ambientHeapMb = parseMaxOldSpaceMb(nodeOpts);
|
||||
if (ambientHeapMb !== null) {
|
||||
if (ambientHeapMb >= RESPAWN_HEAP_MB) return false;
|
||||
cliWarn(
|
||||
` NODE_OPTIONS pins the heap to ${ambientHeapMb}MB — below the ${RESPAWN_HEAP_MB}MB this machine's RAM supports.\n` +
|
||||
` Re-running analyze with the larger auto-sized cap (set GITNEXUS_MEMORY=off to keep the NODE_OPTIONS value).\n`,
|
||||
);
|
||||
} else {
|
||||
const v8Heap = v8.getHeapStatistics().heap_size_limit;
|
||||
if (v8Heap >= HEAP_MB * 1024 * 1024 * 0.9) return false;
|
||||
}
|
||||
|
||||
// --stack-size is a V8 flag not allowed in NODE_OPTIONS on Node 24+, so pass it
|
||||
// only as a direct CLI argument. --max-semi-space-size IS allowed in NODE_OPTIONS.
|
||||
const cliFlags = [HEAP_FLAG, SEMI_FLAG];
|
||||
if (!nodeOpts.includes('--stack-size')) cliFlags.push(STACK_FLAG);
|
||||
|
||||
const childArgs = [...cliFlags, ...process.argv.slice(1)];
|
||||
// Preserve the parent's node flags (execArgv) — dropping them breaks any
|
||||
// loader-launched CLI: `node --import tsx src/cli/index.ts` respawned
|
||||
// without `--import tsx` cannot execute TypeScript and dies with a
|
||||
// swallowed exit 1 (#2649 review). Our heap/semi/stack flags come AFTER
|
||||
// execArgv so V8's later-flag-wins semantics resolve duplicates our way.
|
||||
// Inspector flags are the one exception: replaying `--inspect[-brk]` makes
|
||||
// the child fight the parent for the debug port and die with EADDRINUSE.
|
||||
const preservedExecArgv = process.execArgv.filter((a) => !a.startsWith('--inspect'));
|
||||
const childArgs = [...preservedExecArgv, ...cliFlags, ...process.argv.slice(1)];
|
||||
const childEnv = {
|
||||
...process.env,
|
||||
NODE_OPTIONS: `${nodeOpts} ${HEAP_FLAG} ${SEMI_FLAG}`.trim(),
|
||||
@@ -647,6 +700,13 @@ export interface AnalyzeOptions {
|
||||
* default-on case.
|
||||
*/
|
||||
stats?: boolean;
|
||||
/**
|
||||
* Opt-in auto-commit of any AGENTS.md/CLAUDE.md changes this `analyze` run
|
||||
* makes. Scoped to only those two files (never `git add -A`); no-ops
|
||||
* silently if neither exists, neither changed, or the commit step itself
|
||||
* fails (e.g. no git identity configured). See #2639.
|
||||
*/
|
||||
selfCommit?: boolean;
|
||||
/** Skip installing standard GitNexus skill files directly under .claude/skills/. */
|
||||
skipSkills?: boolean;
|
||||
/**
|
||||
@@ -1393,6 +1453,15 @@ const analyzeCommandImpl = async (
|
||||
const bootstrapArgs: [] | [AnalyzerRunnerIdentity] = runnerIdentityAtBootstrap
|
||||
? [runnerIdentityAtBootstrap]
|
||||
: [];
|
||||
// #2639 review round 2: snapshot which of AGENTS.md/CLAUDE.md are safe to
|
||||
// auto-commit BEFORE runFullAnalysis (and the --skills regeneration
|
||||
// further down) writes to them, so selfCommitContextFiles can tell a
|
||||
// pre-existing unstaged user edit apart from this run's stats refresh
|
||||
// and refuse to sweep the former into the latter's commit.
|
||||
const selfCommitSafety =
|
||||
options.selfCommit === true
|
||||
? snapshotSelfCommitSafety(repoPath, ['AGENTS.md', 'CLAUDE.md'])
|
||||
: undefined;
|
||||
const result = await runFullAnalysis(repoPath, runOptions, runCallbacks, ...bootstrapArgs);
|
||||
|
||||
if (result.alreadyUpToDate) {
|
||||
@@ -1437,6 +1506,11 @@ const analyzeCommandImpl = async (
|
||||
` Updated base_ref to "${resolvedDefaultBranch}" in ${baseRefRefreshed.join(', ')}\n`,
|
||||
);
|
||||
}
|
||||
// #2639: opt-in self-commit of any AGENTS.md/CLAUDE.md churn from this
|
||||
// fast path (e.g. a base_ref refresh above). Best-effort — never throws.
|
||||
if (options.selfCommit === true && selfCommitSafety) {
|
||||
selfCommitContextFiles(repoPath, ['AGENTS.md', 'CLAUDE.md'], selfCommitSafety);
|
||||
}
|
||||
// Safe to return without process.exit(0) — the early-return path in
|
||||
// runFullAnalysis never opens LadybugDB, so no native handles prevent exit.
|
||||
return;
|
||||
@@ -1526,6 +1600,14 @@ const analyzeCommandImpl = async (
|
||||
}
|
||||
}
|
||||
|
||||
// #2639: opt-in self-commit of any AGENTS.md/CLAUDE.md churn written by
|
||||
// this run (the primary generateAIContextFiles call inside
|
||||
// runFullAnalysis, and/or the --skills regeneration above). Best-effort
|
||||
// — never throws, so a missing git identity etc. can't fail `analyze`.
|
||||
if (options.selfCommit === true && selfCommitSafety) {
|
||||
selfCommitContextFiles(repoPath, ['AGENTS.md', 'CLAUDE.md'], selfCommitSafety);
|
||||
}
|
||||
|
||||
const totalTime = ((Date.now() - t0) / 1000).toFixed(1);
|
||||
|
||||
clearInterval(elapsedTimer);
|
||||
@@ -1552,11 +1634,21 @@ const analyzeCommandImpl = async (
|
||||
// progress-bar log() that fired mid-run has already scrolled away, so the
|
||||
// degraded-search state must also appear in the final summary (#1161).
|
||||
if (result.ftsSkipped) {
|
||||
console.log(
|
||||
`\n Warning: full-text/BM25 search is disabled — the LadybugDB FTS extension was unavailable.\n` +
|
||||
` Install it once with network access (GITNEXUS_LBUG_EXTENSION_INSTALL=auto) then rerun, or\n` +
|
||||
` run \`gitnexus analyze --repair-fts\` when connected. Run \`gitnexus doctor\` for details.`,
|
||||
);
|
||||
// #2658 review L2: a build/verify failure is NOT an extension-unavailable
|
||||
// problem — sending the user to install the extension is the wrong remedy.
|
||||
if (result.ftsSkipReason === 'build-failed') {
|
||||
console.log(
|
||||
`\n Warning: full-text/BM25 search is disabled — the search index build failed this run.\n` +
|
||||
` The FTS extension is available; rerun \`gitnexus analyze --repair-fts\`. If it persists,\n` +
|
||||
` check the disk for space or corruption. Run \`gitnexus doctor\` for details.`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`\n Warning: full-text/BM25 search is disabled — the LadybugDB FTS extension was unavailable.\n` +
|
||||
` Install it once with network access (GITNEXUS_LBUG_EXTENSION_INSTALL=auto) then rerun, or\n` +
|
||||
` run \`gitnexus analyze --repair-fts\` when connected. Run \`gitnexus doctor\` for details.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1593,6 +1685,22 @@ const analyzeCommandImpl = async (
|
||||
return;
|
||||
}
|
||||
|
||||
// Another analyze held the index lock past the configured wait ceiling
|
||||
// (#2658, GITNEXUS_INDEX_LOCK_TIMEOUT_MS). The on-disk index is being
|
||||
// refreshed by the holder — this is a clean, expected condition, not a
|
||||
// crash, so render the message without a stack trace.
|
||||
if (err instanceof IndexLockTimeoutError) {
|
||||
cliError(
|
||||
` Another gitnexus analyze (pid ${err.holder.pid} on ${err.holder.hostname}) is ` +
|
||||
`already refreshing this index and did not finish within the wait window.\n` +
|
||||
` The on-disk index is being updated by that run. Retry later, or raise\n` +
|
||||
` GITNEXUS_INDEX_LOCK_TIMEOUT_MS to wait longer.\n`,
|
||||
{ recoveryHint: 'index-lock-timeout', holderPid: err.holder.pid },
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Finalize invariant failure (#1169) — keep the rich actionable
|
||||
// message intact and write through realStderrWrite so it can't be
|
||||
// erased by a leftover bar refresh on slow terminals.
|
||||
@@ -1624,8 +1732,16 @@ const analyzeCommandImpl = async (
|
||||
}
|
||||
|
||||
if (isLbugCheckpointIoError(err)) {
|
||||
// #2599: when the checkpoint IO error also looks busy/locked, another
|
||||
// handle holds the store open — name that actionable cause alongside the
|
||||
// threshold hint (the original error is preserved so the hint still fires).
|
||||
const heldOpen = isLbugCheckpointBusyError(err)
|
||||
? ` Another process may hold the store open (a running \`gitnexus mcp\` server, or a\n` +
|
||||
` stale reader) — close other GitNexus processes on this repo, then retry.\n`
|
||||
: '';
|
||||
cliError(
|
||||
` LadybugDB failed while rotating/removing WAL checkpoint files.\n` +
|
||||
heldOpen +
|
||||
` This can happen when auto-checkpoint runs at the default threshold (~16MB).\n` +
|
||||
` Retry with a larger checkpoint threshold to reduce checkpoint frequency:\n` +
|
||||
` gitnexus analyze --wal-checkpoint-threshold ${RECOMMENDED_WAL_CHECKPOINT_THRESHOLD}\n` +
|
||||
|
||||
@@ -59,7 +59,8 @@ export type RecoveryHint =
|
||||
| 'npm-resolution'
|
||||
| 'module-not-found'
|
||||
| 'gitnexusrc-invalid'
|
||||
| 'default-branch-invalid';
|
||||
| 'default-branch-invalid'
|
||||
| 'index-lock-timeout';
|
||||
|
||||
/**
|
||||
* Common shape for the optional structured-field bag passed to
|
||||
|
||||
@@ -12,8 +12,16 @@ import {
|
||||
type EmbeddingRuntimeResolution,
|
||||
} from '../core/embeddings/runtime-install.js';
|
||||
import { cudaRedirectDoctorStatus } from '../core/embeddings/onnxruntime-node-resolver.js';
|
||||
import { checkLbugNative, probeFtsExtensionLoad } from '../core/lbug/native-check.js';
|
||||
import { getOsPageSize, isPageSizeAwareLadybug } from '../core/lbug/lbug-config.js';
|
||||
import {
|
||||
checkLbugNative,
|
||||
probeFtsExtensionLoad,
|
||||
probeVectorExtensionLoad,
|
||||
} from '../core/lbug/native-check.js';
|
||||
import {
|
||||
getEffectiveBufferPoolSize,
|
||||
getOsPageSize,
|
||||
isPageSizeAwareLadybug,
|
||||
} from '../core/lbug/lbug-config.js';
|
||||
import { diagnoseExtensionLoad } from '../core/lbug/extension-load-error.js';
|
||||
import { getExtensionInstallPolicy } from '../core/lbug/extension-loader.js';
|
||||
import { t } from './i18n/index.js';
|
||||
@@ -146,6 +154,22 @@ export function pageSizeDoctorLines(
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* The hintless buffer-pool doctor line (#2631) — the pool the next Database
|
||||
* open in THIS process would get. Same plain-params testable-helper shape as
|
||||
* pageSizeDoctorLines above. `pool` is getEffectiveBufferPoolSize(): `0` is
|
||||
* the pass-through sentinel for LadybugDB's native 80%-of-RAM default, never
|
||||
* printed as "0 MiB". `envRaw` (the raw GITNEXUS_LBUG_BUFFER_POOL_SIZE value)
|
||||
* marks operator-supplied absolute values as "(env override)" — no scaling
|
||||
* suffix: the hintless default is deliberately unscaled (#2557), and an env
|
||||
* value is absolute, so a "×N" note would misdescribe both.
|
||||
*/
|
||||
export function poolSizeDoctorLine(pool: number, envRaw: string | undefined): string {
|
||||
const value = pool === 0 ? 'native 80% of RAM' : `${Math.round(pool / (1024 * 1024))} MiB`;
|
||||
const envNote = envRaw !== undefined && envRaw.trim().length > 0 ? ' (env override)' : '';
|
||||
return ` ${padDisplayEnd('pool size', 10)}${value}${envNote}`;
|
||||
}
|
||||
|
||||
export const doctorCommand = async () => {
|
||||
const fingerprint = getRuntimeFingerprint();
|
||||
const capabilities = getRuntimeCapabilities();
|
||||
@@ -164,6 +188,11 @@ export const doctorCommand = async () => {
|
||||
for (const line of pageSizeDoctorLines(getOsPageSize(), fingerprint.ladybugdb)) {
|
||||
console.log(line);
|
||||
}
|
||||
// Hintless buffer pool for the next DB open (#2631). Literal label like
|
||||
// the page size line above (no i18n key).
|
||||
console.log(
|
||||
poolSizeDoctorLine(getEffectiveBufferPoolSize(), process.env.GITNEXUS_LBUG_BUFFER_POOL_SIZE),
|
||||
);
|
||||
const nativeCheck = checkLbugNative();
|
||||
if (nativeCheck.ok) {
|
||||
console.log(` ${padDisplayEnd('native', 10)}✓ lbugjs.node loaded`);
|
||||
@@ -195,8 +224,32 @@ export const doctorCommand = async () => {
|
||||
console.log(` ${padDisplayEnd('', 18)}${remedy}`);
|
||||
}
|
||||
}
|
||||
console.log(` ${label('doctor.labels.vectorIndex', 18)}${capabilities.vector}`);
|
||||
console.log(` ${label('doctor.labels.semanticMode', 18)}${capabilities.semanticMode}`);
|
||||
// Live LOAD probe for VECTOR too (#2623). The static capability is just
|
||||
// `platform !== 'win32'`, so it printed "available" on the very machines
|
||||
// where analyze was failing to load the extension — the same contradiction
|
||||
// #2374 fixed for FTS above, and exactly what #2623's reporter saw while
|
||||
// every incremental analyze died on an unloaded VECTOR extension.
|
||||
const vectorProbe = nativeCheck.ok
|
||||
? await probeVectorExtensionLoad()
|
||||
: { loaded: false, reason: 'LadybugDB native module (lbugjs.node) failed to load' };
|
||||
console.log(
|
||||
` ${label('doctor.labels.vectorIndex', 18)}${vectorProbe.loaded ? 'available' : 'unavailable'}`,
|
||||
);
|
||||
if (!vectorProbe.loaded && vectorProbe.reason) {
|
||||
console.log(` ${padDisplayEnd('', 18)}${vectorProbe.reason}`);
|
||||
const { kind, remedy } = diagnoseExtensionLoad(vectorProbe.reason, 'VECTOR');
|
||||
if (kind !== 'unknown') {
|
||||
console.log(` ${padDisplayEnd('', 18)}${remedy}`);
|
||||
}
|
||||
}
|
||||
// Semantic mode follows the probe, not the platform: without a loadable
|
||||
// VECTOR extension the index can be neither built nor queried, so search is
|
||||
// really on exact scan no matter what the platform would allow.
|
||||
console.log(
|
||||
` ${label('doctor.labels.semanticMode', 18)}${
|
||||
vectorProbe.loaded ? capabilities.semanticMode : 'exact-scan'
|
||||
}`,
|
||||
);
|
||||
// Surface the optional-extension install policy so offline users can see
|
||||
// whether analyze/query will reach the network (extension.ladybugdb.com).
|
||||
// Literal label (like the 'native' line) to avoid adding i18n keys.
|
||||
|
||||
@@ -57,6 +57,7 @@ const OPTION_DESCRIPTION_KEYS = {
|
||||
'analyze|--skills': 'help.option.analyze.skills',
|
||||
'analyze|--skip-agents-md': 'help.option.analyze.skipAgentsMd',
|
||||
'analyze|--no-stats': 'help.option.analyze.noStats',
|
||||
'analyze|--self-commit': 'help.option.analyze.selfCommit',
|
||||
'analyze|--skip-skills': 'help.option.analyze.skipSkills',
|
||||
'analyze|--index-only': 'help.option.analyze.indexOnly',
|
||||
'analyze|--skip-git': 'help.option.skipGit',
|
||||
|
||||
@@ -184,8 +184,10 @@ export const en = {
|
||||
'help.option.analyze.skipAgentsMd':
|
||||
'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md',
|
||||
'help.option.analyze.noStats': 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md',
|
||||
'help.option.analyze.selfCommit':
|
||||
'Auto-commit AGENTS.md/CLAUDE.md changes after analyze (opt-in, off by default). Scoped to only those two files (never `git add -A`); no-ops if neither exists, neither changed, or the repo has no git identity configured.',
|
||||
'help.option.analyze.skipSkills':
|
||||
'Skip installing standard GitNexus skill files directly under .claude/skills/. Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). Use --index-only to skip all AI-context file injection.',
|
||||
'Skip installing standard GitNexus skill files directly under .claude/skills/ and .agents/skills/. Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). Use --index-only to skip all AI-context file injection.',
|
||||
'help.option.analyze.indexOnly':
|
||||
'Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills)',
|
||||
'help.option.skipGit':
|
||||
|
||||
@@ -175,8 +175,10 @@ export const zhCN = {
|
||||
'根据检测到的社区生成仓库专属 skill 文件(同时设置 --index-only 时无效)。',
|
||||
'help.option.analyze.skipAgentsMd': '跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus 区块',
|
||||
'help.option.analyze.noStats': '从 AGENTS.md 和 CLAUDE.md 中省略易变的文件/符号计数',
|
||||
'help.option.analyze.selfCommit':
|
||||
'在 analyze 后自动提交 AGENTS.md/CLAUDE.md 的变更(默认关闭,需显式开启)。仅限这两个文件(绝不使用 `git add -A`);若两者均不存在、均未变更,或仓库未配置 git 身份,则不执行任何操作。',
|
||||
'help.option.analyze.skipSkills':
|
||||
'跳过直接安装在 .claude/skills/ 下的标准 GitNexus skill 文件。不抑制 --skills 生成的社区 skill(位于 .claude/skills/gitnexus-area-*)。使用 --index-only 可跳过所有 AI 上下文文件注入。',
|
||||
'跳过直接安装在 .claude/skills/ 和 .agents/skills/ 下的标准 GitNexus skill 文件。不抑制 --skills 生成的社区 skill(位于 .claude/skills/gitnexus-area-*)。使用 --index-only 可跳过所有 AI 上下文文件注入。',
|
||||
'help.option.analyze.indexOnly': '纯索引模式:跳过所有文件注入(AGENTS.md、CLAUDE.md、skills)',
|
||||
'help.option.skipGit': '将提供的路径/cwd 视为索引根目录,并跳过向上查找 git 根目录',
|
||||
'help.option.analyze.name':
|
||||
|
||||
@@ -92,9 +92,15 @@ program
|
||||
'checked-out working tree. Distinct from --default-branch (cosmetic base_ref).',
|
||||
)
|
||||
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
|
||||
.option(
|
||||
'--self-commit',
|
||||
'Auto-commit AGENTS.md/CLAUDE.md changes after analyze (opt-in, off by default). ' +
|
||||
'Scoped to only those two files (never `git add -A`); no-ops if neither exists, ' +
|
||||
'neither changed, or the repo has no git identity configured.',
|
||||
)
|
||||
.option(
|
||||
'--skip-skills',
|
||||
'Skip installing standard GitNexus skill files directly under .claude/skills/. ' +
|
||||
'Skip installing standard GitNexus skill files directly under .claude/skills/ and .agents/skills/. ' +
|
||||
'Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). ' +
|
||||
'Use --index-only to skip all AI-context file injection.',
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { PipelineResult } from '../types/pipeline.js';
|
||||
import { CommunityNode, CommunityMembership } from '../core/ingestion/community-processor.js';
|
||||
import { ProcessNode } from '../core/ingestion/process-processor.js';
|
||||
import { KnowledgeGraph } from '../core/graph/types.js';
|
||||
import { shouldMirrorSkillsToAgents } from './ai-context.js';
|
||||
|
||||
const GENERATED_SKILL_PREFIX = 'gitnexus-area-';
|
||||
const MAX_SKILL_NAME_LENGTH = 64;
|
||||
@@ -74,6 +75,12 @@ export const generateSkillFiles = async (
|
||||
const { communityResult, processResult, graph } = pipelineResult;
|
||||
const outputDir = path.join(repoPath, '.claude', 'skills');
|
||||
const legacyOutputDir = path.join(outputDir, 'generated');
|
||||
// Some agents prioritize repo-local .agents/skills over the global
|
||||
// ~/.agents/skills install (see shouldMirrorSkillsToAgents). When .agents/
|
||||
// exists, mirror the generated community skills there too so those agents
|
||||
// serve the up-to-date copies.
|
||||
const agentsOutputDir = path.join(repoPath, '.agents', 'skills');
|
||||
let mirrorToAgents = await shouldMirrorSkillsToAgents(repoPath);
|
||||
|
||||
// Community skills used to live under an undiscoverable `generated/`
|
||||
// grouping directory. Clear that GitNexus-owned legacy output and
|
||||
@@ -95,6 +102,24 @@ export const generateSkillFiles = async (
|
||||
/* legacy output may not exist */
|
||||
}
|
||||
|
||||
// Mirror cleanup: clear only stale GitNexus-generated community skills under
|
||||
// .agents/skills/ (reserved gitnexus-area-* namespace), preserving mirrored
|
||||
// standard skills and any user-authored skills. Never clear the whole root.
|
||||
if (mirrorToAgents) {
|
||||
try {
|
||||
const entries = await fs.readdir(agentsOutputDir, { withFileTypes: true });
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith(GENERATED_SKILL_PREFIX))
|
||||
.map((entry) =>
|
||||
fs.rm(path.join(agentsOutputDir, entry.name), { recursive: true, force: true }),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
/* mirror root may not exist yet */
|
||||
}
|
||||
}
|
||||
|
||||
if (!communityResult || !communityResult.memberships.length) {
|
||||
console.log('\n Skills: no communities detected, skipping skill generation');
|
||||
return { skills: [], outputPath: outputDir };
|
||||
@@ -135,6 +160,20 @@ export const generateSkillFiles = async (
|
||||
// Step 4: Ensure the shared project-skill root exists. Never clear it: it
|
||||
// also contains user-authored and standard GitNexus skills.
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
// The .agents/ mirror is a side flow: keep it a weak dependency. If the
|
||||
// mirror root cannot be created (e.g. `.agents/skills` exists as a file),
|
||||
// warn and disable mirroring for this run instead of aborting canonical
|
||||
// community-skill generation. Canonical writes below stay unaffected.
|
||||
if (mirrorToAgents) {
|
||||
try {
|
||||
await fs.mkdir(agentsOutputDir, { recursive: true });
|
||||
} catch (err) {
|
||||
console.log(
|
||||
`Warning: Could not create mirror root ${agentsOutputDir} — .agents/skills mirroring disabled for this run: ${err}`,
|
||||
);
|
||||
mirrorToAgents = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Generate skill files
|
||||
const skills: GeneratedSkillInfo[] = [];
|
||||
@@ -185,6 +224,19 @@ export const generateSkillFiles = async (
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8');
|
||||
|
||||
// Mirror to .agents/skills/ for agents that read repo-local skills
|
||||
// (see mirrorToAgents above). Best-effort: a per-skill mirror failure
|
||||
// must not abort canonical community-skill generation.
|
||||
if (mirrorToAgents) {
|
||||
try {
|
||||
const agentsSkillDir = path.join(agentsOutputDir, skillName);
|
||||
await fs.mkdir(agentsSkillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(agentsSkillDir, 'SKILL.md'), content, 'utf-8');
|
||||
} catch (err) {
|
||||
console.log(`Warning: Could not mirror skill ${skillName} to .agents/skills: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
const info: GeneratedSkillInfo = {
|
||||
name: skillName,
|
||||
label: community.label,
|
||||
@@ -201,6 +253,11 @@ export const generateSkillFiles = async (
|
||||
console.log(
|
||||
`\n ${skills.length} skills generated \u2192 .claude/skills/${GENERATED_SKILL_PREFIX}*/`,
|
||||
);
|
||||
if (mirrorToAgents) {
|
||||
console.log(
|
||||
` ${skills.length} skills mirrored \u2192 .agents/skills/${GENERATED_SKILL_PREFIX}*/ (.agents)`,
|
||||
);
|
||||
}
|
||||
|
||||
return { skills, outputPath: outputDir };
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from 'fs/promises';
|
||||
import nodePath from 'path';
|
||||
import type { Path } from 'path-scurry';
|
||||
import { logger } from '../core/logger.js';
|
||||
import { getCoreExcludesFilePath, getGitInfoExcludePath } from '../storage/git.js';
|
||||
|
||||
const DEFAULT_IGNORE_LIST = new Set([
|
||||
// Version Control
|
||||
@@ -350,6 +351,8 @@ export const isHardcodedIgnoredDirectory = (name: string): boolean => {
|
||||
export interface IgnoreOptions {
|
||||
/** Skip .gitignore parsing, only read .gitnexusignore. Defaults to GITNEXUS_NO_GITIGNORE env var. */
|
||||
noGitignore?: boolean;
|
||||
/** Skip core.excludesFile and $GIT_COMMON_DIR/info/exclude. Defaults to GITNEXUS_NO_GLOBAL_IGNORE env var. */
|
||||
noGlobalIgnore?: boolean;
|
||||
}
|
||||
|
||||
export const loadIgnoreRules = async (
|
||||
@@ -359,6 +362,32 @@ export const loadIgnoreRules = async (
|
||||
const ig = ignore();
|
||||
let hasRules = false;
|
||||
|
||||
// Mirror git's own precedence for ignore sources (gitignore(5)): patterns
|
||||
// from core.excludesFile are consulted first (lowest precedence — git's
|
||||
// real global, all-repos file), then $GIT_COMMON_DIR/info/exclude
|
||||
// (per-repo, untracked — no write access to the repo needed), then
|
||||
// .gitignore/.gitnexusignore below. Later ig.add() calls win on
|
||||
// conflicting patterns, matching git's own last-match-wins semantics (#2606).
|
||||
const skipGlobalIgnore = options?.noGlobalIgnore ?? !!process.env.GITNEXUS_NO_GLOBAL_IGNORE;
|
||||
if (!skipGlobalIgnore) {
|
||||
const globalSources = [
|
||||
getCoreExcludesFilePath(repoPath),
|
||||
getGitInfoExcludePath(repoPath),
|
||||
].filter((candidate): candidate is string => candidate !== null);
|
||||
for (const sourcePath of globalSources) {
|
||||
try {
|
||||
const content = await fs.readFile(sourcePath, 'utf-8');
|
||||
ig.add(content);
|
||||
hasRules = true;
|
||||
} catch (err: unknown) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code !== 'ENOENT') {
|
||||
logger.warn(` Warning: could not read ${sourcePath}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Allow users to bypass .gitignore parsing (e.g. when .gitignore accidentally excludes source files)
|
||||
const skipGitignore = options?.noGitignore ?? !!process.env.GITNEXUS_NO_GITIGNORE;
|
||||
const filenames = skipGitignore ? ['.gitnexusignore'] : ['.gitignore', '.gitnexusignore'];
|
||||
|
||||
@@ -381,7 +381,14 @@ const LIBC_VARIANT = detectLibcVariant();
|
||||
|
||||
function resolveRuntimeVariant(): RuntimeVariant {
|
||||
return {
|
||||
executablePath: resolveExistingPath(process.execPath),
|
||||
// Normalized like build.rootPath (#2668): executablePath is a compared
|
||||
// identity field (only invokedArtifact is stripped in the comparison), and
|
||||
// process.execPath carries the same Windows drive-letter case ambiguity —
|
||||
// so leaving it un-normalized would reintroduce the false-stale via runtime.
|
||||
executablePath: normalizeAnalyzerRootPath(
|
||||
resolveExistingPath(process.execPath),
|
||||
process.platform,
|
||||
),
|
||||
nodeVersion: process.version,
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
@@ -524,6 +531,36 @@ function resolveExistingPath(candidate: string): string {
|
||||
return realpathSync.native(path.resolve(candidate));
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-stabilize a path's Windows drive letter so two processes that observed
|
||||
* the same directory under different drive-letter casing (`c:\…` vs `C:\…`)
|
||||
* produce byte-identical analyzer-identity path fields (#2668).
|
||||
*
|
||||
* `realpathSync.native` canonicalizes 8.3 short names and symlinks but does not
|
||||
* guarantee the drive-letter case it returns — it can preserve whatever casing
|
||||
* the caller's path carried, and `import.meta.url` casing depends on how each
|
||||
* entry process (CLI shim vs `npx`/npm wrapper vs server worker) was launched.
|
||||
* When `analyze` stamps `build.rootPath` under one casing and `status`
|
||||
* recomputes it under another, `analyzerRunnerIdentitiesEqual` deep-compares
|
||||
* unequal and `status` reports a freshly-analyzed, untouched repo as stale.
|
||||
* Uppercasing the drive letter (drive letters are case-insensitive; uppercase
|
||||
* is the conventional form) collapses that variance. POSIX paths are returned
|
||||
* unchanged. `platform` is explicit so the transform is unit-testable off
|
||||
* Windows.
|
||||
*
|
||||
* The optional `\\?\` extended-length prefix (which `realpathSync.native` can
|
||||
* emit for paths over MAX_PATH) is preserved and the drive letter after it is
|
||||
* still normalized; UNC paths (`\\server\share`, `\\?\UNC\...`) have no drive
|
||||
* letter and are left untouched.
|
||||
*/
|
||||
export function normalizeAnalyzerRootPath(p: string, platform: NodeJS.Platform): string {
|
||||
if (platform !== 'win32') return p;
|
||||
return p.replace(
|
||||
/^(\\\\\?\\)?([a-z]):/,
|
||||
(_match, prefix: string | undefined, drive: string) => `${prefix ?? ''}${drive.toUpperCase()}:`,
|
||||
);
|
||||
}
|
||||
|
||||
function isFile(candidate: string): boolean {
|
||||
try {
|
||||
return statSync(candidate).isFile();
|
||||
@@ -553,11 +590,30 @@ function manifestLabel(manifest: PackageManifest): string {
|
||||
return `${name}@${version}`;
|
||||
}
|
||||
|
||||
function isInside(parent: string, candidate: string): boolean {
|
||||
const relative = path.relative(parent, candidate);
|
||||
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..');
|
||||
/**
|
||||
* Whether `candidate` is `parent` itself or lives beneath it.
|
||||
*
|
||||
* The absolute-result rejection is load-bearing on Windows: `path.relative`
|
||||
* cannot express a relative path between two different drives, so it returns the
|
||||
* absolute target instead — `path.win32.relative('C:\\parent', 'D:\\other')` is
|
||||
* `'D:\\other'`. That string does not start with `..`, so the `..` checks alone
|
||||
* would report an unrelated drive as *inside* the parent. This mirrors the
|
||||
* containment guards elsewhere in the repo (`server/api.ts`,
|
||||
* `server/git-clone.ts`, `group/extractors/fs-utils.ts`), which all pair the
|
||||
* `..` check with `path.isAbsolute`.
|
||||
*
|
||||
* `pathApi` is injectable so the win32 semantics are unit-testable from a POSIX
|
||||
* runner; production callers always use the platform-bound `path`.
|
||||
*/
|
||||
function isInside(parent: string, candidate: string, pathApi: typeof path = path): boolean {
|
||||
const relative = pathApi.relative(parent, candidate);
|
||||
if (pathApi.isAbsolute(relative)) return false;
|
||||
return relative === '' || (!relative.startsWith(`..${pathApi.sep}`) && relative !== '..');
|
||||
}
|
||||
|
||||
/** Test seam for {@link isInside} (see `_hashAnalyzerIdentityFramesForTests`). */
|
||||
export const _isInsideForTests = isInside;
|
||||
|
||||
function resolveBuildRoot(analyzerModulePath: string): {
|
||||
packageRoot: string;
|
||||
buildRoot: string;
|
||||
@@ -570,9 +626,18 @@ function resolveBuildRoot(analyzerModulePath: string): {
|
||||
const packageRoot = path.dirname(cursor);
|
||||
const packageJson = path.join(packageRoot, 'package.json');
|
||||
if (lstatSync(packageJson).isFile()) {
|
||||
// Normalize the drive-letter case at this single upstream source so
|
||||
// every derived identity path field — build.rootPath, identityCacheKey,
|
||||
// and (via collectDependencyInputs) dependencyRuntime.manifestPath /
|
||||
// lockfilePath — inherits a case-stable root and analyze-stamp equals
|
||||
// status-recompute regardless of launch-path casing (#2668).
|
||||
// Migration: a Windows index stamped before this fix carries the old,
|
||||
// un-normalized casing, so the first post-upgrade `status` sees one
|
||||
// spurious "stale" flip — self-healing on the next `analyze`, which
|
||||
// re-stamps the normalized (idempotent) form.
|
||||
return {
|
||||
packageRoot,
|
||||
buildRoot: cursor,
|
||||
packageRoot: normalizeAnalyzerRootPath(packageRoot, process.platform),
|
||||
buildRoot: normalizeAnalyzerRootPath(cursor, process.platform),
|
||||
kind: base === 'src' ? 'source' : 'distribution',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ interface HttpConfig {
|
||||
maxAttempts: number;
|
||||
retryCapMs: number;
|
||||
minIntervalMs: number;
|
||||
requestDimensions?: number;
|
||||
}
|
||||
|
||||
export interface EmbeddingRequestOptions {
|
||||
@@ -106,20 +107,26 @@ const paceHttpRequest = async (minIntervalMs: number, signal?: AbortSignal): Pro
|
||||
};
|
||||
|
||||
/**
|
||||
* Stable lead of the {@link readConfig} malformed-`GITNEXUS_EMBEDDING_DIMS`
|
||||
* error. `readConfig` throws a plain `Error` (not an {@link HttpEmbeddingError})
|
||||
* because this is a *config* mistake, not an endpoint failure — so the CLI
|
||||
* recognizes it by this lead ({@link isHttpEmbeddingDimsError}) and prints a
|
||||
* clean config message instead of a raw stack dump. See #2385.
|
||||
* Stable lead of a {@link readConfig} malformed dims-env error. `readConfig`
|
||||
* throws a plain `Error` (not an {@link HttpEmbeddingError}) for a malformed
|
||||
* `GITNEXUS_EMBEDDING_DIMS` or `GITNEXUS_EMBEDDING_REQUEST_DIMS` because it's a
|
||||
* *config* mistake, not an endpoint failure — so the CLI recognizes it by this
|
||||
* lead ({@link isHttpEmbeddingDimsError}) and prints a clean config message
|
||||
* instead of a raw stack dump. Each var names itself so the message points the
|
||||
* operator at the variable they actually set, not a sibling. See #2385.
|
||||
*/
|
||||
const EMBEDDING_DIMS_ENV_ERROR_LEAD = 'GITNEXUS_EMBEDDING_DIMS must be a positive integer';
|
||||
const dimsEnvErrorLead = (name: string): string => `${name} must be a positive integer`;
|
||||
const EMBEDDING_DIMS_ENV_ERROR_LEAD = dimsEnvErrorLead('GITNEXUS_EMBEDDING_DIMS');
|
||||
const EMBEDDING_REQUEST_DIMS_ENV_ERROR_LEAD = dimsEnvErrorLead('GITNEXUS_EMBEDDING_REQUEST_DIMS');
|
||||
|
||||
/**
|
||||
* @internal Exported for the CLI analyze error handler. True when `message` is
|
||||
* the {@link readConfig} malformed-DIMS config error (a plain `Error`).
|
||||
* @internal Exported for the CLI analyze error handler. True when `message` is a
|
||||
* {@link readConfig} malformed dims-env config error (a plain `Error`) — for
|
||||
* either `GITNEXUS_EMBEDDING_DIMS` or `GITNEXUS_EMBEDDING_REQUEST_DIMS`.
|
||||
*/
|
||||
export const isHttpEmbeddingDimsError = (message: string): boolean =>
|
||||
message.includes(EMBEDDING_DIMS_ENV_ERROR_LEAD);
|
||||
message.includes(EMBEDDING_DIMS_ENV_ERROR_LEAD) ||
|
||||
message.includes(EMBEDDING_REQUEST_DIMS_ENV_ERROR_LEAD);
|
||||
|
||||
/**
|
||||
* Build config from the current process.env snapshot.
|
||||
@@ -147,6 +154,23 @@ const readConfig = (): HttpConfig | null => {
|
||||
dimensions = parsed;
|
||||
}
|
||||
|
||||
const rawRequestDims = process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS?.trim();
|
||||
let requestDimensions = dimensions;
|
||||
if (rawRequestDims) {
|
||||
if (/^(omit|none|off|false|0)$/i.test(rawRequestDims)) {
|
||||
requestDimensions = undefined;
|
||||
} else {
|
||||
if (!/^\d+$/.test(rawRequestDims)) {
|
||||
throw new Error(`${EMBEDDING_REQUEST_DIMS_ENV_ERROR_LEAD}, got "${rawRequestDims}"`);
|
||||
}
|
||||
const parsed = parseInt(rawRequestDims, 10);
|
||||
if (parsed <= 0) {
|
||||
throw new Error(`${EMBEDDING_REQUEST_DIMS_ENV_ERROR_LEAD}, got "${rawRequestDims}"`);
|
||||
}
|
||||
requestDimensions = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: baseUrl.replace(/\/+$/, ''),
|
||||
model,
|
||||
@@ -163,6 +187,7 @@ const readConfig = (): HttpConfig | null => {
|
||||
300_000,
|
||||
),
|
||||
minIntervalMs: parseNonNegativeIntegerEnv('GITNEXUS_EMBEDDING_MIN_INTERVAL_MS', 0, 300_000),
|
||||
requestDimensions,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -283,9 +308,9 @@ const isEmbeddingItem = (item: unknown): item is EmbeddingItem =>
|
||||
* the `dimensions` field in the request body. Endpoints that implement
|
||||
* Matryoshka truncation (OpenAI text-embedding-3-*, Cohere embed-v3,
|
||||
* Voyage) return a truncated vector at that size; endpoints that do not
|
||||
* recognise the field may ignore it or return 400. Leave
|
||||
* `GITNEXUS_EMBEDDING_DIMS` unset for strict backends that reject
|
||||
* unknown fields.
|
||||
* recognise the field may ignore it or return 400. Set
|
||||
* `GITNEXUS_EMBEDDING_REQUEST_DIMS=omit` for strict backends while keeping
|
||||
* `GITNEXUS_EMBEDDING_DIMS` set to the returned vector size.
|
||||
*/
|
||||
const httpEmbedBatch = async (
|
||||
url: string,
|
||||
@@ -434,7 +459,7 @@ export const httpEmbed = async (
|
||||
config.model,
|
||||
config.apiKey,
|
||||
batchIndex,
|
||||
config.dimensions,
|
||||
config.requestDimensions,
|
||||
requestOptions,
|
||||
config.maxAttempts,
|
||||
config.retryCapMs,
|
||||
@@ -491,7 +516,7 @@ export const httpEmbedQuery = async (
|
||||
config.model,
|
||||
config.apiKey,
|
||||
0,
|
||||
config.dimensions,
|
||||
config.requestDimensions,
|
||||
requestOptions,
|
||||
config.maxAttempts,
|
||||
config.retryCapMs,
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
*
|
||||
* `module.registerHooks` — the synchronous ESM/CJS resolution-hook API the
|
||||
* embedding-stack resolvers rely on — was added in Node 22.15.0 (and 23.5.0 on
|
||||
* the 23.x line). The gitnexus engines floor is `>=22.0.0`, which admits Node
|
||||
* 22.0–22.14 AND 23.0–23.4, where the export is absent.
|
||||
* the 23.x line). The gitnexus engines floor is `^22.18.0 || >=24.11.0`, so
|
||||
* every supported runtime exposes it — but `engines` is advisory (not
|
||||
* engine-strict), so a below-floor Node (22.0–22.14, or the unsupported
|
||||
* 23.0–23.4 line) can still run, where the export is absent.
|
||||
*
|
||||
* In this `"type": "module"` package, a *static named* import of a missing
|
||||
* builtin export (`import { registerHooks } from 'node:module'`) is a
|
||||
|
||||
@@ -52,8 +52,8 @@
|
||||
* per-resolution cost is a single string comparison.
|
||||
*
|
||||
* `module.registerHooks` is marked `@experimental` and requires Node >= 22.15
|
||||
* (the gitnexus engines floor is >= 22.0.0). On older runtimes it is absent and
|
||||
* this is a graceful no-op: embeddings then resolve onnxruntime-common exactly
|
||||
* (below the gitnexus engines floor of `^22.18.0 || >=24.11.0`). On below-floor
|
||||
* runtimes it is absent and this is a graceful no-op: embeddings then resolve onnxruntime-common exactly
|
||||
* as before — fine on hoisted layouts. Any failure during installation is
|
||||
* swallowed.
|
||||
*/
|
||||
@@ -100,9 +100,9 @@ export const ensureOnnxRuntimeCommonResolvable = (): void => {
|
||||
attempted = true;
|
||||
|
||||
try {
|
||||
// Node < 22.15 / < 23.5 (the gitnexus engines floor is >= 22.0.0): no
|
||||
// synchronous hooks API. Degrade gracefully — the import still works on
|
||||
// hoisted layouts.
|
||||
// Node < 22.15 / < 23.5 (below the gitnexus engines floor of
|
||||
// ^22.18.0 || >=24.11.0): no synchronous hooks API. Degrade gracefully —
|
||||
// the import still works on hoisted layouts.
|
||||
const registerHooks = getRegisterHooks();
|
||||
if (typeof registerHooks !== 'function') return;
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
* So CUDA-12 hosts, Windows (DirectML), macOS, and CPU-only hosts are
|
||||
* untouched. Idempotent; any failure is swallowed and leaves the default
|
||||
* resolution exactly as before. `module.registerHooks` requires Node >= 22.15
|
||||
* (the gitnexus engines floor is >= 22.0.0); on older runtimes the redirect is
|
||||
* a no-op, but the default copy's CUDA major is still probed so an
|
||||
* (below the gitnexus engines floor of `^22.18.0 || >=24.11.0`); on below-floor
|
||||
* runtimes the redirect is a no-op, but the default copy's CUDA major is still probed so an
|
||||
* already-matching host (e.g. CUDA 12 + transformers' CUDA-12 build) keeps
|
||||
* auto-selecting the GPU.
|
||||
* `npm link` / symlinked local-dev checkouts are a known caveat: `resolveOurOrtNodeDir`/
|
||||
|
||||
@@ -191,7 +191,7 @@ export const ensureEmbeddingStackResolvable = (): void => {
|
||||
hookAttempted = true;
|
||||
|
||||
try {
|
||||
// Node < 22.15 / < 23.5 (engines floor is >= 22.0.0): no synchronous hooks
|
||||
// Node < 22.15 / < 23.5 (below the engines floor of ^22.18.0 || >=24.11.0): no synchronous hooks
|
||||
// API. Degrade gracefully — normally-installed stacks still resolve; only
|
||||
// the runtime-prefix fallback is unavailable. Reachable now that the import
|
||||
// is a namespace access (see node-module-compat.ts) rather than a static
|
||||
|
||||
@@ -162,6 +162,11 @@ export const createKnowledgeGraph = (): KnowledgeGraph => {
|
||||
forEachRelationship(fn: (rel: GraphRelationship) => void) {
|
||||
relationshipMap.forEach(fn);
|
||||
},
|
||||
forEachRelationshipFields(
|
||||
fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void,
|
||||
) {
|
||||
relationshipMap.forEach((rel) => fn(rel.sourceId, rel.targetId, rel.type, rel.confidence));
|
||||
},
|
||||
getNode: (id: string) => nodeMap.get(id),
|
||||
|
||||
// O(1) count getters - avoid creating arrays just for length
|
||||
|
||||
@@ -27,6 +27,19 @@ export interface KnowledgeGraph {
|
||||
iterRelationshipsByType: (type: RelationshipType) => IterableIterator<GraphRelationship>;
|
||||
forEachNode: (fn: (node: GraphNode) => void) => void;
|
||||
forEachRelationship: (fn: (rel: GraphRelationship) => void) => void;
|
||||
/**
|
||||
* Zero-allocation relationship scan: fields, not objects (#2680).
|
||||
*
|
||||
* The whole-graph scans (the local-symbol pruner, community detection,
|
||||
* process extraction) read only these four fields, and materializing a
|
||||
* `GraphRelationship` per edge just to read them dominates iteration cost once
|
||||
* relationships are held columnar — measured at ~90 ms per analyze on a
|
||||
* million-edge graph. Prefer this over `forEachRelationship` in any pass that
|
||||
* walks every edge and needs no other field.
|
||||
*/
|
||||
forEachRelationshipFields: (
|
||||
fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void,
|
||||
) => void;
|
||||
getNode: (id: string) => GraphNode | undefined;
|
||||
nodeCount: number;
|
||||
relationshipCount: number;
|
||||
@@ -34,5 +47,12 @@ export interface KnowledgeGraph {
|
||||
addRelationship: (relationship: GraphRelationship) => void;
|
||||
removeNode: (nodeId: string) => boolean;
|
||||
removeNodesByFile: (filePath: string) => number;
|
||||
/**
|
||||
* Removes the relationship with this id, returning whether it existed.
|
||||
*
|
||||
* Implementations that offload relationships out of memory cannot always tell
|
||||
* "absent" from "already written out" — `GraphEmitSink` deliberately throws
|
||||
* rather than answering `false` for an edge it can no longer recall (#2680).
|
||||
*/
|
||||
removeRelationship: (relationshipId: string) => boolean;
|
||||
}
|
||||
|
||||
@@ -1031,6 +1031,8 @@ const LBUG_OPEN_RETRY_PATTERNS = [
|
||||
'lock held by another process',
|
||||
];
|
||||
|
||||
// Cross-repo bridge RO open retry. Catalogued as entry 5 of the lbug-config
|
||||
// retry-budget registry; caps back-off so total wait ~3s.
|
||||
const LBUG_OPEN_RETRY_ATTEMPTS = 10;
|
||||
const LBUG_OPEN_RETRY_BASE_MS = 100;
|
||||
/** Cap individual back-off delays so the total wait is bounded (~3s). */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { ClassExtractionConfig } from '../../class-types.js';
|
||||
import { synthesizeJavaAnonymousClassName } from '../../utils/ast-helpers.js';
|
||||
import { synthesizeJavaTypeIdentity } from '../../utils/ast-helpers.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Java
|
||||
@@ -33,10 +33,10 @@ export const javaClassConfig: ClassExtractionConfig = {
|
||||
'record_declaration',
|
||||
],
|
||||
extractName(node) {
|
||||
if (node.type === 'object_creation_expression' || node.type === 'enum_constant') {
|
||||
return synthesizeJavaAnonymousClassName(node);
|
||||
}
|
||||
return undefined;
|
||||
return synthesizeJavaTypeIdentity(node)?.name;
|
||||
},
|
||||
extractType(node) {
|
||||
return synthesizeJavaTypeIdentity(node)?.label;
|
||||
},
|
||||
// An anonymous body whose name CANNOT be synthesized must not become a
|
||||
// Class node at all. Without this skip, `extract()`'s
|
||||
@@ -50,7 +50,7 @@ export const javaClassConfig: ClassExtractionConfig = {
|
||||
definitionNode !== undefined &&
|
||||
(definitionNode.type === 'object_creation_expression' ||
|
||||
definitionNode.type === 'enum_constant') &&
|
||||
synthesizeJavaAnonymousClassName(definitionNode) === undefined
|
||||
synthesizeJavaTypeIdentity(definitionNode) === undefined
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -290,14 +290,16 @@ export const buildCommunityProjection = (knowledgeGraph: KnowledgeGraph): Commun
|
||||
const connectedNodes = new Set<string>();
|
||||
const nodeDegree = new Map<string, number>();
|
||||
|
||||
knowledgeGraph.forEachRelationship((rel) => {
|
||||
if (!isClusteringRelationship(rel.type) || rel.sourceId === rel.targetId) return;
|
||||
if (isLarge && rel.confidence < MIN_CONFIDENCE_LARGE) return;
|
||||
// Field-wise scan (#2680): this walks every edge and reads only these four,
|
||||
// so taking objects would allocate one per edge for nothing.
|
||||
knowledgeGraph.forEachRelationshipFields((sourceId, targetId, type, confidence) => {
|
||||
if (!isClusteringRelationship(type) || sourceId === targetId) return;
|
||||
if (isLarge && confidence < MIN_CONFIDENCE_LARGE) return;
|
||||
|
||||
connectedNodes.add(rel.sourceId);
|
||||
connectedNodes.add(rel.targetId);
|
||||
nodeDegree.set(rel.sourceId, (nodeDegree.get(rel.sourceId) || 0) + 1);
|
||||
nodeDegree.set(rel.targetId, (nodeDegree.get(rel.targetId) || 0) + 1);
|
||||
connectedNodes.add(sourceId);
|
||||
connectedNodes.add(targetId);
|
||||
nodeDegree.set(sourceId, (nodeDegree.get(sourceId) || 0) + 1);
|
||||
nodeDegree.set(targetId, (nodeDegree.get(targetId) || 0) + 1);
|
||||
});
|
||||
|
||||
const nodes: CommunityProjectionNode[] = [];
|
||||
@@ -328,12 +330,12 @@ export const buildCommunityProjection = (knowledgeGraph: KnowledgeGraph): Commun
|
||||
const seenEdges = new Set<string>();
|
||||
const edges: Array<readonly [number, number]> = [];
|
||||
|
||||
knowledgeGraph.forEachRelationship((rel) => {
|
||||
if (!isClusteringRelationship(rel.type) || rel.sourceId === rel.targetId) return;
|
||||
if (isLarge && rel.confidence < MIN_CONFIDENCE_LARGE) return;
|
||||
knowledgeGraph.forEachRelationshipFields((sourceId, targetId, type, confidence) => {
|
||||
if (!isClusteringRelationship(type) || sourceId === targetId) return;
|
||||
if (isLarge && confidence < MIN_CONFIDENCE_LARGE) return;
|
||||
|
||||
const sourceIndex = nodeIndexById.get(rel.sourceId);
|
||||
const targetIndex = nodeIndexById.get(rel.targetId);
|
||||
const sourceIndex = nodeIndexById.get(sourceId);
|
||||
const targetIndex = nodeIndexById.get(targetId);
|
||||
if (sourceIndex === undefined || targetIndex === undefined || sourceIndex === targetIndex)
|
||||
return;
|
||||
|
||||
|
||||
@@ -1,61 +1,77 @@
|
||||
/**
|
||||
* Per-language DI field-matcher registry — the lookup the generic `di`
|
||||
* pipeline phase uses to decide whether a `Property` node is a
|
||||
* dependency-injection fan-out candidate.
|
||||
* Per-language DI resolver registry — the lookup the generic `di` pipeline
|
||||
* phase uses to discover injection sites and provider metadata on graph nodes.
|
||||
*
|
||||
* Mirrors `scope-resolution/pipeline/registry.ts` (`SCOPE_RESOLVERS`): a
|
||||
* single-valued `ReadonlyMap<SupportedLanguages, DiFieldMatcher>` consumed by
|
||||
* single-valued `ReadonlyMap<SupportedLanguages, DiResolver>` consumed by
|
||||
* a framework-neutral phase, so no language or framework names leak into
|
||||
* shared pipeline code. Adding a framework is two lines: implement a
|
||||
* `DiFieldMatcher` in `di-extractors/<framework>.ts` and register it here.
|
||||
* shared pipeline code. Adding a framework means implementing a `DiResolver`
|
||||
* in `di-extractors/<framework>.ts` and registering it here.
|
||||
*
|
||||
* Scope honesty: matchers are per-language *field-injection* matchers.
|
||||
* Constructor injection (the dominant modern Spring idiom) lives on
|
||||
* Method/parameter nodes and would require widening the phase's routing —
|
||||
* deliberately out of scope (see the plan's Deferred work). The registry is
|
||||
* single-valued per language, matching the `SCOPE_RESOLVERS` shape; widen the
|
||||
* value type to arrays only when a second same-language framework actually
|
||||
* lands (a one-line type change then).
|
||||
* The registry is single-valued per language, matching the `SCOPE_RESOLVERS`
|
||||
* shape; widen the value type to arrays only when a second same-language
|
||||
* framework actually lands. Java and Kotlin share Spring's attached metadata
|
||||
* contract while retaining language-specific syntax capture.
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import { springDiFieldMatcher } from './spring.js';
|
||||
import { springDiResolver } from './spring.js';
|
||||
|
||||
/** A successful DI field match, produced by a per-language matcher. */
|
||||
export interface DiFieldMatch {
|
||||
/** The element type name `T` — the injected bean interface. */
|
||||
elementTypeName: string;
|
||||
/** A successful injection-site match, produced by a per-language resolver. */
|
||||
export interface DiInjectionMatch {
|
||||
/** The requested dependency type name. */
|
||||
targetTypeName: string;
|
||||
/** A collection receives every matching provider; a single site may need
|
||||
* framework-specific named/preferred-provider disambiguation. */
|
||||
cardinality: 'single' | 'collection';
|
||||
/** Statically known provider name requested at the injection site. The
|
||||
* resolver owns the human-readable explanation of that selection. */
|
||||
namedSelection?: {
|
||||
name: string;
|
||||
reason: string;
|
||||
};
|
||||
/** Human-readable edge reason. Framework specifics (names, idioms,
|
||||
* collection wrapper, gating annotation) live in this payload so the
|
||||
* shared `di` phase stays framework-neutral. */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-language field-injection matcher: given a `Property` node, return the
|
||||
* parsed DI match or `null` when the field is not container-injected. The
|
||||
* matcher receives the whole node (not pre-plucked fields) so the shared
|
||||
* phase stays ignorant of which properties matter.
|
||||
*/
|
||||
export type DiFieldMatcher = (node: GraphNode) => DiFieldMatch | null;
|
||||
/** Provider metadata used by the shared resolver without naming a framework. */
|
||||
export interface DiProviderMatch {
|
||||
/** Provider names and aliases that can satisfy a named injection. */
|
||||
names: readonly string[];
|
||||
/** Present when the framework marks this as its preferred candidate. The
|
||||
* value is appended to the emitted edge reason when it disambiguates. */
|
||||
preferenceReason?: string;
|
||||
}
|
||||
|
||||
/** Per-language DI behavior. Matchers receive whole nodes so the shared phase
|
||||
* remains ignorant of language/framework-specific property shapes. */
|
||||
export interface DiResolver {
|
||||
matchInjectionSites(node: GraphNode): readonly DiInjectionMatch[];
|
||||
matchProvider(node: GraphNode): DiProviderMatch | null;
|
||||
}
|
||||
|
||||
/** All `SupportedLanguages` string values, for narrowing raw graph strings. */
|
||||
const SUPPORTED_LANGUAGE_VALUES: ReadonlySet<string> = new Set(Object.values(SupportedLanguages));
|
||||
|
||||
/**
|
||||
* Type guard narrowing an arbitrary graph `language` string to
|
||||
* `SupportedLanguages`, so `DI_MATCHERS.get()` needs no cast.
|
||||
* `SupportedLanguages`, so `DI_RESOLVERS.get()` needs no cast.
|
||||
*/
|
||||
export function isSupportedLanguage(value: string): value is SupportedLanguages {
|
||||
return SUPPORTED_LANGUAGE_VALUES.has(value);
|
||||
}
|
||||
|
||||
/** Map of `SupportedLanguages` → `DiFieldMatcher`. The `di` phase routes each
|
||||
* `Property` node here by `node.properties.language`; no entry ⇒ the node is
|
||||
/** Map of `SupportedLanguages` → `DiResolver`. The `di` phase routes each
|
||||
* graph node here by `node.properties.language`; no entry ⇒ the node is
|
||||
* skipped. This is the single source of truth for which languages (and,
|
||||
* transitively, frameworks) produce INJECTS edges. */
|
||||
export const DI_MATCHERS: ReadonlyMap<SupportedLanguages, DiFieldMatcher> = new Map<
|
||||
export const DI_RESOLVERS: ReadonlyMap<SupportedLanguages, DiResolver> = new Map<
|
||||
SupportedLanguages,
|
||||
DiFieldMatcher
|
||||
>([[SupportedLanguages.Java, springDiFieldMatcher]]);
|
||||
DiResolver
|
||||
>([
|
||||
[SupportedLanguages.Java, springDiResolver],
|
||||
[SupportedLanguages.Kotlin, springDiResolver],
|
||||
]);
|
||||
|
||||
@@ -51,13 +51,15 @@
|
||||
* between `<` and the element) are NOT stripped and fail closed —
|
||||
* acceptable.
|
||||
*
|
||||
* Registered under `SupportedLanguages.Java` in `./index.ts` (`DI_MATCHERS`);
|
||||
* language routing is the registry's job, so the matcher itself never reads
|
||||
* `node.properties.language`.
|
||||
* Registered for Java and Kotlin in `./index.ts` (`DI_RESOLVERS`); language
|
||||
* routing is the registry's job, so the matcher itself never reads
|
||||
* `node.properties.language`. Kotlin's AST-backed class metadata is the
|
||||
* primary path because Kotlin Property extraction intentionally exposes less
|
||||
* annotation/type syntax than Java's legacy field contract.
|
||||
*/
|
||||
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import type { DiFieldMatch, DiFieldMatcher } from './index.js';
|
||||
import type { DiInjectionMatch, DiProviderMatch, DiResolver } from './index.js';
|
||||
import { isDev } from '../utils/env.js';
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
@@ -84,6 +86,17 @@ const WILDCARD_SUPER_PREFIX = '? super ';
|
||||
* punctuation) fails closed. */
|
||||
const JAVA_TYPE_NAME_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/;
|
||||
|
||||
/** Ephemeral Class-node property populated by Java's post-resolution Spring
|
||||
* metadata hook. It is consumed in the same pipeline run before persistence. */
|
||||
export const SPRING_DI_INJECTION_SITES_PROPERTY = 'springDiInjectionSites';
|
||||
|
||||
/** Ephemeral Class-node property carrying Spring bean names / @Primary. */
|
||||
export const SPRING_DI_PROVIDER_PROPERTY = 'springDiProvider';
|
||||
|
||||
/** Marker placed on Property nodes whose richer AST-backed field fact was
|
||||
* attached to the owning Class, suppressing the legacy collection fallback. */
|
||||
export const SPRING_DI_CAPTURED_FIELD_PROPERTY = 'springDiCapturedField';
|
||||
|
||||
/**
|
||||
* Split a generic-argument list on TOP-LEVEL commas only, tracking `<`/`>`
|
||||
* bracket depth so nested generics (e.g. the `Pair<A,B>` key in
|
||||
@@ -181,13 +194,33 @@ export function parseSpringCollectionType(
|
||||
return { collectionType: wrapper, elementTypeName };
|
||||
}
|
||||
|
||||
/** Parse either a supported collect-all type or a standard single bean type. */
|
||||
export function parseSpringInjectionType(
|
||||
rawDeclaredType: string,
|
||||
): { targetTypeName: string; cardinality: 'single' | 'collection'; displayType: string } | null {
|
||||
const collection = parseSpringCollectionType(rawDeclaredType);
|
||||
if (collection !== null) {
|
||||
return {
|
||||
targetTypeName: collection.elementTypeName,
|
||||
cardinality: 'collection',
|
||||
displayType: `${collection.collectionType}<${collection.elementTypeName}>`,
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = rawDeclaredType.replace(/\s+/g, '').trim();
|
||||
if (!JAVA_TYPE_NAME_PATTERN.test(normalized)) return null;
|
||||
return { targetTypeName: normalized, cardinality: 'single', displayType: normalized };
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a `Property` node against Spring's collection-injection shape.
|
||||
*
|
||||
* Returns the parsed match (with a Spring-specific human-readable `reason`
|
||||
* payload) or `null` when the field is not container-injected.
|
||||
*/
|
||||
export const springDiFieldMatcher: DiFieldMatcher = (node: GraphNode): DiFieldMatch | null => {
|
||||
export const springDiFieldMatcher = (
|
||||
node: GraphNode,
|
||||
): { elementTypeName: string; reason: string } | null => {
|
||||
// Injection-annotation gate: only fields the container actually
|
||||
// injects (@Autowired / @Inject) are candidates. Plain collection
|
||||
// fields are never injected; @Resource is deliberately excluded
|
||||
@@ -220,3 +253,62 @@ export const springDiFieldMatcher: DiFieldMatcher = (node: GraphNode): DiFieldMa
|
||||
reason: `Spring DI: ${matchedAnnotation} ${parsed.collectionType}<${parsed.elementTypeName}>`,
|
||||
};
|
||||
};
|
||||
|
||||
function isInjectionMatch(value: unknown): value is DiInjectionMatch {
|
||||
if (value === null || typeof value !== 'object') return false;
|
||||
const match = value as Partial<DiInjectionMatch>;
|
||||
const namedSelection = match.namedSelection;
|
||||
return (
|
||||
typeof match.targetTypeName === 'string' &&
|
||||
(match.cardinality === 'single' || match.cardinality === 'collection') &&
|
||||
typeof match.reason === 'string' &&
|
||||
(namedSelection === undefined ||
|
||||
(typeof namedSelection === 'object' &&
|
||||
namedSelection !== null &&
|
||||
typeof namedSelection.name === 'string' &&
|
||||
typeof namedSelection.reason === 'string'))
|
||||
);
|
||||
}
|
||||
|
||||
function isProviderMatch(value: unknown): value is DiProviderMatch {
|
||||
if (value === null || typeof value !== 'object') return false;
|
||||
const provider = value as Partial<DiProviderMatch>;
|
||||
return (
|
||||
Array.isArray(provider.names) &&
|
||||
provider.names.every((name) => typeof name === 'string') &&
|
||||
(provider.preferenceReason === undefined || typeof provider.preferenceReason === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
/** JVM/Spring resolver registered behind the framework-neutral DI seam. */
|
||||
export const springDiResolver: DiResolver = {
|
||||
matchInjectionSites(node): readonly DiInjectionMatch[] {
|
||||
const matches: DiInjectionMatch[] = [];
|
||||
|
||||
// Preserve the existing Property-node collection contract for hand-built
|
||||
// graphs and for compatibility with pre-#2414 extraction fixtures.
|
||||
if (node.label === 'Property' && node.properties[SPRING_DI_CAPTURED_FIELD_PROPERTY] !== true) {
|
||||
const field = springDiFieldMatcher(node);
|
||||
if (field !== null) {
|
||||
matches.push({
|
||||
targetTypeName: field.elementTypeName,
|
||||
cardinality: 'collection',
|
||||
reason: field.reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const attached = node.properties[SPRING_DI_INJECTION_SITES_PROPERTY];
|
||||
if (Array.isArray(attached)) {
|
||||
for (const candidate of attached) {
|
||||
if (isInjectionMatch(candidate)) matches.push(candidate);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
},
|
||||
|
||||
matchProvider(node): DiProviderMatch | null {
|
||||
const attached = node.properties[SPRING_DI_PROVIDER_PROPERTY];
|
||||
return isProviderMatch(attached) ? attached : null;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface SpringBeanCandidateAdapter {
|
||||
}
|
||||
|
||||
type OwnedTypeNamesByOwner = ReadonlyMap<string, ReadonlySet<string>>;
|
||||
type RecognizedAnnotationNames = { readonly has: (value: string) => boolean };
|
||||
|
||||
function simpleNameOf(def: SymbolDefinition): string | undefined {
|
||||
const qualifiedName = def.qualifiedName;
|
||||
@@ -152,7 +153,11 @@ function hasVisibleTypeBinding(
|
||||
return false;
|
||||
}
|
||||
|
||||
function wildcardImportTarget(parsed: ParsedFile, simpleName: string): string | undefined {
|
||||
function wildcardImportTarget(
|
||||
parsed: ParsedFile,
|
||||
simpleName: string,
|
||||
recognizedAnnotations: RecognizedAnnotationNames,
|
||||
): string | undefined {
|
||||
const wildcardPackages = new Set(
|
||||
parsed.parsedImports
|
||||
.filter((entry) => entry.kind === 'wildcard')
|
||||
@@ -161,37 +166,40 @@ function wildcardImportTarget(parsed: ParsedFile, simpleName: string): string |
|
||||
if (wildcardPackages.size !== 1) return undefined;
|
||||
const [packageName] = wildcardPackages;
|
||||
const target = `${packageName}.${simpleName}`;
|
||||
return SPRING_BEAN_STEREOTYPES.has(target) ? target : undefined;
|
||||
return recognizedAnnotations.has(target) ? target : undefined;
|
||||
}
|
||||
|
||||
function resolveSpringAnnotation(
|
||||
rawName: string,
|
||||
parsed: ParsedFile,
|
||||
enclosingScope: ScopeId | null,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
ownedTypeNamesByOwner: OwnedTypeNamesByOwner,
|
||||
isPackageVisibilityIncomplete: boolean,
|
||||
): string | undefined {
|
||||
if (rawName.includes('.')) {
|
||||
return SPRING_BEAN_STEREOTYPES.has(rawName) ? rawName : undefined;
|
||||
}
|
||||
/** Build a scope-aware Spring annotation resolver shared by framework hooks. */
|
||||
export function createSpringAnnotationNameResolver(indexes: ScopeResolutionIndexes) {
|
||||
const ownedTypeNamesByOwner = buildOwnedTypeNamesByOwner(indexes);
|
||||
return (
|
||||
rawName: string,
|
||||
parsed: ParsedFile,
|
||||
enclosingScope: ScopeId | null,
|
||||
recognizedAnnotations: RecognizedAnnotationNames,
|
||||
isPackageVisibilityIncomplete: boolean,
|
||||
): string | undefined => {
|
||||
if (rawName.includes('.')) {
|
||||
return recognizedAnnotations.has(rawName) ? rawName : undefined;
|
||||
}
|
||||
|
||||
if (hasLexicalTypeDeclaration(enclosingScope, rawName, indexes)) return undefined;
|
||||
if (hasInheritedTypeDeclaration(enclosingScope, rawName, indexes, ownedTypeNamesByOwner)) {
|
||||
return undefined;
|
||||
}
|
||||
if (hasLexicalTypeDeclaration(enclosingScope, rawName, indexes)) return undefined;
|
||||
if (hasInheritedTypeDeclaration(enclosingScope, rawName, indexes, ownedTypeNamesByOwner)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const explicitImports = explicitImportTargets(parsed, rawName);
|
||||
if (explicitImports.size > 0) {
|
||||
if (explicitImports.size !== 1) return undefined;
|
||||
const [imported] = explicitImports;
|
||||
return SPRING_BEAN_STEREOTYPES.has(imported) ? imported : undefined;
|
||||
}
|
||||
const explicitImports = explicitImportTargets(parsed, rawName);
|
||||
if (explicitImports.size > 0) {
|
||||
if (explicitImports.size !== 1) return undefined;
|
||||
const [imported] = explicitImports;
|
||||
return recognizedAnnotations.has(imported) ? imported : undefined;
|
||||
}
|
||||
|
||||
const wildcardTarget = wildcardImportTarget(parsed, rawName);
|
||||
if (wildcardTarget === undefined || isPackageVisibilityIncomplete) return undefined;
|
||||
const wildcardTarget = wildcardImportTarget(parsed, rawName, recognizedAnnotations);
|
||||
if (wildcardTarget === undefined || isPackageVisibilityIncomplete) return undefined;
|
||||
|
||||
return hasVisibleTypeBinding(enclosingScope, rawName, indexes) ? undefined : wildcardTarget;
|
||||
return hasVisibleTypeBinding(enclosingScope, rawName, indexes) ? undefined : wildcardTarget;
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a language hook that enriches Class nodes after scope resolution. */
|
||||
@@ -202,7 +210,7 @@ export function createSpringBeanCandidateAttacher(adapter: SpringBeanCandidateAd
|
||||
nodeLookup: GraphNodeLookup,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
): void => {
|
||||
const ownedTypeNamesByOwner = buildOwnedTypeNamesByOwner(indexes);
|
||||
const resolveSpringAnnotation = createSpringAnnotationNameResolver(indexes);
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const fact of adapter.getClassAnnotationFacts(parsed.filePath)) {
|
||||
const classScope = indexes.scopeTree.getScope(fact.classScopeId);
|
||||
@@ -221,8 +229,7 @@ export function createSpringBeanCandidateAttacher(adapter: SpringBeanCandidateAd
|
||||
rawName,
|
||||
parsed,
|
||||
classScope.parent,
|
||||
indexes,
|
||||
ownedTypeNamesByOwner,
|
||||
SPRING_BEAN_STEREOTYPES,
|
||||
adapter.isPackageVisibilityIncomplete(parsed.filePath),
|
||||
);
|
||||
if (annotation !== undefined) recognized.add(annotation);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
|
||||
export const SPRING_CONFIG_DESCRIPTION = 'Spring configuration property';
|
||||
|
||||
export interface SpringValueConsumer {
|
||||
readonly kind: 'value';
|
||||
readonly fieldName: string;
|
||||
readonly line: number;
|
||||
readonly keys: readonly string[];
|
||||
}
|
||||
|
||||
export interface SpringConfigurationPropertiesConsumer {
|
||||
readonly kind: 'configuration-properties';
|
||||
readonly className: string;
|
||||
readonly line: number;
|
||||
readonly prefix: string;
|
||||
}
|
||||
|
||||
export type SpringConfigConsumer = SpringValueConsumer | SpringConfigurationPropertiesConsumer;
|
||||
|
||||
export interface SpringConfigConsumerBatch {
|
||||
readonly filePath: string;
|
||||
readonly consumers: readonly SpringConfigConsumer[];
|
||||
}
|
||||
|
||||
function closestNode(
|
||||
candidates: readonly GraphNode[],
|
||||
filePath: string,
|
||||
name: string,
|
||||
line: number,
|
||||
): GraphNode | undefined {
|
||||
return candidates
|
||||
.filter((node) => node.properties.filePath === filePath && node.properties.name === name)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Math.abs(Number(left.properties.startLine ?? 0) - line) -
|
||||
Math.abs(Number(right.properties.startLine ?? 0) - line),
|
||||
)[0];
|
||||
}
|
||||
|
||||
function markUnresolved(node: GraphNode, key: string): void {
|
||||
const marker = `Spring config unresolved: ${key}`;
|
||||
const existing =
|
||||
typeof node.properties.description === 'string' ? node.properties.description : '';
|
||||
if (existing.includes(marker)) return;
|
||||
node.properties.description = existing.length > 0 ? `${existing}; ${marker}` : marker;
|
||||
}
|
||||
|
||||
function relaxedName(value: string): string {
|
||||
return value.toLowerCase().replace(/[-_.]/g, '');
|
||||
}
|
||||
|
||||
function isSpringConfigNode(node: GraphNode): boolean {
|
||||
return (
|
||||
node.label === 'Property' &&
|
||||
typeof node.properties.description === 'string' &&
|
||||
node.properties.description.startsWith(SPRING_CONFIG_DESCRIPTION)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach normalized, language-provider-produced Spring consumers to config
|
||||
* keys already present in the shared graph.
|
||||
*/
|
||||
export function bindSpringConfigConsumers(
|
||||
graph: KnowledgeGraph,
|
||||
batches: readonly SpringConfigConsumerBatch[],
|
||||
): void {
|
||||
if (batches.length === 0) return;
|
||||
|
||||
const configNodes: GraphNode[] = [];
|
||||
const propertyNodes: GraphNode[] = [];
|
||||
const classNodes: GraphNode[] = [];
|
||||
for (const node of graph.iterNodes()) {
|
||||
if (isSpringConfigNode(node)) configNodes.push(node);
|
||||
else if (node.label === 'Property') propertyNodes.push(node);
|
||||
else if (node.label === 'Class' || node.label === 'Record') classNodes.push(node);
|
||||
}
|
||||
|
||||
const keyNodes = new Map<string, GraphNode[]>();
|
||||
for (const node of configNodes) {
|
||||
const key = String(node.properties.name);
|
||||
const bucket = keyNodes.get(key) ?? [];
|
||||
bucket.push(node);
|
||||
keyNodes.set(key, bucket);
|
||||
}
|
||||
|
||||
const propertiesByOwner = new Map<string, GraphNode[]>();
|
||||
for (const rel of graph.iterRelationshipsByType('HAS_PROPERTY')) {
|
||||
const property = graph.getNode(rel.targetId);
|
||||
if (property?.label !== 'Property' || isSpringConfigNode(property)) continue;
|
||||
const members = propertiesByOwner.get(rel.sourceId) ?? [];
|
||||
members.push(property);
|
||||
propertiesByOwner.set(rel.sourceId, members);
|
||||
}
|
||||
|
||||
const addBinding = (
|
||||
source: GraphNode,
|
||||
target: GraphNode,
|
||||
reason: string,
|
||||
confidence: number,
|
||||
): void => {
|
||||
const edgeId = generateId('USES', `${source.id}->${target.id}:${reason}`);
|
||||
graph.addRelationship({
|
||||
id: edgeId,
|
||||
sourceId: source.id,
|
||||
targetId: target.id,
|
||||
type: 'USES',
|
||||
confidence,
|
||||
reason,
|
||||
});
|
||||
};
|
||||
|
||||
for (const { filePath, consumers } of batches) {
|
||||
for (const consumer of consumers) {
|
||||
if (consumer.kind === 'value') {
|
||||
const field = closestNode(propertyNodes, filePath, consumer.fieldName, consumer.line);
|
||||
if (field === undefined) continue;
|
||||
for (const key of consumer.keys) {
|
||||
const matches = keyNodes.get(key) ?? [];
|
||||
if (matches.length === 0) {
|
||||
markUnresolved(field, key);
|
||||
continue;
|
||||
}
|
||||
for (const match of matches) {
|
||||
addBinding(field, match, `spring-config:@Value ${key}`, 1);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const owner = closestNode(classNodes, filePath, consumer.className, consumer.line);
|
||||
if (owner === undefined) continue;
|
||||
const prefix = `${consumer.prefix}.`;
|
||||
const matches = configNodes.filter((node) => {
|
||||
const key = String(node.properties.name);
|
||||
return key === consumer.prefix || key.startsWith(prefix);
|
||||
});
|
||||
if (matches.length === 0) {
|
||||
markUnresolved(owner, consumer.prefix);
|
||||
continue;
|
||||
}
|
||||
for (const match of matches) {
|
||||
addBinding(owner, match, `spring-config:@ConfigurationProperties ${consumer.prefix}`, 0.95);
|
||||
}
|
||||
|
||||
for (const field of propertiesByOwner.get(owner.id) ?? []) {
|
||||
const fieldName = relaxedName(String(field.properties.name));
|
||||
for (const match of matches) {
|
||||
const key = String(match.properties.name);
|
||||
const suffix = key === consumer.prefix ? '' : key.slice(prefix.length);
|
||||
const firstSegment = suffix.split(/[.\[]/, 1)[0];
|
||||
if (firstSegment.length === 0 || relaxedName(firstSegment) !== fieldName) continue;
|
||||
addBinding(
|
||||
field,
|
||||
match,
|
||||
`spring-config:@ConfigurationProperties field ${consumer.prefix}`,
|
||||
0.95,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import type { ParsedFile, ScopeId } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import type { DiInjectionMatch, DiProviderMatch } from '../../di-extractors/index.js';
|
||||
import {
|
||||
parseSpringInjectionType,
|
||||
SPRING_DI_CAPTURED_FIELD_PROPERTY,
|
||||
SPRING_DI_INJECTION_SITES_PROPERTY,
|
||||
SPRING_DI_PROVIDER_PROPERTY,
|
||||
} from '../../di-extractors/spring.js';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
|
||||
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
|
||||
import { createSpringAnnotationNameResolver } from './bean-candidates.js';
|
||||
import { SPRING_BEAN_STEREOTYPES } from './bean-catalog.js';
|
||||
|
||||
export interface SpringDiAnnotationFact {
|
||||
readonly name: string;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export interface SpringDiDependencyFact<Annotation extends SpringDiAnnotationFact> {
|
||||
readonly name: string;
|
||||
readonly rawType: string;
|
||||
readonly annotations: readonly Annotation[];
|
||||
}
|
||||
|
||||
export interface SpringDiInjectionSiteFact<
|
||||
Annotation extends SpringDiAnnotationFact,
|
||||
SiteKind extends string,
|
||||
> {
|
||||
readonly kind: SiteKind;
|
||||
readonly memberName: string;
|
||||
readonly implicitConstructor: boolean;
|
||||
readonly annotations: readonly Annotation[];
|
||||
readonly dependencies: readonly SpringDiDependencyFact<Annotation>[];
|
||||
}
|
||||
|
||||
export interface SpringDiClassFact<
|
||||
Annotation extends SpringDiAnnotationFact,
|
||||
SiteKind extends string,
|
||||
> {
|
||||
readonly classScopeId: ScopeId;
|
||||
readonly classAnnotations: readonly Annotation[];
|
||||
readonly injectionSites: readonly SpringDiInjectionSiteFact<Annotation, SiteKind>[];
|
||||
}
|
||||
|
||||
const INJECTION_ANNOTATIONS = new Set([
|
||||
'org.springframework.beans.factory.annotation.Autowired',
|
||||
'jakarta.inject.Inject',
|
||||
'javax.inject.Inject',
|
||||
]);
|
||||
|
||||
const QUALIFIER_ANNOTATIONS = new Set([
|
||||
'org.springframework.beans.factory.annotation.Qualifier',
|
||||
'jakarta.inject.Named',
|
||||
'javax.inject.Named',
|
||||
]);
|
||||
|
||||
const PRIMARY_ANNOTATIONS = new Set(['org.springframework.context.annotation.Primary']);
|
||||
|
||||
const RESOLVABLE_DI_ANNOTATIONS = new Set([
|
||||
...SPRING_BEAN_STEREOTYPES.keys(),
|
||||
...INJECTION_ANNOTATIONS,
|
||||
...QUALIFIER_ANNOTATIONS,
|
||||
...PRIMARY_ANNOTATIONS,
|
||||
]);
|
||||
|
||||
const CAPTURE_RELEVANT_ANNOTATIONS = new Set([
|
||||
'Autowired',
|
||||
'Inject',
|
||||
'Qualifier',
|
||||
'Named',
|
||||
'Primary',
|
||||
'Component',
|
||||
'Service',
|
||||
'Repository',
|
||||
'Controller',
|
||||
'RestController',
|
||||
'Configuration',
|
||||
]);
|
||||
|
||||
const STEREOTYPE_SIMPLE_NAMES = new Set(
|
||||
[...SPRING_BEAN_STEREOTYPES.keys()].map((name) => springAnnotationSimpleName(name)),
|
||||
);
|
||||
|
||||
export function springAnnotationSimpleName(name: string): string {
|
||||
const separator = name.lastIndexOf('.');
|
||||
return separator === -1 ? name : name.slice(separator + 1);
|
||||
}
|
||||
|
||||
export function hasSpringDiRelevantAnnotation(
|
||||
annotations: readonly SpringDiAnnotationFact[],
|
||||
): boolean {
|
||||
return annotations.some((annotation) =>
|
||||
CAPTURE_RELEVANT_ANNOTATIONS.has(springAnnotationSimpleName(annotation.name)),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasSpringStereotypeSyntax(annotations: readonly SpringDiAnnotationFact[]): boolean {
|
||||
return annotations.some((annotation) =>
|
||||
STEREOTYPE_SIMPLE_NAMES.has(springAnnotationSimpleName(annotation.name)),
|
||||
);
|
||||
}
|
||||
|
||||
function staticStringArgument(annotationText: string): string | undefined {
|
||||
const args = annotationText.match(/\((.*)\)$/s)?.[1]?.trim();
|
||||
if (args === undefined) return undefined;
|
||||
const value = args.replace(/^value\s*=\s*/, '').trim();
|
||||
const literal = value.match(/^"((?:\\.|[^"\\])*)"$/s);
|
||||
if (literal === null) return undefined;
|
||||
try {
|
||||
return JSON.parse(`"${literal[1]}"`) as string;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultBeanName(className: string): string {
|
||||
if (className.length === 0) return className;
|
||||
if (
|
||||
className.length > 1 &&
|
||||
className[0] !== className[0].toLowerCase() &&
|
||||
className[1] !== className[1].toLowerCase()
|
||||
) {
|
||||
return className;
|
||||
}
|
||||
return className[0].toLowerCase() + className.slice(1);
|
||||
}
|
||||
|
||||
type ParsedSpringInjectionType = NonNullable<ReturnType<typeof parseSpringInjectionType>>;
|
||||
|
||||
export interface SpringDiMetadataAdapter<
|
||||
Annotation extends SpringDiAnnotationFact,
|
||||
SiteKind extends string,
|
||||
> {
|
||||
getFacts(filePath: string): readonly SpringDiClassFact<Annotation, SiteKind>[];
|
||||
isPackageVisibilityIncomplete(filePath: string): boolean;
|
||||
parseInjectionType(rawType: string): ParsedSpringInjectionType | null;
|
||||
capturedMemberKind: SiteKind;
|
||||
isInjectionAnnotationApplicable?(
|
||||
annotation: Annotation,
|
||||
site: SpringDiInjectionSiteFact<Annotation, SiteKind>,
|
||||
): boolean;
|
||||
isQualifierAnnotationApplicable?(
|
||||
annotation: Annotation,
|
||||
site: SpringDiInjectionSiteFact<Annotation, SiteKind>,
|
||||
): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the post-resolution Spring DI metadata hook shared by language adapters.
|
||||
* Language adapters retain syntax capture, type normalization, use-site rules,
|
||||
* and side-channel ownership; this function owns framework semantics only.
|
||||
*/
|
||||
export function createSpringDiMetadataAttacher<
|
||||
Annotation extends SpringDiAnnotationFact,
|
||||
SiteKind extends string,
|
||||
>(adapter: SpringDiMetadataAdapter<Annotation, SiteKind>) {
|
||||
return (
|
||||
graph: KnowledgeGraph,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
): void => {
|
||||
const resolveAnnotation = createSpringAnnotationNameResolver(indexes);
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const incomplete = adapter.isPackageVisibilityIncomplete(parsed.filePath);
|
||||
for (const fact of adapter.getFacts(parsed.filePath)) {
|
||||
const classScope = indexes.scopeTree.getScope(fact.classScopeId);
|
||||
if (classScope === undefined || classScope.kind !== 'Class') continue;
|
||||
const classDef = classScope.ownedDefs.find((definition) => definition.type === 'Class');
|
||||
if (classDef === undefined) continue;
|
||||
const graphId = resolveDefGraphId(parsed.filePath, classDef, nodeLookup);
|
||||
if (graphId === undefined) continue;
|
||||
const classNode = graph.getNode(graphId);
|
||||
if (classNode === undefined || classNode.label !== 'Class') continue;
|
||||
|
||||
const resolvedAnnotations = new Map<string, string | undefined>();
|
||||
const resolveFact = (
|
||||
annotation: Annotation,
|
||||
enclosingScope: ScopeId | null = classScope.parent,
|
||||
): string | undefined => {
|
||||
const cacheKey = `${enclosingScope ?? '<root>'}\0${annotation.name}`;
|
||||
if (resolvedAnnotations.has(cacheKey)) return resolvedAnnotations.get(cacheKey);
|
||||
const resolved = resolveAnnotation(
|
||||
annotation.name,
|
||||
parsed,
|
||||
enclosingScope,
|
||||
RESOLVABLE_DI_ANNOTATIONS,
|
||||
incomplete,
|
||||
);
|
||||
resolvedAnnotations.set(cacheKey, resolved);
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const frameworkAnnotations = Array.isArray(classNode.properties.frameworkAnnotations)
|
||||
? classNode.properties.frameworkAnnotations.filter(
|
||||
(annotation): annotation is string => typeof annotation === 'string',
|
||||
)
|
||||
: [];
|
||||
if (frameworkAnnotations.length > 0) {
|
||||
const names = new Set<string>();
|
||||
let explicitBeanName: string | undefined;
|
||||
let hasDynamicBeanName = false;
|
||||
let primary = false;
|
||||
for (const annotation of fact.classAnnotations) {
|
||||
const resolved = resolveFact(annotation);
|
||||
if (resolved === undefined) continue;
|
||||
if (SPRING_BEAN_STEREOTYPES.has(resolved)) {
|
||||
const argumentText = annotation.text.match(/\((.*)\)$/s)?.[1]?.trim();
|
||||
if (argumentText !== undefined && argumentText.length > 0) {
|
||||
const staticName = staticStringArgument(annotation.text);
|
||||
if (staticName === undefined) hasDynamicBeanName = true;
|
||||
else if (staticName.length > 0) explicitBeanName = staticName;
|
||||
}
|
||||
}
|
||||
if (QUALIFIER_ANNOTATIONS.has(resolved)) {
|
||||
const qualifier = staticStringArgument(annotation.text);
|
||||
if (qualifier !== undefined) names.add(qualifier);
|
||||
}
|
||||
if (PRIMARY_ANNOTATIONS.has(resolved)) primary = true;
|
||||
}
|
||||
if (explicitBeanName !== undefined) names.add(explicitBeanName);
|
||||
else if (!hasDynamicBeanName) names.add(defaultBeanName(classNode.properties.name));
|
||||
const provider: DiProviderMatch = {
|
||||
names: [...names],
|
||||
...(primary ? { preferenceReason: 'selected @Primary' } : {}),
|
||||
};
|
||||
classNode.properties[SPRING_DI_PROVIDER_PROPERTY] = provider;
|
||||
}
|
||||
|
||||
const matches: DiInjectionMatch[] = [];
|
||||
const semanticallyOwnedMemberNames = new Set<string>();
|
||||
for (const site of fact.injectionSites) {
|
||||
let injectionAnnotation: Annotation | undefined;
|
||||
for (const annotation of site.annotations) {
|
||||
if (adapter.isInjectionAnnotationApplicable?.(annotation, site) === false) continue;
|
||||
const resolved = resolveFact(annotation, classScope.id);
|
||||
if (resolved !== undefined && INJECTION_ANNOTATIONS.has(resolved)) {
|
||||
injectionAnnotation = annotation;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (injectionAnnotation === undefined) {
|
||||
if (!site.implicitConstructor || frameworkAnnotations.length === 0) continue;
|
||||
} else if (site.kind === adapter.capturedMemberKind) {
|
||||
// Claim the member only after its injection annotation resolves to
|
||||
// a recognized FQN. Ambiguous wildcard imports stay unclaimed so
|
||||
// the legacy collection matcher can fall back. A dynamic qualifier
|
||||
// later fails closed, but this path still owns the member and must
|
||||
// suppress that legacy fallback.
|
||||
semanticallyOwnedMemberNames.add(site.memberName);
|
||||
}
|
||||
|
||||
for (const dependency of site.dependencies) {
|
||||
const parsedType = adapter.parseInjectionType(dependency.rawType);
|
||||
if (parsedType === null) continue;
|
||||
let qualifierAnnotation: Annotation | undefined;
|
||||
for (const annotation of dependency.annotations) {
|
||||
if (adapter.isQualifierAnnotationApplicable?.(annotation, site) === false) continue;
|
||||
const resolved = resolveFact(annotation, classScope.id);
|
||||
if (resolved !== undefined && QUALIFIER_ANNOTATIONS.has(resolved)) {
|
||||
qualifierAnnotation = annotation;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const qualifier =
|
||||
qualifierAnnotation === undefined
|
||||
? undefined
|
||||
: staticStringArgument(qualifierAnnotation.text);
|
||||
// A present-but-dynamic qualifier is not the same as no qualifier.
|
||||
// Without its value we cannot choose a provider honestly, so fail
|
||||
// closed instead of emitting the unqualified candidate set.
|
||||
if (qualifierAnnotation !== undefined && qualifier === undefined) continue;
|
||||
const trigger =
|
||||
injectionAnnotation === undefined
|
||||
? 'constructor'
|
||||
: `@${springAnnotationSimpleName(injectionAnnotation.name)} ${site.kind}`;
|
||||
const location =
|
||||
site.kind === adapter.capturedMemberKind
|
||||
? site.memberName
|
||||
: `${site.memberName} parameter ${dependency.name}`;
|
||||
matches.push({
|
||||
targetTypeName: parsedType.targetTypeName,
|
||||
cardinality: parsedType.cardinality,
|
||||
...(qualifier === undefined
|
||||
? {}
|
||||
: {
|
||||
namedSelection: {
|
||||
name: qualifier,
|
||||
reason: `qualifier "${qualifier}"`,
|
||||
},
|
||||
}),
|
||||
reason: `Spring DI: ${trigger} ${location}: ${parsedType.displayType}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (matches.length > 0) {
|
||||
classNode.properties[SPRING_DI_INJECTION_SITES_PROPERTY] = matches;
|
||||
}
|
||||
|
||||
for (const memberName of semanticallyOwnedMemberNames) {
|
||||
for (const { def } of classScope.bindings.get(memberName) ?? []) {
|
||||
if (def.ownerId !== classDef.nodeId) continue;
|
||||
const propertyId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
|
||||
if (propertyId === undefined) continue;
|
||||
const property = graph.getNode(propertyId);
|
||||
if (property?.label === 'Property') {
|
||||
property.properties[SPRING_DI_CAPTURED_FIELD_PROPERTY] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -615,7 +615,11 @@ export function populateCsharpNamespaceSiblings(
|
||||
}
|
||||
if (seen.has(memberDef.nodeId)) continue;
|
||||
seen.add(memberDef.nodeId);
|
||||
bucketArr.push({ def: memberDef, origin: 'import' });
|
||||
bucketArr.push({
|
||||
def: memberDef,
|
||||
origin: 'import',
|
||||
visibility: 'static-member-import',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ const csharpScopeResolver: ScopeResolver = {
|
||||
// `(caller, target)` — multiple `g.Greet(...)` sites from Main
|
||||
// yield ONE edge, not one per site.
|
||||
collapseMemberCallsByCallerTarget: true,
|
||||
freeCallsRequireInstanceOwnership: true,
|
||||
|
||||
// C# hoists method return-type bindings to the enclosing Module
|
||||
// scope so `propagateImportedReturnTypes` can mirror them across
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { AnalysisFeatureDescriptor } from '../../../analysis-features.js';
|
||||
|
||||
function isSpringApplicationConfig(filePath: string): boolean {
|
||||
const base = filePath.replaceAll('\\', '/').split('/').pop() ?? '';
|
||||
return /^application(?:-[^.]+)?\.(?:properties|ya?ml)$/i.test(base);
|
||||
}
|
||||
|
||||
/** Durable completeness contract for Java Spring configuration bindings. */
|
||||
export const SPRING_CONFIG_BINDINGS_FEATURE: AnalysisFeatureDescriptor = {
|
||||
id: 'spring.config-bindings',
|
||||
version: 1,
|
||||
// Java sources need consumer extraction even without config files (missing
|
||||
// placeholders still get unresolved markers). Config-only repositories also
|
||||
// need a one-time rebuild to backfill language-agnostic Property nodes.
|
||||
appliesTo: (filePaths) =>
|
||||
filePaths.some(
|
||||
(filePath) => filePath.toLowerCase().endsWith('.java') || isSpringApplicationConfig(filePath),
|
||||
),
|
||||
};
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type JvmPackageFact,
|
||||
} from '../jvm/package-facts.js';
|
||||
import { getJavaPackageFact, setJavaPackageFact } from './package-facts.js';
|
||||
import type { JavaSpringConfigConsumerFact } from './spring-config-bindings.js';
|
||||
import type { JavaSpringDiClassFact } from './spring-di.js';
|
||||
|
||||
export type JavaClassAnnotationFact = ClassAnnotationFact;
|
||||
|
||||
@@ -16,13 +18,19 @@ export interface JavaCaptureSideChannel {
|
||||
readonly kind: 'java';
|
||||
readonly packageFact: JvmPackageFact;
|
||||
readonly classAnnotations: readonly JavaClassAnnotationFact[];
|
||||
readonly springConfigConsumers?: readonly JavaSpringConfigConsumerFact[];
|
||||
readonly springDiFacts?: readonly JavaSpringDiClassFact[];
|
||||
}
|
||||
|
||||
const classAnnotations = createClassAnnotationFactStore();
|
||||
const springConfigConsumers = new Map<string, readonly JavaSpringConfigConsumerFact[]>();
|
||||
const springDiFacts = new Map<string, readonly JavaSpringDiClassFact[]>();
|
||||
|
||||
/** Clear facts retained by a prior workspace pass in a long-lived process. */
|
||||
export function clearJavaClassAnnotationFacts(): void {
|
||||
classAnnotations.clear();
|
||||
springConfigConsumers.clear();
|
||||
springDiFacts.clear();
|
||||
}
|
||||
|
||||
/** Store the annotation syntax collected by Java's existing scope-query traversal. */
|
||||
@@ -33,17 +41,54 @@ export function setJavaClassAnnotationFacts(
|
||||
classAnnotations.set(filePath, facts);
|
||||
}
|
||||
|
||||
export function setJavaSpringConfigConsumerFacts(
|
||||
filePath: string,
|
||||
facts: readonly JavaSpringConfigConsumerFact[],
|
||||
): void {
|
||||
if (facts.length === 0) springConfigConsumers.delete(filePath);
|
||||
else springConfigConsumers.set(filePath, facts);
|
||||
}
|
||||
|
||||
export function getJavaSpringConfigConsumerFacts(
|
||||
filePath: string,
|
||||
): readonly JavaSpringConfigConsumerFact[] {
|
||||
return springConfigConsumers.get(filePath) ?? [];
|
||||
}
|
||||
|
||||
export function setJavaSpringDiFacts(
|
||||
filePath: string,
|
||||
facts: readonly JavaSpringDiClassFact[],
|
||||
): void {
|
||||
if (facts.length === 0) springDiFacts.delete(filePath);
|
||||
else springDiFacts.set(filePath, facts);
|
||||
}
|
||||
|
||||
export function getJavaSpringDiFacts(filePath: string): readonly JavaSpringDiClassFact[] {
|
||||
return springDiFacts.get(filePath) ?? [];
|
||||
}
|
||||
|
||||
/** Snapshot worker-local Java annotation facts for ParsedFile serialization. */
|
||||
export function collectJavaCaptureSideChannel(
|
||||
filePath: string,
|
||||
): JavaCaptureSideChannel | undefined {
|
||||
const facts = classAnnotations.get(filePath);
|
||||
const configConsumers = springConfigConsumers.get(filePath) ?? [];
|
||||
const diFacts = springDiFacts.get(filePath) ?? [];
|
||||
const packageFact = getJavaPackageFact(filePath);
|
||||
if (facts.length === 0 && packageFact === undefined) return undefined;
|
||||
if (
|
||||
facts.length === 0 &&
|
||||
configConsumers.length === 0 &&
|
||||
diFacts.length === 0 &&
|
||||
packageFact === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
kind: 'java',
|
||||
packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT,
|
||||
classAnnotations: facts,
|
||||
...(configConsumers.length > 0 ? { springConfigConsumers: configConsumers } : {}),
|
||||
...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,10 +107,20 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void {
|
||||
!Array.isArray(data.classAnnotations)
|
||||
) {
|
||||
setJavaClassAnnotationFacts(parsed.filePath, []);
|
||||
setJavaSpringConfigConsumerFacts(parsed.filePath, []);
|
||||
setJavaSpringDiFacts(parsed.filePath, []);
|
||||
setJavaPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
|
||||
return;
|
||||
}
|
||||
setJavaClassAnnotationFacts(parsed.filePath, data.classAnnotations);
|
||||
setJavaSpringConfigConsumerFacts(
|
||||
parsed.filePath,
|
||||
Array.isArray(data.springConfigConsumers) ? data.springConfigConsumers : [],
|
||||
);
|
||||
setJavaSpringDiFacts(
|
||||
parsed.filePath,
|
||||
Array.isArray(data.springDiFacts) ? data.springDiFacts : [],
|
||||
);
|
||||
setJavaPackageFact(
|
||||
parsed.filePath,
|
||||
isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT,
|
||||
|
||||
@@ -20,9 +20,10 @@ import {
|
||||
recordClassAnnotationCapture,
|
||||
} from '../../frameworks/spring/bean-candidates.js';
|
||||
import {
|
||||
javaLocalTypeDeclarationContainer,
|
||||
nodeIfType,
|
||||
nodeToCapture,
|
||||
synthesizeJavaAnonymousClassName,
|
||||
synthesizeJavaTypeIdentity,
|
||||
syntheticCapture,
|
||||
} from '../../utils/ast-helpers.js';
|
||||
import { splitImportDeclaration } from './import-decomposer.js';
|
||||
@@ -32,15 +33,25 @@ import { getJavaParser, getJavaScopeQuery } from './query.js';
|
||||
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
import { setJavaClassAnnotationFacts } from './capture-side-channel.js';
|
||||
import {
|
||||
setJavaClassAnnotationFacts,
|
||||
setJavaSpringConfigConsumerFacts,
|
||||
setJavaSpringDiFacts,
|
||||
} from './capture-side-channel.js';
|
||||
import { captureJavaPackageFact } from './package-facts.js';
|
||||
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
|
||||
import { captureJavaSpringConfigConsumerFacts } from './spring-config-bindings.js';
|
||||
import { captureJavaSpringDiClassFact, type JavaSpringDiClassFact } from './spring-di.js';
|
||||
|
||||
/** Declaration anchors that carry function-like arity metadata. */
|
||||
const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const;
|
||||
|
||||
/** tree-sitter-java node types that the method extractor accepts. */
|
||||
const FUNCTION_NODE_TYPES = ['method_declaration', 'constructor_declaration'] as const;
|
||||
const FUNCTION_NODE_TYPES = [
|
||||
'method_declaration',
|
||||
'constructor_declaration',
|
||||
'compact_constructor_declaration',
|
||||
] as const;
|
||||
|
||||
const JAVA_CALLABLE_CAPTURE_OPTIONS = {
|
||||
functionNodeTypes: new Set([...FUNCTION_NODE_TYPES, 'lambda_expression']),
|
||||
@@ -61,6 +72,26 @@ const JAVA_CALLABLE_CAPTURE_OPTIONS = {
|
||||
normalizeQualifiedName: (raw: string) => raw.replaceAll('::', '.'),
|
||||
} as const;
|
||||
|
||||
/** Visibility of a local type begins at its declaration and ends with its
|
||||
* immediately enclosing block (JLS 6.3). A Java-only synthetic Block scope
|
||||
* models that range without changing shared resolver selection semantics. */
|
||||
function javaLocalTypeVisibilityScope(node: SyntaxNode): CaptureMatch | undefined {
|
||||
const container = javaLocalTypeDeclarationContainer(node);
|
||||
if (container === null) return undefined;
|
||||
return {
|
||||
'@scope.block': {
|
||||
name: '@scope.block',
|
||||
range: {
|
||||
startLine: node.startPosition.row + 1,
|
||||
startCol: node.startPosition.column,
|
||||
endLine: container.endPosition.row + 1,
|
||||
endCol: container.endPosition.column,
|
||||
},
|
||||
text: node.text,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Suppress read.member emissions when the field_access is already
|
||||
* covered by a method_invocation (object of a call) or an
|
||||
* assignment_expression (write target). */
|
||||
@@ -95,6 +126,8 @@ export function emitJavaScopeCaptures(
|
||||
const rawMatches = getJavaScopeQuery().matches(tree.rootNode);
|
||||
const out: CaptureMatch[] = [];
|
||||
const classAnnotations = new Map<ScopeId, Set<string>>();
|
||||
const springDiFacts: JavaSpringDiClassFact[] = [];
|
||||
const springDiClassNodeIds = new Set<number>();
|
||||
|
||||
for (const m of rawMatches) {
|
||||
const grouped: Record<string, Capture> = {};
|
||||
@@ -114,6 +147,13 @@ export function emitJavaScopeCaptures(
|
||||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
const springDiClassNode = nodeIfType(nodeMap['@scope.class'], 'class_declaration');
|
||||
if (springDiClassNode !== null && !springDiClassNodeIds.has(springDiClassNode.id)) {
|
||||
springDiClassNodeIds.add(springDiClassNode.id);
|
||||
const fact = captureJavaSpringDiClassFact(springDiClassNode, filePath);
|
||||
if (fact !== null) springDiFacts.push(fact);
|
||||
}
|
||||
|
||||
const annotatedClass = grouped['@class-annotation.class'];
|
||||
const annotationName = grouped['@class-annotation.name'];
|
||||
if (annotatedClass !== undefined && annotationName !== undefined) {
|
||||
@@ -121,6 +161,29 @@ export function emitJavaScopeCaptures(
|
||||
continue;
|
||||
}
|
||||
|
||||
const typeDeclaration = [
|
||||
nodeMap['@declaration.class'],
|
||||
nodeMap['@declaration.enum'],
|
||||
nodeMap['@declaration.record'],
|
||||
nodeMap['@declaration.interface'],
|
||||
].find((node): node is SyntaxNode => node !== undefined);
|
||||
const localTypeIdentity =
|
||||
typeDeclaration === undefined ? undefined : synthesizeJavaTypeIdentity(typeDeclaration);
|
||||
if (
|
||||
localTypeIdentity?.bindingName !== undefined &&
|
||||
grouped['@declaration.name'] !== undefined &&
|
||||
typeDeclaration !== undefined
|
||||
) {
|
||||
grouped['@declaration.binding-name'] = grouped['@declaration.name'];
|
||||
grouped['@declaration.name'] = syntheticCapture(
|
||||
'@declaration.name',
|
||||
typeDeclaration,
|
||||
localTypeIdentity.name,
|
||||
);
|
||||
const visibilityScope = javaLocalTypeVisibilityScope(typeDeclaration);
|
||||
if (visibilityScope !== undefined) out.push(visibilityScope);
|
||||
}
|
||||
|
||||
// Decompose each `import_declaration`. `@import.statement` is captured
|
||||
// directly on the `import_declaration` node.
|
||||
if (grouped['@import.statement'] !== undefined) {
|
||||
@@ -150,6 +213,29 @@ export function emitJavaScopeCaptures(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalize a `new`-expression receiver to its constructed type's simple
|
||||
// name: `new Local().inner()` binds the WHOLE `object_creation_expression`
|
||||
// as `@reference.receiver`, so its raw text is `"new Local()"` — a string
|
||||
// that can never match a scope binding, so the compound-receiver resolver
|
||||
// silently falls through to name-only fallback resolution and picks the
|
||||
// wrong same-named method on a collision (#2564). Rewriting the text to
|
||||
// just `Local` lets Case 2 (class-name / static receiver) in
|
||||
// receiver-bound-calls.ts resolve it via its normal MRO walk. Mirrors the
|
||||
// established `normalizePhpReceiver` precedent (php/captures.ts) — a
|
||||
// language-local capture rewrite, no shared-pipeline change.
|
||||
if (grouped['@reference.receiver'] !== undefined) {
|
||||
const receiverNode = nodeIfType(nodeMap['@reference.receiver'], 'object_creation_expression');
|
||||
const typeNode = receiverNode?.childForFieldName('type');
|
||||
const simpleName = typeNode ? javaBaseSimpleNameOf(typeNode) : undefined;
|
||||
if (simpleName !== undefined) {
|
||||
grouped['@reference.receiver'] = syntheticCapture(
|
||||
'@reference.receiver',
|
||||
receiverNode!,
|
||||
simpleName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter read.member when it's a child of method_invocation or assignment.
|
||||
// `@reference.read.member` is captured directly on the `field_access` node.
|
||||
if (grouped['@reference.read.member'] !== undefined) {
|
||||
@@ -257,6 +343,11 @@ export function emitJavaScopeCaptures(
|
||||
}
|
||||
|
||||
setJavaClassAnnotationFacts(filePath, materializeClassAnnotationFacts(classAnnotations));
|
||||
setJavaSpringConfigConsumerFacts(
|
||||
filePath,
|
||||
captureJavaSpringConfigConsumerFacts(tree.rootNode, filePath),
|
||||
);
|
||||
setJavaSpringDiFacts(filePath, springDiFacts);
|
||||
|
||||
return [
|
||||
...resolveVarTypeBindings(out),
|
||||
@@ -269,8 +360,8 @@ export function emitJavaScopeCaptures(
|
||||
|
||||
/**
|
||||
* Synthesize `@declaration.class` matches for anonymous class bodies
|
||||
* (`new Runnable() { ... }`), named by the same javac-style authority
|
||||
* (`synthesizeJavaAnonymousClassName` → `Worker$N`) the structure phase
|
||||
* (`new Runnable() { ... }`), named by the same javac-compatible authority
|
||||
* (`synthesizeJavaTypeIdentity` → `Worker$N`) the structure phase
|
||||
* uses — the two layers agree by construction (#2550).
|
||||
*
|
||||
* The anchor is the `class_body` node: it shares its range with the
|
||||
@@ -283,13 +374,13 @@ export function emitJavaScopeCaptures(
|
||||
function synthesizeJavaAnonymousClassDeclarations(rootNode: SyntaxNode): CaptureMatch[] {
|
||||
const out: CaptureMatch[] = [];
|
||||
for (const oce of rootNode.descendantsOfType('object_creation_expression')) {
|
||||
const name = synthesizeJavaAnonymousClassName(oce);
|
||||
if (name === undefined) continue;
|
||||
const identity = synthesizeJavaTypeIdentity(oce);
|
||||
if (identity === undefined) continue;
|
||||
const body = oce.namedChildren.find((c) => c.type === 'class_body');
|
||||
if (body === undefined) continue;
|
||||
out.push({
|
||||
'@declaration.class': nodeToCapture('@declaration.class', body),
|
||||
'@declaration.name': syntheticCapture('@declaration.name', body, name),
|
||||
'@declaration.name': syntheticCapture('@declaration.name', body, identity.name),
|
||||
});
|
||||
|
||||
// Inheritance: the anonymous class extends/implements its constructed
|
||||
@@ -330,7 +421,7 @@ function synthesizeJavaAnonymousClassDeclarations(rootNode: SyntaxNode): Capture
|
||||
out.push({
|
||||
'@type-binding.annotation': nodeToCapture('@type-binding.annotation', declNode),
|
||||
'@type-binding.name': nodeToCapture('@type-binding.name', varName),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', oce, name),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', oce, identity.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -341,23 +432,49 @@ function synthesizeJavaAnonymousClassDeclarations(rootNode: SyntaxNode): Capture
|
||||
// constant's class extends its HOST ENUM (javac semantics), so the
|
||||
// inherits reference names the enum — giving `mroFor(E$N) ∋ E` and
|
||||
// keeping bare calls from the body to the enum's own helpers alive
|
||||
// through the ownership gate's MRO arm. No receiver typeBinding piece:
|
||||
// constants are not variable initializers; `E.A.hook()` dispatch rides
|
||||
// the existing enum receiver machinery.
|
||||
// through the ownership gate's MRO arm.
|
||||
for (const constant of rootNode.descendantsOfType('enum_constant')) {
|
||||
const name = synthesizeJavaAnonymousClassName(constant);
|
||||
if (name === undefined) continue;
|
||||
const body = constant.childForFieldName?.('body');
|
||||
if (body === null || body === undefined || body.type !== 'class_body') continue;
|
||||
out.push({
|
||||
'@declaration.class': nodeToCapture('@declaration.class', body),
|
||||
'@declaration.name': syntheticCapture('@declaration.name', body, name),
|
||||
});
|
||||
const hostEnum = javaEnclosingEnumNameOf(constant);
|
||||
if (hostEnum !== undefined) {
|
||||
const bodyNode = constant.childForFieldName?.('body');
|
||||
const isBodied = bodyNode !== null && bodyNode !== undefined && bodyNode.type === 'class_body';
|
||||
const bodiedIdentity = synthesizeJavaTypeIdentity(constant);
|
||||
if (bodiedIdentity !== undefined && isBodied) {
|
||||
out.push({
|
||||
'@reference.inherits': nodeToCapture('@reference.inherits', body),
|
||||
'@reference.name': syntheticCapture('@reference.name', body, hostEnum),
|
||||
'@declaration.class': nodeToCapture('@declaration.class', bodyNode),
|
||||
'@declaration.name': syntheticCapture('@declaration.name', bodyNode, bodiedIdentity.name),
|
||||
});
|
||||
if (hostEnum !== undefined) {
|
||||
out.push({
|
||||
'@reference.inherits': nodeToCapture('@reference.inherits', bodyNode),
|
||||
'@reference.name': syntheticCapture('@reference.name', bodyNode, hostEnum),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Receiver dispatch (#2561): `E.CONST.method()` resolves through the
|
||||
// generic compound-receiver chain walk, which looks up each dotted
|
||||
// segment via the owning class scope's `typeBindings` map — the same
|
||||
// mechanism a field declaration uses (`private User user;` binds
|
||||
// `user` on the class scope). Binding the constant's own simple name
|
||||
// there — to its synthesized `E$N` class when bodied (MRO includes E,
|
||||
// so members inherited from the enum still resolve), or to the host
|
||||
// enum itself when body-less — makes `E.CONST.method()` resolve with
|
||||
// no changes to the shared receiver-binding machinery.
|
||||
//
|
||||
// A bodied constant binds ONLY to its `E$N` class, never the host enum:
|
||||
// if name synthesis fails on a malformed/error-recovery tree (`bodiedName`
|
||||
// undefined despite a real body), emit nothing rather than silently
|
||||
// misattributing an OVERRIDING constant's receiver to the enum's own
|
||||
// (non-overridden) method — a wrong edge is worse than no edge. Mirrors
|
||||
// the `object_creation_expression` branch, which skips on synthesis
|
||||
// failure. `hostEnum` is used only for genuinely body-less constants.
|
||||
const constantNameNode = constant.childForFieldName?.('name');
|
||||
const constantType = isBodied ? bodiedIdentity?.name : hostEnum;
|
||||
if (constantNameNode !== null && constantNameNode !== undefined && constantType !== undefined) {
|
||||
out.push({
|
||||
'@type-binding.annotation': nodeToCapture('@type-binding.annotation', constant),
|
||||
'@type-binding.name': nodeToCapture('@type-binding.name', constantNameNode),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', constant, constantType),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ const JAVA_SCOPE_QUERY = `
|
||||
|
||||
(method_declaration) @scope.function
|
||||
(constructor_declaration) @scope.function
|
||||
(compact_constructor_declaration) @scope.function
|
||||
|
||||
;; Declarations — types
|
||||
(class_declaration
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
} from './index.js';
|
||||
import { populateJavaPackageSiblings } from './package-siblings.js';
|
||||
import { attachSpringBeanCandidateMetadata } from './spring-bean-metadata.js';
|
||||
import { attachJavaSpringConfigBindings } from './spring-config-bindings.js';
|
||||
import { attachJavaSpringDiMetadata } from './spring-di.js';
|
||||
import {
|
||||
applyJavaCaptureSideChannel,
|
||||
clearJavaClassAnnotationFacts,
|
||||
@@ -83,7 +85,11 @@ const javaScopeResolver: ScopeResolver = {
|
||||
|
||||
populateNamespaceSiblings: populateJavaPackageSiblings,
|
||||
populateRangeBindings: populateJavaCrossFileReturnTypes,
|
||||
emitPostResolutionEdges: attachSpringBeanCandidateMetadata,
|
||||
emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes, ctx) => {
|
||||
attachSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||
attachJavaSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||
attachJavaSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes, ctx);
|
||||
},
|
||||
};
|
||||
|
||||
export { javaScopeResolver };
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { makeScopeId, type ParsedFile, type ScopeId } from 'gitnexus-shared';
|
||||
import {
|
||||
bindSpringConfigConsumers,
|
||||
type SpringConfigConsumer,
|
||||
} from '../../frameworks/spring/config-bindings.js';
|
||||
import { createSpringAnnotationNameResolver } from '../../frameworks/spring/bean-candidates.js';
|
||||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { getJavaParser } from './query.js';
|
||||
import { getJavaSpringConfigConsumerFacts } from './capture-side-channel.js';
|
||||
import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js';
|
||||
|
||||
const VALUE_ANNOTATION = 'org.springframework.beans.factory.annotation.Value';
|
||||
const CONFIGURATION_PROPERTIES_ANNOTATION =
|
||||
'org.springframework.boot.context.properties.ConfigurationProperties';
|
||||
|
||||
interface JavaAnnotation {
|
||||
readonly name: string;
|
||||
readonly node: SyntaxNode;
|
||||
}
|
||||
|
||||
interface JavaImports {
|
||||
readonly exact: ReadonlySet<string>;
|
||||
readonly wildcard: ReadonlySet<string>;
|
||||
readonly localTypes: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export interface JavaSpringConfigConsumerFact {
|
||||
readonly consumer: SpringConfigConsumer;
|
||||
readonly annotationName: string;
|
||||
readonly classScopeId: ScopeId;
|
||||
}
|
||||
|
||||
function collectJavaImports(root: SyntaxNode): JavaImports {
|
||||
const exact = new Set<string>();
|
||||
const wildcard = new Set<string>();
|
||||
const localTypes = new Set<string>();
|
||||
|
||||
for (const node of root.descendantsOfType('import_declaration')) {
|
||||
const imported = node.text
|
||||
.replace(/^\s*import\s+(?:static\s+)?/, '')
|
||||
.replace(/;\s*$/, '')
|
||||
.trim();
|
||||
if (imported.endsWith('.*')) wildcard.add(imported.slice(0, -2));
|
||||
else exact.add(imported);
|
||||
}
|
||||
|
||||
for (const type of [
|
||||
'class_declaration',
|
||||
'interface_declaration',
|
||||
'enum_declaration',
|
||||
'record_declaration',
|
||||
'annotation_type_declaration',
|
||||
]) {
|
||||
for (const node of root.descendantsOfType(type)) {
|
||||
const name = node.childForFieldName('name')?.text;
|
||||
if (name) localTypes.add(name);
|
||||
}
|
||||
}
|
||||
return { exact, wildcard, localTypes };
|
||||
}
|
||||
|
||||
function annotationsOn(node: SyntaxNode): JavaAnnotation[] {
|
||||
const modifiers = node.namedChildren.find((child) => child.type === 'modifiers');
|
||||
if (modifiers === undefined) return [];
|
||||
const annotations: JavaAnnotation[] = [];
|
||||
for (const child of modifiers.namedChildren) {
|
||||
if (child.type !== 'annotation' && child.type !== 'marker_annotation') continue;
|
||||
const name = child.childForFieldName('name')?.text ?? child.firstNamedChild?.text;
|
||||
if (name) annotations.push({ name, node: child });
|
||||
}
|
||||
return annotations;
|
||||
}
|
||||
|
||||
function resolvesToAnnotation(
|
||||
rawName: string,
|
||||
canonicalName: string,
|
||||
imports: JavaImports,
|
||||
): boolean {
|
||||
if (rawName.includes('.')) return rawName === canonicalName;
|
||||
if (imports.localTypes.has(rawName)) return false;
|
||||
if (imports.exact.has(canonicalName)) return true;
|
||||
const packageName = canonicalName.slice(0, canonicalName.lastIndexOf('.'));
|
||||
return imports.wildcard.has(packageName);
|
||||
}
|
||||
|
||||
function decodeJavaStringLiteral(literal: string): string {
|
||||
const delimiterLength = literal.startsWith('"""') && literal.endsWith('"""') ? 3 : 1;
|
||||
return literal
|
||||
.slice(delimiterLength, -delimiterLength)
|
||||
.replace(/\\u([0-9a-fA-F]{4})/g, (_match, hex: string) =>
|
||||
String.fromCharCode(Number.parseInt(hex, 16)),
|
||||
)
|
||||
.replace(/\\(["'\\btnfr])/g, (_match, escaped: string) => {
|
||||
const controls: Record<string, string> = {
|
||||
b: '\b',
|
||||
t: '\t',
|
||||
n: '\n',
|
||||
f: '\f',
|
||||
r: '\r',
|
||||
};
|
||||
return controls[escaped] ?? escaped;
|
||||
});
|
||||
}
|
||||
|
||||
function javaStringLiterals(annotation: SyntaxNode): string[] {
|
||||
return annotation
|
||||
.descendantsOfType('string_literal')
|
||||
.map((literal) => decodeJavaStringLiteral(literal.text));
|
||||
}
|
||||
|
||||
/** Extract statically readable Spring placeholder keys from a Java annotation. */
|
||||
export function parseValuePlaceholderKeys(annotation: SyntaxNode): string[] {
|
||||
const keys = new Set<string>();
|
||||
for (const literal of javaStringLiterals(annotation)) {
|
||||
for (const match of literal.matchAll(/\$\{([^{}]+)\}/g)) {
|
||||
const key = match[1].split(':', 1)[0].trim();
|
||||
if (/^[A-Za-z0-9_.-]+$/.test(key)) keys.add(key);
|
||||
}
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
/** Extract `prefix`/`value` (or the positional value) from the annotation. */
|
||||
export function parseConfigurationPropertiesPrefix(annotation: SyntaxNode): string | null {
|
||||
const named = annotation.descendantsOfType('element_value_pair').find((pair) => {
|
||||
const key = pair.childForFieldName('key')?.text;
|
||||
return key === 'prefix' || key === 'value';
|
||||
});
|
||||
const namedValue = named?.childForFieldName('value');
|
||||
const argumentsNode = annotation.childForFieldName('arguments');
|
||||
const literalNode =
|
||||
(namedValue?.type === 'string_literal'
|
||||
? namedValue
|
||||
: namedValue?.descendantsOfType('string_literal')[0]) ??
|
||||
(named === undefined
|
||||
? argumentsNode?.namedChildren.find((child) => child.type === 'string_literal')
|
||||
: undefined);
|
||||
if (literalNode === undefined) return null;
|
||||
const prefix = decodeJavaStringLiteral(literalNode.text)
|
||||
.trim()
|
||||
.replace(/^\.+|\.+$/g, '');
|
||||
return /^[A-Za-z0-9_.-]+$/.test(prefix) ? prefix : null;
|
||||
}
|
||||
|
||||
function classScopeId(filePath: string, declaration: SyntaxNode): ScopeId {
|
||||
return makeScopeId({
|
||||
filePath,
|
||||
range: nodeToCapture('@scope.class', declaration).range,
|
||||
kind: 'Class',
|
||||
});
|
||||
}
|
||||
|
||||
function enclosingClass(node: SyntaxNode): SyntaxNode | undefined {
|
||||
let current = node.parent;
|
||||
while (current !== null) {
|
||||
if (current.type === 'class_declaration' || current.type === 'record_declaration') {
|
||||
return current;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Collect config facts from the Java parser's existing AST (no reparse). */
|
||||
export function captureJavaSpringConfigConsumerFacts(
|
||||
root: SyntaxNode,
|
||||
filePath: string,
|
||||
): JavaSpringConfigConsumerFact[] {
|
||||
const imports = collectJavaImports(root);
|
||||
const facts: JavaSpringConfigConsumerFact[] = [];
|
||||
|
||||
for (const field of root.descendantsOfType('field_declaration')) {
|
||||
const annotations = annotationsOn(field).filter((annotation) =>
|
||||
resolvesToAnnotation(annotation.name, VALUE_ANNOTATION, imports),
|
||||
);
|
||||
if (annotations.length === 0) continue;
|
||||
const owner = enclosingClass(field);
|
||||
if (owner === undefined) continue;
|
||||
for (const declarator of field.namedChildren.filter(
|
||||
(child) => child.type === 'variable_declarator',
|
||||
)) {
|
||||
const fieldName = declarator.childForFieldName('name')?.text;
|
||||
if (!fieldName) continue;
|
||||
for (const annotation of annotations) {
|
||||
const keys = parseValuePlaceholderKeys(annotation.node);
|
||||
if (keys.length > 0) {
|
||||
facts.push({
|
||||
consumer: { kind: 'value', fieldName, line: field.startPosition.row + 1, keys },
|
||||
annotationName: annotation.name,
|
||||
classScopeId: classScopeId(filePath, owner),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const type of ['class_declaration', 'record_declaration']) {
|
||||
for (const declaration of root.descendantsOfType(type)) {
|
||||
const className = declaration.childForFieldName('name')?.text;
|
||||
if (!className) continue;
|
||||
for (const annotation of annotationsOn(declaration)) {
|
||||
if (!resolvesToAnnotation(annotation.name, CONFIGURATION_PROPERTIES_ANNOTATION, imports)) {
|
||||
continue;
|
||||
}
|
||||
const prefix = parseConfigurationPropertiesPrefix(annotation.node);
|
||||
if (prefix !== null) {
|
||||
facts.push({
|
||||
consumer: {
|
||||
kind: 'configuration-properties',
|
||||
className,
|
||||
line: declaration.startPosition.row + 1,
|
||||
prefix,
|
||||
},
|
||||
annotationName: annotation.name,
|
||||
classScopeId: classScopeId(filePath, declaration),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
/** Parse Java consumers for focused unit tests; production reuses the worker AST. */
|
||||
export function extractJavaSpringConfigConsumers(source: string): SpringConfigConsumer[] {
|
||||
const tree = parseSourceSafe(getJavaParser(), source);
|
||||
return captureJavaSpringConfigConsumerFacts(tree.rootNode, '<memory>').map(
|
||||
(fact) => fact.consumer,
|
||||
);
|
||||
}
|
||||
|
||||
/** Java ScopeResolver post-resolution hook for Spring configuration consumers. */
|
||||
export function attachJavaSpringConfigBindings(
|
||||
graph: KnowledgeGraph,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
_nodeLookup: GraphNodeLookup,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
_ctx: { readonly fileContents: ReadonlyMap<string, string> },
|
||||
): void {
|
||||
const resolveAnnotation = createSpringAnnotationNameResolver(indexes);
|
||||
const recognizedAnnotations = new Set([VALUE_ANNOTATION, CONFIGURATION_PROPERTIES_ANNOTATION]);
|
||||
const batches: Array<{ filePath: string; consumers: SpringConfigConsumer[] }> = [];
|
||||
for (const parsed of parsedFiles) {
|
||||
const consumers: SpringConfigConsumer[] = [];
|
||||
for (const fact of getJavaSpringConfigConsumerFacts(parsed.filePath)) {
|
||||
const classScope = indexes.scopeTree.getScope(fact.classScopeId);
|
||||
if (classScope === undefined || classScope.kind !== 'Class') continue;
|
||||
const expectedAnnotation =
|
||||
fact.consumer.kind === 'value' ? VALUE_ANNOTATION : CONFIGURATION_PROPERTIES_ANNOTATION;
|
||||
const enclosingScope = fact.consumer.kind === 'value' ? classScope.id : classScope.parent;
|
||||
const resolved = resolveAnnotation(
|
||||
fact.annotationName,
|
||||
parsed,
|
||||
enclosingScope,
|
||||
recognizedAnnotations,
|
||||
isJavaPackageSiblingVisibilityIncomplete(parsed.filePath),
|
||||
);
|
||||
if (resolved === expectedAnnotation) consumers.push(fact.consumer);
|
||||
}
|
||||
if (consumers.length > 0) batches.push({ filePath: parsed.filePath, consumers });
|
||||
}
|
||||
bindSpringConfigConsumers(graph, batches);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { makeScopeId } from 'gitnexus-shared';
|
||||
import {
|
||||
createSpringDiMetadataAttacher,
|
||||
hasSpringDiRelevantAnnotation,
|
||||
hasSpringStereotypeSyntax,
|
||||
type SpringDiAnnotationFact,
|
||||
type SpringDiClassFact,
|
||||
type SpringDiDependencyFact,
|
||||
type SpringDiInjectionSiteFact,
|
||||
} from '../../frameworks/spring/di-metadata.js';
|
||||
import { parseSpringInjectionType } from '../../di-extractors/spring.js';
|
||||
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js';
|
||||
import { getJavaSpringDiFacts } from './capture-side-channel.js';
|
||||
|
||||
export type JavaAnnotationSyntaxFact = SpringDiAnnotationFact;
|
||||
|
||||
export type JavaSpringDependencyFact = SpringDiDependencyFact<JavaAnnotationSyntaxFact>;
|
||||
|
||||
type JavaSpringInjectionSiteKind = 'field' | 'constructor' | 'method';
|
||||
|
||||
export type JavaSpringInjectionSiteFact = SpringDiInjectionSiteFact<
|
||||
JavaAnnotationSyntaxFact,
|
||||
JavaSpringInjectionSiteKind
|
||||
>;
|
||||
|
||||
export type JavaSpringDiClassFact = SpringDiClassFact<
|
||||
JavaAnnotationSyntaxFact,
|
||||
JavaSpringInjectionSiteKind
|
||||
>;
|
||||
|
||||
function annotationFacts(node: SyntaxNode): JavaAnnotationSyntaxFact[] {
|
||||
const facts: JavaAnnotationSyntaxFact[] = [];
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type !== 'modifiers') continue;
|
||||
for (const modifier of child.namedChildren) {
|
||||
if (modifier.type !== 'marker_annotation' && modifier.type !== 'annotation') continue;
|
||||
const nameNode = modifier.childForFieldName('name') ?? modifier.firstNamedChild;
|
||||
if (nameNode === null) continue;
|
||||
facts.push({ name: nameNode.text.trim(), text: modifier.text.trim() });
|
||||
}
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
function dependenciesOf(callable: SyntaxNode): JavaSpringDependencyFact[] {
|
||||
const parameters = callable.childForFieldName('parameters');
|
||||
if (parameters === null) return [];
|
||||
const dependencies: JavaSpringDependencyFact[] = [];
|
||||
for (const parameter of parameters.namedChildren) {
|
||||
if (parameter.type !== 'formal_parameter' && parameter.type !== 'spread_parameter') continue;
|
||||
const nameNode = parameter.childForFieldName('name');
|
||||
const typeNode = parameter.childForFieldName('type');
|
||||
if (nameNode === null || typeNode === null) continue;
|
||||
dependencies.push({
|
||||
name: nameNode.text.trim(),
|
||||
rawType: typeNode.text.trim(),
|
||||
annotations: annotationFacts(parameter),
|
||||
});
|
||||
}
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture one class already surfaced by Java's scope query.
|
||||
*
|
||||
* `captures.ts` calls this from its existing query-match traversal, so Spring
|
||||
* DI does not perform a second recursive walk from the AST root.
|
||||
*/
|
||||
export function captureJavaSpringDiClassFact(
|
||||
classNode: SyntaxNode,
|
||||
filePath: string,
|
||||
): JavaSpringDiClassFact | null {
|
||||
const body = classNode.childForFieldName('body');
|
||||
if (body === null) return null;
|
||||
const classAnnotations = annotationFacts(classNode);
|
||||
const injectionSites: JavaSpringInjectionSiteFact[] = [];
|
||||
|
||||
const constructors = body.namedChildren.filter(
|
||||
(child) => child.type === 'constructor_declaration',
|
||||
);
|
||||
for (const constructor of constructors) {
|
||||
const annotations = annotationFacts(constructor);
|
||||
const implicitConstructor =
|
||||
constructors.length === 1 &&
|
||||
hasSpringStereotypeSyntax(classAnnotations) &&
|
||||
!hasSpringDiRelevantAnnotation(annotations);
|
||||
if (!implicitConstructor && !hasSpringDiRelevantAnnotation(annotations)) continue;
|
||||
injectionSites.push({
|
||||
kind: 'constructor',
|
||||
memberName: constructor.childForFieldName('name')?.text.trim() ?? '<constructor>',
|
||||
implicitConstructor,
|
||||
annotations,
|
||||
dependencies: dependenciesOf(constructor),
|
||||
});
|
||||
}
|
||||
|
||||
for (const member of body.namedChildren) {
|
||||
if (member.type === 'field_declaration') {
|
||||
const annotations = annotationFacts(member);
|
||||
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
|
||||
const typeNode = member.childForFieldName('type');
|
||||
if (typeNode === null) continue;
|
||||
for (const declarator of member.namedChildren) {
|
||||
if (declarator.type !== 'variable_declarator') continue;
|
||||
const nameNode = declarator.childForFieldName('name');
|
||||
if (nameNode === null) continue;
|
||||
injectionSites.push({
|
||||
kind: 'field',
|
||||
memberName: nameNode.text.trim(),
|
||||
implicitConstructor: false,
|
||||
annotations,
|
||||
dependencies: [
|
||||
{
|
||||
name: nameNode.text.trim(),
|
||||
rawType: typeNode.text.trim(),
|
||||
annotations,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
} else if (member.type === 'method_declaration') {
|
||||
const annotations = annotationFacts(member);
|
||||
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
|
||||
injectionSites.push({
|
||||
kind: 'method',
|
||||
memberName: member.childForFieldName('name')?.text.trim() ?? '<method>',
|
||||
implicitConstructor: false,
|
||||
annotations,
|
||||
dependencies: dependenciesOf(member),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (injectionSites.length === 0 && !hasSpringDiRelevantAnnotation(classAnnotations)) return null;
|
||||
const classCapture = nodeToCapture('@spring-di.class', classNode);
|
||||
return {
|
||||
classScopeId: makeScopeId({ filePath, range: classCapture.range, kind: 'Class' }),
|
||||
classAnnotations,
|
||||
injectionSites,
|
||||
};
|
||||
}
|
||||
|
||||
/** Attach resolved, framework-private DI metadata to Class nodes. */
|
||||
export const attachJavaSpringDiMetadata = createSpringDiMetadataAttacher<
|
||||
JavaAnnotationSyntaxFact,
|
||||
JavaSpringInjectionSiteKind
|
||||
>({
|
||||
getFacts: getJavaSpringDiFacts,
|
||||
isPackageVisibilityIncomplete: isJavaPackageSiblingVisibilityIncomplete,
|
||||
parseInjectionType: parseSpringInjectionType,
|
||||
capturedMemberKind: 'field',
|
||||
});
|
||||
@@ -185,10 +185,10 @@ export const kotlinProvider = defineLanguage({
|
||||
emitScopeCaptures: emitKotlinScopeCaptures,
|
||||
// ── #2195 PDG layer: Kotlin CFG visitor (vendored grammar) ──
|
||||
cfgVisitor: createKotlinCfgVisitor(),
|
||||
// Worker-side: snapshot companion-scope marks, package visibility, and
|
||||
// class-annotation facts `emitKotlinScopeCaptures` just populated into plain
|
||||
// data on `ParsedFile.captureSideChannel`, so the main thread can restore all
|
||||
// three via `applyCaptureSideChannel` WITHOUT a re-parse (#1983). See
|
||||
// Worker-side: snapshot companion-scope marks, package visibility, class
|
||||
// annotations, and Spring DI facts `emitKotlinScopeCaptures` just populated
|
||||
// into plain data on `ParsedFile.captureSideChannel`, so the main thread can
|
||||
// restore them via `applyCaptureSideChannel` WITHOUT a re-parse (#1983). See
|
||||
// `kotlin/capture-side-channel.ts`.
|
||||
// `assertCloneable` is a runtime identity; it makes a future non-serializable
|
||||
// value in the side-channel payload a compile error here, at the source, rather
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* from the `@scope.companion` marker capture.
|
||||
* - Spring Bean class-annotation facts collected during the same scope-query
|
||||
* traversal, consumed only after imports and package visibility finalize.
|
||||
* - Spring DI class facts (constructor/property/method injection syntax),
|
||||
* resolved and attached only after imports finalize.
|
||||
* - A JVM package fact read from the already-parsed root, so package-sibling
|
||||
* visibility never re-parses Kotlin source on the main thread.
|
||||
*
|
||||
@@ -29,7 +31,8 @@
|
||||
* The single generic `ParsedFile.captureSideChannel` field is shared with C++,
|
||||
* which is safe because each file is one language (a `.kt` file uses the kotlin
|
||||
* provider, a `.cpp` file the cpp provider). The payload is self-describing
|
||||
* (`{ kind: 'kotlin', companionScopes, packageFact, classAnnotations }`) so
|
||||
* (`{ kind: 'kotlin', companionScopes, packageFact, classAnnotations,
|
||||
* springDiFacts }`) so
|
||||
* `applyKotlinCaptureSideChannel` only restores kotlin state and ignores a
|
||||
* foreign-shaped snapshot.
|
||||
*/
|
||||
@@ -46,8 +49,10 @@ import {
|
||||
} from '../jvm/package-facts.js';
|
||||
import { getCompanionScopesForFile, markCompanionScope } from './companion-scopes.js';
|
||||
import { getKotlinPackageFact, setKotlinPackageFact } from './package-facts.js';
|
||||
import type { KotlinSpringDiClassFact } from './spring-di.js';
|
||||
|
||||
const classAnnotations = createClassAnnotationFactStore();
|
||||
const springDiFacts = new Map<string, readonly KotlinSpringDiClassFact[]>();
|
||||
|
||||
/**
|
||||
* Plain JSON-serializable snapshot of the per-file Kotlin capture-time
|
||||
@@ -63,10 +68,13 @@ export interface KotlinCaptureSideChannel {
|
||||
readonly packageFact: JvmPackageFact;
|
||||
/** Class annotation syntax collected by the existing scope traversal. */
|
||||
readonly classAnnotations: readonly ClassAnnotationFact[];
|
||||
/** Constructor, property, and method injection syntax captured per class. */
|
||||
readonly springDiFacts?: readonly KotlinSpringDiClassFact[];
|
||||
}
|
||||
|
||||
export function clearKotlinClassAnnotationFacts(): void {
|
||||
classAnnotations.clear();
|
||||
springDiFacts.clear();
|
||||
}
|
||||
|
||||
export function setKotlinClassAnnotationFacts(
|
||||
@@ -80,6 +88,18 @@ export function getKotlinClassAnnotationFacts(filePath: string): readonly ClassA
|
||||
return classAnnotations.get(filePath);
|
||||
}
|
||||
|
||||
export function setKotlinSpringDiFacts(
|
||||
filePath: string,
|
||||
facts: readonly KotlinSpringDiClassFact[],
|
||||
): void {
|
||||
if (facts.length === 0) springDiFacts.delete(filePath);
|
||||
else springDiFacts.set(filePath, facts);
|
||||
}
|
||||
|
||||
export function getKotlinSpringDiFacts(filePath: string): readonly KotlinSpringDiClassFact[] {
|
||||
return springDiFacts.get(filePath) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* `LanguageProvider.collectCaptureSideChannel` implementation for Kotlin.
|
||||
* Returns `undefined` when this file recorded no side-channel state at all, so
|
||||
@@ -90,8 +110,14 @@ export function collectKotlinCaptureSideChannel(
|
||||
): KotlinCaptureSideChannel | undefined {
|
||||
const companionScopes = getCompanionScopesForFile(filePath);
|
||||
const annotationFacts = classAnnotations.get(filePath);
|
||||
const diFacts = springDiFacts.get(filePath) ?? [];
|
||||
const packageFact = getKotlinPackageFact(filePath);
|
||||
if (companionScopes.length === 0 && annotationFacts.length === 0 && packageFact === undefined) {
|
||||
if (
|
||||
companionScopes.length === 0 &&
|
||||
annotationFacts.length === 0 &&
|
||||
diFacts.length === 0 &&
|
||||
packageFact === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
@@ -99,6 +125,7 @@ export function collectKotlinCaptureSideChannel(
|
||||
companionScopes,
|
||||
packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT,
|
||||
classAnnotations: annotationFacts,
|
||||
...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,6 +148,7 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void {
|
||||
!Array.isArray(data.classAnnotations)
|
||||
) {
|
||||
classAnnotations.set(parsed.filePath, []);
|
||||
setKotlinSpringDiFacts(parsed.filePath, []);
|
||||
setKotlinPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
|
||||
return;
|
||||
}
|
||||
@@ -128,6 +156,10 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void {
|
||||
markCompanionScope(parsed.filePath, scopeId);
|
||||
}
|
||||
classAnnotations.set(parsed.filePath, data.classAnnotations);
|
||||
setKotlinSpringDiFacts(
|
||||
parsed.filePath,
|
||||
Array.isArray(data.springDiFacts) ? data.springDiFacts : [],
|
||||
);
|
||||
setKotlinPackageFact(
|
||||
parsed.filePath,
|
||||
isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT,
|
||||
|
||||
@@ -18,9 +18,10 @@ import { normalizeKotlinType } from './interpret.js';
|
||||
import { synthesizeKotlinReceiverBinding } from './receiver-binding.js';
|
||||
import { getKotlinParser, getKotlinScopeQuery } from './query.js';
|
||||
import { markCompanionScope } from './companion-scopes.js';
|
||||
import { setKotlinClassAnnotationFacts } from './capture-side-channel.js';
|
||||
import { setKotlinClassAnnotationFacts, setKotlinSpringDiFacts } from './capture-side-channel.js';
|
||||
import { captureKotlinPackageFact } from './package-facts.js';
|
||||
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
|
||||
import { captureKotlinSpringDiClassFact, type KotlinSpringDiClassFact } from './spring-di.js';
|
||||
|
||||
const FUNCTION_DECL_TAGS = ['@declaration.function'] as const;
|
||||
|
||||
@@ -83,6 +84,8 @@ export function emitKotlinScopeCaptures(
|
||||
|
||||
const out: CaptureMatch[] = [];
|
||||
const classAnnotations = new Map<ScopeId, Set<string>>();
|
||||
const springDiFacts: KotlinSpringDiClassFact[] = [];
|
||||
const springDiClassNodeIds = new Set<number>();
|
||||
const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode);
|
||||
out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes));
|
||||
out.push(...synthesizeKotlinLoopBindings(tree.rootNode, returnTypes));
|
||||
@@ -106,6 +109,13 @@ export function emitKotlinScopeCaptures(
|
||||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
const springDiClassNode = nodeIfType(groupedNodes['@scope.class'], 'class_declaration');
|
||||
if (springDiClassNode !== null && !springDiClassNodeIds.has(springDiClassNode.id)) {
|
||||
springDiClassNodeIds.add(springDiClassNode.id);
|
||||
const fact = captureKotlinSpringDiClassFact(springDiClassNode, filePath);
|
||||
if (fact !== null) springDiFacts.push(fact);
|
||||
}
|
||||
|
||||
const annotatedClass = grouped['@class-annotation.class'];
|
||||
const annotationName = grouped['@class-annotation.name'];
|
||||
if (annotatedClass !== undefined && annotationName !== undefined) {
|
||||
@@ -288,6 +298,7 @@ export function emitKotlinScopeCaptures(
|
||||
}
|
||||
|
||||
setKotlinClassAnnotationFacts(filePath, materializeClassAnnotationFacts(classAnnotations));
|
||||
setKotlinSpringDiFacts(filePath, springDiFacts);
|
||||
out.push(...synthesizeCallableFlowCaptures(tree.rootNode, KOTLIN_CALLABLE_CAPTURE_OPTIONS));
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { isKotlinStaticOnly } from './owners.js';
|
||||
import { populateKotlinPackageSiblings } from './package-siblings.js';
|
||||
import { attachKotlinSpringBeanCandidateMetadata } from './spring-bean-metadata.js';
|
||||
import { clearKotlinPackageFacts } from './package-facts.js';
|
||||
import { attachKotlinSpringDiMetadata } from './spring-di.js';
|
||||
|
||||
/**
|
||||
* Kotlin scope resolver for RFC #909 Ring 3.
|
||||
@@ -120,9 +121,13 @@ export const kotlinScopeResolver: ScopeResolver = {
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
collapseMemberCallsByCallerTarget: false,
|
||||
hoistTypeBindingsToModule: true,
|
||||
freeCallsRequireInstanceOwnership: true,
|
||||
postExtractSourceTextPolicy: 'uncached-files',
|
||||
populateNamespaceSiblings: populateKotlinPackageSiblings,
|
||||
emitPostResolutionEdges: attachKotlinSpringBeanCandidateMetadata,
|
||||
emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes) => {
|
||||
attachKotlinSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||
attachKotlinSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { makeScopeId } from 'gitnexus-shared';
|
||||
import { parseSpringInjectionType } from '../../di-extractors/spring.js';
|
||||
import {
|
||||
createSpringDiMetadataAttacher,
|
||||
hasSpringDiRelevantAnnotation,
|
||||
hasSpringStereotypeSyntax,
|
||||
type SpringDiAnnotationFact,
|
||||
type SpringDiClassFact,
|
||||
type SpringDiDependencyFact,
|
||||
type SpringDiInjectionSiteFact,
|
||||
} from '../../frameworks/spring/di-metadata.js';
|
||||
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { getKotlinSpringDiFacts } from './capture-side-channel.js';
|
||||
import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.js';
|
||||
|
||||
export interface KotlinAnnotationSyntaxFact extends SpringDiAnnotationFact {
|
||||
readonly useSiteTarget?: string;
|
||||
}
|
||||
|
||||
export type KotlinSpringDependencyFact = SpringDiDependencyFact<KotlinAnnotationSyntaxFact>;
|
||||
|
||||
type KotlinSpringInjectionSiteKind = 'property' | 'constructor' | 'method';
|
||||
|
||||
export type KotlinSpringInjectionSiteFact = SpringDiInjectionSiteFact<
|
||||
KotlinAnnotationSyntaxFact,
|
||||
KotlinSpringInjectionSiteKind
|
||||
>;
|
||||
|
||||
export type KotlinSpringDiClassFact = SpringDiClassFact<
|
||||
KotlinAnnotationSyntaxFact,
|
||||
KotlinSpringInjectionSiteKind
|
||||
>;
|
||||
|
||||
const KOTLIN_TYPE_NODES = new Set(['user_type', 'nullable_type', 'function_type']);
|
||||
|
||||
function firstDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | undefined {
|
||||
const stack = [...node.namedChildren].reverse();
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (current === undefined) continue;
|
||||
if (current.type === type) return current;
|
||||
for (let index = current.namedChildren.length - 1; index >= 0; index--) {
|
||||
const child = current.namedChildren[index];
|
||||
if (child !== undefined) stack.push(child);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function annotationFact(annotation: SyntaxNode): KotlinAnnotationSyntaxFact | null {
|
||||
const nameNode = firstDescendantOfType(annotation, 'user_type');
|
||||
if (nameNode === undefined) return null;
|
||||
const useSiteTarget = annotation.namedChildren
|
||||
.find((child) => child.type === 'use_site_target')
|
||||
?.text.replace(/:\s*$/, '')
|
||||
.trim();
|
||||
return {
|
||||
name: nameNode.text.trim(),
|
||||
text: annotation.text.trim(),
|
||||
...(useSiteTarget === undefined || useSiteTarget.length === 0 ? {} : { useSiteTarget }),
|
||||
};
|
||||
}
|
||||
|
||||
function annotationsFromModifierContainer(node: SyntaxNode): KotlinAnnotationSyntaxFact[] {
|
||||
const facts: KotlinAnnotationSyntaxFact[] = [];
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type !== 'annotation') continue;
|
||||
const fact = annotationFact(child);
|
||||
if (fact !== null) facts.push(fact);
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
function annotationFacts(node: SyntaxNode): KotlinAnnotationSyntaxFact[] {
|
||||
const facts: KotlinAnnotationSyntaxFact[] = [];
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type !== 'modifiers' && child.type !== 'parameter_modifiers') continue;
|
||||
facts.push(...annotationsFromModifierContainer(child));
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
function directTypeNode(node: SyntaxNode): SyntaxNode | undefined {
|
||||
return node.namedChildren.find((child) => KOTLIN_TYPE_NODES.has(child.type));
|
||||
}
|
||||
|
||||
function parameterDependency(
|
||||
parameter: SyntaxNode,
|
||||
precedingAnnotations: readonly KotlinAnnotationSyntaxFact[] = [],
|
||||
): KotlinSpringDependencyFact | null {
|
||||
const nameNode = parameter.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
const typeNode = directTypeNode(parameter);
|
||||
if (nameNode === undefined || typeNode === undefined) return null;
|
||||
return {
|
||||
name: nameNode.text.trim(),
|
||||
rawType: typeNode.text.trim(),
|
||||
annotations: [...precedingAnnotations, ...annotationFacts(parameter)],
|
||||
};
|
||||
}
|
||||
|
||||
function functionDependencies(callable: SyntaxNode): KotlinSpringDependencyFact[] {
|
||||
const parameters = callable.namedChildren.find(
|
||||
(child) => child.type === 'function_value_parameters',
|
||||
);
|
||||
if (parameters === undefined) return [];
|
||||
const dependencies: KotlinSpringDependencyFact[] = [];
|
||||
let pendingAnnotations: KotlinAnnotationSyntaxFact[] = [];
|
||||
for (const child of parameters.namedChildren) {
|
||||
if (child.type === 'parameter_modifiers') {
|
||||
pendingAnnotations = annotationsFromModifierContainer(child);
|
||||
continue;
|
||||
}
|
||||
if (child.type !== 'parameter') continue;
|
||||
const dependency = parameterDependency(child, pendingAnnotations);
|
||||
pendingAnnotations = [];
|
||||
if (dependency !== null) dependencies.push(dependency);
|
||||
}
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
function primaryConstructorDependencies(constructor: SyntaxNode): KotlinSpringDependencyFact[] {
|
||||
const dependencies: KotlinSpringDependencyFact[] = [];
|
||||
for (const parameter of constructor.namedChildren) {
|
||||
if (parameter.type !== 'class_parameter') continue;
|
||||
const dependency = parameterDependency(parameter);
|
||||
if (dependency !== null) dependencies.push(dependency);
|
||||
}
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
function propertyDependency(property: SyntaxNode): KotlinSpringDependencyFact | null {
|
||||
const variable = property.namedChildren.find((child) => child.type === 'variable_declaration');
|
||||
if (variable === undefined) return null;
|
||||
const nameNode = variable.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
const typeNode = directTypeNode(variable);
|
||||
if (nameNode === undefined || typeNode === undefined) return null;
|
||||
const annotations = annotationFacts(property);
|
||||
return {
|
||||
name: nameNode.text.trim(),
|
||||
rawType: typeNode.text.trim(),
|
||||
annotations,
|
||||
};
|
||||
}
|
||||
|
||||
function isKotlinBeanCandidateClass(classNode: SyntaxNode): boolean {
|
||||
if (classNode.children.some((child) => child.type === 'interface' || child.type === 'enum')) {
|
||||
return false;
|
||||
}
|
||||
const modifiers = classNode.namedChildren.find((child) => child.type === 'modifiers');
|
||||
return !modifiers?.namedChildren.some(
|
||||
(child) => child.type === 'class_modifier' && child.text.trim() === 'annotation',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture one class already surfaced by Kotlin's scope query. Kotlin-specific
|
||||
* syntax is normalized here while import/FQN semantics remain deferred until
|
||||
* post-resolution.
|
||||
*/
|
||||
export function captureKotlinSpringDiClassFact(
|
||||
classNode: SyntaxNode,
|
||||
filePath: string,
|
||||
): KotlinSpringDiClassFact | null {
|
||||
if (!isKotlinBeanCandidateClass(classNode)) return null;
|
||||
const classAnnotations = annotationFacts(classNode);
|
||||
const injectionSites: KotlinSpringInjectionSiteFact[] = [];
|
||||
const body = classNode.namedChildren.find((child) => child.type === 'class_body');
|
||||
const primaryConstructor = classNode.namedChildren.find(
|
||||
(child) => child.type === 'primary_constructor',
|
||||
);
|
||||
const secondaryConstructors =
|
||||
body?.namedChildren.filter((child) => child.type === 'secondary_constructor') ?? [];
|
||||
const constructorCount =
|
||||
(primaryConstructor === undefined ? 0 : 1) + secondaryConstructors.length;
|
||||
|
||||
if (primaryConstructor !== undefined) {
|
||||
const annotations = annotationFacts(primaryConstructor);
|
||||
const implicitConstructor =
|
||||
constructorCount === 1 &&
|
||||
hasSpringStereotypeSyntax(classAnnotations) &&
|
||||
!hasSpringDiRelevantAnnotation(annotations);
|
||||
if (implicitConstructor || hasSpringDiRelevantAnnotation(annotations)) {
|
||||
injectionSites.push({
|
||||
kind: 'constructor',
|
||||
memberName: '<primary-constructor>',
|
||||
implicitConstructor,
|
||||
annotations,
|
||||
dependencies: primaryConstructorDependencies(primaryConstructor),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const constructor of secondaryConstructors) {
|
||||
const annotations = annotationFacts(constructor);
|
||||
const implicitConstructor =
|
||||
constructorCount === 1 &&
|
||||
hasSpringStereotypeSyntax(classAnnotations) &&
|
||||
!hasSpringDiRelevantAnnotation(annotations);
|
||||
if (!implicitConstructor && !hasSpringDiRelevantAnnotation(annotations)) continue;
|
||||
injectionSites.push({
|
||||
kind: 'constructor',
|
||||
memberName: '<secondary-constructor>',
|
||||
implicitConstructor,
|
||||
annotations,
|
||||
dependencies: functionDependencies(constructor),
|
||||
});
|
||||
}
|
||||
|
||||
if (body !== undefined) {
|
||||
for (const member of body.namedChildren) {
|
||||
if (member.type === 'property_declaration') {
|
||||
const annotations = annotationFacts(member);
|
||||
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
|
||||
const dependency = propertyDependency(member);
|
||||
if (dependency === null) continue;
|
||||
injectionSites.push({
|
||||
kind: 'property',
|
||||
memberName: dependency.name,
|
||||
implicitConstructor: false,
|
||||
annotations,
|
||||
dependencies: [dependency],
|
||||
});
|
||||
} else if (member.type === 'function_declaration') {
|
||||
const annotations = annotationFacts(member);
|
||||
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
|
||||
const name =
|
||||
member.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim() ??
|
||||
'<method>';
|
||||
injectionSites.push({
|
||||
kind: 'method',
|
||||
memberName: name,
|
||||
implicitConstructor: false,
|
||||
annotations,
|
||||
dependencies: functionDependencies(member),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (injectionSites.length === 0 && !hasSpringDiRelevantAnnotation(classAnnotations)) return null;
|
||||
const classCapture = nodeToCapture('@spring-di.class', classNode);
|
||||
return {
|
||||
classScopeId: makeScopeId({ filePath, range: classCapture.range, kind: 'Class' }),
|
||||
classAnnotations,
|
||||
injectionSites,
|
||||
};
|
||||
}
|
||||
|
||||
function isApplicableInjectionAnnotation(
|
||||
annotation: KotlinAnnotationSyntaxFact,
|
||||
site: KotlinSpringInjectionSiteFact,
|
||||
): boolean {
|
||||
if (annotation.useSiteTarget === undefined) return true;
|
||||
if (site.kind === 'constructor') return annotation.useSiteTarget === 'constructor';
|
||||
if (site.kind === 'property') {
|
||||
return annotation.useSiteTarget === 'field' || annotation.useSiteTarget === 'set';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isApplicableQualifierAnnotation(
|
||||
annotation: KotlinAnnotationSyntaxFact,
|
||||
site: KotlinSpringInjectionSiteFact,
|
||||
): boolean {
|
||||
if (annotation.useSiteTarget === undefined) return true;
|
||||
if (site.kind === 'property') {
|
||||
return (
|
||||
annotation.useSiteTarget === 'field' ||
|
||||
annotation.useSiteTarget === 'param' ||
|
||||
annotation.useSiteTarget === 'setparam'
|
||||
);
|
||||
}
|
||||
return annotation.useSiteTarget === 'param';
|
||||
}
|
||||
|
||||
function parseKotlinSpringInjectionType(rawType: string) {
|
||||
// Kotlin nullable suffixes, type projections, and mutable collection aliases
|
||||
// do not change the JVM bean type selected by Spring. Normalize only those
|
||||
// surface forms; stars, function types, arrays, and nested generic elements
|
||||
// still fail closed in the shared parser.
|
||||
const normalized = rawType
|
||||
.replace(/\bMutable(List|Set|Collection|Map)(?=\s*<)/g, '$1')
|
||||
.replace(/([<,])\s*(?:out|in)\s+/g, '$1')
|
||||
.replace(/\?(?=\s*(?:[>,]|$))/g, '');
|
||||
return parseSpringInjectionType(normalized);
|
||||
}
|
||||
|
||||
/** Attach resolved, framework-private DI metadata to Kotlin Class nodes. */
|
||||
export const attachKotlinSpringDiMetadata = createSpringDiMetadataAttacher<
|
||||
KotlinAnnotationSyntaxFact,
|
||||
KotlinSpringInjectionSiteKind
|
||||
>({
|
||||
getFacts: getKotlinSpringDiFacts,
|
||||
isPackageVisibilityIncomplete: isKotlinPackageSiblingVisibilityIncomplete,
|
||||
parseInjectionType: parseKotlinSpringInjectionType,
|
||||
capturedMemberKind: 'property',
|
||||
isInjectionAnnotationApplicable: isApplicableInjectionAnnotation,
|
||||
isQualifierAnnotationApplicable: isApplicableQualifierAnnotation,
|
||||
});
|
||||
@@ -8,10 +8,9 @@
|
||||
* 1. **Per-name import statements** — `import a, b` and
|
||||
* `from m import x, y` decompose to one match per imported name
|
||||
* (see `import-decomposer.ts`).
|
||||
* 2. **Receiver type bindings** — each `function_definition` inside a
|
||||
* class body emits a `@type-binding.self` (or `@type-binding.cls`
|
||||
* for `@classmethod`) capture so Pass-4 attaches the implicit
|
||||
* receiver (see `receiver-binding.ts`).
|
||||
* 2. **Receiver type bindings** — methods emit an implicit `self` / `cls`
|
||||
* binding, and `__init__` assignments from annotated parameters emit
|
||||
* class-scoped instance-field bindings (see `receiver-binding.ts`).
|
||||
*
|
||||
* Pure given the input source text. No I/O, no globals consulted.
|
||||
*/
|
||||
@@ -25,7 +24,10 @@ import {
|
||||
} from '../../utils/ast-helpers.js';
|
||||
import { splitImportStatement } from './import-decomposer.js';
|
||||
import { getPythonParser, getPythonScopeQuery } from './query.js';
|
||||
import { synthesizeReceiverTypeBinding } from './receiver-binding.js';
|
||||
import {
|
||||
synthesizeConstructorFieldTypeBindings,
|
||||
synthesizeReceiverTypeBinding,
|
||||
} from './receiver-binding.js';
|
||||
import { synthesizeDependsReferences } from './depends-references.js';
|
||||
import { computePythonArityMetadata } from './arity-metadata.js';
|
||||
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
|
||||
@@ -133,6 +135,7 @@ export function emitPythonScopeCaptures(
|
||||
if (fnNode !== null) {
|
||||
const synth = synthesizeReceiverTypeBinding(fnNode);
|
||||
if (synth !== null) out.push(synth);
|
||||
out.push(...synthesizeConstructorFieldTypeBindings(fnNode));
|
||||
for (const depRef of synthesizeDependsReferences(fnNode)) out.push(depRef);
|
||||
}
|
||||
continue;
|
||||
|
||||
@@ -119,7 +119,10 @@ export function interpretPythonTypeBinding(captures: CaptureMatch): ParsedTypeBi
|
||||
// `cls` is a self-like receiver; share the source label so downstream
|
||||
// `Registry.lookup` Step 2 treats them identically.
|
||||
else if (captures['@type-binding.cls'] !== undefined) source = 'self';
|
||||
else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
|
||||
else if (captures['@type-binding.instance-field'] !== undefined) {
|
||||
source =
|
||||
captures['@type-binding.parameter'] !== undefined ? 'parameter-annotation' : 'annotation';
|
||||
} else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
|
||||
else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation';
|
||||
else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred';
|
||||
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Synthesize `@type-binding.self` / `@type-binding.cls` captures for
|
||||
* methods.
|
||||
* Synthesize implicit receiver and constructor-assigned field type bindings
|
||||
* for methods.
|
||||
*
|
||||
* Tree-sitter can't easily express "the first parameter of a function
|
||||
* defined directly inside a class body" via a single static query.
|
||||
@@ -113,3 +113,114 @@ export function synthesizeReceiverTypeBinding(fnNode: SyntaxNode): CaptureMatch
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', first, className),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize class-scope field bindings for the common Python constructor
|
||||
* injection pattern:
|
||||
*
|
||||
* def __init__(self, service: Service):
|
||||
* self.service = service
|
||||
*
|
||||
* An explicit field annotation (`self.service: Service = ...`) is also
|
||||
* accepted and takes precedence over a parameter annotation. Deliberately do
|
||||
* not infer from arbitrary unannotated RHS expressions: the receiver resolver
|
||||
* needs a declared type, not a name-only guess.
|
||||
*/
|
||||
export function synthesizeConstructorFieldTypeBindings(fnNode: SyntaxNode): CaptureMatch[] {
|
||||
if (fnNode.childForFieldName('name')?.text !== '__init__') return [];
|
||||
if (findEnclosingClassDefinition(fnNode) === null) return [];
|
||||
if (hasDecorator(fnNode, 'staticmethod') || hasDecorator(fnNode, 'classmethod')) return [];
|
||||
|
||||
const receiver = synthesizeReceiverTypeBinding(fnNode);
|
||||
const receiverName = receiver?.['@type-binding.self']?.text;
|
||||
if (receiverName === undefined) return [];
|
||||
|
||||
const parameters = fnNode.childForFieldName('parameters');
|
||||
const body = fnNode.childForFieldName('body');
|
||||
if (parameters === null || body === null) return [];
|
||||
|
||||
const parameterTypes = new Map<string, string>();
|
||||
for (let i = 0; i < parameters.namedChildCount; i++) {
|
||||
const parameter = parameters.namedChild(i);
|
||||
if (parameter === null) continue;
|
||||
const name = firstParameterName(parameter);
|
||||
const annotation = parameter.childForFieldName('type');
|
||||
if (name !== null && annotation !== null) parameterTypes.set(name, annotation.text);
|
||||
}
|
||||
|
||||
type Candidate = { readonly match: CaptureMatch; readonly explicit: boolean };
|
||||
const candidates = new Map<string, Candidate>();
|
||||
|
||||
const stack: SyntaxNode[] = [body];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()!;
|
||||
if (
|
||||
node !== body &&
|
||||
(node.type === 'function_definition' ||
|
||||
node.type === 'lambda' ||
|
||||
node.type === 'class_definition' ||
|
||||
node.type === 'if_statement' ||
|
||||
node.type === 'for_statement' ||
|
||||
node.type === 'while_statement' ||
|
||||
node.type === 'try_statement' ||
|
||||
node.type === 'match_statement')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node.type === 'assignment') {
|
||||
const left = node.childForFieldName('left');
|
||||
const right = node.childForFieldName('right');
|
||||
if (left?.type === 'attribute') {
|
||||
const object = left.childForFieldName('object');
|
||||
const field = left.childForFieldName('attribute');
|
||||
if (object?.type === 'identifier' && object.text === receiverName && field !== null) {
|
||||
const explicitType = node.childForFieldName('type');
|
||||
const parameterType =
|
||||
right?.type === 'identifier' ? parameterTypes.get(right.text) : undefined;
|
||||
const typeName = explicitType?.text ?? parameterType;
|
||||
if (typeName !== undefined) {
|
||||
const explicit = explicitType !== null;
|
||||
const existing = candidates.get(field.text);
|
||||
if (existing === undefined || explicit || !existing.explicit) {
|
||||
candidates.set(field.text, {
|
||||
explicit,
|
||||
match: {
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', field, field.text),
|
||||
'@type-binding.type': syntheticCapture(
|
||||
'@type-binding.type',
|
||||
explicitType ?? right ?? field,
|
||||
typeName,
|
||||
),
|
||||
...(explicit
|
||||
? {}
|
||||
: {
|
||||
'@type-binding.parameter': syntheticCapture(
|
||||
'@type-binding.parameter',
|
||||
right ?? field,
|
||||
'1',
|
||||
),
|
||||
}),
|
||||
'@type-binding.instance-field': syntheticCapture(
|
||||
'@type-binding.instance-field',
|
||||
node,
|
||||
'1',
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push in reverse so the LIFO walk visits source order. That keeps Map
|
||||
// insertion order (and therefore emitted capture order) deterministic.
|
||||
for (let i = node.namedChildCount - 1; i >= 0; i--) {
|
||||
const child = node.namedChild(i);
|
||||
if (child !== null) stack.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
return [...candidates.values()].map(({ match }) => match);
|
||||
}
|
||||
|
||||
@@ -36,15 +36,23 @@ export function pythonFunctionDefinitionLabel(
|
||||
// ─── bindingScopeFor ──────────────────────────────────────────────────────
|
||||
|
||||
/** Python has no block scope, so the central extractor's "innermost
|
||||
* enclosing scope" default is already correct: `for x in …` creates
|
||||
* `x` in the enclosing function/module scope (because we never emit a
|
||||
* `@scope.block` for the for-loop body), comprehension variables stay
|
||||
* in their expression context, etc. Returns `null` to delegate. */
|
||||
* enclosing scope" default is already correct for ordinary bindings.
|
||||
* Constructor-injected instance fields are the exception: their marker is
|
||||
* anchored inside `__init__`, but compound receiver resolution needs the
|
||||
* field type on the enclosing Class scope. */
|
||||
export function pythonBindingScopeFor(
|
||||
_decl: CaptureMatch,
|
||||
_innermost: Scope,
|
||||
_tree: ScopeTree,
|
||||
decl: CaptureMatch,
|
||||
innermost: Scope,
|
||||
tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
if (decl['@type-binding.instance-field'] !== undefined) {
|
||||
let current: Scope | undefined = innermost;
|
||||
while (current !== undefined) {
|
||||
if (current.kind === 'Class') return current.id;
|
||||
if (current.parent === null) break;
|
||||
current = tree.getScope(current.parent);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,23 @@ import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'git
|
||||
|
||||
const REF_PREFIX_RE = /^&\s*(mut\s+)?/;
|
||||
const PTR_PREFIX_RE = /^\*\s*(const|mut)?\s*/;
|
||||
const DYN_PREFIX_RE = /^dyn\s+/;
|
||||
const ENUM_VARIANT_NAMES = new Set(['Some', 'None', 'Ok', 'Err']);
|
||||
|
||||
// `dyn Trait`, `&dyn Trait`, `Box<dyn Trait>` all name a trait object whose
|
||||
// receiver-dispatch target is the trait itself (#2604) — strip the `dyn`
|
||||
// keyword and any auto-trait/lifetime bound list (`dyn Trait + Send`) down to
|
||||
// the principal trait name. Reference/pointer sigils are stripped by the
|
||||
// caller first; wrapper unwrapping (Box<T> etc.) runs before this so the
|
||||
// unwrapped inner text still gets the same treatment.
|
||||
function stripDynBound(t: string): string {
|
||||
if (!DYN_PREFIX_RE.test(t)) return t;
|
||||
t = t.replace(DYN_PREFIX_RE, '');
|
||||
const plus = t.indexOf('+');
|
||||
if (plus !== -1) t = t.slice(0, plus);
|
||||
return t.trim();
|
||||
}
|
||||
|
||||
// ─── interpretImport ──────────────────────────────────────────────────────
|
||||
|
||||
export function interpretRustImport(captures: CaptureMatch): ParsedImport | null {
|
||||
@@ -98,6 +113,7 @@ export function normalizeRustTypeName(text: string): string {
|
||||
const inner = extractFirstGenericArg(t);
|
||||
if (inner !== null) t = inner;
|
||||
}
|
||||
t = stripDynBound(t);
|
||||
const bracket = t.indexOf('<');
|
||||
if (bracket !== -1) t = t.slice(0, bracket);
|
||||
// Take last segment of qualified paths (crate::foo::Bar → Bar)
|
||||
@@ -158,6 +174,7 @@ function normalizeRustReturnType(text: string): string {
|
||||
}
|
||||
}
|
||||
}
|
||||
t = stripDynBound(t);
|
||||
const bracket = t.indexOf('<');
|
||||
if (bracket !== -1) t = t.slice(0, bracket);
|
||||
const lastColon = t.lastIndexOf('::');
|
||||
|
||||
@@ -10,6 +10,7 @@ const RUST_SCOPE_QUERY = `
|
||||
(enum_item) @scope.class
|
||||
(union_item) @scope.class
|
||||
(function_item) @scope.function
|
||||
(function_signature_item) @scope.function
|
||||
(closure_expression) @scope.function
|
||||
(block) @scope.block
|
||||
(if_expression) @scope.block
|
||||
@@ -55,6 +56,14 @@ const RUST_SCOPE_QUERY = `
|
||||
(function_item
|
||||
name: (identifier) @declaration.name) @declaration.function
|
||||
|
||||
;; Declarations — trait method signature (required method, no body,
|
||||
;; e.g. fn foo(self) -> T; inside a trait body). Without this, an abstract
|
||||
;; trait method is invisible to scope resolution — never owned by its
|
||||
;; trait's Class scope, so a dyn Trait receiver can never dispatch to
|
||||
;; it (#2604).
|
||||
(function_signature_item
|
||||
name: (identifier) @declaration.name) @declaration.function
|
||||
|
||||
;; Declarations — struct fields
|
||||
(field_declaration
|
||||
name: (field_identifier) @declaration.name
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { parseSourceSafe, ParseTimeoutError } from '../../../tree-sitter/safe-parse.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { logger } from '../../../logger.js';
|
||||
import { lookupBindingsAt } from '../../scope-resolution/scope/walkers.js';
|
||||
|
||||
/**
|
||||
* Populate type bindings for patterns and iterators that the tree-sitter
|
||||
@@ -16,9 +17,54 @@ import { logger } from '../../../logger.js';
|
||||
* Runs in Phase 2 (after propagateImportedReturnTypes) so all cross-file
|
||||
* type bindings are available for lookup.
|
||||
*/
|
||||
type RustTree = ReturnType<ReturnType<typeof getRustParser>['parse']>;
|
||||
|
||||
/**
|
||||
* Hold parsed trees for reuse across both prepass loops only when the whole
|
||||
* Rust source fits this budget. Trees are much larger than their source, so a
|
||||
* modest source cap keeps peak held-tree memory bounded; larger repos fall
|
||||
* back to re-parsing per loop (unchanged RSS).
|
||||
*/
|
||||
const TREE_REUSE_SOURCE_BUDGET_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Parse `filePath`'s source once, honoring the caller's `treeCache` and, when
|
||||
* provided, an in-function `store` so the two prepass loops share a single
|
||||
* parse instead of re-parsing every file. Returns null when the source is
|
||||
* missing or parsing times out.
|
||||
*/
|
||||
function getOrParseTree(
|
||||
parser: ReturnType<typeof getRustParser>,
|
||||
filePath: string,
|
||||
ctx: {
|
||||
readonly fileContents: ReadonlyMap<string, string>;
|
||||
readonly treeCache?: { get(filePath: string): unknown };
|
||||
},
|
||||
store: Map<string, RustTree> | undefined,
|
||||
): RustTree | null {
|
||||
const cached = (ctx.treeCache?.get(filePath) ?? store?.get(filePath)) as RustTree | undefined;
|
||||
if (cached !== undefined) return cached;
|
||||
const sourceText = ctx.fileContents.get(filePath);
|
||||
if (sourceText === undefined) return null;
|
||||
let tree: RustTree;
|
||||
try {
|
||||
tree = parseSourceSafe(parser, sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ParseTimeoutError) {
|
||||
logger.warn({ file: filePath }, 'rust range-binding: parse timed out, skipping file');
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
store?.set(filePath, tree);
|
||||
return tree;
|
||||
}
|
||||
|
||||
export function populateRustRangeBindings(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
_indexes: ScopeResolutionIndexes,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
ctx: {
|
||||
readonly fileContents: ReadonlyMap<string, string>;
|
||||
readonly treeCache?: { get(filePath: string): unknown };
|
||||
@@ -26,45 +72,45 @@ export function populateRustRangeBindings(
|
||||
): void {
|
||||
const parser = getRustParser();
|
||||
const allReturnTypes = new Map<string, string>();
|
||||
const ambiguousReturnTypes = new Set<string>();
|
||||
const allFieldTypes = new Map<string, Map<string, string>>();
|
||||
const ambiguousFieldTypes = new Set<string>();
|
||||
// Per-defining-file, un-collapsed, FULL-generic return/field types. When a
|
||||
// bare name is ambiguous (#2514) but the call site's `use` import pins a
|
||||
// single definition, we resolve that definition's file here and read its
|
||||
// untruncated type so a generic `Vec<Repo>` element type survives (#2514
|
||||
// follow-up: import-disambiguated duplicates resolve like the compiler).
|
||||
const returnTypeByFile = new Map<string, Map<string, string>>();
|
||||
const fieldTypeByFile = new Map<string, Map<string, Map<string, string>>>();
|
||||
// Parse each file once and reuse across both loops when the workspace fits
|
||||
// the byte budget; otherwise re-parse per loop to bound RSS (see helper).
|
||||
let totalSourceBytes = 0;
|
||||
for (const parsed of parsedFiles) {
|
||||
totalSourceBytes += ctx.fileContents.get(parsed.filePath)?.length ?? 0;
|
||||
}
|
||||
const treeStore: Map<string, RustTree> | undefined =
|
||||
totalSourceBytes <= TREE_REUSE_SOURCE_BUDGET_BYTES ? new Map() : undefined;
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const sourceText = ctx.fileContents.get(parsed.filePath);
|
||||
if (sourceText === undefined) continue;
|
||||
|
||||
const cachedTree = ctx.treeCache?.get(parsed.filePath) as
|
||||
| ReturnType<typeof parser.parse>
|
||||
| undefined;
|
||||
let tree: ReturnType<typeof parser.parse>;
|
||||
if (cachedTree !== undefined) {
|
||||
tree = cachedTree;
|
||||
} else {
|
||||
try {
|
||||
tree = parseSourceSafe(parser, sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ParseTimeoutError) {
|
||||
logger.warn(
|
||||
{ file: parsed.filePath },
|
||||
'rust range-binding: parse timed out, skipping file',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const tree = getOrParseTree(parser, parsed.filePath, ctx, treeStore);
|
||||
if (tree === null) continue;
|
||||
|
||||
for (const fn of tree.rootNode.descendantsOfType('function_item')) {
|
||||
const nameNode = fn.childForFieldName('name');
|
||||
const retType = fn.childForFieldName('return_type');
|
||||
if (nameNode !== null && retType !== null) {
|
||||
const name = nameNode.text;
|
||||
// Ambiguity is a latch, not a toggle: once a name has two or more
|
||||
// workspace definitions it stays ambiguous for the rest of the
|
||||
// prepass, regardless of duplicate count or file order (#2514).
|
||||
if (allReturnTypes.has(name)) {
|
||||
allReturnTypes.delete(name);
|
||||
} else {
|
||||
ambiguousReturnTypes.add(name);
|
||||
} else if (!ambiguousReturnTypes.has(name)) {
|
||||
allReturnTypes.set(name, retType.text);
|
||||
}
|
||||
// Full-generic record per defining file for import-disambiguated lookup.
|
||||
recordByFile(returnTypeByFile, parsed.filePath, name, retType.text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,11 +128,16 @@ export function populateRustRangeBindings(
|
||||
}
|
||||
if (fields.size > 0) {
|
||||
const name = nameNode.text;
|
||||
// Same ambiguity latch as return types (#2514): a third same-named
|
||||
// struct must not restore a resolvable global field map.
|
||||
if (allFieldTypes.has(name)) {
|
||||
allFieldTypes.delete(name);
|
||||
} else {
|
||||
ambiguousFieldTypes.add(name);
|
||||
} else if (!ambiguousFieldTypes.has(name)) {
|
||||
allFieldTypes.set(name, fields);
|
||||
}
|
||||
// Full-generic record per defining file for import-disambiguated lookup.
|
||||
recordByFile(fieldTypeByFile, parsed.filePath, name, fields);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,39 +150,32 @@ export function populateRustRangeBindings(
|
||||
}
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const sourceText = ctx.fileContents.get(parsed.filePath);
|
||||
if (sourceText === undefined) continue;
|
||||
|
||||
const cachedTree = ctx.treeCache?.get(parsed.filePath) as
|
||||
| ReturnType<typeof parser.parse>
|
||||
| undefined;
|
||||
let tree: ReturnType<typeof parser.parse>;
|
||||
if (cachedTree !== undefined) {
|
||||
tree = cachedTree;
|
||||
} else {
|
||||
try {
|
||||
tree = parseSourceSafe(parser, sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ParseTimeoutError) {
|
||||
logger.warn(
|
||||
{ file: parsed.filePath },
|
||||
'rust range-binding: parse timed out, skipping file',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const tree = getOrParseTree(parser, parsed.filePath, ctx, treeStore);
|
||||
if (tree === null) continue;
|
||||
|
||||
const scopeMap = new Map(parsed.scopes.map((s) => [s.id, s]));
|
||||
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
|
||||
if (moduleScope === undefined) continue;
|
||||
|
||||
processForLoops(tree.rootNode, parsed, scopeMap, moduleScope, allReturnTypes);
|
||||
processForLoops(
|
||||
tree.rootNode,
|
||||
parsed,
|
||||
scopeMap,
|
||||
moduleScope,
|
||||
allReturnTypes,
|
||||
indexes,
|
||||
returnTypeByFile,
|
||||
);
|
||||
processPatternBindings(tree.rootNode, parsed, scopeMap, moduleScope);
|
||||
processStructDestructuring(tree.rootNode, parsed, scopeMap, moduleScope, allFieldTypes);
|
||||
processStructDestructuring(
|
||||
tree.rootNode,
|
||||
parsed,
|
||||
scopeMap,
|
||||
moduleScope,
|
||||
allFieldTypes,
|
||||
indexes,
|
||||
fieldTypeByFile,
|
||||
);
|
||||
processPendingAssignments(
|
||||
tree.rootNode,
|
||||
parsed,
|
||||
@@ -196,12 +240,88 @@ function normalizeFieldType(text: string): string {
|
||||
return t.trim();
|
||||
}
|
||||
|
||||
/** Get-or-create the inner map for `file` and record `name -> value`. */
|
||||
function recordByFile<V>(
|
||||
byFile: Map<string, Map<string, V>>,
|
||||
file: string,
|
||||
name: string,
|
||||
value: V,
|
||||
): void {
|
||||
let inner = byFile.get(file);
|
||||
if (inner === undefined) {
|
||||
inner = new Map<string, V>();
|
||||
byFile.set(file, inner);
|
||||
}
|
||||
inner.set(name, value);
|
||||
}
|
||||
|
||||
/** Final segment of a dot-joined qualified name (`a.make` -> `make`), or the
|
||||
* bare name when the def carries no qualifier. */
|
||||
function simpleName(qualifiedName: string | undefined, bareName: string): string {
|
||||
if (qualifiedName === undefined) return bareName;
|
||||
const dot = qualifiedName.lastIndexOf('.');
|
||||
return dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1);
|
||||
}
|
||||
|
||||
/** Distinct `(file, name)` definitions, in first-seen order. */
|
||||
function uniqueDefs(
|
||||
defs: readonly { file: string; name: string }[],
|
||||
): { file: string; name: string }[] {
|
||||
const seen = new Set<string>();
|
||||
const out: { file: string; name: string }[] = [];
|
||||
for (const d of defs) {
|
||||
const key = `${d.file} ${d.name}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(d);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `name` at `moduleScope` to the value recorded in `byFile` for the one
|
||||
* definition visible here, or null when zero or several are visible (which
|
||||
* keeps the #2514 ambiguity latch). Mirrors Rust name resolution: explicit
|
||||
* `use`/re-export imports and local defs shadow `use x::*` globs, so a glob is
|
||||
* consulted only when no explicit binding names `name`, and even then only when
|
||||
* exactly one glob-target file actually defines it.
|
||||
*/
|
||||
function resolveImportedDef<V>(
|
||||
name: string,
|
||||
moduleScope: Scope,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
byFile: ReadonlyMap<string, ReadonlyMap<string, V>>,
|
||||
): V | null {
|
||||
const explicit = uniqueDefs(
|
||||
lookupBindingsAt(moduleScope.id, name, indexes)
|
||||
.filter((r) => r.origin === 'import' || r.origin === 'reexport' || r.origin === 'local')
|
||||
.map((r) => ({ file: r.def.filePath, name: simpleName(r.def.qualifiedName, name) })),
|
||||
);
|
||||
const defs =
|
||||
explicit.length > 0
|
||||
? explicit
|
||||
: uniqueDefs(
|
||||
(indexes.imports.get(moduleScope.id) ?? [])
|
||||
.filter(
|
||||
(e) =>
|
||||
e.kind === 'wildcard-expanded' &&
|
||||
e.targetFile !== null &&
|
||||
byFile.get(e.targetFile)?.has(name) === true,
|
||||
)
|
||||
.map((e) => ({ file: e.targetFile as string, name })),
|
||||
);
|
||||
if (defs.length !== 1) return null;
|
||||
return byFile.get(defs[0].file)?.get(defs[0].name) ?? null;
|
||||
}
|
||||
|
||||
function processForLoops(
|
||||
root: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
allReturnTypes: ReadonlyMap<string, string>,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
returnTypeByFile: ReadonlyMap<string, Map<string, string>>,
|
||||
): void {
|
||||
for (const forNode of root.descendantsOfType('for_expression')) {
|
||||
const patternNode = forNode.childForFieldName('pattern');
|
||||
@@ -217,6 +337,8 @@ function processForLoops(
|
||||
scopeMap,
|
||||
moduleScope,
|
||||
allReturnTypes,
|
||||
indexes,
|
||||
returnTypeByFile,
|
||||
);
|
||||
if (elementType === null) continue;
|
||||
|
||||
@@ -331,7 +453,9 @@ function processStructDestructuring(
|
||||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
allFieldTypes?: ReadonlyMap<string, Map<string, string>>,
|
||||
allFieldTypes: ReadonlyMap<string, Map<string, string>>,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
fieldTypeByFile: ReadonlyMap<string, ReadonlyMap<string, Map<string, string>>>,
|
||||
): void {
|
||||
for (const letNode of root.descendantsOfType('let_declaration')) {
|
||||
const patternNode = letNode.childForFieldName('pattern');
|
||||
@@ -356,7 +480,13 @@ function processStructDestructuring(
|
||||
|
||||
let fieldType = lookupFieldType(typeName, fieldName, parsed, scopeMap, moduleScope);
|
||||
if (fieldType === null) {
|
||||
fieldType = allFieldTypes?.get(typeName)?.get(fieldName) ?? null;
|
||||
fieldType = allFieldTypes.get(typeName)?.get(fieldName) ?? null;
|
||||
}
|
||||
if (fieldType === null) {
|
||||
// Import-disambiguated duplicate struct (#2514 follow-up): the global
|
||||
// field map is ambiguous, but a `use` import pins one definition.
|
||||
const fields = resolveImportedDef(typeName, moduleScope, indexes, fieldTypeByFile);
|
||||
fieldType = fields?.get(fieldName) ?? null;
|
||||
}
|
||||
if (fieldType !== null) {
|
||||
injectTypeBinding(targetScope, fieldName, fieldType);
|
||||
@@ -481,7 +611,9 @@ function resolveIterableElementType(
|
||||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
allReturnTypes?: ReadonlyMap<string, string>,
|
||||
allReturnTypes: ReadonlyMap<string, string>,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
returnTypeByFile: ReadonlyMap<string, ReadonlyMap<string, string>>,
|
||||
): string | null {
|
||||
let iterableNode = valueNode;
|
||||
if (iterableNode.type === 'reference_expression') {
|
||||
@@ -506,10 +638,16 @@ function resolveIterableElementType(
|
||||
}
|
||||
|
||||
if (func.type === 'identifier') {
|
||||
const crossFileReturn = allReturnTypes?.get(func.text);
|
||||
const crossFileReturn = allReturnTypes.get(func.text);
|
||||
if (crossFileReturn !== undefined) return unwrapGeneric(crossFileReturn);
|
||||
const rawReturn = lookupRawFunctionReturnType(func.text, valueNode);
|
||||
if (rawReturn !== null) return unwrapGeneric(rawReturn);
|
||||
// Import-disambiguated duplicate: the bare-name map is ambiguous (#2514)
|
||||
// but a `use` import pins one definition. Read its FULL return type
|
||||
// here, BEFORE the scope-binding lookup below, because that binding is
|
||||
// generic-truncated (`Vec<Repo>` becomes `Vec`), losing the element.
|
||||
const importedReturn = resolveImportedDef(func.text, moduleScope, indexes, returnTypeByFile);
|
||||
if (importedReturn !== null) return unwrapGeneric(importedReturn);
|
||||
const returnType = lookupReturnTypeInScopes(func.text, parsed, scopeMap, moduleScope);
|
||||
if (returnType !== null) return unwrapGeneric(returnType);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GraphNode, GraphRelationship, NodeLabel } from 'gitnexus-shared';
|
||||
import type { GraphNode, NodeLabel, RelationshipType } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../graph/types.js';
|
||||
import { parseTruthyEnv } from './utils/env.js';
|
||||
|
||||
@@ -30,9 +30,13 @@ const isLocalValueCandidate = (node: GraphNode): boolean => {
|
||||
// True when `rel` is the structural `File -> DEFINES -> candidate` edge. Callers
|
||||
// guard on the candidate already being the edge target, so only the source label
|
||||
// needs checking here.
|
||||
const isFileDefinesEdge = (graph: KnowledgeGraph, rel: GraphRelationship): boolean => {
|
||||
if (rel.type !== 'DEFINES') return false;
|
||||
return graph.getNode(rel.sourceId)?.label === 'File';
|
||||
const isFileDefinesEdge = (
|
||||
graph: KnowledgeGraph,
|
||||
type: RelationshipType,
|
||||
sourceId: string,
|
||||
): boolean => {
|
||||
if (type !== 'DEFINES') return false;
|
||||
return graph.getNode(sourceId)?.label === 'File';
|
||||
};
|
||||
|
||||
export const pruneLocalValueSymbols = (
|
||||
@@ -51,21 +55,21 @@ export const pruneLocalValueSymbols = (
|
||||
if (candidateIds.size === 0) return emptyStats(false);
|
||||
|
||||
const candidatesWithSemanticEdges = new Set<string>();
|
||||
for (const rel of graph.iterRelationships()) {
|
||||
// Field-wise scan (#2680): a whole-graph walk that reads only these three, so
|
||||
// materializing a relationship object per edge would be pure overhead.
|
||||
graph.forEachRelationshipFields((sourceId, targetId, type) => {
|
||||
// Any outgoing edge from a candidate is a semantic edge: the only structural
|
||||
// edge a block-local value symbol carries is the incoming File -> DEFINES, on
|
||||
// which the candidate is the target, never the source.
|
||||
if (candidateIds.has(rel.sourceId)) {
|
||||
candidatesWithSemanticEdges.add(rel.sourceId);
|
||||
if (candidateIds.has(sourceId)) {
|
||||
candidatesWithSemanticEdges.add(sourceId);
|
||||
}
|
||||
|
||||
// An incoming edge is semantic unless it is the structural File -> DEFINES.
|
||||
if (candidateIds.has(rel.targetId)) {
|
||||
if (!isFileDefinesEdge(graph, rel)) {
|
||||
candidatesWithSemanticEdges.add(rel.targetId);
|
||||
}
|
||||
if (candidateIds.has(targetId) && !isFileDefinesEdge(graph, type, sourceId)) {
|
||||
candidatesWithSemanticEdges.add(targetId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let prunedNodes = 0;
|
||||
for (const candidateId of candidateIds) {
|
||||
|
||||
@@ -1,91 +1,91 @@
|
||||
/**
|
||||
* Phase: di
|
||||
*
|
||||
* Framework-neutral dependency-injection resolution. Routes `Property` nodes
|
||||
* by `properties.language` to the per-language field matchers registered in
|
||||
* `di-extractors/` (`DI_MATCHERS` — same registry seam shape as
|
||||
* `SCOPE_RESOLVERS`), then fans each match out to `INJECTS` edges from the
|
||||
* consumer Class node to every Class implementing the matched element
|
||||
* interface.
|
||||
*
|
||||
* This file names NO language or framework: which fields count as
|
||||
* container-injected — and why — is entirely the registered matcher's
|
||||
* business (see `di-extractors/` for the matchers and their semantics,
|
||||
* including deliberate annotation exclusions). The matcher also supplies the
|
||||
* human-readable edge `reason`, so framework specifics stay in the payload,
|
||||
* never in this phase.
|
||||
*
|
||||
* The resolution uses ONLY graph data — Property nodes, `HAS_PROPERTY` edges,
|
||||
* `IMPLEMENTS` edges, and Interface nodes. No filesystem access is performed:
|
||||
* the structural information was already extracted by earlier parse /
|
||||
* structure phases.
|
||||
*
|
||||
* Interface resolution is scoped to the CANDIDATE'S OWN language and prefers
|
||||
* qualified names: a dotted element type resolves via the language's
|
||||
* `qualifiedName` index; a bare simple name resolves only while unique within
|
||||
* that language. Ambiguous names — simple OR qualified (a qualifiedName has
|
||||
* no file-path component, so the same package+name duplicated across monorepo
|
||||
* modules collides too) — fail CLOSED — no edge, never
|
||||
* last-writer-wins — but observably: skips are counted in the phase output's
|
||||
* `ambiguousSkipped` and named in an isDev debug log, so "no DI fields" is
|
||||
* distinguishable from "all candidates ambiguous". Same-package/import-aware
|
||||
* disambiguation is a documented follow-up (see the plan's Deferred work).
|
||||
* Framework-neutral dependency-injection resolution. Per-language resolvers
|
||||
* identify injection sites and provider metadata; this phase performs only
|
||||
* graph-level type/heritage resolution and emits Class -> Class INJECTS edges.
|
||||
*
|
||||
* @deps mro
|
||||
* @reads graph (Property nodes, HAS_PROPERTY edges, IMPLEMENTS edges, Interface nodes)
|
||||
* @reads graph (Class/Interface/member nodes and heritage/ownership edges)
|
||||
* @writes graph (INJECTS edges)
|
||||
*/
|
||||
|
||||
import type { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { GraphNode, SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { PipelinePhase, PipelineContext } from './types.js';
|
||||
import { DI_MATCHERS, isSupportedLanguage } from '../di-extractors/index.js';
|
||||
import {
|
||||
DI_RESOLVERS,
|
||||
isSupportedLanguage,
|
||||
type DiInjectionMatch,
|
||||
type DiProviderMatch,
|
||||
} from '../di-extractors/index.js';
|
||||
import { isDev } from '../utils/env.js';
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
export interface DIOutput {
|
||||
injectsEdges: number;
|
||||
/** Kept for output compatibility; now counts every matched injection site. */
|
||||
fieldsScanned: number;
|
||||
/** Candidates skipped because their element type name — bare simple name
|
||||
* or dotted qualified name — matched more than one Interface within the
|
||||
* candidate's language (fail-closed). */
|
||||
/** Sites skipped because the requested type name itself was ambiguous. */
|
||||
ambiguousSkipped: number;
|
||||
/** Single-valued sites represented by multiple low-confidence candidates. */
|
||||
ambiguousInjections: number;
|
||||
}
|
||||
|
||||
/** Sentinel marking an interface name (simple or qualified) claimed by more
|
||||
* than one Interface node within a language — resolution must fail closed. */
|
||||
const AMBIGUOUS: unique symbol = Symbol('ambiguous');
|
||||
|
||||
/** Per-language interface lookup: qualified names resolve exactly; bare
|
||||
* simple names resolve only while unique within the language. Both indexes
|
||||
* fail closed on their own duplicates. */
|
||||
interface InterfaceIndex {
|
||||
/** `properties.qualifiedName` → Interface node id (when extracted — e.g.
|
||||
* package-qualified for languages with a file-scope package declaration),
|
||||
* or {@link AMBIGUOUS} once a second Interface claims the same qualified
|
||||
* name in the same language — realistic in monorepos, where the same
|
||||
* package+name is duplicated across modules or main/test source roots
|
||||
* (a qualifiedName carries no file-path component). */
|
||||
interface NameIndex {
|
||||
byQualifiedName: Map<string, string | typeof AMBIGUOUS>;
|
||||
/** `properties.name` → Interface node id, or {@link AMBIGUOUS} once a
|
||||
* second same-name Interface appears in the same language. */
|
||||
bySimpleName: Map<string, string | typeof AMBIGUOUS>;
|
||||
}
|
||||
|
||||
/** A Property node a registered matcher accepted as a DI fan-out candidate. */
|
||||
interface CandidateField {
|
||||
propertyId: string;
|
||||
/** The candidate's language — interface resolution (Pass 3) looks up ONLY
|
||||
* this language's interface index. */
|
||||
interface CandidateSite extends DiInjectionMatch {
|
||||
siteNodeId: string;
|
||||
language: SupportedLanguages;
|
||||
elementTypeName: string;
|
||||
/** Matcher-supplied edge reason (carries the framework specifics). */
|
||||
}
|
||||
|
||||
interface PendingEdge {
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
confidence: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
function emptyNameIndex(): NameIndex {
|
||||
return { byQualifiedName: new Map(), bySimpleName: new Map() };
|
||||
}
|
||||
|
||||
function addIndexedName(index: NameIndex, node: GraphNode): void {
|
||||
const qualifiedName = node.properties.qualifiedName;
|
||||
if (typeof qualifiedName === 'string') {
|
||||
index.byQualifiedName.set(
|
||||
qualifiedName,
|
||||
index.byQualifiedName.has(qualifiedName) ? AMBIGUOUS : node.id,
|
||||
);
|
||||
}
|
||||
const simpleName = node.properties.name;
|
||||
index.bySimpleName.set(simpleName, index.bySimpleName.has(simpleName) ? AMBIGUOUS : node.id);
|
||||
}
|
||||
|
||||
function resolveIndexedName(index: NameIndex | undefined, name: string) {
|
||||
if (index === undefined) return undefined;
|
||||
return name.includes('.') ? index.byQualifiedName.get(name) : index.bySimpleName.get(name);
|
||||
}
|
||||
|
||||
function providerCandidates(
|
||||
ids: ReadonlySet<string>,
|
||||
providers: ReadonlyMap<string, DiProviderMatch>,
|
||||
): string[] {
|
||||
const all = [...ids];
|
||||
const recognized = all.filter((id) => providers.has(id));
|
||||
// Recall-first fallback: provider metadata can be incomplete (custom
|
||||
// registration mechanisms and legacy indexes can omit it). Prefer
|
||||
// framework-recognized providers when present, but keep structurally valid
|
||||
// candidates when none are known instead of dropping the injection entirely.
|
||||
return recognized.length > 0 ? recognized : all;
|
||||
}
|
||||
|
||||
export const diPhase: PipelinePhase<DIOutput> = {
|
||||
name: 'di',
|
||||
// Depends on `mro` for ordering: heritage edges (IMPLEMENTS/EXTENDS) must be
|
||||
// fully populated before we resolve interface→implementer fan-out.
|
||||
deps: ['mro'],
|
||||
|
||||
async execute(ctx: PipelineContext): Promise<DIOutput> {
|
||||
@@ -96,174 +96,193 @@ export const diPhase: PipelinePhase<DIOutput> = {
|
||||
stats: { filesProcessed: 0, totalFiles: 0, nodesCreated: ctx.graph.nodeCount },
|
||||
});
|
||||
|
||||
// ── Pass 1: route Property nodes to registered per-language matchers ───
|
||||
// Early-exit optimization: if no registered matcher accepts any Property
|
||||
// node, skip all index construction. This makes the phase a no-op on
|
||||
// repos with no DI-matched fields (no IMPLEMENTS / HAS_PROPERTY scans).
|
||||
const candidates: CandidateField[] = [];
|
||||
|
||||
const candidates: CandidateSite[] = [];
|
||||
const providers = new Map<string, DiProviderMatch>();
|
||||
ctx.graph.forEachNode((node) => {
|
||||
if (node.label !== 'Property') return;
|
||||
const language = node.properties.language;
|
||||
if (language === undefined || !isSupportedLanguage(language)) return;
|
||||
const matcher = DI_MATCHERS.get(language);
|
||||
if (matcher === undefined) return;
|
||||
const match = matcher(node);
|
||||
if (match === null) return;
|
||||
candidates.push({
|
||||
propertyId: node.id,
|
||||
language,
|
||||
elementTypeName: match.elementTypeName,
|
||||
reason: match.reason,
|
||||
});
|
||||
const resolver = DI_RESOLVERS.get(language);
|
||||
if (resolver === undefined) return;
|
||||
|
||||
const provider = resolver.matchProvider(node);
|
||||
if (provider !== null) providers.set(node.id, provider);
|
||||
for (const match of resolver.matchInjectionSites(node)) {
|
||||
candidates.push({ ...match, siteNodeId: node.id, language });
|
||||
}
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return { injectsEdges: 0, fieldsScanned: 0, ambiguousSkipped: 0 };
|
||||
return {
|
||||
injectsEdges: 0,
|
||||
fieldsScanned: 0,
|
||||
ambiguousSkipped: 0,
|
||||
ambiguousInjections: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Pass 2: build single-pass reverse indexes ─────────────────────────
|
||||
|
||||
// interfaceNodeId → Set<implementerClassId> (reverse of IMPLEMENTS edge)
|
||||
// IMPLEMENTS edges go Class→Interface, so target is the interface.
|
||||
// Keyed by node id — globally unique — so this index needs no language
|
||||
// scoping; only NAME-based lookups (below) do.
|
||||
const interfaceToImplementers = new Map<string, Set<string>>();
|
||||
for (const rel of ctx.graph.iterRelationshipsByType('IMPLEMENTS')) {
|
||||
const implementerId = rel.sourceId; // Class
|
||||
const interfaceId = rel.targetId; // Interface
|
||||
let set = interfaceToImplementers.get(interfaceId);
|
||||
if (set === undefined) {
|
||||
set = new Set();
|
||||
interfaceToImplementers.set(interfaceId, set);
|
||||
const set = interfaceToImplementers.get(rel.targetId) ?? new Set<string>();
|
||||
set.add(rel.sourceId);
|
||||
interfaceToImplementers.set(rel.targetId, set);
|
||||
}
|
||||
|
||||
const memberToClass = new Map<string, string>();
|
||||
for (const relationType of ['HAS_PROPERTY', 'HAS_METHOD'] as const) {
|
||||
for (const rel of ctx.graph.iterRelationshipsByType(relationType)) {
|
||||
memberToClass.set(rel.targetId, rel.sourceId);
|
||||
}
|
||||
set.add(implementerId);
|
||||
}
|
||||
|
||||
// propertyNodeId → consumerClassId (reverse of HAS_PROPERTY edge)
|
||||
// HAS_PROPERTY edges go Class→Property, so target is the property.
|
||||
const propertyToClass = new Map<string, string>();
|
||||
for (const rel of ctx.graph.iterRelationshipsByType('HAS_PROPERTY')) {
|
||||
propertyToClass.set(rel.targetId, rel.sourceId);
|
||||
}
|
||||
|
||||
// language → InterfaceIndex (from Interface-labeled nodes). Scoped per
|
||||
// language so an Interface in one language can never satisfy a candidate
|
||||
// from another. Within a language, a name resolves only while unique —
|
||||
// a second Interface claiming the same simple OR qualified name flips
|
||||
// that entry to AMBIGUOUS and resolution fails closed (never
|
||||
// last-writer-wins).
|
||||
// Index only languages that can resolve: an Interface in a language with
|
||||
// no candidate can never be looked up in Pass 3.
|
||||
const candidateLanguages = new Set<string>(candidates.map((c) => c.language));
|
||||
const interfacesByLanguage = new Map<string, InterfaceIndex>();
|
||||
const candidateLanguages = new Set<string>(candidates.map((candidate) => candidate.language));
|
||||
const interfacesByLanguage = new Map<string, NameIndex>();
|
||||
const classesByLanguage = new Map<string, NameIndex>();
|
||||
const classNodes = new Map<string, GraphNode>();
|
||||
ctx.graph.forEachNode((node) => {
|
||||
if (node.label !== 'Interface') return;
|
||||
if (node.label !== 'Class' && node.label !== 'Interface') return;
|
||||
const language = node.properties.language;
|
||||
if (typeof language !== 'string') return; // no language ⇒ unindexable
|
||||
if (!candidateLanguages.has(language)) return;
|
||||
let index = interfacesByLanguage.get(language);
|
||||
if (index === undefined) {
|
||||
index = { byQualifiedName: new Map(), bySimpleName: new Map() };
|
||||
interfacesByLanguage.set(language, index);
|
||||
}
|
||||
// `qualifiedName` reaches NodeProperties through the extensible index
|
||||
// signature, so narrow it explicitly (no `any`).
|
||||
const qualifiedName = node.properties.qualifiedName;
|
||||
if (typeof qualifiedName === 'string') {
|
||||
index.byQualifiedName.set(
|
||||
qualifiedName,
|
||||
index.byQualifiedName.has(qualifiedName) ? AMBIGUOUS : node.id,
|
||||
);
|
||||
}
|
||||
const simpleName = node.properties.name;
|
||||
index.bySimpleName.set(simpleName, index.bySimpleName.has(simpleName) ? AMBIGUOUS : node.id);
|
||||
if (typeof language !== 'string' || !candidateLanguages.has(language)) return;
|
||||
const indexes = node.label === 'Class' ? classesByLanguage : interfacesByLanguage;
|
||||
const index = indexes.get(language) ?? emptyNameIndex();
|
||||
addIndexedName(index, node);
|
||||
indexes.set(language, index);
|
||||
if (node.label === 'Class') classNodes.set(node.id, node);
|
||||
});
|
||||
|
||||
// ── Pass 3: emit INJECTS edges ────────────────────────────────────────
|
||||
let injectsEdges = 0;
|
||||
let ambiguousSkipped = 0;
|
||||
const ambiguousElementTypes = new Set<string>();
|
||||
const seenEdges = new Set<string>();
|
||||
let ambiguousInjections = 0;
|
||||
const ambiguousTypeNames = new Set<string>();
|
||||
const pending = new Map<string, PendingEdge>();
|
||||
|
||||
const queueEdge = (edge: PendingEdge): void => {
|
||||
if (edge.sourceId === edge.targetId) return;
|
||||
const id = `INJECTS:${edge.sourceId}->${edge.targetId}`;
|
||||
const existing = pending.get(id);
|
||||
if (existing === undefined || edge.confidence > existing.confidence) pending.set(id, edge);
|
||||
};
|
||||
|
||||
for (const candidate of candidates) {
|
||||
// Resolve the consumer Class that owns this Property.
|
||||
const consumerClassId = propertyToClass.get(candidate.propertyId);
|
||||
if (!consumerClassId) continue;
|
||||
const siteNode = ctx.graph.getNode(candidate.siteNodeId);
|
||||
const consumerClassId =
|
||||
siteNode?.label === 'Class' ? siteNode.id : memberToClass.get(candidate.siteNodeId);
|
||||
if (consumerClassId === undefined) continue;
|
||||
|
||||
// Resolve the element type name via the CANDIDATE'S OWN language index
|
||||
// only — a same-named Interface in another language never participates.
|
||||
const index = interfacesByLanguage.get(candidate.language);
|
||||
if (index === undefined) continue;
|
||||
|
||||
// A dotted element type is a qualified name (e.g. `com.a.Shape`) —
|
||||
// exact qualifiedName lookup, unaffected by simple-name ambiguity.
|
||||
// A bare name uses the simple-name index. BOTH lookups fail CLOSED
|
||||
// on their own ambiguity (a qualified name too can be claimed twice —
|
||||
// same package+name across monorepo modules): no edge (never
|
||||
// last-writer-wins), but counted and logged so the skip is
|
||||
// observable. Same-package/import-aware disambiguation is a
|
||||
// deliberate follow-up (plan: Deferred work).
|
||||
let interfaceId: string | undefined;
|
||||
if (candidate.elementTypeName.includes('.')) {
|
||||
const entry = index.byQualifiedName.get(candidate.elementTypeName);
|
||||
if (entry === AMBIGUOUS) {
|
||||
ambiguousSkipped++;
|
||||
ambiguousElementTypes.add(candidate.elementTypeName);
|
||||
continue;
|
||||
}
|
||||
interfaceId = entry;
|
||||
} else {
|
||||
const entry = index.bySimpleName.get(candidate.elementTypeName);
|
||||
if (entry === AMBIGUOUS) {
|
||||
ambiguousSkipped++;
|
||||
ambiguousElementTypes.add(candidate.elementTypeName);
|
||||
continue;
|
||||
}
|
||||
interfaceId = entry;
|
||||
const classEntry = resolveIndexedName(
|
||||
classesByLanguage.get(candidate.language),
|
||||
candidate.targetTypeName,
|
||||
);
|
||||
const interfaceEntry = resolveIndexedName(
|
||||
interfacesByLanguage.get(candidate.language),
|
||||
candidate.targetTypeName,
|
||||
);
|
||||
if (
|
||||
classEntry === AMBIGUOUS ||
|
||||
interfaceEntry === AMBIGUOUS ||
|
||||
(classEntry !== undefined && interfaceEntry !== undefined)
|
||||
) {
|
||||
// A simple/qualified name claimed by both a Class and an Interface is
|
||||
// type-ambiguous too. Fail closed rather than guessing which Java type
|
||||
// the injection site meant; import-aware disambiguation is not
|
||||
// available in this graph-only phase. This intentionally applies to
|
||||
// legacy collection sites too: a Class/Interface collision no longer
|
||||
// fans out through the interface on a simple-name guess.
|
||||
ambiguousSkipped++;
|
||||
ambiguousTypeNames.add(candidate.targetTypeName);
|
||||
continue;
|
||||
}
|
||||
if (interfaceId === undefined) continue;
|
||||
|
||||
// Fan out to every class implementing that interface.
|
||||
const implementers = interfaceToImplementers.get(interfaceId);
|
||||
if (!implementers) continue;
|
||||
const structural = new Set<string>();
|
||||
if (typeof classEntry === 'string') structural.add(classEntry);
|
||||
if (typeof interfaceEntry === 'string') {
|
||||
for (const id of interfaceToImplementers.get(interfaceEntry) ?? []) structural.add(id);
|
||||
}
|
||||
structural.delete(consumerClassId);
|
||||
if (structural.size === 0) continue;
|
||||
|
||||
for (const implId of implementers) {
|
||||
// Skip self-edges: a class never injects its own bean into itself.
|
||||
if (implId === consumerClassId) continue;
|
||||
let viable = providerCandidates(structural, providers);
|
||||
const namedSelection = candidate.namedSelection;
|
||||
if (namedSelection !== undefined) {
|
||||
viable = viable.filter(
|
||||
(id) => providers.get(id)?.names.includes(namedSelection.name) === true,
|
||||
);
|
||||
if (viable.length === 0) continue;
|
||||
}
|
||||
|
||||
// Dedup-safe edge ID: deterministic from (consumer, implementer).
|
||||
const edgeId = `INJECTS:${consumerClassId}->${implId}`;
|
||||
if (seenEdges.has(edgeId)) continue;
|
||||
seenEdges.add(edgeId);
|
||||
if (candidate.cardinality === 'collection') {
|
||||
const confidence = namedSelection === undefined ? 0.8 : 0.9;
|
||||
const suffix = namedSelection === undefined ? '' : `; ${namedSelection.reason}`;
|
||||
for (const targetId of viable) {
|
||||
queueEdge({
|
||||
sourceId: consumerClassId,
|
||||
targetId,
|
||||
confidence,
|
||||
reason: candidate.reason + suffix,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.graph.addRelationship({
|
||||
id: edgeId,
|
||||
if (viable.length === 1) {
|
||||
const suffix = namedSelection === undefined ? '' : `; ${namedSelection.reason}`;
|
||||
queueEdge({
|
||||
sourceId: consumerClassId,
|
||||
targetId: implId,
|
||||
type: 'INJECTS',
|
||||
confidence: 0.8,
|
||||
// Matcher-supplied reason — names the framework and the annotation
|
||||
// actually found on the field (see di-extractors/).
|
||||
reason: candidate.reason,
|
||||
targetId: viable[0],
|
||||
confidence: namedSelection === undefined ? 0.9 : 0.95,
|
||||
reason: candidate.reason + suffix,
|
||||
});
|
||||
injectsEdges++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const preferred = viable.flatMap((id) => {
|
||||
const reason = providers.get(id)?.preferenceReason;
|
||||
return reason === undefined ? [] : [{ id, reason }];
|
||||
});
|
||||
if (namedSelection === undefined && preferred.length === 1) {
|
||||
const selected = preferred[0];
|
||||
queueEdge({
|
||||
sourceId: consumerClassId,
|
||||
targetId: selected.id,
|
||||
confidence: 0.95,
|
||||
reason: `${candidate.reason}; ${selected.reason}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
ambiguousInjections++;
|
||||
const candidateNames = viable
|
||||
.map((id) => classNodes.get(id)?.properties.name ?? id)
|
||||
.sort()
|
||||
.join(', ');
|
||||
for (const targetId of viable) {
|
||||
queueEdge({
|
||||
sourceId: consumerClassId,
|
||||
targetId,
|
||||
confidence: 0.5,
|
||||
reason: `${candidate.reason}; ambiguous candidates: ${candidateNames}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, edge] of pending) {
|
||||
ctx.graph.addRelationship({ id, type: 'INJECTS', ...edge });
|
||||
}
|
||||
|
||||
if (isDev && ambiguousSkipped > 0) {
|
||||
// One aggregated debug line (not per-candidate spam): duplicate simple
|
||||
// names are NORMAL in large repos, but the skip must stay observable.
|
||||
logger.debug(
|
||||
`🧩 DI: ${ambiguousSkipped} candidate(s) skipped — ambiguous element interface name(s): ${[...ambiguousElementTypes].sort().join(', ')}`,
|
||||
`DI: ${ambiguousSkipped} site(s) skipped because requested type names were ambiguous: ${[...ambiguousTypeNames].sort().join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (isDev && (injectsEdges > 0 || ambiguousSkipped > 0)) {
|
||||
if (isDev && (pending.size > 0 || ambiguousInjections > 0)) {
|
||||
logger.info(
|
||||
`🧩 DI: ${injectsEdges} INJECTS edges from ${candidates.length} injection-annotated collection fields (${ambiguousSkipped} ambiguous skipped)`,
|
||||
`DI: ${pending.size} INJECTS edges from ${candidates.length} injection sites (${ambiguousInjections} ambiguous single-site resolutions)`,
|
||||
);
|
||||
}
|
||||
|
||||
return { injectsEdges, fieldsScanned: candidates.length, ambiguousSkipped };
|
||||
return {
|
||||
injectsEdges: pending.size,
|
||||
fieldsScanned: candidates.length,
|
||||
ambiguousSkipped,
|
||||
ambiguousInjections,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
scopeResolutionPhase,
|
||||
type ScopeResolutionOutput,
|
||||
} from '../scope-resolution/pipeline/phase.js';
|
||||
export { springConfigPhase, type SpringConfigOutput } from './spring-config.js';
|
||||
export { pruneLocalSymbolsPhase, type PruneLocalSymbolsOutput } from './prune-local-symbols.js';
|
||||
export { taintSummariesPhase, type TaintSummariesOutput } from './taint-summaries.js';
|
||||
export { callSummariesPhase, type CallSummariesOutput } from './call-summaries.js';
|
||||
|
||||
@@ -95,7 +95,9 @@ import {
|
||||
import type { KnowledgeGraph } from '../../graph/types.js';
|
||||
import type { PipelineOptions } from '../pipeline.js';
|
||||
import fs from 'node:fs';
|
||||
import { effectiveRamBytes, memoryAutopilotDisabled } from '../utils/effective-ram.js';
|
||||
import path from 'node:path';
|
||||
import v8 from 'node:v8';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import { isDev } from '../utils/env.js';
|
||||
@@ -111,6 +113,81 @@ import { isDebugHeapEnabled, logHeapProbe } from '../utils/heap-probe.js';
|
||||
import { logger } from '../../logger.js';
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Heap-scale guardrail constants (#2649). Measured on a Linux-kernel analyze:
|
||||
* ~75 graph nodes per PARSEABLE file (~5M nodes / ~65k parseable files;
|
||||
* validated against heap probes at chunks 25/50/75 of 113 — the first
|
||||
* calibration divided by total scanned files and under-projected by ~30%),
|
||||
* main-thread heap per node. C-heavy corpus; other language mixes vary — these
|
||||
* feed a WARNING and an emergency abort, never a hard admission gate, so
|
||||
* estimate error only shifts when the operator hears about the problem, not
|
||||
* whether analyze runs.
|
||||
*
|
||||
* RECALIBRATED for streamed structural emit (#2680), which is on by default for
|
||||
* full rebuilds and holds relationships out of the JS heap. The original 2250
|
||||
* was measured against the object-based graph; an A/B at 400k nodes / 1.08M
|
||||
* edges put streaming at 1.40x smaller (819 MB -> 584 MB), so the corpus-
|
||||
* calibrated figure is divided by that ratio: 2250 / 1.40 ~= 1600. Scaling the
|
||||
* measured constant rather than substituting a synthetic one keeps #2649's
|
||||
* kernel calibration intact and changes only the one thing that actually moved.
|
||||
*
|
||||
* If streaming is disabled (GITNEXUS_STREAM_GRAPH_EMIT=0, or any non-force run)
|
||||
* this UNDER-projects by ~40%, so the preflight warning may stay quiet on a repo
|
||||
* that then struggles. That is the safe direction to be wrong in: the abort
|
||||
* below reads LIVE heap use, not this projection, so it still catches the real
|
||||
* condition — only the early warning is affected.
|
||||
*/
|
||||
const PROJECTED_NODES_PER_FILE = 75;
|
||||
const PROJECTED_HEAP_BYTES_PER_NODE = 1600;
|
||||
/** Warn at scan end when the projection crosses this share of the heap limit. */
|
||||
const PREFLIGHT_WARN_FRACTION = 0.85;
|
||||
/**
|
||||
* Abort the chunk loop when live heap use crosses this share of the limit.
|
||||
* Above ~0.95 V8 enters the ineffective-mark-compact death spiral (2s+ GC
|
||||
* pauses that also falsely idle-timeout healthy workers, #2649); 0.92 leaves
|
||||
* one chunk's worth of headroom to fail with an actionable message instead.
|
||||
* `GITNEXUS_MEMORY=off` declines the abort (proceed-at-own-risk).
|
||||
*/
|
||||
const HEAP_ABORT_FRACTION = 0.92;
|
||||
|
||||
/** Projected main-thread heap need for the parse phase (#2649). */
|
||||
export function projectParseHeapNeedBytes(parseableFileCount: number): number {
|
||||
return parseableFileCount * PROJECTED_NODES_PER_FILE * PROJECTED_HEAP_BYTES_PER_NODE;
|
||||
}
|
||||
|
||||
/** True when the mid-loop heap guard should abort the parse (#2649). */
|
||||
export function shouldAbortForHeapPressure(heapUsedBytes: number, heapLimitBytes: number): boolean {
|
||||
if (memoryAutopilotDisabled()) return false;
|
||||
return heapUsedBytes > heapLimitBytes * HEAP_ABORT_FRACTION;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONE action a user should take when this repository doesn't fit the
|
||||
* current heap (#2649). Users hitting memory limits are already frustrated —
|
||||
* a menu of env knobs at that moment is noise. Branch on whether the machine
|
||||
* itself has more memory to give: if this process's limit sits well below
|
||||
* what the RAM-aware auto-sizer would grant (an inherited NODE_OPTIONS pin or
|
||||
* explicit flag), the fix is to drop the pin — gitnexus sizes itself.
|
||||
* Otherwise the machine is the ceiling and only scope or hardware helps.
|
||||
* Escape hatches (GITNEXUS_MEMORY etc.) stay in the README env table.
|
||||
*/
|
||||
export function heapPressureRemedy(heapLimitBytes: number): string {
|
||||
// Effective RAM honors a real cgroup limit — raw os.totalmem() told users
|
||||
// inside an 8GB-limited container on a 64GB host that "this machine has
|
||||
// more memory available", an advice loop with no exit (#2649 review).
|
||||
const autoCapBytes = effectiveRamBytes() * 0.75;
|
||||
if (heapLimitBytes < autoCapBytes * 0.9) {
|
||||
return (
|
||||
`This machine has more memory available: re-run without the --max-old-space-size ` +
|
||||
`pin (NODE_OPTIONS or node flag) — gitnexus sizes its heap to the machine automatically.`
|
||||
);
|
||||
}
|
||||
return (
|
||||
`This machine is at its memory ceiling: exclude generated or vendored directories ` +
|
||||
`via .gitnexusignore, or analyze on a machine with more memory.`
|
||||
);
|
||||
}
|
||||
|
||||
/** Max bytes of source content to load per parse chunk.
|
||||
*
|
||||
* Memory bound for the worker pool dispatch + a granularity knob for
|
||||
@@ -516,6 +593,22 @@ export async function runChunkedParseAndResolve(
|
||||
MIN_SUB_BATCH_BYTES,
|
||||
Math.ceil(chunkByteBudget / (effectivePoolSize * TARGET_JOBS_PER_WORKER)),
|
||||
);
|
||||
// Heap-scale guardrails (#2649), measured on a Linux-kernel analyze
|
||||
// (94,773 files): ~55 graph nodes per parseable file and ~2.2KB of
|
||||
// main-thread heap per node, linear across 113 chunks (see
|
||||
// docs/plans/2026-07-23-gitnexus-plan-large-repo-analyze-oom.md §2).
|
||||
// Estimates, not contracts — used only to warn early (preflight) and to
|
||||
// convert a certain multi-minute GC death spiral into an immediate
|
||||
// actionable error (mid-loop guard).
|
||||
const projectedHeapNeedBytes = projectParseHeapNeedBytes(parseableScanned.length);
|
||||
const heapLimitBytes = v8.getHeapStatistics().heap_size_limit;
|
||||
if (projectedHeapNeedBytes > heapLimitBytes * PREFLIGHT_WARN_FRACTION) {
|
||||
logger.warn(
|
||||
`Large repository: analyzing ${parseableScanned.length} files needs roughly ${Math.round(projectedHeapNeedBytes / 1024 / 1024 / 1024)}GB of memory, ` +
|
||||
`but Node is limited to ${Math.round(heapLimitBytes / 1024 / 1024 / 1024)}GB — analyze may stop early. ${heapPressureRemedy(heapLimitBytes)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const chunks: string[][] = [];
|
||||
let currentChunk: string[] = [];
|
||||
let currentBytes = 0;
|
||||
@@ -869,6 +962,18 @@ export async function runChunkedParseAndResolve(
|
||||
`nodes=${graph.nodeCount} parsedFiles=${allParsedFiles.length}`,
|
||||
);
|
||||
}
|
||||
// #2649 mid-loop heap guard: fail actionably BEFORE V8 enters the
|
||||
// ineffective-mark-compact death spiral (which also falsely times out
|
||||
// healthy workers). The pool is torn down by this function's finally.
|
||||
const heapUsedNow = process.memoryUsage().heapUsed;
|
||||
const heapLimitNow = v8.getHeapStatistics().heap_size_limit;
|
||||
if (shouldAbortForHeapPressure(heapUsedNow, heapLimitNow)) {
|
||||
throw new Error(
|
||||
`Analyze stopped before running out of memory: ${Math.round(heapUsedNow / 1024 / 1024)}MB of the ` +
|
||||
`${Math.round(heapLimitNow / 1024 / 1024)}MB Node heap in use at parse chunk ${chunkIdx + 1}/${numChunks} (#2649). ` +
|
||||
heapPressureRemedy(heapLimitNow),
|
||||
);
|
||||
}
|
||||
const chunkPaths = chunks[chunkIdx];
|
||||
// Start wall-clock for the per-chunk throughput log emitted at end
|
||||
// of this iteration. The gate is computed once above; here we just
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user