From 2ac30b0c5a7ae42fce61ca78512938c2b0b83a29 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:05:02 -0700 Subject: [PATCH 1/4] impact, path and hooks: each dependent once, prose is not a binding, labels worded from the reason (#1389, #1423, #1544) One row per dependent: impact keyed its must-change and direct rows on (callable, reason), so a callable with two reasons printed twice and the section header counted it once per rung. Rows are now keyed on the callable within each section; the surest reason names the row and the others follow as "also:". --json keeps every reason. path next: names each caller once, and the Grep preview prints overloads in one file as one line. Non-source text: a name matched inside a .sh or .dl file (or an extensionless shell script) is out of scope and dropped. A plain-word name (note, export, validate) written with no call, quote or qualifier around it is prose: counted with the grep that lists it, listed under --page all or --limit, flagged "prose" in --json, and never counted as a binding. Labels: entry points are worded from one table in ax_edges.py. A route, main, RPC, CLI or message surface keeps today's sentence; a framework callback (an ORM hook, a bean constructor, a lifecycle method) says the framework calls it back and a change breaks that contract. An event or request hop says it sends what the handler handles. The tests line names test-code callables the answer lists but the count does not credit. --- plugins/axiomcode/hooks/enrich.py | 8 +- .../skills/axiomcode/scripts/ax_edges.py | 47 +++++- .../skills/axiomcode/scripts/ax_nonsource.py | 50 ++++++- .../skills/axiomcode/scripts/ax_pages.py | 9 +- .../axiomcode/scripts/axiomcode-context | 2 + .../skills/axiomcode/scripts/axiomcode-impact | 138 +++++++++++++++--- .../skills/axiomcode/scripts/axiomcode-path | 37 +++-- .../csharp/entry-worded-by-reason/case.json | 16 ++ .../src/ItemsController.cs | 11 ++ .../entry-worded-by-reason/src/Shop.csproj | 3 + .../src/ShopDbContext.cs | 11 ++ .../unmodelled-entry-not-local/case.json | 2 +- .../java/entry-worded-by-reason/case.json | 16 ++ .../src/com/example/Initializer.java | 5 + .../src/com/example/Mid.java | 2 + .../src/com/example/Store.java | 16 ++ .../src/com/example/StoreApi.java | 12 ++ .../src/com/example/Top.java | 2 + .../src/com/example/Wire.java | 5 + .../.github/workflows/ci.yml | 5 + .../common-word-text-is-prose/case.json | 26 ++++ .../common-word-text-is-prose/config.yml | 2 + .../python/common-word-text-is-prose/notes.py | 19 +++ .../common-word-text-is-prose/routes.cfg | 2 + .../python/common-word-text-is-prose/rules.dl | 2 + .../python/common-word-text-is-prose/run.sh | 3 + .../templates/page.html | 4 + .../python/common-word-text-is-prose/wrapper | 2 + .../one-row-per-dependent/a/__init__.py | 0 .../python/one-row-per-dependent/a/views.py | 5 + .../one-row-per-dependent/b/__init__.py | 0 .../python/one-row-per-dependent/b/views.py | 5 + .../python/one-row-per-dependent/case.json | 18 +++ .../one-row-per-dependent/formatters.py | 22 +++ tests/cases/python/shebang-script/case.json | 6 +- tests/enrich_lines.py | 8 +- 36 files changed, 477 insertions(+), 44 deletions(-) create mode 100644 tests/cases/csharp/entry-worded-by-reason/case.json create mode 100644 tests/cases/csharp/entry-worded-by-reason/src/ItemsController.cs create mode 100644 tests/cases/csharp/entry-worded-by-reason/src/Shop.csproj create mode 100644 tests/cases/csharp/entry-worded-by-reason/src/ShopDbContext.cs create mode 100644 tests/cases/java/entry-worded-by-reason/case.json create mode 100644 tests/cases/java/entry-worded-by-reason/src/com/example/Initializer.java create mode 100644 tests/cases/java/entry-worded-by-reason/src/com/example/Mid.java create mode 100644 tests/cases/java/entry-worded-by-reason/src/com/example/Store.java create mode 100644 tests/cases/java/entry-worded-by-reason/src/com/example/StoreApi.java create mode 100644 tests/cases/java/entry-worded-by-reason/src/com/example/Top.java create mode 100644 tests/cases/java/entry-worded-by-reason/src/com/example/Wire.java create mode 100644 tests/cases/python/common-word-text-is-prose/.github/workflows/ci.yml create mode 100644 tests/cases/python/common-word-text-is-prose/case.json create mode 100644 tests/cases/python/common-word-text-is-prose/config.yml create mode 100644 tests/cases/python/common-word-text-is-prose/notes.py create mode 100644 tests/cases/python/common-word-text-is-prose/routes.cfg create mode 100644 tests/cases/python/common-word-text-is-prose/rules.dl create mode 100644 tests/cases/python/common-word-text-is-prose/run.sh create mode 100644 tests/cases/python/common-word-text-is-prose/templates/page.html create mode 100644 tests/cases/python/common-word-text-is-prose/wrapper create mode 100644 tests/cases/python/one-row-per-dependent/a/__init__.py create mode 100644 tests/cases/python/one-row-per-dependent/a/views.py create mode 100644 tests/cases/python/one-row-per-dependent/b/__init__.py create mode 100644 tests/cases/python/one-row-per-dependent/b/views.py create mode 100644 tests/cases/python/one-row-per-dependent/case.json create mode 100644 tests/cases/python/one-row-per-dependent/formatters.py diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index b0c747d14..6d1dbc198 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -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]) @@ -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 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index a5fd5008e..17323fd82 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -168,10 +168,10 @@ 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', } @@ -179,6 +179,47 @@ 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 ()", 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 () 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 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py index 8e6bffa5a..aef1c5b37 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py @@ -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 `validate` 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) @@ -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 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py index 43a10398b..a0d932d02 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py @@ -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) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 9b380c3bf..e090f3d1d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -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: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index d1537aad9..406b58015 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -114,6 +114,19 @@ def ckey(k): # framework's own registration, so it outranks every name match, and it is not a call to the declaration. CERT = {'resolved': 0, 'one of a set': 1, 'registered': 2, 'capped set': 3, 'remote': 4, 'framework': 5, 'in scope': 6, 'by name': 7, 'text': 8, 'alongside': 9} +def also_text(whys, shown=2): + """the other reasons a row's callable has, said on the same line: `; also: ` (one row per dependent)""" + if not whys: return '' + return '; also: ' + '; '.join(whys[:shown]) + (f" (+{len(whys) - shown} more)" if len(whys) > shown else '') + + +def merge_reasons(why, others): + """-> (why, others left): reasons that differ from the row's only in their last word -- `using the same field a`, + `... field b` -- are said once, `using the same field a, b`, rather than as a sentence each""" + head = why.rsplit(' ', 1)[0] + ' ' if ' ' in why else None + same = [o for o in others if head and o.startswith(head) and ' ' not in o[len(head):]] + if not same: return why, others + return why + ', ' + ', '.join(o[len(head):] for o in same), [o for o in others if o not in same] LOCAL_KINDS = {'LOCAL_VARIABLE', 'PARAMETER', 'LAMBDA_PARAMETER', 'VARIABLE', 'PARAM'} # a ref the parser says is a local, not a member QUALIFIED_KINDS = {'FIELD_ACCESS', 'PROPERTY_ACCESS', 'ATTRIBUTE_ACCESS'} MEMBER_KINDS = {'FIELD', 'PROPERTY', 'ATTRIBUTE', 'METHOD', 'FUNCTION'} # written `x.name`, against a receiver; the rest are bare names @@ -683,7 +696,7 @@ class Impact: return [ln for ln in range(s['line'], min(s['end_line'], len(L)) + 1) if pat.search(L[ln - 1])] # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '31' # 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '32' # 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -1646,7 +1659,20 @@ def main(argv): for c, why, q_ in res['contract']: contract_of[(c, why)].add(LAB[q_]) # sorted once, here: the JSON and the printed answer both read this list, and a relation has no inherent # order — leaving it as the solver emitted it made the same answer come out shuffled between engines. - contract = sorted((k for k in contract_of if k[0] in g.sym), key=lambda cw: (g.disp(cw[0]), g.loc(cw[0]), cw[1])) + # ONE ROW PER DECLARATION. The relation is keyed on (declaration, reason), so an override that is also matched by + # name printed twice, once as "overrides it" and once as "name match", and the header counted it as both. The + # surest reason names the row (a contract the engine resolved before a name match) and the others follow it as + # `also:`; the targets it is bound for are the union of both. + _cwhy = collections.defaultdict(set) + for (c, why) in contract_of: + if c in g.sym: _cwhy[c].add(why) + contract_also, contract_for = {}, {} + contract = [] + for c, whys in _cwhy.items(): + ws = sorted(whys, key=lambda w: ('name match' in w, w)) + contract.append((c, ws[0])); contract_also[c] = ws[1:] + contract_for[c] = set().union(*(contract_of[(c, w)] for w in ws)) + contract.sort(key=lambda cw: (g.disp(cw[0]), g.loc(cw[0]), cw[1])) seeds = {x[0] for x in res['seed']} # `direct` is a SET of (q, c, role, why, cert, f, l) tuples, so two calls to the target on ONE LINE derive one # row and the counter below saw one site: `shape.getCenter().getX() + "," + shape.getCenter().getY()` read as @@ -1696,7 +1722,24 @@ def main(argv): best[k] = (best[k][0], best[k][1], _lower(best[k][2], loc), best[k][3] + ns) # same certainty: the lowest line else: best[k] = (best[k][0], best[k][1], best[k][2], best[k][3] + ns) # a worse one: counted, not located - D = [(c, role, why, cert, loc, n) for (c, why), (role, cert, loc, n) in best.items()] + # ONE ROW PER DEPENDENT IN EACH SECTION. `best` is keyed on (callable, reason) so that each reason keeps its own + # site count and first line, but a callable with two reasons -- a resolved caller that is also a sibling on the + # same field, a framework hop that is also a by-name match, a writer that writes it twice over -- was printed once + # per reason, and the section header counted it once per rung, so the rungs added up to more than the callables + # (#1423). The surest reason is the row; the others are said on it as `also:`. A callable that WRITES and READS a + # field stays in both sections: those are two different answers. + _grp = lambda role: 'w' if role in ('produces', 'writes') else 'r' + by_c = collections.defaultdict(list) + for (c, why), (role, cert, loc, n) in best.items(): by_c[(c, _grp(role))].append((CERT[cert], why, role, cert, loc, n)) + D = []; direct_also = {}; direct_for = {}; direct_reasons = {} + for (c, gk), rs in by_c.items(): + rs.sort() + _r, why, role, cert, loc, n = rs[0] + D.append((c, role, why, cert, loc, n)) + direct_also[(c, gk)] = [w for _x, w, *_ in rs[1:] if w != why] + direct_for[(c, gk)] = set().union(*(from_target[(c, w)] for _x, w, *_ in rs)) + # --json keeps every reason with its own certainty, first line and site count: nothing the old rows said is lost + direct_reasons[(c, gk)] = [{'why': w, 'certainty': ct, 'at': lc, 'sites': nn} for _x, w, _ro, ct, lc, nn in rs] # `alongside` is unbounded by nature — a 200-member type would bury the rest of the answer. Keep the ones that share a # field with the target first, then the nearest by file, and say how many were left out (#762). ALONG_CAP = int(os.environ.get('AXIOMCODE_ALONGSIDE_CAP', '0')) # 0 = keep them all: the PRINTED answer is already bounded by --limit @@ -1882,18 +1925,19 @@ def main(argv): or cert in ('registered', 'capped set'): hops += 1 if not any((c, t) in real for t in asserted.get(c, ())): bad += 1 + prose_hits = getattr(getattr(I, '_ns', None), 'prose', set()) if as_json: print(json.dumps({'targets': [{'kind': k, 'label': lab} for k, lab, _ in targets], - 'contract': [{'id': c, 'display': g.disp(c), 'why': why, 'at': g.loc(c), 'for': sorted(contract_of[(c, why)])} for c, why in contract], + 'contract': [{'id': c, 'display': g.disp(c), 'why': why, 'also': contract_also.get(c, []), 'at': g.loc(c), 'for': sorted(contract_for[c])} for c, why in contract], # the sort key ends in x[0], the id: two distinct anon-class methods can share a # display AND every other component of the key, and without the id the order is # whatever the solver happened to emit — which is not the same between backends. - 'direct': [{'id': c, 'display': g.disp(c), 'role': role, 'why': why, 'certainty': cert, 'at': loc, 'sites': n, 'for': sorted(from_target[(c, why)])} for c, role, why, cert, loc, n in sorted(D, key=lambda x: (CERT[x[3]], x[1], g.disp(x[0]), x[2], x[4], x[0]))], + 'direct': [{'id': c, 'display': g.disp(c), 'role': role, 'why': why, 'also': direct_also.get((c, _grp(role)), []), 'reasons': direct_reasons.get((c, _grp(role)), []), 'certainty': cert, 'at': loc, 'sites': n, 'for': sorted(direct_for[(c, _grp(role))])} for c, role, why, cert, loc, n in sorted(D, key=lambda x: (CERT[x[3]], x[1], g.disp(x[0]), x[2], x[4], x[0]))], 'reached': [{'id': m, 'display': g.disp(m), 'hops': d, 'for': sorted(reach_from[m]), 'at': g.loc(m), 'test': bool(g.sym[m]['is_test'])} for m, d in sorted(reached.items(), key=lambda x: (x[1], g.disp(x[0]), g.loc(x[0]), x[0]))], 'tests': [{'id': m, 'display': g.disp(m), 'owner': g.sym[m]['owner'], 'name': g.sym[m]['name'], 'hops': d, 'via': g.disp(fx) if fx else None, 'at': g.loc(m), 'certainty': test_cert.get(m), 'chain': [g.disp(x) for x in chains.get(m, [])]} for m, (d, fx) in sorted(tests.items(), key=lambda kv: (kv[1][0], g.disp(kv[0]), g.loc(kv[0]), kv[0]))], 'inherited_tests': [{'id': s_, 'display': g.disp(s_), 'at': g.loc(s_), 'inherits': sorted(g.disp(m) for m in ms)} for s_, ms in sorted(inh.items(), key=lambda kv: (g.disp(kv[0]), g.loc(kv[0])))], 'byname_callers': [{'id': c, 'display': g.disp(c), 'at': g.loc(c)} for c in sorted(byname_seeds, key=by_place)], - 'external': [{'at': f'{f}:{l}', 'name': n, 'how': how} for f, l, n, how in sorted({(r[0], int(r[1]), r[2], r[3]) for r in res.get('extbind', [])})], + 'external': [{'at': f'{f}:{l}', 'name': n, 'how': how, **({'prose': True} if (n, f, l) in prose_hits else {})} for f, l, n, how in sorted({(r[0], int(r[1]), r[2], r[3]) for r in res.get('extbind', [])})], 'test_universe': universe, 'verified': bad == 0, 'checked_hops': hops, 'unresolved_inside': u}, indent=1)) return 0 for k, lab, _ in targets: print(f"change: {lab} [{k}]") @@ -1910,10 +1954,10 @@ def main(argv): break if contract: nm = sum(1 for _, why in contract if 'name match' in why) - print(f"must change with it ({len(contract)}: " + (f"{len(contract) - nm} bound by a contract the engine resolved, {nm} matched by name" if nm else "bound by a contract the engine resolved") + "):") + print(f"must change with it ({len(contract)}: " + ("matched by name" if nm == len(contract) else f"{len(contract) - nm} bound by a contract the engine resolved, {nm} matched by name" if nm else "bound by a contract the engine resolved") + "):") # printed in a fixed order — by display, then reason. The rows are a set; leaving them in the order the # engine emitted them makes the same answer come out shuffled from one solver to another. - for c, why in contract[:limit]: print(f" {g.disp(c)} {g.loc(c)} — {why}" + (f" [for {', '.join(sorted(contract_of[(c, why)]))}]" if multi else '')) + for c, why in contract[:limit]: print(f" {g.disp(c)} {g.loc(c)} — {why}" + also_text(contract_also.get(c)) + (f" [for {', '.join(sorted(contract_for[c]))}]" if multi else '')) if len(contract) > limit: print(f" … +{len(contract) - limit} (--limit N)") # the window a reader actually sees must not be decided by ASCII: ``, ``, `` sort before # every letter, and in a callback-heavy language they are most of the list, so the file that breaks is pushed into @@ -1941,13 +1985,34 @@ def main(argv): ep_by_reason = collections.defaultdict(list) for m, why in ep_rows: if not _is_t(m): ep_by_reason[why].append(m) + # WORDED FROM THE REASON (ax_edges.ENTRY). Only a surface the outside world calls (a route, a process start, a remote + # client, a message) is one whose change "changes what the outside world can call"; a hook, a lifecycle method or a + # constructor the container runs is called back by the framework, and a change to it breaks that contract instead. + # And where a row above SENDS the request or event this handles, the handler is not unreached: the framework runs + # it for what that row sends, so "no client call site reaches it" would contradict the row. + _ev_why = ax_edges.TIER_WHY.get('event_dispatch') + _senders = {c for c, role, why, cert, loc, n in D if why == _ev_why} + sent_to = lambda m: bool(_senders) and (len(seeds) == 1 or any(m in asserted.get(c, ()) for c in _senders)) for why in sorted(ep_by_reason): ms = sorted(ep_by_reason[why], key=g.disp) - if len(ms) == 1: - print(f"entry point: {g.disp(ms[0])} is a {why} entry point — a framework invokes it, so no client call site reaches it. Changing it changes what the outside world can call.") + tail = f" … +{len(ms) - limit} (--limit N)" if len(ms) > limit else '' + if any(sent_to(m) for m in ms): + one = len(ms) == 1 + print(f"entry point: {g.disp(ms[0]) + ' is' if one else f'{len(ms)} entry points, each'} {ax_edges.entry_phrase(why)} — a framework runs " + f"{'it' if one else 'them'} for the request or event the sender row(s) above send, so no call site names {'it' if one else 'them'}" + + ('' if one else ': ' + ', '.join(g.disp(m) for m in ms[:limit]) + tail)) + elif ax_edges.entry_outside(why): + if len(ms) == 1: + print(f"entry point: {g.disp(ms[0])} is a {why} entry point — a framework invokes it, so no client call site reaches it. Changing it changes what the outside world can call.") + else: + print(f"entry point: {len(ms)} {why} entry points — a framework invokes them, so no client call site reaches them. Changing one changes what the outside world can call: " + + ', '.join(g.disp(m) for m in ms[:limit]) + tail) + elif len(ms) == 1: + print(f"entry point: {g.disp(ms[0])} is {ax_edges.entry_phrase(why)} — the framework calls it back, so no client call site reaches it; " + "a change breaks that framework contract, not an outside caller.") else: - print(f"entry point: {len(ms)} {why} entry points — a framework invokes them, so no client call site reaches them. Changing one changes what the outside world can call: " - + ', '.join(g.disp(m) for m in ms[:limit]) + (f" … +{len(ms) - limit} (--limit N)" if len(ms) > limit else '')) + print(f"entry point: {len(ms)} entry points, each {ax_edges.entry_phrase(why)} — the framework calls them back, so no client call site reaches them; " + "a change to one breaks that framework contract, not an outside caller: " + ', '.join(g.disp(m) for m in ms[:limit]) + tail) n_ep_test = len({m for m, _ in ep_rows if _is_t(m)}) if n_ep_test: print(f"entry point: {n_ep_test} test entry point(s) — a runner invokes them, so no client call site reaches them either; they are not a surface the outside world calls. The tests section below is what names them.") @@ -2059,7 +2124,9 @@ def main(argv): f = (r[4] or '').rsplit(':', 1)[0] if len(shown) < limit and per[f] < cap: per[f] += 1; shown.append(r) else: hidden.append(r) - for c, role, why, cert, loc, n in shown: print(f" [{cert}] {g.disp(c)} {loc} — {why}" + (f" ({n} sites)" if n > 1 else '') + (f" [for {', '.join(sorted(from_target[(c, why)]))}]" if multi else '')) + for c, role, why, cert, loc, n in shown: + why, oth = merge_reasons(why, direct_also.get((c, _grp(role))) or []) + print(f" [{cert}] {g.disp(c)} {loc} — {why}" + (f" ({n} sites)" if n > 1 else '') + also_text(oth) + (f" [for {', '.join(sorted(direct_for[(c, _grp(role))]))}]" if multi else '')) if hidden: byf = collections.Counter((r[4] or '').rsplit(':', 1)[0] for r in hidden) print(f" … +{len(hidden)} more in {len(byf)} file(s): " + ', '.join(f"{f} ({n})" for f, n in byf.most_common(6)) + (' …' if len(byf) > 6 else '') + " (--limit N for all of them)") @@ -2078,6 +2145,12 @@ def main(argv): if any(k == 'var' for k, _, _ in targets) and not reached: print("reaches those through resolved calls: nothing — a local lives inside its method; it escapes only through what the method returns or writes, and the callers of the method are its impact only if it does") ext = sorted({(r[0], int(r[1]), r[2], r[3]) for r in res.get('extbind', [])}, key=lambda x: (x[0], x[1])) + # A COMMON WORD WRITTEN AS A WORD IS NOT A BINDING (ax_nonsource.py, PROSE): `note` in a template's text, `build` + # in a CI file, a Maven `validate`. Those rows leave `ext` here, so nothing below -- the section, + # its header count, `next:`, the --delete verdict -- reads them as a place a rename breaks; they are counted with + # the search that lists them, printed each under --page all / --limit N, and kept in --json with "prose": true. + ext_prose = [x for x in ext if (x[2], x[0], x[1]) in prose_hits] + ext = [x for x in ext if (x[2], x[0], x[1]) not in prose_hits] print(f"reaches those through resolved calls: {len(reached)} more callable(s) in {nfiles} file(s)" + (f" within {depth} hop(s)" if depth < 40 else '') + (f" under *{IN}*" if IN else '')) weak = [m for m in reached if m not in sure_set] if weak: print(f" {len(weak)} of those {len(reached)} are reached ONLY through a dependent that is not an exact edge ([by name], [text], [one of a set]): they stand or fall with it") @@ -2115,13 +2188,25 @@ def main(argv): for m, d in ent[:limit]: decs = [r[0].split('.')[-1] for r in g.q("SELECT name FROM decorations WHERE owner_id = ?", m)] if g.has('decorations') else [] print(f" {d:2} hop(s) {g.disp(m)}{' [test]' if g.sym[m]['is_test'] else ''}{(' @' + ','.join(decs[:3])) if decs else ''} {g.loc(m)}") + # the entry points' own overflow line, under their list: it sat inside the next section and printed only when + # that section did + if len(ent) > limit: print(f" … +{len(ent) - limit} (--limit N)") if ext: byfile = collections.Counter(f for f, *_ in ext) print(f"bound from outside the source ({len(ext)} site(s) in {len(byfile)} file(s): a name matched in a file the index does" " not read as source, so the compiler is silent — nothing here fails a build; a rename or a retype breaks it at RUN time):") for f, l, n, how in ext[:limit]: print(f" [text] {f}:{l} — {how} ({n})") if len(ext) > limit: print(f" … +{len(ext) - limit} (--limit N)") - if len(ent) > limit: print(f" … +{len(ent) - limit} (--limit N)") + if ext_prose: + byname_p = collections.Counter(n for _f, _l, n, _h in ext_prose) + nfp = len({f for f, *_ in ext_prose}) + print(f"{'also ' if ext else ''}written as a plain word outside the source: {len(ext_prose)} line(s) in {nfp} file(s) name " + + ', '.join(f'"{n}"' for n in sorted(byname_p)) + " with no call, quote or qualifier around it (template text, CI and" + " build files, comments) — not counted as a binding; " + ' · '.join(f'grep -rnw "{n}" .' for n in sorted(byname_p)[:2]) + + (" lists them" if not list_all else '')) + if list_all: + for f, l, n, how in ext_prose[:limit]: print(f" [text, prose] {f}:{l} — {how} ({n})") + if len(ext_prose) > limit: print(f" … +{len(ext_prose) - limit} (--limit N)") if inh: print(f" inherited by {len(inh)} test class(es) that extend a reached one — they run those tests too: " + ', '.join(f"{g.disp(s_)} ({len(ms)}) {g.loc(s_)}" for s_, ms in sorted(inh.items(), key=lambda x: (-len(x[1]), g.disp(x[0])))[:6])) tfiles = {g.sym[m]['file'] for m in tests} | {g.sym[s_]['file'] for s_ in inh if s_ in g.sym} all_tfiles = {r[0] for r in g.q("SELECT DISTINCT file FROM symbols WHERE is_test = 1 AND file IS NOT NULL")} @@ -2129,6 +2214,16 @@ def main(argv): weak_tests = [m for m in tests if m not in sure_set] sys.stdout = _stdout print(f"tests: {len(tests)} of {universe} test method(s) reach the change" + (" (their own body, or a fixture that runs before them: constructor, static initializer, @Before*/setUp)" if tests else '')) + # THE TESTS LINE AGREES WITH THE ROWS ABOVE IT. A callable in test code that this answer LISTS as reaching the change + # (a [test] entry point, a test row) but that the count does not credit -- a runner entry this tool does not + # collect (a custom test attribute, a script's main), a remote hop the test walk does not take -- made "tests: 0" + # sit under a [test] row. They are named here, and `next:` counts them as tests to run. + test_rows = sorted(({m for m, _ in ent if _is_t(m)} | {c for c, role, why, cert, loc, n in D if _is_t(c) and cert != 'alongside'}) - set(tests), + key=lambda m: (g.disp(m), g.loc(m))) + if test_rows: + print(f" {'and ' if tests else 'but '}{len(test_rows)} callable(s) in test code listed above reach it by a route this count does not credit " + "(a runner entry it does not collect, a remote or framework hop) — treat them as tests to run: " + + ', '.join(f"{g.disp(m)} {g.loc(m)}" for m in test_rows[:4]) + (f" … +{len(test_rows) - 4}" if len(test_rows) > 4 else '')) if not tests and unmod: print(f" NOT CHECKED: {unmod_sig} — a framework calls it and the graph does not model how, so a test that enters " f"through that framework (a request, a started context, an event) reaches it with no route counted here: {unmod_grep}") @@ -2338,7 +2433,9 @@ def main(argv): # AN ENTRY POINT IS CALLED BY A FRAMEWORK, AND SO IS AN UNMODELLED ONE (#1446). The entry-point line above said # a framework invokes it, and this verdict then printed the one for a class nothing registers, word for word. fw_why = []; epw = sorted(ep_by_reason) - if epw: fw_why.append(f"it is {'an' if epw[0][:1] in 'aeiou' else 'a'} {'/'.join(epw[:3])} entry point (above) — a framework calls it, so no call site in the graph is expected") + if epw and not any(ax_edges.entry_outside(w) for w in epw): + fw_why.append(f"it is {' / '.join(ax_edges.entry_phrase(w) for w in epw[:3])} (above) — the framework calls it back, so no call site in the graph is expected") + elif epw: fw_why.append(f"it is {'an' if epw[0][:1] in 'aeiou' else 'a'} {'/'.join(epw[:3])} entry point (above) — a framework calls it, so no call site in the graph is expected") if unmod: fw_why.append(f"NOT CHECKED: {unmod_sig} — a framework may call it and the graph does not model how; before deleting run: {unmod_grep}") if why: print(" NOT SAFE — " + '; '.join(why) + ". They are listed above.") elif fw_why: print(" NOT SAFE TO ASSUME — no dependent in this graph, but " + '; '.join(fw_why) + ". Check what else the graph cannot see:") @@ -2411,8 +2508,8 @@ def main(argv): # a label the answer USED must be explained here; a label it did not use must not be, or the note # describes evidence the reader cannot find above it. `registered` is new and is the one most # likely to be misread, because on JavaScript it is where a third of the routes go. - REGISTERED_NOTE = ("[registered] is a callable handed over as a value — the engine recorded the hand-off, " - "not a call to it, and cannot say the holder ever runs it") + REGISTERED_NOTE = ("[registered] is a callable handed over as a value, or a handler a framework runs for a request or event " + "sent to it — the engine recorded the hand-off or the send, not a call to it, and cannot say it ever runs") used_registered = 'registered' in set(test_cert.values()) if byname: legend = "[one of a set] is one member of a target set the engine could not narrow (its own oracle: right 126 of 351 times, against 528 of 585 for a resolved edge), [by name] / [text] may be a same-named other thing" @@ -2471,7 +2568,7 @@ def main(argv): ep_why = sorted(ep_by_reason) weak_locs = list(dict.fromkeys(loc for c, role, why, cert, loc, n in sorted(D, key=lambda x: (CERT.get(x[3], 99), x[4] or '')) if cert not in STRONG_DIRECT and cert != 'alongside' and loc and loc not in here)) - nt = len(tests) + nt = len(tests) + len(test_rows) tpart = (f"; {nt} test(s) reach it — run them after the edit (`test-impact`)" if nt else '') if places: places = list(dict.fromkeys(places + [x for x in ext_locs if x not in here])) if not places and ext_locs: @@ -2482,6 +2579,11 @@ def main(argv): print(f"next: {nobody}, but it is not local — {', '.join(foreign[:3])} calls it from outside the graph: " f"read {', '.join(here) or 'the declaration'}; a body change is safe there, a change to its name or signature " f"stops that caller reaching it{lead}") + elif not places and ep_why and not any(ax_edges.entry_outside(w) for w in ep_why): + # a framework callback (ax_edges.ENTRY): no route and no outside caller to break, only the framework's contract + print(f"next: nothing in this graph calls it, but it is not local — it is {' / '.join(ax_edges.entry_phrase(w) for w in ep_why[:3])}, so the " + f"framework calls it back: read {', '.join(here) or 'the declaration'}; a body change is safe there, a change to its name, parameters " + f"or result breaks that framework contract{tpart}{lead}") elif not places and ep_why: print(f"next: nothing in this graph calls it, but it is not local — it is {'an' if ep_why[0][:1] in 'aeiou' else 'a'} {'/'.join(ep_why[:3])} entry point, so its " f"callers are outside the graph (the framework, a request, a client, a runner, a scheduler): read {', '.join(here) or 'the declaration'}; a body change " diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 5d0277dad..0b43bbe32 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -941,24 +941,33 @@ def closure(g, sel, upstream, limit=40, depth=40): # turned 3 exact callers into 342 methods. Both numbers are true -- the first is what must run it, the second what may -- # and a count that does not say which it is reads as the first. Walk the same direction again over exact calls only. if rows: - exact_adj = collections.defaultdict(set) - for a, b, t in g.edges(): - if t != 'dispatch' and ax_edges.direct_cert(t) == 'resolved': (exact_adj[b].add(a) if upstream else exact_adj[a].add(b)) - ex, fr = set(ids), list(ids) - for _ in range(depth): - nxt = [] - for x in fr: - for y in exact_adj.get(x, ()): - if y not in ex: ex.add(y); nxt.append(y) - fr = nxt - if not fr: break + # A REQUEST OR EVENT SENT TO ITS HANDLER (`event_dispatch`) is not a dispatch choice: the framework runs the + # handler for what is sent, so what reaches the sender reaches the handler. It is walked as its own layer on top + # of the exact calls and counted apart, rather than filed with the interface fan-out as "MAY run it". + def _walk(ok): + adj_ = collections.defaultdict(set) + for a, b, t in g.edges(): + if ok(t): (adj_[b].add(a) if upstream else adj_[a].add(b)) + ex_, fr_ = set(ids), list(ids) + for _ in range(depth): + nxt = [] + for x in fr_: + for y in adj_.get(x, ()): + if y not in ex_: ex_.add(y); nxt.append(y) + fr_ = nxt + if not fr_: break + return ex_ + _exact_t = lambda t: t != 'dispatch' and ax_edges.direct_cert(t) == 'resolved' + ex = _walk(_exact_t) in_rows = {m for m, _ in rows} exact = (ex - set(ids)) & in_rows + sent = ((_walk(lambda t: _exact_t(t) or t in ax_edges.TIER_WHY) - set(ids)) & in_rows) - exact prod = lambda ms: sum(1 for m in ms if not g.sym[m]['is_test']) n_prod, n_test = prod(in_rows), len(in_rows) - prod(in_rows) e_prod, e_test = prod(exact), len(exact) - prod(exact) - print(f" {n_prod} production, {n_test} test. Through exact calls only: {e_prod} production, {e_test} test; the other " - f"{len(in_rows) - len(exact)} only through a dispatch choice (an interface or override with several implementations, " + print(f" {n_prod} production, {n_test} test. Through exact calls only: {e_prod} production, {e_test} test" + + (f"; {len(sent)} more through a request or event sent to a handler, which the framework runs for what is sent" if sent else '') + + f"; the other {len(in_rows) - len(exact) - len(sent)} only through a dispatch choice (an interface or override with several implementations, " "a call with several candidates) — they MAY run it, not must") if not upstream: print_boundary(g, ids, "it calls into libraries directly", "it also makes") @@ -1095,7 +1104,7 @@ def entered_in_process(g, ids): part = keys[k:k + 900]; q = ','.join('?' * len(part)) if g.has('entry_points'): for r in g.q(f"SELECT method_id, reason FROM entry_points WHERE method_id IN ({q})", *part): - if r['reason'] not in NOT_FROM_A_LIBRARY_CALL: out.setdefault(mid[r['method_id']], f"{r['reason']} entry point") + if r['reason'] not in NOT_FROM_A_LIBRARY_CALL: out.setdefault(mid[r['method_id']], ax_edges.entry_phrase(r['reason'])) if g.has('overrides'): for r in g.q(f"""SELECT o.overriding_method_id m, b.qualified_name b FROM overrides o JOIN methods b ON b.id = o.method_id WHERE o.overriding_method_id IN ({q}) AND b.provenance <> 'client'""", *part): diff --git a/tests/cases/csharp/entry-worded-by-reason/case.json b/tests/cases/csharp/entry-worded-by-reason/case.json new file mode 100644 index 000000000..0fa8bf9f0 --- /dev/null +++ b/tests/cases/csharp/entry-worded-by-reason/case.json @@ -0,0 +1,16 @@ +{ + "lang": "csharp", + "checks": [ + { + "why": "a model-building callback the ORM runs is worded from its reason: no 'a orm_hook entry point', no 'what the outside world can call', and next: names no route to break", + "run": ["impact", "ShopDbContext.OnModelCreating"], + "want": ["is a model hook the ORM or validation library runs — the framework calls it back", "so the framework calls it back: read"], + "avoid": ["orm_hook entry point", "outside world can call", "a change to its name, route,"] + }, + { + "why": "control: an HTTP action is a surface the outside world calls and keeps today's sentence", + "run": ["impact", "ItemsController.Get"], + "want": ["entry point — a framework invokes it, so no client call site reaches it. Changing it changes what the outside world can call.", "entry point, so its callers are outside the graph"] + } + ] +} diff --git a/tests/cases/csharp/entry-worded-by-reason/src/ItemsController.cs b/tests/cases/csharp/entry-worded-by-reason/src/ItemsController.cs new file mode 100644 index 000000000..b8e0a4128 --- /dev/null +++ b/tests/cases/csharp/entry-worded-by-reason/src/ItemsController.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Shop; + +[ApiController] +[Route("items")] +public class ItemsController : ControllerBase +{ + [HttpGet] + public string Get() => "x"; +} diff --git a/tests/cases/csharp/entry-worded-by-reason/src/Shop.csproj b/tests/cases/csharp/entry-worded-by-reason/src/Shop.csproj new file mode 100644 index 000000000..ef8279905 --- /dev/null +++ b/tests/cases/csharp/entry-worded-by-reason/src/Shop.csproj @@ -0,0 +1,3 @@ + + net8.0 + diff --git a/tests/cases/csharp/entry-worded-by-reason/src/ShopDbContext.cs b/tests/cases/csharp/entry-worded-by-reason/src/ShopDbContext.cs new file mode 100644 index 000000000..2c0920cd2 --- /dev/null +++ b/tests/cases/csharp/entry-worded-by-reason/src/ShopDbContext.cs @@ -0,0 +1,11 @@ +using Microsoft.EntityFrameworkCore; + +namespace Shop; + +public class ShopDbContext : DbContext +{ + protected override void OnModelCreating(ModelBuilder builder) + { + builder.HasDefaultSchema("shop"); + } +} diff --git a/tests/cases/csharp/unmodelled-entry-not-local/case.json b/tests/cases/csharp/unmodelled-entry-not-local/case.json index 20324c61d..510128444 100644 --- a/tests/cases/csharp/unmodelled-entry-not-local/case.json +++ b/tests/cases/csharp/unmodelled-entry-not-local/case.json @@ -4,7 +4,7 @@ { "why": "--delete on a hosted service says a framework calls it, not the dead-code verdict (#1446)", "run": ["impact", "SweepWorker", "--kind", "type", "--delete"], - "want": ["NOT SAFE TO ASSUME", "lifecycle entry point (above) — a framework calls it"], + "want": ["NOT SAFE TO ASSUME", "a lifecycle callback (above) — the framework calls it back"], "avoid": ["· nothing", "no dependent at any certainty in this graph. Before deleting"] }, { diff --git a/tests/cases/java/entry-worded-by-reason/case.json b/tests/cases/java/entry-worded-by-reason/case.json new file mode 100644 index 000000000..14ced020d --- /dev/null +++ b/tests/cases/java/entry-worded-by-reason/case.json @@ -0,0 +1,16 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "path '*' next: names the one caller once, though it is listed both as the nearest caller and as an entry point (#1389)", + "run": ["path", "*", "Wire.initContext"], + "want": ["the nearest caller is at 1 hop(s): Initializer.initialize", "read it;"], + "avoid": ["callers are", "Initializer.java:4, Initializer.initialize"]}, + {"why": "control: a caller that is not an entry point is named once as before", + "run": ["path", "*", "Wire.other"], + "want": ["the nearest caller is at 1 hop(s): Mid.x"]}, + {"why": "a constructor the container runs is a framework callback: worded from its reason, never the bare token, and never 'what the outside world can call'", + "run": ["impact", "Store.Store"], + "want": ["is a constructor the container runs to build a bean — the framework calls it back", "breaks that framework contract"], + "avoid": ["bean_ctor entry point", "outside world can call", "a change to its name, route,"]}, + {"why": "control: an HTTP route handler is a surface the outside world calls, and keeps today's sentence", + "run": ["impact", "StoreApi.items"], + "want": ["is a http entry point — a framework invokes it, so no client call site reaches it. Changing it changes what the outside world can call.", "entry point, so its callers are outside the graph"]}]} diff --git a/tests/cases/java/entry-worded-by-reason/src/com/example/Initializer.java b/tests/cases/java/entry-worded-by-reason/src/com/example/Initializer.java new file mode 100644 index 000000000..489a88981 --- /dev/null +++ b/tests/cases/java/entry-worded-by-reason/src/com/example/Initializer.java @@ -0,0 +1,5 @@ +package com.example; +public class Initializer { + private final Wire w = new Wire(); + public void initialize() { w.initContext(); } +} diff --git a/tests/cases/java/entry-worded-by-reason/src/com/example/Mid.java b/tests/cases/java/entry-worded-by-reason/src/com/example/Mid.java new file mode 100644 index 000000000..f3587209d --- /dev/null +++ b/tests/cases/java/entry-worded-by-reason/src/com/example/Mid.java @@ -0,0 +1,2 @@ +package com.example; +public class Mid { private final Wire w = new Wire(); public void x() { w.other(); } } diff --git a/tests/cases/java/entry-worded-by-reason/src/com/example/Store.java b/tests/cases/java/entry-worded-by-reason/src/com/example/Store.java new file mode 100644 index 000000000..4d6ba882c --- /dev/null +++ b/tests/cases/java/entry-worded-by-reason/src/com/example/Store.java @@ -0,0 +1,16 @@ +package com.example; + +import org.springframework.stereotype.Component; + +@Component +public class Store { + private final Wire wire; + + public Store(Wire wire) { + this.wire = wire; + } + + public String find(String id) { + return id; + } +} diff --git a/tests/cases/java/entry-worded-by-reason/src/com/example/StoreApi.java b/tests/cases/java/entry-worded-by-reason/src/com/example/StoreApi.java new file mode 100644 index 000000000..efdf6e467 --- /dev/null +++ b/tests/cases/java/entry-worded-by-reason/src/com/example/StoreApi.java @@ -0,0 +1,12 @@ +package com.example; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class StoreApi { + @GetMapping("/items") + public String items() { + return "x"; + } +} diff --git a/tests/cases/java/entry-worded-by-reason/src/com/example/Top.java b/tests/cases/java/entry-worded-by-reason/src/com/example/Top.java new file mode 100644 index 000000000..63d16f77c --- /dev/null +++ b/tests/cases/java/entry-worded-by-reason/src/com/example/Top.java @@ -0,0 +1,2 @@ +package com.example; +public class Top { private final Mid m = new Mid(); public void y() { m.x(); } } diff --git a/tests/cases/java/entry-worded-by-reason/src/com/example/Wire.java b/tests/cases/java/entry-worded-by-reason/src/com/example/Wire.java new file mode 100644 index 000000000..ac78bdc56 --- /dev/null +++ b/tests/cases/java/entry-worded-by-reason/src/com/example/Wire.java @@ -0,0 +1,5 @@ +package com.example; +public class Wire { + public void initContext() { System.out.println("a"); } + public void other() { System.out.println("b"); } +} diff --git a/tests/cases/python/common-word-text-is-prose/.github/workflows/ci.yml b/tests/cases/python/common-word-text-is-prose/.github/workflows/ci.yml new file mode 100644 index 000000000..1270a5c87 --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/.github/workflows/ci.yml @@ -0,0 +1,5 @@ +name: note checks +on: push +jobs: + build: + runs-on: ubuntu-latest diff --git a/tests/cases/python/common-word-text-is-prose/case.json b/tests/cases/python/common-word-text-is-prose/case.json new file mode 100644 index 000000000..ae8254b53 --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/case.json @@ -0,0 +1,26 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a name in a shell script (by extension or by its #! line) or a Datalog file is never a binding: those files are out of scope, so none of them is a [text] row", + "run": ["impact", "notes.py:1"], + "avoid": ["run.sh:", "rules.dl:", "wrapper:"]}, + {"why": "a common word written as a word (template text, a CI job name) is prose: counted once with the search that lists it, never listed as a place a rename breaks", + "run": ["impact", "notes.py:1"], + "want": ["written as a plain word outside the source:", "name \"note\" with no call, quote or qualifier around it", "grep -rnw \"note\" ."], + "avoid": ["[text] templates/page.html:1", "[text] .github/workflows/ci.yml:1"]}, + {"why": "control: the same common word called (onclick=\"note()\") or quoted as a whole value (a YAML list item) is written the way a binding is, and stays a [text] row", + "run": ["impact", "notes.py:1"], + "want": ["[text] config.yml:2", "[text] templates/page.html:3"]}, + {"why": "--page all lists the prose lines too, marked, so the answer stays complete", + "run": ["impact", "notes.py:1", "--page", "all"], + "want": ["[text, prose] templates/page.html:1", "[text, prose] .github/workflows/ci.yml:1"]}, + {"why": "--json keeps every row and flags the prose ones", + "run": ["impact", "notes.py:1", "--json"], + "want": ["\"at\": \".github/workflows/ci.yml:1\"", "\"prose\": true"]}, + {"why": "control: a name that is not a plain word (make_note) keeps every text row, prose or not", + "run": ["impact", "make_note"], + "want": ["[text] templates/page.html:2"], + "avoid": ["written as a plain word"]}, + {"why": "control: a qualified mention of a common word (Journal.entry in a settings file) stays a binding, while the bare word in template prose does not", + "run": ["impact", "Journal.entry"], + "want": ["[text] routes.cfg:2", "names the method in full", "name \"entry\" with no call"], + "avoid": ["[text] templates/page.html:4"]}]} diff --git a/tests/cases/python/common-word-text-is-prose/config.yml b/tests/cases/python/common-word-text-is-prose/config.yml new file mode 100644 index 000000000..e109b6118 --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/config.yml @@ -0,0 +1,2 @@ +handlers: + - "note" diff --git a/tests/cases/python/common-word-text-is-prose/notes.py b/tests/cases/python/common-word-text-is-prose/notes.py new file mode 100644 index 000000000..e9f5ee619 --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/notes.py @@ -0,0 +1,19 @@ +def note(text): + return text.strip() + + +def make_note(text): + return note(text) + + +def handle(request): + return make_note(request) + + +class Journal: + def entry(self, text): + return note(text) + + +def log(j: Journal): + return j.entry('x') diff --git a/tests/cases/python/common-word-text-is-prose/routes.cfg b/tests/cases/python/common-word-text-is-prose/routes.cfg new file mode 100644 index 000000000..459705ec5 --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/routes.cfg @@ -0,0 +1,2 @@ +[routes] +handler = Journal.entry diff --git a/tests/cases/python/common-word-text-is-prose/rules.dl b/tests/cases/python/common-word-text-is-prose/rules.dl new file mode 100644 index 000000000..e7c202439 --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/rules.dl @@ -0,0 +1,2 @@ +// note: every note is a rule +.decl note(x:symbol) diff --git a/tests/cases/python/common-word-text-is-prose/run.sh b/tests/cases/python/common-word-text-is-prose/run.sh new file mode 100644 index 000000000..92d2da9dd --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/run.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# note: run the note tool +python -c "import notes; notes.note('x')" diff --git a/tests/cases/python/common-word-text-is-prose/templates/page.html b/tests/cases/python/common-word-text-is-prose/templates/page.html new file mode 100644 index 000000000..d5d82315c --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/templates/page.html @@ -0,0 +1,4 @@ +

Leave a note here, then press save.

+

The make_note helper formats it.

+ +

Each entry is kept for a week.

diff --git a/tests/cases/python/common-word-text-is-prose/wrapper b/tests/cases/python/common-word-text-is-prose/wrapper new file mode 100644 index 000000000..5c7c410eb --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/wrapper @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +note "$@" diff --git a/tests/cases/python/one-row-per-dependent/a/__init__.py b/tests/cases/python/one-row-per-dependent/a/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/one-row-per-dependent/a/views.py b/tests/cases/python/one-row-per-dependent/a/views.py new file mode 100644 index 000000000..3b4e00636 --- /dev/null +++ b/tests/cases/python/one-row-per-dependent/a/views.py @@ -0,0 +1,5 @@ +from formatters import FormatterPlugin + + +def show(f: FormatterPlugin, body): + return f.format_body(body, 'application/json') diff --git a/tests/cases/python/one-row-per-dependent/b/__init__.py b/tests/cases/python/one-row-per-dependent/b/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/one-row-per-dependent/b/views.py b/tests/cases/python/one-row-per-dependent/b/views.py new file mode 100644 index 000000000..4f07dd2b4 --- /dev/null +++ b/tests/cases/python/one-row-per-dependent/b/views.py @@ -0,0 +1,5 @@ +from formatters import FormatterPlugin + + +def show(f: FormatterPlugin, body): + return f.format_body(body, 'text/plain') diff --git a/tests/cases/python/one-row-per-dependent/case.json b/tests/cases/python/one-row-per-dependent/case.json new file mode 100644 index 000000000..fdeef1004 --- /dev/null +++ b/tests/cases/python/one-row-per-dependent/case.json @@ -0,0 +1,18 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "an override's supertype method is bound to it twice (a dispatch candidate and a name match): it is ONE row, under the surer reason, with the other said on it, and the header counts it once", + "run": ["impact", "JSONFormatter.format_body"], + "want": ["must change with it (1: bound by a contract the engine resolved):", "also: declares the same member in a supertype"]}, + {"why": "a sibling that shares two fields with the target is one row naming both fields, and the rungs in the header add up to the callables (#1423)", + "run": ["impact", "Box.grow"], + "want": ["using the same field height, width", "(7 callable(s): 7 alongside)"], + "avoid": ["using the same field width\n"]}, + {"why": "control: two distinct callables of one display name in two files are two dependents, and both stay", + "run": ["impact", "FormatterPlugin.format_body"], + "want": ["show a/views.py:5", "show b/views.py:5"]}, + {"why": "--json keeps every reason with its own certainty and line", + "run": ["impact", "Box.grow", "--json"], + "want": ["\"reasons\": [", "using the same field width"]}, + {"why": "path '*' names each nearest caller once, and a caller that is also an entry point is not named twice (#1389)", + "run": ["path", "*", "formatters.py:7"], + "avoid": ["show a/views.py:5, show a/views.py"]}]} diff --git a/tests/cases/python/one-row-per-dependent/formatters.py b/tests/cases/python/one-row-per-dependent/formatters.py new file mode 100644 index 000000000..66e3f8e33 --- /dev/null +++ b/tests/cases/python/one-row-per-dependent/formatters.py @@ -0,0 +1,22 @@ +class FormatterPlugin: + def format_body(self, content, mime): + return content + + +class JSONFormatter(FormatterPlugin): + def format_body(self, content, mime): + return content.strip() + + +class Box: + def __init__(self): + self.width = 1 + self.height = 1 + + def grow(self): + self.width += 1 + self.height += 1 + + def shrink(self): + self.width -= 1 + self.height -= 1 diff --git a/tests/cases/python/shebang-script/case.json b/tests/cases/python/shebang-script/case.json index 885b16155..b6101f99e 100644 --- a/tests/cases/python/shebang-script/case.json +++ b/tests/cases/python/shebang-script/case.json @@ -4,10 +4,10 @@ "run": ["impact", "price"], "want": ["report"], "avoid": ["[text] make-report"]}, - {"why": "a shell script that only runs python3 is not read as Python: its mention of the function stays a text match, not a caller", + {"why": "a shell script that only runs python3 is not read as Python, and a shell script is out of scope: its mention of the function is neither a caller nor a [text] row", "run": ["impact", "price"], - "want": ["[text] deploy:2"], - "avoid": ["deploy."]}, + "want": ["report"], + "avoid": ["deploy.", "[text] deploy"]}, {"why": "an edit inside a shebang script names the function it changed, so test-impact has a declaration to start from", "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new.txt", "--file", "make-report"], "want": ["body", "report"], diff --git a/tests/enrich_lines.py b/tests/enrich_lines.py index 29e3dad7e..324e205fc 100644 --- a/tests/enrich_lines.py +++ b/tests/enrich_lines.py @@ -4,7 +4,7 @@ · a caller count of 0 says why where the graph knows: `entry (http)`, `0 resolved, N by name`, `? framework (@X)`; a method with no signal still reads `0` (the control); · the Grep preview names production callers before tests (#1507), and a name declared several times lists the base - first with its override count, two different files never print as the same line, and the rest are counted (#1546); + first with its override count, two different files never print as the same line, two overloads in one file print once, and the rest are counted (#1546); · a grep run through the shell over another command's output or over files no graph indexes adds nothing, and a constant-shaped word is not looked up as a callable (#1604); · an edit that changes no declaration adds nothing, a body-only edit adds one line of reaching tests and the command @@ -50,6 +50,9 @@ ' public void pokeAll() { new Plain().countAll(); }\n}\n'), P + 'Plain.java': ('package app.orders;\n\npublic class Plain {\n public String countAll() { return "1"; }\n' ' public int unusedCount() { return Integer.parseInt(countAll()); }\n}\n'), + # two overloads in one file: one name to the reader, printed once + P + 'Pricer.java': ('package app.orders;\n\npublic class Pricer {\n public int quoteAll(int n) { return n; }\n\n' + ' public int quoteAll(String s) { return quoteAll(s.length()); }\n}\n'), } # two test stubs in modules whose paths sort before core/, with the same file name and line for m in ('json', 'xml'): @@ -126,6 +129,9 @@ def git(repo, *a): check('the production override comes before test stubs', len(rows) > 1 and 'JsonConverterFactory.widgetConverter' in rows[1], g) check('the declarations not shown are counted', '(+2 more declaration(s))' in g, g) check('two printed lines are never the same', len(rows) == len(set(rows)), g) + g = grep(repo, 'quoteAll') + rows = [l for l in g.splitlines() if l.startswith(' ') and 'Pricer.quoteAll' in l] + check('two overloads in one file print as one line that says so', len(rows) == 1 and '(2 overloads)' in rows[0], g) sys.path.insert(0, HOOKS); import _graphline two = _graphline.distinct_paths(['adapter-json/src/test/java/app/json/Stub.java', 'adapter-xml/src/test/java/app/xml/Stub.java']) check('two same-named files in different modules print with the path that tells them apart', two == ['json/Stub.java', 'xml/Stub.java'], two) From 490539f085b78aee8687c8c54da37af268561b09 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:12:01 -0700 Subject: [PATCH 2/4] path: an outside-world entry keeps its wording in the library-call note; the grouping case uses an engine reason token --- plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path | 3 ++- tests/cases/java/entry-points-grouped/case.json | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 0b43bbe32..4fec0b8f7 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1104,7 +1104,8 @@ def entered_in_process(g, ids): part = keys[k:k + 900]; q = ','.join('?' * len(part)) if g.has('entry_points'): for r in g.q(f"SELECT method_id, reason FROM entry_points WHERE method_id IN ({q})", *part): - if r['reason'] not in NOT_FROM_A_LIBRARY_CALL: out.setdefault(mid[r['method_id']], ax_edges.entry_phrase(r['reason'])) + if r['reason'] not in NOT_FROM_A_LIBRARY_CALL: out.setdefault(mid[r['method_id']], f"{r['reason']} entry point" if ax_edges.entry_outside(r['reason']) + else ax_edges.entry_phrase(r['reason'])) if g.has('overrides'): for r in g.q(f"""SELECT o.overriding_method_id m, b.qualified_name b FROM overrides o JOIN methods b ON b.id = o.method_id WHERE o.overriding_method_id IN ({q}) AND b.provenance <> 'client'""", *part): diff --git a/tests/cases/java/entry-points-grouped/case.json b/tests/cases/java/entry-points-grouped/case.json index 6e90caec4..a3696e11b 100644 --- a/tests/cases/java/entry-points-grouped/case.json +++ b/tests/cases/java/entry-points-grouped/case.json @@ -1,13 +1,13 @@ {"lang": "java", "src": "src", "sql": ["CREATE TABLE IF NOT EXISTS entry_points (method_id TEXT, reason TEXT)", "DELETE FROM entry_points", - "INSERT INTO entry_points (method_id, reason) SELECT method_id, 'http route' FROM symbols WHERE display LIKE 'Api.%' AND method_id IS NOT NULL AND display <> 'Api.Api'", + "INSERT INTO entry_points (method_id, reason) SELECT method_id, 'http' FROM symbols WHERE display LIKE 'Api.%' AND method_id IS NOT NULL AND display <> 'Api.Api'", "INSERT INTO entry_points (method_id, reason) SELECT method_id, 'test' FROM symbols WHERE display LIKE 'ApiTest.%' AND method_id IS NOT NULL AND display <> 'ApiTest.ApiTest'"], "checks": [ {"why": "four entry points of one reason are explained ONCE and named, not one full sentence each (#1036)", "run": ["impact", "Api", "--kind", "type"], - "want": ["4 http route entry points", "Api.listOrders", "Api.deleteOrder"], - "avoid": ["Api.listOrders is a http route entry point"]}, + "want": ["4 http entry points", "Api.listOrders", "Api.deleteOrder"], + "avoid": ["Api.listOrders is a http entry point"]}, {"why": "test entry points are counted apart from the real ones and not explained one by one (#1036)", "run": ["impact", "Api", "--kind", "type"], "want": ["3 test entry point(s)"], From f8a1fdc1fbfad8dfbc9a0dfd2d259383b9da0f13 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:23:43 -0700 Subject: [PATCH 3/4] tests: the one-row-per-dependent fixture uses neutral names --- tests/cases/python/one-row-per-dependent/a/views.py | 6 +++--- tests/cases/python/one-row-per-dependent/b/views.py | 6 +++--- tests/cases/python/one-row-per-dependent/case.json | 6 +++--- .../{formatters.py => greeters.py} | 12 ++++++------ 4 files changed, 15 insertions(+), 15 deletions(-) rename tests/cases/python/one-row-per-dependent/{formatters.py => greeters.py} (53%) diff --git a/tests/cases/python/one-row-per-dependent/a/views.py b/tests/cases/python/one-row-per-dependent/a/views.py index 3b4e00636..cbb6fc360 100644 --- a/tests/cases/python/one-row-per-dependent/a/views.py +++ b/tests/cases/python/one-row-per-dependent/a/views.py @@ -1,5 +1,5 @@ -from formatters import FormatterPlugin +from greeters import Greeter -def show(f: FormatterPlugin, body): - return f.format_body(body, 'application/json') +def show(g: Greeter, name): + return g.greet(name, 'plain') diff --git a/tests/cases/python/one-row-per-dependent/b/views.py b/tests/cases/python/one-row-per-dependent/b/views.py index 4f07dd2b4..e5d4e4d09 100644 --- a/tests/cases/python/one-row-per-dependent/b/views.py +++ b/tests/cases/python/one-row-per-dependent/b/views.py @@ -1,5 +1,5 @@ -from formatters import FormatterPlugin +from greeters import Greeter -def show(f: FormatterPlugin, body): - return f.format_body(body, 'text/plain') +def show(g: Greeter, name): + return g.greet(name, 'loud') diff --git a/tests/cases/python/one-row-per-dependent/case.json b/tests/cases/python/one-row-per-dependent/case.json index fdeef1004..58a6451a7 100644 --- a/tests/cases/python/one-row-per-dependent/case.json +++ b/tests/cases/python/one-row-per-dependent/case.json @@ -1,18 +1,18 @@ {"lang": "python", "src": ".", "checks": [ {"why": "an override's supertype method is bound to it twice (a dispatch candidate and a name match): it is ONE row, under the surer reason, with the other said on it, and the header counts it once", - "run": ["impact", "JSONFormatter.format_body"], + "run": ["impact", "LoudGreeter.greet"], "want": ["must change with it (1: bound by a contract the engine resolved):", "also: declares the same member in a supertype"]}, {"why": "a sibling that shares two fields with the target is one row naming both fields, and the rungs in the header add up to the callables (#1423)", "run": ["impact", "Box.grow"], "want": ["using the same field height, width", "(7 callable(s): 7 alongside)"], "avoid": ["using the same field width\n"]}, {"why": "control: two distinct callables of one display name in two files are two dependents, and both stay", - "run": ["impact", "FormatterPlugin.format_body"], + "run": ["impact", "Greeter.greet"], "want": ["show a/views.py:5", "show b/views.py:5"]}, {"why": "--json keeps every reason with its own certainty and line", "run": ["impact", "Box.grow", "--json"], "want": ["\"reasons\": [", "using the same field width"]}, {"why": "path '*' names each nearest caller once, and a caller that is also an entry point is not named twice (#1389)", - "run": ["path", "*", "formatters.py:7"], + "run": ["path", "*", "greeters.py:7"], "avoid": ["show a/views.py:5, show a/views.py"]}]} diff --git a/tests/cases/python/one-row-per-dependent/formatters.py b/tests/cases/python/one-row-per-dependent/greeters.py similarity index 53% rename from tests/cases/python/one-row-per-dependent/formatters.py rename to tests/cases/python/one-row-per-dependent/greeters.py index 66e3f8e33..d3744b425 100644 --- a/tests/cases/python/one-row-per-dependent/formatters.py +++ b/tests/cases/python/one-row-per-dependent/greeters.py @@ -1,11 +1,11 @@ -class FormatterPlugin: - def format_body(self, content, mime): - return content +class Greeter: + def greet(self, name, style): + return name -class JSONFormatter(FormatterPlugin): - def format_body(self, content, mime): - return content.strip() +class LoudGreeter(Greeter): + def greet(self, name, style): + return name.upper() class Box: From 34197bb185a4d8d00e59278558f7717891208018 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:02:28 -0700 Subject: [PATCH 4/4] tests: the event listener's answer uses the per-tier event wording, and its entry line credits the publisher rows --- tests/cases/java/event-reaches-its-listeners/case.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/cases/java/event-reaches-its-listeners/case.json b/tests/cases/java/event-reaches-its-listeners/case.json index 8dc1e79ad..0fb5e9a8b 100644 --- a/tests/cases/java/event-reaches-its-listeners/case.json +++ b/tests/cases/java/event-reaches-its-listeners/case.json @@ -9,8 +9,12 @@ "want": ["event_dispatch"]}, {"why": "the listener's answer names the publisher as an event, not as a value handed over and not as a call that bypasses the container proxy", "run": ["impact", "Audit.on"], - "want": ["OrderService.place", "OrderService.placeExpress", "publishes an event this handles"], + "want": ["OrderService.place", "OrderService.placeExpress", "sends the request or event this handles"], "avoid": ["obtained itself", "handed over as a value", "the change is local"]}, + {"why": "and its entry line says the framework runs it for what the publisher rows send, not that no client call site reaches it", + "run": ["impact", "Audit.on"], + "want": ["for the request or event the sender row(s) above send"], + "avoid": ["so no client call site reaches it"]}, {"why": "control: a listener for a SUBTYPE does not receive the supertype's event", "run": ["path", "OrderService.place", "Audit.onExpress"], "expect_error": true,