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
5 changes: 4 additions & 1 deletion parser/src/parsers/java/extractors/field-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,9 +470,12 @@ export class FieldExtractor {
);

// Extract expressions from field initializer (if present)
// Skip comment nodes between = and the initializer, as a local variable's initializer does:
// `static final F X = // why\n new F() { ... };` dropped the whole initializer, anonymous class included
const equalsIndex = declarator.children.findIndex(c => c.type === '=');
if (equalsIndex >= 0 && equalsIndex < declarator.children.length - 1) {
const initializerNode = declarator.children[equalsIndex + 1];
const initializerNode = declarator.children.slice(equalsIndex + 1)
.find(c => c.type !== 'line_comment' && c.type !== 'block_comment');
if (initializerNode) {
const expressions = this.expressionExtractor.extractFromFieldInitializer(
initializerNode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2525,7 +2525,8 @@ export class LocalVariableExtractor {
// Extract initializer expressions
const equalsIndex = child.children.findIndex(c => c.type === '=');
if (equalsIndex >= 0 && equalsIndex < child.children.length - 1) {
const initializerNode = child.children[equalsIndex + 1];
const initializerNode = child.children.slice(equalsIndex + 1)
.find(c => c.type !== 'line_comment' && c.type !== 'block_comment');
if (initializerNode) {
this.extractInitializerExpressions(
initializerNode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,8 @@ public class CommentsAreInvisible {
void castExpression() { Object x = (Object) /* a */ f(); }
void forClauses() { for (int i = /* a */ 0; /* b */ c; /* c */ i++) { } }
int switchArm() { return switch (1) { default -> /* a */ f(); }; }
Object fieldInit = /* a */ new Object();
Runnable fieldAnon = // a
new Runnable() { public void run() { f(); } };
void tryResource() throws Exception { try (java.io.StringReader r = /* a */ new java.io.StringReader("")) { f(); } }
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,8 @@ public class CommentsAreInvisibleControl {
void castExpression() { Object x = (Object) f(); }
void forClauses() { for (int i = 0; c; i++) { } }
int switchArm() { return switch (1) { default -> f(); }; }
Object fieldInit = new Object();
Runnable fieldAnon =
new Runnable() { public void run() { f(); } };
void tryResource() throws Exception { try (java.io.StringReader r = new java.io.StringReader("")) { f(); } }
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@

# a certainty that is an exact edge (or not a row about a call at all): no evidence
EXACT = {None, '', 'resolved', 'sound', 'entry', 'defines', 'defines (not a call)', 'must change', 'alongside',
'stubs it', 'at import', 'decorator', 'test'}
'stubs it', 'at import', 'at load', 'decorator', 'test'}


def _env(k):
Expand Down
16 changes: 10 additions & 6 deletions plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,14 @@ _CONFIG = {'config', 'config_key_known', 'config_site'}
# exactly the kind that used to be handed them empty. Skipping them there made the rule produce nothing at all,
# silently — the one failure mode this table can have.
_KEY_LAYER = {'literal', 'dec_literal'}
# and `field` / `faccess` are read by the `at load` hop for every kind that walks the closure: a test that reads a static
# field of a type runs that type's initializer (load_hop / init_use). Handed them empty, a method target's closure that
# reached a field initializer named only the type's CALLERS and dropped every test that touches it through a field.
_LOAD_LAYER = {'field', 'faccess'}
KIND_SKIPS = { # relations a query of this kind can never reach
'method': (_REF_LAYER - {'reexport', 'reexport_from'} - _KEY_LAYER) | _CONFIG,
'param': (_REF_LAYER - {'reexport', 'reexport_from'} - _KEY_LAYER) | _CONFIG,
'var': (_REF_LAYER - {'reexport', 'reexport_from'} - _KEY_LAYER) | _CONFIG,
'method': (_REF_LAYER - {'reexport', 'reexport_from'} - _KEY_LAYER - _LOAD_LAYER) | _CONFIG,
'param': (_REF_LAYER - {'reexport', 'reexport_from'} - _KEY_LAYER - _LOAD_LAYER) | _CONFIG,
'var': (_REF_LAYER - {'reexport', 'reexport_from'} - _KEY_LAYER - _LOAD_LAYER) | _CONFIG,
'decoration': (_REF_LAYER - _KEY_LAYER) | _CONFIG,
'config': _REF_LAYER - _KEY_LAYER,
'newconst': (_REF_LAYER - {'switch_over'} - _KEY_LAYER) | _CONFIG,
Expand Down Expand Up @@ -2884,7 +2888,7 @@ def main(argv):
# absorbed: a front end that gains a tier should show up here the first time anyone queries, not go
# quietly into the bottom rung forever.
UNKNOWN_TIERS = set()
CERT_UP = {'known_edge': 'sound', 'library': 'sound', 'written': 'sound', 'multi_inferred': 'one of a set', 'dispatch': 'dispatch', 'defines': 'defines', 'by key': 'by key', 'spawns': 'spawns', 'decorator': 'sound', 'decorator by name': 'decorator by name', 'at import': 'at import', 'protocol': 'protocol',
CERT_UP = {'known_edge': 'sound', 'library': 'sound', 'written': 'sound', 'multi_inferred': 'one of a set', 'dispatch': 'dispatch', 'defines': 'defines', 'by key': 'by key', 'spawns': 'spawns', 'decorator': 'sound', 'decorator by name': 'decorator by name', 'at import': 'at import', 'at load': 'at load', 'protocol': 'protocol',
# handed over as a value and invoked by whoever holds it (`callback_registered`), or emitted
# here and handled there (`event_dispatch`). Real engine edges, so `by name` understates them;
# registrations rather than resolutions, so `one of a set` — which claims a resolved target
Expand Down Expand Up @@ -2941,7 +2945,7 @@ def main(argv):
# is not the `fixture` rung it used to be labelled as — a fixture route is weak for exactly the reason this one
# is strong. It sits below `one of a set` all the same, because it is not a resolved call and should not borrow
# the word that means one.
CERT_RANK = {'sound': 0, 'defines': 1, 'at import': 2, 'one of a set': 3, 'dispatch': 4, 'registered': 5,
CERT_RANK = {'sound': 0, 'defines': 1, 'at import': 2, 'at load': 2, 'one of a set': 3, 'dispatch': 4, 'registered': 5,
'remote': 6, 'spawns': 7, 'by key': 8, 'decorator by name': 9, 'protocol': 10, 'fixture': 11,
'by name': 12}
# `remote` (#1293) sits just above `by key`: both join two ends on a string, but the engine matched a remote
Expand Down Expand Up @@ -3489,7 +3493,7 @@ def main(argv):
# 180k answer on a hub method -- 435 tests, 433 of them through routes this answer itself calls weak -- which
# no agent can read inline, so it was saved to a file and re-read piecemeal. Every test is still accounted for:
# the strong ones by name here, all of them by rung and file under --tests, each route under --why.
STRONG = ('sound', 'defines', 'at import', 'one of a set')
STRONG = ('sound', 'defines', 'at import', 'at load', 'one of a set')
tname = lambda m: f"{g.sym[m]['owner'] or os.path.basename(g.sym[m]['file'])}::{g.sym[m]['name']}"
torder = lambda m: (tests[m][0], g.sym[m]['display'], g.loc(m), m)
def route(m):
Expand Down
34 changes: 34 additions & 0 deletions plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,17 @@ direct(q, c, "uses", cat("framework-mediated, not a call: servlet_filter via ",
protocol_member(t, m) :- member(t, m, n, _), match("__.*__", n), n != "__init__".
.decl constructs(c:symbol, t:symbol)
constructs(c, t) :- member(t, i, "__init__", _), edge(c, i, _), c != i.
// THE SAME SHAPE IN JAVA: the members a LIBRARY runs on a client object. `equals`, `hashCode` and `toString` are run
// by a HashMap key, a StringBuilder, String.format, an assertion; an override of a library method (`iterator` of an
// Iterable, `compareTo`, `close`, `size` of a Collection) by the for-each loop, the sort, try-with-resources, the
// collection itself. The call is written on a library receiver or not written at all, and Object's members are not
// fanned to client overrides on purpose (resolution/virtual-dispatch.dl: the CHA blow-up), so a change to one had no
// caller and selected no test. Whoever constructs the type, or a subtype of it, holds the object those calls run on.
// Still only from the CHANGE itself, as above.
protocol_member(t, m) :- member(t, m, n, _), java_decl(m), (n = "equals" ; n = "hashCode" ; n = "toString").
protocol_member(t, m) :- member(t, m, _, _), java_decl(m), override(b, m), !decl_file(b, _).
constructs(c, t) :- java_decl(t), kind(i, "constructor"), owner(i, t), edge(c, i, _), c != i.
constructs(c, t) :- java_decl(t), extends(s, t), kind(i, "constructor"), owner(i, s), edge(c, i, _), c != i.
// The hop is taken only where the CHANGE ITSELF is a protocol member, not wherever one turns up in the closure.
// As a global edge it was logically fine and empirically noisy: any closure that happened to contain a protocol
// member pulled in every constructor of that type and, through them, their tests. Measured across three subjects
Expand All @@ -1103,6 +1114,29 @@ constructs(c, t) :- member(t, i, "__init__", _), edge(c, i, _), c != i.
protocol_hop(q, c, m) :- seed(q, m), protocol_member(t, m), constructs(c, t), c != m.
up(q, c, 1) :- protocol_hop(q, c, _).
parent_up(q, a, b, "protocol") :- reach(q, a, d), d > 0, reach(q, b, d1), d1 = d - 1, protocol_hop(q, a, b).
// WHAT RUNS WHEN A TYPE IS LOADED OR BUILT (Java). A call written in a field initializer has no method around it, so
// the engine names the TYPE as its caller (call_chain.dl `call_from`), and a `static { }` / `{ }` block is a
// <clinit> / <init_block> callable. Nothing calls either: the JVM runs them when the type is first used or an
// instance is built. So the walk stopped at the type, and a factory whose `INSTANCE = new Factory()` throws — which
// breaks every test that touches the factory, by an ExceptionInInitializerError — selected none of them.
// The hop goes from that initializer to whoever uses the type in a way that runs it: calls one of its members (a
// static method, a constructor, a method of an instance), constructs a subtype, or reads or writes one of its fields.
// The type's own members count too: once its initialization has failed, every one of them fails with it.
// Taken only where the walk ARRIVES at the type as a caller (d > 0): a type that is the change itself, or the owner
// of a changed field, is a seed for other reasons, and its users are already the direct rows of those rules.
.decl init_runner(r:symbol, t:symbol)
init_runner(t, t) :- typ(t, _, _), java_decl(t), edge(t, _, _).
init_runner(c, t) :- member(t, c, "<clinit>", _), java_decl(c).
init_runner(c, t) :- member(t, c, "<init_block>", _), java_decl(c).
.decl init_use(a:symbol, t:symbol)
init_use(a, t) :- calls(a, m, tier, _, _), tier != "stub", owner(m, t).
init_use(a, t) :- calls(a, m, tier, _, _), tier != "stub", kind(m, "constructor"), owner(m, s), extends(s, t).
init_use(a, t) :- faccess(a, fl, _, _, _, _), field(fl, t, _, _, _).
.decl load_hop(q:symbol, a:symbol, r:symbol)
load_hop(q, a, r) :- up(q, r, d), d > 0, init_runner(r, t), r = t, init_use(a, t), a != r.
load_hop(q, a, r) :- up(q, r, _), init_runner(r, t), r != t, init_use(a, t), a != r.
up(q, a, d+1) :- up(q, r, d), load_hop(q, a, r), d < 40.
parent_up(q, a, b, "at load") :- reach(q, a, d), d > 0, reach(q, b, d1), d1 = d - 1, load_hop(q, a, b).
// WHAT RUNS AT IMPORT. `settings = Settings()` at the top level of a module is executed by the import statement,
// so breaking Settings.__init__ breaks importing that module, and with it every module that imports it and every
// test in those files. The graph records the module body's call to the constructor and records nothing at all about
Expand Down
32 changes: 32 additions & 0 deletions plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -3571,6 +3571,32 @@ def _has_framework_hops(q, at=None, site_file=None):
return True
return False

def _java_protocol(q, mids):
"""True when one of these Java methods is a member a library runs on the object (dl/impact.dl protocol_member)"""
if not mids or not _has(q, 'symbols'): return False
ph = ','.join('?' * len(mids))
if q(f"SELECT 1 FROM symbols WHERE id IN ({ph}) AND file LIKE '%.java' AND name IN ('equals', 'hashCode', 'toString') LIMIT 1", *mids):
return True
return bool(_has(q, 'overrides') and q(f"""SELECT 1 FROM overrides o JOIN methods b ON b.id = o.method_id
JOIN symbols s ON s.id = o.overriding_method_id
WHERE o.overriding_method_id IN ({ph}) AND b.provenance = 'lib'
AND s.file LIKE '%.java' LIMIT 1""", *mids))

def _reaches_init(q, depth):
"""True when the closure holds a Java initializer the rules hop on from (load_hop): a type reached as a caller, or a
<clinit> / <init_block> callable"""
if not depth or not _has(q, 'symbols'): return False
ids = [m for m, d in depth.items() if d > 0 and m.startswith('TYPE_')]
if ids and q("SELECT 1 FROM call_edges WHERE caller_id IN ({}) LIMIT 1".format(','.join('?' * len(ids))), *ids):
return True
ids = list(depth)
for i in range(0, len(ids), 900):
part = ids[i:i + 900]
if q("SELECT 1 FROM symbols WHERE id IN ({}) AND name IN ('<clinit>', '<init_block>') AND file LIKE '%.java' LIMIT 1"
.format(','.join('?' * len(part))), *part):
return True
return False

def _spawn_edges(q, lines, at):
"""fw_edge(a, b, "spawns") :- spawns_fact(a, b): a test that runs a script by its path (ax_spawn.py)"""
if lines is None or at is None or not _has(q, 'symbols'): return []
Expand Down Expand Up @@ -3635,6 +3661,9 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non
# `target(q,k,s,x)`: a STRING target is written (q,"string","",value) — no symbol at all — so the ids and
# the extras are kept apart rather than one standing in for the other.
ids = sorted({s_ for _k, s_, _x in mine if s_})
# a Java member a library runs (dl/impact.dl protocol_member: Object's equals / hashCode / toString, an
# override of a library method) is reached through whoever constructs its type: not ported, so decline
if _java_protocol(q, sorted({s_ for k_, s_, _x in mine if s_ and k_ == 'method'})): return None
by_kind, extra = {}, {}
for k, s_, x in mine:
if s_: by_kind.setdefault(k, set()).add(s_)
Expand Down Expand Up @@ -3868,6 +3897,9 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non
out['direct_edge'] += [[c, m, qq] for c, m in _de]
out['seed_byname'] += [[c, qq] for c in byname]
depth = reach_from(rev, seeds, byname, gate=gate)
# the `at load` hop (dl/impact.dl load_hop) is not ported: a closure that arrives at a Java type as a caller
# (a field initializer) or at a <clinit> / <init_block> goes on to the type's users there, so decline
if _reaches_init(q, depth): return None
out['reach'] += [[m, str(d), qq] for m, d in depth.items()]
# reach_sure: the same closure from the seeds that are an exact edge only — a seed reached ONLY through a
# by-name / text / one-of-a-set dependent is weak, and the answer says how much of itself rests on those
Expand Down
10 changes: 10 additions & 0 deletions tests/cases/java/field-initializer-after-a-comment/case.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{"lang": "java", "src": "src",
"checks": [
{"why": "a field initializer written after a comment is still read: the anonymous class it creates, and what its method calls, are in the graph",
"run": ["impact", "Shout.loud", "--tests"],
"want": ["RouterTest::routesUpper"],
"avoid": ["0 of"]},
{"why": "control: the same initializer with no comment, read before and after",
"run": ["impact", "Shout.soft", "--tests"],
"want": ["RouterTest::routesLower"],
"avoid": ["0 of"]}]}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package pkg;

public interface Handler {
String handle(String s);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package pkg;

public final class Handlers {
private Handlers() { }

public static final Handler UPPER =
// a comment between the `=` and the initializer, as a formatter or an author leaves one
new Handler() {
@Override
public String handle(String s) { return Shout.loud(s); }
};

// control: the same shape with no comment
public static final Handler LOWER =
new Handler() {
@Override
public String handle(String s) { return Shout.soft(s); }
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package pkg;

public class Router {
public String route(Handler h, String s) { return h.handle(s); }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package pkg;

import org.junit.Test;

public class RouterTest {
@Test
public void routesUpper() { assert "X".equals(new Router().route(Handlers.UPPER, "x")); }

@Test
public void routesLower() { assert "x".equals(new Router().route(Handlers.LOWER, "X")); }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package pkg;

public final class Shout {
static String loud(String s) { return s.toUpperCase(); }

static String soft(String s) { return s.toLowerCase(); }
}
14 changes: 14 additions & 0 deletions tests/cases/java/library-runs-the-members-it-is-given/case.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{"lang": "java", "src": "src", "library": "lib/kit",
"checks": [
{"why": "hashCode is run by the map the key is put into; the test that builds the key through Index is selected",
"run": ["impact", "Key.hashCode", "--tests"],
"want": ["IndexTest::countsRepeats"],
"avoid": ["TapTest", "OtherTest", "0 of"]},
{"why": "an override of a library method is run by the library; the test that builds the object and hands it over is selected",
"run": ["impact", "Tap.next", "--tests"],
"want": ["TapTest::drains"],
"avoid": ["IndexTest", "OtherTest", "0 of"]},
{"why": "control: a toString on a type no test constructs selects no test",
"run": ["impact", "Unseen.toString", "--tests"],
"want": ["0 of"],
"avoid": ["IndexTest", "TapTest", "OtherTest"]}]}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package kit;

public final class Pump {
private Pump() { }

// the library calls the client's override: no call to it is written in the client
public static String drain(Source s) { return s.next(); }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package kit;

public interface Source {
String next();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package pkg;

import java.util.HashMap;
import java.util.Map;

public class Index {
private final Map<Key, Integer> counts = new HashMap<>();

public int put(String name) { return counts.merge(new Key(name), 1, Integer::sum); }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package pkg;

import org.junit.Test;

public class IndexTest {
@Test
public void countsRepeats() { Index i = new Index(); i.put("a"); assert i.put("a") == 2; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package pkg;

public final class Key {
private final String name;

Key(String name) { this.name = name; }

// run by the HashMap the key is put into: no call is written on a Key receiver
@Override
public int hashCode() { return name.hashCode(); }

@Override
public boolean equals(Object o) { return o instanceof Key && ((Key) o).name.equals(name); }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package pkg;

import org.junit.Test;

// control: constructs none of the types above
public class OtherTest {
@Test
public void unrelated() { assert "x".length() == 1; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package pkg;

import kit.Source;

public class Tap implements Source {
@Override
public String next() { return "drip"; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package pkg;

import kit.Pump;
import org.junit.Test;

public class TapTest {
@Test
public void drains() { assert "drip".equals(Pump.drain(new Tap())); }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package pkg;

// control: a type no test constructs
public final class Unseen {
@Override
public String toString() { return "unseen"; }
}
Loading
Loading