diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 2cc15b7d2..6e827fa05 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -58,6 +58,7 @@ An answer's label is the **worst** rung on its route. Read it before acting on t | `[defines]` · `[protocol]` · `[decorator by name]` | closure from its definer · interpreter-called method · wrapper rebinding the name | | `[fixture]` · `[at import]` | injected before the test body · module raised on import, test never collected | | `[by key]` | joined through a registration **string** (route, signal, CLI command) — not an edge | +| `[stubs it]` | a call written inside a mock's stub or verification (`when(m.f())`, `verify(m).f()`, `Setup(x => x.F())`, `Received().F()`): names it, runs none of it — never a test route, listed apart | | `[in scope]` · `[by name]` · `[text]` | same name in the owner's scope · same name elsewhere (may be another thing) · text only | Below `[sound]` / `[one of a set]` the order is a tie-break, not a measured ranking. `[sound]` means the edges diff --git a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md index 906e52d4c..074ac4321 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md +++ b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md @@ -95,3 +95,9 @@ the number in front of you. Skipping what it does not name is a decision about r you: a test reached only through reflection, a service loader, a subprocess, or a case built at runtime does not appear here (the `[text]` tier above recovers the ones whose test names the file it loads). +A test that only **stubs** a changed declaration on a mock (`when(repo.find(1))`, `mock.Setup(r => r.Find(1))`) is not +selected for a body edit: it runs none of the body. It is named on a `not selected:` line, and it is selected when the +change is a signature change or a removal, which breaks the stub. A test that reaches the change only through a +framework-entered entry point (an HTTP route, an event, a mediator send) is named on a `NOT COUNTED` line, with the +search that finds it, unless a `[by key]` route already joined it. + diff --git a/plugins/axiomcode/skills/axiomcode/reference/impact.md b/plugins/axiomcode/skills/axiomcode/reference/impact.md index de376f493..cf2eb57e2 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/impact.md +++ b/plugins/axiomcode/skills/axiomcode/reference/impact.md @@ -179,6 +179,18 @@ line and names the constructor query to run; take that suggestion before acting of what a service's suite does. The two spellings of a path are matched segment by segment (`/orders/o-1/price` against `/orders/{order_id}/price`, ``, `:id`), never normalised. It is **not** an edge the engine resolved and is never shown as one: the hop is `[by key]`, and a literal can be a same-valued other thing. +- **a stub on a mock is NOT a hop** — `when(repo.find(1))`, `verify(repo).save(x)`, `doReturn(v).when(repo).find(1)`, + `mock.Setup(r => r.Find(1))`, `mock.Verify(...)`, `sub.Received().Find(1)`, `sub.Find(1).Returns(v)`: the engine + resolves the call to the declared method, which is right about the name and wrong about execution, since the receiver + is a mock. Such a site is marked by its position against the mocking library's own call (a knob table per language in + `scripts/ax_edges.py`, `STUB_WRAPPERS`), and it is a `[stubs it]` row: a rename or a new parameter breaks it, a body + change never does. It is kept out of the closure, so a test whose only contact is a stub is not counted under `tests:`; + it is listed on its own `[stubs it]` line, and `test-impact` selects it only for a signature change or a removal. A call + in the stub's ARGUMENT list (`when(repo.find(Ids.first()))`) runs for real and stays a route. A test that drives the + class under test with a mock injected still counts through the class under test: the graph cannot see which object + is injected. An entry point of the change that a framework enters (a route handler, a listener) is named on a + `NOT COUNTED` line with the search that finds the tests driving it, since those are counted only where a `[by key]` + route joins them. - **a decorator that rebinds the name is a hop** — `@audited def summarise(…)` leaves `summarise` denoting what `audited(summarise)` RETURNED, so every caller written with that name runs the wrapper. That is the engine's own resolution (`ext_decorated_name_target`), not a name match, so the hop is `[sound]`; without it a `functools.wraps` diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index 17323fd82..daa83158c 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -30,7 +30,7 @@ reaches B in 11 calls" is false when five of the eleven are containment. `calls_in()` is what a hop count is taken from. """ -import re +import collections, re # ── how certain a hop is. Lower is more certain; the reader prefers the lowest at every step. ────── TIER_RANK = { @@ -56,6 +56,7 @@ 'ambiguous_dynamic': 8, # a call through C# `dynamic`: undecidable from source by design 'ambiguous_unknown': 8, 'by-name': 9, # not resolved at all: the names simply match + 'stub': 9, # written inside a mock's stub or verification: named, never run (stub_sites) } UNRANKED = 10 # an engine tier this table has not been taught — least certain, never silent @@ -82,6 +83,7 @@ 'fan_capped': 'the candidate set was too large to enumerate — a sample, not the set', 'written': 'the call is written at that line', 'by-name': 'unresolved — the names match and nothing more', + 'stub': 'written inside a mock\'s stub or verification; the real method does not run there', } # ── what the hop IS, in one word that means the same thing in every language ─────────────────────── @@ -160,6 +162,7 @@ def legend(tiers): 'callback_registered': 'registered', 'event_dispatch': 'registered', 'ambient_terminal': 'registered', 'dynamic_terminal': 'registered', 'intrinsic_terminal': 'registered', 'fan_capped': 'capped set', + 'stub': 'stubs it', # a call inside a mock's stub or verification (stub_sites below): named, never run } DIRECT_CERT_DEFAULT = 'registered' # unlisted: an edge the engine asserted and this table cannot name — never `resolved` @@ -167,6 +170,7 @@ def legend(tiers): DIRECT_WHY = { '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', + 'stubs it': 'stubs it on a mock: the real method does not run there, and the test breaks only if the name or parameters change', } # …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. @@ -225,12 +229,12 @@ def entry_outside(reason): # instead: the membership is exactly what it was before this table existed, so no row leaves any # set — only the label it is printed under changes. It matters most for the --delete verdict, where # dropping a hand-off would turn "something still holds this" into "safe to delete". -EDGE_BACKED = frozenset({'resolved', 'one of a set', 'registered', 'capped set'}) +EDGE_BACKED = frozenset({'resolved', 'one of a set', 'registered', 'capped set', 'stubs it'}) # most certain first. A caller with several call sites to the same callee can hold sites of different # tiers; a summary that names the caller once takes the best of them, which is the honest reading of # "at least one resolved call exists here". -DIRECT_ORDER = ('resolved', 'one of a set', 'registered', 'capped set') +DIRECT_ORDER = ('resolved', 'one of a set', 'registered', 'capped set', 'stubs it') # ── `defines`: a callable written inside another one's body ──────────────────────────────────────────────── @@ -319,6 +323,140 @@ def defines_edges(callables, sites=None): return [(p, i, 'defines') for (_, i), p in parent.items()] +# ── a STUB SITE: a call written inside a mocking library's stub or verification ───────────────────────────── +# `when(repo.find(id)).thenReturn(x)`, `verify(repo).save(x)`, `mock.Setup(r => r.Find(id))`, +# `sub.Received().Save(x)`, `sub.Find(id).Returns(x)`. The engine resolves `repo.find` to the declared method, which +# is right about the NAME (a rename or a new parameter breaks the test) and wrong about EXECUTION: the receiver is a +# mock, so the real body never runs there. Walked as a call, every stub was a `[sound]` route from the test to the +# real method, and "which tests run this body" was answered with the tests that replace it. Measured on a Spring REST +# project: all 8 tests named for a service method stubbed it on a @MockBean, and none of them ran it. +# +# A site is a stub site by its POSITION against the wrapper's own call site (the parser records both spans), never by +# its own name, and only when the wrapper did NOT resolve to a client declaration: a project method called `when` or +# `Setup` is not the library's. Three shapes, one knob table per language: +# arg the stubbed call is the wrapper's ARGUMENT, the outermost call in it: `when(x.m(a()))` marks m and not a(), +# which runs for real to build the stub's argument; `mock.Setup(x => x.M())` marks M +# prefix the wrapper's RESULT is the stubbed call's receiver: `verify(x).m()`, `sub.Received(1).M()`. Mockito's +# `doReturn(v).when(x).m()` is this shape too, and `when` counts here only behind a do* call, because an HTTP +# test DSL writes a request as `given().when().get("/orders")` and that `get` is the request, not a stub +# recv the stubbed call is the wrapper's RECEIVER: NSubstitute's `sub.M(a).Returns(v)` +# Python has no row on purpose: unittest.mock replaces an attribute by STRING (`patch("pkg.mod.f")`, +# `patch.object(C, "m")`) and a MagicMock receiver is untyped, so no call on a mock resolves to a client method. +STUB_WRAPPERS = { + 'java': { + 'arg': {'when', 'given'}, # Mockito.when, BDDMockito.given + 'prefix': {'verify', 'should'}, # verify(x).m(), then(x).should().m() + 'prefix_after_do': {'when'}, # doReturn(v).when(x).m() + 'recv': set(), + 'lambda_arg': False, + }, + 'csharp': { + # Moq's mock.Setup(x => x.M()), FakeItEasy's A.CallTo(() => f.M()). The stubbed call is written in a LAMBDA + # (lambda_arg below): a snapshot library's `Verify(await svc.Get())` runs Get for real, and a BDD runner's + # `.When(x => GetOrders())` runs its lambda, so `When` is not in the table at all + 'arg': {'Setup', 'SetupGet', 'SetupSet', 'SetupSequence', 'SetupProperty', 'Verify', 'VerifyGet', 'VerifySet', + 'CallTo', 'CallToSet'}, + 'lambda_arg': True, + 'prefix': {'Received', 'DidNotReceive', 'ReceivedWithAnyArgs', 'DidNotReceiveWithAnyArgs'}, # NSubstitute + 'prefix_after_do': set(), + 'recv': {'Returns', 'ReturnsForAnyArgs', 'ReturnsNull', 'ReturnsNullForAnyArgs', 'Throws', 'ThrowsAsync', + 'ThrowsForAnyArgs', 'ThrowsAsyncForAnyArgs'}, # NSubstitute + }, +} +STUB_DO = {'doReturn', 'doThrow', 'doNothing', 'doAnswer', 'doCallRealMethod'} +STUB_LANG = {'java': 'java', 'kt': 'java', 'groovy': 'java', 'cs': 'csharp'} +STUB_TIER = 'stub' + + +def _short(name): + """a call site's callee as written, reduced to its simple name: `Mockito.when` -> when, `Substitute.For` -> For""" + n = re.sub(r'<.*$', '', (name or '').replace('()', '')).strip() + return n.rsplit('.', 1)[-1] + + +def _stub_lang(f): + return STUB_LANG.get((f or '').rsplit('.', 1)[-1]) if '.' in (f or '') else None + + +def stub_sites(q): + """the ids of the call sites written inside a mocking library's stub or verification (STUB_WRAPPERS above). + q(sql, params) -> rows. Only the files holding a wrapper-named call site are read.""" + names = {n for lang in STUB_WRAPPERS.values() for k in ('arg', 'prefix', 'prefix_after_do', 'recv') for n in lang[k]} + files = sorted({f for f, n in q("SELECT DISTINCT file_path, callee_name FROM call_sites WHERE callee_name IS NOT NULL", ()) + if _stub_lang(f) and _short(n) in names}) + out = set() + for f in files: + lang = STUB_WRAPPERS[_stub_lang(f)] + rows = [(r[0], _short(r[1]), (r[2], r[3], r[4], r[5]), bool(r[6]), bool(r[7])) for r in q( + """SELECT s.id, s.callee_name, s.start_line, s.start_column, s.end_line, s.end_column, + EXISTS (SELECT 1 FROM call_edges e WHERE e.call_site_id = s.id AND e.callee_provenance = 'client' + AND e.callee_method_id IS NOT NULL), + EXISTS (SELECT 1 FROM symbols y WHERE y.id = s.caller_id AND y.display LIKE '%%') + FROM call_sites s WHERE s.file_path = ?""", (f,)) if None not in r[2:6]] + spans = [(i, sp) for i, _, sp, _, _ in rows] + in_lambda = {i for i, _, _, _, lam in rows if lam} + wraps = [(n, sp) for _, n, sp, client, _ in rows if not client] # the LIBRARY's wrapper, never a project method + def outermost(cands): # the candidates no other candidate encloses + return {i for i, sp in cands if not any(_within(sp, o) for _, o in cands)} + def nearest(cands): # the candidates enclosing no other candidate + return {i for i, sp in cands if not any(_within(o, sp) for _, o in cands)} + for n, w in wraps: + if n in lang['arg']: + # inside the wrapper's span and NOT inside its receiver chain, which starts where the wrapper starts: + # `given().body(new HashMap<>() {…}).when().put("/orders/1")` holds the HashMap inside `when`'s span, as + # the receiver's argument, not as when's + recv = [sp for _, sp in spans if _within(sp, w) and sp[:2] == w[:2]] + out |= outermost([(i, sp) for i, sp in spans if _within(sp, w) and sp[:2] > w[:2] + and not any(sp == r or _within(sp, r) for r in recv) + and (i in in_lambda or not lang['lambda_arg'])]) + if n in lang['prefix'] or (n in lang['prefix_after_do'] and + any(x in STUB_DO and _within(sp, w) and sp[:2] == w[:2] for x, sp in wraps)): + out |= nearest([(i, sp) for i, sp in spans if _within(w, sp) and sp[:2] == w[:2]]) + if n in lang['recv']: + out |= outermost([(i, sp) for i, sp in spans if _within(sp, w) and sp[:2] == w[:2]]) + return out + + +# ── a MOCKED TYPE: a test class that holds a mock of T never runs T's methods ──────────────────────────────────── +# `@MockBean OrderService orders` in a web test replaces the bean the controller is handed, so a request the test sends +# reaches the controller and stops at the mock: a change to OrderService's body cannot fail that test, however the +# route to it is drawn. Read off the test class's FIELDS (and those of the classes it extends): a mock-making +# annotation on the field, a Moq `Mock` field type, or a mock factory written in the field's initializer. A spy +# (@Spy, @SpyBean, Substitute.ForPartsOf) runs the real methods and is not a mock here; neither is @InjectMocks, the +# object under test. A mock held in a LOCAL is not read (no fact records a local's initializer), so a test that builds +# its mocks inside the method keeps its routes. +MOCK_FIELD_DECOR = {'Mock', 'MockBean', 'MockitoBean'} +MOCK_FACTORY = {'mock', 'For', 'Fake'} # Mockito.mock(T.class), Substitute.For(), A.Fake() +_MOCK_GENERIC = re.compile(r'^(?:\w+\.)*Mock<\s*([\w.]+)') + + +def _simple_type(t): + return re.sub(r'<.*$', '', (t or '').strip()).split('.')[-1] + + +def mocked_types(q, line_of=None): + """{test type simple name: {mocked type simple name}} from the fields a test class declares. q(sql, params); + line_of(file, line) -> the source line, read only where the parser kept `Mock` and dropped its type argument (C#).""" + try: + fields = q("SELECT id, type_name, owner_qualified_name, file_path, start_line FROM fields WHERE owner_qualified_name IS NOT NULL", ()) + except Exception: + return {} + dec = collections.defaultdict(set) + for oid, name in q("SELECT owner_id, name FROM decorations WHERE owner_id IS NOT NULL", ()): + dec[oid].add((name or '').split('.')[-1]) + fac = collections.defaultdict(set) + for f, l, n in q("SELECT file_path, start_line, callee_name FROM call_sites WHERE callee_name IS NOT NULL", ()): + if _short(n) in MOCK_FACTORY: fac[(f, l)].add(_short(n)) + out = collections.defaultdict(set) + for fid, tn, owner, f, l in fields: + m = _MOCK_GENERIC.match(tn or '') + if not m and _simple_type(tn) == 'Mock' and line_of and f and l: + m = re.search(r'\bMock<\s*([\w.]+)', line_of(f, l) or '') + t = m.group(1).split('.')[-1] if m else (_simple_type(tn) if (dec.get(fid, set()) & MOCK_FIELD_DECOR or fac.get((f, l))) else None) + if t: out[owner.split('.')[-1]].add(t) + return dict(out) + + def best_cert(certs): return min(certs, key=lambda c: DIRECT_ORDER.index(c) if c in DIRECT_ORDER else len(DIRECT_ORDER)) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py index c452c453c..6b9f12166 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py @@ -210,9 +210,114 @@ def decoration_keys(q, site_file=None): 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') out.append((owner, sf(f) if f else '', l or 0, kind, key, why)) + out += _prefixed_routes(q, out, sf) return sorted(set(out)) +# A HANDLER'S ROUTE IS ITS TYPE'S PREFIX AND ITS OWN PATH. `@RequestMapping("/orders/{id}")` on the class and a bare +# `@GetMapping` on the method serve "/orders/{id}"; `@PostMapping("lines")` beside it serves "/orders/{id}/lines"; a +# path written without its leading slash (`@RequestMapping("widgets")`) is served from the root all the same. Read one +# decoration at a time, the prefix registered the CLASS, which no closure walks, and a relative path registered +# nothing, so a test driving such a handler over HTTP reached nothing: a request test for a class-mapped controller +# counted 0 tests. A decoration is a mapping when its NAME says so (…Mapping, JAX-RS @Path, a verb, ASP.NET's +# [Route] / [HttpGet]); a `@Transactional` beside it is not a route. ASP.NET's [controller] and [action] tokens are +# the type's name without its Controller suffix and the method's name. +import collections, re +_MAPPING = re.compile(r'^(\w*Mapping|Path|GET|POST|PUT|DELETE|PATCH|Http(Get|Post|Put|Delete|Patch)|Route)$') + + +def _first_path(text): + m = re.search(r'"([^"\s]{0,120})"', (text or '').replace('"""', '"')) + return m.group(1) if m else None + + +def _join(a, b): + return '/' + '/'.join(x.strip('/') for x in (a, b) if x and x.strip('/')) + + +def _prefixed_routes(q, rows, sf): + if not _has(q, 'symbols'): + return [] + seen = {(r[0], r[4]) for r in rows} + sym = {i: (d, n, o, bool(t) and not m) for i, d, n, o, t, m in q("SELECT id, display, name, owner, type_id, method_id FROM symbols WHERE display IS NOT NULL")} + prefix = collections.defaultdict(set) # type display -> the paths its own mapping serves + methods = [] + for owner, name, text, f, l in q("SELECT owner_id, name, text, file, line FROM decorations WHERE owner_id IS NOT NULL"): + short = (name or '').split('.')[-1] + if not _MAPPING.match(short) or owner not in sym: continue + p_ = _first_path(text) + d, n, o, is_type = sym[owner] + if is_type: + if p_: prefix[d].add(p_) + else: + methods.append((owner, short, p_, f, l)) + out = [] + for mid, short, p_, f, l in methods: + d, n, o, _t = sym[mid] + tname = (o or '').split('.')[-1] + for pre in sorted(prefix.get(o) or {''}): + key = _join(pre, p_ or '') + key = key.replace('[controller]', re.sub(r'Controller$', '', tname).lower()).replace('[action]', (n or '').lower()) + if key == '/' and not (pre or p_): continue + if (mid, key) in seen: continue + seen.add((mid, key)) + out.append((mid, sf(f) if f else '', l or 0, 'route', key, + f'registered as a route "{key}" by @{short}' + (" under its type's prefix" if pre else '') + ' — the router calls it, no call site does')) + return out + + +# THE HTTP METHOD A ROUTE ANSWERS, AND THE ONE A TEST SENDS. "/orders" is two handlers when one answers GET and the other +# POST, and a test that posts an order drives only the second. Joined on the path alone, every test that lists orders +# reached the handler that creates one, and a path five tests write (one per verb) was refused as a key that +# identifies nothing. The verb is read where it is written: the handler's decoration (`@GetMapping`, `[HttpPost]`, +# `@router.put`, `@RequestMapping(method = DELETE)`, JAX-RS `@DELETE` beside `@Path`) and the request call that +# carries the path literal (`.put("/orders/{id}", id)`, `client.delete(...)`, `DeleteAsync(...)`). Either side unknown +# joins as before. +_VERBS = ('GET', 'POST', 'PUT', 'DELETE', 'PATCH') +_VERB_NAME = re.compile(r'^(?:(Get|Post|Put|Delete|Patch)Mapping|Http(Get|Post|Put|Delete|Patch)|(GET|POST|PUT|DELETE|PATCH)|(get|post|put|delete|patch))$') + + +def route_verbs(q, site_file=None): + """{(decl, key, VERB)}: the HTTP method each decoration-registered route answers, where a decoration says it.""" + if not _has(q, 'decorations'): + return set() + verbs = collections.defaultdict(set) + for owner, name, text in q("SELECT owner_id, name, text FROM decorations WHERE owner_id IS NOT NULL"): + short = (name or '').split('.')[-1] + m = _VERB_NAME.match(short) + if m: + verbs[owner].add(next(g for g in m.groups() if g).upper()) + elif _MAPPING.match(short) or short == 'route': + for v in re.findall(r'\b(GET|POST|PUT|DELETE|PATCH)\b', text or ''): verbs[owner].add(v) + out = set() + for decl, _f, _l, kind, key, _w in decoration_keys(q, site_file): + if kind == 'route': + for v in verbs.get(decl, ()): out.add((decl, key, v)) + return out + + +def literal_verbs(q, at, site_file=None): + """{(caller, literal, VERB)}: a path literal written as the argument of a request call named for one HTTP method — + the innermost such call whose span holds the literal's line.""" + if not (_has(q, 'literals') and _has(q, 'call_sites')): + return set() + sf = site_file or (lambda x: x) + calls = collections.defaultdict(list) + for n, f, a, b in q("SELECT callee_name, file_path, start_line, end_line FROM call_sites WHERE callee_name IS NOT NULL AND start_line > 0"): + short = re.sub(r'Async$', '', (n or '').split('.')[-1].split('<')[0]).upper() + if short in _VERBS: calls[sf(f) if f else ''].append((a, b or a, short)) + out = set() + for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + if not (isinstance(v, str) and v.startswith('/')): continue + f2 = sf(f) if f else '' + hold = [(b - a, -a, verb) for a, b, verb in calls.get(f2, ()) if a <= l <= b] + if not hold: continue + verb = min(hold)[2] + c = at(f, l) + if c: out.add((c, v, verb)) + return out + + # ── a registration written as a CALL that no verb list knows ───────────────────────────────────────────────── # the Python web framework's `application.add_url_rule("/quote/", view_func=legacy_quote)` is a route registration whose verb # is not an HTTP verb, and the same shape appears wherever a framework takes (path, handler) under a name of its own diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 406b58015..1a3acbe37 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -112,8 +112,10 @@ def ckey(k): # and it is not a call to the declaration at all, so it cannot outrank a target set. # `framework` (#1509) sits directly below `remote` for the same reason: the engine joined both ends on the # framework's own registration, so it outranks every name match, and it is not a call to the declaration. +# `stubs it` (a call inside a mock's stub or verification) is the engine's resolution, so it outranks every name match, +# and runs nothing, so it sits below every hop that does: a rename breaks it, a body change never does. 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} + 'stubs it': 6, 'in scope': 7, 'by name': 8, 'text': 9, 'alongside': 10} 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 '' @@ -696,7 +698,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 = '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 + IMPACT_VERSION = '33' # 33: calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 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 @@ -871,12 +873,16 @@ class Impact: # on cert_tier, and a tier with no row would drop its dependents out of the answer entirely rather # than mislabel them, which is the worse of the two failures. ax_edges.direct_cert has the default. W('cert_tier', sorted({(t, ax_edges.direct_cert(t), ax_edges.direct_why(t)) - for (t,) in g.q("SELECT DISTINCT tier FROM call_edges")})) - W('calls', [(r['caller_id'], r['callee_method_id'], r['tier'], g.site_file(r['file_path']) if r['file_path'] else '', r['start_line'] or 0) - for r in g.q("SELECT e.caller_id, e.callee_method_id, e.tier, s.file_path, s.start_line FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id WHERE e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL")]) + for t in {r[0] for r in g.q("SELECT DISTINCT tier FROM call_edges")} | {ax_edges.STUB_TIER}})) + # a call written inside a mock's stub or verification keeps its row and loses its tier: "stub" (ax_edges.stub_sites) + stubs = ax_edges.stub_sites(lambda s, p: g.q(s, *p)) if g.has('call_sites') else set() + W('calls', [(r['caller_id'], r['callee_method_id'], ax_edges.STUB_TIER if r['call_site_id'] in stubs else r['tier'], g.site_file(r['file_path']) if r['file_path'] else '', r['start_line'] or 0) + for r in g.q("SELECT e.call_site_id, e.caller_id, e.callee_method_id, e.tier, s.file_path, s.start_line FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id WHERE e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL")]) CTOR = ('new', 'anon_new', 'CONSTRUCTOR_CALL') - W('unresolved', [(r['caller_id'], (r['callee_name'] or '').split('.')[-1], 'new' if r['kind'] in CTOR else 'method', g.site_file(r['file_path']) if r['file_path'] else '', r['start_line'] or 0) - for r in g.q("SELECT s.caller_id, s.callee_name, s.kind, s.file_path, s.start_line FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.callee_name IS NOT NULL AND s.callee_name <> ''")]) + # an unresolved site inside a mock's stub or verification (a Moq lambda whose parameter the engine did not type) + # is the kind "stub": still a name match for the rows, never a by-name seed of the closure + W('unresolved', [(r['caller_id'], (r['callee_name'] or '').split('.')[-1], 'new' if r['kind'] in CTOR else ax_edges.STUB_TIER if r['id'] in stubs else 'method', g.site_file(r['file_path']) if r['file_path'] else '', r['start_line'] or 0) + for r in g.q("SELECT s.id, s.caller_id, s.callee_name, s.kind, s.file_path, s.start_line FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.callee_name IS NOT NULL AND s.callee_name <> ''")]) # a call site with a name that the engine did NOT bind to a client declaration: unresolved, or sent to a library # because the callee is generated (a dataclass constructor is a call to the class itself). The generated-member # rules look here, so a generated accessor or constructor is found whichever way the engine classified the site @@ -944,6 +950,9 @@ class Impact: + ax_registration.registrations(g.q, g.site_file)): if rd in g.sym and key: regk.append((rd, kind, key)) W('reg_key_fact', sorted(set(regk))) + # the HTTP method each side names, where it names one (ax_registration.route_verbs / literal_verbs) + W('reg_verb', sorted(x for x in ax_registration.route_verbs(g.q, g.site_file) if x[0] in g.sym)) + W('lit_verb', sorted(ax_registration.literal_verbs(g.q, self.at, g.site_file))) # THE NAME A DECORATOR REBINDS. `@audited def summarise(...)` leaves the name `summarise` denoting what # `audited(summarise)` RETURNED — the wrapper — and the engine resolves that (ext_decorated_name_target). # Every caller written `summarise(...)` therefore runs the wrapper, and a change to the wrapper reaches @@ -1406,7 +1415,7 @@ class Impact: prof('query facts written'); r = subprocess.run(prog + ['-F', F, '-D', O], capture_output=True, text=True); prof('souffle returned') if r.returncode: die("souffle failed:\n" + r.stderr[-1200:]) out = {} - for n in ('contract', 'direct', 'direct_edge', 'seed', 'seed_byname', 'reach', 'reach_sure', 'parent_up', 'test_near', 'test_hit', 'inherited_test', 'extbind', 'gen_fired', 'caller_handles', 'caller_unhandled', 'target_throws'): + for n in ('contract', 'direct', 'direct_edge', 'seed', 'seed_byname', 'reach', 'reach_sure', 'parent_up', 'test_near', 'test_hit', 'test_stub', 'inherited_test', 'extbind', 'gen_fired', 'caller_handles', 'caller_unhandled', 'target_throws'): rows = [] for l in open(os.path.join(O, n + '.csv')): p = l.rstrip('\n').split('\t') @@ -1777,6 +1786,10 @@ def main(argv): inh = {} for s_, m, d in {(x[0], x[1], int(x[2])) for x in res['inherited_test']}: if m in tests and int(d) <= depth and s_ in g.sym: inh.setdefault(s_, []).append(m) + # a test that only STUBS the change on a mock (`when(repo.find(1))`, `mock.Setup(r => r.Find(1))`): it names the + # declaration, so a rename or a new parameter breaks it, and never runs it, so it is not a route and not counted + stubbed = sorted({x[0] for x in res.get('test_stub', []) if x[0] in g.sym and x[0] not in tests}, + key=lambda m: (g.sym[m]['file'] or '', g.sym[m]['display'] or '', m)) byname_seeds = {x[0] for x in res['seed_byname'] if x[0] in g.sym} # the callers on an untyped receiver are SAMPLED in the text answer, so their order decides which are shown. Symbol # ids are not stable between two builds of the same tree, so ordering by id showed a different sample each build: @@ -1788,13 +1801,51 @@ def main(argv): universe = sum(1 for _ in open(os.path.join(g.facts, 'impact', 'test_method.facts'))) # an `alongside` row is not a place the change is observed from: it must stay out of the entry points, as it stays out # of the seeds — a reader is promised that an entry point reaches the change (#762) - direct_ids = {c for c, role, why, cert, loc, n in D if cert != 'alongside'} | {c for c, _ in contract} + # …nor is a stub on a mock: it names the declaration and runs none of it + direct_ids = {c for c, role, why, cert, loc, n in D if cert != 'alongside' and cert != 'stubs it' + and not why.startswith('stubs a method of this name')} | {c for c, _ in contract} direct_ids = {c for c in direct_ids if c in g.sym} ent = [(m, d) for m, d in {**{c: 0 for c in direct_ids}, **reached}.items() if not callers_of.get(m) or g.sym[m]['is_test']] inside = list({*reached, *seeds, *direct_ids}) # the direct rows are not in seed for a method target u = g.q(f"SELECT count(*) n FROM unresolved_sites WHERE caller_id IN ({','.join('?' * len(inside))})", *inside)[0]['n'] if inside else 0 # the check that means something: every printed chain hop, and every resolved direct entry, is looked up again in graph.sqlite chains = {m: I.chain(m, parent) for m in tests} + # A TEST THAT HOLDS A MOCK OF A TYPE ON ITS ROUTE never runs that type's methods (ax_edges.mocked_types): a web test + # with `@MockBean OrderService` reaches the controller over HTTP and stops at the mock, so a route through + # OrderService is not a route, whatever its rung. Read off the route the answer prints (the nearest one); the test + # moves to the stub listing, named for the type it mocks. + mock_via = {} + if tests: + def _line_of(f, l): + try: return I.lines(g.site_file(f))[l - 1] + except Exception: return '' + mocks = ax_edges.mocked_types(lambda s_, p_: g.q(s_, *p_), _line_of) + if mocks: + tsimple = {r['type_id']: (r['display'] or '').split('.')[-1] for r in g.q("SELECT type_id, display FROM symbols WHERE type_id IS NOT NULL AND method_id IS NULL")} + anc = collections.defaultdict(set) + for r in g.q("SELECT type_id, ancestor_type_id FROM type_ancestors") if g.has('type_ancestors') else []: + if r[0] in tsimple and r[1] in tsimple: anc[tsimple[r[0]]].add(tsimple[r[1]]) + up_names = lambda n: {n} | anc.get(n, set()) + for m in list(tests): + own = (g.sym[m]['owner'] or '').split('.')[-1] + held = set().union(*(mocks.get(x, set()) for x in up_names(own))) + if not held: continue + via = tests[m][1] + ch = (chains.get(m) or []) if not via else [m] + I.chain(via, parent) + # the hop's OWN type, or a supertype of it only where the hop is a dispatch choice (the caller's call was + # resolved to the mocked interface, and the implementation is one of its candidates). A real CachingStore + # wrapping a mocked IStore implements that interface too, and a test that calls the real one + # directly reaches it through a resolved call, which is not through the mock. + for a_, n in zip(ch, ch[1:]): + ow = ((g.sym.get(n) or {}).get('owner') or '').split('.')[-1] + if not ow: continue + ts = {t for x, t in parent.get(a_, ()) if x == n} + hit = ({ow} | (anc.get(ow, set()) if ts & {'dispatch', 'multi_inferred'} else set())) & held + if hit: + mock_via[m] = sorted(hit)[0]; break + for m in mock_via: + tests.pop(m, None); chains.pop(m, None) + stubbed = sorted(set(stubbed) | set(mock_via), key=lambda m: (g.sym[m]['file'] or '', g.sym[m]['display'] or '', m)) # how sure is each test's route to the change? the worst hop on its chain — what makes the answer wide is visible here: # a call resolved to a SET of targets, or an override reached through its base, is the engine's over-approximation # a tier no rung is written for, collected as the routes are labelled so it can be SAID rather than @@ -1926,6 +1977,22 @@ def main(argv): 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()) + # the closure's own framework-entered entry points (not the target: `unmod` speaks for it), with the route each + # serves when a decoration registers one, and one search for the tests that drive them + fw_ent, fw_grep = [], '' + _ent_ids = [m for m, _d in sorted(ent, key=lambda x: (x[1], g.disp(x[0]))) if m not in set(seeds) and not g.sym[m]['is_test']] if not tests else [] + if _ent_ids: + _routes = collections.defaultdict(set) + for rd, _f, _l, kind, key, _w in ax_registration.decoration_keys(g.q, g.site_file): + if kind == 'route' and rd in _ent_ids: _routes[rd].add(key) + for m in _ent_ids: + own = [sig for _i, sig in P.unmodelled_entry(g, [m]) if ' on its type ' not in sig and 'does not contain' not in sig] + if own or _routes.get(m): + fw_ent.append((m, (own[0].split(' on ')[0] if own else '') + (f' "{sorted(_routes[m])[0]}"' if _routes.get(m) else ''))) + pre = sorted({re.split(r'[{<:]', k)[0] for m, _ in fw_ent for k in _routes.get(m, ()) if len(re.split(r'[{<:]', k)[0]) > 1})[:4] + names = sorted({g.sym[m]['name'] for m, _ in fw_ent if g.sym[m].get('name')})[:4] + fw_grep = ("grep -rnF " + ' '.join(f"-e '\"{p_}'" for p_ in pre) + f" {P.grep_roots(g)}") if pre else \ + ("grep -rnw " + ' '.join(f'-e "{n}"' for n in names) + f" {P.grep_roots(g)}" if names else '') 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, 'also': contract_also.get(c, []), 'at': g.loc(c), 'for': sorted(contract_for[c])} for c, why in contract], @@ -1935,6 +2002,10 @@ def main(argv): '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]))], + 'framework_entries': [{'id': m, 'display': g.disp(m), 'signal': sig, 'at': g.loc(m)} for m, sig in fw_ent], + 'framework_grep': fw_grep, + 'stub_tests': [{'id': m, 'display': g.disp(m), 'owner': g.sym[m]['owner'], 'name': g.sym[m]['name'], 'at': g.loc(m), 'certainty': 'stubs it', + **({'mocks': mock_via[m]} if m in mock_via else {})} for m in stubbed], '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, **({'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', [])})], @@ -2135,6 +2206,14 @@ def main(argv): srch = (f'grep -rn "\\.{nm[0]}(" {P.grep_roots(g)}' if len(nm) == 1 else P.grep_for(g, nm[0]) if nm else '') print(f" +{len({c for c, *_ in collapsed})} [by name] (the same method name on an untyped receiver; may be another method)" + (f" — {srch} lists them" if srch else '') + "; --page all or --limit N prints each row") + # a test callable that only STUBS it on a mock names it and runs none of it: said apart, never as "reach it" + tstub = [r for r in tst if r[3] == 'stubs it' or r[2].startswith('stubs a method of this name')] + tst = [r for r in tst if r not in tstub] + if tstub and not want_tests: + sbyf = collections.Counter((r[4] or '').rsplit(':', 1)[0] for r in tstub if r[4]) + print(f" +{len({c for c, *_ in tstub})} test callable(s) in {len(sbyf)} file(s): " + ', '.join(f"{f} ({n})" for f, n in sbyf.most_common(4)) + + (' …' if len(sbyf) > 4 else '') + " only stub it on a mock (when/verify, Setup/Verify, Received/Returns): they name it and" + " run none of it, so they break on a rename or a new parameter and never on a body change") if tst and not want_tests: # the count names its files: a pointer that says only "+31" leaves the reader not knowing what was cut or # where, which is what the crowding rule exists to prevent (tests/cases/typescript/anonymous-crowding) @@ -2227,7 +2306,25 @@ def main(argv): 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}") + # …and the same for what REACHES it: an entry point of this closure that a framework enters (a route handler, a + # listener, a message handler) is driven by tests through the framework — a request, an event, a mediator send — + # and a route is counted only where a key or an edge joins the two. Said, with the search that finds those tests, + # rather than letting the count stand as if it were the whole set. + # only where the count is 0: beside counted tests it is one more line on every answer that crosses a web layer, and + # the claim it corrects ("no test reaches it") is not being made + if fw_ent and not tests: + shown = ', '.join(f"{g.disp(m)} {sig}" for m, sig in fw_ent[:3]) + (f" … +{len(fw_ent) - 3}" if len(fw_ent) > 3 else '') + print(f" NOT COUNTED: {len(fw_ent)} entry point(s) of this change are entered by a framework ({shown}) — a test that drives" + f" them through it (a request, an event, a mediator send) reaches the change with no route here unless a [by key] row" + f" says so, so the count is a lower bound; to find those tests: {fw_grep}") if weak_tests: print(f" {len(weak_tests)} of them only through a dependent that is not an exact edge") + if stubbed: + sbf = collections.Counter(g.sym[m]['file'] for m in stubbed) + mk = sorted({mock_via[m] for m in stubbed if m in mock_via}) + print(f" [stubs it] {len(stubbed)} more test(s) only stub it on a mock (when/verify, Setup/Verify, Received/Returns)" + + (f" or reach it only through a type they hold as a mock ({', '.join(mk[:3])}{' …' if len(mk) > 3 else ''})" if mk else '') + + " and never run it: not counted above, since a body change cannot fail them; a rename or a new parameter does — " + + ', '.join(f"{f} ({n})" for f, n in sbf.most_common(4)) + (f" … +{len(sbf) - 4}" if len(sbf) > 4 else '')) if tests: byc = collections.Counter(test_cert.values()) print(" how sure each route is: " + ', '.join(f"{n} {c}" for c, n in sorted(byc.items(), key=lambda x: CERT_RANK[x[0]])) + f" — {len(sure)} reach it through resolved single-target calls within 3 hops") @@ -2279,6 +2376,15 @@ def main(argv): else: print(f" {f} ({len(ms)}){via}: " + ', '.join(tname(m) for m in shown) + (f" … +{len(ms) - limit} more (--limit N)" if len(ms) > limit else '')) if not want_why: print(" --why adds each test's route (with --tests-in to keep it short)") + if want_tests and stubbed: + sfiles = collections.defaultdict(list) + for m in stubbed: + if not tests_in or tests_in in (g.sym[m]['file'] or ''): sfiles[g.sym[m]['file']].append(m) + if sfiles: + print(f" [stubs it] {sum(len(ms) for ms in sfiles.values())} test(s) in {len(sfiles)} file(s), not counted: each stubs it on a mock and never runs it" + " (they break on a rename or a new parameter, never on a body change):") + for f, ms in sorted(sfiles.items(), key=lambda kv: (-len(kv[1]), kv[0])): + print(f" {f} ({len(ms)}): " + ', '.join(tname(m) for m in ms[:limit]) + (f" … +{len(ms) - limit} more (--limit N)" if len(ms) > limit else '')) # --depends: the downstream direction (#761). `impact` walks callers only, so what the target itself calls is # never reported, and that is what an agent editing it has to change alongside. Same edges, read the other way; # the same certainty vocabulary, printed as its own section so the two questions are never confused. @@ -2454,6 +2560,7 @@ def main(argv): if decs: bits.append("decorations on it: @" + ', @'.join(decs[:5]) + " — a framework may call it with no call site, and a decorator can hand the wrapper to the caller instead of this declaration") if u: bits.append(f"{u} unresolved call(s) inside the impacted set") if tests: bits.append(f"{len(tests)} test(s) reach it") + if stubbed: bits.append(f"{len(stubbed)} test(s) stub it on a mock or hold its type as one ([stubs it] above): deleting it breaks the stubs they write, though no body change fails them") for b in bits: print(f" · {b}") if not bits and not calls and not contract and not weak and not ov and not ext and not fw_why: print(" · nothing — no by-name match, no literal, no decoration, no unresolved call inside") if not vis and g.has('methods'): print(" · visibility is not recorded in this graph, so exported surface cannot be told from internal") diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 8fb7cab13..404481d61 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -585,7 +585,8 @@ class G: # upgraded plugin keeps answering from the edges the old code exported (the graph is unchanged, so `index` says it # is up to date and nothing rebuilds). 2: defines needs a written, strictly wider span (#1402, #1399); 1 was the # bare mtime. - EXPORT_VERSION = '2' + # 3: a call written inside a mock's stub or verification is not an edge (ax_edges.stub_sites). + EXPORT_VERSION = '3' def export(self): stamp = os.path.join(self.facts, 'stamp'); want = f"{self.db_mtime}:{self.EXPORT_VERSION}" if os.path.exists(stamp) and open(stamp).read() == want: return @@ -593,7 +594,12 @@ class G: # `generated` is a client declaration too: a Lombok accessor, a record member, a C# auto-property. The engine # synthesises it and RESOLVES the call sites that name it, so filtering the edge out lost every call into a # generated member — which, on a project whose models are all @Data, is most of what touches the models. - edges = [(r['caller_id'], r['callee_method_id'], r['tier']) for r in self.q("SELECT caller_id, callee_method_id, tier FROM call_edges WHERE callee_method_id IS NOT NULL AND callee_provenance IN ('client', 'generated')")] + # …except a call written inside a mocking library's stub or verification (`when(repo.find(1))`, + # `mock.Setup(r => r.Find(1))`): the receiver is a mock, so the real body never runs there, and walked as a call + # it made every test that stubs a method a `sound` route to it. impact lists those sites as "stubs it". + stubs = ax_edges.stub_sites(lambda s, p: self.q(s, *p)) if self.has('call_sites') else set() + edges = [(r['caller_id'], r['callee_method_id'], r['tier']) for r in self.q("SELECT call_site_id, caller_id, callee_method_id, tier FROM call_edges WHERE callee_method_id IS NOT NULL AND callee_provenance IN ('client', 'generated')") + if r['call_site_id'] not in stubs] # calls into libraries: an edge to a terminal node (a library method has no out-edges here — nothing is inferred about its # body), so `path '*' java.nio.file.Files.readAllBytes` can ask who reaches that library call edges += [(r['caller_id'], r['callee_method_id'], 'library') for r in self.q("SELECT DISTINCT caller_id, callee_method_id FROM call_edges WHERE tier = 'boundary_lib' AND callee_method_id IS NOT NULL")] @@ -621,7 +627,7 @@ class G: have = {(a, b) for a, b, _ in edges} edges += [(r[0], r[1], 'dispatch') for r in self.q("SELECT DISTINCT c0, c1 FROM ext_fn_value_call") if (r[0], r[1]) not in have] self.write('edge', edges) - self.write('byname', [(r[0], r[1]) for r in self.q("SELECT DISTINCT s.caller_id, s.callee_name FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.callee_name IS NOT NULL AND s.callee_name <> ''")]) + self.write('byname', sorted({(r[1], r[2]) for r in self.q("SELECT s.id, s.caller_id, s.callee_name FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.callee_name IS NOT NULL AND s.callee_name <> ''") if r[0] not in stubs})) self.write('named', [(r['name'], r['id']) for r in self.sym.values() if r['method_id'] and r['kind'] != 'module' and r['name']]) t = f'{stamp}.{os.getpid()}.tmp'; open(t, 'w').write(want); replace_file(t, stamp) def write(self, name, rows, d=None): diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index f831fe5d9..77817e7ed 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -433,6 +433,8 @@ def main(argv): i = passthru.index('--in'); scope = ['--in', passthru[i + 1]] tests = {} # test id -> record + stub_only, stub_by = {}, collections.defaultdict(set) # tests that only stub a body-edited declaration + fw_entries, fw_greps = {}, [] # entry points a framework enters: their tests are not counted unreadable, notes = [], [] pulled = collections.defaultdict(set) unseen = [] @@ -457,6 +459,23 @@ def main(argv): if rid and rid not in tests: tests[rid] = dict(rec, hops=None, certainty='inherited') pulled[rid].add(batch[0]) + # a test that only STUBS the declaration on a mock (`when(repo.find(1))`, `mock.Setup(r => r.Find(1))`) runs + # none of it: impact does not count it, and a body edit cannot fail it. A signature edit or a removal can (the + # stub no longer compiles, or stubs a method nothing calls), so there it is selected, labelled as what it is. + for rec in d.get('framework_entries') or []: + fw_entries.setdefault(rec.get('id') or rec.get('display'), rec) + if d.get('framework_grep') and d['framework_grep'] not in fw_greps: fw_greps.append(d['framework_grep']) + # a batch of several declarations breaks a stub when any of them is a signature change or a removal + breaks = any((by_target[t].get('kind') or '') in STUB_BREAKING for t in batch) + bt = batch[0] if len(batch) == 1 else f"{len(batch)} declarations" + for rec in d.get('stub_tests') or []: + rid = rec.get('id') + if not rid: continue + if breaks: + if rid not in tests: tests[rid] = dict(rec, hops=None) + pulled[rid].add(bt) + else: + stub_only.setdefault(rid, rec); stub_by[rid].add(bt) files = sorted({r.get('at', '').split(':')[0] for r in tests.values() if r.get('at')}) # A selected test carrying no location drops out of `files`, and `files` is what the command PRINTS and @@ -495,6 +514,8 @@ def main(argv): 'command': cmd_all, 'abstract_replaced': replaced, 'not_reached_from': unseen, + 'framework_entries': list(fw_entries.values()), 'framework_grep': fw_greps, + 'stub_only_not_selected': [dict(r, stubs=sorted(stub_by[i])) for i, r in stub_only.items() if i not in tests], 'unreadable_answers': [t for t, _ in unreadable], 'same_package_only': pkg_hits, 'same_name_only': name_hits.get('files') or [], @@ -586,6 +607,18 @@ def main(argv): print(f"\n {', '.join(list(progs)[:4])} also run(s) as a program (a __main__ / main entry): a test that starts it " "as a subprocess, or drives it from case data, is not a call edge and no test names it by file — the " "selection is a lower bound for it") + if fw_entries and not tests: + fe = list(fw_entries.values()) + print(f"\n NOT COUNTED: {len(fe)} entry point(s) of the change are entered by a framework (" + + ', '.join(f"{r.get('display')} {r.get('signal') or ''}".strip() for r in fe[:3]) + (f" … +{len(fe) - 3}" if len(fe) > 3 else '') + + ") — a test that drives them through it (a request, an event, a mediator send) is not selected unless a [by key]" + " route joins it, so the selection is a lower bound; to find those tests: " + ' ; '.join(fw_greps[:2])) + so = [r for i, r in stub_only.items() if i not in tests] + if so: + sf = collections.Counter((r.get('at') or '').split(':')[0] for r in so) + print(f"\n not selected: {len(so)} test(s) only stub a changed declaration on a mock (when/verify, Setup/Verify," + " Received/Returns) and run none of it, so a body edit cannot fail them; a signature change or a removal" + " selects them: " + ', '.join(f"{f} ({n})" for f, n in sf.most_common(4)) + (f" … +{len(sf) - 4}" if len(sf) > 4 else '')) if unlocated: print(f"\n {len(unlocated)} selected test(s) carry no location, so they are named here rather than" f" in the list above and cannot appear in the command:") @@ -615,6 +648,11 @@ def main(argv): return 0 +# the change kinds (axiomcode changed) that break a test which only stubs the declaration: its stub is written +# against the name and the parameters +STUB_BREAKING = {'signature', 'removed'} + + def _guess_lang(files): ext = collections.Counter(os.path.splitext(f)[1] for f in files) if not ext: return '' diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 26bc7ff15..970c8da10 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -331,10 +331,14 @@ route_site(f, l) :- registration(_, f, l, "route", _, _). // fast path reads the same one, so the rules and the fast path cannot disagree about a tier — which // is how the two copies of the binary test drifted apart from the rest of the vocabulary to begin with. .decl cert_tier(tier:symbol, cert:symbol, why:symbol) .input cert_tier -direct(q, c, "uses", why, cert, f, l) :- target(q, "method", m, _), calls(c, m, t, f, l), t != "multi_inferred", cert_tier(t, cert, why), !bean_call(q, c, m), !route_site(f, l). +direct(q, c, "uses", why, cert, f, l) :- target(q, "method", m, _), calls(c, m, t, f, l), t != "multi_inferred", t != "stub", cert_tier(t, cert, why), !bean_call(q, c, m), !route_site(f, l). +// A call written inside a mock's stub or verification (`when(repo.find(1))`, `mock.Setup(r => r.Find(1))`): the exporter +// gives it the tier "stub" (ax_edges.stub_sites). It names the method, so a rename or a new parameter breaks it, and it +// runs nothing: the receiver is a mock. Its own row, whatever else the caller does; it is not an edge of the closure. +direct(q, c, "uses", why, cert, f, l) :- target(q, "method", m, _), calls(c, m, "stub", f, l), cert_tier("stub", cert, why). // a registration names the row: the edge is the engine's, the sentence is the registration's, and the // certainty stays the tier's — a route handed over as a value is still handed over as a value. -direct(q, c, "uses", why, cert, f, l) :- target(q, "method", m, _), calls(c, m, t, f, l), t != "multi_inferred", cert_tier(t, cert, _), !bean_call(q, c, m), registration(_, f, l, "route", _, why). +direct(q, c, "uses", why, cert, f, l) :- target(q, "method", m, _), calls(c, m, t, f, l), t != "multi_inferred", t != "stub", cert_tier(t, cert, _), !bean_call(q, c, m), registration(_, f, l, "route", _, why). // A call INTO a method of a container-managed bean. Whether the container's proxy is on the path decides whether a // behavioural annotation added to the method (a transaction, a cache, an async dispatch, a retry, an authorization // check) reaches this caller at all: a call from inside the bean goes through `this` and never through the proxy. @@ -342,24 +346,25 @@ direct(q, c, "uses", why, cert, f, l) :- target(q, "method", m, _), calls(c, m, .decl bean_call(q:symbol, c:symbol, m:symbol) // Not for an event either: the event system invokes a listener on the container's bean, so the proxy IS on that // path, and "an instance it obtained itself" would be false (#1391). It keeps its tier's own row above. -bean_call(q, c, m) :- target(q, "method", m, _), owner(m, ot), bean(_, ot, _), calls(c, m, t, _, _), t != "multi_inferred", t != "event_dispatch". +bean_call(q, c, m) :- target(q, "method", m, _), owner(m, ot), bean(_, ot, _), calls(c, m, t, _, _), t != "multi_inferred", t != "event_dispatch", t != "stub". // the type that the container hands the bean to: a field injection names the type, a constructor or setter names the method .decl receives_bean(t:symbol, into:symbol) receives_bean(t, into) :- injected(t, into, _), typ(into, _, _). receives_bean(t, into) :- injected(t, x, _), owner(x, into). direct(q, c, "uses", "calls it from inside the same bean — through this, not through the container proxy, so an added proxied annotation does NOT apply to this caller", "resolved", f, l) :- - bean_call(q, c, m), owner(m, ot), owner(c, ot), calls(c, m, _, f, l). + bean_call(q, c, m), owner(m, ot), owner(c, ot), calls(c, m, t, f, l), t != "stub". direct(q, c, "uses", "calls it on the injected bean — the container proxy is on the path, so an added proxied annotation applies here", "resolved", f, l) :- - bean_call(q, c, m), owner(m, ot), owner(c, cot), cot != ot, receives_bean(ot, cot), calls(c, m, _, f, l). + bean_call(q, c, m), owner(m, ot), owner(c, cot), cot != ot, receives_bean(ot, cot), calls(c, m, t, f, l), t != "stub". direct(q, c, "uses", "calls it on an instance it obtained itself, not from the container — no proxy on the path, so an added proxied annotation does not apply here", "resolved", f, l) :- - bean_call(q, c, m), owner(m, ot), owner(c, cot), cot != ot, !receives_bean(ot, cot), calls(c, m, _, f, l). + bean_call(q, c, m), owner(m, ot), owner(c, cot), cot != ot, !receives_bean(ot, cot), calls(c, m, t, f, l), t != "stub". direct(q, c, "uses", "calls it", "one of a set", f, l) :- target(q, "method", m, _), calls(c, m, "multi_inferred", f, l). // `!is_target_decl`: `this.rows.find(...)` inside `OrdersService.find` is the ARRAY's find, and with the receiver // untyped it matched the target by name — printing the declaration as a reader of itself. Worse, that row made the // change its own [by name] dependent, so `weak_seed` held for it and the whole answer said "12 of those 12 are // reached ONLY through a dependent that is not an exact edge": everything downstream looked untrustworthy because of // one self-match. A recursive call needs no row either — the body is what is changing. -direct(q, c, "uses", "calls a method of this name (receiver not typed)", "by name", f, l) :- target(q, "method", m, _), named(n, m), unresolved(c, n, k, f, l), !ctor_kind(k), !is_target_decl(q, c). +direct(q, c, "uses", "calls a method of this name (receiver not typed)", "by name", f, l) :- target(q, "method", m, _), named(n, m), unresolved(c, n, k, f, l), !ctor_kind(k), k != "stub", !is_target_decl(q, c). +direct(q, c, "uses", "stubs a method of this name on a mock (receiver not typed): the real method does not run there", "by name", f, l) :- target(q, "method", m, _), named(n, m), unresolved(c, n, "stub", f, l), !is_target_decl(q, c). // ── a method handed over as a VALUE, never called where it is named ────────────────────────────────────────── // `app.get('/orders/:id', getOrder)`, `useEffect(load)`, `items.map(format)`, `handlers = {"x": handle_x}`: the @@ -643,7 +648,7 @@ seed_of(q, c) :- target(q, "string", _, _), direct(q, c, _, _, cert, _, _), cert // a nested type reached through `import static Outer.Inner`, a dynamic receiver) MAY be a call to the target. It is // not a resolved edge, so it is kept out of seed / direct and enters only here, marked, so the answer can say so. .decl seed_byname(q:symbol, c:symbol) -seed_byname(q, c) :- target(q, "method", m, _), named(n, m), unresolved(c, n, _, _, _), !seed(q, c). +seed_byname(q, c) :- target(q, "method", m, _), named(n, m), unresolved(c, n, k, _, _), k != "stub", !seed(q, c). // and a declaration handed over as a VALUE — `Depends(get_settings)`, `add_task(send_receipt, id)`, an element of // a pipeline list. `valueref` already produces the DIRECT row for these ("names it as a value — passed, stored or // registered"), and the closure stopped there: the answer named the callable that hands it over and then said the @@ -739,10 +744,33 @@ key_use_fan(k, n) :- key_written(_, k), n = count : { key_written(_, k) }. key_capped(k) :- reg_key_fan(k, n), key_cap(c), n > c. key_capped(k) :- key_use_fan(k, n), key_use_cap(c), n > c. .output key_capped -.decl fw_edge(a:symbol, b:symbol, how:symbol) -fw_edge(a, b, "by key") :- literal(a, k, _, _), reg_key(b, _, k), a != b, !key_capped(k). +// THE HTTP METHOD, where both sides name one (ax_registration.route_verbs / literal_verbs): a test that POSTs "/orders" +// drives the handler that answers POST, not the one beside it that answers GET. A side that names none joins as before. +// The writer cap is counted per registration over the writers whose method fits it, so a path five tests write, one +// verb each, is not refused on behalf of a handler only two of them can reach; a key with no verb anywhere counts +// exactly as before. +// reg_verb(b, k, v) route k registered by b answers the HTTP method v +// lit_verb(a, k, v) a writes the path k as the argument of a request call for the method v +.decl reg_verb(b:symbol, k:symbol, v:symbol) .input reg_verb +.decl lit_verb(a:symbol, k:symbol, v:symbol) .input lit_verb +.decl key_join(a:symbol, b:symbol, k:symbol, w:symbol) +key_join(a, b, k, k) :- literal(a, k, _, _), reg_key(b, _, k), a != b. // the same, where the two spellings of the path differ: /orders/o-1/price written, /orders/{order_id}/price registered -fw_edge(a, b, "by key") :- literal(a, w, _, _), route_alias(w, r), reg_key(b, _, r), a != b, !key_capped(r). +key_join(a, b, r, w) :- literal(a, w, _, _), route_alias(w, r), reg_key(b, _, r), a != b. +.decl verb_fits(a:symbol, b:symbol, k:symbol, w:symbol) +verb_fits(a, b, k, w) :- key_join(a, b, k, w), !reg_verb(b, k, _). +verb_fits(a, b, k, w) :- key_join(a, b, k, w), !lit_verb(a, w, _). +verb_fits(a, b, k, w) :- key_join(a, b, k, w), lit_verb(a, w, v), reg_verb(b, k, v). +.decl reg_capped(k:symbol) +reg_capped(k) :- reg_key_fan(k, n), key_cap(c), n > c. +// the writers of the key as registered (an alias spelling is not counted, as before). A helper relation, not +// `count : { verb_fits(_, b, k, k) }`: Soufflé 2.5 counts an aggregate atom with a repeated variable as 1. +.decl exact_fit(a:symbol, b:symbol, k:symbol) +exact_fit(a, b, k) :- verb_fits(a, b, k, k). +.decl use_fan_for(b:symbol, k:symbol, n:number) +use_fan_for(b, k, n) :- reg_key(b, _, k), n = count : { exact_fit(_, b, k) }. +.decl fw_edge(a:symbol, b:symbol, how:symbol) +fw_edge(a, b, "by key") :- verb_fits(a, b, k, _), !reg_capped(k), use_fan_for(b, k, n), key_use_cap(c), n <= c. // a decorator that REBINDS THE NAME: `@audited def summarise` leaves `summarise` denoting the wrapper, so every // caller written with that name runs the wrapper. This one is the engine's own resolution, not a name match. fw_edge(a, b, "decorator") :- calls(a, m, _, _, _), decorated_name(m, b), a != b. @@ -897,6 +925,26 @@ test_near(q, m, d) :- test_hit(q, m, d, _), d = min x : test_hit(q, m, x, _). .output test_near .output test_hit +// ── a test that only STUBS the change: named, and never counted as running it ───────────────────────────────── +// `when(repo.find(1))`, `verify(repo).save(x)`, `mock.Setup(r => r.Find(1))` name a method the closure reaches and run +// none of it. The tier "stub" keeps them out of `edge`, so they are not routes; this says which tests hold one, so a +// signature change still finds them. The callable that writes the stub (a test, a lambda in it, a setUp, a helper the +// setUp calls) is walked up through the same edges as the closure, and a test is found from it by the same three +// shapes a route ends in: the test itself, a fixture that runs before the tests of its type, a helper of that type. +.decl stub_at(q:symbol, x:symbol) +stub_at(q, x) :- reach(q, c, _), calls(x, c, "stub", _, _). +// A stub site the engine could not resolve (a Moq lambda whose parameter it did not type) is only a NAME: it leaves +// seed_byname, so it is no route, and it is not listed here either, since the same name is often another type's method. +.decl stub_near(q:symbol, a:symbol) +stub_near(q, x) :- stub_at(q, x). +stub_near(q, o) :- stub_at(q, x), lex_in(o, x). +stub_near(q, a) :- stub_near(q, b), edge(a, b, _). +.decl test_stub(q:symbol, m:symbol) +test_stub(q, m) :- stub_near(q, m), test_method(m). +test_stub(q, m) :- stub_near(q, fx), fixture(fx), !test_method(fx), owner(fx, t), scope(t, s), member(s, m, _, _), test_method(m). +test_stub(q, m) :- stub_near(q, c), !test_method(c), !fixture(c), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m), decl_file(c, f), is_test_file(f). +.output test_stub + // ── bound from outside the source ────────────────────────────────────────────────────────────────────────────── // A name written in a file the index does not read as source: an XML mapper, a META-INF/services file, a // properties or yaml file, a build script, a spec. The binding is made by name at run time, so the compiler is diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 03ac38556..cc05d78ab 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -157,10 +157,15 @@ def _at(f_, l_): # (`callback_registered`) and a capped fan-out (`fan_capped`) as `resolved` — the strongest claim # the tool makes — while the rules, which read the same column, now do not (#1131). A caller with # several sites is named once, under the best of them. + # A call inside a mock's stub or verification is `stubs it`, and is not walked below: the same set the rules + # and the path export read (ax_edges.stub_sites). + stubs = ax_edges.stub_sites(lambda s_, p_: q(s_, p_).fetchall()) + con.execute("CREATE TEMP TABLE _stub(id TEXT PRIMARY KEY)") + con.executemany("INSERT OR IGNORE INTO _stub VALUES(?)", [(x,) for x in stubs]) _reads = collections.defaultdict(set) - for d, tier in q(f"""SELECT DISTINCT s.display, ce.tier FROM call_edges ce JOIN symbols s ON s.id=ce.caller_id + for d, tier, sid in q(f"""SELECT DISTINCT s.display, ce.tier, ce.call_site_id FROM call_edges ce JOIN symbols s ON s.id=ce.caller_id WHERE ce.callee_method_id IN ({ph})""", ids): - _reads[d].add(ax_edges.direct_cert(tier)) + _reads[d].add(ax_edges.direct_cert(ax_edges.STUB_TIER if sid in stubs else tier)) # …and the end a FRAMEWORK hands it over from (#1509): a task's .delay() producer, a signal's sender, a route # table, a Depends() default. The rules list it as a `framework` dependent, so this does too, or the hook # line and `impact` name different dependents for the same declaration. A direct row only, as in the rules: @@ -176,7 +181,7 @@ def _at(f_, l_): short = target.rsplit('.', 1)[-1] byname = sorted({r[0] for r in q( """SELECT DISTINCT s.display FROM call_sites cs JOIN unresolved_sites us ON us.call_site_id=cs.id - JOIN symbols s ON s.id=cs.caller_id WHERE cs.callee_name=?""", (short,))} - set(reads)) + JOIN symbols s ON s.id=cs.caller_id WHERE cs.callee_name=? AND cs.id NOT IN (SELECT id FROM _stub)""", (short,))} - set(reads)) # the two counts. Each edge table joins in its OWN recursive branch so SQLite drives them by index; building # one combined edge CTE first scans all 608k edges per call (1.89 s against 0.02 s for the same answer). # THE DISPATCH HOP IS NARROWED, the same way the RULES narrow it. `edge.facts` is written by @@ -209,7 +214,8 @@ def _at(f_, l_): SELECT DISTINCT base_method_id b, candidate_method_id c FROM dispatch_candidates""") con.execute("CREATE INDEX _disp_c ON _disp(c)") rec = ("SELECT value, 0 FROM json_each(?)\n" - " UNION SELECT ce.caller_id, r.d+1 FROM call_edges ce JOIN r ON ce.callee_method_id=r.id WHERE r.d '' AND COALESCE(s.id, o.id) IS NOT NULL"""): if t != ot: continue recv.add(tgt if tgt in istype else type_of(tgt)) - # a bean call site, any tier but the set and an event: the event system invokes a listener on the container's + # a bean call site, any tier but the set, a stub and an event: the event system invokes a listener on the container's # bean, so the proxy IS on that path and "an instance it obtained itself" would be false (#1391) - callers = {c for c, tier, _f, _l in sites if tier not in ('multi_inferred', 'event_dispatch')} + callers = {c for c, tier, _f, _l in sites if tier not in ('multi_inferred', 'event_dispatch', ax_edges.STUB_TIER)} why = {} for c in callers: cot = type_of(c) @@ -742,6 +750,9 @@ def type_of(sid): return callers, why +STUB_BYNAME_WHY = 'stubs a method of this name on a mock (receiver not typed): the real method does not run there' + + def direct_for_method(q, ids, code=None, rel=None, at=None, lines=None): """direct(q,c,role,why,cert,f,l) for a method target — the rows the answer groups by *why* and *how sure*. @@ -757,15 +768,20 @@ def direct_for_method(q, ids, code=None, rel=None, at=None, lines=None): rows = [] # ordered by site line: when a caller has several call sites the answer names one of them, and the rules name # the lowest. Leaving the order to the table printed a different site (254 against 257) for the same caller. - sites = q(f"""SELECT e.caller_id, e.tier, s.file_path, s.start_line + # a site inside a mock's stub or verification carries the tier "stub", exactly as the rules' `calls` fact does + stubs = ax_edges.stub_sites(lambda s_, p_: q(s_, *p_)) + sites = [(c, ax_edges.STUB_TIER if sid in stubs else t, f, l) for sid, c, t, f, l in q( + f"""SELECT e.call_site_id, e.caller_id, e.tier, s.file_path, s.start_line FROM call_edges e LEFT JOIN call_sites s ON s.id=e.call_site_id WHERE e.callee_method_id IN ({ph}) AND e.callee_provenance='client' - ORDER BY s.start_line""", *ids) + ORDER BY s.start_line""", *ids)] bean_callers, why_of = _bean_call(q, ids, sites) routes = {(f, l): w for _d, f, l, k, _key, w in ax_registration.registrations(q, rel) if k == 'route'} for c, tier, f, l in sites: cert = ax_edges.direct_cert(tier) - if tier == 'multi_inferred': + if tier == ax_edges.STUB_TIER: + rows.append((c, 'uses', ax_edges.DIRECT_WHY[cert], cert, f or '', l or 0)) + elif tier == 'multi_inferred': # rule 208 carries no `!bean_call` guard, so a multi_inferred site stays `one of a set` even into a bean rows.append((c, 'uses', 'calls it', 'one of a set', f or '', l or 0)) elif c not in bean_callers: # `… , !bean_call(q, c, m)` @@ -778,15 +794,15 @@ def direct_for_method(q, ids, code=None, rel=None, at=None, lines=None): # …and for a caller into a container-managed bean, EVERY site of it — the three bean rules end in a bare # `calls(c, m, _, f, l)` with no tier test, so a multi_inferred site of a bean caller is a row here too. for c, tier, f, l in sites: - if c in bean_callers: rows.append((c, 'uses', why_of[c], 'resolved', f or '', l or 0)) + if c in bean_callers and tier != ax_edges.STUB_TIER: rows.append((c, 'uses', why_of[c], 'resolved', f or '', l or 0)) names = {r[0] for r in q(f"SELECT name FROM symbols WHERE id IN ({ph})", *ids) if r[0]} seen = {r[0] for r in rows} for n in names: - for c, f, l, kind in q("""SELECT s.caller_id, s.file_path, s.start_line, s.kind FROM call_sites s + for sid, c, f, l, kind in q("""SELECT s.id, s.caller_id, s.file_path, s.start_line, s.kind FROM call_sites s JOIN unresolved_sites u ON u.call_site_id=s.id WHERE s.callee_name=?""", n): if kind in ('new', 'anon_new', 'CONSTRUCTOR_CALL'): continue # !ctor_kind(k) if c in ids: continue # !is_target_decl(q, c) - rows.append((c, 'uses', 'calls a method of this name (receiver not typed)', 'by name', f or '', l or 0)) + rows.append((c, 'uses', STUB_BYNAME_WHY if sid in stubs else 'calls a method of this name (receiver not typed)', 'by name', f or '', l or 0)) # the declaration handed over as a VALUE — a route registration, a callback — which has no call site at all # (the valueref / registered rules). The convention table is shared with the rules, in ax_registration.py, so # the two backends cannot disagree about what a registration is. @@ -1498,6 +1514,49 @@ class itself). The generated-member rules look here, so an accessor is found whi GEN_DECOR = set(GENERATED) # the names alone, where only membership matters +def stub_tests(q, depth, sets, stubs, rev=None): + """test_stub(q,m) (impact.dl): a test that stubs a reached callable on a mock, and so names it without running it. + The callable that writes the stub, and every callable lexically around it (a lambda's writer), is walked up the + same edges as the closure; a test is found from what that reaches as a route ends: the test itself, a fixture of + its type or a base type, or a helper of that type declared in a test file.""" + if not depth: return [] + tm, fx = sets + ids = list(depth); xs = set() + if stubs: + for k in range(0, len(ids), 400): + part = ids[k:k + 400] + for sid, c in q(f"SELECT call_site_id, caller_id FROM call_edges WHERE callee_method_id IN ({','.join('?' * len(part))})", *part): + if sid in stubs: xs.add(c) + if not xs: return [] + near = set(xs) + for x, f, a, b in q(f"SELECT id, file, line, end_line FROM symbols WHERE id IN ({','.join('?' * len(xs))})", *xs): + if not f: continue + near |= {r[0] for r in q("SELECT id FROM symbols WHERE file = ? AND line <= ? AND end_line >= ? AND id <> ?", f, a, b or a, x)} + frontier = list(near) + while frontier and rev is not None: + nxt = [] + for b in frontier: + for a, _t in rev.get(b, ()): + if a not in near: near.add(a); nxt.append(a) + frontier = nxt + info = {} + ph = ','.join('?' * len(near)) + for i, o, f in q(f"SELECT id, owner, file FROM symbols WHERE id IN ({ph})", *near): info[i] = (o, f) + tfiles = {r[0] for r in q("SELECT DISTINCT file FROM symbols WHERE is_test = 1 AND file IS NOT NULL")} + out = {m for m in near if m in tm} + owners = {info[x][0] for x in near if x in info and info[x][0] and x not in tm and (x in fx or info[x][1] in tfiles)} + if owners: + sub = set(owners) + if _has(q, 'type_ancestors'): + tid = {r[0]: r[1] for r in q("SELECT display, type_id FROM symbols WHERE type_id IS NOT NULL AND method_id IS NULL")} + disp = {v: k for k, v in tid.items()} + want = {tid[o] for o in owners if o in tid} + sub |= {disp[r[0]] for r in q("SELECT type_id, ancestor_type_id FROM type_ancestors") if r[1] in want and r[0] in disp} + for m, o in q("SELECT id, owner FROM symbols WHERE method_id IS NOT NULL AND owner IS NOT NULL"): + if m in tm and o in sub: out.add(m) + return sorted(out) + + def inherited_tests(q, hits): """`inherited_test(q,s,m,d) :- test_hit(q,m,d,_), owner(m,t), extends(s,t), typ(s,_,_), s != t` — the test classes that extend a class whose test was reached run that test too, in their own file.""" @@ -2544,9 +2603,9 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non # them. Without this the answer prints the machine's absolute path where the rules print src/main/java/… rel = site_file or (lambda x: x) out = {k: [] for k in ('contract', 'direct', 'direct_edge', 'seed', 'seed_byname', 'reach', 'reach_sure', - 'parent_up', 'test_near', 'test_hit', 'inherited_test', 'extbind', 'gen_fired', + 'parent_up', 'test_near', 'test_hit', 'test_stub', 'inherited_test', 'extbind', 'gen_fired', 'caller_handles', 'caller_unhandled', 'target_throws')} - E = _edges(q); rev = _rev(E); sets = _test_sets(q, lines, rel) + E = _edges(q); rev = _rev(E); sets = _test_sets(q, lines, rel); stubs = ax_edges.stub_sites(lambda s_, p_: q(s_, *p_)) for qq in QS: # A query can carry SEVERAL target kinds at once: a name match that hits both a method and a field # resolves to both, and the rules simply union what each kind derives. Dispatch per kind and union here @@ -2581,7 +2640,8 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non seeds |= set(mids) | {c for c, _ in con} de += q(f"""SELECT DISTINCT caller_id, callee_method_id FROM call_edges WHERE callee_method_id IN ({mph}) AND callee_provenance='client'""", *mids) - byname = sorted({c for c, _r, _w, cert, _f, _l in d if cert == 'by name' and not ax_registration.is_value_why(_w)} - seeds) + byname = sorted({c for c, _r, _w, cert, _f, _l in d if cert == 'by name' and not ax_registration.is_value_why(_w) + and _w != STUB_BYNAME_WHY} - seeds) if 'type' in by_kind: tids = sorted(by_kind['type']) @@ -2797,6 +2857,7 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non hits = sorted(tests_reaching(q, depth, sets, every=True), key=lambda r: (r[0], r[1], r[2])) out['test_hit'] += [[m, str(d), via, qq] for m, d, via in hits] out['inherited_test'] += [[s_, m, str(d), qq] for s_, m, d in inherited_tests(q, hits)] + out['test_stub'] += [[m, qq] for m in stub_tests(q, depth, sets, stubs, rev=rev)] # gen_fired(q,d,why) — WHICH decoration or shape made the generated rules apply, so the answer can say # why it believes in members that have no declaration. # 143 type · 144 clinit · 145 field (through its owner) · 146 method (through its owner) diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index e7da7e45b..9e6e516d9 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -58,6 +58,7 @@ An answer's label is the **worst** rung on its route. Read it before acting on t | `[defines]` · `[protocol]` · `[decorator by name]` | closure from its definer · interpreter-called method · wrapper rebinding the name | | `[fixture]` · `[at import]` | injected before the test body · module raised on import, test never collected | | `[by key]` | joined through a registration **string** (route, signal, CLI command) — not an edge | +| `[stubs it]` | a call written inside a mock's stub or verification (`when(m.f())`, `verify(m).f()`, `Setup(x => x.F())`, `Received().F()`): names it, runs none of it — never a test route, listed apart | | `[in scope]` · `[by name]` · `[text]` | same name in the owner's scope · same name elsewhere (may be another thing) · text only | Below `[sound]` / `[one of a set]` the order is a tie-break, not a measured ranking. `[sound]` means the edges diff --git a/skills/axiomcode/reference/changed-and-tests.md b/skills/axiomcode/reference/changed-and-tests.md index 906e52d4c..074ac4321 100644 --- a/skills/axiomcode/reference/changed-and-tests.md +++ b/skills/axiomcode/reference/changed-and-tests.md @@ -95,3 +95,9 @@ the number in front of you. Skipping what it does not name is a decision about r you: a test reached only through reflection, a service loader, a subprocess, or a case built at runtime does not appear here (the `[text]` tier above recovers the ones whose test names the file it loads). +A test that only **stubs** a changed declaration on a mock (`when(repo.find(1))`, `mock.Setup(r => r.Find(1))`) is not +selected for a body edit: it runs none of the body. It is named on a `not selected:` line, and it is selected when the +change is a signature change or a removal, which breaks the stub. A test that reaches the change only through a +framework-entered entry point (an HTTP route, an event, a mediator send) is named on a `NOT COUNTED` line, with the +search that finds it, unless a `[by key]` route already joined it. + diff --git a/skills/axiomcode/reference/impact.md b/skills/axiomcode/reference/impact.md index de376f493..cf2eb57e2 100644 --- a/skills/axiomcode/reference/impact.md +++ b/skills/axiomcode/reference/impact.md @@ -179,6 +179,18 @@ line and names the constructor query to run; take that suggestion before acting of what a service's suite does. The two spellings of a path are matched segment by segment (`/orders/o-1/price` against `/orders/{order_id}/price`, ``, `:id`), never normalised. It is **not** an edge the engine resolved and is never shown as one: the hop is `[by key]`, and a literal can be a same-valued other thing. +- **a stub on a mock is NOT a hop** — `when(repo.find(1))`, `verify(repo).save(x)`, `doReturn(v).when(repo).find(1)`, + `mock.Setup(r => r.Find(1))`, `mock.Verify(...)`, `sub.Received().Find(1)`, `sub.Find(1).Returns(v)`: the engine + resolves the call to the declared method, which is right about the name and wrong about execution, since the receiver + is a mock. Such a site is marked by its position against the mocking library's own call (a knob table per language in + `scripts/ax_edges.py`, `STUB_WRAPPERS`), and it is a `[stubs it]` row: a rename or a new parameter breaks it, a body + change never does. It is kept out of the closure, so a test whose only contact is a stub is not counted under `tests:`; + it is listed on its own `[stubs it]` line, and `test-impact` selects it only for a signature change or a removal. A call + in the stub's ARGUMENT list (`when(repo.find(Ids.first()))`) runs for real and stays a route. A test that drives the + class under test with a mock injected still counts through the class under test: the graph cannot see which object + is injected. An entry point of the change that a framework enters (a route handler, a listener) is named on a + `NOT COUNTED` line with the search that finds the tests driving it, since those are counted only where a `[by key]` + route joins them. - **a decorator that rebinds the name is a hop** — `@audited def summarise(…)` leaves `summarise` denoting what `audited(summarise)` RETURNED, so every caller written with that name runs the wrapper. That is the engine's own resolution (`ext_decorated_name_target`), not a name match, so the hop is `[sound]`; without it a `functools.wraps` diff --git a/tests/cases/csharp/mock-stub-is-not-a-test-route/Shop.Tests/PricesTests.cs b/tests/cases/csharp/mock-stub-is-not-a-test-route/Shop.Tests/PricesTests.cs new file mode 100644 index 000000000..731b67120 --- /dev/null +++ b/tests/cases/csharp/mock-stub-is-not-a-test-route/Shop.Tests/PricesTests.cs @@ -0,0 +1,77 @@ +using System.Threading.Tasks; +using Moq; +using NSubstitute; +using Shop; +using VerifyXunit; +using Xunit; + +namespace Shop.Tests; + +public class MoqStubTests +{ + private readonly Mock _prices = new Mock(); + + public MoqStubTests() + { + _prices.Setup(p => p.Price(1)).Returns(5); + } + + [Fact] + public void StubsInTheConstructor() + { + Assert.NotNull(_prices.Object); + } + + [Fact] + public void VerifiesTheStub() + { + _prices.Verify(p => p.Price(1)); + } +} + +public class SubstituteStubTests +{ + private readonly Prices _prices = Substitute.For(); + + [Fact] + public void StubsWithReturns() + { + _prices.Price(Ids.First()).Returns(4); + _prices.Received().Price(2); + } +} + +public class RealPriceTests +{ + [Fact] + public void RunsThePrice() + { + Assert.Equal(2, new Prices().Price(1)); + } + + [Fact] + public void TotalsWithTheRealPrice() + { + Assert.Equal(3, new Checkout(new Prices()).Total(1)); + } +} + +public class CheckoutWithMockTests +{ + private readonly Mock _prices = new Mock(); + + [Fact] + public void TotalsWithAMockedPrice() + { + Assert.Equal(1, new Checkout(_prices.Object).Total(1)); + } +} + +public class SnapshotTests +{ + [Fact] + public async Task VerifiesASnapshot() + { + await Verifier.Verify(new Prices().Price(3)); + } +} diff --git a/tests/cases/csharp/mock-stub-is-not-a-test-route/Shop/Prices.cs b/tests/cases/csharp/mock-stub-is-not-a-test-route/Shop/Prices.cs new file mode 100644 index 000000000..924339947 --- /dev/null +++ b/tests/cases/csharp/mock-stub-is-not-a-test-route/Shop/Prices.cs @@ -0,0 +1,32 @@ +namespace Shop; + +public class Prices +{ + public virtual int Price(int id) + { + return id * 2; + } +} + +public class Ids +{ + public static int First() + { + return 1; + } +} + +public class Checkout +{ + private readonly Prices _prices; + + public Checkout(Prices prices) + { + _prices = prices; + } + + public int Total(int id) + { + return _prices.Price(id) + 1; + } +} diff --git a/tests/cases/csharp/mock-stub-is-not-a-test-route/case.json b/tests/cases/csharp/mock-stub-is-not-a-test-route/case.json new file mode 100644 index 000000000..127aef5bc --- /dev/null +++ b/tests/cases/csharp/mock-stub-is-not-a-test-route/case.json @@ -0,0 +1,61 @@ +{ + "lang": "csharp", + "checks": [ + { + "why": "a call written in a Moq Setup/Verify lambda or on an NSubstitute Received()/Returns() runs none of the real method, in a test or in the test class's constructor: those tests are not counted, and the tests that call the real object, directly or through the class under test, are. A stub the engine resolved is listed apart; one in a lambda whose parameter it did not type is only a name, and is neither counted nor listed", + "run": [ + "impact", + "Prices.Price", + "--tests" + ], + "want": [ + "tests: 3 of 7 test method(s)", + "RealPriceTests::RunsThePrice", + "RealPriceTests::TotalsWithTheRealPrice", + "[stubs it] 2 test(s) in 1 file(s), not counted", + "SubstituteStubTests::StubsWithReturns", + "CheckoutWithMockTests::TotalsWithAMockedPrice" + ], + "avoid": [ + "[fixture]", + "MoqStubTests::", + "[by name] 1 test", + "[by name] 2 test" + ] + }, + { + "why": "near miss: a snapshot library's Verify(value) takes no lambda, so the call that builds its argument runs and the test is counted", + "run": [ + "impact", + "Prices.Price", + "--tests" + ], + "want": [ + "SnapshotTests::VerifiesASnapshot" + ] + }, + { + "why": "near miss: a call in the stubbed call's argument list runs for real to build the argument", + "run": [ + "impact", + "Ids.First", + "--tests" + ], + "want": [ + "tests: 1 of 7", + "SubstituteStubTests::StubsWithReturns" + ] + }, + { + "why": "the stubs stay dependents of the declaration, as stubs, so a rename still finds them", + "run": [ + "impact", + "Prices.Price" + ], + "want": [ + "only stub it on a mock", + "[resolved] Checkout.Total" + ] + } + ] +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/case.json b/tests/cases/java/mock-stub-is-not-a-test-route/case.json new file mode 100644 index 000000000..c5f7fecb2 --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/case.json @@ -0,0 +1,90 @@ +{ + "lang": "java", + "src": "src", + "checks": [ + { + "why": "a call written inside when(...), verify(x).m() or doReturn(v).when(x).m() on a mock runs none of the real method, and neither does a route through a type the test class holds as a @Mock: those tests are not counted, and a test that calls the real object, directly or through the class under test, is", + "run": [ + "impact", + "PriceService.price", + "--tests" + ], + "want": [ + "tests: 2 of 6 test method(s)", + "PriceServiceTest::runsThePrice", + "CheckoutTest::totalsWithTheRealPrice", + "[stubs it] 3 test(s) in 3 file(s), not counted", + "CheckoutStubTest::stubsThePrice", + "FixtureStubTest::usesTheStubbedPrice", + "CheckoutWithMockTest::totalsWithAMockedPrice", + "a type they hold as a mock (PriceService)" + ], + "avoid": [ + "[sound] 3", + "[sound] 4", + "[fixture]" + ] + }, + { + "why": "near miss: the class under test is not itself mocked, so the mock-holding test still reaches it", + "run": [ + "impact", + "Checkout.total", + "--tests" + ], + "want": [ + "tests: 2 of 6", + "CheckoutWithMockTest::totalsWithAMockedPrice", + "CheckoutTest::totalsWithTheRealPrice" + ] + }, + { + "why": "the stub stays a dependent of the declaration: a rename or a new parameter breaks it, so it is listed, as a stub and not a call", + "run": [ + "impact", + "PriceService.price" + ], + "want": [ + "only stub it on a mock", + "[resolved] Checkout.total" + ] + }, + { + "why": "near miss: a call written in the stub's ARGUMENT list runs for real to build the argument, so the stubbing test reaches it", + "run": [ + "impact", + "Ids.first", + "--tests" + ], + "want": [ + "tests: 1 of 6", + "CheckoutStubTest::stubsThePrice" + ] + }, + { + "why": "near miss: an HTTP test DSL's when() is a request step, not a stub, so a client call in its receiver chain is a route", + "run": [ + "impact", + "Payloads.make", + "--tests" + ], + "want": [ + "tests: 1 of 6", + "RequestTest::postsAPayload" + ] + }, + { + "why": "a stub is not a hop of a chain either: the stubbing test has no path to the real method", + "run": [ + "path", + "CheckoutStubTest.stubsThePrice", + "PriceService.price" + ], + "expect_error": true, + "avoid": [ + "CheckoutStubTest.stubsThePrice \u2192 PriceService.price" + ], + "want": [] + } + ] +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Checkout.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Checkout.java new file mode 100644 index 000000000..35af28e59 --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Checkout.java @@ -0,0 +1,13 @@ +package shop; + +public class Checkout { + private final PriceService prices; + + public Checkout(PriceService prices) { + this.prices = prices; + } + + public int total(int id) { + return prices.price(id) + 1; + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutStubTest.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutStubTest.java new file mode 100644 index 000000000..87cf69f8b --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutStubTest.java @@ -0,0 +1,19 @@ +package shop; + +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +public class CheckoutStubTest { + @Mock private PriceService prices; + + @Test + public void stubsThePrice() { + when(prices.price(Ids.first())).thenReturn(5); + verify(prices).price(1); + doReturn(3).when(prices).price(2); + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutTest.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutTest.java new file mode 100644 index 000000000..4f09f55ec --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutTest.java @@ -0,0 +1,12 @@ +package shop; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +public class CheckoutTest { + @Test + public void totalsWithTheRealPrice() { + assertEquals(3, new Checkout(new PriceService()).total(1)); + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutWithMockTest.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutWithMockTest.java new file mode 100644 index 000000000..194eec1a8 --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutWithMockTest.java @@ -0,0 +1,15 @@ +package shop; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +public class CheckoutWithMockTest { + @Mock private PriceService prices; + + @Test + public void totalsWithAMockedPrice() { + assertEquals(1, new Checkout(prices).total(1)); + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/FixtureStubTest.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/FixtureStubTest.java new file mode 100644 index 000000000..52690baed --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/FixtureStubTest.java @@ -0,0 +1,28 @@ +package shop; + +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +public class FixtureStubTest { + @Mock private PriceService prices; + + @BeforeEach + public void setUp() { + stubPrices(); + } + + private void stubPrices() { + when(prices.price(7)).thenReturn(0); + } + + @Test + public void usesTheStubbedPrice() { + assertStubbed(); + } + + private void assertStubbed() { + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Ids.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Ids.java new file mode 100644 index 000000000..125db5ddc --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Ids.java @@ -0,0 +1,7 @@ +package shop; + +public class Ids { + public static int first() { + return 1; + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Payloads.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Payloads.java new file mode 100644 index 000000000..c04c801ff --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Payloads.java @@ -0,0 +1,7 @@ +package shop; + +public class Payloads { + public static String make() { + return "{}"; + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/PriceService.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/PriceService.java new file mode 100644 index 000000000..58731b080 --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/PriceService.java @@ -0,0 +1,7 @@ +package shop; + +public class PriceService { + public int price(int id) { + return id * 2; + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/PriceServiceTest.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/PriceServiceTest.java new file mode 100644 index 000000000..942b28ca1 --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/PriceServiceTest.java @@ -0,0 +1,13 @@ +package shop; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +public class PriceServiceTest { + @Test + public void runsThePrice() { + PriceService prices = new PriceService(); + assertEquals(2, prices.price(1)); + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/RequestTest.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/RequestTest.java new file mode 100644 index 000000000..83621ebbf --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/RequestTest.java @@ -0,0 +1,12 @@ +package shop; + +import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; + +import org.junit.jupiter.api.Test; + +public class RequestTest { + @Test + public void postsAPayload() { + given().body(Payloads.make()).when().post("/orders").then().statusCode(200); + } +} diff --git a/tests/cases/java/route-under-type-prefix-and-verb/case.json b/tests/cases/java/route-under-type-prefix-and-verb/case.json new file mode 100644 index 000000000..c1fb67b9e --- /dev/null +++ b/tests/cases/java/route-under-type-prefix-and-verb/case.json @@ -0,0 +1,13 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a handler's route is its type's @RequestMapping prefix and its own mapping, and it is joined only to the requests that send its HTTP method: the three GETs of /orders drive list, the two POSTs do not", + "run": ["impact", "OrdersController.list", "--tests"], + "want": ["tests: 3 of 6 test method(s)", "OrdersListTest::listsOrders", "[by key]"], + "avoid": ["OrdersCreateTest"]}, + {"why": "near miss: the same path under the other verb reaches the other handler, and five tests writing one path are not refused as a key that identifies nothing, since each handler is written by fewer than the cap", + "run": ["impact", "OrdersController.create", "--tests"], + "want": ["tests: 2 of 6 test method(s)", "OrdersCreateTest::createsAnOrder", "OrdersCreateTest::rejectsAnEmptyOrder"], + "avoid": ["OrdersListTest", "OrdersCreateTest::addsALine"]}, + {"why": "a relative method path joins the type's prefix: @PostMapping(\"lines\") under /orders serves /orders/lines", + "run": ["impact", "OrdersController.addLine", "--tests"], + "want": ["tests: 1 of 6", "OrdersCreateTest::addsALine"]}]} diff --git a/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersController.java b/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersController.java new file mode 100644 index 000000000..758e470f3 --- /dev/null +++ b/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersController.java @@ -0,0 +1,25 @@ +package shop; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/orders") +public class OrdersController { + @GetMapping + public String list() { + return "[]"; + } + + @PostMapping + public String create() { + return "{}"; + } + + @PostMapping("lines") + public String addLine() { + return "{}"; + } +} diff --git a/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersCreateTest.java b/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersCreateTest.java new file mode 100644 index 000000000..0301211e1 --- /dev/null +++ b/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersCreateTest.java @@ -0,0 +1,22 @@ +package shop; + +import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; + +import org.junit.jupiter.api.Test; + +public class OrdersCreateTest { + @Test + public void createsAnOrder() { + given().body("{}").when().post("/orders").then().statusCode(201); + } + + @Test + public void rejectsAnEmptyOrder() { + given().body("").when().post("/orders").then().statusCode(422); + } + + @Test + public void addsALine() { + given().body("{}").when().post("/orders/lines").then().statusCode(201); + } +} diff --git a/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersListTest.java b/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersListTest.java new file mode 100644 index 000000000..8b2fc7762 --- /dev/null +++ b/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersListTest.java @@ -0,0 +1,22 @@ +package shop; + +import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; + +import org.junit.jupiter.api.Test; + +public class OrdersListTest { + @Test + public void listsOrders() { + given().when().get("/orders").then().statusCode(200); + } + + @Test + public void listsOrdersAgain() { + given().when().get("/orders").then().statusCode(200); + } + + @Test + public void listsOrdersPaged() { + given().param("page", 2).when().get("/orders").then().statusCode(200); + } +}