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
8 changes: 7 additions & 1 deletion plugins/axiomcode/hooks/enrich.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,11 @@ def lookup(n):
if e: rel_[r['id']] = (e['how'], ctx_names.get(e['who'], '?'))
rows = sorted(rows, key=lambda r: r['id'] not in rel_) # stable: the order above within each group
if plain and not rel_ and not unres: return n, [], []
# OVERLOADS IN ONE FILE ARE ONE NAME TO THE READER: `ISender.Send` declared twice in one interface printed as the
# same line twice. Each (display, file) is one line, saying how many declarations it stands for.
grp = {}
for r in rows: grp.setdefault((r['display'], r['file']), []).append(r)
rows = [v[0] for v in grp.values()]; n_ol = {v[0]['id']: len(v) for v in grp.values()}
total = len(rows); rows = rows[:2]
out = []
paths = _graphline.distinct_paths([r['file'] or '' for r in rows])
Expand All @@ -404,7 +409,8 @@ def lookup(n):
+ (f"; {nt} in tests" if nt and 2 < len(up) < 40 and nt < len(up) else '') + ")") if up \
else _graphline.zero_label(c, r['method_id'], r['display'].rsplit('.', 1)[-1], r['is_test'])
out.append(f" {r['display']} {path}:{r['line']} ← {callers} → {dn}" + (f" ? {un}" if un else '')
+ (f" ⇣ {nover[r['id']]} override(s)" if nover.get(r['id']) else '') + tag)
+ (f" ⇣ {nover[r['id']]} override(s)" if nover.get(r['id']) else '')
+ (f" ({n_ol[r['id']]} overloads)" if n_ol.get(r['id'], 1) > 1 else '') + tag)
if total > len(rows) and out: out[-1] += f" (+{total - len(rows)} more declaration(s){'' if total < 40 else ' or more'})"
if unres: out.append(f" ({ctx_names.get(unres[0]['caller_id'], '?')}, which you just read, calls a `{n}` at L{unres[0]['start_line']} whose receiver is not typed — it may be any of the above)")
return n, rows, out
Expand Down
47 changes: 44 additions & 3 deletions plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,17 +168,58 @@ def legend(tiers):
'registered': 'handed over as a value — the engine recorded the hand-off, not a call site',
'capped set': 'calls it, as one of a candidate set too large to enumerate — this is a sample of that set',
}
# …and where the TIER says something more specific than its certainty. An event is not handed over as a
# value: the caller publishes or emits it, and the event system runs every handler whose event it is.
# …and where the TIER says something more specific than its certainty. A request or event is not handed over as a
# value (a JavaScript callback's wording): the dependent sends it, and a framework runs the handler for what is sent.
TIER_WHY = {
'event_dispatch': 'publishes an event this handles: the event system runs it, no call site names it',
'event_dispatch': 'sends the request or event this handles — a framework runs it for what is sent here, no call site names it',
}


def direct_why(tier):
"""what a DIRECT dependent row says for an edge of this tier."""
return TIER_WHY.get(tier) or DIRECT_WHY.get(direct_cert(tier), 'calls it')


