Skip to content

Commit fe97151

Browse files
clay-goodclaude
andcommitted
fix(java): no phantom JAX-RS routes for HTTP clients + JPA inline-annotation fields (#138)
Round-5 adversarial sweep (added /tmp/retrofit: interface/annotation/generics- heavy). Two real Java bugs found and fixed; broad audit otherwise clean. - JAX-RS false positives (HIGH): route detection fired on any file with @path + an HTTP-method annotation, so Retrofit — an HTTP CLIENT library whose @GET/@path come from retrofit2.http on interface methods — produced 28 phantom server routes. Require a javax/jakarta.ws.rs import (the defining signal for a real JAX-RS resource) before classifying as JAX-RS. Retrofit now yields 0 routes; petclinic Spring routes (17) and genuine JAX-RS resources unaffected. - JPA inline-annotated fields: `@Id private Long id;` (annotation inline with the declaration, the common javax.persistence style) was dropped — the parser treated the whole line as a pure annotation. Peel leading annotations and parse the remainder, so inline-annotated fields (incl. the @id primary key) are kept. Regression tests added for both. Full suite green (3722 passing). Note: the sweep also surfaced a Kotlin (.kt) call-graph receiver-loss self-edge — out of this Java-scoped work; flagged for separate follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d687d8f commit fe97151

5 files changed

Lines changed: 110 additions & 7 deletions

File tree

openspec/specs/analyzer/spec.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5085,6 +5085,12 @@ The system SHALL inject call-graph edges into the file-level dependency graph fo
50855085

50865086
> Decision recorded: 67580817
50875087
> Date: 2026-06-17
5088+
### Requirement: JaxrsRouteDetectionRequiresJavaxjakartawsrsImportToAvoidFalsePositivesFromHttpClientLibraries
5089+
5090+
The system SHALL only classify a Java/Kotlin file as a JAX-RS server endpoint when it imports from the javax.ws.rs or jakarta.ws.rs package, to prevent false positives from HTTP client annotation libraries.
5091+
5092+
> Decision recorded: f9de2e30
5093+
> Date: 2026-06-17
50885094
50895095
## Technical Notes
50905096

@@ -5502,3 +5508,33 @@ The analyzer hardcoded JS/TS/Python extensions in several places, causing Java/K
55025508
Java/Kotlin require imports only for cross-package references; same-package classes are used with no import. The dependency graph was built purely from import edges, so a Java project's file-level graph was nearly empty (spring-petclinic: 10 edges) while its call graph held 1261 — same-package relationships were invisible, hurting structural comprehension and leaving cluster views empty. Fix: (1) run injection for Java/Kotlin regardless of import-edge count, (2) seed the dedup set with existing edges so injected call edges never duplicate an import edge, (3) resolve call-graph file paths to absolute so the two id spaces align.
55035509

55045510
**Consequences:** New exported SAME_PACKAGE_IMPLICIT_LANGS set (Java, Kotlin). injectCallGraphEdges now dedupes against pre-existing edges, making it safe to run alongside import edges. The absolute-path resolution also repairs the previously-silent no-op injection for Swift/C/C++. Java/Kotlin dependency graphs are now populated (petclinic 10→70 edges, gson 318→1517) with structural clusters; injected edges carry isCallEdge:true.
5511+
5512+
### Require a ws.rs import for JAX-RS route detection
5513+
5514+
**Status:** Approved
5515+
**Date:** 2026-06-17
5516+
**ID:** 954eac79
5517+
5518+
JAX-RS route detection fired on any Java file containing both @Path and an HTTP-method annotation (@GET/@POST/...). Retrofit — an HTTP CLIENT library — uses identically-named @GET/@POST/@Path from retrofit2.http on interface methods (client request templates), so OpenLore hallucinated 28 phantom server routes for it (adversarial-audit finding). The defining signal for a real JAX-RS server resource is the javax.ws.rs / jakarta.ws.rs import, which Retrofit never has and Spring does not need. Gating JAX-RS detection on that import removes the false positives without affecting Spring (separate detection path) or genuine JAX-RS resources (which always import ws.rs).
5519+
5520+
**Consequences:** extractJavaRouteDefinitions now requires an `import javax|jakarta.ws.rs` before classifying a file as JAX-RS. Retrofit/OkHttp client interfaces yield 0 routes; petclinic Spring routes (17) and JAX-RS resources (which import ws.rs) are unaffected.
5521+
5522+
### JPA field parser handles inline annotations on the same line as the field declaration
5523+
5524+
**Status:** Approved
5525+
**Date:** 2026-06-17
5526+
**ID:** 8605684f
5527+
5528+
Common JPA patterns place annotations inline with the field (e.g. `@Id private Long id;`). The previous parser only recognized annotations on their own line, causing inline-annotated fields — including primary keys — to be silently dropped from schema extraction.
5529+
5530+
**Consequences:** The parser now iteratively strips leading annotations before testing for a field match, correctly capturing inline-annotated fields. Pure-annotation lines still accumulate in pendingAnn for multi-line annotation stacks.
5531+
5532+
### JAX-RS route detection requires javax/jakarta.ws.rs import to avoid false positives from HTTP client libraries
5533+
5534+
**Status:** Approved
5535+
**Date:** 2026-06-17
5536+
**ID:** f9de2e30
5537+
5538+
Retrofit interfaces use identically-named @GET/@POST/@Path annotations from retrofit2.http, which are client request templates, not server endpoints. Without checking the import package, the parser would emit phantom server routes for HTTP client definitions.
5539+
5540+
**Consequences:** JAX-RS routes are only detected when the file imports from javax.ws.rs or jakarta.ws.rs; projects using non-standard JAX-RS re-exports would not be recognized. Retrofit and similar HTTP client interfaces are correctly excluded.

src/core/analyzer/http-route-parser.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1125,6 +1125,30 @@ public class UserResource {
11251125
expect(paths).toContain('POST /users');
11261126
expect(paths).toContain('GET /users/{id}');
11271127
});
1128+
1129+
it('does NOT treat a Retrofit client interface as server routes (#138)', async () => {
1130+
// Retrofit's @GET/@Path come from retrofit2.http — client request templates,
1131+
// not server endpoints. Without the JAX-RS (ws.rs) import these must yield 0
1132+
// routes, otherwise OpenLore hallucinates a server API for a client library.
1133+
const file = await createFile(tempDir, 'GitHubService.java', `
1134+
package com.example;
1135+
1136+
import retrofit2.Call;
1137+
import retrofit2.http.GET;
1138+
import retrofit2.http.Path;
1139+
1140+
public interface GitHubService {
1141+
@GET("/repos/{owner}/{repo}")
1142+
Call<Repo> getRepo(@Path("owner") String owner, @Path("repo") String repo);
1143+
1144+
@GET("/users/{user}/repos")
1145+
Call<List<Repo>> listRepos(@Path("user") String user);
1146+
}
1147+
`);
1148+
1149+
const routes = await extractJavaRouteDefinitions(file);
1150+
expect(routes).toEqual([]);
1151+
});
11281152
});
11291153

