From 9bfc102cea255c3dfe986d06c40a4e6d96088530 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 06:51:27 -0400 Subject: [PATCH] fix(neo4j): carry body_span, type parameters, leaf spans and call-site facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep that followed #255 walked every analysis.json `$def` against V2GraphProjector and found nine more fields the payload emits and the graph declares nowhere. This is the mechanical half: additive properties, no new node family, no comment model, no schema_version move. - `:JCallable` gains the body block's own offsets under a `body_` prefix (`body_start_line` … `body_end_byte`). `span` covers the whole declaration, so before this the graph could not tell a signature from the body it encloses, and a body-less declaration read the same as one whose body was not recorded. A prefix rather than a second node: a body block has no identity, it is a second pair of offsets into the same file. Absent, not zeroed, when there is no body. - `:JType` and `:JCallable` gain `type_parameters_json`. A type parameter carries a name, resolved bounds, a span and its own annotations, so it does not flatten to a scalar — same treatment as `parameters_json`, which is the precedent for exactly this shape. - The enum-constant and record-component blocks now call `putLines` and `annotate`, as `projectField` already did. Both leaf labels join the `J_ANNOTATED_BY` sources. - `J_ANNOTATED_BY` carries the application site's span, on a keyed edge. Java annotations are repeatable, so one (owner, annotation) endpoint pair legitimately occurs twice on one declaration; a plain MERGE collapsed the two applications onto one relationship and kept only the last span and argument list. `_k` is the span key. - `:JBodyNode` gains `callee_signature` (the erased signature of the resolved callee, which is what distinguishes overloads at a call site) and `arguments`. `arguments` stays body-local rather than being mapped through `globalOrdinal` the way `call_node` is, which is what #256 specified. The conformance assertion for that mapping failed on the first run: an argument id names an argument *expression*, and an expression is a body node only when it is itself a call site. On daytrader8, 537 of 3666 argument ids name a body node, so the mapping would have handed out 3129 `:JBodyNode` ids resolving to nothing. Carrying the canonical local ids verbatim keeps the fact exact and positionally aligned with `argument_expr` and `argument_types`; a consumer that wants the node for a nested call composes it the same way this projection does. Every field is asserted against the analysis.json value per node, not by presence, and the body offsets are asserted to slice `:JModule.source` to text that starts with `{` and ends with `}`. The enum, record, annotation and generics assertions run over `enum-record-bodies-test` and `generics-varargs-duplicate-signature-test`: the L4 fixture declares no enum, no record, no annotation and no generic, which is how these drops survived every existing assertion. Closes #256 --- schema.neo4j.json | 41 ++- .../com/ibm/cldk/neo4j/V2GraphProjector.java | 80 ++++- .../com/ibm/cldk/neo4j/V2SchemaCatalog.java | 40 ++- .../neo4j/V2Neo4jSchemaConformanceTest.java | 282 +++++++++++++++++- 4 files changed, 419 insertions(+), 24 deletions(-) diff --git a/schema.neo4j.json b/schema.neo4j.json index 34ed7b60..07b6acc8 100644 --- a/schema.neo4j.json +++ b/schema.neo4j.json @@ -44,6 +44,7 @@ "base_types": "string[]", "interfaces": "string[]", "docstring": "string", + "type_parameters_json": "string", "is_entrypoint": "boolean", "entrypoint_frameworks": "string[]", "start_line": "integer", @@ -76,12 +77,19 @@ "is_implicit": "boolean", "is_entrypoint": "boolean", "entrypoint_frameworks": "string[]", + "type_parameters_json": "string", "start_line": "integer", "start_column": "integer", "end_line": "integer", "end_column": "integer", "start_byte": "integer", - "end_byte": "integer" + "end_byte": "integer", + "body_start_line": "integer", + "body_start_column": "integer", + "body_end_line": "integer", + "body_end_column": "integer", + "body_start_byte": "integer", + "body_end_byte": "integer" } }, { @@ -139,7 +147,13 @@ "id": "string", "name": "string", "arguments": "string[]", - "docstring": "string" + "docstring": "string", + "start_line": "integer", + "start_column": "integer", + "end_line": "integer", + "end_column": "integer", + "start_byte": "integer", + "end_byte": "integer" } }, { @@ -152,7 +166,13 @@ "type": "string", "modifiers": "string[]", "is_variadic": "boolean", - "docstring": "string" + "docstring": "string", + "start_line": "integer", + "start_column": "integer", + "end_line": "integer", + "end_column": "integer", + "start_byte": "integer", + "end_byte": "integer" } }, { @@ -171,6 +191,8 @@ "is_static_call": "boolean", "argument_types": "string[]", "argument_expr": "string[]", + "callee_signature": "string", + "arguments": "string[]", "var": "string", "call_node": "string", "start_line": "integer", @@ -392,13 +414,22 @@ "from": [ "JType", "JCallable", - "JField" + "JField", + "JEnumConstant", + "JRecordComponent" ], "to": [ "JAnnotation" ], "properties": { - "arguments": "string[]" + "arguments": "string[]", + "_k": "string", + "start_line": "integer", + "start_column": "integer", + "end_line": "integer", + "end_column": "integer", + "start_byte": "integer", + "end_byte": "integer" } }, { diff --git a/src/main/java/com/ibm/cldk/neo4j/V2GraphProjector.java b/src/main/java/com/ibm/cldk/neo4j/V2GraphProjector.java index e25443d7..3e7e21bb 100644 --- a/src/main/java/com/ibm/cldk/neo4j/V2GraphProjector.java +++ b/src/main/java/com/ibm/cldk/neo4j/V2GraphProjector.java @@ -38,6 +38,7 @@ import com.ibm.cldk.schema.JModule; import com.ibm.cldk.schema.JRecordComponent; import com.ibm.cldk.schema.JType; +import com.ibm.cldk.schema.JTypeParameter; import com.ibm.cldk.schema.JVariableDeclaration; import com.ibm.cldk.schema.Span; import com.ibm.cldk.schema.V2Json; @@ -218,6 +219,7 @@ private static void projectType(RowBuilder b, NodeRef parent, String containment p.put("base_types", type.getBaseTypes()); p.put("interfaces", type.getInterfaces()); p.put("docstring", docstringOf(type.getComments())); + putTypeParameters(p, type.getTypeParameters()); putLines(p, type.getSpan()); if (type.isEntrypointClass()) { p.put("is_entrypoint", true); @@ -252,9 +254,11 @@ private static void projectType(RowBuilder b, NodeRef parent, String containment ep.put("name", ec.getName()); ep.put("arguments", ec.getArguments()); ep.put("docstring", docstringOf(ec.getComments())); + putLines(ep, ec.getSpan()); ep.put("_module", fileKey); NodeRef er = b.node(Arrays.asList("JEnumConstant"), "id", id, RowBuilder.prune(ep)); b.edge("J_HAS_ENUM_CONSTANT", ref, er); + annotate(b, er, ec.getDecorators()); } for (JRecordComponent rc : type.getRecordComponents()) { Map rp = RowBuilder.props(); @@ -267,9 +271,11 @@ private static void projectType(RowBuilder b, NodeRef parent, String containment rp.put("is_variadic", true); } rp.put("docstring", docstringOf(rc.getComments())); + putLines(rp, rc.getSpan()); rp.put("_module", fileKey); NodeRef rr = b.node(Arrays.asList("JRecordComponent"), "id", id, RowBuilder.prune(rp)); b.edge("J_HAS_RECORD_COMPONENT", ref, rr); + annotate(b, rr, rc.getDecorators()); } for (Map.Entry c : type.getCallables().entrySet()) { projectCallable(b, ref, c.getKey(), c.getValue(), module, fileKey, typeIdByFqn); @@ -332,7 +338,13 @@ private static void projectCallable(RowBuilder b, NodeRef owner, String signatur p.put("is_entrypoint", true); p.put("entrypoint_frameworks", c.getEntrypointFrameworks()); } + putTypeParameters(p, c.getTypeParameters()); putLines(p, c.getSpan()); + // The body block's own offsets, under a `body_` prefix. `span` covers the whole declaration, + // so without this the graph cannot tell the signature from the body it encloses -- and an + // abstract or interface method, which has no body at all, reads the same as one whose body + // was simply not recorded. Absent (not zeroed) when there is no body. + putLines(p, c.getBodySpan(), "body_"); p.put("_module", fileKey); NodeRef ref = b.node(labels, "id", c.getId(), RowBuilder.prune(p)); b.edge("J_HAS_METHOD", owner, ref); @@ -369,6 +381,15 @@ private static void projectCallable(RowBuilder b, NodeRef owner, String signatur np.put("is_static_call", n.getIsStaticCall()); np.put("argument_types", n.getArgumentTypes()); np.put("argument_expr", n.getArgumentExpr()); + np.put("callee_signature", n.getCalleeSignature()); + // The canonical `arguments`: one body-local `line:col` id per argument, positionally + // aligned with `argument_expr` and `argument_types`. Kept local, NOT mapped to global + // body-node ids the way `call_node` below is: an argument is a body node only when it is + // itself a call site (`f(g(x))`), so qualifying every entry with the owning callable + // would hand out `:JBodyNode` ids that mostly resolve to nothing. A consumer that wants + // the node for a nested call builds it the same way this projection does -- the owner id, + // an `@`, then the local id. + np.put("arguments", n.getArguments()); putLines(np, n.getSpan()); np.put("_module", fileKey); // L4 SDG synthetic-vertex payload: absent on every non-synthetic node (prune drops nulls). @@ -429,6 +450,36 @@ private static String globalOrdinal(String callableId, String localKey) { return localKey.startsWith("@") ? callableId + localKey : callableId + "@" + localKey; } + /** + * A span as a MERGE discriminant. Byte offsets when the span has them, the line/column pair + * otherwise, and {@code ""} for no span -- two annotations without spans collapse, which is the + * pre-existing behaviour and no worse than it. + */ + private static String spanKey(Span span) { + if (span == null) { + return ""; + } + if (span.getBytes() != null && span.getBytes().length > 1) { + return span.getBytes()[0] + ":" + span.getBytes()[1]; + } + if (span.getStart() != null && span.getStart().length > 1) { + return "L" + span.getStart()[0] + ":" + span.getStart()[1]; + } + return ""; + } + + /** + * The declared type parameters, serialized whole. Each carries a name, its resolved bounds, its + * own span and its own annotations, so it does not flatten to a scalar -- same call as + * {@code parameters_json}, and for the same reason. Absent when the declaration is not generic. + */ + private static void putTypeParameters(Map p, List params) { + if (params == null || params.isEmpty()) { + return; + } + p.put("type_parameters_json", V2Json.compact().toJson(params)); + } + // ------------------------------------------------------------------------------------------ // Repository-artifact layer: build manifests, config files, declared dependencies. // ------------------------------------------------------------------------------------------ @@ -601,7 +652,12 @@ private static void annotate(RowBuilder b, NodeRef owner, List decor RowBuilder.prune(mapOf("name", d.getName()))); Map p = RowBuilder.props(); p.put("arguments", d.getArgs()); - b.edge("J_ANNOTATED_BY", owner, ann, RowBuilder.prune(p)); + putLines(p, d.getSpan()); + // `_k` = the application site. Annotations are repeatable in Java (`@Foo @Foo`), so the + // same (owner, annotation) endpoint pair can occur more than once on one declaration; + // without the discriminant a plain MERGE collapses them onto one relationship and keeps + // only the last span and argument list SET. + b.keyedEdge("J_ANNOTATED_BY", owner, ann, RowBuilder.prune(p), spanKey(d.getSpan())); } } @@ -733,24 +789,34 @@ private static String slice(String source, Span span) { * position (two nodes on one line) unrepresentable. */ private static void putLines(Map p, Span span) { + putLines(p, span, ""); + } + + /** + * As above, under a property-name prefix, for a node carrying a second span beside its own — + * {@code JCallable} carries the declaration's span unprefixed and the body block's under + * {@code body_}. A prefix rather than a second node because a body block is not a thing with an + * identity; it is a second pair of offsets into the same file. + */ + private static void putLines(Map p, Span span, String prefix) { if (span == null) { return; } if (span.getStart() != null && span.getStart().length > 0) { - p.put("start_line", span.getStart()[0]); + p.put(prefix + "start_line", span.getStart()[0]); if (span.getStart().length > 1) { - p.put("start_column", span.getStart()[1]); + p.put(prefix + "start_column", span.getStart()[1]); } } if (span.getEnd() != null && span.getEnd().length > 0) { - p.put("end_line", span.getEnd()[0]); + p.put(prefix + "end_line", span.getEnd()[0]); if (span.getEnd().length > 1) { - p.put("end_column", span.getEnd()[1]); + p.put(prefix + "end_column", span.getEnd()[1]); } } if (span.getBytes() != null && span.getBytes().length > 1) { - p.put("start_byte", span.getBytes()[0]); - p.put("end_byte", span.getBytes()[1]); + p.put(prefix + "start_byte", span.getBytes()[0]); + p.put(prefix + "end_byte", span.getBytes()[1]); } } diff --git a/src/main/java/com/ibm/cldk/neo4j/V2SchemaCatalog.java b/src/main/java/com/ibm/cldk/neo4j/V2SchemaCatalog.java index c36f5b85..57129967 100644 --- a/src/main/java/com/ibm/cldk/neo4j/V2SchemaCatalog.java +++ b/src/main/java/com/ibm/cldk/neo4j/V2SchemaCatalog.java @@ -98,9 +98,19 @@ Map done() { * are the ones they must adopt. */ private static Map lines(P p) { - return p.put("start_line", "integer").put("start_column", "integer") - .put("end_line", "integer").put("end_column", "integer") - .put("start_byte", "integer").put("end_byte", "integer").done(); + return span(p, "").done(); + } + + /** + * The same six properties under a name prefix, for a node carrying a second span beside its own. + * {@code JCallable} is the only one: {@code body_*} is the body block, where the unprefixed span + * is the whole declaration. Absent entirely on an abstract or interface method, which has no + * body -- distinguishable from a body that was not recorded, which the line pair alone was not. + */ + private static P span(P p, String prefix) { + return p.put(prefix + "start_line", "integer").put(prefix + "start_column", "integer") + .put(prefix + "end_line", "integer").put(prefix + "end_column", "integer") + .put(prefix + "start_byte", "integer").put(prefix + "end_byte", "integer"); } public static final List NODE_LABELS = buildNodeLabels(); @@ -136,10 +146,11 @@ private static List buildNodeLabels() { lines(new P().put("id", "string").put("name", "string").put("kind", "string") .put("modifiers", "string[]").put("base_types", "string[]") .put("interfaces", "string[]").put("docstring", "string") + .put("type_parameters_json", "string") .put("is_entrypoint", "boolean").put("entrypoint_frameworks", "string[]")))); n.add(node("JCallable", "JSymbol", "id", - lines(new P().put("id", "string").put("name", "string").put("signature", "string") + span(span(new P().put("id", "string").put("name", "string").put("signature", "string") .put("kind", "string").put("declaration", "string").put("return_type", "string") .put("parameters_json", "string").put("modifiers", "string[]") .put("error_channel", "string[]").put("code", "string").put("docstring", "string") @@ -147,7 +158,7 @@ private static List buildNodeLabels() { .put("referenced_types", "string[]").put("accessed_fields", "string[]") .put("is_implicit", "boolean").put("is_entrypoint", "boolean") .put("entrypoint_frameworks", "string[]") - ))); + .put("type_parameters_json", "string"), ""), "body_").done())); n.add(node("JExternal", "JSymbol", "id", new P().put("id", "string").put("kind", "string").put("signature", "string") @@ -163,13 +174,13 @@ private static List buildNodeLabels() { .put("initializer", "string")))); n.add(node("JEnumConstant", "JEnumConstant", "id", - new P().put("id", "string").put("name", "string").put("arguments", "string[]") - .put("docstring", "string").done())); + lines(new P().put("id", "string").put("name", "string").put("arguments", "string[]") + .put("docstring", "string")))); n.add(node("JRecordComponent", "JRecordComponent", "id", - new P().put("id", "string").put("name", "string").put("type", "string") + lines(new P().put("id", "string").put("name", "string").put("type", "string") .put("modifiers", "string[]").put("is_variadic", "boolean") - .put("docstring", "string").done())); + .put("docstring", "string")))); n.add(node("JBodyNode", "JBodyNode", "id", lines(new P().put("id", "string").put("kind", "string").put("method_name", "string") @@ -177,6 +188,7 @@ private static List buildNodeLabels() { .put("return_type", "string").put("accessibility", "string") .put("is_constructor_call", "boolean").put("is_static_call", "boolean") .put("argument_types", "string[]").put("argument_expr", "string[]") + .put("callee_signature", "string").put("arguments", "string[]") // L4 SDG synthetic-vertex payload. .put("var", "string").put("call_node", "string")))); @@ -238,8 +250,14 @@ private static List buildRelTypes() { r.add(rel("J_IMPORTS", Arrays.asList("JModule"), Arrays.asList("JModule", "JPackage"), new P().put("spellings", "string[]").put("is_static", "boolean") .put("is_wildcard", "boolean").done())); - r.add(rel("J_ANNOTATED_BY", Arrays.asList("JType", "JCallable", "JField"), - Arrays.asList("JAnnotation"), new P().put("arguments", "string[]").done())); + // `_k` = the application site's span. Java annotations are repeatable (`@Foo @Foo`), so one + // (owner, annotation) endpoint pair legitimately occurs twice on one declaration; without the + // discriminant a plain MERGE collapses the two applications onto one relationship and keeps + // only the last span and argument list SET. + r.add(rel("J_ANNOTATED_BY", + Arrays.asList("JType", "JCallable", "JField", "JEnumConstant", "JRecordComponent"), + Arrays.asList("JAnnotation"), + lines(new P().put("arguments", "string[]").put("_k", "string")))); // L3 CPG overlay. `_k` is the MERGE discriminant (internal, underscore-prefixed): J_CFG_NEXT // merges per `kind` (a conditional's true/false pair), J_DDG per `(var, prov)`. r.add(rel("J_CFG_NEXT", body, body, new P().put("kind", "string").put("_k", "string").done())); diff --git a/src/test/java/com/ibm/cldk/neo4j/V2Neo4jSchemaConformanceTest.java b/src/test/java/com/ibm/cldk/neo4j/V2Neo4jSchemaConformanceTest.java index 4b3b9e3b..ec93318d 100644 --- a/src/test/java/com/ibm/cldk/neo4j/V2Neo4jSchemaConformanceTest.java +++ b/src/test/java/com/ibm/cldk/neo4j/V2Neo4jSchemaConformanceTest.java @@ -29,9 +29,18 @@ import com.ibm.cldk.schema.Analysis; import com.ibm.cldk.schema.CanId; import com.ibm.cldk.schema.JArtifact; +import com.ibm.cldk.schema.JBodyNode; +import com.ibm.cldk.schema.JCallable; +import com.ibm.cldk.schema.JDecorator; import com.ibm.cldk.schema.JDependency; +import com.ibm.cldk.schema.JEnumConstant; import com.ibm.cldk.schema.JModule; +import com.ibm.cldk.schema.JRecordComponent; +import com.ibm.cldk.schema.JType; +import com.ibm.cldk.schema.JTypeParameter; +import com.ibm.cldk.schema.Span; import com.ibm.cldk.schema.V2Emitter; +import com.ibm.cldk.schema.V2Json; import com.ibm.cldk.syntactic_analysis.L1Extractor; import com.ibm.cldk.syntactic_analysis.L2CallGraph; import com.ibm.cldk.syntactic_analysis.dataflow.SdgVertices; @@ -40,7 +49,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; @@ -597,8 +608,277 @@ void noIdCarriesTheOldLanguageFirstShape() { } } + // ------------------------------------------------------------------------------------------ + // #256: the analysis.json facts the projection carried in the payload and dropped in the graph. + // ------------------------------------------------------------------------------------------ + + /** + * {@code body_span} reaches the graph under a {@code body_} prefix and resolves to the block it + * claims. {@code span} covers the whole declaration, so without this the graph cannot tell a + * signature from the body it encloses — and a body-less declaration (abstract, interface) reads + * the same as one whose body was simply not recorded. Absence is asserted too, because that is + * the distinction: zeroed offsets would read as a body at the top of the file. + */ + @Test + void everyCallableBodySpanReachesTheGraphAndSlicesToItsBlock() { + int withBody = 0; + for (JModule m : analysis.getApplication().getSymbolTable().values()) { + for (JType t : typesOf(m)) { + for (JCallable c : t.getCallables().values()) { + NodeRow row = findNode("JSymbol", c.getId()); + assertNotNull(row, "no :JCallable row for " + c.getId()); + if (c.getBodySpan() == null) { + assertNull(row.props.get("body_start_line"), + c.getId() + " has no body_span in analysis.json, so the graph must " + + "carry no body_ offsets either"); + continue; + } + withBody++; + assertSpan(row.props, "body_", c.getBodySpan(), "body_span of " + c.getId()); + String block = sliceUtf8(m.getSource(), c.getBodySpan()); + assertTrue(block.startsWith("{") && block.endsWith("}"), + "the body_ byte offsets of " + c.getId() + " must slice module.source to " + + "the block itself, got: " + block); + } + } + } + assertTrue(withBody > 0, "precondition: the fixture must declare callables with bodies"); + } + + /** + * A call body node's {@code callee_signature} and {@code arguments}. The signature is what the + * call resolves to; {@code arguments} is the canonical per-argument id list, and it must arrive + * position for position, since {@code argument_expr} and {@code argument_types} are indexed by the + * same positions. The ids stay body-local: an argument is a body node only when it is itself a + * call site, so owner-qualifying them would produce {@code :JBodyNode} ids that mostly resolve to + * nothing (which is how this assertion first failed). + */ + @Test + void callBodyNodesCarryCalleeSignatureAndArgumentIds() { + int signatures = 0; + int argumentLists = 0; + for (JModule m : analysis.getApplication().getSymbolTable().values()) { + for (JType t : typesOf(m)) { + for (JCallable c : t.getCallables().values()) { + for (Map.Entry e : c.getBody().entrySet()) { + String id = ordinal(c.getId(), e.getKey()); + NodeRow row = findNode("JBodyNode", id); + assertNotNull(row, "no :JBodyNode row for " + id); + JBodyNode n = e.getValue(); + if (n.getCalleeSignature() != null) { + signatures++; + assertEquals(n.getCalleeSignature(), row.props.get("callee_signature"), + "callee_signature differs from analysis.json for " + id); + } + if (n.getArguments().isEmpty()) { + assertNull(row.props.get("arguments"), + id + " takes no arguments, so it must carry no `arguments`"); + continue; + } + argumentLists++; + assertEquals(n.getArguments(), row.props.get("arguments"), + "`arguments` of " + id + " must be the same ids analysis.json gives, " + + "in the same order -- argument_expr and argument_types are " + + "indexed by the same positions"); + } + } + } + } + assertTrue(signatures > 0, "precondition: the fixture must contain resolved calls"); + assertTrue(argumentLists > 0, "precondition: the fixture must contain calls with arguments"); + } + + /** + * Enum constants and record components carry their own spans, and their annotations reach the + * graph as keyed J_ANNOTATED_BY edges. Projected from a second fixture because the L4 one + * declares no enum, no record and no annotation at all — the reason these three drops survived + * every existing assertion. + */ + @Test + void enumConstantsAndRecordComponentsCarryTheirSpansAndAnnotations() throws Exception { + Projection p = projectFixture("enum-record-bodies-test"); + int constants = 0; + int components = 0; + int annotations = 0; + for (JModule m : p.analysis.getApplication().getSymbolTable().values()) { + for (JType t : typesOf(m)) { + for (JEnumConstant ec : t.getEnumConstants()) { + String id = t.getId() + "#enum#" + ec.getName(); + NodeRow row = findNode(p.rows, "JEnumConstant", id); + assertNotNull(row, "no :JEnumConstant row for " + id); + assertNotNull(ec.getSpan(), "precondition: L1 records the constant's span"); + assertSpan(row.props, "", ec.getSpan(), "span of " + id); + constants++; + annotations += assertAnnotationEdges(p.rows, id, ec.getDecorators()); + } + for (JRecordComponent rc : t.getRecordComponents()) { + String id = t.getId() + "#rec#" + rc.getName(); + NodeRow row = findNode(p.rows, "JRecordComponent", id); + assertNotNull(row, "no :JRecordComponent row for " + id); + assertNotNull(rc.getSpan(), "precondition: L1 records the component's span"); + assertSpan(row.props, "", rc.getSpan(), "span of " + id); + components++; + annotations += assertAnnotationEdges(p.rows, id, rc.getDecorators()); + } + } + } + assertEquals(3, constants, "precondition: Op declares PLUS, MINUS and NOOP"); + assertEquals(2, components, "precondition: Money declares tags and cents"); + assertTrue(annotations > 0, + "precondition: the fixture must annotate a leaf declaration (@Deprecated on NOOP)"); + } + + /** + * A generic declaration's {@code type_parameters}, serialized whole as + * {@code type_parameters_json} — same treatment as {@code parameters_json}, because a parameter + * carries a name, resolved bounds, a span and its own annotations and so does not flatten to a + * scalar. Round-tripped rather than string-compared, and absence asserted on the non-generic + * declarations in the same fixture. + */ + @Test + void genericDeclarationsCarryTheirTypeParameters() throws Exception { + Projection p = projectFixture("generics-varargs-duplicate-signature-test"); + int generic = 0; + for (JModule m : p.analysis.getApplication().getSymbolTable().values()) { + for (JType t : typesOf(m)) { + generic += assertTypeParameters( + findNode(p.rows, "JSymbol", t.getId()), t.getTypeParameters(), t.getId()); + for (JCallable c : t.getCallables().values()) { + generic += assertTypeParameters( + findNode(p.rows, "JSymbol", c.getId()), c.getTypeParameters(), c.getId()); + } + } + } + assertTrue(generic > 0, "precondition: the fixture must declare generic callables"); + } + + /** Every type in a module, nested types included. */ + private static List typesOf(JModule module) { + List out = new ArrayList<>(); + collectTypes(module.getTypes().values(), out); + return out; + } + + private static void collectTypes(Collection types, List out) { + for (JType t : types) { + out.add(t); + collectTypes(t.getTypes().values(), out); + } + } + + /** + * A projected span property by property against the one {@code analysis.json} carries, rather + * than by presence: a span that arrives with the wrong end, or with the line pair and no byte + * pair, is unusable in the same way the drop was. + */ + private static void assertSpan(Map props, String prefix, Span span, String what) { + assertEquals(span.getStart()[0], props.get(prefix + "start_line"), what + " start_line"); + assertEquals(span.getStart()[1], props.get(prefix + "start_column"), what + " start_column"); + assertEquals(span.getEnd()[0], props.get(prefix + "end_line"), what + " end_line"); + assertEquals(span.getEnd()[1], props.get(prefix + "end_column"), what + " end_column"); + assertEquals(span.getBytes()[0], props.get(prefix + "start_byte"), what + " start_byte"); + assertEquals(span.getBytes()[1], props.get(prefix + "end_byte"), what + " end_byte"); + } + + /** + * The J_ANNOTATED_BY edges out of one declaration: one per application site, each keyed on that + * site and carrying its own span. Java annotations are repeatable, so a plain MERGE on the + * endpoint pair collapses {@code @Foo @Foo} onto one relationship and keeps only the last span. + */ + private static int assertAnnotationEdges(GraphRows in, String owner, List decorators) { + for (JDecorator d : decorators) { + String key = spanKey(d.getSpan()); + EdgeRow found = null; + for (EdgeRow e : in.edges) { + if (e.type.equals("J_ANNOTATED_BY") && e.from.value.equals(owner) + && key.equals(e.key)) { + found = e; + break; + } + } + assertNotNull(found, "no J_ANNOTATED_BY edge out of " + owner + + " keyed on its application site " + key); + if (d.getSpan() != null) { + assertSpan(found.props, "", d.getSpan(), "span of @" + d.getName() + " on " + owner); + } + } + return decorators.size(); + } + + /** Mirrors {@code V2GraphProjector.spanKey}: the byte pair, else the line/column pair. */ + private static String spanKey(Span span) { + if (span == null) { + return ""; + } + if (span.getBytes() != null && span.getBytes().length > 1) { + return span.getBytes()[0] + ":" + span.getBytes()[1]; + } + if (span.getStart() != null && span.getStart().length > 1) { + return "L" + span.getStart()[0] + ":" + span.getStart()[1]; + } + return ""; + } + + private static int assertTypeParameters(NodeRow row, List declared, String id) { + assertNotNull(row, "no row for " + id); + Object projected = row.props.get("type_parameters_json"); + if (declared.isEmpty()) { + assertNull(projected, id + " declares no type parameters, so it must carry no " + + "type_parameters_json"); + return 0; + } + assertNotNull(projected, "no type_parameters_json on " + id + ", which analysis.json gives " + + declared.size() + " type parameter(s)"); + JTypeParameter[] parsed = + V2Json.compact().fromJson((String) projected, JTypeParameter[].class); + assertEquals(declared.size(), parsed.length, "type_parameters_json of " + id + + " must hold one entry per declared parameter, in declaration order"); + for (int i = 0; i < parsed.length; i++) { + assertEquals(declared.get(i).getName(), parsed[i].getName(), + "type parameter " + i + " of " + id); + assertEquals(declared.get(i).getBounds(), parsed[i].getBounds(), + "bounds of type parameter " + declared.get(i).getName() + " of " + id); + } + return parsed.length; + } + + /** Mirrors {@code V2GraphProjector.globalOrdinal}: {@code @tag} keys concatenate, others get an {@code @}. */ + private static String ordinal(String callableId, String localKey) { + return localKey.startsWith("@") ? callableId + localKey : callableId + "@" + localKey; + } + + /** The projector's own UTF-8 byte slice of {@code module.source}. */ + private static String sliceUtf8(String source, Span span) { + byte[] bytes = source.getBytes(StandardCharsets.UTF_8); + return new String(bytes, span.getBytes()[0], span.getBytes()[1] - span.getBytes()[0], + StandardCharsets.UTF_8); + } + + /** One fixture's payload and the graph projected from it, kept together. */ + private static final class Projection { + final Analysis analysis; + final GraphRows rows; + + Projection(Analysis analysis, GraphRows rows) { + this.analysis = analysis; + this.rows = rows; + } + } + + /** L1 over one of the small fixtures, emitted and projected. */ + private static Projection projectFixture(String app) throws Exception { + Map modules = + L1Extractor.extractAll(Paths.get("src/test/resources/test-applications/" + app), app); + Analysis emitted = V2Emitter.emit(app, 1, modules, "test"); + return new Projection(emitted, V2GraphProjector.project(emitted, app)); + } + private static NodeRow findNode(String mergeLabel, String value) { - for (NodeRow node : rows.nodes) { + return findNode(rows, mergeLabel, value); + } + + private static NodeRow findNode(GraphRows in, String mergeLabel, String value) { + for (NodeRow node : in.nodes) { if (node.labels.get(0).equals(mergeLabel) && node.value.equals(value)) { return node; }