# ── entry points: what the reason token means, said in words ───────────────────────────────────────────────
# `entry_points.reason` is an engine token (`orm_hook`, `bean_ctor`, `framework_hook`). Printed raw inside a fixed
# sentence it read "is a orm_hook entry point … Changing it changes what the outside world can call" for a model
# configuration callback the ORM runs at startup, which no outside caller ever reaches. Two kinds, one table:
# OUTSIDE a request, a process start, a remote client, a command line or a message reaches it: changing it
# changes what the outside world can call, and the sentence says so (unchanged).
# CALLBACK the framework calls it back (a hook, a lifecycle method, a constructor it runs, a factory, a fixture, a
# provider): a change breaks that framework contract, not an outside caller.
# Every surface that words an entry point (impact's entry line and `next:`, path) reads this table. A token it has not
# seen is worded "framework-called (<token>)", never printed bare, and is a callback: the weaker claim.
ENTRY = {
'http': ('an HTTP route handler', 'outside'), 'url': ('a URL route handler', 'outside'),
'main': ('a program entry point', 'outside'), 'grpc_service': ('a gRPC service method', 'outside'),
'hub': ('a real-time hub method', 'outside'), 'cli': ('a command-line command', 'outside'),
'queue': ('a message consumer', 'outside'), 'task': ('a background task a queue runs', 'outside'),
'web_filter': ('a web request filter', 'outside'),
'package_export': ('an export of the package', 'outside'),
'exported_from_entry_module': ('an export of the entry module', 'outside'),
'unimported_module': ('a module run directly, which nothing imports', 'outside'),
'framework_hook': ('a framework hook', 'callback'), 'orm_hook': ('a model hook the ORM or validation library runs', 'callback'),
'lifecycle': ('a lifecycle callback', 'callback'), 'bean_ctor': ('a constructor the container runs to build a bean', 'callback'),
'factory': ('a factory method the container calls', 'callback'), 'fixture': ('a test fixture', 'callback'),
'service_loader': ('a provider a service loader instantiates', 'callback'),
'spring_factories': ('an auto-configuration class the container loads', 'callback'),
'di_provider': ('a dependency-injection provider', 'callback'), 'signal_receiver': ('a signal receiver', 'callback'),
'web_listener': ('a web container listener', 'callback'), 'scheduled': ('a scheduled job', 'callback'),
'event_listener': ('an event listener', 'callback'),
'test': ('a test', 'test'),
}


def entry_phrase(reason):
"""'an ORM model hook' for `orm_hook`; an unknown token is 'a framework-called (<token>) method'"""
return ENTRY[reason][0] if reason in ENTRY else f"a framework-called ({reason}) method"


def entry_outside(reason):
"""does the outside world (a request, a process start, a client, a message) call an entry point of this reason?"""
return ENTRY.get(reason, ('', 'callback'))[1] == 'outside'

# certainties that are backed by an edge the ENGINE asserted, as opposed to a name or a text match.
# Consumers that used to test `cert == 'resolved'` to mean "this row claims an edge" test this
# instead: the membership is exactly what it was before this table existed, so no row leaves any
Expand Down
50 changes: 48 additions & 2 deletions plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,52 @@ def _classify(fp):
return 1 if b'\0' in head else 0


# ── which name matches are noise ─────────────────────────────────────────────────────────────────────────
# Two rules, applied to every hit before anyone sees it (impact's "bound from outside the source", context's text
# bindings):
#
# OUT OF SCOPE. A shell script or a Datalog file is never analysed, so a name matched inside one is noise by rule:
# `hits` for a method `hits` was 21 rows, most of them CI scripts and rule comments. An extensionless script is
# one by its first line (`mvnw`, `gradlew`: `#!/bin/sh`).
#
# PROSE. A name that is a plain English word (`note`, `export`, `build`, `validate`: lowercase letters only, no `_`
# and no case boundary) is written as a word in templates, YAML, CI files and comments far more often than as a
# binding: 261 rows for one Django view, 71 for `note`, a Maven `<phase>validate</phase>` for a Java `validate`.
# Such a match is kept as a binding only where the line writes it the way code or configuration refers to a
# callable: called (`note(`), quoted as a value (`"note"`, `'note'`), or qualified with `#`, `::` or `->`
# (`Owner#note`; a CSS `#note` selector is not one). A dotted `Owner.note` is matched as the qualified name itself and never reaches this rule. The
# rest are PROSE: counted and grep-able, not listed as places a rename breaks.
OUT_OF_SCOPE_EXT = {'.sh', '.bash', '.zsh', '.ksh', '.dl'}
SHELL_SHEBANG = re.compile(r'#!\s*\S*(?:/|\s)(?:env\s+)?(?:ba|z|k|da)?sh\b')
COMMON = re.compile(r'[a-z]+')
_QUOTES = '"\'`'
QUALIFIER = re.compile(r'[\w$)\]>](?:#|::|->)$') # `Owner#note`, `Owner::note`, `$obj->note`; not a CSS `#note` selector


