From 7666a009f058630cd55d0daceaea162719f7e1d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 11:04:37 +0000 Subject: [PATCH 1/5] fix(java): resolve E.CONST.method() enum-constant receiver dispatch (#2561) Calling a method on an enum-constant receiver (E.CONST.method()) emitted no CALLS edge. The receiver "E.CONST" is a two-segment compound receiver; resolveCompoundReceiverClass walks each dotted segment via the owning class scope's typeBindings map, but enum constants had no typeBinding, so the constant segment dead-ended and no target was ever resolved. #2555/#2558 gave bodied constants a first-class synthesized E$N class with an MRO that includes the host enum; this is the receiver-side follow-up. synthesizeJavaAnonymousClassDeclarations now emits a class-scope typeBinding for every enum constant's simple name -> its E$N class (bodied) or the host enum itself (body-less), reusing the exact mechanism a field declaration uses. The generic compound-receiver chain walk then resolves E.CONST.method() with no change to any shared scope-resolution code. Bodied dispatch (EnumConst.A.hook() -> EnumConst$1.hook#0) and body-less inherited dispatch (Plain.A.m() -> Plain.m#0) are covered by new tests in the existing java-enum-constant-body fixture; both were verified to fail against the pre-fix tree. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/ingestion/languages/java/captures.ts | 47 +++++++++++++------ .../src/EnumConst.java | 4 ++ .../java-enum-constant-body/src/Plain.java | 13 +++++ .../test/integration/resolvers/java.test.ts | 29 ++++++++++++ 4 files changed, 79 insertions(+), 14 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/java-enum-constant-body/src/Plain.java diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index 3adb036c8..2212c4355 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -372,23 +372,42 @@ 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 bodiedName = synthesizeJavaAnonymousClassName(constant); + if (bodiedName !== undefined) { + const body = constant.childForFieldName?.('body'); + if (body !== null && body !== undefined && body.type === 'class_body') { + out.push({ + '@declaration.class': nodeToCapture('@declaration.class', body), + '@declaration.name': syntheticCapture('@declaration.name', body, bodiedName), + }); + if (hostEnum !== undefined) { + out.push({ + '@reference.inherits': nodeToCapture('@reference.inherits', body), + '@reference.name': syntheticCapture('@reference.name', body, 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. + const constantNameNode = constant.childForFieldName?.('name'); + const constantType = bodiedName ?? hostEnum; + if (constantNameNode !== null && constantNameNode !== undefined && constantType !== undefined) { out.push({ - '@reference.inherits': nodeToCapture('@reference.inherits', body), - '@reference.name': syntheticCapture('@reference.name', body, hostEnum), + '@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), }); } } diff --git a/gitnexus/test/fixtures/lang-resolution/java-enum-constant-body/src/EnumConst.java b/gitnexus/test/fixtures/lang-resolution/java-enum-constant-body/src/EnumConst.java index 65292b0c5..6342aa576 100644 --- a/gitnexus/test/fixtures/lang-resolution/java-enum-constant-body/src/EnumConst.java +++ b/gitnexus/test/fixtures/lang-resolution/java-enum-constant-body/src/EnumConst.java @@ -21,4 +21,8 @@ class Unrelated { public void caller() { hook(); } + + public void dispatchToConstant() { + EnumConst.A.hook(); + } } diff --git a/gitnexus/test/fixtures/lang-resolution/java-enum-constant-body/src/Plain.java b/gitnexus/test/fixtures/lang-resolution/java-enum-constant-body/src/Plain.java new file mode 100644 index 000000000..87c750f61 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-enum-constant-body/src/Plain.java @@ -0,0 +1,13 @@ +public enum Plain { + A; + + public void m() { + System.out.println("plain m"); + } +} + +class PlainCaller { + public void callPlain() { + Plain.A.m(); + } +} diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index c7e0545c0..1eeb35e04 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -3055,3 +3055,32 @@ describe('Java enum constant bodies (#2555)', () => { expect(misattributed).toBeUndefined(); }, 60000); }); + +// --------------------------------------------------------------------------- +// #2561: E.CONST.method() emits no CALLS edge — the receiver-side follow-up +// to #2555. A bodied constant's receiver must resolve to its synthesized +// E$N class; a body-less constant's receiver must resolve to the host enum +// itself. +// --------------------------------------------------------------------------- + +describe('Java enum-constant receiver dispatch (#2561)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'java-enum-constant-body'), () => {}); + }, 60000); + + it('resolves EnumConst.A.hook() to the bodied constant override (EnumConst$1.hook#0)', () => { + const calls = getRelationships(result, 'CALLS'); + const dispatch = calls.find((c) => c.source === 'dispatchToConstant' && c.target === 'hook'); + expect(dispatch).toBeDefined(); + expect(dispatch!.rel.targetId).toBe('Method:src/EnumConst.java:EnumConst$1.hook#0'); + }); + + it("resolves Plain.A.m() to the body-less constant's inherited enum method (Plain.m#0)", () => { + const calls = getRelationships(result, 'CALLS'); + const dispatch = calls.find((c) => c.source === 'callPlain' && c.target === 'm'); + expect(dispatch).toBeDefined(); + expect(dispatch!.rel.targetId).toBe('Method:src/Plain.java:Plain.m#0'); + }); +}); From d9437e6d7470756be04d5862665df69ae6a009c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 12:19:51 +0000 Subject: [PATCH 2/5] test(bench): rebaseline java scope-capture fingerprint for #2561 The enum-constant receiver-dispatch fix adds one @type-binding.* capture per enum constant, so the java scope-capture fingerprint shifts (85fc7af9 -> a822cef9). Pure capture-additive drift; no bench fixtures added; scaling 1.024 < 1.5 budget. Verified `measure.mjs --check` passes for all 14 languages. Co-Authored-By: Claude Opus 4.8 (1M context) --- gitnexus/bench/scope-capture/baselines.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index d1413db56..0c065f52b 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -89,7 +89,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": "85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537", + "fingerprint": "a822cef937cb65c544987a155b05c2715f6648cbd4be30d2ad36700c4e6846c9", "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.", @@ -98,7 +98,8 @@ "_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_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_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. Pure capture-additive (one extra type-binding match per enum_constant in the corpus); no bench fixtures added. Prior 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537 -> a822cef937cb65c544987a155b05c2715f6648cbd4be30d2ad36700c4e6846c9; scaling 1.024 < 1.5." }, "typescript": { "fingerprint": "3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4", From 70e0a7766c17fe056b3aec99a12b0cad86dc66aa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 13:51:31 +0000 Subject: [PATCH 3/5] =?UTF-8?q?fix(java):=20address=20#2561=20review=20?= =?UTF-8?q?=E2=80=94=20inherited-dispatch=20test=20+=20bodied=20fail-safe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gitnexus-review-agent findings on PR #2602: - MEDIUM: the bodied-constant MRO-to-host-enum path (a qualified call to an inherited, non-overridden enum method) was claimed in a comment but never tested. Add EnumConst.A.log() -> EnumConst.log#0, exercising E$N's @reference.inherits MRO arm end to end. - LOW: `bodiedName ?? hostEnum` conflated "body-less" with "name synthesis failed on a bodied constant" (reachable only on malformed/error-recovery trees), silently binding an overriding constant's receiver to the host enum — a wrong edge instead of no edge. Switch to `isBodied ? bodiedName : hostEnum` so a bodied constant binds ONLY to its E$N class, mirroring the object_creation_expression branch's skip-on-synthesis-failure. Verified output-neutral on the well-formed bench corpus. Rebaseline the java scope-capture fingerprint (a822cef9 -> d04298a9): the bench corpus IS test/fixtures/lang-resolution, so the new dispatchInherited fixture method shifts it (+6 capture groups); the logic change contributes nothing (confirmed by isolating the fixture-only fingerprint). java.test.ts 242 passed; measure.mjs --check PASS (14 languages); tsc/prettier/eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- gitnexus/bench/scope-capture/baselines.json | 4 +-- .../core/ingestion/languages/java/captures.ts | 31 ++++++++++++------- .../src/EnumConst.java | 4 +++ .../test/integration/resolvers/java.test.ts | 11 +++++++ 4 files changed, 36 insertions(+), 14 deletions(-) diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 0c065f52b..cab3765a4 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -89,7 +89,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": "a822cef937cb65c544987a155b05c2715f6648cbd4be30d2ad36700c4e6846c9", + "fingerprint": "d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686", "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.", @@ -99,7 +99,7 @@ "_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_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. Pure capture-additive (one extra type-binding match per enum_constant in the corpus); no bench fixtures added. Prior 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537 -> a822cef937cb65c544987a155b05c2715f6648cbd4be30d2ad36700c4e6846c9; scaling 1.024 < 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." }, "typescript": { "fingerprint": "3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4", diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index 2212c4355..62d9fe788 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -375,20 +375,19 @@ function synthesizeJavaAnonymousClassDeclarations(rootNode: SyntaxNode): Capture // through the ownership gate's MRO arm. for (const constant of rootNode.descendantsOfType('enum_constant')) { const hostEnum = javaEnclosingEnumNameOf(constant); + const bodyNode = constant.childForFieldName?.('body'); + const isBodied = bodyNode !== null && bodyNode !== undefined && bodyNode.type === 'class_body'; const bodiedName = synthesizeJavaAnonymousClassName(constant); - if (bodiedName !== undefined) { - const body = constant.childForFieldName?.('body'); - if (body !== null && body !== undefined && body.type === 'class_body') { + if (bodiedName !== undefined && isBodied) { + out.push({ + '@declaration.class': nodeToCapture('@declaration.class', bodyNode), + '@declaration.name': syntheticCapture('@declaration.name', bodyNode, bodiedName), + }); + if (hostEnum !== undefined) { out.push({ - '@declaration.class': nodeToCapture('@declaration.class', body), - '@declaration.name': syntheticCapture('@declaration.name', body, bodiedName), + '@reference.inherits': nodeToCapture('@reference.inherits', bodyNode), + '@reference.name': syntheticCapture('@reference.name', bodyNode, hostEnum), }); - if (hostEnum !== undefined) { - out.push({ - '@reference.inherits': nodeToCapture('@reference.inherits', body), - '@reference.name': syntheticCapture('@reference.name', body, hostEnum), - }); - } } } @@ -401,8 +400,16 @@ function synthesizeJavaAnonymousClassDeclarations(rootNode: SyntaxNode): Capture // 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 = bodiedName ?? hostEnum; + const constantType = isBodied ? bodiedName : hostEnum; if (constantNameNode !== null && constantNameNode !== undefined && constantType !== undefined) { out.push({ '@type-binding.annotation': nodeToCapture('@type-binding.annotation', constant), diff --git a/gitnexus/test/fixtures/lang-resolution/java-enum-constant-body/src/EnumConst.java b/gitnexus/test/fixtures/lang-resolution/java-enum-constant-body/src/EnumConst.java index 6342aa576..abfdeb902 100644 --- a/gitnexus/test/fixtures/lang-resolution/java-enum-constant-body/src/EnumConst.java +++ b/gitnexus/test/fixtures/lang-resolution/java-enum-constant-body/src/EnumConst.java @@ -25,4 +25,8 @@ class Unrelated { public void dispatchToConstant() { EnumConst.A.hook(); } + + public void dispatchInherited() { + EnumConst.A.log(); + } } diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index 1eeb35e04..bfaa00196 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -3077,6 +3077,17 @@ describe('Java enum-constant receiver dispatch (#2561)', () => { expect(dispatch!.rel.targetId).toBe('Method:src/EnumConst.java:EnumConst$1.hook#0'); }); + it("resolves EnumConst.A.log() to the host enum's inherited method via E$N's MRO (EnumConst.log#0)", () => { + // A's body overrides hook() but NOT log(); log() lives only on the enum. + // The bodied constant's receiver binds to EnumConst$1, whose MRO includes + // EnumConst (via @reference.inherits), so the qualified call reaches the + // host enum's own method — the inherited-dispatch capability the fix enables. + const calls = getRelationships(result, 'CALLS'); + const dispatch = calls.find((c) => c.source === 'dispatchInherited' && c.target === 'log'); + expect(dispatch).toBeDefined(); + expect(dispatch!.rel.targetId).toBe('Method:src/EnumConst.java:EnumConst.log#0'); + }); + it("resolves Plain.A.m() to the body-less constant's inherited enum method (Plain.m#0)", () => { const calls = getRelationships(result, 'CALLS'); const dispatch = calls.find((c) => c.source === 'callPlain' && c.target === 'm'); From 5b906c318920b8cb5ce0cb599cdb4c8fb2ac3c02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Tue, 21 Jul 2026 15:39:56 +0100 Subject: [PATCH 4/5] fix(eval): bind the resolved node binary into the sandbox, not a hardcoded host path (#2607) Surfaced by the first real workflow_dispatch run on the self-hosted runner (https://github.com/abhigyanpatwari/GitNexus/actions/runs/29836411744): every session failed with error_kind infra-error, error_detail "bwrap: execvp /usr/local/bin/node: No such file or directory", tripping the outage-streak breaker after 5 consecutive failures. sanitized_graph.py and runner_sessions.py invoke the sandboxed graph CLI at the fixed path /usr/local/bin/node. _runtime_mount_args only binds /usr, /bin, /lib, /lib64 wholesale, so that path resolves correctly when node happens to live under /usr/local/bin on the host -- true on GitHub-hosted runner images, but not on a self-hosted runner, where actions/setup-node installs into its own tool-cache directory instead (outside all four bound trees, so invisible to the sandbox regardless of what PATH says on the host). Fix lives entirely in the mount construction: resolve `node` via shutil.which (correctly picks up wherever actions/setup-node put it, since its tool-cache dir is already on PATH by the time this runs) and bind it read-only to the same fixed sandbox path the two call sites already expect. Neither call site needed to change. Backward compatible with GitHub-hosted runners, where this resolves to the same path and binds a harmless no-op self-mount. Co-authored-by: Claude Sonnet 5 --- eval/tests/test_proposer_sandbox.py | 26 ++++++++++++++++++++++++- eval/workflow_bench/proposer_sandbox.py | 13 ++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/eval/tests/test_proposer_sandbox.py b/eval/tests/test_proposer_sandbox.py index 70f4fe76b..22b4ce9b0 100644 --- a/eval/tests/test_proposer_sandbox.py +++ b/eval/tests/test_proposer_sandbox.py @@ -24,6 +24,7 @@ from workflow_bench.proposer_sandbox import ( SANDBOX_USER_SKILLS, ReadOnlyMount, SandboxError, + _runtime_mount_args, build_claude_settings, build_sandbox_environment, prepare_sandbox, @@ -191,6 +192,30 @@ 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_wherever_path_puts_it(monkeypatch) -> None: + # sanitized_graph.py and runner_sessions.py invoke the sandboxed graph CLI + # at the fixed path /usr/local/bin/node. That's only covered by the /usr + # bind when node happens to live under /usr/local/bin on the host -- true + # on GitHub-hosted runner images, but not on a self-hosted runner where + # actions/setup-node installs into its own tool-cache directory instead + # (observed empirically: "bwrap: execvp /usr/local/bin/node: No such file + # or directory" on a fresh self-hosted runner). + 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] == "/usr/local/bin/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 "/usr/local/bin/node" not in args + + 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" @@ -772,4 +797,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" - diff --git a/eval/workflow_bench/proposer_sandbox.py b/eval/workflow_bench/proposer_sandbox.py index 731f11ad2..7910bd87e 100644 --- a/eval/workflow_bench/proposer_sandbox.py +++ b/eval/workflow_bench/proposer_sandbox.py @@ -354,6 +354,17 @@ def _runtime_mount_args() -> list[str]: path = Path(raw) if path.exists(): args += ["--ro-bind", raw, raw] + # sanitized_graph.py and runner_sessions.py invoke the sandboxed graph + # CLI at the fixed path /usr/local/bin/node. That's only covered by the + # /usr bind above when node happens to live under /usr/local/bin on the + # host -- true on GitHub-hosted runner images, but not on a self-hosted + # runner where actions/setup-node installs into its own tool-cache + # directory instead. Bind whatever `node` actually resolves to on PATH + # to that same fixed sandbox path so both call sites keep working + # regardless of where the host actually put it. + node_bin = shutil.which("node") + if node_bin: + args += ["--ro-bind", node_bin, "/usr/local/bin/node"] for raw in ( "/etc/ssl", "/etc/hosts", @@ -398,7 +409,7 @@ def _create_python3_wrapper(private_root: Path) -> Path: """ wrapper = private_root / "python3" - wrapper.write_text("#!/bin/bash\nset -eu\nexec /usr/bin/python3 \"$@\"\n") + wrapper.write_text('#!/bin/bash\nset -eu\nexec /usr/bin/python3 "$@"\n') wrapper.chmod(0o500) return wrapper From bba25b2103f70fef53d0dc16a90e7479ca75046b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Tue, 21 Jul 2026 16:09:45 +0100 Subject: [PATCH 5/5] fix(eval): bind resolved node to a fresh sandbox path (corrects #2607) (#2609) * fix(eval): bind the resolved node to a fresh sandbox path, not one under /usr #2607 bound the resolved `node` to /usr/local/bin/node, but that path lives inside the /usr tree that _runtime_mount_args already read-only-binds wholesale. The second real workflow_dispatch run on the self-hosted runner (https://github.com/abhigyanpatwari/GitNexus/actions/runs/29840270554) failed immediately in the bubblewrap preflight: "bwrap: Can't create file at /usr/local/bin/node: Read-only file system" -- bwrap can't create a new mount-point file inside a tree it already bound read-only when the real path doesn't already exist there on the host, which is exactly the self-hosted case this bind exists to fix. Introduces SANDBOX_NODE (/opt/claude/node), a fresh path outside every tree _runtime_mount_args binds, following the same pattern SANDBOX_CLAUDE and SANDBOX_PYTHON3 already use. Updates the two real call sites (sanitized_graph.py, runner_sessions.py) to use the constant instead of the hardcoded literal, so the fix can't drift out of sync with itself again, and re-exports it from runner.py alongside the other SANDBOX_* names for the real-bwrap tests that reference it directly. Adds a real-bwrap test (gated behind GITNEXUS_REQUIRE_BWRAP_CANARY, same as the existing ones) that copies a real node binary to a path outside every bound tree and actually launches bwrap against it -- an argv-construction test alone can't catch a bwrap-level "Read-only file system" error, only a real invocation can, and that's exactly the gap that let #2607's version of this fix through review looking correct. Co-Authored-By: Claude Sonnet 5 * fix(eval): don't let the new real-bwrap test's node-mock break bwrap's own resolution CI caught this immediately: the new test_real_bubblewrap_runs_node_from_outside_the_bound_trees monkeypatched shutil.which to return None for anything but "node", but prepare_sandbox's own bwrap/claude resolution (_resolve_executable) goes through shutil.which too -- so the test broke bwrap discovery before the sandbox it's supposed to exercise could even be built ("SandboxError: required executable is unavailable: bwrap"). Delegate to the real shutil.which for every other name instead of blanket-returning None. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- eval/tests/test_proposer_sandbox.py | 63 ++++++++++++++++++---- eval/tests/test_workflow_bench_sessions.py | 6 +-- eval/workflow_bench/proposer_sandbox.py | 20 ++++--- eval/workflow_bench/runner.py | 1 + eval/workflow_bench/runner_sessions.py | 5 +- eval/workflow_bench/sanitized_graph.py | 3 +- 6 files changed, 76 insertions(+), 22 deletions(-) diff --git a/eval/tests/test_proposer_sandbox.py b/eval/tests/test_proposer_sandbox.py index 22b4ce9b0..2c4c37259 100644 --- a/eval/tests/test_proposer_sandbox.py +++ b/eval/tests/test_proposer_sandbox.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import os +import shutil import stat import subprocess import sys @@ -19,6 +20,7 @@ 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_PYTHON3, SANDBOX_SHELL_PREFIX, SANDBOX_USER_SKILLS, @@ -192,14 +194,19 @@ 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_wherever_path_puts_it(monkeypatch) -> None: +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 - # at the fixed path /usr/local/bin/node. That's only covered by the /usr - # bind when node happens to live under /usr/local/bin on the host -- true - # on GitHub-hosted runner images, but not on a self-hosted runner where - # actions/setup-node installs into its own tool-cache directory instead - # (observed empirically: "bwrap: execvp /usr/local/bin/node: No such file - # or directory" on a fresh self-hosted runner). + # 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, @@ -207,13 +214,14 @@ def test_runtime_mounts_bind_the_resolved_node_wherever_path_puts_it(monkeypatch 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] == "/usr/local/bin/node" + 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_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 "/usr/local/bin/node" not in args + assert SANDBOX_NODE not in args def test_stricter_prefix_freezes_evaluated_skills_and_can_unshare_network(tmp_path: Path) -> None: @@ -244,6 +252,43 @@ 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", diff --git a/eval/tests/test_workflow_bench_sessions.py b/eval/tests/test_workflow_bench_sessions.py index e03104ed3..c10afa401 100644 --- a/eval/tests/test_workflow_bench_sessions.py +++ b/eval/tests/test_workflow_bench_sessions.py @@ -438,11 +438,11 @@ 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 @@ -451,7 +451,7 @@ def test_real_bubblewrap_runtime_mount_imports_cli_without_exposing_checkout(tmp # resolve-analyze-cmd.cjs. Require the compiled analyze module # directly so this canary actually exercises that chain. analyze_imported = sandbox.run( - ["/usr/local/bin/node", "-e", f"require('{runner.SANDBOX_GITNEXUS}/dist/cli/analyze.js')"], + [runner.SANDBOX_NODE, "-e", f"require('{runner.SANDBOX_GITNEXUS}/dist/cli/analyze.js')"], timeout=10, ) diff --git a/eval/workflow_bench/proposer_sandbox.py b/eval/workflow_bench/proposer_sandbox.py index 7910bd87e..2884eb067 100644 --- a/eval/workflow_bench/proposer_sandbox.py +++ b/eval/workflow_bench/proposer_sandbox.py @@ -27,6 +27,7 @@ SANDBOX_TMP = "/tmp" SANDBOX_CLAUDE = "/opt/claude/claude" SANDBOX_SHELL_PREFIX = "/opt/claude/shell-prefix" SANDBOX_PYTHON3 = "/opt/claude/python3" +SANDBOX_NODE = "/opt/claude/node" SANDBOX_PATH = "/opt/claude:/usr/local/bin:/usr/bin:/bin" SANDBOX_GITNEXUS = "/opt/gitnexus" SANDBOX_GITNEXUS_SHARED = "/opt/gitnexus-shared" @@ -355,16 +356,19 @@ def _runtime_mount_args() -> list[str]: if path.exists(): args += ["--ro-bind", raw, raw] # sanitized_graph.py and runner_sessions.py invoke the sandboxed graph - # CLI at the fixed path /usr/local/bin/node. That's only covered by the - # /usr bind above when node happens to live under /usr/local/bin on the - # host -- true on GitHub-hosted runner images, but not on a self-hosted - # runner where actions/setup-node installs into its own tool-cache - # directory instead. Bind whatever `node` actually resolves to on PATH - # to that same fixed sandbox path so both call sites keep working - # regardless of where the host actually put it. + # 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, "/usr/local/bin/node"] + args += ["--ro-bind", node_bin, SANDBOX_NODE] for raw in ( "/etc/ssl", "/etc/hosts", diff --git a/eval/workflow_bench/runner.py b/eval/workflow_bench/runner.py index b502c8023..d67934cd0 100644 --- a/eval/workflow_bench/runner.py +++ b/eval/workflow_bench/runner.py @@ -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, diff --git a/eval/workflow_bench/runner_sessions.py b/eval/workflow_bench/runner_sessions.py index 53e7335ee..cc1708572 100644 --- a/eval/workflow_bench/runner_sessions.py +++ b/eval/workflow_bench/runner_sessions.py @@ -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", ], diff --git a/eval/workflow_bench/sanitized_graph.py b/eval/workflow_bench/sanitized_graph.py index d0e9418dd..23339b1c7 100644 --- a/eval/workflow_bench/sanitized_graph.py +++ b/eval/workflow_bench/sanitized_graph.py @@ -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, ]