11301154
describe('extractAllHttpEdges with Java', () => {

src/core/analyzer/http-route-parser.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,7 +553,14 @@ export async function extractJavaRouteDefinitions(filePath: string): Promise<Rou
553553
}
554554

555555
const isSpring = /@(?:Rest)?Controller\b|@(?:Get|Post|Put|Delete|Patch)Mapping\b|@RequestMapping\b/.test(clean);
556-
const isJaxrs = /@Path\b/.test(clean) && /@(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\b/.test(clean);
556+
// JAX-RS server annotations come from javax/jakarta.ws.rs. Require that import
557+
// so we don't mistake an HTTP CLIENT library for a server: Retrofit interfaces
558+
// use identically-named @GET/@POST/@Path from retrofit2.http (client request
559+
// templates, not server endpoints) and would otherwise yield phantom routes.
560+
const hasJaxrsImport = /\bimport\s+(?:static\s+)?(?:javax|jakarta)\.ws\.rs\b/.test(clean);
561+
const isJaxrs = hasJaxrsImport
562+
&& /@Path\b/.test(clean)
563+
&& /@(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\b/.test(clean);
557564

558565
// ── Spring: shorthand mappings (@GetMapping, @PostMapping, …) ──────────────
559566
if (isSpring) {

src/core/analyzer/schema-extractor.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,32 @@ public class BaseEntity {
391391
expect(tables[0].fields[0].nullable).toBe(false);
392392
});
393393

394+
it('captures fields whose annotation is inline with the declaration (#138)', async () => {
395+
// javax.persistence + inline annotations (`@Id private Long id;`) — the
396+
// common style that a per-line "starts with @" check would drop.
397+
const fp = await createFile(tmpDir, 'LegacyUser.java', `
398+
package com.example;
399+
400+
import javax.persistence.Entity;
401+
import javax.persistence.Id;
402+
import javax.persistence.Column;
403+
404+
@Entity
405+
public class LegacyUser {
406+
@Id private Long id;
407+
@Column(nullable = false) private String email;
408+
private String nickname;
409+
}
410+
`);
411+
const tables = await extractSchemas([fp], tmpDir);
412+
expect(tables).toHaveLength(1);
413+
const byName = Object.fromEntries(tables[0].fields.map(f => [f.name, f]));
414+
expect(Object.keys(byName).sort()).toEqual(['email', 'id', 'nickname']);
415+
expect(byName['id'].nullable).toBe(false); // @Id ⇒ non-null
416+
expect(byName['email'].nullable).toBe(false); // nullable = false
417+
expect(byName['nickname'].nullable).toBe(true);
418+
});
419+
394420
it('ignores Java files that are not entities', async () => {
395421
const fp = await createFile(tmpDir, 'PlainService.java', `
396422
package com.example;

src/core/analyzer/schema-extractor.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -275,12 +275,21 @@ function parseJpaEntity(source: string, rel: string): SchemaTable[] {
275275

276276
// Only inspect declarations directly inside the class body (depth 1), so
277277
// calls and locals inside method bodies (depth ≥ 2) are ignored.
278-
if (depth === 1) {
279-
if (trimmed.startsWith('@')) {
280-
pendingAnn.push(trimmed);
281-
} else if (trimmed) {
282-
const fieldMatch = trimmed.match(JPA_FIELD_RE);
283-
const isStatic = /\bstatic\b/.test(trimmed);
278+
if (depth === 1 && trimmed) {
279+
// Peel leading annotations into pendingAnn. They may be on their own line
280+
// OR inline with the declaration (`@Id private Long id;`), so we strip them
281+
// and then test whatever remains as a field — otherwise inline-annotated
282+
// fields (very common, e.g. the @Id primary key) would be lost.
283+
let rest = trimmed;
284+
let am: RegExpMatchArray | null;
285+
while ((am = rest.match(/^@[\w.]+(?:\([^)]*\))?\s*/))) {
286+
pendingAnn.push(am[0].trim());
287+
rest = rest.slice(am[0].length);
288+
}
289+
290+
if (rest) {
291+
const fieldMatch = rest.match(JPA_FIELD_RE);
292+
const isStatic = /\bstatic\b/.test(rest);
284293
const isTransient = /@Transient\b/.test(pendingAnn.join(' '));
285294
if (fieldMatch && !isStatic && !isTransient) {
286295
const fieldName = fieldMatch[2];
@@ -295,6 +304,7 @@ function parseJpaEntity(source: string, rel: string): SchemaTable[] {
295304
}
296305
pendingAnn = [];
297306
}
307+
// else: the line was pure annotation(s) — keep pendingAnn for the next line.
298308
}
299309

300310
const opens = (lineText.match(/\{/g) ?? []).length;

0 commit comments

Comments
 (0)