Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .claude/SCHEMA_DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 67 additions & 10 deletions src/main/java/com/ibm/cldk/artifacts/DataflowTiers.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -75,6 +76,59 @@ static String interproc(Owner owner, String var, CallSiteIndex sites, Map<String
return InterprocTier.close(owner, var, sites, owners);
}

/** What {@link #interprocAll} closed on: the union of every caller's targets, and how. */
static final class Closure {
final List<String> targets;
/** True when at least one caller closed through {@code extra} rather than a literal. */
final boolean viaExtra;

Closure(List<String> targets, boolean viaExtra) {
this.targets = targets;
this.viaExtra = viaExtra;
}
}

/**
* L4, many-valued: as {@link #interproc}, but every caller's argument may close on a
* <em>set</em> — 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<String, Owner> owners, Function<String, List<String>> 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<CallSiteIndex.Site> targeting = sites.byCallee.get(c.getId());
if (targeting == null || targeting.isEmpty()
|| sites.unresolvedNames.contains(InterprocTier.simpleName(c))) {
return null;
}
Set<String> 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<String> args = site.node.getArgumentExpr();
List<String> 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
// ----------------------------------------------------------------------------------------
Expand Down Expand Up @@ -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<String, Owner> 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;
}
Expand All @@ -278,7 +335,7 @@ static String close(Owner owner, String var, CallSiteIndex sites, Map<String, Ow
}

/** 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,
static String siteLiteral(CallSiteIndex.Site site, int paramIndex,
Map<String, Owner> owners) {
List<String> args = site.node.getArgumentExpr();
if (args == null || args.size() <= paramIndex) {
Expand All @@ -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;
}
Expand All @@ -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 "";
Expand Down
200 changes: 200 additions & 0 deletions src/main/java/com/ibm/cldk/artifacts/StringTables.java
Original file line number Diff line number Diff line change
@@ -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 <em>every</em> literal in that initializer — a may-dispatch. DayTrader's
* {@code TradeConfig.getPage(N)} = {@code return webUI[webInterface][pageNumber]} is the shape.
*
* <p>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<String, List<Owner>> 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<String, JModule> modules) {
if (modules != null) {
for (JModule m : modules.values()) {
index(m.getTypes(), m.getSource());
}
}
}

private void index(Map<String, JType> types, String source) {
if (types == null) {
return;
}
for (Map.Entry<String, JType> 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<String> 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<Owner> 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<String> 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<String> roots = new LinkedHashSet<>();
List<ReturnStmt> 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<String> literalsOf(String initializer) {
List<String> 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<String> 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);
}
}
Loading