def out_of_scope(rel, text):
"""a shell script or a Datalog file: a name matched there is never a binding (owner's rule)"""
if os.path.splitext(rel)[1].lower() in OUT_OF_SCOPE_EXT: return True
return not os.path.splitext(rel)[1] and bool(SHELL_SHEBANG.match(text[:120]))


def is_common(name):
"""a plain word: the names whose bare mentions are mostly prose"""
return bool(COMMON.fullmatch(name or ''))


def code_shaped(line, start, end):
"""the match line[start:end] is written as a reference: called, quoted as a whole value, or #/::/-> qualified"""
before, after = line[:start], line[end:]
if after.lstrip().startswith('('): return True
if before[-1:] and before[-1] in _QUOTES and after[:1] == before[-1]: return True
return bool(QUALIFIER.search(before))


class NonSource:
def __init__(self, repo, cache_dir, indexed, skip_dir, skip_ext):
self.repo, self.cache_dir, self.indexed, self.skip_dir, self.skip_ext = repo, cache_dir, indexed, skip_dir, skip_ext
self._files = None; self._uncached = set(); self.con = None
self.prose = set() # (name, rel, line) hits of a common word with no reference syntax around it (see PROSE)
if cache_dir and not os.environ.get('AXIOMCODE_NO_SCAN_CACHE'):
try:
os.makedirs(cache_dir, exist_ok=True)
Expand Down Expand Up @@ -154,8 +196,12 @@ def hits(self, names):
if only is not None and rel not in only: continue
try: text = open(os.path.join(self.repo, rel), errors='replace').read()
except OSError: continue
if not any(n in text for n in names): continue
if not any(n in text for n in names) or out_of_scope(rel, text): continue
for i, line in enumerate(text.split('\n'), 1):
for m in pat.finditer(line): hits.append((m.group(1), rel, i))
shaped = {}
for m in pat.finditer(line):
n = m.group(1); hits.append((n, rel, i))
shaped[n] = shaped.get(n, False) or not is_common(n) or code_shaped(line, m.start(1), m.end(1))
self.prose.update((n, rel, i) for n, ok in shaped.items() if not ok)
if len(hits) > 2000: break
return hits
9 changes: 8 additions & 1 deletion plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,14 @@ def next_path(text):
rows = re.findall(r'^\s+(\d+) hop\(s\)\s+(\S+).*?\s' + LOC, text, re.M)
if rows:
near = min(int(h) for h, _, _ in rows)
first = [(n, loc) for h, n, loc in rows if int(h) == near][:5]
# each caller ONCE (#1389): the same caller is printed under "nearest callers" (at its call line) and again under
# "entry points" (at its declaration), so the rows are keyed on the name and its FILE, not the line. Two callers
# of one display name in two files are two callers, and stay two.
seen_, first = set(), []
for h, n, loc in rows:
if int(h) == near and (n, loc.rsplit(':', 1)[0]) not in seen_:
seen_.add((n, loc.rsplit(':', 1)[0])); first.append((n, loc))
first = first[:5]
total = re.search(r'(\d+) (?:method|callable)s?\b', text)
return (f"next: the nearest {'caller is' if len(first) == 1 else 'callers are'} at {near} hop(s): "
+ ', '.join(f"{n} {loc}" for n, loc in first)
Expand Down
2 changes: 2 additions & 0 deletions plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,9 @@ def text_bindings(g, sids, under=(), want_ext=()):
except Exception: return []
ns_of = {}
out, seen = [], set()
prose = getattr(nonsource(g), 'prose', set()) # a common word written as a word is not a binding (ax_nonsource.py)
for nm, f, line in hits:
if (nm, f, line) in prose: continue
if under and not any(f.startswith(d.rstrip('/') + '/') for d in under): continue
if f.endswith('.xml'):
if f not in ns_of:
Expand Down
Loading
Loading