diff --git a/bazel-jdt-bridge/java-bridge/bnd.bnd b/bazel-jdt-bridge/java-bridge/bnd.bnd
index 2778bd6..33db071 100644
--- a/bazel-jdt-bridge/java-bridge/bnd.bnd
+++ b/bazel-jdt-bridge/java-bridge/bnd.bnd
@@ -12,8 +12,10 @@ Import-Package: \
org.eclipse.core.runtime.jobs, \
org.eclipse.jdt.launching, \
org.osgi.framework, \
+ org.osgi.framework.hooks.weaving, \
*
Export-Package: com.bazel.jdt
+Private-Package: org.objectweb.asm,org.objectweb.asm.signature
Bundle-NativeCode: \
native/linux-x86_64/libbazel_jdt_core.so; osname=Linux; processor=x86_64, \
native/linux-aarch64/libbazel_jdt_core.so; osname=Linux; processor=aarch64, \
diff --git a/bazel-jdt-bridge/java-bridge/pom.xml b/bazel-jdt-bridge/java-bridge/pom.xml
index fcb8cdc..12b109e 100644
--- a/bazel-jdt-bridge/java-bridge/pom.xml
+++ b/bazel-jdt-bridge/java-bridge/pom.xml
@@ -50,6 +50,18 @@
3.23.0
provided
+
+ org.osgi
+ osgi.core
+ 8.0.0
+ provided
+
+
+ org.ow2.asm
+ asm
+ 9.7
+ compile
+
junit
junit
diff --git a/bazel-jdt-bridge/java-bridge/src/main/java/com/bazel/jdt/BazelActivator.java b/bazel-jdt-bridge/java-bridge/src/main/java/com/bazel/jdt/BazelActivator.java
index fea0252..350a17a 100644
--- a/bazel-jdt-bridge/java-bridge/src/main/java/com/bazel/jdt/BazelActivator.java
+++ b/bazel-jdt-bridge/java-bridge/src/main/java/com/bazel/jdt/BazelActivator.java
@@ -19,6 +19,8 @@
import org.eclipse.core.runtime.Status;
import org.osgi.framework.BundleActivator;
import org.osgi.framework.BundleContext;
+import org.osgi.framework.ServiceRegistration;
+import org.osgi.framework.hooks.weaving.WeavingHook;
public class BazelActivator implements BundleActivator {
private static final ILog LOG = Platform.getLog(BazelActivator.class);
@@ -26,12 +28,16 @@ public class BazelActivator implements BundleActivator {
Pattern.compile(".+_[0-9a-f]{4,}$");
private IResourceChangeListener invisibleProjectListener;
+ private ServiceRegistration weavingHookRegistration;
@Override
public void start(BundleContext context) throws Exception {
LOG.log(new Status(IStatus.INFO, "com.bazel.jdt",
"Bazel JDT Bridge bundle starting"));
+ weavingHookRegistration = context.registerService(
+ WeavingHook.class, new JDTUtilsPatcher(), null);
+
invisibleProjectListener = this::checkForInvisibleProjects;
ResourcesPlugin.getWorkspace().addResourceChangeListener(
invisibleProjectListener, IResourceChangeEvent.POST_CHANGE);
@@ -39,6 +45,10 @@ public void start(BundleContext context) throws Exception {
@Override
public void stop(BundleContext context) throws Exception {
+ if (weavingHookRegistration != null) {
+ weavingHookRegistration.unregister();
+ weavingHookRegistration = null;
+ }
if (invisibleProjectListener != null) {
ResourcesPlugin.getWorkspace().removeResourceChangeListener(invisibleProjectListener);
invisibleProjectListener = null;
diff --git a/bazel-jdt-bridge/java-bridge/src/main/java/com/bazel/jdt/JDTUtilsPatcher.java b/bazel-jdt-bridge/java-bridge/src/main/java/com/bazel/jdt/JDTUtilsPatcher.java
new file mode 100644
index 0000000..5c88d43
--- /dev/null
+++ b/bazel-jdt-bridge/java-bridge/src/main/java/com/bazel/jdt/JDTUtilsPatcher.java
@@ -0,0 +1,144 @@
+package com.bazel.jdt;
+
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.Label;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.osgi.framework.hooks.weaving.WeavingHook;
+import org.osgi.framework.hooks.weaving.WovenClass;
+
+public class JDTUtilsPatcher implements WeavingHook, Opcodes {
+
+ private static final Logger LOG = Logger.getLogger(JDTUtilsPatcher.class.getName());
+
+ static final String JDTUTILS_INTERNAL_NAME = "org/eclipse/jdt/ls/core/internal/JDTUtils";
+ static final String TARGET_METHOD = "searchDecompiledSources";
+ private static final String TARGET_BUNDLE = "org.eclipse.jdt.ls.core";
+ private static final String NPE_INTERNAL_NAME = "java/lang/NullPointerException";
+ private static final String COLLECTIONS_INTERNAL_NAME = "java/util/Collections";
+
+ @Override
+ public void weave(WovenClass wovenClass) {
+ String bundleName = wovenClass.getBundleWiring().getBundle().getSymbolicName();
+ if (!TARGET_BUNDLE.equals(bundleName)) {
+ return;
+ }
+
+ byte[] original = wovenClass.getBytes();
+ if (!containsTargetCallSite(original)) {
+ return;
+ }
+
+ try {
+ byte[] patched = patchCallerClass(original);
+ if (patched != null) {
+ wovenClass.setBytes(patched);
+ LOG.info("Patched " + wovenClass.getClassName()
+ + ": wrapped searchDecompiledSources call site with NPE guard");
+ }
+ } catch (Exception e) {
+ LOG.log(Level.WARNING,
+ "Failed to patch " + wovenClass.getClassName() + ", leaving class unmodified", e);
+ }
+ }
+
+ static boolean containsTargetCallSite(byte[] classBytes) {
+ boolean[] found = {false};
+ ClassReader reader = new ClassReader(classBytes);
+ reader.accept(new ClassVisitor(ASM9) {
+ @Override
+ public MethodVisitor visitMethod(int access, String name, String descriptor,
+ String signature, String[] exceptions) {
+ if (found[0]) return null;
+ return new MethodVisitor(ASM9) {
+ @Override
+ public void visitMethodInsn(int opcode, String owner, String mName,
+ String mDescriptor, boolean isInterface) {
+ if (opcode == INVOKESTATIC
+ && JDTUTILS_INTERNAL_NAME.equals(owner)
+ && TARGET_METHOD.equals(mName)) {
+ found[0] = true;
+ }
+ }
+ };
+ }
+ }, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
+ return found[0];
+ }
+
+ byte[] patchCallerClass(byte[] classBytes) {
+ ClassReader reader = new ClassReader(classBytes);
+ ClassWriter writer = new SafeClassWriter(reader, ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+ boolean[] patched = {false};
+
+ ClassVisitor visitor = new ClassVisitor(ASM9, writer) {
+ @Override
+ public MethodVisitor visitMethod(int access, String name, String descriptor,
+ String signature, String[] exceptions) {
+ MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
+ return new CallSiteWrappingVisitor(mv, patched);
+ }
+ };
+
+ reader.accept(visitor, 0);
+ return patched[0] ? writer.toByteArray() : null;
+ }
+
+ static class CallSiteWrappingVisitor extends MethodVisitor {
+ private final boolean[] patched;
+
+ CallSiteWrappingVisitor(MethodVisitor mv, boolean[] patched) {
+ super(ASM9, mv);
+ this.patched = patched;
+ }
+
+ @Override
+ public void visitMethodInsn(int opcode, String owner, String name,
+ String descriptor, boolean isInterface) {
+ if (opcode == INVOKESTATIC
+ && JDTUTILS_INTERNAL_NAME.equals(owner)
+ && TARGET_METHOD.equals(name)) {
+
+ Label tryStart = new Label();
+ Label tryEnd = new Label();
+ Label catchHandler = new Label();
+ Label afterCatch = new Label();
+
+ mv.visitTryCatchBlock(tryStart, tryEnd, catchHandler, NPE_INTERNAL_NAME);
+ mv.visitLabel(tryStart);
+ super.visitMethodInsn(opcode, owner, name, descriptor, isInterface);
+ mv.visitLabel(tryEnd);
+ mv.visitJumpInsn(GOTO, afterCatch);
+ mv.visitLabel(catchHandler);
+ mv.visitInsn(POP);
+ mv.visitMethodInsn(INVOKESTATIC, COLLECTIONS_INTERNAL_NAME,
+ "emptyList", "()Ljava/util/List;", false);
+ mv.visitLabel(afterCatch);
+
+ patched[0] = true;
+ return;
+ }
+ super.visitMethodInsn(opcode, owner, name, descriptor, isInterface);
+ }
+ }
+
+ private static class SafeClassWriter extends ClassWriter {
+ SafeClassWriter(ClassReader classReader, int flags) {
+ super(classReader, flags);
+ }
+
+ @Override
+ protected String getCommonSuperClass(String type1, String type2) {
+ try {
+ return super.getCommonSuperClass(type1, type2);
+ } catch (Exception e) {
+ return "java/lang/Object";
+ }
+ }
+ }
+}
diff --git a/bazel-jdt-bridge/java-bridge/src/test/java/com/bazel/jdt/JDTUtilsPatcherTest.java b/bazel-jdt-bridge/java-bridge/src/test/java/com/bazel/jdt/JDTUtilsPatcherTest.java
new file mode 100644
index 0000000..7af0ad8
--- /dev/null
+++ b/bazel-jdt-bridge/java-bridge/src/test/java/com/bazel/jdt/JDTUtilsPatcherTest.java
@@ -0,0 +1,142 @@
+package com.bazel.jdt;
+
+import static org.junit.Assert.*;
+
+import java.lang.reflect.Method;
+import java.util.List;
+
+import org.junit.Test;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+
+public class JDTUtilsPatcherTest implements Opcodes {
+
+ private static final String SIMPLE_DESC = "()Ljava/util/List;";
+
+ @Test
+ public void patchCallerClass_wrapsCallSite() {
+ byte[] caller = buildCallerClass();
+ JDTUtilsPatcher patcher = new JDTUtilsPatcher();
+ byte[] patched = patcher.patchCallerClass(caller);
+
+ assertNotNull("should patch class that calls searchDecompiledSources", patched);
+ assertFalse("patched bytes should differ",
+ java.util.Arrays.equals(caller, patched));
+ }
+
+ @Test
+ public void patchCallerClass_skipsClassWithNoCallSite() {
+ byte[] unrelated = buildUnrelatedClass();
+ JDTUtilsPatcher patcher = new JDTUtilsPatcher();
+ byte[] patched = patcher.patchCallerClass(unrelated);
+
+ assertNull("should return null for class without searchDecompiledSources call", patched);
+ }
+
+ @Test
+ public void patchCallerClass_skipsWhenOwnerDiffers() {
+ byte[] wrongOwner = buildCallerWithDifferentOwner();
+ JDTUtilsPatcher patcher = new JDTUtilsPatcher();
+ byte[] patched = patcher.patchCallerClass(wrongOwner);
+
+ assertNull("should not patch calls to other classes", patched);
+ }
+
+ @Test
+ public void patchedCallSite_returnsEmptyListInsteadOfNPE() throws Exception {
+ byte[] fakeJDTUtils = buildFakeJDTUtils();
+ byte[] caller = buildCallerClass();
+
+ JDTUtilsPatcher patcher = new JDTUtilsPatcher();
+ byte[] patchedCaller = patcher.patchCallerClass(caller);
+ assertNotNull(patchedCaller);
+
+ TestClassLoader loader = new TestClassLoader();
+ loader.define("org.eclipse.jdt.ls.core.internal.JDTUtils", fakeJDTUtils);
+ Class> callerClass = loader.define("TestCaller", patchedCaller);
+
+ Method method = callerClass.getMethod("callSearch");
+ Object result = method.invoke(null);
+
+ assertNotNull("patched call site should return non-null", result);
+ assertTrue("should return a List", result instanceof List);
+ assertTrue("should return empty list", ((List>) result).isEmpty());
+ }
+
+ private byte[] buildCallerClass() {
+ ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+ cw.visit(V17, ACC_PUBLIC, "TestCaller", null, "java/lang/Object", null);
+
+ MethodVisitor mv = cw.visitMethod(ACC_PUBLIC | ACC_STATIC,
+ "callSearch", SIMPLE_DESC, null, null);
+ mv.visitCode();
+ mv.visitMethodInsn(INVOKESTATIC, JDTUtilsPatcher.JDTUTILS_INTERNAL_NAME,
+ JDTUtilsPatcher.TARGET_METHOD, SIMPLE_DESC, false);
+ mv.visitInsn(ARETURN);
+ mv.visitMaxs(1, 0);
+ mv.visitEnd();
+
+ cw.visitEnd();
+ return cw.toByteArray();
+ }
+
+ private byte[] buildFakeJDTUtils() {
+ ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+ cw.visit(V17, ACC_PUBLIC,
+ JDTUtilsPatcher.JDTUTILS_INTERNAL_NAME, null, "java/lang/Object", null);
+
+ MethodVisitor mv = cw.visitMethod(ACC_PUBLIC | ACC_STATIC,
+ JDTUtilsPatcher.TARGET_METHOD, SIMPLE_DESC, null, null);
+ mv.visitCode();
+ mv.visitTypeInsn(NEW, "java/lang/NullPointerException");
+ mv.visitInsn(DUP);
+ mv.visitLdcInsn("occurrences is null");
+ mv.visitMethodInsn(INVOKESPECIAL, "java/lang/NullPointerException",
+ "", "(Ljava/lang/String;)V", false);
+ mv.visitInsn(ATHROW);
+ mv.visitMaxs(3, 0);
+ mv.visitEnd();
+
+ cw.visitEnd();
+ return cw.toByteArray();
+ }
+
+ private byte[] buildUnrelatedClass() {
+ ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+ cw.visit(V17, ACC_PUBLIC, "UnrelatedClass", null, "java/lang/Object", null);
+
+ MethodVisitor mv = cw.visitMethod(ACC_PUBLIC | ACC_STATIC,
+ "doSomething", "()V", null, null);
+ mv.visitCode();
+ mv.visitInsn(RETURN);
+ mv.visitMaxs(0, 0);
+ mv.visitEnd();
+
+ cw.visitEnd();
+ return cw.toByteArray();
+ }
+
+ private byte[] buildCallerWithDifferentOwner() {
+ ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+ cw.visit(V17, ACC_PUBLIC, "WrongOwnerCaller", null, "java/lang/Object", null);
+
+ MethodVisitor mv = cw.visitMethod(ACC_PUBLIC | ACC_STATIC,
+ "callSearch", SIMPLE_DESC, null, null);
+ mv.visitCode();
+ mv.visitMethodInsn(INVOKESTATIC, "com/other/Utils",
+ JDTUtilsPatcher.TARGET_METHOD, SIMPLE_DESC, false);
+ mv.visitInsn(ARETURN);
+ mv.visitMaxs(1, 0);
+ mv.visitEnd();
+
+ cw.visitEnd();
+ return cw.toByteArray();
+ }
+
+ private static class TestClassLoader extends ClassLoader {
+ Class> define(String name, byte[] bytes) {
+ return defineClass(name, bytes, 0, bytes.length);
+ }
+ }
+}
diff --git a/docs/weaving-hook-npe-fix.md b/docs/weaving-hook-npe-fix.md
new file mode 100644
index 0000000..300f4bb
--- /dev/null
+++ b/docs/weaving-hook-npe-fix.md
@@ -0,0 +1,202 @@
+# WeavingHook NPE Fix — JDTUtils.searchDecompiledSources
+
+> Implemented: 2026-05-20
+
+---
+
+## 1. Problem
+
+JDT.LS has an unfixed bug ([eclipse-jdtls#3083](https://github.com/eclipse-jdtls/eclipse.jdt.ls/issues/3083)) in `JDTUtils.searchDecompiledSources()`. The `MethodInvocation` branch is missing a `finder.initialize()` call:
+
+```java
+// JDTUtils.java (JDT.LS 1.58.0), around line 1830
+} else if (node instanceof MethodInvocation mi) {
+ SimpleName name = mi.getName();
+ // BUG: missing finder.initialize(unit, name);
+}
+OccurrenceLocation[] occurrences = finder.getOccurrences();
+for (OccurrenceLocation occurrence : occurrences) { // NPE: occurrences is null
+```
+
+This causes a `NullPointerException` that crashes "Find References" and "Go to Implementation" requests entirely when the search hits a classpath JAR without source attachment.
+
+**Affected callers:**
+- `ReferencesHandler$1.acceptSearchMatch()` — "Find References"
+- `NavigateToDefinitionHandler` — "Go to Definition"
+
+Both enter the `searchDecompiledSources` code path when `classFile.getSourceRange() == null` (no source attachment).
+
+## 2. Solution Overview
+
+We use the OSGi standard **WeavingHook** API to patch the bytecode of classes that **call** `searchDecompiledSources`, wrapping the specific call site in a `try-catch(NullPointerException)` that returns `Collections.emptyList()` on catch.
+
+```
+┌──────────── Before (crashes) ──────────────────────┐
+│ │
+│ ReferencesHandler$1.acceptSearchMatch() │
+│ │ │
+│ └─ JDTUtils.searchDecompiledSources(...) │
+│ └─ NPE thrown → entire request fails │
+│ │
+├──────────── After (graceful degradation) ───────────┤
+│ │
+│ ReferencesHandler$1.acceptSearchMatch() │
+│ │ │
+│ ├─ try { │
+│ │ JDTUtils.searchDecompiledSources(...) │
+│ │ } catch (NullPointerException) { │
+│ │ return Collections.emptyList(); │
+│ │ } │
+│ │ │
+│ └─ Request completes with partial results │
+│ │
+└─────────────────────────────────────────────────────┘
+```
+
+## 3. Why Patch Callers, Not JDTUtils Itself
+
+The initial approach was to patch `JDTUtils.searchDecompiledSources()` directly — wrapping the entire method body in try-catch. This failed because of class loading timing:
+
+```
+Timeline:
+ 19:07:53 JavaLanguageServerPlugin started → JDTUtils class loaded
+ 19:07:58 Our bundle starts → WeavingHook registered (too late for JDTUtils)
+ 19:09:39 NPE fires in searchDecompiledSources (unpatched)
+```
+
+**WeavingHook only intercepts first-time class loading** (`defineClass`). Since `JDTUtils` is a core utility class loaded during JDT.LS initialization (before our bundle is even installed), the hook never fires for it.
+
+**Handler classes are lazily loaded** — `ReferencesHandler`, `NavigateToDefinitionHandler`, and their inner classes are only loaded when the user first triggers the corresponding LSP request, well after our WeavingHook is registered.
+
+## 4. Implementation Details
+
+### 4.1 Files Changed
+
+| File | Change |
+|------|--------|
+| `java-bridge/pom.xml` | Added `org.ow2.asm:asm:9.7` (compile) and `org.osgi:osgi.core:8.0.0` (provided) |
+| `java-bridge/bnd.bnd` | Added `org.osgi.framework.hooks.weaving` to Import-Package, `Private-Package` for ASM embedding |
+| `java-bridge/src/main/java/com/bazel/jdt/JDTUtilsPatcher.java` | **New** — WeavingHook implementation |
+| `java-bridge/src/main/java/com/bazel/jdt/BazelActivator.java` | Register/unregister WeavingHook service |
+| `java-bridge/src/test/java/com/bazel/jdt/JDTUtilsPatcherTest.java` | **New** — 4 unit tests |
+
+### 4.2 JDTUtilsPatcher Architecture
+
+```
+┌────────────────── weave(WovenClass) ──────────────────────┐
+│ │
+│ 1. Bundle filter │
+│ └─ wovenClass.bundle != "org.eclipse.jdt.ls.core"? │
+│ → skip (most classes never reach step 2) │
+│ │
+│ 2. Pre-scan (read-only, no COMPUTE_FRAMES) │
+│ └─ containsTargetCallSite(bytes): │
+│ ClassReader + bare MethodVisitor scans for │
+│ INVOKESTATIC JDTUtils.searchDecompiledSources │
+│ using SKIP_DEBUG | SKIP_FRAMES flags │
+│ → No call site found? skip │
+│ │
+│ 3. Patch (only reached for classes with the call site) │
+│ └─ patchCallerClass(bytes): │
+│ ClassReader → ClassVisitor → CallSiteWrappingVisitor │
+│ → SafeClassWriter (COMPUTE_FRAMES + COMPUTE_MAXS) │
+│ │
+│ CallSiteWrappingVisitor.visitMethodInsn(): │
+│ When seeing INVOKESTATIC to searchDecompiledSources│
+│ → Emit: try { originalCall } catch (NPE) { │
+│ return Collections.emptyList(); │
+│ } │
+│ │
+│ 4. Apply │
+│ └─ wovenClass.setBytes(patchedBytes) │
+│ │
+└────────────────────────────────────────────────────────────┘
+```
+
+### 4.3 The Pre-scan Optimization
+
+Without pre-scanning, `patchCallerClass()` runs `ClassWriter(COMPUTE_FRAMES)` on every class from the JDT.LS bundle. `COMPUTE_FRAMES` calls `getCommonSuperClass()` internally, which uses `Class.forName()` — this fails in OSGi when the target class references types not visible to our bundle's classloader (e.g., `org.gradle.tooling.model.GradleProject`, `org.eclipse.lsp4j.CompletionItem`).
+
+The pre-scan uses a bare `ClassVisitor` with no `ClassWriter`, so no frame computation occurs. Only classes confirmed to contain the call site proceed to the full transformation.
+
+### 4.4 SafeClassWriter
+
+Even with pre-scanning, the classes that DO contain the call site may reference types outside our bundle's visibility. `SafeClassWriter` overrides `getCommonSuperClass()` to fall back to `"java/lang/Object"` when type resolution fails, instead of throwing `TypeNotPresentException`.
+
+### 4.5 BazelActivator Integration
+
+```java
+// In start():
+weavingHookRegistration = context.registerService(
+ WeavingHook.class, new JDTUtilsPatcher(), null);
+
+// In stop():
+if (weavingHookRegistration != null) {
+ weavingHookRegistration.unregister();
+ weavingHookRegistration = null;
+}
+```
+
+The hook is registered during `BazelActivator.start()`, which executes during `BundleUtils.loadBundles()`. At this point, handler classes have not yet been loaded (they are loaded on first LSP request), so the WeavingHook fires in time.
+
+## 5. Degradation Strategy
+
+```
+┌────────────────── Degradation Layers ─────────────────────┐
+│ │
+│ Layer 1: WeavingHook registration │
+│ ├─ Success → call site patched when handler loads │
+│ └─ Failure → bundle still works, NPE persists (current │
+│ behavior, no regression) │
+│ │
+│ Layer 2: Pre-scan + patch │
+│ ├─ Call site found → patch applied │
+│ └─ Not found → JDT.LS version changed method name, │
+│ no modification (silent skip) │
+│ │
+│ Layer 3: Runtime catch │
+│ ├─ NPE thrown → caught, returns emptyList() │
+│ │ "Find References" shows partial results │
+│ └─ No NPE → method returns normally (no overhead) │
+│ │
+│ If upstream fixes the bug: patch becomes a no-op │
+│ (try-catch still safe, just never triggers) │
+│ │
+└────────────────────────────────────────────────────────────┘
+```
+
+## 6. Version Compatibility
+
+The patch is designed to be robust across JDT.LS versions:
+
+- **Method matching**: Only matches by owner class internal name (`org/eclipse/jdt/ls/core/internal/JDTUtils`) and method name (`searchDecompiledSources`). Does **not** match by descriptor, so signature changes (new parameters, different return type) are tolerated.
+- **Call site wrapping**: Wraps the specific `INVOKESTATIC` instruction, not the entire method. Works regardless of the caller's method structure.
+- **Silent skip**: If `searchDecompiledSources` is renamed or removed in a future version, the pre-scan finds no call sites and the hook is a no-op.
+- **Upstream fix**: If the NPE bug is fixed upstream, the try-catch still compiles and loads correctly — it simply never catches anything.
+
+## 7. Testing
+
+Four unit tests in `JDTUtilsPatcherTest.java`:
+
+| Test | Verifies |
+|------|----------|
+| `patchCallerClass_wrapsCallSite` | ASM transformation produces different bytecode for a class calling `searchDecompiledSources` |
+| `patchCallerClass_skipsClassWithNoCallSite` | Returns null (no modification) for unrelated classes |
+| `patchCallerClass_skipsWhenOwnerDiffers` | Does not patch calls to methods with the same name on different classes |
+| `patchedCallSite_returnsEmptyListInsteadOfNPE` | End-to-end: loads a fake JDTUtils that throws NPE, patches the caller, invokes it, and verifies `Collections.emptyList()` is returned |
+
+Run tests:
+```bash
+cd java-bridge && mvn test -Dtest=JDTUtilsPatcherTest
+```
+
+## 8. Approaches Considered and Rejected
+
+| Approach | Why Rejected |
+|----------|-------------|
+| **Stub source JAR generation** | Persistent edge cases with inner classes, anonymous classes, synthetic classes. Reverted after multiple iterations. |
+| **OSGi Fragment** | Equinox `ClasspathManager` searches host classpath entries before fragment entries — fragments cannot replace existing host classes. |
+| **Patch JDTUtils directly** | `JDTUtils` is loaded during JDT.LS initialization, before our bundle starts. WeavingHook never fires for already-loaded classes. |
+| **Equinox ClassLoaderHook** | Internal API, requires framework extension configuration (`hookconfigurators.properties`), cannot be registered from a normal bundle. |
+| **Upstream PR** | Outside project control, uncertain timeline. |
+| **Disable `includeDecompiledSources`** | Globally disables decompiled source browsing — too large a UX degradation. |