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
7 changes: 7 additions & 0 deletions graph/python/engine/resolution/generics.dl
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,13 @@ param_class_object_bound("client", ph, t) :-
type_ref_nesting("client", r, _, "1", child),
type_ref("client", "TYPE_VAR", _, vn, _, child), vn != "",
typevar_bound("client", vn, t).
// …and the same under a union (`cls: type[CmdType] | None = None`): see param_class_object_ref.
param_class_object_bound("client", ph, t) :-
param_class_object_ref("client", ph, s),
type_ref_owner("client", ph, "METHOD_PARAM", r), s != r,
type_ref_nesting("client", s, _, _, child),
type_ref("client", "TYPE_VAR", _, vn, _, child), vn != "",
typevar_bound("client", vn, t).
expr_type_class_object("client", e, t) :-
expr_names_param("client", e, ph),
param_class_object_bound("client", ph, t).
22 changes: 22 additions & 0 deletions graph/python/engine/resolution/value-flow.dl
Original file line number Diff line number Diff line change
Expand Up @@ -450,13 +450,35 @@ expr_type_class_object(p, e, t) :-
annotation_names_a_class("type").
annotation_names_a_class("Type").

// ── param_class_object_ref(Prov, ParamHash, SubscriptRef) ─────────────────────
// The `type[...]` subscript a parameter's annotation IS, or one operand of its union is.
// `cls: type[Command] | None = None` (and `Optional[type[Command]]`) is the default-None
// spelling of the same class-object parameter: the body replaces None with a default class
// and calls `cls(...)`. Read only at the top level, that call stayed unresolved, and so did
// every constructor reached through it.
param_class_object_ref("client", ph, r) :-
type_ref_owner("client", ph, "METHOD_PARAM", r),
type_ref("client", _, "METHOD_PARAM", tn, _, r),
annotation_names_a_class(tn).
param_class_object_ref("client", ph, s) :-
type_ref_owner("client", ph, "METHOD_PARAM", r),
union_operand("client", r, s),
type_ref("client", _, _, tn, _, s),
annotation_names_a_class(tn).

expr_type_class_object("client", e, t) :-
expr_names_param("client", e, ph),
type_ref_owner("client", ph, "METHOD_PARAM", r),
type_ref("client", _, "METHOD_PARAM", tn, _, r),
annotation_names_a_class(tn),
type_ref_nesting("client", r, _, "1", child),
type_ref_resolved("client", t, child).
expr_type_class_object("client", e, t) :-
expr_names_param("client", e, ph),
param_class_object_ref("client", ph, s),
type_ref_owner("client", ph, "METHOD_PARAM", r), s != r,
type_ref_nesting("client", s, _, _, child),
type_ref_resolved("client", t, child).

// A name written MORE THAN ONCE takes the union, for the same reason the instance side
// does: a sound set beats a blank, and the tier follows from the target count.
Expand Down
1 change: 1 addition & 0 deletions graph/python/souffle/decls_all.dl
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@
.decl binding_element_lib_type(c0:symbol,c1:symbol)
.decl param_declared_type_by_parser(c0:symbol,c1:symbol,c2:symbol)
.decl union_operand(c0:symbol,c1:symbol,c2:symbol)
.decl param_class_object_ref(c0:symbol,c1:symbol,c2:symbol)
.decl type_ref_element(c0:symbol,c1:symbol,c2:symbol)
.decl annotation_owner_module(c0:symbol,c1:symbol,c2:symbol,c3:symbol)
.decl annotation_container_kind(c0:symbol)
Expand Down
28 changes: 25 additions & 3 deletions plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -671,12 +671,17 @@ def _string_literals(q):
# 3. a string that names a MEMBER OF A TYPE THE SAME DECORATION NAMES: `@SelectProvider(type = StockSql.class,
# method = "byShelf")` points at StockSql.byShelf; it is a reference to that method, not a key for this one.
# Only decided with the graph (`names_member(type, name)`); without it the string is kept.
_STRING = re.compile(r'"([^"]{1,120})"|\'([^\']{1,120})\'')
# 4. a string that names a PARAMETER OF THE DECLARATION IT DECORATES: `@option("--params", "-p", "params")` on
# `def main(url, params)` binds the value a caller passes after `--params` to `params`. The flags are what a caller
# writes to reach the declaration; the parameter name is written by every function that builds a dict with a
# `params` key, and joined as a key it made each of them a caller of the command.
# 5. a string inside ANOTHER call in the decoration: `type=File("wb")` configures a value, it names nothing registered.
_STRING =re.compile(r'"([^"]{1,120})"|\'([^\']{1,120})\'')
_KEYWORD_BEFORE = re.compile(r'(\w+)\s*[=:]\s*[\[{(]?\s*(?:(?:"[^"]*"|\'[^\']*\')\s*,\s*)*$')
_TYPE_ARG = re.compile(r'(?<![\w."\'$])([A-Z][\w$]*)(?:\s*\.\s*class)?(?=\s*[,)\]}])')


def decoration_key_strings(text, name=None, names_member=None):
def decoration_key_strings(text, name=None, names_member=None, params=None):
"""the strings a decoration's text registers its declaration under, sorted: every quoted string in it but prose,
and but the three shapes above (a non-registering decoration `name`, a configuring keyword, a member reference).
A STRING WITH A SPACE IN IT IS PROSE, NOT A KEY: `@widgets.doc("Endpoint to list the widgets")`, `@Operation(summary = "List
Expand All @@ -693,10 +698,26 @@ def decoration_key_strings(text, name=None, names_member=None):
kw = _KEYWORD_BEFORE.search(t[:m.start()].replace('"""', '"'))
if kw and not _KEY_KEYWORD.match(kw.group(1)): continue
if any(names_member(ty, key) for ty in types): continue
if _call_depth(t, m.start()) > 1: continue
if params and key in params: continue
out.add(key)
return sorted(out)


