From ef9d946dd79d388ff0c7d8283d459e84f7a29843 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 11 Sep 2026 09:13:08 -0400 Subject: [PATCH 1/5] feat(artifacts): view-template role, return expressions, and the view-dispatch literal tier (#259) - ArtifactDiscovery classifies the JSP family, Facelets, and Thymeleaf html under templates/ or WEB-INF/ as view-template; faces-config.xml becomes tool-config; bare html elsewhere stays unknown. - return body nodes carry the returned expression in argument_expr. - ViewDispatches resolves forward/include/sendRedirect sites to the artifact they reach, by declared receiver type, or records them unresolved as non-literal / no-such-artifact / ambiguous. - Literals hoists the string-literal decoders ConfigUses and the new pass share. --- .../ibm/cldk/artifacts/ArtifactDiscovery.java | 12 + .../com/ibm/cldk/artifacts/ConfigUses.java | 59 +---- .../java/com/ibm/cldk/artifacts/Literals.java | 43 ++++ .../ibm/cldk/artifacts/ViewDispatches.java | 226 ++++++++++++++++++ .../ibm/cldk/schema/JViewDispatchEdge.java | 26 ++ .../cldk/schema/JViewDispatchUnresolved.java | 30 +++ .../controlflow/BodyNodeBuilder.java | 6 + .../cldk/artifacts/ArtifactDiscoveryTest.java | 53 ++++ .../cldk/artifacts/ViewDispatchesTest.java | 189 +++++++++++++++ .../ReturnExpressionTest.java | 84 +++++++ 10 files changed, 676 insertions(+), 52 deletions(-) create mode 100644 src/main/java/com/ibm/cldk/artifacts/Literals.java create mode 100644 src/main/java/com/ibm/cldk/artifacts/ViewDispatches.java create mode 100644 src/main/java/com/ibm/cldk/schema/JViewDispatchEdge.java create mode 100644 src/main/java/com/ibm/cldk/schema/JViewDispatchUnresolved.java create mode 100644 src/test/java/com/ibm/cldk/artifacts/ViewDispatchesTest.java create mode 100644 src/test/java/com/ibm/cldk/syntactic_analysis/ReturnExpressionTest.java diff --git a/src/main/java/com/ibm/cldk/artifacts/ArtifactDiscovery.java b/src/main/java/com/ibm/cldk/artifacts/ArtifactDiscovery.java index ed156d9d..755977b4 100644 --- a/src/main/java/com/ibm/cldk/artifacts/ArtifactDiscovery.java +++ b/src/main/java/com/ibm/cldk/artifacts/ArtifactDiscovery.java @@ -70,8 +70,20 @@ private ArtifactDiscovery() {} new Rule("bootstrap*.yml", "yaml", List.of("tool-config")), new Rule("logback*.xml", "xml", List.of("tool-config")), new Rule("web.xml", "xml", List.of("tool-config")), + new Rule("faces-config.xml", "xml", List.of("tool-config")), new Rule("persistence.xml", "xml", List.of("tool-config")), new Rule("beans.xml", "xml", List.of("tool-config")), + // View templates (#259, spec 2026-09-11 D1). A bare *.html anywhere else stays `unknown`: + // a static page and a Thymeleaf template are not distinguishable by name, and `*` + // crosses '/' here, so `*/templates/*.html` reaches a nested templates/admin/x.html. + new Rule("*.jsp", "jsp", List.of("view-template")), + new Rule("*.jspx", "jsp", List.of("view-template")), + new Rule("*.jspf", "jsp", List.of("view-template")), + new Rule("*.tag", "jsp", List.of("view-template")), + new Rule("*.tagx", "jsp", List.of("view-template")), + new Rule("*.xhtml", "xhtml", List.of("view-template")), + new Rule("*/templates/*.html", "html", List.of("view-template")), + new Rule("*/WEB-INF/*.html", "html", List.of("view-template")), new Rule("*.tf", "text", List.of("iac")), new Rule(".github/workflows/*.yml", "yaml", List.of("ci")), new Rule(".github/workflows/*.yaml", "yaml", List.of("ci")), diff --git a/src/main/java/com/ibm/cldk/artifacts/ConfigUses.java b/src/main/java/com/ibm/cldk/artifacts/ConfigUses.java index 77fb102f..8b0cc219 100644 --- a/src/main/java/com/ibm/cldk/artifacts/ConfigUses.java +++ b/src/main/java/com/ibm/cldk/artifacts/ConfigUses.java @@ -61,7 +61,7 @@ private ConfigUses() {} private static final List LITERAL_AND_DATAFLOW = List.of("literal", "dataflow"); /** A bare Java identifier — the only key-argument shape a dataflow tier can trace. */ - private static final Pattern IDENTIFIER = Pattern.compile("^[A-Za-z_$][A-Za-z0-9_$]*$"); + private static final Pattern IDENTIFIER = Literals.IDENTIFIER; /** * A call-site detector. {@code namespaces} is a preference order, not a filter: the @@ -297,7 +297,7 @@ private static void collectCallSite(String appName, String callableId, String lo } List args = node.getArgumentExpr(); String arg = args != null && args.size() > rule.keyArg ? args.get(rule.keyArg) : null; - String literal = stringLiteral(arg); + String literal = Literals.stringLiteral(arg); // The bare name a dataflow tier can trace. Only a plain identifier qualifies: a field // access or any compound expression has no single local for the DDG to close over. String keyName = literal == null && arg != null && IDENTIFIER.matcher(arg).matches() @@ -492,11 +492,11 @@ private static String annotationMember(JDecorator decorator, String... names) { for (String name : names) { for (String arg : args) { if (arg.startsWith(name + "=")) { - return stringLiteral(arg.substring(name.length() + 1)); + return Literals.stringLiteral(arg.substring(name.length() + 1)); } } } - return args.size() == 1 && args.get(0).indexOf('=') < 0 ? stringLiteral(args.get(0)) : null; + return args.size() == 1 && args.get(0).indexOf('=') < 0 ? Literals.stringLiteral(args.get(0)) : null; } // ---------------------------------------------------------------------------------------- @@ -597,7 +597,7 @@ private static String assignLiteral(String source, JBodyNode def) { if (decl.getVariables().size() != 1) { return null; } - return literalOf(decl.getVariable(0).getInitializer().orElse(null)); + return Literals.literalOf(decl.getVariable(0).getInitializer().orElse(null)); } if (expr.isAssignExpr()) { com.github.javaparser.ast.expr.AssignExpr assign = expr.asAssignExpr(); @@ -605,7 +605,7 @@ private static String assignLiteral(String source, JBodyNode def) { || !assign.getTarget().isNameExpr()) { return null; } - return literalOf(assign.getValue()); + return Literals.literalOf(assign.getValue()); } return null; } @@ -717,7 +717,7 @@ private static String siteLiteral(CallSiteIndex.Site site, int paramIndex, return null; } String arg = args.get(paramIndex); - String direct = stringLiteral(arg); + String direct = Literals.stringLiteral(arg); if (direct != null) { return direct; } @@ -783,51 +783,6 @@ private static String slice(String source, JBodyNode node) { return new String(raw, bytes[0], bytes[1] - bytes[0], StandardCharsets.UTF_8); } - private static String literalOf(com.github.javaparser.ast.expr.Expression expr) { - return expr != null && expr.isStringLiteralExpr() - ? unescape(expr.asStringLiteralExpr().getValue()) - : null; - } - - /** Decode a Java string-literal expression; {@code null} when the expression is not one. */ - private static String stringLiteral(String expr) { - if (expr == null || expr.length() < 2 || expr.charAt(0) != '"' - || expr.charAt(expr.length() - 1) != '"') { - return null; - } - return unescape(expr.substring(1, expr.length() - 1), true); - } - - /** Decode Java escapes in a literal's body; JavaParser hands back the raw escaped text. */ - private static String unescape(String body) { - return unescape(body, false); - } - - private static String unescape(String body, boolean rejectInteriorQuote) { - StringBuilder out = new StringBuilder(body.length()); - for (int i = 0; i < body.length(); i++) { - char c = body.charAt(i); - if (c == '"' && rejectInteriorQuote) { - // An UNESCAPED interior quote means this is not one literal — a concatenation - // ("a" + "b") arrives here as a single expression — so it belongs in the - // non-literal bucket rather than being silently truncated to its first half. - return null; - } - if (c == '\\' && i + 1 < body.length()) { - out.append(body.charAt(++i)); - } else { - out.append(c); - } - } - return out.toString(); - } - - /** - * The {@code @external} ghost for an annotation-driven read. An annotation is not literally a - * callee, but {@code @Value} injection is a read and the ghost names what performed it, - * so every unresolved read projects the same {@code JApplication → JExternal} shape as - * codeanalyzer-python's rather than some reads carrying no endpoint at all. - */ private static String ghost(String appName, String annotationType) { return CanId.externalId(appName, annotationType, "value()"); } diff --git a/src/main/java/com/ibm/cldk/artifacts/Literals.java b/src/main/java/com/ibm/cldk/artifacts/Literals.java new file mode 100644 index 00000000..67082afe --- /dev/null +++ b/src/main/java/com/ibm/cldk/artifacts/Literals.java @@ -0,0 +1,43 @@ +package com.ibm.cldk.artifacts; + +import java.util.regex.Pattern; + +/** String-literal decoding shared by the config-use and view-dispatch passes. */ +final class Literals { + + private Literals() {} + + /** A bare Java identifier — the only argument shape a dataflow tier can trace. */ + static final Pattern IDENTIFIER = Pattern.compile("^[A-Za-z_$][A-Za-z0-9_$]*$"); + + /** The decoded value of a {@code "..."} source expression, or {@code null} if it is not one. */ + static String stringLiteral(String expr) { + if (expr == null || expr.length() < 2 || expr.charAt(0) != '"' + || expr.charAt(expr.length() - 1) != '"') { + return null; + } + return unescape(expr.substring(1, expr.length() - 1), true); + } + + static String literalOf(com.github.javaparser.ast.expr.Expression expr) { + return expr != null && expr.isStringLiteralExpr() + ? unescape(expr.asStringLiteralExpr().getValue(), false) + : null; + } + + static String unescape(String body, boolean rejectInteriorQuote) { + StringBuilder out = new StringBuilder(body.length()); + for (int i = 0; i < body.length(); i++) { + char c = body.charAt(i); + if (c == '"' && rejectInteriorQuote) { + return null; + } + if (c == '\\' && i + 1 < body.length()) { + out.append(body.charAt(++i)); + } else { + out.append(c); + } + } + return out.toString(); + } +} diff --git a/src/main/java/com/ibm/cldk/artifacts/ViewDispatches.java b/src/main/java/com/ibm/cldk/artifacts/ViewDispatches.java new file mode 100644 index 00000000..9958904e --- /dev/null +++ b/src/main/java/com/ibm/cldk/artifacts/ViewDispatches.java @@ -0,0 +1,226 @@ +package com.ibm.cldk.artifacts; + +import com.ibm.cldk.schema.CanId; +import com.ibm.cldk.schema.JArtifact; +import com.ibm.cldk.schema.JBodyNode; +import com.ibm.cldk.schema.JCallEdge; +import com.ibm.cldk.schema.JCallable; +import com.ibm.cldk.schema.JModule; +import com.ibm.cldk.schema.JType; +import com.ibm.cldk.schema.JViewDispatchEdge; +import com.ibm.cldk.schema.JViewDispatchUnresolved; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * The view-dispatch pass (#259, spec 2026-09-11): joins the body nodes that hand a request to a view + * — {@code RequestDispatcher.forward} / {@code include}, {@code HttpServletResponse.sendRedirect} — + * to the {@link JArtifact} they reach, and records the ones that reach none. The same shape as + * {@link ConfigUses}: pure tree-in/records-out, a literal tier at every level, and it never + * guesses — a target closes on exactly one artifact or it is recorded unresolved with a reason. + * + *

Detection is by declared receiver type, never by bare method name: {@code forward} + * and {@code include} are ordinary names, and claiming a project's own would be a confident wrong + * edge. The consequence is that the servlet API must resolve for the pass to see anything, which is + * the same condition {@link ConfigUses} already lives with. + */ +public final class ViewDispatches { + + private ViewDispatches() {} + + private static final List LITERAL = List.of("literal"); + + private static final Set DISPATCHER_TYPES = Set.of( + "javax.servlet.RequestDispatcher", "jakarta.servlet.RequestDispatcher"); + private static final Set RESPONSE_TYPES = Set.of( + "javax.servlet.http.HttpServletResponse", "jakarta.servlet.http.HttpServletResponse"); + + public static final class Result { + public final List dispatches; + public final List unresolved; + + Result(List dispatches, List unresolved) { + this.dispatches = dispatches; + this.unresolved = unresolved; + } + } + + /** One detected dispatch site, before resolution. */ + private static final class Site { + final String id; + final String callee; + final String via; + /** The target expression's source text; {@code null} when the site has none to read. */ + final String targetExpr; + + Site(String id, String callee, String via, String targetExpr) { + this.id = id; + this.callee = callee; + this.via = via; + this.targetExpr = targetExpr; + } + } + + public static Result detect(String appName, Map modules, + Map artifacts) { + return detect(appName, modules, artifacts, 1, null); + } + + public static Result detect(String appName, Map modules, + Map artifacts, int analysisLevel, List callGraph) { + List sites = new ArrayList<>(); + if (modules != null) { + for (JModule module : modules.values()) { + collectTypes(appName, module.getTypes(), sites); + } + } + return resolve(sites, artifacts); + } + + // ---------------------------------------------------------------------------------------- + // Detection + // ---------------------------------------------------------------------------------------- + + private static void collectTypes(String appName, Map types, List sites) { + if (types == null) { + return; + } + for (JType type : types.values()) { + for (JCallable callable : type.getCallables().values()) { + for (Map.Entry e : callable.getBody().entrySet()) { + Site site = siteOf(appName, callable.getId(), e.getKey(), e.getValue()); + if (site != null) { + sites.add(site); + } + } + collectTypes(appName, callable.getTypes(), sites); + } + collectTypes(appName, type.getTypes(), sites); + } + } + + private static Site siteOf(String appName, String callableId, String localId, JBodyNode node) { + if (!"call".equals(node.getKind()) || node.getMethodName() == null) { + return null; + } + String method = node.getMethodName(); + String receiver = node.getReceiverType(); + String via; + String target; + if (("forward".equals(method) || "include".equals(method)) + && DISPATCHER_TYPES.contains(receiver)) { + via = method; + target = dispatcherArgument(node.getReceiverExpr()); + } else if ("sendRedirect".equals(method) && RESPONSE_TYPES.contains(receiver)) { + via = "redirect"; + List args = node.getArgumentExpr(); + target = args != null && !args.isEmpty() ? args.get(0) : null; + } else { + return null; + } + String signature = node.getCalleeSignature() != null ? node.getCalleeSignature() : method; + return new Site(CanId.ordinalId(callableId, localId), + CanId.externalId(appName, receiver, signature), via, target); + } + + /** + * The argument of the {@code getRequestDispatcher(...)} / {@code getNamedDispatcher(...)} call + * that produced a dispatcher, read off the dispatching call's receiver expression — the chain + * {@code ctx.getRequestDispatcher("/x.jsp")} ends at that call. A receiver that is not such a + * chain (a dispatcher held in a local) has no target to read here. + */ + // ponytail: a dispatcher stored in a local (`rd.forward(...)`) is non-literal; trace `rd` to its + // `getRequestDispatcher` initializer if that shape shows up in real code. + private static String dispatcherArgument(String receiverExpr) { + if (receiverExpr == null || !receiverExpr.endsWith(")")) { + return null; + } + int at = Math.max(receiverExpr.lastIndexOf("getRequestDispatcher("), + receiverExpr.lastIndexOf("getNamedDispatcher(")); + if (at < 0) { + return null; + } + int open = receiverExpr.indexOf('(', at); + String arg = receiverExpr.substring(open + 1, receiverExpr.length() - 1).trim(); + return arg.isEmpty() ? null : arg; + } + + // ---------------------------------------------------------------------------------------- + // Resolution + // ---------------------------------------------------------------------------------------- + + private static Result resolve(List sites, Map artifacts) { + List dispatches = new ArrayList<>(); + List unresolved = new ArrayList<>(); + for (Site site : sites) { + String literal = Literals.stringLiteral(site.targetExpr); + if (literal == null) { + unresolved.add(unresolved(site, null, "non-literal")); + continue; + } + List matched = matchPath(literal, artifacts); + if (matched.size() == 1) { + JViewDispatchEdge edge = new JViewDispatchEdge(); + edge.setSrc(site.id); + edge.setDst(matched.get(0).getId()); + edge.setVia(site.via); + edge.setProv(new ArrayList<>(LITERAL)); + dispatches.add(edge); + } else { + unresolved.add(unresolved(site, literal, + matched.isEmpty() ? "no-such-artifact" : "ambiguous")); + } + } + dispatches.sort(Comparator.comparing(JViewDispatchEdge::getSrc) + .thenComparing(JViewDispatchEdge::getDst)); + unresolved.sort(Comparator.comparing(JViewDispatchUnresolved::getSite) + .thenComparing(JViewDispatchUnresolved::getReason) + .thenComparing(u -> u.getTarget() == null ? "" : u.getTarget())); + return new Result(dispatches, unresolved); + } + + /** + * The artifacts a servlet-context-relative path names: those whose repo-relative path is the + * path itself or ends with {@code /}, segment-aligned. Any artifact qualifies, not only a + * view template — a redirect to a static page is still a dispatch to a file. A URL scheme or a + * query string is not a file: the query is dropped, a scheme never matches. + */ + private static List matchPath(String target, Map artifacts) { + List out = new ArrayList<>(); + if (artifacts == null || target.contains("://")) { + return out; + } + String path = target; + int q = path.indexOf('?'); + if (q >= 0) { + path = path.substring(0, q); + } + while (path.startsWith("/")) { + path = path.substring(1); + } + if (path.isEmpty()) { + return out; + } + for (JArtifact a : artifacts.values()) { + String p = a.getPath(); + if (p.equals(path) || p.endsWith("/" + path)) { + out.add(a); + } + } + return out; + } + + private static JViewDispatchUnresolved unresolved(Site site, String target, String reason) { + JViewDispatchUnresolved u = new JViewDispatchUnresolved(); + u.setSite(site.id); + u.setCallee(site.callee); + u.setTarget(target); + u.setVia(site.via); + u.setReason(reason); + u.setProv(new ArrayList<>(LITERAL)); + return u; + } +} diff --git a/src/main/java/com/ibm/cldk/schema/JViewDispatchEdge.java b/src/main/java/com/ibm/cldk/schema/JViewDispatchEdge.java new file mode 100644 index 00000000..7048880c --- /dev/null +++ b/src/main/java/com/ibm/cldk/schema/JViewDispatchEdge.java @@ -0,0 +1,26 @@ +package com.ibm.cldk.schema; + +import java.util.ArrayList; +import java.util.List; +import lombok.Data; + +/** + * One resolved view dispatch: a body node that hands the request to a view template — a + * {@code forward} / {@code include} / {@code sendRedirect} call, or a {@code return} in a controller + * — and the {@link JArtifact} it reaches (#259, spec 2026-09-11 D2). Application-scope like + * {@link JConfigUseEdge}: the endpoints span code and artifacts. + */ +@Data +public class JViewDispatchEdge { + /** The dispatching body node's global ordinal id, {@code @}. */ + private String src; + + /** The {@link JArtifact} id of the view reached. */ + private String dst; + + /** Mechanism: {@code forward | include | redirect | view-name | navigation}. */ + private String via; + + /** The tier that closed the target: {@code literal} at L1, {@code dataflow} at L3/L4. */ + private List prov = new ArrayList<>(); +} diff --git a/src/main/java/com/ibm/cldk/schema/JViewDispatchUnresolved.java b/src/main/java/com/ibm/cldk/schema/JViewDispatchUnresolved.java new file mode 100644 index 00000000..3ea7f966 --- /dev/null +++ b/src/main/java/com/ibm/cldk/schema/JViewDispatchUnresolved.java @@ -0,0 +1,30 @@ +package com.ibm.cldk.schema; + +import java.util.ArrayList; +import java.util.List; +import lombok.Data; + +/** + * A detected view dispatch that closed on no artifact — first-class so a page nobody can trace is as + * visible as one that resolves (#259, spec 2026-09-11 D4). Mirrors {@link JConfigRead}. + */ +@Data +public class JViewDispatchUnresolved { + /** The dispatching body node's global ordinal id. */ + private String site; + + /** The {@code @external} can-id of the dispatching callee. */ + private String callee; + + /** The decoded literal target when there was one; absent for {@code reason="non-literal"}. */ + private String target; + + /** Mechanism, as on {@link JViewDispatchEdge#getVia()}. */ + private String via; + + /** {@code non-literal}, {@code no-such-artifact}, or {@code ambiguous}. */ + private String reason; + + /** Every tier attempted before giving up. */ + private List prov = new ArrayList<>(); +} diff --git a/src/main/java/com/ibm/cldk/syntactic_analysis/controlflow/BodyNodeBuilder.java b/src/main/java/com/ibm/cldk/syntactic_analysis/controlflow/BodyNodeBuilder.java index 5d2f95a5..c922f915 100644 --- a/src/main/java/com/ibm/cldk/syntactic_analysis/controlflow/BodyNodeBuilder.java +++ b/src/main/java/com/ibm/cldk/syntactic_analysis/controlflow/BodyNodeBuilder.java @@ -14,6 +14,7 @@ import com.github.javaparser.ast.stmt.TryStmt; import com.ibm.cldk.schema.JBodyNode; import com.ibm.cldk.syntactic_analysis.L1BuildContext; +import java.util.List; import java.util.Map; /** @@ -112,6 +113,11 @@ private static void visit(ControlFlowGraph g, Statement s, L1BuildContext ctx) { } if (s.isReturnStmt()) { ensure(g, s, "return", ctx); + // The returned expression's source (#259): the one fact that lets a literal tier see + // `return "home"` in a controller. A bare `return` keeps the empty list. + s.asReturnStmt().getExpression().ifPresent(e -> + g.ensureNode(nodeIdFor(s), "return", ctx.spanOf(s)) + .setArgumentExpr(List.of(e.toString()))); return; } // break, continue, throw, expression statements, empty statements, etc. diff --git a/src/test/java/com/ibm/cldk/artifacts/ArtifactDiscoveryTest.java b/src/test/java/com/ibm/cldk/artifacts/ArtifactDiscoveryTest.java index 06aeec88..c758838d 100644 --- a/src/test/java/com/ibm/cldk/artifacts/ArtifactDiscoveryTest.java +++ b/src/test/java/com/ibm/cldk/artifacts/ArtifactDiscoveryTest.java @@ -281,4 +281,57 @@ void discover_slashContainingPatternMatchesTheFullPathNotJustTheBasename(@TempDi assertNotNull(other); assertEquals(List.of("unknown"), other.getRoles(), "a plain *.yml outside k8s/ falls to the generic rule"); } + + // ---- view templates (#259) -------------------------------------------------------------- + + private static JArtifact discoverOne(Path tmp, String relPath, String text) throws IOException { + Path f = tmp.resolve(relPath); + Files.createDirectories(f.getParent()); + Files.writeString(f, text, StandardCharsets.UTF_8); + return ArtifactDiscovery.discover(tmp, "app", true, 262144).get(relPath); + } + + @Test + void discover_classifiesJspFamilyAsViewTemplates(@TempDir Path tmp) throws IOException { + for (String rel : List.of("src/main/webapp/a.jsp", "src/main/webapp/b.jspx", + "src/main/webapp/WEB-INF/c.jspf", "src/main/webapp/WEB-INF/tags/d.tag", + "src/main/webapp/WEB-INF/tags/e.tagx")) { + JArtifact a = discoverOne(tmp, rel, "<%= 1 %>"); + assertEquals("jsp", a.getFormat(), rel); + assertEquals(List.of("view-template"), a.getRoles(), rel); + } + } + + @Test + void discover_classifiesFaceletsAsViewTemplates(@TempDir Path tmp) throws IOException { + JArtifact a = discoverOne(tmp, "src/main/webapp/login.xhtml", ""); + assertEquals("xhtml", a.getFormat()); + assertEquals(List.of("view-template"), a.getRoles()); + } + + @Test + void discover_classifiesHtmlUnderTemplatesOrWebInfAsViewTemplates(@TempDir Path tmp) + throws IOException { + JArtifact nested = discoverOne(tmp, "src/main/resources/templates/admin/users.html", ""); + assertEquals("html", nested.getFormat()); + assertEquals(List.of("view-template"), nested.getRoles()); + JArtifact webInf = discoverOne(tmp, "src/main/webapp/WEB-INF/views/home.html", ""); + assertEquals("html", webInf.getFormat()); + assertEquals(List.of("view-template"), webInf.getRoles()); + } + + @Test + void discover_leavesBareHtmlElsewhereUnknown(@TempDir Path tmp) throws IOException { + // A static page and a Thymeleaf template are not distinguishable by name (spec D1). + JArtifact a = discoverOne(tmp, "src/main/webapp/index.html", ""); + assertEquals("text", a.getFormat()); + assertEquals(List.of("unknown"), a.getRoles()); + } + + @Test + void discover_classifiesFacesConfigAsToolConfig(@TempDir Path tmp) throws IOException { + JArtifact a = discoverOne(tmp, "src/main/webapp/WEB-INF/faces-config.xml", ""); + assertEquals("xml", a.getFormat()); + assertEquals(List.of("tool-config"), a.getRoles()); + } } diff --git a/src/test/java/com/ibm/cldk/artifacts/ViewDispatchesTest.java b/src/test/java/com/ibm/cldk/artifacts/ViewDispatchesTest.java new file mode 100644 index 00000000..ebff6ee9 --- /dev/null +++ b/src/test/java/com/ibm/cldk/artifacts/ViewDispatchesTest.java @@ -0,0 +1,189 @@ +package com.ibm.cldk.artifacts; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +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.JModule; +import com.ibm.cldk.schema.JType; +import com.ibm.cldk.schema.JViewDispatchEdge; +import com.ibm.cldk.schema.JViewDispatchUnresolved; +import com.ibm.cldk.syntactic_analysis.L1Extractor; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * The view-dispatch literal tier end to end over the servlet API: real L1 extraction plus the + * artifact layer, then {@link ViewDispatches}. The fixture carries every outcome at once — three + * resolved mechanisms, a URL that is not a file, a variable target, and an ambiguous name — because + * the failure this pass guards against is a confident wrong edge, which only shows up when the + * unresolved buckets are checked alongside the resolved one. + * + *

