From 4407e6b6457d4b8559435d0f6dbcddea02541404 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 11 Sep 2026 10:26:24 -0400 Subject: [PATCH] feat(artifacts): may-dispatch over a static string table, prov table (#261) StringTables closes a dispatch target that is a call into a static String[]/String[][] table with a literal initializer on every entry of the table, one J_DISPATCHES_TO per matching artifact with prov table. DataflowTiers.interprocAll lets the call-graph tier bind a parameter to the union of its callers' literals and tables; literal callers that disagree still refuse, so dataflow edges keep meaning exactly one target. On daytrader8 the graph goes from 3 to 37 dispatch edges and every page in TradeConfig.webUI that exists on disk has an incoming edge. --- .claude/SCHEMA_DECISIONS.md | 15 ++ README.md | 5 +- .../com/ibm/cldk/artifacts/DataflowTiers.java | 77 ++++++- .../com/ibm/cldk/artifacts/StringTables.java | 200 ++++++++++++++++++ .../ibm/cldk/artifacts/ViewDispatches.java | 126 +++++++---- .../ibm/cldk/artifacts/ViewTableTierTest.java | 140 ++++++++++++ 6 files changed, 511 insertions(+), 52 deletions(-) create mode 100644 src/main/java/com/ibm/cldk/artifacts/StringTables.java create mode 100644 src/test/java/com/ibm/cldk/artifacts/ViewTableTierTest.java diff --git a/.claude/SCHEMA_DECISIONS.md b/.claude/SCHEMA_DECISIONS.md index ea4549fb..72ad8c65 100644 --- a/.claude/SCHEMA_DECISIONS.md +++ b/.claude/SCHEMA_DECISIONS.md @@ -476,6 +476,21 @@ is one edge type with `via` (`forward | include | redirect | view-name | navigat 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`). +### D33 — May-dispatch over a static string table: `J_DISPATCHES_TO` with `prov: ["table"]` +Spec § 4.5 (codeanalyzer-java#261). Released 3.3.0 on DayTrader resolved 3 of 23 dispatches; the rest go +through `TradeConfig.getPage(N)` = `return webUI[webInterface][pageNumber]`, a static `String[][]` of page +paths indexed by a runtime-selected interface, which no single-literal tier can or should close. The table +tier (`StringTables`) closes exactly that shape — a call `T.m(…)` whose every `return` is an array access +rooted at a static `String[]`/`String[][]` field of `T` with a literal array initializer — on **every** +literal of the initializer, one edge per matching artifact, `prov: ["table"]`. The same closure applies +per caller when the interprocedural tier binds a parameter (`DataflowTiers.interprocAll`); the union is the +candidate set and `prov` is `table` when any table contributed. The invariant that makes this honest: +`literal` and `dataflow` edges still mean exactly one target (literal callers that disagree still refuse), +and only a `table` edge is many-per-site. Resolution is by simple type name + method name inside the tree, +so the tier runs at `-a 1`; same-named types or same-arity overloads make it give up. `prov` on +`view_dispatches` / `J_DISPATCHES_TO` is therefore `literal | table | dataflow`; `table` is listed as +attempted only for a call-shaped target. + ### 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 6b4d2112..261f1cb5 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,10 @@ controller's `return "home"` — and `via` names the mechanism (`forward`, `incl `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. +guessed; like config reads, `-a 3` and `-a 4` widen the literal tier over the dataflow graph. One +deliberate over-approximation: a target that is a lookup into a static string table (DayTrader's +`TradeConfig.getPage(N)`) yields one edge per table entry with `prov: ["table"]` — a may-dispatch — +while `literal` and `dataflow` edges always mean exactly one target. **Entrypoint coverage.** `:JApplication` carries `entrypoint_frameworks` and `entrypoint_report_json`, and every entrypoint node carries `entrypoint_frameworks` naming the diff --git a/src/main/java/com/ibm/cldk/artifacts/DataflowTiers.java b/src/main/java/com/ibm/cldk/artifacts/DataflowTiers.java index b82afc0f..1e823745 100644 --- a/src/main/java/com/ibm/cldk/artifacts/DataflowTiers.java +++ b/src/main/java/com/ibm/cldk/artifacts/DataflowTiers.java @@ -14,6 +14,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Function; /** * The two dataflow tiers the config-use and view-dispatch passes share: closing a bare local over @@ -75,6 +76,59 @@ static String interproc(Owner owner, String var, CallSiteIndex sites, Map targets; + /** True when at least one caller closed through {@code extra} rather than a literal. */ + final boolean viaExtra; + + Closure(List targets, boolean viaExtra) { + this.targets = targets; + this.viaExtra = viaExtra; + } + } + + /** + * L4, many-valued: as {@link #interproc}, but every caller's argument may close on a + * set — a literal, a local traced to one, or whatever {@code extra} makes of the raw + * argument text (the view-dispatch table tier, #261). Closes only when every caller closes; the + * result is the union, so a caller the tiers cannot read still refuses the whole binding. + */ + static Closure interprocAll(Owner owner, String var, CallSiteIndex sites, + Map owners, Function> extra) { + if (owner == null) { + return null; + } + JCallable c = owner.callable; + int paramIndex = InterprocTier.parameterIndex(c, var); + if (paramIndex < 0 || InterprocTier.locallyRedefined(c, var) || c.isEntrypoint()) { + return null; + } + List targeting = sites.byCallee.get(c.getId()); + if (targeting == null || targeting.isEmpty() + || sites.unresolvedNames.contains(InterprocTier.simpleName(c))) { + return null; + } + Set union = new LinkedHashSet<>(); + boolean viaExtra = false; + for (CallSiteIndex.Site site : targeting) { + String one = InterprocTier.siteLiteral(site, paramIndex, owners); + if (one != null) { + union.add(one); + continue; + } + List args = site.node.getArgumentExpr(); + List many = args != null && args.size() > paramIndex + ? extra.apply(args.get(paramIndex)) : null; + if (many == null || many.isEmpty()) { + return null; + } + union.addAll(many); + viaExtra = true; + } + return new Closure(new ArrayList<>(union), viaExtra); + } + // ---------------------------------------------------------------------------------------- // L3 intra tier: close a bare name over its own callable's DDG // ---------------------------------------------------------------------------------------- @@ -241,18 +295,21 @@ static final class InterprocTier { private InterprocTier() {} + static int parameterIndex(JCallable c, String var) { + for (int i = 0; i < c.getParameters().size(); i++) { + if (var.equals(c.getParameters().get(i).getName())) { + return i; + } + } + return -1; + } + 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; - } - } + int paramIndex = parameterIndex(c, var); if (paramIndex < 0 || locallyRedefined(c, var)) { return null; } @@ -278,7 +335,7 @@ static String close(Owner owner, String var, CallSiteIndex sites, Map owners) { List args = site.node.getArgumentExpr(); if (args == null || args.size() <= paramIndex) { @@ -303,7 +360,7 @@ private static String siteLiteral(CallSiteIndex.Site site, int paramIndex, * 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) { + static boolean locallyRedefined(JCallable c, String var) { if (c.getDdg() == null) { return false; } @@ -315,7 +372,7 @@ private static boolean locallyRedefined(JCallable c, String var) { return false; } - private static String simpleName(JCallable c) { + static String simpleName(JCallable c) { String signature = c.getSignature(); if (signature == null) { return ""; diff --git a/src/main/java/com/ibm/cldk/artifacts/StringTables.java b/src/main/java/com/ibm/cldk/artifacts/StringTables.java new file mode 100644 index 00000000..bb8dcdff --- /dev/null +++ b/src/main/java/com/ibm/cldk/artifacts/StringTables.java @@ -0,0 +1,200 @@ +package com.ibm.cldk.artifacts; + +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.expr.ArrayAccessExpr; +import com.github.javaparser.ast.expr.Expression; +import com.github.javaparser.ast.expr.MethodCallExpr; +import com.github.javaparser.ast.expr.StringLiteralExpr; +import com.github.javaparser.ast.stmt.BlockStmt; +import com.github.javaparser.ast.stmt.ReturnStmt; +import com.ibm.cldk.schema.JCallable; +import com.ibm.cldk.schema.JField; +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 view-dispatch table tier (spec 2026-09-11 § 4.5, #261): a target expression that is a call + * {@code T.m(...)} whose every {@code return} is an array access rooted at a static + * {@code String[]} / {@code String[][]} field of {@code T} with an array-initializer of string + * literals closes on every literal in that initializer — a may-dispatch. DayTrader's + * {@code TradeConfig.getPage(N)} = {@code return webUI[webInterface][pageNumber]} is the shape. + * + *

Resolution is by declaring-type simple name plus method name within the tree, not by the L2 + * {@code callee}, so it runs at {@code -a 1}; two same-named types both declaring the method, or two + * same-arity overloads, make it give up rather than pick one. Anything that is not exactly this + * shape — a computed return, a field without a literal initializer — returns {@code null}. + */ +final class StringTables { + + private final Map> typesBySimpleName = new LinkedHashMap<>(); + + /** A type plus the module source its callables' body spans slice. */ + private static final class Owner { + final JType type; + final String source; + + Owner(JType type, String source) { + this.type = type; + this.source = source; + } + } + + StringTables(Map modules) { + if (modules != null) { + for (JModule m : modules.values()) { + index(m.getTypes(), m.getSource()); + } + } + } + + private void index(Map types, String source) { + if (types == null) { + return; + } + for (Map.Entry e : types.entrySet()) { + typesBySimpleName.computeIfAbsent(e.getKey(), k -> new ArrayList<>()) + .add(new Owner(e.getValue(), source)); + for (JCallable c : e.getValue().getCallables().values()) { + index(c.getTypes(), source); + } + index(e.getValue().getTypes(), source); + } + } + + /** True when {@code expr} has the call shape the tier can even attempt, for the {@code prov} record. */ + static boolean isCall(String expr) { + return parseCall(expr) != null; + } + + /** Every literal of the table {@code expr} indexes, or {@code null} if it is not a table lookup. */ + List close(String expr) { + MethodCallExpr call = parseCall(expr); + if (call == null) { + return null; + } + String typeName = call.getScope().get().toString(); + typeName = typeName.substring(typeName.lastIndexOf('.') + 1); + List owners = typesBySimpleName.get(typeName); + if (owners == null || owners.size() != 1) { + return null; + } + Owner owner = owners.get(0); + JCallable callee = uniqueCallable(owner.type, call.getNameAsString(), call.getArguments().size()); + if (callee == null) { + return null; + } + String field = tableField(callee, owner.source); + if (field == null) { + return null; + } + JField f = owner.type.getFields().get(field); + if (f == null || f.getType() == null || !f.getType().contains("String") + || !f.getModifiers().contains("static") || f.getInitializer() == null) { + return null; + } + List literals = literalsOf(f.getInitializer()); + return literals.isEmpty() ? null : literals; + } + + private static MethodCallExpr parseCall(String expr) { + if (expr == null || !expr.endsWith(")")) { + return null; + } + Expression parsed; + try { + parsed = StaticJavaParser.parseExpression(expr); + } catch (RuntimeException e) { + return null; + } + if (!parsed.isMethodCallExpr() || parsed.asMethodCallExpr().getScope().isEmpty()) { + return null; + } + Expression scope = parsed.asMethodCallExpr().getScope().get(); + return scope.isNameExpr() || scope.isFieldAccessExpr() ? parsed.asMethodCallExpr() : null; + } + + private static JCallable uniqueCallable(JType type, String name, int arity) { + JCallable found = null; + for (JCallable c : type.getCallables().values()) { + if (c.getSignature() != null && c.getSignature().startsWith(name + "(") + && c.getParameters().size() == arity) { + if (found != null) { + return null; + } + found = c; + } + } + return found; + } + + /** The one static field every {@code return} of {@code callee} indexes into, or {@code null}. */ + private static String tableField(JCallable callee, String source) { + String body = slice(source, callee.getBodySpan()); + if (body == null) { + return null; + } + BlockStmt block; + try { + block = StaticJavaParser.parseBlock(body); + } catch (RuntimeException e) { + return null; + } + Set roots = new LinkedHashSet<>(); + List returns = block.findAll(ReturnStmt.class); + for (ReturnStmt r : returns) { + Expression e = r.getExpression().orElse(null); + if (e == null || !e.isArrayAccessExpr()) { + return null; + } + while (e.isArrayAccessExpr()) { + e = ((ArrayAccessExpr) e).getName(); + } + if (e.isNameExpr()) { + roots.add(e.asNameExpr().getNameAsString()); + } else if (e.isFieldAccessExpr()) { + roots.add(e.asFieldAccessExpr().getNameAsString()); + } else { + return null; + } + } + return returns.isEmpty() || roots.size() != 1 ? null : roots.iterator().next(); + } + + private static List literalsOf(String initializer) { + List out = new ArrayList<>(); + Expression parsed; + try { + // An array initializer is not an expression on its own; give it a declaration to sit in. + parsed = StaticJavaParser.parseVariableDeclarationExpr("String[][] t = " + initializer); + } catch (RuntimeException e) { + return out; + } + Set seen = new LinkedHashSet<>(); + for (StringLiteralExpr s : parsed.findAll(StringLiteralExpr.class)) { + if (seen.add(s.getValue())) { + out.add(s.getValue()); + } + } + return out; + } + + private static String slice(String source, Span span) { + int[] bytes = span == null ? null : span.getBytes(); + if (source == null || bytes == null || bytes.length < 2) { + 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 45dc3021..5c129e30 100644 --- a/src/main/java/com/ibm/cldk/artifacts/ViewDispatches.java +++ b/src/main/java/com/ibm/cldk/artifacts/ViewDispatches.java @@ -33,10 +33,9 @@ public final class ViewDispatches { private ViewDispatches() {} - /** Provenance vocabulary, shared with {@link ConfigUses}: exactly {@code literal|dataflow}. */ + /** Provenance vocabulary: {@code literal | table | dataflow} — {@code table} is this pass's own (§ 4.5). */ 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"); @@ -69,36 +68,38 @@ private static final class Site { // 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; + /** What the target closed on — one literal, or a table's entries — and the tier that did it. */ + final List targets; + final String closedBy; Site(String id, String callee, String via, String targetExpr, String callableId, String localId) { this(id, callee, via, targetExpr, callableId, localId, - Literals.stringLiteral(targetExpr), false); + Literals.stringLiteral(targetExpr) == null ? null + : List.of(Literals.stringLiteral(targetExpr)), + "literal"); } private Site(String id, String callee, String via, String targetExpr, String callableId, - String localId, String literal, boolean closedByDataflow) { + String localId, List targets, String closedBy) { this.id = id; this.callee = callee; this.via = via; this.targetExpr = targetExpr; this.callableId = callableId; this.localId = localId; - this.literal = literal; - this.closedByDataflow = closedByDataflow; + this.targets = targets; + this.closedBy = closedBy; } /** The bare identifier a tier can trace, or null for a literal or a compound expression. */ String varName() { - return literal == null && targetExpr != null + return targets == 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); + Site closedTo(List traced, String tier) { + return new Site(id, callee, via, targetExpr, callableId, localId, traced, tier); } } @@ -221,60 +222,83 @@ private static String dispatcherArgument(String receiverExpr) { /** * 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)}. + * {@code view_dispatches(-a 1) ⊆ view_dispatches(-a 3) ⊆ view_dispatches(-a 4)}. The table + * tier (§ 4.5) sits between literal and dataflow: level-independent, and the only tier whose + * answer is a set. */ 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) { - (site.literal != null ? closed : pending).add(site); + (site.targets != null ? closed : pending).add(site); } + StringTables tables = new StringTables(modules); + pending = runTier(pending, closed, s -> tables.close(s.targetExpr), "table"); 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())); + pending = runTier(pending, closed, s -> one( + DataflowTiers.intra(owners.get(s.callableId), s.localId, s.varName())), "dataflow"); 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 still = new ArrayList<>(); + for (Site s : pending) { + DataflowTiers.Closure c = s.varName() == null ? null + : DataflowTiers.interprocAll(owners.get(s.callableId), s.varName(), index, + owners, tables::close); + // A `dataflow` edge still means exactly one target: literal callers that + // disagree refuse, as before. Only a table makes a many-valued closure legal. + if (c == null || (!c.viaExtra && c.targets.size() > 1)) { + still.add(s); + } else { + closed.add(s.closedTo(c.targets, c.viaExtra ? "table" : "dataflow")); + } + } + pending = still; } } List resolvers = viewResolvers(artifacts); for (Site site : closed) { + Map matched = new java.util.LinkedHashMap<>(); 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); + for (String literal : site.targets) { + List hits; + if (!"view-name".equals(site.via)) { + hits = 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"; + hits = matchPath(literal.substring(literal.indexOf(':') + 1), artifacts); + } else { + hits = matchViewName(literal, resolvers, artifacts); + } + for (JArtifact a : hits) { + matched.putIfAbsent(a.getId(), a); + } + } + // One literal must name exactly one artifact; a table is a may-dispatch and names many. + boolean table = "table".equals(site.closedBy); + if (matched.isEmpty() || (!table && matched.size() > 1)) { + unresolved.add(unresolved(site, table ? null : site.targets.get(0), + matched.isEmpty() ? "no-such-artifact" : "ambiguous", attempted(site, analysisLevel))); + continue; } - if (matched.size() == 1) { + for (JArtifact a : matched.values()) { JViewDispatchEdge edge = new JViewDispatchEdge(); edge.setSrc(site.id); - edge.setDst(matched.get(0).getId()); + edge.setDst(a.getId()); 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)); + edge.setProv(new ArrayList<>(List.of(site.closedBy))); dispatches.add(edge); - } else { - 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)); + unresolved.add(unresolved(site, null, "non-literal", attempted(site, analysisLevel))); } dispatches.sort(Comparator.comparing(JViewDispatchEdge::getSrc) .thenComparing(JViewDispatchEdge::getDst)); @@ -284,6 +308,26 @@ private static Result resolve(List sites, Map artifacts return new Result(dispatches, unresolved); } + private static List one(String literal) { + return literal == null ? null : List.of(literal); + } + + /** + * Every tier that could have been ATTEMPTED on this site: {@code literal} always, {@code table} + * only for a call-shaped target (the tier is not applicable to a name or a literal), and + * {@code dataflow} from L3 — so an unresolved record says how hard the pass tried. + */ + private static List attempted(Site site, int analysisLevel) { + List out = new ArrayList<>(LITERAL); + if (StringTables.isCall(site.targetExpr)) { + out.add("table"); + } + if (analysisLevel >= 3) { + out.addAll(DATAFLOW); + } + return out; + } + /** * 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 @@ -361,14 +405,14 @@ private static List matchViewName(String name, List resolve } private static List runTier(List pending, List closed, - Function tier) { + Function> tier, String name) { List still = new ArrayList<>(); for (Site site : pending) { - String traced = site.varName() == null ? null : tier.apply(site); - if (traced == null) { + List traced = site.targetExpr == null ? null : tier.apply(site); + if (traced == null || traced.isEmpty()) { still.add(site); } else { - closed.add(site.closedTo(traced)); + closed.add(site.closedTo(traced, name)); } } return still; diff --git a/src/test/java/com/ibm/cldk/artifacts/ViewTableTierTest.java b/src/test/java/com/ibm/cldk/artifacts/ViewTableTierTest.java new file mode 100644 index 00000000..23a261a8 --- /dev/null +++ b/src/test/java/com/ibm/cldk/artifacts/ViewTableTierTest.java @@ -0,0 +1,140 @@ +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 table tier (spec § 4.5, #261): a dispatch target that is a call into a static string table + * closes on every entry of the table as a may-dispatch, {@code prov: ["table"]}. The refusals + * matter as much as the closures: a return that is not a table lookup, or a table without a + * literal initializer, must not close. + */ +class ViewTableTierTest { + + private static final String APP = "view-table-test"; + + @TempDir + Path root; + + private static final String PAGES = + "package demo;\n" + + "public class Pages {\n" + + " static int mode = 0;\n" + + " static String[][] table = {\n" + + " { \"/a.jsp\", \"/b.jsp\", \"/app?action=x\" },\n" + + " { \"/aImg.jsp\", \"/bImg.jsp\", \"/app?action=x\" } };\n" + + " static String page(int i) { return table[mode][i]; }\n" + + " static String computed(int i) { return \"/\" + i + \".jsp\"; }\n" + + " static String[] loaded;\n" + + " static String fromLoaded(int i) { return loaded[i]; }\n" + + "}\n"; + + private static final String HEAD = + "package demo;\n" + + "import javax.servlet.http.*;\n" + + "public class Front extends HttpServlet {\n"; + + private ViewDispatches.Result run(String front, int analysisLevel) throws Exception { + ServletApiStubs.write(root); + for (String v : List.of("a", "b", "aImg", "bImg", "c")) { + ServletApiStubs.write(root, "src/main/webapp/" + v + ".jsp", "<%= 1 %>"); + } + if (!java.nio.file.Files.exists(root.resolve("src/main/java/demo/Pages.java"))) { + ServletApiStubs.write(root, "src/main/java/demo/Pages.java", PAGES); + } + ServletApiStubs.write(root, "src/main/java/demo/Front.java", front); + 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 targets(ViewDispatches.Result r) { + return r.dispatches.stream() + .map(e -> e.getProv() + " " + e.getDst().substring(e.getDst().lastIndexOf('/') + 1)) + .sorted().collect(Collectors.toList()); + } + + @Test + void aTableLookupClosesOnEveryEntryAtLevelOne() throws Exception { + ViewDispatches.Result r = run(HEAD + + " void doGet(HttpServletRequest req, HttpServletResponse res) {\n" + + " req.getRequestDispatcher(Pages.page(1)).include(req, res);\n" + + " }\n}\n", 1); + assertEquals(List.of("[table] a.jsp", "[table] aImg.jsp", "[table] b.jsp", "[table] bImg.jsp"), + targets(r), "every page in the table, the servlet URL contributing nothing"); + assertTrue(r.unresolved.isEmpty()); + assertEquals(1, r.dispatches.stream().map(e -> e.getSrc()).distinct().count(), "one site"); + } + + @Test + void aTableNamingNoFileIsNoSuchArtifactWithTableAttempted() throws Exception { + ServletApiStubs.write(root, "src/main/java/demo/Pages.java", + PAGES.replace(".jsp", ".missing")); + ViewDispatches.Result r = run(HEAD + + " void doGet(HttpServletRequest req, HttpServletResponse res) {\n" + + " req.getRequestDispatcher(Pages.page(1)).include(req, res);\n" + + " }\n}\n", 1); + assertTrue(r.dispatches.isEmpty()); + JViewDispatchUnresolved u = r.unresolved.get(0); + assertEquals("no-such-artifact", u.getReason()); + assertEquals(List.of("literal", "table"), u.getProv()); + } + + @Test + void aComputedReturnDoesNotClose() throws Exception { + ViewDispatches.Result r = run(HEAD + + " void doGet(HttpServletRequest req, HttpServletResponse res) {\n" + + " req.getRequestDispatcher(Pages.computed(1)).include(req, res);\n" + + " }\n}\n", 1); + assertTrue(r.dispatches.isEmpty()); + assertEquals("non-literal", r.unresolved.get(0).getReason()); + } + + @Test + void aTableWithoutALiteralInitializerDoesNotClose() throws Exception { + ViewDispatches.Result r = run(HEAD + + " void doGet(HttpServletRequest req, HttpServletResponse res) {\n" + + " req.getRequestDispatcher(Pages.fromLoaded(1)).include(req, res);\n" + + " }\n}\n", 1); + assertTrue(r.dispatches.isEmpty()); + assertEquals("non-literal", r.unresolved.get(0).getReason()); + } + + private static final String PARAMETER = HEAD + + " void a() { show(Pages.page(0)); }\n" + + " void b() { show(\"/c.jsp\"); }\n" + + " void show(String page) {\n" + + " getServletContext().getRequestDispatcher(page).include(null, null);\n" + + " }\n}\n"; + + @Test + void aParameterBoundToATableAndALiteralUnionsAtLevelFour() throws Exception { + ViewDispatches.Result r = run(PARAMETER, 4); + assertEquals(List.of("[table] a.jsp", "[table] aImg.jsp", "[table] b.jsp", "[table] bImg.jsp", + "[table] c.jsp"), targets(r)); + } + + @Test + void theParameterStaysNonLiteralBelowLevelFour() throws Exception { + ViewDispatches.Result r = run(PARAMETER, 3); + assertTrue(r.dispatches.isEmpty()); + assertEquals("non-literal", r.unresolved.get(0).getReason()); + } +}