def _call_depth(t, i):
"""how many parentheses are open at offset i of a decoration's text, strings blanked: 1 is the decoration's own
argument list"""
return _STRING.sub(lambda m: '"' + ' ' * (len(m.group(0)) - 2) + '"', t[:i]).count('(') - \
_STRING.sub(lambda m: '"' + ' ' * (len(m.group(0)) - 2) + '"', t[:i]).count(')')


def _params_of(signature):
"""the parameter names a `name(a, b=1, *c)` signature declares"""
m = re.search(r'\((.*)\)', signature or '')
if not m: return set()
return {re.sub(r'[:=].*$', '', p).strip().lstrip('*') for p in m.group(1).split(',')} - {''}


def member_names(q):
"""names_member for decoration_key_strings, read from the graph: does a type of this simple name declare a member
of that name"""
Expand All @@ -720,13 +741,14 @@ def decoration_keys(q, site_file=None):
# production code; nothing is lost by declining to read a test's own decoration as a registration.
tests = {r[0] for r in q("SELECT id FROM symbols WHERE is_test = 1")} if _has(q, 'symbols') else set()
members = member_names(q)
sigs = dict(q("SELECT id, signature FROM symbols WHERE signature IS NOT NULL")) if _has(q, 'symbols') else {}
out = []
for owner, name, text, f, l in q("""SELECT owner_id, name, text, file, line FROM decorations
WHERE text IS NOT NULL AND text <> '' AND owner_id IS NOT NULL"""):
if owner in tests:
continue
short = (name or '').split('.')[-1]
for key in decoration_key_strings(text, name, members):
for key in decoration_key_strings(text, name, members, _params_of(sigs.get(owner))):
kind = 'route' if key.startswith('/') else 'key'
why = (f'registered as a route "{key}" by @{short} — the router calls it, no call site does' if kind == 'route'
else f'registered under "{key}" by @{short} — whoever writes that string reaches it, and no call site does')
Expand Down
162 changes: 140 additions & 22 deletions plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path
Original file line number Diff line number Diff line change
Expand Up @@ -1887,10 +1887,49 @@ def grep_for(g, name):
"""the exact search that finds what the graph could not: the name as a whole word in the source roots"""
return f'grep -rnw "{name}" {grep_roots(g)}'

# ── a JavaScript test file that never loads the target ────────────────────────────────────────────────────────────
JS_EXT = ('.js', '.mjs', '.cjs', '.jsx')
def never_loads(g, A, B):
"""(test file, target files) when every start is in ONE JavaScript test file whose imports, followed through the
project, never reach a file the target is declared in; else None. Such a file cannot run the target, whatever route
the call graph has: the route goes through a callable another test file declares (a callback handed to a shared
function) or an override a plugin installs only where it is loaded. Read from impact's import facts
(imports_fact), only when they were written for this graph; where either end has no import at all the scan cannot
say (a path alias, a file loaded by a computed name), and nothing is gated. impact drops the same tests the same way."""
fa = {g.sym[m].get('file') for m in A if m in g.sym}
fb = {g.sym[m].get('file') for m in B if m in g.sym} - {None, '<library>', '<written>'}
if len(fa) != 1 or not fb: return None
fa = next(iter(fa))
if not fa or not fa.endswith(JS_EXT) or fa in fb or not all(g.sym[m].get('is_test') for m in A if m in g.sym): return None
D = os.path.join(g.facts, 'impact')
try:
if not open(os.path.join(D, 'stamp')).read().startswith(f"{g.db_mtime}:"): return None
fwd, rev = collections.defaultdict(set), collections.defaultdict(set)
for ln in open(os.path.join(D, 'imports_fact.facts'), encoding='utf-8', errors='replace'):
x, _, y = ln.rstrip('\n').partition('\t')
if x and y: fwd[x].add(y); rev[y].add(x)
dyn = {ln.rstrip('\n') for ln in open(os.path.join(D, 'imports_dynamic.facts'), encoding='utf-8', errors='replace')}
except OSError:
return None
if not fwd.get(fa) or not all(rev.get(f) for f in fb): return None
seen, todo = {fa}, [fa]
while todo:
for y in fwd.get(todo.pop(), ()):
if y not in seen: seen.add(y); todo.append(y)
# a file on the way that loads a module by a computed name (`require(path.join(dir, f))`) may load the target
return (fa, sorted(fb)) if not (seen & fb) and not (seen & dyn) else None

# ── the query────────────────────────────────────────────────────────────────────────────────────────────────────
def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20):
g.export()
la, A = g.resolve(a); lb, B = g.resolve(b)
nl = never_loads(g, A, B)
if nl:
RESULT['never_loads'] = {'test_file': nl[0], 'target_files': nl[1]}
print(f"no route: {nl[0]} never loads {', '.join(nl[1])} — none of the files it imports, followed through the project,"
f" imports it, so no run of that test executes {lb}. The call graph's routes between them go through code that"
f" file never loads (a callback another test file hands to a shared function, an override a plugin installs).")
return 1
g.add_outside_call_edges()
res = run(g, {'fwd': (A, B), 'rev': (B, A)}, ('path.dl', 'path-every.dl') if every else ('path.dl',))
if not res['hit']['fwd'] and not res['hit']['rev']: # nothing resolved connects them: now, and only now, the by-name closure
Expand Down
Loading
Loading