The servlet API is stubbed as fixture source so receiver types resolve: detection is by + * declared receiver type, never by bare method name, exactly as {@link ConfigUses} does it. + */ +class ViewDispatchesTest { + + private static final String APP = "view-dispatch-test"; + + @TempDir + static Path root; + + private static ViewDispatches.Result result; + private static Map modules; + + static void write(String rel, String text) throws Exception { + Path f = root.resolve(rel); + Files.createDirectories(f.getParent()); + Files.writeString(f, text, StandardCharsets.UTF_8); + } + + @BeforeAll + static void analyze() throws Exception { + write("src/main/java/javax/servlet/RequestDispatcher.java", + "package javax.servlet;\npublic interface RequestDispatcher {\n" + + " void forward(ServletRequest q, ServletResponse s);\n" + + " void include(ServletRequest q, ServletResponse s);\n}\n"); + write("src/main/java/javax/servlet/ServletRequest.java", + "package javax.servlet;\npublic interface ServletRequest {\n" + + " RequestDispatcher getRequestDispatcher(String path);\n}\n"); + write("src/main/java/javax/servlet/ServletResponse.java", + "package javax.servlet;\npublic interface ServletResponse {}\n"); + write("src/main/java/javax/servlet/ServletContext.java", + "package javax.servlet;\npublic interface ServletContext {\n" + + " RequestDispatcher getRequestDispatcher(String path);\n}\n"); + write("src/main/java/javax/servlet/http/HttpServletRequest.java", + "package javax.servlet.http;\n" + + "public interface HttpServletRequest extends javax.servlet.ServletRequest {}\n"); + write("src/main/java/javax/servlet/http/HttpServletResponse.java", + "package javax.servlet.http;\n" + + "public interface HttpServletResponse extends javax.servlet.ServletResponse {\n" + + " void sendRedirect(String location);\n}\n"); + write("src/main/java/javax/servlet/http/HttpServlet.java", + "package javax.servlet.http;\npublic abstract class HttpServlet {\n" + + " public javax.servlet.ServletContext getServletContext() { return null; }\n}\n"); + + write("src/main/webapp/pages/x.jsp", "<%= 1 %>"); + write("src/main/webapp/y.jsp", "<%= 2 %>"); + write("src/main/webapp/WEB-INF/z.jsp", "<%= 3 %>"); + write("src/main/webapp/dup.jsp", "<%= 4 %>"); + write("src/main/webapp/other/dup.jsp", "<%= 5 %>"); + + write("src/main/java/demo/Front.java", + "package demo;\n" + + "import javax.servlet.http.*;\n" + + "public class Front extends HttpServlet {\n" + + " protected void doGet(HttpServletRequest req, HttpServletResponse res) {\n" + + " getServletContext().getRequestDispatcher(\"/pages/x.jsp\").forward(req, res);\n" + + " req.getRequestDispatcher(\"/y.jsp\").include(req, res);\n" + + " res.sendRedirect(\"/WEB-INF/z.jsp\");\n" + + " getServletContext().getRequestDispatcher(\"/servlet/Other\").forward(req, res);\n" + + " req.getRequestDispatcher(\"/dup.jsp\").forward(req, res);\n" + + " }\n" + + " void dyn(HttpServletRequest req, HttpServletResponse res, String page) {\n" + + " req.getRequestDispatcher(page).forward(req, res);\n" + + " }\n" + + "}\n"); + + modules = L1Extractor.extractAll(root, APP, null, new LinkedHashMap<>(), 1, 3, "ast"); + Map artifacts = ArtifactDiscovery.discover(root, APP, true, 262144); + result = ViewDispatches.detect(APP, modules, artifacts); + } + + /** The ordinal id of the {@code n}-th call to {@code method} inside {@code callable}, in source order. */ + private static String site(String callable, String method, int n) { + for (JModule m : modules.values()) { + for (JType t : m.getTypes().values()) { + for (JCallable c : t.getCallables().values()) { + if (!c.getSignature().startsWith(callable + "(")) { + continue; + } + List ids = c.getBody().entrySet().stream() + .filter(e -> "call".equals(e.getValue().getKind()) + && method.equals(e.getValue().getMethodName())) + .map(e -> CanId.ordinalId(c.getId(), e.getKey())) + .sorted(ViewDispatchesTest::bySourcePosition) + .collect(Collectors.toList()); + return ids.get(n); + } + } + } + throw new AssertionError("no callable " + callable); + } + + private static int bySourcePosition(String a, String b) { + String[] x = a.substring(a.lastIndexOf('@') + 1).split(":"); + String[] y = b.substring(b.lastIndexOf('@') + 1).split(":"); + int line = Integer.compare(Integer.parseInt(x[0]), Integer.parseInt(y[0])); + return line != 0 ? line : Integer.compare(Integer.parseInt(x[1]), Integer.parseInt(y[1])); + } + + private static String artifact(String rel) { + return CanId.artifactId(APP, rel); + } + + private static String edge(JViewDispatchEdge e) { + return e.getSrc() + " -[" + e.getVia() + " " + e.getProv() + "]-> " + e.getDst(); + } + + @Test + void theThreeServletMechanismsResolveToTheirArtifacts() { + List expected = List.of( + site("doGet", "forward", 0) + " -[forward [literal]]-> " + artifact("src/main/webapp/pages/x.jsp"), + site("doGet", "include", 0) + " -[include [literal]]-> " + artifact("src/main/webapp/y.jsp"), + site("doGet", "sendRedirect", 0) + " -[redirect [literal]]-> " + artifact("src/main/webapp/WEB-INF/z.jsp")); + assertEquals(expected.stream().sorted().collect(Collectors.toList()), + result.dispatches.stream().map(ViewDispatchesTest::edge).collect(Collectors.toList())); + } + + @Test + void aServletUrlIsNoSuchArtifact() { + JViewDispatchUnresolved u = unresolved(site("doGet", "forward", 1)); + assertEquals("/servlet/Other", u.getTarget()); + assertEquals("forward", u.getVia()); + assertEquals("no-such-artifact", u.getReason()); + assertEquals(List.of("literal"), u.getProv()); + assertEquals(CanId.externalId(APP, "javax.servlet.RequestDispatcher", + "forward(javax.servlet.ServletRequest, javax.servlet.ServletResponse)"), u.getCallee()); + } + + @Test + void aNameMatchingTwoArtifactsIsAmbiguous() { + JViewDispatchUnresolved u = unresolved(site("doGet", "forward", 2)); + assertEquals("/dup.jsp", u.getTarget()); + assertEquals("ambiguous", u.getReason()); + } + + @Test + void aVariableTargetIsNonLiteralAtLevelOne() { + JViewDispatchUnresolved u = unresolved(site("dyn", "forward", 0)); + assertNull(u.getTarget()); + assertEquals("forward", u.getVia()); + assertEquals("non-literal", u.getReason()); + assertEquals(List.of("literal"), u.getProv()); + } + + @Test + void nothingElseIsRecorded() { + assertEquals(3, result.dispatches.size()); + assertEquals(3, result.unresolved.size()); + } + + private static JViewDispatchUnresolved unresolved(String site) { + return result.unresolved.stream().filter(u -> site.equals(u.getSite())).findFirst() + .orElseThrow(() -> new AssertionError("no unresolved record at " + site + "; have " + + result.unresolved.stream().map(JViewDispatchUnresolved::getSite) + .collect(Collectors.toList()))); + } +} diff --git a/src/test/java/com/ibm/cldk/syntactic_analysis/ReturnExpressionTest.java b/src/test/java/com/ibm/cldk/syntactic_analysis/ReturnExpressionTest.java new file mode 100644 index 00000000..e62bd938 --- /dev/null +++ b/src/test/java/com/ibm/cldk/syntactic_analysis/ReturnExpressionTest.java @@ -0,0 +1,84 @@ +package com.ibm.cldk.syntactic_analysis; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.ibm.cldk.schema.JBodyNode; +import com.ibm.cldk.schema.JCallable; +import com.ibm.cldk.schema.JModule; +import com.ibm.cldk.schema.JType; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * A {@code return} body node carries the returned expression's source in {@code argument_expr} + * (#259, spec 2026-09-11 D3), and a bare {@code return} carries nothing. + */ +class ReturnExpressionTest { + + @TempDir + Path root; + + private Map bodyOf(String callableName) throws Exception { + Path src = root.resolve("src/main/java/demo"); + Files.createDirectories(src); + Files.writeString(src.resolve("Home.java"), + "package demo;\n" + + "public class Home {\n" + + " String view() { return \"home\"; }\n" + + " int sum(int a, int b) { return a + b; }\n" + + " void bare() { return; }\n" + + "}\n", + StandardCharsets.UTF_8); + Map modules = L1Extractor.extractAll( + root, "app", null, new LinkedHashMap<>(), 3, 3, "ast"); + for (JModule m : modules.values()) { + for (JType t : m.getTypes().values()) { + for (JCallable c : t.getCallables().values()) { + if (c.getSignature().startsWith(callableName + "(")) { + return c.getBody(); + } + } + } + } + throw new AssertionError("no callable " + callableName); + } + + private static List returns(Map body) { + List out = new ArrayList<>(); + for (JBodyNode n : body.values()) { + if ("return".equals(n.getKind())) { + out.add(n); + } + } + return out; + } + + @Test + void aReturnedLiteralIsCarriedAsSource() throws Exception { + List r = returns(bodyOf("view")); + assertEquals(1, r.size()); + assertEquals(List.of("\"home\""), r.get(0).getArgumentExpr()); + } + + @Test + void aReturnedExpressionIsCarriedVerbatim() throws Exception { + List r = returns(bodyOf("sum")); + assertEquals(1, r.size()); + assertEquals(List.of("a + b"), r.get(0).getArgumentExpr()); + } + + @Test + void aBareReturnCarriesNothing() throws Exception { + List r = returns(bodyOf("bare")); + assertEquals(1, r.size()); + assertTrue(r.get(0).getArgumentExpr().isEmpty()); + } +} From e606ae021c52693757513d7af79a18f6fdeeaef6 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 11 Sep 2026 09:16:37 -0400 Subject: [PATCH 2/5] feat(artifacts): view-dispatch dataflow tiers over the shared DataflowTiers (#259) Hoists the L3 intra and L4 interprocedural tiers out of ConfigUses into DataflowTiers, unchanged, and runs them over non-literal dispatch targets: a bare local closes over the callable's own DDG, a parameter over every call site that binds it, and any disagreement stays non-literal. --- .../com/ibm/cldk/artifacts/ConfigUses.java | 320 +--------------- .../com/ibm/cldk/artifacts/DataflowTiers.java | 354 ++++++++++++++++++ .../ibm/cldk/artifacts/ViewDispatches.java | 97 ++++- .../ibm/cldk/artifacts/ServletApiStubs.java | 45 +++ .../ViewDispatchDataflowTierTest.java | 147 ++++++++ .../cldk/artifacts/ViewDispatchesTest.java | 29 +- 6 files changed, 642 insertions(+), 350 deletions(-) create mode 100644 src/main/java/com/ibm/cldk/artifacts/DataflowTiers.java create mode 100644 src/test/java/com/ibm/cldk/artifacts/ServletApiStubs.java create mode 100644 src/test/java/com/ibm/cldk/artifacts/ViewDispatchDataflowTierTest.java diff --git a/src/main/java/com/ibm/cldk/artifacts/ConfigUses.java b/src/main/java/com/ibm/cldk/artifacts/ConfigUses.java index 8b0cc219..cca7b1ed 100644 --- a/src/main/java/com/ibm/cldk/artifacts/ConfigUses.java +++ b/src/main/java/com/ibm/cldk/artifacts/ConfigUses.java @@ -8,22 +8,17 @@ import com.ibm.cldk.schema.JConfigKey; import com.ibm.cldk.schema.JConfigRead; import com.ibm.cldk.schema.JConfigUseEdge; -import com.ibm.cldk.schema.JDdgEdge; import com.ibm.cldk.schema.JDecorator; import com.ibm.cldk.schema.JField; import com.ibm.cldk.schema.JModule; import com.ibm.cldk.schema.JParameter; import com.ibm.cldk.schema.JType; -import com.ibm.cldk.schema.Span; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.function.Function; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -169,17 +164,6 @@ Read closedTo(String closed) { } } - /** A callable plus the module source its spans slice, for the dataflow tiers. */ - private static final class Owner { - final JCallable callable; - final String source; - - Owner(JCallable callable, String source) { - this.callable = callable; - this.source = source; - } - } - /** * Detect config reads across the L1 tree and resolve them against the declared keys. * @@ -207,7 +191,7 @@ public static Result detect(String appName, Map modules, Map> keysByNamespace = keysByNamespace(artifacts); List reads = new ArrayList<>(); - Map owners = new LinkedHashMap<>(); + Map owners = new LinkedHashMap<>(); if (modules != null) { for (JModule module : modules.values()) { collectTypes(appName, module.getTypes(), module.getSource(), reads, owners); @@ -221,7 +205,7 @@ public static Result detect(String appName, Map modules, // ---------------------------------------------------------------------------------------- private static void collectTypes(String appName, Map types, String source, - List reads, Map owners) { + List reads, Map owners) { if (types == null) { return; } @@ -231,7 +215,7 @@ private static void collectTypes(String appName, Map types, Strin } private static void collectType(String appName, JType type, String source, List reads, - Map owners) { + Map owners) { for (JDecorator d : type.getDecorators()) { if (isAnnotation(d, CONFIGURATION_PROPERTIES_ANNOTATION)) { String prefix = annotationMember(d, "prefix", "value"); @@ -252,8 +236,8 @@ private static void collectType(String appName, JType type, String source, List< } private static void collectCallable(String appName, JCallable callable, String source, - List reads, Map owners) { - owners.put(callable.getId(), new Owner(callable, source)); + List reads, Map owners) { + owners.put(callable.getId(), new DataflowTiers.Owner(callable, source)); collectValueAnnotations(appName, callable.getDecorators(), callable.getId(), reads); for (JParameter p : callable.getParameters()) { // A parameter is not an addressable node in the v2 tree, so an injected parameter's read @@ -318,7 +302,7 @@ private static void collectCallSite(String appName, String callableId, String lo // ---------------------------------------------------------------------------------------- private static Result resolve(List reads, Map> keysByNamespace, - Map owners, int analysisLevel, List callGraph) { + Map owners, int analysisLevel, List callGraph) { List uses = new ArrayList<>(); List unresolved = new ArrayList<>(); @@ -334,11 +318,13 @@ private static Result resolve(List reads, Map> ke (read.literal != null ? closed : pending).add(read); } if (analysisLevel >= 3) { - pending = runTier(pending, closed, read -> IntraTier.close(read, owners)); + pending = runTier(pending, closed, + read -> DataflowTiers.intra(owners.get(read.callableId), read.localId, read.keyName)); } if (analysisLevel >= 4) { - CallSiteIndex sites = new CallSiteIndex(owners, callGraph); - pending = runTier(pending, closed, read -> InterprocTier.close(read, owners, sites)); + DataflowTiers.CallSiteIndex sites = new DataflowTiers.CallSiteIndex(owners, callGraph); + pending = runTier(pending, closed, + read -> DataflowTiers.interproc(owners.get(read.callableId), read.keyName, sites, owners)); } for (Read read : closed) { @@ -499,290 +485,6 @@ private static String annotationMember(JDecorator decorator, String... names) { return args.size() == 1 && args.get(0).indexOf('=') < 0 ? Literals.stringLiteral(args.get(0)) : null; } - // ---------------------------------------------------------------------------------------- - // L3 intra tier: close a bare name over its own callable's DDG - // ---------------------------------------------------------------------------------------- - - /** - * Closes {@code env.getProperty(key)} when every DDG-reaching definition of {@code key} at the - * call site is one and the same string literal. - */ - private static final class IntraTier { - - private IntraTier() {} - - static String close(Read read, Map owners) { - Owner owner = owners.get(read.callableId); - if (owner == null || owner.callable.getDdg() == null || owner.source == null) { - return null; - } - return reachingLiteral(owner.callable, owner.source, read.localId, read.keyName); - } - - /** - * The one string literal every reaching definition of {@code var} closes on at - * {@code useLocalId} — {@code null} if nothing reaches, if any reaching def is not a literal - * assignment, or if two reaching defs disagree. - * - *

Only {@code ssa} edges are consulted, and that is load-bearing. At {@code -a 4} - * the ddg also carries {@code points-to} edges, which may-alias this variable's use to an - * unrelated write that is not a {@code name = "literal"} shape. Since any non-closing - * reaching def kills resolution, letting an alias edge in would REMOVE an edge the ssa-only - * L3 set resolved cleanly — a widening that breaks {@code -a 3 ⊆ -a 4}, which is exactly what - * the additive contract forbids. A bare local can only be rebound by its own name, so - * widening past {@code ssa} here adds no soundness, only noise. - * - *

Span containment, not id equality. The CFG/DDG is statement-level while a - * {@code call} body node is keyed by its own narrower span, so a def's recorded use - * site is the enclosing statement — which coincides with the call's own id only when the call - * is a bare expression statement. Containment covers that and the common - * {@code return env.getProperty(key);} nesting without special-casing either. - */ - private static String reachingLiteral(JCallable c, String source, String useLocalId, - String var) { - JBodyNode use = c.getBody().get(useLocalId); - int[] useBytes = bytesOf(use); - if (useBytes == null) { - return null; - } - Set literals = new LinkedHashSet<>(); - boolean reached = false; - for (JDdgEdge edge : c.getDdg()) { - if (!var.equals(edge.getVar()) || !edge.getProv().contains("ssa")) { - continue; - } - int[] dstBytes = bytesOf(c.getBody().get(edge.getDst())); - if (dstBytes == null - || !(dstBytes[0] <= useBytes[0] && useBytes[1] <= dstBytes[1])) { - continue; // some other reference to `var`, not this call's - } - reached = true; - String literal = assignLiteral(source, c.getBody().get(edge.getSrc())); - if (literal == null) { - // Any non-closing reaching def kills the resolution rather than being skipped: - // two paths assigning different things means the read is genuinely ambiguous, and - // picking one would be a confident wrong answer. - return null; - } - literals.add(literal); - } - return reached && literals.size() == 1 ? literals.iterator().next() : null; - } - - /** - * The string constant a single definition closes on. Accepts only a single-target - * {@code = "literal"} — a declarator with one variable, or a plain assignment. A - * compound assignment, a multi-declarator statement, or a formal-parameter binding (no span) - * correctly never closes. - */ - private static String assignLiteral(String source, JBodyNode def) { - String text = slice(source, def); - if (text == null) { - return null; - } - com.github.javaparser.ast.stmt.Statement stmt; - try { - stmt = com.github.javaparser.StaticJavaParser.parseStatement( - text.endsWith(";") ? text : text + ";"); - } catch (RuntimeException e) { - return null; - } - if (!stmt.isExpressionStmt()) { - return null; - } - com.github.javaparser.ast.expr.Expression expr = stmt.asExpressionStmt().getExpression(); - if (expr.isVariableDeclarationExpr()) { - com.github.javaparser.ast.expr.VariableDeclarationExpr decl = - expr.asVariableDeclarationExpr(); - if (decl.getVariables().size() != 1) { - return null; - } - return Literals.literalOf(decl.getVariable(0).getInitializer().orElse(null)); - } - if (expr.isAssignExpr()) { - com.github.javaparser.ast.expr.AssignExpr assign = expr.asAssignExpr(); - if (assign.getOperator() != com.github.javaparser.ast.expr.AssignExpr.Operator.ASSIGN - || !assign.getTarget().isNameExpr()) { - return null; - } - return Literals.literalOf(assign.getValue()); - } - return null; - } - } - - // ---------------------------------------------------------------------------------------- - // L4 interprocedural tier: close a parameter over the call graph - // ---------------------------------------------------------------------------------------- - - /** Every in-project {@code call} body node, indexed by the callee it resolved to. */ - private static final class CallSiteIndex { - /** callee id → the call sites targeting it, each with its owning callable. */ - final Map> byCallee = new LinkedHashMap<>(); - /** Simple names of calls whose callee never resolved — the completeness spoilers. */ - final Set unresolvedNames = new LinkedHashSet<>(); - - static final class Site { - final JCallable caller; - final String source; - final String localId; - final JBodyNode node; - - Site(JCallable caller, String source, String localId, JBodyNode node) { - this.caller = caller; - this.source = source; - this.localId = localId; - this.node = node; - } - } - - CallSiteIndex(Map owners, List callGraph) { - for (Owner owner : owners.values()) { - for (Map.Entry e : owner.callable.getBody().entrySet()) { - JBodyNode node = e.getValue(); - if (!"call".equals(node.getKind())) { - continue; - } - if (node.getCallee() == null) { - if (node.getMethodName() != null) { - unresolvedNames.add(node.getMethodName()); - } - continue; - } - byCallee.computeIfAbsent(node.getCallee(), k -> new ArrayList<>()) - .add(new Site(owner.callable, owner.source, e.getKey(), node)); - } - } - } - } - - /** - * Closes {@code String read(String name) { return System.getenv(name); }} when {@code name} is a - * parameter that the callable never rebinds and every call site targeting it supplies - * the same literal. - * - *

Every is the word doing the work. A callee whose call-site set is incomplete must not - * close: a caller the analyzer could not see may supply a different key, and answering from the - * callers it did see would be a confident wrong answer. Known ceiling, stated rather than - * papered over: a {@code public} method can be called from outside the analyzed project - * entirely, which no in-project call graph can rule out — the same whole-application assumption - * codeanalyzer-python's tier makes. - */ - private static final class InterprocTier { - - private InterprocTier() {} - - static String close(Read read, Map owners, CallSiteIndex sites) { - Owner owner = owners.get(read.callableId); - if (owner == null) { - return null; - } - JCallable c = owner.callable; - int paramIndex = -1; - for (int i = 0; i < c.getParameters().size(); i++) { - if (read.keyName.equals(c.getParameters().get(i).getName())) { - paramIndex = i; - break; - } - } - if (paramIndex < 0 || locallyRedefined(c, read.keyName)) { - return null; - } - // A framework, not an in-project caller, supplies an entrypoint's arguments, so its - // call-site set is complete only by accident. - if (c.isEntrypoint()) { - return null; - } - List targeting = sites.byCallee.get(c.getId()); - if (targeting == null || targeting.isEmpty() - || sites.unresolvedNames.contains(simpleName(c))) { - return null; - } - Set literals = new LinkedHashSet<>(); - for (CallSiteIndex.Site site : targeting) { - String literal = siteLiteral(site, paramIndex, owners); - if (literal == null) { - return null; - } - literals.add(literal); - } - return literals.size() == 1 ? literals.iterator().next() : null; - } - - /** The literal a call site passes at {@code paramIndex}, directly or via one caller-side hop. */ - private static String siteLiteral(CallSiteIndex.Site site, int paramIndex, - Map owners) { - List args = site.node.getArgumentExpr(); - if (args == null || args.size() <= paramIndex) { - return null; - } - String arg = args.get(paramIndex); - String direct = Literals.stringLiteral(arg); - if (direct != null) { - return direct; - } - // ONE hop only, and deliberately not recursive: a chain of forwarding callers is a - // fixpoint, not a lookup, and this tier is a lookup. - if (!IDENTIFIER.matcher(arg).matches() || site.source == null - || site.caller.getDdg() == null) { - return null; - } - return IntraTier.reachingLiteral(site.caller, site.source, site.localId, arg); - } - - /** - * Whether {@code var} is rebound anywhere in the body. A parameter's only definition should be - * the synthetic formal binding, which carries no span; a def with a real span means a caller's - * argument is not provably what the read sees. - */ - private static boolean locallyRedefined(JCallable c, String var) { - if (c.getDdg() == null) { - return false; - } - for (JDdgEdge edge : c.getDdg()) { - if (var.equals(edge.getVar()) && bytesOf(c.getBody().get(edge.getSrc())) != null) { - return true; - } - } - return false; - } - - private static String simpleName(JCallable c) { - String signature = c.getSignature(); - if (signature == null) { - return ""; - } - int paren = signature.indexOf('('); - return paren < 0 ? signature : signature.substring(0, paren); - } - } - - // ---------------------------------------------------------------------------------------- - // Span slicing shared by both tiers - // ---------------------------------------------------------------------------------------- - - private static int[] bytesOf(JBodyNode node) { - if (node == null) { - return null; - } - Span span = node.getSpan(); - int[] bytes = span == null ? null : span.getBytes(); - return bytes != null && bytes.length >= 2 ? bytes : null; - } - - /** UTF-8 byte slice of the module source for a node's span; {@code null} when it has none. */ - private static String slice(String source, JBodyNode node) { - int[] bytes = bytesOf(node); - if (source == null || bytes == null) { - return null; - } - byte[] raw = source.getBytes(StandardCharsets.UTF_8); - if (bytes[0] < 0 || bytes[1] > raw.length || bytes[0] >= bytes[1]) { - return null; - } - return new String(raw, bytes[0], bytes[1] - bytes[0], StandardCharsets.UTF_8); - } - private static String ghost(String appName, String annotationType) { return CanId.externalId(appName, annotationType, "value()"); } diff --git a/src/main/java/com/ibm/cldk/artifacts/DataflowTiers.java b/src/main/java/com/ibm/cldk/artifacts/DataflowTiers.java new file mode 100644 index 00000000..b82afc0f --- /dev/null +++ b/src/main/java/com/ibm/cldk/artifacts/DataflowTiers.java @@ -0,0 +1,354 @@ +package com.ibm.cldk.artifacts; + +import com.ibm.cldk.schema.JBodyNode; +import com.ibm.cldk.schema.JCallEdge; +import com.ibm.cldk.schema.JCallable; +import com.ibm.cldk.schema.JDdgEdge; +import com.ibm.cldk.schema.JModule; +import com.ibm.cldk.schema.JType; +import com.ibm.cldk.schema.Span; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * The two dataflow tiers the config-use and view-dispatch passes share: closing a bare local over + * its own callable's DDG (L3, {@link #intra}) and a parameter over every call site that binds it + * (L4, {@link #interproc}). Both answer the same question — "is this name, at this use, one string + * literal on every path?" — and both refuse rather than guess when it is not. Hoisted out of + * {@link ConfigUses} (spec 2026-09-07) unchanged when the view-dispatch pass (#259) needed them. + */ +final class DataflowTiers { + + private DataflowTiers() {} + + /** A callable plus the module source its spans slice, for the dataflow tiers. */ + static final class Owner { + final JCallable callable; + final String source; + + Owner(JCallable callable, String source) { + this.callable = callable; + this.source = source; + } + } + + + /** Every callable in the tree with the source its spans slice, keyed by callable id. */ + static Map owners(Map modules) { + Map out = new LinkedHashMap<>(); + if (modules != null) { + for (JModule module : modules.values()) { + collect(module.getTypes(), module.getSource(), out); + } + } + return out; + } + + private static void collect(Map types, String source, Map out) { + if (types == null) { + return; + } + for (JType type : types.values()) { + for (JCallable c : type.getCallables().values()) { + out.put(c.getId(), new Owner(c, source)); + collect(c.getTypes(), source, out); + } + collect(type.getTypes(), source, out); + } + } + + /** L3: the one literal every reaching definition of {@code var} closes on at {@code useLocalId}. */ + static String intra(Owner owner, String useLocalId, String var) { + if (owner == null || owner.callable.getDdg() == null || owner.source == null) { + return null; + } + return IntraTier.reachingLiteral(owner.callable, owner.source, useLocalId, var); + } + + /** L4: the one literal every call site binds to the parameter named {@code var}. */ + static String interproc(Owner owner, String var, CallSiteIndex sites, Map owners) { + return InterprocTier.close(owner, var, sites, owners); + } + + // ---------------------------------------------------------------------------------------- + // L3 intra tier: close a bare name over its own callable's DDG + // ---------------------------------------------------------------------------------------- + + /** + * Closes {@code env.getProperty(key)} when every DDG-reaching definition of {@code key} at the + * call site is one and the same string literal. + */ + static final class IntraTier { + + private IntraTier() {} + + + /** + * The one string literal every reaching definition of {@code var} closes on at + * {@code useLocalId} — {@code null} if nothing reaches, if any reaching def is not a literal + * assignment, or if two reaching defs disagree. + * + *

Only {@code ssa} edges are consulted, and that is load-bearing. At {@code -a 4} + * the ddg also carries {@code points-to} edges, which may-alias this variable's use to an + * unrelated write that is not a {@code name = "literal"} shape. Since any non-closing + * reaching def kills resolution, letting an alias edge in would REMOVE an edge the ssa-only + * L3 set resolved cleanly — a widening that breaks {@code -a 3 ⊆ -a 4}, which is exactly what + * the additive contract forbids. A bare local can only be rebound by its own name, so + * widening past {@code ssa} here adds no soundness, only noise. + * + *

Span containment, not id equality. The CFG/DDG is statement-level while a + * {@code call} body node is keyed by its own narrower span, so a def's recorded use + * site is the enclosing statement — which coincides with the call's own id only when the call + * is a bare expression statement. Containment covers that and the common + * {@code return env.getProperty(key);} nesting without special-casing either. + */ + static String reachingLiteral(JCallable c, String source, String useLocalId, + String var) { + JBodyNode use = c.getBody().get(useLocalId); + int[] useBytes = bytesOf(use); + if (useBytes == null) { + return null; + } + Set literals = new LinkedHashSet<>(); + boolean reached = false; + for (JDdgEdge edge : c.getDdg()) { + if (!var.equals(edge.getVar()) || !edge.getProv().contains("ssa")) { + continue; + } + int[] dstBytes = bytesOf(c.getBody().get(edge.getDst())); + if (dstBytes == null + || !(dstBytes[0] <= useBytes[0] && useBytes[1] <= dstBytes[1])) { + continue; // some other reference to `var`, not this call's + } + reached = true; + String literal = assignLiteral(source, c.getBody().get(edge.getSrc())); + if (literal == null) { + // Any non-closing reaching def kills the resolution rather than being skipped: + // two paths assigning different things means the read is genuinely ambiguous, and + // picking one would be a confident wrong answer. + return null; + } + literals.add(literal); + } + return reached && literals.size() == 1 ? literals.iterator().next() : null; + } + + /** + * The string constant a single definition closes on. Accepts only a single-target + * {@code = "literal"} — a declarator with one variable, or a plain assignment. A + * compound assignment, a multi-declarator statement, or a formal-parameter binding (no span) + * correctly never closes. + */ + private static String assignLiteral(String source, JBodyNode def) { + String text = slice(source, def); + if (text == null) { + return null; + } + com.github.javaparser.ast.stmt.Statement stmt; + try { + stmt = com.github.javaparser.StaticJavaParser.parseStatement( + text.endsWith(";") ? text : text + ";"); + } catch (RuntimeException e) { + return null; + } + if (!stmt.isExpressionStmt()) { + return null; + } + com.github.javaparser.ast.expr.Expression expr = stmt.asExpressionStmt().getExpression(); + if (expr.isVariableDeclarationExpr()) { + com.github.javaparser.ast.expr.VariableDeclarationExpr decl = + expr.asVariableDeclarationExpr(); + if (decl.getVariables().size() != 1) { + return null; + } + return Literals.literalOf(decl.getVariable(0).getInitializer().orElse(null)); + } + if (expr.isAssignExpr()) { + com.github.javaparser.ast.expr.AssignExpr assign = expr.asAssignExpr(); + if (assign.getOperator() != com.github.javaparser.ast.expr.AssignExpr.Operator.ASSIGN + || !assign.getTarget().isNameExpr()) { + return null; + } + return Literals.literalOf(assign.getValue()); + } + return null; + } + } + + // ---------------------------------------------------------------------------------------- + // L4 interprocedural tier: close a parameter over the call graph + // ---------------------------------------------------------------------------------------- + + /** Every in-project {@code call} body node, indexed by the callee it resolved to. */ + static final class CallSiteIndex { + /** callee id → the call sites targeting it, each with its owning callable. */ + final Map> byCallee = new LinkedHashMap<>(); + /** Simple names of calls whose callee never resolved — the completeness spoilers. */ + final Set unresolvedNames = new LinkedHashSet<>(); + + static final class Site { + final JCallable caller; + final String source; + final String localId; + final JBodyNode node; + + Site(JCallable caller, String source, String localId, JBodyNode node) { + this.caller = caller; + this.source = source; + this.localId = localId; + this.node = node; + } + } + + CallSiteIndex(Map owners, List callGraph) { + for (Owner owner : owners.values()) { + for (Map.Entry e : owner.callable.getBody().entrySet()) { + JBodyNode node = e.getValue(); + if (!"call".equals(node.getKind())) { + continue; + } + if (node.getCallee() == null) { + if (node.getMethodName() != null) { + unresolvedNames.add(node.getMethodName()); + } + continue; + } + byCallee.computeIfAbsent(node.getCallee(), k -> new ArrayList<>()) + .add(new Site(owner.callable, owner.source, e.getKey(), node)); + } + } + } + } + + /** + * Closes {@code String read(String name) { return System.getenv(name); }} when {@code name} is a + * parameter that the callable never rebinds and every call site targeting it supplies + * the same literal. + * + *

Every is the word doing the work. A callee whose call-site set is incomplete must not + * close: a caller the analyzer could not see may supply a different key, and answering from the + * callers it did see would be a confident wrong answer. Known ceiling, stated rather than + * papered over: a {@code public} method can be called from outside the analyzed project + * entirely, which no in-project call graph can rule out — the same whole-application assumption + * codeanalyzer-python's tier makes. + */ + static final class InterprocTier { + + private InterprocTier() {} + + static String close(Owner owner, String var, CallSiteIndex sites, Map owners) { + if (owner == null) { + return null; + } + JCallable c = owner.callable; + int paramIndex = -1; + for (int i = 0; i < c.getParameters().size(); i++) { + if (var.equals(c.getParameters().get(i).getName())) { + paramIndex = i; + break; + } + } + if (paramIndex < 0 || locallyRedefined(c, var)) { + return null; + } + // A framework, not an in-project caller, supplies an entrypoint's arguments, so its + // call-site set is complete only by accident. + if (c.isEntrypoint()) { + return null; + } + List targeting = sites.byCallee.get(c.getId()); + if (targeting == null || targeting.isEmpty() + || sites.unresolvedNames.contains(simpleName(c))) { + return null; + } + Set literals = new LinkedHashSet<>(); + for (CallSiteIndex.Site site : targeting) { + String literal = siteLiteral(site, paramIndex, owners); + if (literal == null) { + return null; + } + literals.add(literal); + } + return literals.size() == 1 ? literals.iterator().next() : null; + } + + /** The literal a call site passes at {@code paramIndex}, directly or via one caller-side hop. */ + private static String siteLiteral(CallSiteIndex.Site site, int paramIndex, + Map owners) { + List args = site.node.getArgumentExpr(); + if (args == null || args.size() <= paramIndex) { + return null; + } + String arg = args.get(paramIndex); + String direct = Literals.stringLiteral(arg); + if (direct != null) { + return direct; + } + // ONE hop only, and deliberately not recursive: a chain of forwarding callers is a + // fixpoint, not a lookup, and this tier is a lookup. + if (!Literals.IDENTIFIER.matcher(arg).matches() || site.source == null + || site.caller.getDdg() == null) { + return null; + } + return IntraTier.reachingLiteral(site.caller, site.source, site.localId, arg); + } + + /** + * Whether {@code var} is rebound anywhere in the body. A parameter's only definition should be + * the synthetic formal binding, which carries no span; a def with a real span means a caller's + * argument is not provably what the read sees. + */ + private static boolean locallyRedefined(JCallable c, String var) { + if (c.getDdg() == null) { + return false; + } + for (JDdgEdge edge : c.getDdg()) { + if (var.equals(edge.getVar()) && bytesOf(c.getBody().get(edge.getSrc())) != null) { + return true; + } + } + return false; + } + + private static String simpleName(JCallable c) { + String signature = c.getSignature(); + if (signature == null) { + return ""; + } + int paren = signature.indexOf('('); + return paren < 0 ? signature : signature.substring(0, paren); + } + } + + // ---------------------------------------------------------------------------------------- + // Span slicing shared by both tiers + // ---------------------------------------------------------------------------------------- + + static int[] bytesOf(JBodyNode node) { + if (node == null) { + return null; + } + Span span = node.getSpan(); + int[] bytes = span == null ? null : span.getBytes(); + return bytes != null && bytes.length >= 2 ? bytes : null; + } + + /** UTF-8 byte slice of the module source for a node's span; {@code null} when it has none. */ + static String slice(String source, JBodyNode node) { + int[] bytes = bytesOf(node); + if (source == null || bytes == null) { + return null; + } + byte[] raw = source.getBytes(StandardCharsets.UTF_8); + if (bytes[0] < 0 || bytes[1] > raw.length || bytes[0] >= bytes[1]) { + return null; + } + return new String(raw, bytes[0], bytes[1] - bytes[0], StandardCharsets.UTF_8); + } + +} diff --git a/src/main/java/com/ibm/cldk/artifacts/ViewDispatches.java b/src/main/java/com/ibm/cldk/artifacts/ViewDispatches.java index 9958904e..1a0bfab4 100644 --- a/src/main/java/com/ibm/cldk/artifacts/ViewDispatches.java +++ b/src/main/java/com/ibm/cldk/artifacts/ViewDispatches.java @@ -14,6 +14,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Function; /** * The view-dispatch pass (#259, spec 2026-09-11): joins the body nodes that hand a request to a view @@ -31,7 +32,10 @@ public final class ViewDispatches { private ViewDispatches() {} + /** Provenance vocabulary, shared with {@link ConfigUses}: exactly {@code literal|dataflow}. */ private static final List LITERAL = List.of("literal"); + private static final List DATAFLOW = List.of("dataflow"); + private static final List LITERAL_AND_DATAFLOW = List.of("literal", "dataflow"); private static final Set DISPATCHER_TYPES = Set.of( "javax.servlet.RequestDispatcher", "jakarta.servlet.RequestDispatcher"); @@ -55,12 +59,39 @@ private static final class Site { final String via; /** The target expression's source text; {@code null} when the site has none to read. */ final String targetExpr; + // Dataflow anchors, for a target that is a bare name — the only shape a tier can trace. + final String callableId; + final String localId; + /** The literal the target closed on, by the call site itself or by a tier; else null. */ + final String literal; + final boolean closedByDataflow; - Site(String id, String callee, String via, String targetExpr) { + Site(String id, String callee, String via, String targetExpr, String callableId, + String localId) { + this(id, callee, via, targetExpr, callableId, localId, + Literals.stringLiteral(targetExpr), false); + } + + private Site(String id, String callee, String via, String targetExpr, String callableId, + String localId, String literal, boolean closedByDataflow) { this.id = id; this.callee = callee; this.via = via; this.targetExpr = targetExpr; + this.callableId = callableId; + this.localId = localId; + this.literal = literal; + this.closedByDataflow = closedByDataflow; + } + + /** The bare identifier a tier can trace, or null for a literal or a compound expression. */ + String varName() { + return literal == null && targetExpr != null + && Literals.IDENTIFIER.matcher(targetExpr).matches() ? targetExpr : null; + } + + Site closedTo(String traced) { + return new Site(id, callee, via, targetExpr, callableId, localId, traced, true); } } @@ -77,7 +108,7 @@ public static Result detect(String appName, Map modules, collectTypes(appName, module.getTypes(), sites); } } - return resolve(sites, artifacts); + return resolve(sites, artifacts, modules, analysisLevel, callGraph); } // ---------------------------------------------------------------------------------------- @@ -123,7 +154,7 @@ private static Site siteOf(String appName, String callableId, String localId, JB } String signature = node.getCalleeSignature() != null ? node.getCalleeSignature() : method; return new Site(CanId.ordinalId(callableId, localId), - CanId.externalId(appName, receiver, signature), via, target); + CanId.externalId(appName, receiver, signature), via, target, callableId, localId); } /** @@ -152,28 +183,51 @@ private static String dispatcherArgument(String receiverExpr) { // Resolution // ---------------------------------------------------------------------------------------- - private static Result resolve(List sites, Map artifacts) { + /** + * Tiers run over what the previous one could not close, in increasing cost, exactly as in + * {@link ConfigUses}: a site closed at a lower tier is never recomputed, so + * {@code view_dispatches(-a 1) ⊆ view_dispatches(-a 3) ⊆ view_dispatches(-a 4)}. + */ + private static Result resolve(List sites, Map artifacts, + Map modules, int analysisLevel, List callGraph) { List dispatches = new ArrayList<>(); List unresolved = new ArrayList<>(); + List attempted = analysisLevel >= 3 ? LITERAL_AND_DATAFLOW : LITERAL; + + List closed = new ArrayList<>(); + List pending = new ArrayList<>(); for (Site site : sites) { - String literal = Literals.stringLiteral(site.targetExpr); - if (literal == null) { - unresolved.add(unresolved(site, null, "non-literal")); - continue; + (site.literal != null ? closed : pending).add(site); + } + if (analysisLevel >= 3 && !pending.isEmpty()) { + Map owners = DataflowTiers.owners(modules); + pending = runTier(pending, closed, + s -> DataflowTiers.intra(owners.get(s.callableId), s.localId, s.varName())); + if (analysisLevel >= 4) { + DataflowTiers.CallSiteIndex index = new DataflowTiers.CallSiteIndex(owners, callGraph); + pending = runTier(pending, closed, + s -> DataflowTiers.interproc(owners.get(s.callableId), s.varName(), index, owners)); } - List matched = matchPath(literal, artifacts); + } + + for (Site site : closed) { + List matched = matchPath(site.literal, artifacts); if (matched.size() == 1) { JViewDispatchEdge edge = new JViewDispatchEdge(); edge.setSrc(site.id); edge.setDst(matched.get(0).getId()); edge.setVia(site.via); - edge.setProv(new ArrayList<>(LITERAL)); + // The tier that CLOSED this site, not every tier attempted (same rule as config_uses). + edge.setProv(new ArrayList<>(site.closedByDataflow ? DATAFLOW : LITERAL)); dispatches.add(edge); } else { - unresolved.add(unresolved(site, literal, - matched.isEmpty() ? "no-such-artifact" : "ambiguous")); + unresolved.add(unresolved(site, site.literal, + matched.isEmpty() ? "no-such-artifact" : "ambiguous", attempted)); } } + for (Site site : pending) { + unresolved.add(unresolved(site, null, "non-literal", attempted)); + } dispatches.sort(Comparator.comparing(JViewDispatchEdge::getSrc) .thenComparing(JViewDispatchEdge::getDst)); unresolved.sort(Comparator.comparing(JViewDispatchUnresolved::getSite) @@ -213,14 +267,29 @@ private static List matchPath(String target, Map a return out; } - private static JViewDispatchUnresolved unresolved(Site site, String target, String reason) { + private static List runTier(List pending, List closed, + Function tier) { + List still = new ArrayList<>(); + for (Site site : pending) { + String traced = site.varName() == null ? null : tier.apply(site); + if (traced == null) { + still.add(site); + } else { + closed.add(site.closedTo(traced)); + } + } + return still; + } + + private static JViewDispatchUnresolved unresolved(Site site, String target, String reason, + List attempted) { JViewDispatchUnresolved u = new JViewDispatchUnresolved(); u.setSite(site.id); u.setCallee(site.callee); u.setTarget(target); u.setVia(site.via); u.setReason(reason); - u.setProv(new ArrayList<>(LITERAL)); + u.setProv(new ArrayList<>(attempted)); return u; } } diff --git a/src/test/java/com/ibm/cldk/artifacts/ServletApiStubs.java b/src/test/java/com/ibm/cldk/artifacts/ServletApiStubs.java new file mode 100644 index 00000000..e5aa0826 --- /dev/null +++ b/src/test/java/com/ibm/cldk/artifacts/ServletApiStubs.java @@ -0,0 +1,45 @@ +package com.ibm.cldk.artifacts; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * The slice of the servlet API the view-dispatch tests need, written as fixture source so receiver + * types resolve without a jar on the classpath. + */ +final class ServletApiStubs { + + private ServletApiStubs() {} + + static void write(Path root) throws Exception { + write(root, "src/main/java/javax/servlet/RequestDispatcher.java", + "package javax.servlet;\npublic interface RequestDispatcher {\n" + + " void forward(ServletRequest q, ServletResponse s);\n" + + " void include(ServletRequest q, ServletResponse s);\n}\n"); + write(root, "src/main/java/javax/servlet/ServletRequest.java", + "package javax.servlet;\npublic interface ServletRequest {\n" + + " RequestDispatcher getRequestDispatcher(String path);\n}\n"); + write(root, "src/main/java/javax/servlet/ServletResponse.java", + "package javax.servlet;\npublic interface ServletResponse {}\n"); + write(root, "src/main/java/javax/servlet/ServletContext.java", + "package javax.servlet;\npublic interface ServletContext {\n" + + " RequestDispatcher getRequestDispatcher(String path);\n}\n"); + write(root, "src/main/java/javax/servlet/http/HttpServletRequest.java", + "package javax.servlet.http;\n" + + "public interface HttpServletRequest extends javax.servlet.ServletRequest {}\n"); + write(root, "src/main/java/javax/servlet/http/HttpServletResponse.java", + "package javax.servlet.http;\n" + + "public interface HttpServletResponse extends javax.servlet.ServletResponse {\n" + + " void sendRedirect(String location);\n}\n"); + write(root, "src/main/java/javax/servlet/http/HttpServlet.java", + "package javax.servlet.http;\npublic abstract class HttpServlet {\n" + + " public javax.servlet.ServletContext getServletContext() { return null; }\n}\n"); + } + + static void write(Path root, String rel, String text) throws Exception { + Path f = root.resolve(rel); + Files.createDirectories(f.getParent()); + Files.writeString(f, text, StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/com/ibm/cldk/artifacts/ViewDispatchDataflowTierTest.java b/src/test/java/com/ibm/cldk/artifacts/ViewDispatchDataflowTierTest.java new file mode 100644 index 00000000..0e4de2f5 --- /dev/null +++ b/src/test/java/com/ibm/cldk/artifacts/ViewDispatchDataflowTierTest.java @@ -0,0 +1,147 @@ +package com.ibm.cldk.artifacts; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.ibm.cldk.schema.CanId; +import com.ibm.cldk.schema.JArtifact; +import com.ibm.cldk.schema.JModule; +import com.ibm.cldk.schema.JViewDispatchUnresolved; +import com.ibm.cldk.syntactic_analysis.L1Extractor; +import com.ibm.cldk.syntactic_analysis.L2CallGraph; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * The view-dispatch dataflow tiers: closing a non-literal target over the callable's own DDG (L3) + * and over the call graph (L4), with the refusals that keep the pass from guessing. Same shape as + * {@link ConfigDataflowTierTest}. + */ +class ViewDispatchDataflowTierTest { + + private static final String APP = "view-dataflow-test"; + + @TempDir + Path root; + + private ViewDispatches.Result run(String source, int analysisLevel) throws Exception { + ServletApiStubs.write(root); + ServletApiStubs.write(root, "src/main/webapp/pages/x.jsp", "<%= 1 %>"); + ServletApiStubs.write(root, "src/main/webapp/y.jsp", "<%= 2 %>"); + ServletApiStubs.write(root, "src/main/java/demo/Front.java", source); + Map modules = L1Extractor.extractAll( + root, APP, null, new LinkedHashMap<>(), Math.max(analysisLevel, 1), 3, "ast"); + Map artifacts = ArtifactDiscovery.discover(root, APP, true, 262144); + L2CallGraph.Result l2 = analysisLevel >= 2 + ? L2CallGraph.build(APP, modules, null, false) + : null; + return ViewDispatches.detect(APP, modules, artifacts, analysisLevel, + l2 == null ? null : l2.callGraph()); + } + + private static List edges(ViewDispatches.Result r) { + return r.dispatches.stream() + .map(e -> e.getVia() + " " + e.getProv() + " -> " + e.getDst()) + .collect(Collectors.toList()); + } + + private static JViewDispatchUnresolved only(ViewDispatches.Result r) { + assertEquals(1, r.unresolved.size(), "exactly one unresolved record"); + return r.unresolved.get(0); + } + + private static final String HEAD = + "package demo;\n" + + "import javax.servlet.http.*;\n" + + "public class Front extends HttpServlet {\n"; + + private static final String LOCAL_VARIABLE = HEAD + + " void doGet(HttpServletRequest req, HttpServletResponse res) {\n" + + " String page = \"/pages/x.jsp\";\n" + + " req.getRequestDispatcher(page).forward(req, res);\n" + + " }\n}\n"; + + @Test + void aLocalIsNonLiteralAtLevelOne() throws Exception { + ViewDispatches.Result r = run(LOCAL_VARIABLE, 1); + assertTrue(r.dispatches.isEmpty()); + JViewDispatchUnresolved u = only(r); + assertEquals("non-literal", u.getReason()); + assertEquals(List.of("literal"), u.getProv()); + } + + @Test + void aLocalClosesOverTheDdgAtLevelThree() throws Exception { + ViewDispatches.Result r = run(LOCAL_VARIABLE, 3); + assertEquals(List.of("forward [dataflow] -> " + CanId.artifactId(APP, "src/main/webapp/pages/x.jsp")), + edges(r)); + assertTrue(r.unresolved.isEmpty()); + } + + @Test + void twoDisagreeingDefinitionsStayNonLiteral() throws Exception { + ViewDispatches.Result r = run(HEAD + + " void doGet(HttpServletRequest req, HttpServletResponse res, boolean b) {\n" + + " String page = \"/pages/x.jsp\";\n" + + " if (b) { page = \"/y.jsp\"; }\n" + + " req.getRequestDispatcher(page).forward(req, res);\n" + + " }\n}\n", 3); + assertTrue(r.dispatches.isEmpty()); + JViewDispatchUnresolved u = only(r); + assertEquals("non-literal", u.getReason()); + assertEquals(List.of("literal", "dataflow"), u.getProv(), "both tiers were attempted"); + } + + @Test + void aTracedLiteralThatNamesNoFileIsNoSuchArtifact() throws Exception { + ViewDispatches.Result r = run(HEAD + + " void doGet(HttpServletRequest req, HttpServletResponse res) {\n" + + " String page = \"/servlet/Other\";\n" + + " req.getRequestDispatcher(page).forward(req, res);\n" + + " }\n}\n", 3); + JViewDispatchUnresolved u = only(r); + assertEquals("no-such-artifact", u.getReason()); + assertEquals("/servlet/Other", u.getTarget()); + assertEquals(List.of("literal", "dataflow"), u.getProv()); + } + + // The helper takes no servlet-typed parameter on purpose: the Jakarta finder marks any method + // with an HttpServletRequest parameter as an entrypoint, and the interprocedural tier rightly + // refuses to bind an entrypoint's parameter from a call site (the container binds it). + private static final String PARAMETER = HEAD + + " void doGet(HttpServletRequest req, HttpServletResponse res) { show(\"/y.jsp\"); }\n" + + " void show(String page) {\n" + + " getServletContext().getRequestDispatcher(page).forward(null, null);\n" + + " }\n}\n"; + + @Test + void aParameterStaysNonLiteralAtLevelThree() throws Exception { + ViewDispatches.Result r = run(PARAMETER, 3); + assertTrue(r.dispatches.isEmpty()); + assertEquals("non-literal", only(r).getReason()); + } + + @Test + void aParameterClosesOverTheCallGraphAtLevelFour() throws Exception { + ViewDispatches.Result r = run(PARAMETER, 4); + assertEquals(List.of("forward [dataflow] -> " + CanId.artifactId(APP, "src/main/webapp/y.jsp")), + edges(r)); + } + + @Test + void twoCallersWithDifferentPagesStayNonLiteral() throws Exception { + ViewDispatches.Result r = run(HEAD + + " void a() { show(\"/y.jsp\"); }\n" + + " void b() { show(\"/pages/x.jsp\"); }\n" + + " void show(String page) {\n" + + " getServletContext().getRequestDispatcher(page).forward(null, null);\n" + + " }\n}\n", 4); + assertTrue(r.dispatches.isEmpty()); + assertEquals("non-literal", only(r).getReason()); + } +} diff --git a/src/test/java/com/ibm/cldk/artifacts/ViewDispatchesTest.java b/src/test/java/com/ibm/cldk/artifacts/ViewDispatchesTest.java index ebff6ee9..27790b31 100644 --- a/src/test/java/com/ibm/cldk/artifacts/ViewDispatchesTest.java +++ b/src/test/java/com/ibm/cldk/artifacts/ViewDispatchesTest.java @@ -12,8 +12,6 @@ import com.ibm.cldk.schema.JViewDispatchEdge; import com.ibm.cldk.schema.JViewDispatchUnresolved; import com.ibm.cldk.syntactic_analysis.L1Extractor; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.nio.file.Path; import java.util.LinkedHashMap; import java.util.List; @@ -44,35 +42,12 @@ class ViewDispatchesTest { private static Map modules; static void write(String rel, String text) throws Exception { - Path f = root.resolve(rel); - Files.createDirectories(f.getParent()); - Files.writeString(f, text, StandardCharsets.UTF_8); + ServletApiStubs.write(root, rel, text); } @BeforeAll static void analyze() throws Exception { - write("src/main/java/javax/servlet/RequestDispatcher.java", - "package javax.servlet;\npublic interface RequestDispatcher {\n" - + " void forward(ServletRequest q, ServletResponse s);\n" - + " void include(ServletRequest q, ServletResponse s);\n}\n"); - write("src/main/java/javax/servlet/ServletRequest.java", - "package javax.servlet;\npublic interface ServletRequest {\n" - + " RequestDispatcher getRequestDispatcher(String path);\n}\n"); - write("src/main/java/javax/servlet/ServletResponse.java", - "package javax.servlet;\npublic interface ServletResponse {}\n"); - write("src/main/java/javax/servlet/ServletContext.java", - "package javax.servlet;\npublic interface ServletContext {\n" - + " RequestDispatcher getRequestDispatcher(String path);\n}\n"); - write("src/main/java/javax/servlet/http/HttpServletRequest.java", - "package javax.servlet.http;\n" - + "public interface HttpServletRequest extends javax.servlet.ServletRequest {}\n"); - write("src/main/java/javax/servlet/http/HttpServletResponse.java", - "package javax.servlet.http;\n" - + "public interface HttpServletResponse extends javax.servlet.ServletResponse {\n" - + " void sendRedirect(String location);\n}\n"); - write("src/main/java/javax/servlet/http/HttpServlet.java", - "package javax.servlet.http;\npublic abstract class HttpServlet {\n" - + " public javax.servlet.ServletContext getServletContext() { return null; }\n}\n"); + ServletApiStubs.write(root); write("src/main/webapp/pages/x.jsp", "<%= 1 %>"); write("src/main/webapp/y.jsp", "<%= 2 %>"); From cf8ff8ae12fd38c445a2fd1f244276c01b71b6f9 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 11 Sep 2026 09:18:10 -0400 Subject: [PATCH 3/5] feat(artifacts): Spring view names resolve through the declared or default view resolver (#259) A Spring entrypoint's String return, new ModelAndView(name) and setViewName(name) are view-name dispatch sites. Names expand through spring.mvc.view.* when declared and spring.thymeleaf.* or Thymeleaf's defaults otherwise; redirect:/forward: prefixes re-dispatch as paths; a return outside a Spring entrypoint is never a view name. --- .../ibm/cldk/artifacts/ViewDispatches.java | 111 ++++++++++++-- .../cldk/artifacts/ViewNameDispatchTest.java | 139 ++++++++++++++++++ 2 files changed, 241 insertions(+), 9 deletions(-) create mode 100644 src/test/java/com/ibm/cldk/artifacts/ViewNameDispatchTest.java diff --git a/src/main/java/com/ibm/cldk/artifacts/ViewDispatches.java b/src/main/java/com/ibm/cldk/artifacts/ViewDispatches.java index 1a0bfab4..45dc3021 100644 --- a/src/main/java/com/ibm/cldk/artifacts/ViewDispatches.java +++ b/src/main/java/com/ibm/cldk/artifacts/ViewDispatches.java @@ -5,6 +5,7 @@ import com.ibm.cldk.schema.JBodyNode; import com.ibm.cldk.schema.JCallEdge; import com.ibm.cldk.schema.JCallable; +import com.ibm.cldk.schema.JConfigKey; import com.ibm.cldk.schema.JModule; import com.ibm.cldk.schema.JType; import com.ibm.cldk.schema.JViewDispatchEdge; @@ -41,6 +42,12 @@ private ViewDispatches() {} "javax.servlet.RequestDispatcher", "jakarta.servlet.RequestDispatcher"); private static final Set RESPONSE_TYPES = Set.of( "javax.servlet.http.HttpServletResponse", "jakarta.servlet.http.HttpServletResponse"); + private static final String MODEL_AND_VIEW = "org.springframework.web.servlet.ModelAndView"; + private static final String STRING = "java.lang.String"; + + /** Thymeleaf's own defaults, applied when the application declares no resolver keys. */ + private static final String THYMELEAF_PREFIX = "classpath:/templates/"; + private static final String THYMELEAF_SUFFIX = ".html"; public static final class Result { public final List dispatches; @@ -120,9 +127,13 @@ private static void collectTypes(String appName, Map types, List< return; } for (JType type : types.values()) { + boolean springType = type.getEntrypointFrameworks().contains("spring"); for (JCallable callable : type.getCallables().values()) { + // The view-name gate (spec D4): only a Spring entrypoint's `return "x"` is a view + // name, so `return "home"` in ordinary code never matches a template named home. + boolean viewNames = springType || callable.getEntrypointFrameworks().contains("spring"); for (Map.Entry e : callable.getBody().entrySet()) { - Site site = siteOf(appName, callable.getId(), e.getKey(), e.getValue()); + Site site = siteOf(appName, callable, e.getKey(), e.getValue(), viewNames); if (site != null) { sites.add(site); } @@ -133,22 +144,46 @@ private static void collectTypes(String appName, Map types, List< } } - private static Site siteOf(String appName, String callableId, String localId, JBodyNode node) { - if (!"call".equals(node.getKind()) || node.getMethodName() == null) { + private static Site siteOf(String appName, JCallable callable, String localId, JBodyNode node, + boolean viewNames) { + String callableId = callable.getId(); + List args = node.getArgumentExpr(); + String arg0 = args != null && !args.isEmpty() ? args.get(0) : null; + if ("return".equals(node.getKind())) { + // A String-returning controller method's return IS the view name. A ModelAndView-returning + // one is anchored on the construction / setViewName site instead, which carries the name. + if (!viewNames || arg0 == null || !STRING.equals(callable.getReturnType())) { + return null; + } + return new Site(CanId.ordinalId(callableId, localId), callableId, "view-name", arg0, + callableId, localId); + } + if (!"call".equals(node.getKind())) { return null; } - String method = node.getMethodName(); String receiver = node.getReceiverType(); + String method = node.getMethodName(); String via; String target; - if (("forward".equals(method) || "include".equals(method)) + if (node.isConstructorCall() && MODEL_AND_VIEW.equals(receiver)) { + if (arg0 == null) { + return null; // `new ModelAndView()` names no view; setViewName will + } + via = "view-name"; + target = arg0; + method = ""; + } else if (method == null) { + return null; + } else if ("setViewName".equals(method) && MODEL_AND_VIEW.equals(receiver)) { + via = "view-name"; + target = arg0; + } else if (("forward".equals(method) || "include".equals(method)) && DISPATCHER_TYPES.contains(receiver)) { via = method; target = dispatcherArgument(node.getReceiverExpr()); } else if ("sendRedirect".equals(method) && RESPONSE_TYPES.contains(receiver)) { via = "redirect"; - List args = node.getArgumentExpr(); - target = args != null && !args.isEmpty() ? args.get(0) : null; + target = arg0; } else { return null; } @@ -210,13 +245,26 @@ private static Result resolve(List sites, Map artifacts } } + List resolvers = viewResolvers(artifacts); for (Site site : closed) { - List matched = matchPath(site.literal, artifacts); + String via = site.via; + String literal = site.literal; + List matched; + if (!"view-name".equals(via)) { + matched = matchPath(literal, artifacts); + } else if (literal.startsWith("redirect:") || literal.startsWith("forward:")) { + // Spring's special view-name prefixes re-dispatch as path targets (spec D4.1). + via = literal.startsWith("redirect:") ? "redirect" : "forward"; + literal = literal.substring(literal.indexOf(':') + 1); + matched = matchPath(literal, artifacts); + } else { + matched = matchViewName(literal, resolvers, artifacts); + } if (matched.size() == 1) { JViewDispatchEdge edge = new JViewDispatchEdge(); edge.setSrc(site.id); edge.setDst(matched.get(0).getId()); - edge.setVia(site.via); + edge.setVia(via); // The tier that CLOSED this site, not every tier attempted (same rule as config_uses). edge.setProv(new ArrayList<>(site.closedByDataflow ? DATAFLOW : LITERAL)); dispatches.add(edge); @@ -267,6 +315,51 @@ private static List matchPath(String target, Map a return out; } + /** + * The {@code (prefix, suffix)} pairs a view name is expanded through (spec D4.2): the declared + * {@code spring.mvc.view.*} pair when either key is a literal, and the {@code spring.thymeleaf.*} + * pair — declared, or Thymeleaf's defaults when not. A key whose value is a {@code ${...}} + * placeholder is not a literal and contributes nothing; the pass does not guess what it binds to. + */ + private static List viewResolvers(Map artifacts) { + Map keys = new java.util.HashMap<>(); + if (artifacts != null) { + for (JArtifact a : artifacts.values()) { + for (JConfigKey k : a.getConfigKeys()) { + if (k.getValue() != null && !k.getValue().contains("${")) { + keys.putIfAbsent(k.getKey(), k.getValue()); + } + } + } + } + List out = new ArrayList<>(); + if (keys.containsKey("spring.mvc.view.prefix") || keys.containsKey("spring.mvc.view.suffix")) { + out.add(new String[] {keys.getOrDefault("spring.mvc.view.prefix", ""), + keys.getOrDefault("spring.mvc.view.suffix", "")}); + } + out.add(new String[] {keys.getOrDefault("spring.thymeleaf.prefix", THYMELEAF_PREFIX), + keys.getOrDefault("spring.thymeleaf.suffix", THYMELEAF_SUFFIX)}); + return out; + } + + /** Every artifact any resolver expands {@code name} to; more than one is ambiguous, none is absent. */ + private static List matchViewName(String name, List resolvers, + Map artifacts) { + Map out = new java.util.LinkedHashMap<>(); + for (String[] r : resolvers) { + String prefix = r[0]; + for (String scheme : List.of("classpath:", "file:")) { + if (prefix.startsWith(scheme)) { + prefix = prefix.substring(scheme.length()); + } + } + for (JArtifact a : matchPath(prefix + name + r[1], artifacts)) { + out.putIfAbsent(a.getId(), a); + } + } + return new ArrayList<>(out.values()); + } + private static List runTier(List pending, List closed, Function tier) { List still = new ArrayList<>(); diff --git a/src/test/java/com/ibm/cldk/artifacts/ViewNameDispatchTest.java b/src/test/java/com/ibm/cldk/artifacts/ViewNameDispatchTest.java new file mode 100644 index 00000000..0652df8b --- /dev/null +++ b/src/test/java/com/ibm/cldk/artifacts/ViewNameDispatchTest.java @@ -0,0 +1,139 @@ +package com.ibm.cldk.artifacts; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.ibm.cldk.schema.CanId; +import com.ibm.cldk.schema.JArtifact; +import com.ibm.cldk.schema.JModule; +import com.ibm.cldk.syntactic_analysis.L1Extractor; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Spring view names (spec 2026-09-11 D3/D4): a controller's {@code return "home"}, + * {@code new ModelAndView("home")} and {@code setViewName("home")} resolve against the view + * resolver's prefix and suffix — Thymeleaf's defaults, or the {@code spring.mvc.view.*} / + * {@code spring.thymeleaf.*} config keys when declared — and a {@code return "home"} outside a + * Spring entrypoint is not a dispatch at all. {@code return} body nodes exist from L3, so the tree + * is extracted at 3. + */ +class ViewNameDispatchTest { + + private static final String APP = "view-name-test"; + + @TempDir + Path root; + + private ViewDispatches.Result run(String controller, String properties, String... views) + throws Exception { + ServletApiStubs.write(root, "src/main/java/org/springframework/stereotype/Controller.java", + "package org.springframework.stereotype;\npublic @interface Controller {}\n"); + ServletApiStubs.write(root, + "src/main/java/org/springframework/web/bind/annotation/GetMapping.java", + "package org.springframework.web.bind.annotation;\n" + + "public @interface GetMapping { String value() default \"\"; }\n"); + ServletApiStubs.write(root, "src/main/java/org/springframework/web/servlet/ModelAndView.java", + "package org.springframework.web.servlet;\npublic class ModelAndView {\n" + + " public ModelAndView() {}\n public ModelAndView(String view) {}\n" + + " public void setViewName(String view) {}\n}\n"); + for (String view : views) { + ServletApiStubs.write(root, view, ""); + } + if (properties != null) { + ServletApiStubs.write(root, "src/main/resources/application.properties", properties); + } + ServletApiStubs.write(root, "src/main/java/demo/Home.java", controller); + + Map modules = L1Extractor.extractAll( + root, APP, null, new LinkedHashMap<>(), 3, 3, "ast"); + Map artifacts = ArtifactDiscovery.discover(root, APP, true, 262144); + for (JArtifact a : artifacts.values()) { + if (ConfigKeys.isEligible(a)) { + a.setConfigKeys(ConfigKeys.extract( + a, DependencyView.readFromDisk(root, a.getPath()), true).keys); + } + } + return ViewDispatches.detect(APP, modules, artifacts, 3, null); + } + + private static List edges(ViewDispatches.Result r) { + return r.dispatches.stream() + .map(e -> e.getVia() + " " + e.getProv() + " -> " + e.getDst()) + .sorted() + .collect(Collectors.toList()); + } + + private static String art(String rel) { + return CanId.artifactId(APP, rel); + } + + private static final String CONTROLLER = + "package demo;\n" + + "import org.springframework.stereotype.Controller;\n" + + "import org.springframework.web.bind.annotation.GetMapping;\n" + + "import org.springframework.web.servlet.ModelAndView;\n" + + "@Controller\n" + + "public class Home {\n" + + " @GetMapping(\"/\") String home() { return \"home\"; }\n" + + " @GetMapping(\"/r\") String back() { return \"redirect:/y.jsp\"; }\n" + + " @GetMapping(\"/m\") ModelAndView mav() { return new ModelAndView(\"admin/users\"); }\n" + + " @GetMapping(\"/s\") ModelAndView set() {\n" + + " ModelAndView m = new ModelAndView(); m.setViewName(\"home\"); return m;\n" + + " }\n" + + " @GetMapping(\"/n\") int count() { return 3; }\n" + + "}\n" + + "class Plain {\n" + + " String home() { return \"home\"; }\n" + + "}\n"; + + @Test + void thymeleafDefaultsResolveControllerViewNames() throws Exception { + ViewDispatches.Result r = run(CONTROLLER, null, + "src/main/resources/templates/home.html", + "src/main/resources/templates/admin/users.html", + "src/main/webapp/y.jsp"); + assertEquals(List.of( + "redirect [literal] -> " + art("src/main/webapp/y.jsp"), + "view-name [literal] -> " + art("src/main/resources/templates/admin/users.html"), + "view-name [literal] -> " + art("src/main/resources/templates/home.html"), + "view-name [literal] -> " + art("src/main/resources/templates/home.html")), + edges(r)); + assertTrue(r.unresolved.isEmpty(), "a `return 3` and a non-controller `return \"home\"` are not dispatches"); + } + + @Test + void declaredMvcPrefixAndSuffixResolveJspViews() throws Exception { + ViewDispatches.Result r = run( + "package demo;\n" + + "import org.springframework.stereotype.Controller;\n" + + "import org.springframework.web.bind.annotation.GetMapping;\n" + + "@Controller\npublic class Home {\n" + + " @GetMapping(\"/\") String home() { return \"home\"; }\n}\n", + "spring.mvc.view.prefix=/WEB-INF/jsp/\nspring.mvc.view.suffix=.jsp\n", + "src/main/webapp/WEB-INF/jsp/home.jsp"); + assertEquals(List.of("view-name [literal] -> " + art("src/main/webapp/WEB-INF/jsp/home.jsp")), + edges(r)); + } + + @Test + void aViewNameWithNoTemplateIsNoSuchArtifact() throws Exception { + ViewDispatches.Result r = run( + "package demo;\n" + + "import org.springframework.stereotype.Controller;\n" + + "import org.springframework.web.bind.annotation.GetMapping;\n" + + "@Controller\npublic class Home {\n" + + " @GetMapping(\"/\") String home() { return \"missing\"; }\n}\n", + null); + assertTrue(r.dispatches.isEmpty()); + assertEquals(1, r.unresolved.size()); + assertEquals("no-such-artifact", r.unresolved.get(0).getReason()); + assertEquals("missing", r.unresolved.get(0).getTarget()); + assertEquals("view-name", r.unresolved.get(0).getVia()); + } +} From e05fad1be5e48b0948f4d342129f1ea013bd12ad Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 11 Sep 2026 09:22:02 -0400 Subject: [PATCH 4/5] feat(neo4j): project J_DISPATCHES_TO and carry view_dispatches on the application (#259) - JApplication.view_dispatches / view_dispatches_unresolved, wired in CodeAnalyzer after config reads at every level. - V2SchemaCatalog declares J_DISPATCHES_TO from JBodyNode to Artifact with via and prov; V2GraphProjector emits one edge per resolved dispatch and none for unresolved ones; schema.neo4j.json regenerated. - The v2 JSON oracle accepts the two lists; the CLI test drives both projections end to end over a servlet fixture; plantsbywebsphere's view-template set is pinned exactly. - SCHEMA_DECISIONS D32 and the README record the contract. --- .claude/SCHEMA_DECISIONS.md | 20 ++++++++++ README.md | 10 +++++ schema.neo4j.json | 13 +++++++ src/main/java/com/ibm/cldk/CodeAnalyzer.java | 12 ++++++ .../com/ibm/cldk/neo4j/V2GraphProjector.java | 24 ++++++++++++ .../com/ibm/cldk/neo4j/V2SchemaCatalog.java | 9 +++++ .../com/ibm/cldk/schema/JApplication.java | 14 +++++++ .../com/ibm/cldk/CodeAnalyzerV2CliTest.java | 38 ++++++++++++++++++ .../cldk/artifacts/ArtifactDiscoveryTest.java | 25 ++++++++++++ .../ibm/cldk/artifacts/ServletApiStubs.java | 6 +-- .../cldk/artifacts/ViewDispatchesTest.java | 39 +++++++++++++++++++ .../resources/schema/analysis.v2.schema.json | 30 ++++++++++++++ 12 files changed, 237 insertions(+), 3 deletions(-) diff --git a/.claude/SCHEMA_DECISIONS.md b/.claude/SCHEMA_DECISIONS.md index c7791fa9..ea4549fb 100644 --- a/.claude/SCHEMA_DECISIONS.md +++ b/.claude/SCHEMA_DECISIONS.md @@ -456,6 +456,26 @@ be the same observation. Before this, `:JApplication` carried only `name`/`schem `analyzer_name`/`analyzer_version`, and the 2,604 projected `:JEntrypoint` marks came with no record of how the pass that found them behaved. +### D32 — View templates are artifacts with a role; `J_DISPATCHES_TO` reaches them from the dispatching body node +Spec `codellm-devkit/.github` `docs/design/specs/2026-09-11-java-view-templates-and-dispatch.md` (#259). +`ArtifactDiscovery` classifies the JSP family (`format: jsp`), Facelets (`xhtml`) and Thymeleaf `.html` under +`templates/` or `WEB-INF/` (`html`) as `roles: ["view-template"]`; a bare `.html` anywhere else stays `unknown` +because a static page and a template are not distinguishable by name. `faces-config.xml` is `tool-config`. +`return` body nodes carry the returned expression in `argument_expr` (empty for a bare `return`) — a population +change to an existing field, and the one fact that lets the literal tier see `return "home"`. The dispatch pass +mirrors `ConfigUses`: detection by declared receiver type (`RequestDispatcher.forward/include`, +`HttpServletResponse.sendRedirect`, `ModelAndView` construction / `setViewName`, and a Spring entrypoint's +String `return`), the literal tier at every level, `DataflowTiers` (hoisted out of `ConfigUses`) widening at +`-a 3` / `-a 4`, and a target that closes on exactly one artifact or is recorded unresolved as `non-literal` / +`no-such-artifact` / `ambiguous`. View names expand through `spring.mvc.view.*` when declared and +`spring.thymeleaf.*` or Thymeleaf's defaults otherwise; `redirect:` / `forward:` prefixes re-dispatch as paths. +Two consequences stated rather than left implicit: `return` nodes exist only from L3, so a controller's +return-based view name is invisible at `-a 1` (its `ModelAndView` sites are not); and the servlet / Spring +types must resolve for the pass to see anything, the same condition config reads live with. `J_DISPATCHES_TO` +is one edge type with `via` (`forward | include | redirect | view-name | navigation`) rather than one per +mechanism; `navigation` is reserved for JSF and emits nothing until a JSF finder exists. Unresolved dispatches +have no target node and stay JSON-only (`view_dispatches_unresolved`). + ### Graph contract version, on both of the above `V2SchemaCatalog.SCHEMA_VERSION` does **not** move. Both additions are additive over labels the held `2.0.0` baseline already reserves, and a re-baseline is a coordinated cross-analyzer decision diff --git a/README.md b/README.md index 77ce3bb2..6b4d2112 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,16 @@ not assume it is always a body node. A read that matched no declared key is kept that resolves. The literal tier runs at every level; `-a 3` and `-a 4` widen it over the dataflow graph (`prov: ["dataflow"]`). +**View dispatches.** JSP, Facelets and Thymeleaf templates are `:Artifact` nodes with +`roles: ["view-template"]`, and `J_DISPATCHES_TO` says which code reaches one: its source is the +`:JBodyNode` that hands the request over — a `RequestDispatcher.forward` / `include` or +`HttpServletResponse.sendRedirect` call, a `ModelAndView` construction or `setViewName`, or a Spring +controller's `return "home"` — and `via` names the mechanism (`forward`, `include`, `redirect`, +`view-name`). View names expand through `spring.mvc.view.*` / `spring.thymeleaf.*` when declared and +Thymeleaf's defaults otherwise. A target that is a variable, a servlet URL, or a name matching two +templates is kept in `analysis.json` as `view_dispatches_unresolved` with its reason rather than +guessed; like config reads, `-a 3` and `-a 4` widen the literal tier over the dataflow graph. + **Entrypoint coverage.** `:JApplication` carries `entrypoint_frameworks` and `entrypoint_report_json`, and every entrypoint node carries `entrypoint_frameworks` naming the framework finders that recognised it. The report is present **even when empty**: the detection pass diff --git a/schema.neo4j.json b/schema.neo4j.json index 07b6acc8..54a5897d 100644 --- a/schema.neo4j.json +++ b/schema.neo4j.json @@ -581,6 +581,19 @@ "prov": "string[]", "_k": "string" } + }, + { + "type": "J_DISPATCHES_TO", + "from": [ + "JBodyNode" + ], + "to": [ + "Artifact" + ], + "properties": { + "via": "string", + "prov": "string[]" + } } ], "constraints": [ diff --git a/src/main/java/com/ibm/cldk/CodeAnalyzer.java b/src/main/java/com/ibm/cldk/CodeAnalyzer.java index 201c73bd..bc57b87b 100644 --- a/src/main/java/com/ibm/cldk/CodeAnalyzer.java +++ b/src/main/java/com/ibm/cldk/CodeAnalyzer.java @@ -25,6 +25,7 @@ import com.ibm.cldk.artifacts.ConfigKeys; import com.ibm.cldk.artifacts.ConfigUses; import com.ibm.cldk.artifacts.DependencyView; +import com.ibm.cldk.artifacts.ViewDispatches; import com.ibm.cldk.entities.JavaCompilationUnit; import com.ibm.cldk.javaee.EntrypointScan; import com.ibm.cldk.neo4j.BoltConfig; @@ -637,6 +638,17 @@ private void analyzeV2() throws Exception { analysis.getApplication().setConfigReadsUnresolved(configReads.unresolved); } + // View dispatches (#259) join dispatch sites to the artifact layer's view templates -- the + // same shape as config reads, at every level, with the dataflow tiers widening from -a 3. + ViewDispatches.Result views = ViewDispatches.detect(application, modules, artifacts, + analysisLevel, analysis.getApplication().getCallGraph()); + if (!views.dispatches.isEmpty()) { + analysis.getApplication().setViewDispatches(views.dispatches); + } + if (!views.unresolved.isEmpty()) { + analysis.getApplication().setViewDispatchesUnresolved(views.unresolved); + } + if ("neo4j".equalsIgnoreCase(emit)) { // The RESOLVED name, not the raw --app-name: the payload and the root's can:// id are // both built from `application`, so handing the emitter a blank/raw `appName` here keys diff --git a/src/main/java/com/ibm/cldk/neo4j/V2GraphProjector.java b/src/main/java/com/ibm/cldk/neo4j/V2GraphProjector.java index 3e7e21bb..ecfa9bfd 100644 --- a/src/main/java/com/ibm/cldk/neo4j/V2GraphProjector.java +++ b/src/main/java/com/ibm/cldk/neo4j/V2GraphProjector.java @@ -40,6 +40,7 @@ import com.ibm.cldk.schema.JType; import com.ibm.cldk.schema.JTypeParameter; import com.ibm.cldk.schema.JVariableDeclaration; +import com.ibm.cldk.schema.JViewDispatchEdge; import com.ibm.cldk.schema.Span; import com.ibm.cldk.schema.V2Json; import java.nio.charset.StandardCharsets; @@ -181,6 +182,7 @@ public static GraphRows project(Analysis analysis, String appName) { projectArtifacts(b, analysis.getApplication(), app); // Strictly after projectArtifacts: J_USES_CONFIG addresses ConfigKey nodes that pass mints. projectConfigUses(b, analysis.getApplication(), app); + projectViewDispatches(b, analysis.getApplication()); return b.finish(); } @@ -726,6 +728,28 @@ private static void projectConfigUses(RowBuilder b, JApplication application, No } } + /** + * View dispatches (#259): {@code J_DISPATCHES_TO} from the dispatching body node to the + * {@code Artifact} it reaches. Unresolved dispatches are not projected — there is no target + * node — and stay in {@code analysis.json} (spec 2026-09-11 § 5). + */ + private static void projectViewDispatches(RowBuilder b, JApplication application) { + if (application.getViewDispatches() == null) { + return; + } + for (JViewDispatchEdge e : application.getViewDispatches()) { + NodeRef src = b.refTo(e.getSrc()); + NodeRef dst = b.refTo(e.getDst()); + if (src == null || dst == null) { + continue; + } + Map p = RowBuilder.props(); + p.put("via", e.getVia()); + p.put("prov", e.getProv()); + b.edge("J_DISPATCHES_TO", src, dst, RowBuilder.prune(p)); + } + } + /** * Upsert the {@code :JExternal} row for an {@code @external} can-id, recovering its binary * declaring type and signature from the id's own path segments. Merging is by id, so a callee diff --git a/src/main/java/com/ibm/cldk/neo4j/V2SchemaCatalog.java b/src/main/java/com/ibm/cldk/neo4j/V2SchemaCatalog.java index 57129967..f863d83a 100644 --- a/src/main/java/com/ibm/cldk/neo4j/V2SchemaCatalog.java +++ b/src/main/java/com/ibm/cldk/neo4j/V2SchemaCatalog.java @@ -305,6 +305,15 @@ private static List buildRelTypes() { new P().put("key", "string").put("reason", "string").put("prov", "string[]") .put("_k", "string").done())); + // View dispatches (#259, spec 2026-09-11 D2): the body node that hands the request to a view + // -- a forward/include/sendRedirect call, or a controller's return -- and the Artifact it + // reaches. `via` carries the mechanism (forward | include | redirect | view-name | + // navigation); one edge type rather than one per mechanism because the consumer question + // is "which pages can this code reach", and the mechanism is an attribute of the answer. + // Unresolved dispatches have no target node and stay in analysis.json. + r.add(rel("J_DISPATCHES_TO", Arrays.asList("JBodyNode"), Arrays.asList("Artifact"), + new P().put("via", "string").put("prov", "string[]").done())); + return r; } diff --git a/src/main/java/com/ibm/cldk/schema/JApplication.java b/src/main/java/com/ibm/cldk/schema/JApplication.java index 811050d1..ceb1d491 100644 --- a/src/main/java/com/ibm/cldk/schema/JApplication.java +++ b/src/main/java/com/ibm/cldk/schema/JApplication.java @@ -60,6 +60,20 @@ public class JApplication { */ private List configReadsUnresolved; + /** + * Resolved view dispatches (#259) — a {@code forward} / {@code include} / {@code sendRedirect} + * call or a controller's view name, and the {@link JArtifact} it reaches. Sorted by + * {@code (src, dst)}. {@code null} (absent) when nothing was detected. + */ + private List viewDispatches; + + /** + * Detected dispatches that closed on no artifact — a variable target, a servlet URL, or a view + * name matching two templates. Sorted by {@code (site, reason, target)}. {@code null} (absent) + * when there are none. + */ + private List viewDispatchesUnresolved; + /** * Coverage and failure record for the entrypoint pass. Unlike every other overlay on this node, * it is emitted always, even when empty: the pass under-approximates by design, so an diff --git a/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java b/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java index 29ecdd6c..50b6cca5 100644 --- a/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java +++ b/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java @@ -746,4 +746,42 @@ void v2OmitsArtifactsAndDependenciesWhenNoNonSourceFilesExist(@TempDir Path tmp) assertFalse(app.has("dependencies"), "no manifests means no declared dependencies"); } + // ---- view dispatches (#259) ---------------------------------------------------------------- + + @Test + void viewDispatchesReachBothProjections(@TempDir Path root) throws Exception { + com.ibm.cldk.artifacts.ServletApiStubs.write(root); + com.ibm.cldk.artifacts.ServletApiStubs.write(root, "src/main/webapp/home.jsp", "<%= 1 %>"); + com.ibm.cldk.artifacts.ServletApiStubs.write(root, "src/main/java/demo/Front.java", + "package demo;\nimport javax.servlet.http.*;\n" + + "public class Front extends HttpServlet {\n" + + " protected void doGet(HttpServletRequest req, HttpServletResponse res) {\n" + + " req.getRequestDispatcher(\"/home.jsp\").forward(req, res);\n" + + " req.getRequestDispatcher(\"/servlet/Other\").forward(req, res);\n" + + " }\n}\n"); + Path out = root.resolve("out"); + + assertEquals(0, run("-i", root.toString(), "-o", out.toString(), "--no-build")); + JsonObject app = JsonParser.parseString(Files.readString(out.resolve("analysis.json"))) + .getAsJsonObject().getAsJsonObject("application"); + assertEquals(1, app.getAsJsonArray("view_dispatches").size()); + JsonObject d = app.getAsJsonArray("view_dispatches").get(0).getAsJsonObject(); + assertEquals("forward", d.get("via").getAsString()); + assertTrue(d.get("dst").getAsString().endsWith("/artifact/src/main/webapp/home.jsp")); + assertEquals(1, app.getAsJsonArray("view_dispatches_unresolved").size()); + assertEquals("no-such-artifact", app.getAsJsonArray("view_dispatches_unresolved").get(0) + .getAsJsonObject().get("reason").getAsString()); + JsonObject jsp = app.getAsJsonObject("artifacts").getAsJsonObject("src/main/webapp/home.jsp"); + assertEquals("view-template", jsp.getAsJsonArray("roles").get(0).getAsString()); + + assertEquals(0, run("-i", root.toString(), "-o", out.toString(), "--emit", "neo4j", "--no-build")); + String script = Files.readString(out.resolve("graph.cypher")); + assertTrue(script.contains("J_DISPATCHES_TO"), "the resolved dispatch must be projected"); + assertEquals(1, script.split("J_DISPATCHES_TO", -1).length - 1 - countIn(script, "J_DISPATCHES_TO {"), + "exactly one J_DISPATCHES_TO statement: the unresolved dispatch is JSON-only"); + } + + private static int countIn(String s, String needle) { + return s.split(java.util.regex.Pattern.quote(needle), -1).length - 1; + } } diff --git a/src/test/java/com/ibm/cldk/artifacts/ArtifactDiscoveryTest.java b/src/test/java/com/ibm/cldk/artifacts/ArtifactDiscoveryTest.java index c758838d..7f91d00f 100644 --- a/src/test/java/com/ibm/cldk/artifacts/ArtifactDiscoveryTest.java +++ b/src/test/java/com/ibm/cldk/artifacts/ArtifactDiscoveryTest.java @@ -334,4 +334,29 @@ void discover_classifiesFacesConfigAsToolConfig(@TempDir Path tmp) throws IOExce assertEquals("xml", a.getFormat()); assertEquals(List.of("tool-config"), a.getRoles()); } + + @Test + void discover_viewTemplatesOfPlantsByWebSphereAreExactlyItsJspAndFacelets() throws IOException { + Path app = Path.of("src/test/resources/test-applications/plantsbywebsphere"); + Map artifacts = ArtifactDiscovery.discover(app, "pbw", false, 262144); + + java.util.Set views = new java.util.TreeSet<>(); + for (JArtifact a : artifacts.values()) { + if (a.getRoles().contains("view-template")) { + views.add(a.getPath()); + } + } + // Hand-listed: `find . -name '*.jsp' -o -name '*.xhtml'`. No .html (its pages are static), + // no .java, nothing under resources/. + assertEquals(new java.util.TreeSet<>(List.of( + "src/main/webapp/WEB-INF/PlantTemplate.xhtml", "src/main/webapp/account.xhtml", + "src/main/webapp/backorderadmin.jsp", "src/main/webapp/cart.xhtml", + "src/main/webapp/checkout_final.xhtml", "src/main/webapp/error.jsp", + "src/main/webapp/help.xhtml", "src/main/webapp/login.xhtml", + "src/main/webapp/orderdone.xhtml", "src/main/webapp/orderinfo.xhtml", + "src/main/webapp/product.xhtml", "src/main/webapp/promo.xhtml", + "src/main/webapp/register.xhtml", "src/main/webapp/shopping.xhtml", + "src/main/webapp/supplierconfig.jsp", "src/main/webapp/viewExpired.xhtml")), views); + assertEquals(List.of("unknown"), artifacts.get("src/main/webapp/index.html").getRoles()); + } } diff --git a/src/test/java/com/ibm/cldk/artifacts/ServletApiStubs.java b/src/test/java/com/ibm/cldk/artifacts/ServletApiStubs.java index e5aa0826..d6940d16 100644 --- a/src/test/java/com/ibm/cldk/artifacts/ServletApiStubs.java +++ b/src/test/java/com/ibm/cldk/artifacts/ServletApiStubs.java @@ -8,11 +8,11 @@ * The slice of the servlet API the view-dispatch tests need, written as fixture source so receiver * types resolve without a jar on the classpath. */ -final class ServletApiStubs { +public final class ServletApiStubs { private ServletApiStubs() {} - static void write(Path root) throws Exception { + public static void write(Path root) throws Exception { write(root, "src/main/java/javax/servlet/RequestDispatcher.java", "package javax.servlet;\npublic interface RequestDispatcher {\n" + " void forward(ServletRequest q, ServletResponse s);\n" @@ -37,7 +37,7 @@ static void write(Path root) throws Exception { + " public javax.servlet.ServletContext getServletContext() { return null; }\n}\n"); } - static void write(Path root, String rel, String text) throws Exception { + public static void write(Path root, String rel, String text) throws Exception { Path f = root.resolve(rel); Files.createDirectories(f.getParent()); Files.writeString(f, text, StandardCharsets.UTF_8); diff --git a/src/test/java/com/ibm/cldk/artifacts/ViewDispatchesTest.java b/src/test/java/com/ibm/cldk/artifacts/ViewDispatchesTest.java index 27790b31..0ee61406 100644 --- a/src/test/java/com/ibm/cldk/artifacts/ViewDispatchesTest.java +++ b/src/test/java/com/ibm/cldk/artifacts/ViewDispatchesTest.java @@ -2,8 +2,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import com.ibm.cldk.neo4j.GraphRows; +import com.ibm.cldk.neo4j.GraphRows.EdgeRow; +import com.ibm.cldk.neo4j.V2GraphProjector; +import com.ibm.cldk.schema.Analysis; import com.ibm.cldk.schema.CanId; +import com.ibm.cldk.schema.JApplication; import com.ibm.cldk.schema.JArtifact; import com.ibm.cldk.schema.JBodyNode; import com.ibm.cldk.schema.JCallable; @@ -11,11 +17,14 @@ import com.ibm.cldk.schema.JType; import com.ibm.cldk.schema.JViewDispatchEdge; import com.ibm.cldk.schema.JViewDispatchUnresolved; +import com.ibm.cldk.schema.V2Emitter; import com.ibm.cldk.syntactic_analysis.L1Extractor; import java.nio.file.Path; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -40,6 +49,7 @@ class ViewDispatchesTest { private static ViewDispatches.Result result; private static Map modules; + private static GraphRows rows; static void write(String rel, String text) throws Exception { ServletApiStubs.write(root, rel, text); @@ -74,6 +84,13 @@ static void analyze() throws Exception { modules = L1Extractor.extractAll(root, APP, null, new LinkedHashMap<>(), 1, 3, "ast"); Map artifacts = ArtifactDiscovery.discover(root, APP, true, 262144); result = ViewDispatches.detect(APP, modules, artifacts); + + Analysis analysis = V2Emitter.emit(APP, 1, modules, "test", null, null, null, null, + artifacts, null); + JApplication app = analysis.getApplication(); + app.setViewDispatches(result.dispatches); + app.setViewDispatchesUnresolved(result.unresolved); + rows = V2GraphProjector.project(analysis, APP); } /** The ordinal id of the {@code n}-th call to {@code method} inside {@code callable}, in source order. */ @@ -155,6 +172,28 @@ void nothingElseIsRecorded() { assertEquals(3, result.unresolved.size()); } + // ---- projection ---------------------------------------------------------------------------- + + @Test + void everyResolvedDispatchIsProjectedWithItsMechanismAndNothingElseIs() { + List projected = rows.edges.stream() + .filter(e -> e.type.equals("J_DISPATCHES_TO")).collect(Collectors.toList()); + assertEquals(result.dispatches.size(), projected.size(), + "every analysis.json view_dispatch must reach the graph, and vice versa"); + Set nodeIds = rows.nodes.stream().map(n -> n.value).collect(Collectors.toSet()); + Set expected = result.dispatches.stream() + .map(d -> d.getSrc() + " " + d.getVia() + " " + d.getProv() + " " + d.getDst()) + .collect(Collectors.toSet()); + Set got = new HashSet<>(); + for (EdgeRow e : projected) { + assertTrue(nodeIds.contains(e.from.value), "dangling src: " + e.from.value); + assertTrue(nodeIds.contains(e.to.value), "dangling dst: " + e.to.value); + assertEquals("Artifact", e.to.label); + got.add(e.from.value + " " + e.props.get("via") + " " + e.props.get("prov") + " " + e.to.value); + } + assertEquals(expected, got); + } + private static JViewDispatchUnresolved unresolved(String site) { return result.unresolved.stream().filter(u -> site.equals(u.getSite())).findFirst() .orElseThrow(() -> new AssertionError("no unresolved record at " + site + "; have " diff --git a/src/test/resources/schema/analysis.v2.schema.json b/src/test/resources/schema/analysis.v2.schema.json index f50eca08..18107ffa 100644 --- a/src/test/resources/schema/analysis.v2.schema.json +++ b/src/test/resources/schema/analysis.v2.schema.json @@ -138,6 +138,10 @@ "config_reads_unresolved": { "type": "array", "items": { "$ref": "#/$defs/configRead" } }, + "view_dispatches": { "type": "array", "items": { "$ref": "#/$defs/viewDispatchEdge" } }, + "view_dispatches_unresolved": { + "type": "array", "items": { "$ref": "#/$defs/viewDispatchUnresolved" } + }, "entrypoint_report": { "$ref": "#/$defs/entrypointReport" } } }, @@ -156,6 +160,32 @@ } }, + "viewDispatchEdge": { + "type": "object", + "additionalProperties": false, + "required": ["src", "dst", "via", "prov"], + "properties": { + "src": { "type": "string", "description": "The dispatching body node's ordinal id: a forward/include/sendRedirect call, a ModelAndView construction or setViewName, or a controller's return." }, + "dst": { "$ref": "#/$defs/artifactCanId" }, + "via": { "enum": ["forward", "include", "redirect", "view-name", "navigation"] }, + "prov": { "$ref": "#/$defs/stringList" } + } + }, + + "viewDispatchUnresolved": { + "type": "object", + "additionalProperties": false, + "required": ["site", "callee", "via", "reason", "prov"], + "properties": { + "site": { "type": "string" }, + "callee": { "type": "string", "description": "The @external can-id of the dispatching callee, or the enclosing callable's id for a return site." }, + "target": { "type": "string" }, + "via": { "enum": ["forward", "include", "redirect", "view-name", "navigation"] }, + "reason": { "enum": ["non-literal", "no-such-artifact", "ambiguous"] }, + "prov": { "$ref": "#/$defs/stringList" } + } + }, + "configRead": { "type": "object", "additionalProperties": false, From 57ea7520f6163abac2f6bc61f3623ace61219195 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 11 Sep 2026 10:00:51 -0400 Subject: [PATCH 5/5] test(cli): count J_DISPATCHES_TO rows in the batch, not statements (#259) --- .../com/ibm/cldk/CodeAnalyzerV2CliTest.java | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java b/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java index 50b6cca5..8a82ff18 100644 --- a/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java +++ b/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java @@ -776,12 +776,19 @@ void viewDispatchesReachBothProjections(@TempDir Path root) throws Exception { assertEquals(0, run("-i", root.toString(), "-o", out.toString(), "--emit", "neo4j", "--no-build")); String script = Files.readString(out.resolve("graph.cypher")); - assertTrue(script.contains("J_DISPATCHES_TO"), "the resolved dispatch must be projected"); - assertEquals(1, script.split("J_DISPATCHES_TO", -1).length - 1 - countIn(script, "J_DISPATCHES_TO {"), - "exactly one J_DISPATCHES_TO statement: the unresolved dispatch is JSON-only"); - } - - private static int countIn(String s, String needle) { - return s.split(java.util.regex.Pattern.quote(needle), -1).length - 1; + // Rows are batched: one UNWIND statement per relationship type, one row per edge. Count the + // rows of the J_DISPATCHES_TO batch, so the unresolved dispatch being JSON-only is checked + // against the graph rather than against the number of statements. + java.util.regex.Matcher m = java.util.regex.Pattern + .compile("UNWIND \\[\\n(.*?)\\n\\] AS row\\n(.*?);", java.util.regex.Pattern.DOTALL) + .matcher(script); + int rows = -1; + while (m.find()) { + if (m.group(2).contains("J_DISPATCHES_TO")) { + rows = (int) m.group(1).lines().filter(l -> l.trim().startsWith("{")).count(); + } + } + assertEquals(1, rows, "one J_DISPATCHES_TO row: the resolved forward, and not the unresolved one"); + assertTrue(script.contains("via: 'forward'"), "the row carries its mechanism"); } }