diff --git a/graph/python/engine/resolution/generics.dl b/graph/python/engine/resolution/generics.dl index 20e9e93e..964f502a 100644 --- a/graph/python/engine/resolution/generics.dl +++ b/graph/python/engine/resolution/generics.dl @@ -279,6 +279,13 @@ param_class_object_bound("client", ph, t) :- type_ref_nesting("client", r, _, "1", child), type_ref("client", "TYPE_VAR", _, vn, _, child), vn != "", typevar_bound("client", vn, t). +// …and the same under a union (`cls: type[CmdType] | None = None`): see param_class_object_ref. +param_class_object_bound("client", ph, t) :- + param_class_object_ref("client", ph, s), + type_ref_owner("client", ph, "METHOD_PARAM", r), s != r, + type_ref_nesting("client", s, _, _, child), + type_ref("client", "TYPE_VAR", _, vn, _, child), vn != "", + typevar_bound("client", vn, t). expr_type_class_object("client", e, t) :- expr_names_param("client", e, ph), param_class_object_bound("client", ph, t). diff --git a/graph/python/engine/resolution/value-flow.dl b/graph/python/engine/resolution/value-flow.dl index 6fd224af..51b73f51 100644 --- a/graph/python/engine/resolution/value-flow.dl +++ b/graph/python/engine/resolution/value-flow.dl @@ -450,6 +450,22 @@ expr_type_class_object(p, e, t) :- annotation_names_a_class("type"). annotation_names_a_class("Type"). +// ── param_class_object_ref(Prov, ParamHash, SubscriptRef) ───────────────────── +// The `type[...]` subscript a parameter's annotation IS, or one operand of its union is. +// `cls: type[Command] | None = None` (and `Optional[type[Command]]`) is the default-None +// spelling of the same class-object parameter: the body replaces None with a default class +// and calls `cls(...)`. Read only at the top level, that call stayed unresolved, and so did +// every constructor reached through it. +param_class_object_ref("client", ph, r) :- + type_ref_owner("client", ph, "METHOD_PARAM", r), + type_ref("client", _, "METHOD_PARAM", tn, _, r), + annotation_names_a_class(tn). +param_class_object_ref("client", ph, s) :- + type_ref_owner("client", ph, "METHOD_PARAM", r), + union_operand("client", r, s), + type_ref("client", _, _, tn, _, s), + annotation_names_a_class(tn). + expr_type_class_object("client", e, t) :- expr_names_param("client", e, ph), type_ref_owner("client", ph, "METHOD_PARAM", r), @@ -457,6 +473,12 @@ expr_type_class_object("client", e, t) :- annotation_names_a_class(tn), type_ref_nesting("client", r, _, "1", child), type_ref_resolved("client", t, child). +expr_type_class_object("client", e, t) :- + expr_names_param("client", e, ph), + param_class_object_ref("client", ph, s), + type_ref_owner("client", ph, "METHOD_PARAM", r), s != r, + type_ref_nesting("client", s, _, _, child), + type_ref_resolved("client", t, child). // A name written MORE THAN ONCE takes the union, for the same reason the instance side // does: a sound set beats a blank, and the tier follows from the target count. diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index 08cc0035..db011f11 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -244,6 +244,7 @@ .decl binding_element_lib_type(c0:symbol,c1:symbol) .decl param_declared_type_by_parser(c0:symbol,c1:symbol,c2:symbol) .decl union_operand(c0:symbol,c1:symbol,c2:symbol) +.decl param_class_object_ref(c0:symbol,c1:symbol,c2:symbol) .decl type_ref_element(c0:symbol,c1:symbol,c2:symbol) .decl annotation_owner_module(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl annotation_container_kind(c0:symbol) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py index 6eb56111..0167e2c4 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py @@ -671,12 +671,17 @@ def _string_literals(q): # 3. a string that names a MEMBER OF A TYPE THE SAME DECORATION NAMES: `@SelectProvider(type = StockSql.class, # method = "byShelf")` points at StockSql.byShelf; it is a reference to that method, not a key for this one. # Only decided with the graph (`names_member(type, name)`); without it the string is kept. -_STRING = re.compile(r'"([^"]{1,120})"|\'([^\']{1,120})\'') +# 4. a string that names a PARAMETER OF THE DECLARATION IT DECORATES: `@option("--params", "-p", "params")` on +# `def main(url, params)` binds the value a caller passes after `--params` to `params`. The flags are what a caller +# writes to reach the declaration; the parameter name is written by every function that builds a dict with a +# `params` key, and joined as a key it made each of them a caller of the command. +# 5. a string inside ANOTHER call in the decoration: `type=File("wb")` configures a value, it names nothing registered. +_STRING =re.compile(r'"([^"]{1,120})"|\'([^\']{1,120})\'') _KEYWORD_BEFORE = re.compile(r'(\w+)\s*[=:]\s*[\[{(]?\s*(?:(?:"[^"]*"|\'[^\']*\')\s*,\s*)*$') _TYPE_ARG = re.compile(r'(? 1: continue + if params and key in params: continue out.add(key) return sorted(out) +def _call_depth(t, i): + """how many parentheses are open at offset i of a decoration's text, strings blanked: 1 is the decoration's own + argument list""" + return _STRING.sub(lambda m: '"' + ' ' * (len(m.group(0)) - 2) + '"', t[:i]).count('(') - \ + _STRING.sub(lambda m: '"' + ' ' * (len(m.group(0)) - 2) + '"', t[:i]).count(')') + + +def _params_of(signature): + """the parameter names a `name(a, b=1, *c)` signature declares""" + m = re.search(r'\((.*)\)', signature or '') + if not m: return set() + return {re.sub(r'[:=].*$', '', p).strip().lstrip('*') for p in m.group(1).split(',')} - {''} + + def member_names(q): """names_member for decoration_key_strings, read from the graph: does a type of this simple name declare a member of that name""" @@ -720,13 +741,14 @@ def decoration_keys(q, site_file=None): # production code; nothing is lost by declining to read a test's own decoration as a registration. tests = {r[0] for r in q("SELECT id FROM symbols WHERE is_test = 1")} if _has(q, 'symbols') else set() members = member_names(q) + sigs = dict(q("SELECT id, signature FROM symbols WHERE signature IS NOT NULL")) if _has(q, 'symbols') else {} out = [] for owner, name, text, f, l in q("""SELECT owner_id, name, text, file, line FROM decorations WHERE text IS NOT NULL AND text <> '' AND owner_id IS NOT NULL"""): if owner in tests: continue short = (name or '').split('.')[-1] - for key in decoration_key_strings(text, name, members): + for key in decoration_key_strings(text, name, members, _params_of(sigs.get(owner))): kind = 'route' if key.startswith('/') else 'key' why = (f'registered as a route "{key}" by @{short} — the router calls it, no call site does' if kind == 'route' else f'registered under "{key}" by @{short} — whoever writes that string reaches it, and no call site does') diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 9f3a4100..c62505c0 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -799,7 +799,9 @@ class Impact: # REPOSITORY is kept: a stdlib or third-party import names no file here and is dropped rather than guessed. IMPORT_RE = { '.py': re.compile(r'^\s*(?:from\s+(\.*[\w.]*)\s+import|import\s+([\w.]+))', re.M), - '.ts': re.compile(r"""(?:^\s*import\b[^'"\n]*from\s*|^\s*export\b[^'"\n]*from\s*|\brequire\s*\(\s*)['"]([^'"]+)['"]""", re.M), + # `import x from`, `export … from`, `require(`, and the two forms that load a module for its effect or later: + # `import './locale/fr'` (a side-effect import names no binding, so it has no `from`) and `import('./x')` + '.ts': re.compile(r"""(?:^\s*import\b[^'"\n]*from\s*|^\s*export\b[^'"\n]*from\s*|^\s*import\s*|\brequire\s*\(\s*|\bimport\s*\(\s*)['"]([^'"]+)['"]""", re.M), } IMPORT_RE['.tsx'] = IMPORT_RE['.js'] = IMPORT_RE['.jsx'] = IMPORT_RE['.mjs'] = IMPORT_RE['.cjs'] = IMPORT_RE['.ts'] @@ -826,6 +828,9 @@ class Impact: if n.endswith('/__init__'): mod.setdefault(n[:-len('/__init__')].replace('/', '.'), f) out = set() + # a JavaScript file that loads a module by a COMPUTED specifier (`require(path.join(dir, file))`, `import(name)`): + # what it loads is not in these facts, so a closure through it is not complete (imports_dynamic) + self._dyn_import = set() # pytest imports every conftest.py from the rootdir down to a test file's directory before the file itself, # so whatever a conftest imports is imported for each test file beneath it conftests = [f for f in files if f.rsplit('/', 1)[-1] == 'conftest.py'] @@ -844,6 +849,7 @@ class Impact: if not rx: continue try: text = '\n'.join(self.lines(f)) except Exception: continue + if ext != '.py' and self.DYN_IMPORT.search(text): self._dyn_import.add(f) for m in rx.finditer(text): raw = next((x for x in m.groups() if x), '') if not raw: continue @@ -866,12 +872,40 @@ class Impact: if tgt and tgt != f: out.add((f, tgt)) return sorted(out) + DYN_IMPORT = re.compile(r"""(? 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 = '71' # 71: (70 on its branch) imports_fact resolves a JavaScript directory import (`require('..')`, a folder with a package.json); imports_dynamic, the files that load a module by a computed name, and load_run (dl), test code that reaches the change through calls alone; 70: fn_returns / fn_returns_param / decorated_call (a by-name decorator hop lands on the wrapper the decorator returns), a decoration string naming a parameter of the declaration is no registration key; 69: a library-callback site is a library receiver only where a hand-back from it is summarised (ax_edges.library_receiver_sites); 68: both 67s below, from two branches; 67: spawns_fact also links the module a `python -m pkg.mod` spawn runs; 67: libcb, a library summarised to call a client member back (Python library-callbacks.dl), walked as fw_edge "library callback"; 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): 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 @@ -1402,7 +1448,7 @@ class Impact: if stack: lex.append((i, stack[-1][2])) stack.append((a, b, i)) W('lex_parent', sorted(set(lex))) - W('imports_fact', self.imports()) + W('imports_fact', self.imports()); W('imports_dynamic', sorted((f,) for f in self._dyn_import)) # a servlet filter a test's context holds runs on every request that test sends (ax_registration.filter_links) W('filter_run', sorted(set(ax_registration.filter_links(g.q)))) # a test that runs a script as a child process by its path: the path is the only link (ax_spawn.py) @@ -1745,6 +1791,18 @@ class Impact: a, b = bym.get(r[0]), bym.get(r[1]) if a and b and a != b: deconame.append((a, b)) W('decorated_name', sorted(set(deconame))) + # …and where the engine could NOT resolve the decoration, what the decorator the name denotes RETURNS, so the + # by-name decorator hop (dl/impact.dl `deco_wrapper`) lands on the wrapper the callers run, not on the + # decorator's own body: `fn_returns(m, w)` m returns the function w, `fn_returns_param(m)` m returns an argument + # it was handed (the registering `return f`), `decorated_call(s, d)` the decoration is written as a call + # (`@route("/x")`: the decorator applied is what `route` returned, not `route`). + bym = {sy['method_id']: i for i, sy in g.sym.items() if sy.get('method_id')} + W('fn_returns', sorted({(bym[a], bym[b]) for a, b in (g.q("SELECT c1, c2 FROM ext_method_returns_method") if g.has('ext_method_returns_method') else []) + if a in bym and b in bym and a != b})) + W('fn_returns_param', sorted({(bym[a],) for (a,) in (g.q("SELECT c1 FROM ext_method_returns_param") if g.has('ext_method_returns_param') else []) + if a in bym})) + W('decorated_call', sorted({(r[0], r[1].split('.')[-1]) for r in (g.q("SELECT owner_id, name, text FROM decorations") if g.has('decorations') else []) + if r[1] and re.match(r'\s*@?\s*[\w.]+\s*\(', r[2] or '')})) # ── the two spellings of one route ────────────────────────────────────────────────────────────────────── # A test asks for `/orders/o-1/price`; the handler is registered as `/orders/{order_id}/price`. Neither # string contains the other, and no call site joins them — the router does, at run time, by matching the @@ -2215,7 +2273,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', 'test_stub', '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', 'load_run'): rows = [] for l in open(os.path.join(O, n + '.csv')): p = l.rstrip('\n').split('\t') @@ -2866,29 +2924,39 @@ def main(argv): # resolved to a table in a service the test never starts -- is a lead further out, not a test of the change: on # one monorepo 19 test files were selected for a handler table's module and 2 of them import it. None where the # language has no import graph here (imports_fact covers Python, JavaScript and TypeScript). - _rev = None; _importers = {} + _rev = None; _importers = {}; _fwd = None; _dyn = set() + # THE FILE ASKED ABOUT, not the end of the nearest route. That route is the shortest one, and the closure is a + # union over every caller: a test reaches `Option`'s constructor through a subclass ANOTHER test file declares + # (`class MyOption extends Option`), whose file it never loads, while the route it really takes is one hop + # longer. Read off the route's end, the test was said never to load the change; read off the asked declaration's + # own file, it loads it through the package entry like every other test. + tgt_files = {g.sym[x]['file'] for _k, _lab, pay in targets if isinstance(pay, list) for x in pay + if isinstance(x, str) and x in g.sym and g.sym[x].get('file')} def loads_change(m, fx): - nonlocal _rev + nonlocal _rev, _fwd tf = g.sym[m].get('file') or '' route = test_route(m, fx) seed_file = (g.sym.get(route[-1]) or {}).get('file') if route else None if not tf or not seed_file or os.path.splitext(tf)[1].lower() not in I.IMPORT_RE: return None - if tf == seed_file or test_cert.get(m) in ('spawns', 'at import'): return True + if tf == seed_file or tf in tgt_files or test_cert.get(m) in ('spawns', 'at import'): return True if _rev is None: - _rev = collections.defaultdict(set) + _rev = collections.defaultdict(set); _fwd = collections.defaultdict(set) try: for ln in open(os.path.join(g.facts, 'impact', 'imports_fact.facts'), encoding='utf-8', errors='replace'): a, _, b = ln.rstrip('\n').partition('\t') - if a and b: _rev[b].add(a) + if a and b: _rev[b].add(a); _fwd[a].add(b) + _dyn.update(ln.rstrip('\n') for ln in open(os.path.join(g.facts, 'impact', 'imports_dynamic.facts'), encoding='utf-8', errors='replace')) except OSError: _rev = {} # no import facts: nothing can be said either way if not _rev: return None - if seed_file not in _importers: - seen, todo = {seed_file}, [seed_file] - while todo: - for a in _rev.get(todo.pop(), ()): - if a not in seen: seen.add(a); todo.append(a) - _importers[seed_file] = seen + def importers(f): + if f not in _importers: + seen, todo = {f}, [f] + while todo: + for a in _rev.get(todo.pop(), ()): + if a not in seen: seen.add(a); todo.append(a) + _importers[f] = seen + return _importers[f] loaders = {tf} | ({g.sym[fx].get('file')} if fx and fx in g.sym else set()) if tf.endswith('.py'): d_ = os.path.dirname(tf) @@ -2896,7 +2964,18 @@ def main(argv): loaders.add(f"{d_}/conftest.py" if d_ else 'conftest.py') if not d_: break d_ = os.path.dirname(d_) - return bool(loaders & _importers[seed_file]) + files_ = tgt_files or {seed_file} + # a file NOTHING here imports is loaded some other way (a plugin required by a computed name, a bundle a + # runner config names), and a test file that imports nothing here reads the package through a path alias + # this scan does not resolve: in both the import graph cannot say, so it says nothing + if not any(len(importers(f)) > 1 for f in files_) or not any(_fwd.get(x) for x in loaders): return None + # and where the test, or a file it loads, loads a module by a computed name, it may load the changed one + seen, todo = set(loaders), list(loaders) + while todo: + for y in _fwd.get(todo.pop(), ()): + if y not in seen: seen.add(y); todo.append(y) + if seen & _dyn: return None + return any(loaders & importers(f) for f in files_) # a MODULE counted as a test is a script test (graph_sql.script_tests): it is labelled with the command it runs by script_ids = {m for m in tests if g.sym[m]['kind'] == 'module'} _runs = {} @@ -3069,6 +3148,35 @@ def main(argv): if CERT_RANK[c] > CERT_RANK[worst]: worst = c return worst test_cert = {m: cert_of(m) for m in tests} + # RUNS WHILE THE FILE LOADS: the route is an import (`at import`), or the test is credited through its file's module + # body or the describe() block around it and that block reaches the change through calls alone (load_run), so the + # change runs while the runner loads the file, before any test + load_run = {x[0] for x in res.get('load_run', [])} + _DESCRIBE = re.compile(r'(? 4 else '')) + if not_loading: + nlf = sorted({g.sym[m]['file'] for m in not_loading}) + print(f" not counted: {len(nlf)} test file(s) reach it only through code they never load — none of their imports, followed" + " through the project, imports the changed file (a callback another test file hands over, a plugin they never import): " + + ', '.join(nlf[:4]) + (f" … +{len(nlf) - 4}" if len(nlf) > 4 else '')) # A PROGRAM THE SUITES START AS A SUBPROCESS (a CLI script, a hook, a subcommand its dispatcher execs): no test # calls it, so the count is 0, and a reader took that as untested and ran nothing if not tests: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index dfdf5441..1ab1480f 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1887,10 +1887,49 @@ def grep_for(g, name): """the exact search that finds what the graph could not: the name as a whole word in the source roots""" return f'grep -rnw "{name}" {grep_roots(g)}' +# ── a JavaScript test file that never loads the target ──────────────────────────────────────────────────────────── +JS_EXT = ('.js', '.mjs', '.cjs', '.jsx') +def never_loads(g, A, B): + """(test file, target files) when every start is in ONE JavaScript test file whose imports, followed through the + project, never reach a file the target is declared in; else None. Such a file cannot run the target, whatever route + the call graph has: the route goes through a callable another test file declares (a callback handed to a shared + function) or an override a plugin installs only where it is loaded. Read from impact's import facts + (imports_fact), only when they were written for this graph; where either end has no import at all the scan cannot + say (a path alias, a file loaded by a computed name), and nothing is gated. impact drops the same tests the same way.""" + fa = {g.sym[m].get('file') for m in A if m in g.sym} + fb = {g.sym[m].get('file') for m in B if m in g.sym} - {None, '', ''} + if len(fa) != 1 or not fb: return None + fa = next(iter(fa)) + if not fa or not fa.endswith(JS_EXT) or fa in fb or not all(g.sym[m].get('is_test') for m in A if m in g.sym): return None + D = os.path.join(g.facts, 'impact') + try: + if not open(os.path.join(D, 'stamp')).read().startswith(f"{g.db_mtime}:"): return None + fwd, rev = collections.defaultdict(set), collections.defaultdict(set) + for ln in open(os.path.join(D, 'imports_fact.facts'), encoding='utf-8', errors='replace'): + x, _, y = ln.rstrip('\n').partition('\t') + if x and y: fwd[x].add(y); rev[y].add(x) + dyn = {ln.rstrip('\n') for ln in open(os.path.join(D, 'imports_dynamic.facts'), encoding='utf-8', errors='replace')} + except OSError: + return None + if not fwd.get(fa) or not all(rev.get(f) for f in fb): return None + seen, todo = {fa}, [fa] + while todo: + for y in fwd.get(todo.pop(), ()): + if y not in seen: seen.add(y); todo.append(y) + # a file on the way that loads a module by a computed name (`require(path.join(dir, f))`) may load the target + return (fa, sorted(fb)) if not (seen & fb) and not (seen & dyn) else None + # ── the query──────────────────────────────────────────────────────────────────────────────────────────────────── def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): g.export() la, A = g.resolve(a); lb, B = g.resolve(b) + nl = never_loads(g, A, B) + if nl: + RESULT['never_loads'] = {'test_file': nl[0], 'target_files': nl[1]} + print(f"no route: {nl[0]} never loads {', '.join(nl[1])} — none of the files it imports, followed through the project," + f" imports it, so no run of that test executes {lb}. The call graph's routes between them go through code that" + f" file never loads (a callback another test file hands to a shared function, an override a plugin installs).") + return 1 g.add_outside_call_edges() res = run(g, {'fwd': (A, B), 'rev': (B, A)}, ('path.dl', 'path-every.dl') if every else ('path.dl',)) if not res['hit']['fwd'] and not res['hit']['rev']: # nothing resolved connects them: now, and only now, the by-name closure diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index 29e9f97e..2d29b424 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -1425,12 +1425,12 @@ def _script_for(repo, pkg, prel): return None -def js_plan(repo, files): +def js_plan(repo, files, cap=40): """(commands, files no runner collects): each file with the runner its own package would run it with.""" by_runner = collections.OrderedDict() singles, unrun = [], [] repo = os.path.realpath(repo) - for f in sorted(files)[:40]: + for f in (sorted(files)[:cap] if cap else sorted(files)): # the nearest directory whose runner collects the file runs it: a member's own config first, then the # root's (a workspace member with no runner of its own is run by the root's, with paths from the root) hit, any_pkg, d = None, False, os.path.dirname(f) @@ -1990,6 +1990,10 @@ def main(argv): # say it back, not a declaration the graph lacks. `Owner.m(p)` still refused is asked as `Owner.m` (the tests # that reach the method are the ones a parameter edit can fail); a batch still refused is asked one by one, so # one bad target does not cost the others their tests + # a JavaScript function that is also a constructor (`function MarkdownIt(opts)`, `function Route(path)`) is a type + # to `changed` and has no type declaration impact can name at that line: it is asked as the function it is + if rc != 0 and tk == 'type': + rc, o, e = run('axiomcode-impact', batch + [repo, '--tests', '--json'] + scope) if rc != 0 and len(batch) == 1 and '(' in batch[0]: bare = re.sub(r'\(.*\)$', '', batch[0]) rc, o, e = run('axiomcode-impact', [bare, repo, '--tests', '--json', '--kind', 'method'] + scope) @@ -2067,6 +2071,74 @@ def main(argv): ownerless = [r for r in tests.values() if not r.get('owner') and r.get('at') and not r.get('script')] # a script is its file run_files = sorted(set(files) | set(edited)) lang = (changed.get('lang') or '').lower() or _guess_lang(run_files or changed_files) + # A SELECTED JAVASCRIPT FILE NO RUNNER COLLECTS is not a test run: a benchmark or interop script under test/ that no + # include glob or package script names (`test/autobahn.js` beside `test/*.test.js`), or a file the runner only + # reaches through another test file that requires it (`require('./es2017/asyncFunctions.js')`). It is replaced by + # the collected test files that import it, through the project's imports, and where none does it moves to + # `not_collected` and out of the selection. Where a test file loads modules by a computed name + # (`require(path.join(dir, f))`) nothing can be said, and the file stays. + rehomed = {} + if lang == 'javascript' and (files or further): + sel = sorted(set(files) | set(further)) + unrun = [f for f in js_plan(repo, sel, cap=None)[1] if f.endswith(('.js', '.mjs', '.cjs', '.jsx'))] + if unrun: + D = os.path.join(os.environ.get('AXIOMCODE_GRAPH') or ax_fresh.graph_dir(repo_), 'out', 'dl', 'impact') + rev = collections.defaultdict(set); dyn = None + try: + for ln in open(os.path.join(D, 'imports_fact.facts'), encoding='utf-8', errors='replace'): + a, _, b = ln.rstrip('\n').partition('\t') + if a and b: rev[b].add(a) + dyn = {ln.rstrip('\n') for ln in open(os.path.join(D, 'imports_dynamic.facts'), encoding='utf-8', errors='replace')} + except OSError: + dyn = None # no import facts: say nothing, move nothing + # a collected test file that loads modules by a computed name (`fs.readdirSync(dir).forEach(f => require(f))`) + # may be what runs a file nothing requires by its path: such a file stays + dyn_runner = dyn is not None and any(not js_plan(repo, [x], cap=None)[1] and graph_sql.TEST_REGISTRAR.search(_read(os.path.join(repo, x), 400_000) or '') + for x in sorted(dyn) if x.endswith(('.js', '.mjs', '.cjs', '.jsx'))) + if dyn is not None: + for f in unrun: + seen, todo = {f}, [f] + while todo: + for a in rev.get(todo.pop(), ()): + if a not in seen: seen.add(a); todo.append(a) + if seen & dyn: continue + ups = sorted(seen - {f}) + if not ups and dyn_runner: continue + runs = [u for u in ups if not js_plan(repo, [u], cap=None)[1] and graph_sql.TEST_REGISTRAR.search(_read(os.path.join(repo, u), 400_000) or '')] + rehomed[f] = runs + if rehomed: + new_near = [x for x in files if x not in rehomed] + [r for f in files if f in rehomed for r in rehomed[f]] + new_far = [x for x in further if x not in rehomed] + [r for f in further if f in rehomed for r in rehomed[f]] + files = sorted(set(new_near)); further = sorted(set(new_far) - set(files)) + run_files = sorted(set(files) | set(edited)) + # A CHANGE THAT RUNS WHILE MOCHA LOADS THE SPECS FAILS THE WHOLE RUN. mocha requires every spec file, and runs every + # describe() body, in ONE process before it runs a single test; a throw there aborts the run and every spec file + # in it fails, the ones that never touch the change included. A test reached at load time -- through its file's + # module body (an `at import` route, or the module as the route) or through the describe() block around it -- is + # therefore every file that mocha run collects, not just its own. jest and vitest load each file in its own + # module registry, where the same throw fails that file alone, so only a mocha run is widened. + load_wide = {} + if lang == 'javascript' and tests: + for f in sorted({(r.get('at') or '').rpartition(':')[0] for r in tests.values() if r.get('at_load')} - {''}): + d = os.path.dirname(f); hit = None + while hit is None: + drel = f[len(d) + 1:] if d else f + hit = next(((d, name, collects) for name, collects, _c in _runners_of(repo, d) if collects(drel)), None) + if not d: break + d = os.path.dirname(d) + if not hit or hit[1] != 'mocha' or hit[0] in load_wide: continue + base = os.path.join(repo, hit[0]) if hit[0] else repo + run_list = [] + for root, dirs, fs in os.walk(base): + dirs[:] = [x for x in dirs if x not in ('node_modules', '.git', '.axiomcode', 'dist', 'coverage') and not x.startswith('.')] + for x in fs: + rel = os.path.relpath(os.path.join(root, x), base).replace(os.sep, '/') + if x.endswith(('.js', '.mjs', '.cjs', '.jsx', '.ts')) and hit[2](rel): + run_list.append(f"{hit[0]}/{rel}" if hit[0] else rel) + if len(run_list) > 1: load_wide[hit[0]] = sorted(run_list) + if load_wide: + files = sorted(set(files) | {x for v in load_wide.values() for x in v}); further = sorted(set(further) - set(files)) + run_files = sorted(set(files) | set(edited)) # the graph `impact` answered from, asked again for which selected classes are abstract (command_for) db = os.path.join(os.environ.get('AXIOMCODE_GRAPH') or ax_fresh.graph_dir(repo_), 'out', 'graph.sqlite') # the classes the command names: every owner, and a test that has none by its file (a class that inherits a @@ -2090,7 +2162,9 @@ def main(argv): 'edited_test_files': edited, 'command': cmd_all, 'abstract_replaced': replaced, - 'not_collected': js_plan(repo, run_files)[1] if lang in ('typescript', 'javascript') else [], + 'not_collected': sorted(set(js_plan(repo, run_files)[1] if lang in ('typescript', 'javascript') else []) | set(rehomed)), + **({'run_by_importer': {f: r for f, r in rehomed.items() if r}} if any(rehomed.values()) else {}), + **({'whole_run_at_load': {d or '.': len(v) for d, v in load_wide.items()}} if load_wide else {}), '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], @@ -2195,6 +2269,17 @@ def main(argv): if cmd_all: print("\n " + cmd_all.replace("\n", "\n ")) print_unrun(lang, repo, run_files) + for d_, v_ in sorted(load_wide.items()): + print(f"\n the change runs while mocha loads the specs (a module body or a describe() block reaches it), and a throw" + f" there fails the whole run: every one of the {len(v_)} spec file(s) mocha collects in {d_ or '.'} is listed") + moved = {f: r for f, r in rehomed.items() if r}; gone = sorted(f for f, r in rehomed.items() if not r) + if moved: + print(f"\n {len(moved)} file(s) the graph reached are run only through the test files that require them, which are" + " listed instead: " + '; '.join(f"{f} <- {', '.join(r[:2])}" + (f" +{len(r) - 2}" if len(r) > 2 else '') for f, r in sorted(moved.items())[:4]) + + (f" … +{len(moved) - 4}" if len(moved) > 4 else '')) + if gone: + print(f"\n not selected: {len(gone)} file(s) under the tests reach it but no runner collects them and no test file requires" + " them (a script or benchmark beside the tests): " + ', '.join(gone[:4]) + (f" … +{len(gone) - 4}" if len(gone) > 4 else '')) if cmd_further: print("\n then, further out: " + cmd_further.replace("\n", "\n ")) elif edited and cmd_all: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 6c5f1978..47041a86 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -957,6 +957,19 @@ up(q, a, d+1) :- up_running(q, b, d), import_hop(q, a, b), d < 40. parent_up(q, a, b, "at import") :- reach(q, a, d), d > 0, reach(q, b, d1), d1 = d - 1, import_hop(q, a, b). .output reach .output parent_up +// WHAT RUNS WHILE A TEST FILE LOADS: a declaration in test code that reaches the change through calls alone (a resolved +// call or one of a resolved set; never `defines`, never a hand-over, which runs later or not at all), so a module body +// or a describe() block that is one of these runs the change before any test does. A mocha run requires every spec and +// runs every describe() in one process, and a throw there fails the whole run (axiomcode-test-impact reads this as +// `at_load` on the test it credits). Walked only as far as test code: the first test-file declaration ends the walk. +.decl load_up(q:symbol, m:symbol) +load_up(q, m) :- seed(q, m). +load_up(q, a) :- load_up(q, b), !load_stop(b), edge(a, b, t), (t = "known_edge" ; t = "multi_inferred"), !state_gate(a, b, _). +.decl load_stop(m:symbol) +load_stop(m) :- decl_file(m, f), is_test_file(f), !kind(m, "module"). +.decl load_run(q:symbol, m:symbol) +load_run(q, m) :- load_up(q, m), decl_file(m, f), is_test_file(f). +.output load_run // the same closure from the seeds that are an exact edge only: what is reached ONLY through a [by name] / [text] / // [one of a set] dependent is everything outside it, and the answer says how much of itself rests on those @@ -1069,7 +1082,35 @@ fw_edge(a, b, "spawns") :- spawns_fact(a, b), a != b. .decl dec_decl(d:symbol, w:symbol) dec_decl(d, w) :- decorated(_, d), named(d, w), 1 = count : { named(d, _) }. dec_decl(d, w) :- decorated(s, d), named(d, w), decl_file(s, f), decl_file(w, f). -fw_edge(a, b, "decorator by name") :- decorated(a, d), dec_decl(d, b), a != b. +// WHO RUNS WHAT THE DECORATOR RETURNED, NOT WHO WAS DECORATED. That hop, taken from the decorated declaration to +// the decorator's body, made the decorated function a caller of the decorator: `@bp.route("/") def index` put `index`, +// and through it every test that requests "/", above everything `route` calls. Nothing runs `route` when `index` is +// called: the decoration ran it once, where the `def` is. What a caller of the decorated NAME runs is whatever the +// decorator RETURNED for it: a wrapper (`def wrapper(*a): … return f(*a)`, which may reach the change), or the +// function itself (`return f`, a registration), which adds nothing. Where the decoration is written as a call +// (`@route("/")`), the decorator applied is what `route` returned, and it is THAT one's return the name denotes. +// fn_returns(m, w) m returns the function w (Python ext_method_returns_method) +// fn_returns_param(m) m returns an argument it was handed +// decorated_call(s, d) s carries the decoration d written as a call +// Where what the decorator returns is unknown, the hop stays as it was: the walk keeps a route it cannot judge. +.decl fn_returns(m:symbol, w:symbol) .input fn_returns +.decl fn_returns_param(m:symbol) .input fn_returns_param +.decl decorated_call(s:symbol, d:symbol) .input decorated_call +.decl deco_applied(s:symbol, x:symbol) +deco_applied(s, b) :- decorated(s, d), dec_decl(d, b), !decorated_call(s, d). +deco_applied(s, x) :- decorated(s, d), dec_decl(d, b), decorated_call(s, d), fn_returns(b, x). +.decl deco_wrapper(s:symbol, w:symbol) +deco_wrapper(s, w) :- deco_applied(s, x), fn_returns(x, w), w != s. +.decl deco_known(s:symbol, d:symbol) +deco_known(s, d) :- decorated(s, d), dec_decl(d, b), !decorated_call(s, d), fn_returns(b, _). +deco_known(s, d) :- decorated(s, d), dec_decl(d, b), !decorated_call(s, d), fn_returns_param(b). +deco_known(s, d) :- decorated(s, d), dec_decl(d, b), decorated_call(s, d), fn_returns(b, x), fn_returns(x, _). +deco_known(s, d) :- decorated(s, d), dec_decl(d, b), decorated_call(s, d), fn_returns(b, x), fn_returns_param(x). +// The decorated declaration stands for its wrapper: whatever reaches the name (a call, a route, a registry key) runs it. +// The engine leaves a call to a name whose decorator it could not resolve unresolved, so the hop is taken from the +// declaration itself rather than from its callers. +fw_edge(s, w, "decorator by name") :- deco_wrapper(s, w). +fw_edge(a, b, "decorator by name") :- decorated(a, d), dec_decl(d, b), a != b, !deco_known(a, d). // A CLIENT ACROSS A PROCESS: the sender depends on the handler it reaches (#1293). `remote` above makes the client a // DIRECT row; walked only there, a test that drives the client never reached a change to the handler, so // test-impact stopped at the route while impact named the client one line up. Walked here as its own tier, the diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/case.json b/tests/cases/javascript/a-test-that-never-loads-the-change/case.json new file mode 100644 index 00000000..3478cd58 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/case.json @@ -0,0 +1,110 @@ +{ + "lang": "javascript", + "src": "src", + "checks": [ + { + "why": "a plugin's override of a prototype method runs only where the plugin is loaded: the test that loads it is a test of it, the one that never imports the plugin file is not, though the receiver's dispatch set holds the override for every caller", + "run": [ + "impact", + "plugins/upper.js:4", + "--tests-only" + ], + "want": [ + "upper.test.js (", + "not counted: 1 test file(s)", + "plain.test.js" + ], + "avoid": [ + "plain.test.js (", + "later.test.js", + "audited.test.js" + ] + }, + { + "why": "a callback one test file hands to a shared function is not run by another test file that calls the same function: later.test.js never loads lib/audit.js", + "run": [ + "impact", + "audit", + "--tests-only" + ], + "want": [ + "audited.test.js (", + "not counted: 1 test file(s)" + ], + "avoid": [ + "later.test.js (" + ] + }, + { + "why": "CONTROL: a test that loads a module by a computed name (`require(path.join(...))`) may load the plugin, so it stays", + "run": [ + "impact", + "plugins/upper.js:4", + "--tests-only" + ], + "want": [ + "by-name.test.js (" + ] + }, + { + "why": "CONTROL: `require('..')`, `require('../')` and `require('../index.js')` load the package root's main file, so a test of the core class through any of them loads lib/core.js and stays selected", + "run": [ + "impact", + "Fmt.render", + "--tests-only" + ], + "want": [ + "plain.test.js (", + "upper.test.js (", + "by-name.test.js (" + ], + "avoid": [ + "not counted" + ] + }, + { + "why": "CONTROL: a test that imports the package by its own name (`require('fmtlib')`) loads its main file too", + "run": [ + "impact", + "later", + "--tests-only" + ], + "want": [ + "audited.test.js (", + "later.test.js (" + ], + "avoid": [ + "not counted" + ] + }, + { + "why": "path from a test file that never loads the target's file says so instead of printing a route through another test file's callback", + "run": [ + "path", + "test/later.test.js", + "audit" + ], + "want": [ + "never loads src/lib/audit.js" + ], + "avoid": [ + "call(s):" + ], + "expect_error": true + }, + { + "why": "CONTROL: path from the test that loads the plugin still prints its route", + "run": [ + "path", + "test/upper.test.js", + "plugins/upper.js:4" + ], + "want": [ + "call(s)" + ], + "avoid": [ + "never loads" + ] + } + ] +} diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/index.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/index.js new file mode 100644 index 00000000..7b150bf5 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/index.js @@ -0,0 +1 @@ +module.exports = require('./lib/core.js') diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/lib/audit.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/lib/audit.js new file mode 100644 index 00000000..8d1cd9b5 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/lib/audit.js @@ -0,0 +1,5 @@ +function audit(value) { + return 'audited ' + value +} + +module.exports = { audit } diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/lib/core.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/lib/core.js new file mode 100644 index 00000000..44aaed5a --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/lib/core.js @@ -0,0 +1,13 @@ +class Fmt { + constructor(v) { this.v = v } + render() { return '[' + this.parse(this.v) + ']' } + parse(v) { return String(v) } +} + +function extend(plugin) { plugin(Fmt) } + +function later(value, done) { + setTimeout(() => done(value), 0) +} + +module.exports = { Fmt, extend, later } diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/package.json b/tests/cases/javascript/a-test-that-never-loads-the-change/src/package.json new file mode 100644 index 00000000..9bdf58c6 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/package.json @@ -0,0 +1 @@ +{"name": "fmtlib", "version": "1.0.0", "main": "index.js"} diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/plugins/upper.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/plugins/upper.js new file mode 100644 index 00000000..9242ca15 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/plugins/upper.js @@ -0,0 +1,7 @@ +module.exports = function upper(C) { + const proto = C.prototype + const old = proto.parse + proto.parse = function (v) { + return old.call(this, v).toUpperCase() + } +} diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/audited.test.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/audited.test.js new file mode 100644 index 00000000..96e878b5 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/audited.test.js @@ -0,0 +1,6 @@ +const { later } = require('fmtlib') +const { audit } = require('../lib/audit') + +test('audits what it is handed', (cb) => { + later(2, function check(v) { expect(audit(v)).toBe('audited 2'); cb() }) +}) diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/by-name.test.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/by-name.test.js new file mode 100644 index 00000000..52ad807e --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/by-name.test.js @@ -0,0 +1,7 @@ +const path = require('path') +const { Fmt, extend } = require('..') + +test('loads a plugin by its name', () => { + extend(require(path.join(__dirname, '..', 'plugins', process.env.PLUGIN || 'upper'))) + expect(new Fmt('b').render()).toBe('[B]') +}) diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/later.test.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/later.test.js new file mode 100644 index 00000000..dd8791d3 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/later.test.js @@ -0,0 +1,5 @@ +const { later } = require('../') + +test('hands the value on', (cb) => { + later(1, function seen(v) { expect(v).toBe(1); cb() }) +}) diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/plain.test.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/plain.test.js new file mode 100644 index 00000000..4b566b09 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/plain.test.js @@ -0,0 +1,5 @@ +const { Fmt } = require('..') + +test('renders a value', () => { + expect(new Fmt('a').render()).toBe('[a]') +}) diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/upper.test.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/upper.test.js new file mode 100644 index 00000000..32555671 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/upper.test.js @@ -0,0 +1,8 @@ +const { Fmt, extend } = require('../index.js') +const upper = require('../plugins/upper') + +extend(upper) + +test('renders upper case', () => { + expect(new Fmt('a').render()).toBe('[A]') +}) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/case.json b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/case.json new file mode 100644 index 00000000..944a65ef --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/case.json @@ -0,0 +1,89 @@ +{ + "lang": "javascript", + "src": ".", + "checks": [ + { + "why": "a change a describe() block runs while mocha loads the specs fails the whole mocha run, so every spec file it collects is selected, not only the one that calls it", + "run": [ + "test-impact", + "{repo}", + "lib/build.js" + ], + "want": [ + "test/a.spec.js", + "test/b.spec.js", + "test/c.spec.js", + "test/d.spec.js", + "fails the whole run" + ] + }, + { + "why": "CONTROL: a change only a test body runs (inside it()) fails that spec alone", + "run": [ + "test-impact", + "{repo}", + "lib/parse.js" + ], + "want": [ + "test/c.spec.js" + ], + "avoid": [ + "test/a.spec.js", + "test/b.spec.js", + "fails the whole run" + ] + }, + { + "why": "CONTROL: jest loads each file in its own module registry, so the same describe()-time call fails that file alone", + "run": [ + "test-impact", + "{repo}", + "jestpkg/lib/build.js" + ], + "want": [ + "jestpkg/test/a.test.js" + ], + "avoid": [ + "jestpkg/test/b.test.js", + "fails the whole run" + ] + }, + { + "why": "a file under the tests that no runner collects is not a test run: one a collected spec requires is replaced by that spec, and a script nothing requires is not selected", + "run": [ + "test-impact", + "{repo}", + "lib/fmt.js" + ], + "want": [ + "test/b.spec.js", + "run only through the test files that require them", + "not selected: 1 file(s)", + "test/bench.js" + ], + "avoid": [ + "test/parts/extra.js (", + "test/bench.js (" + ] + }, + { + "why": "a function that is also a constructor (`function Thing(n)`) whose header line changes is a changed TYPE to `changed`; test-impact asks for it as the function it is and finds the spec that constructs it", + "run": [ + "test-impact", + "{repo}", + "--old", + "{repo}/edits/ctor-old.txt", + "--new", + "{repo}/edits/ctor-new.txt", + "--file", + "lib/ctor.js" + ], + "want": [ + "test/d.spec.js" + ], + "avoid": [ + "could not be resolved to a graph symbol" + ] + } + ] +} diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/edits/ctor-new.txt b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/edits/ctor-new.txt new file mode 100644 index 00000000..878c2762 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/edits/ctor-new.txt @@ -0,0 +1,9 @@ +function Thing(n, unit) { + this.n = n +} + +Thing.prototype.twice = function twice() { + return this.n * 2 +} + +module.exports = Thing diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/edits/ctor-old.txt b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/edits/ctor-old.txt new file mode 100644 index 00000000..c4bd522f --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/edits/ctor-old.txt @@ -0,0 +1,9 @@ +function Thing(n) { + this.n = n +} + +Thing.prototype.twice = function twice() { + return this.n * 2 +} + +module.exports = Thing diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/lib/build.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/lib/build.js new file mode 100644 index 00000000..3e4e5e0f --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/lib/build.js @@ -0,0 +1,5 @@ +function jbuild(o) { + return Object.keys(o) +} + +module.exports = { jbuild } diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/package.json b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/package.json new file mode 100644 index 00000000..e6e5ed84 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/package.json @@ -0,0 +1 @@ +{"name": "jestpkg", "version": "1.0.0", "scripts": {"test": "jest"}, "devDependencies": {"jest": "^29.0.0"}} diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/test/a.test.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/test/a.test.js new file mode 100644 index 00000000..d18814b4 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/test/a.test.js @@ -0,0 +1,8 @@ +const { jbuild } = require('../lib/build') + +describe('jbuild', () => { + const keys = jbuild({ y: 1 }) + test('lists the keys', () => { + expect(keys).toEqual(['y']) + }) +}) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/test/b.test.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/test/b.test.js new file mode 100644 index 00000000..17bdeac5 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/test/b.test.js @@ -0,0 +1,3 @@ +test('adds', () => { + expect(1 + 1).toBe(2) +}) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/build.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/build.js new file mode 100644 index 00000000..f5598d58 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/build.js @@ -0,0 +1,5 @@ +function build(o) { + return Object.keys(o) +} + +module.exports = { build } diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/ctor.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/ctor.js new file mode 100644 index 00000000..c4bd522f --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/ctor.js @@ -0,0 +1,9 @@ +function Thing(n) { + this.n = n +} + +Thing.prototype.twice = function twice() { + return this.n * 2 +} + +module.exports = Thing diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/fmt.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/fmt.js new file mode 100644 index 00000000..d4d9d97e --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/fmt.js @@ -0,0 +1,5 @@ +function fmt(s) { + return '<' + s + '>' +} + +module.exports = { fmt } diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/parse.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/parse.js new file mode 100644 index 00000000..8499cfe3 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/parse.js @@ -0,0 +1,5 @@ +function parse(s) { + return s.trim() +} + +module.exports = { parse } diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/package.json b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/package.json new file mode 100644 index 00000000..0826e6db --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/package.json @@ -0,0 +1,2 @@ +{"name": "loadtime", "version": "1.0.0", "scripts": {"test": "mocha"}, "devDependencies": {"mocha": "^10.0.0"}, + "mocha": {"spec": "test/*.spec.js"}} diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/a.spec.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/a.spec.js new file mode 100644 index 00000000..12865cef --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/a.spec.js @@ -0,0 +1,9 @@ +const assert = require('assert') +const { build } = require('../lib/build') + +describe('build', function () { + const keys = build({ x: 1 }) + it('lists the keys', function () { + assert.deepStrictEqual(keys, ['x']) + }) +}) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/b.spec.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/b.spec.js new file mode 100644 index 00000000..c16be893 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/b.spec.js @@ -0,0 +1,9 @@ +const assert = require('assert') +const { fmt } = require('../lib/fmt') + +describe('fmt', function () { + it('wraps', function () { + assert.strictEqual(fmt('x'), '') + }) + require('./parts/extra') +}) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/bench.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/bench.js new file mode 100644 index 00000000..570352a9 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/bench.js @@ -0,0 +1,5 @@ +const { fmt } = require('../lib/fmt') + +const t = Date.now() +for (let i = 0; i < 1e5; i++) fmt('b') +console.log(Date.now() - t) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/c.spec.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/c.spec.js new file mode 100644 index 00000000..e96d3693 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/c.spec.js @@ -0,0 +1,8 @@ +const assert = require('assert') +const { parse } = require('../lib/parse') + +describe('parse', function () { + it('trims', function () { + assert.strictEqual(parse(' x '), 'x') + }) +}) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/d.spec.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/d.spec.js new file mode 100644 index 00000000..e10d1828 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/d.spec.js @@ -0,0 +1,8 @@ +const assert = require('assert') +const Thing = require('../lib/ctor') + +describe('Thing', function () { + it('builds one', function () { + assert.strictEqual(new Thing(2).n, 2) + }) +}) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/parts/extra.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/parts/extra.js new file mode 100644 index 00000000..456484a0 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/parts/extra.js @@ -0,0 +1,6 @@ +const assert = require('assert') +const { fmt } = require('../../lib/fmt') + +it('wraps the empty string', function () { + assert.strictEqual(fmt(''), '<>') +}) diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/case.json b/tests/cases/python/decorator-by-name-runs-its-wrapper/case.json new file mode 100644 index 00000000..8fc2466f --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/case.json @@ -0,0 +1,26 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "a decorator matched only by its name that REGISTERS the function and hands it back unchanged: calling the decorated function never runs the decorator, so a change to what the decorator calls does not reach the decorated function (it used to be listed as a caller of the decorator, and everything reaching it with it)", + "run": [ + "impact", + "remember" + ], + "avoid": [ + "on_order_created" + ] + }, + { + "why": "the control: a decorator matched only by its name that hands back a WRAPPER. The decorated name now denotes the wrapper, so a change the wrapper reaches still reaches the decorated declaration", + "run": [ + "impact", + "tick" + ], + "want": [ + "crunch_numbers" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/handlers.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/handlers.py new file mode 100644 index 00000000..1e560ecd --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/handlers.py @@ -0,0 +1,12 @@ +from loader import load + +tools = load() + + +@tools.registered("order-created") +def on_order_created(order): + return order + + +def dispatch(kind, payload): + return tools.REGISTRY[kind](payload) diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/loader.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/loader.py new file mode 100644 index 00000000..f0d989ca --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/loader.py @@ -0,0 +1,2 @@ +def load(): + return __import__("tools") diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_handlers.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_handlers.py new file mode 100644 index 00000000..09b903cd --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_handlers.py @@ -0,0 +1,5 @@ +from handlers import dispatch + + +def test_dispatch(): + assert dispatch("order-created", 1) == 1 diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_work.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_work.py new file mode 100644 index 00000000..63ad00a4 --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_work.py @@ -0,0 +1,5 @@ +from work import run_crunch + + +def test_crunch(): + assert run_crunch() == 6 diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/tools.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/tools.py new file mode 100644 index 00000000..093c1318 --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/tools.py @@ -0,0 +1,25 @@ +"""Two decorators the project declares: one hands back a wrapper, one registers and hands back the function.""" +REGISTRY = {} + + +def timed(fn): + def wrapper(*args): + tick() + return fn(*args) + return wrapper + + +def tick(): + return 1 + + +def registered(name): + def register(fn): + REGISTRY[name] = fn + remember(name) + return fn + return register + + +def remember(name): + return name diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/work.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/work.py new file mode 100644 index 00000000..372c130d --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/work.py @@ -0,0 +1,13 @@ +"""The decorator is reached through a value the graph cannot type, so the decoration is matched by its name.""" +from loader import load + +tools = load() + + +@tools.timed +def crunch_numbers(n): + return n * 2 + + +def run_crunch(): + return crunch_numbers(3) diff --git a/tests/cases/python/decorator-the-project-declares/case.json b/tests/cases/python/decorator-the-project-declares/case.json index 0fcb2134..ee7d61f0 100644 --- a/tests/cases/python/decorator-the-project-declares/case.json +++ b/tests/cases/python/decorator-the-project-declares/case.json @@ -3,7 +3,7 @@ "src": ".", "checks": [ { - "why": "a decorator the project declares is a dependency of everything it wraps: `@exporter(\"csv\") def export_csv` means export_csv IS what exporter returned, so a test that drives the handler must run when the decorator changes — and nothing but the @ line says so, since the decorator has no call site and the table is read by a key. The control is a module of its OWN: a sibling in the same file is reached whatever the decorator does, because the decoration runs while that file is being imported", + "why": "a decorator the project declares is a dependency of everything it wraps' MODULE: `@exporter(\"csv\") def export_csv` runs exporter(\"csv\") and the function it returned while app.py is being imported, so a test that imports app must run when the decorator changes. Calling export_csv later never runs exporter (it registered the function and handed it back unchanged), so the route is the import, not a hop from the decorated function. The control is a module of its OWN: a sibling in the same file is reached whatever the decorator does, because the decoration runs while that file is being imported", "run": [ "impact", "exporter", @@ -11,7 +11,7 @@ ], "want": [ "test_export.py", - "decorator by name" + "at import" ], "avoid": [ "test_plain.py" @@ -32,14 +32,14 @@ ] }, { - "why": "the hop is named for what it is. The decoration carries a simple NAME, so the declaration it denotes is matched by that name and not resolved, and the answer must not sell that as a resolved call", + "why": "the hop is named for what it is: the decoration runs at import, and the answer says so rather than selling it as a resolved call", "run": [ "impact", "exporter", "--tests" ], "want": [ - "decorator by name" + "at import" ], "avoid": [ "how sure each route is: 3 sound" diff --git a/tests/cases/python/option-parameter-name-is-no-key/case.json b/tests/cases/python/option-parameter-name-is-no-key/case.json new file mode 100644 index 00000000..dd288be8 --- /dev/null +++ b/tests/cases/python/option-parameter-name-is-no-key/case.json @@ -0,0 +1,16 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "a decoration string that names a parameter of the declaration it decorates binds that parameter; it is not a key anything dispatches on. Every dict with a `level` key wrote it, and was joined to the command as a caller", + "run": ["impact", "describe", "--tests"], + "avoid": ["defaults", "test_config.py"] + }, + { + "why": "the control: the flag is what a caller writes to reach the command, and a test that writes it still reaches the change", + "run": ["impact", "describe", "--tests"], + "want": ["test_cli.py"] + } + ] +} diff --git a/tests/cases/python/option-parameter-name-is-no-key/cli.py b/tests/cases/python/option-parameter-name-is-no-key/cli.py new file mode 100644 index 00000000..0bd55772 --- /dev/null +++ b/tests/cases/python/option-parameter-name-is-no-key/cli.py @@ -0,0 +1,17 @@ +"""A command whose decorations declare its parameters: the flags are what a caller writes, the last name is the +parameter the value is bound to.""" + + +def option(*decls, **settings): + def attach(fn): + return fn + return attach + + +@option("--level", "-l", "level", type=int) +def main(level=0): + return describe(level) + + +def describe(level): + return level diff --git a/tests/cases/python/option-parameter-name-is-no-key/config.py b/tests/cases/python/option-parameter-name-is-no-key/config.py new file mode 100644 index 00000000..6cf1e6bb --- /dev/null +++ b/tests/cases/python/option-parameter-name-is-no-key/config.py @@ -0,0 +1,2 @@ +def defaults(): + return {"level": 3} diff --git a/tests/cases/python/option-parameter-name-is-no-key/tests/test_cli.py b/tests/cases/python/option-parameter-name-is-no-key/tests/test_cli.py new file mode 100644 index 00000000..9becc312 --- /dev/null +++ b/tests/cases/python/option-parameter-name-is-no-key/tests/test_cli.py @@ -0,0 +1,6 @@ +from cli import main + + +def test_level_flag(): + args = ["--level", "2"] + assert main(int(args[1])) == 2 diff --git a/tests/cases/python/option-parameter-name-is-no-key/tests/test_config.py b/tests/cases/python/option-parameter-name-is-no-key/tests/test_config.py new file mode 100644 index 00000000..b5c2cfb5 --- /dev/null +++ b/tests/cases/python/option-parameter-name-is-no-key/tests/test_config.py @@ -0,0 +1,5 @@ +from config import defaults + + +def test_defaults(): + assert defaults()["level"] == 3 diff --git a/tests/cases/python/optional-class-object-parameter/case.json b/tests/cases/python/optional-class-object-parameter/case.json new file mode 100644 index 00000000..b0f17354 --- /dev/null +++ b/tests/cases/python/optional-class-object-parameter/case.json @@ -0,0 +1,16 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "a class-object parameter spelled with its default (`cls: type[CmdType] | None = None`, `Optional[Type[Command]]`) holds a class just as `type[Command]` does: `cls(name)` constructs it, so the constructor's callers include the code that calls through the parameter, in a closure or not", + "run": ["impact", "Command.__init__"], + "want": ["decorator", "option"] + }, + { + "why": "the control: the bare `type[Command]` spelling was already read", + "run": ["impact", "Command.__init__"], + "want": ["fixed"] + } + ] +} diff --git a/tests/cases/python/optional-class-object-parameter/commands.py b/tests/cases/python/optional-class-object-parameter/commands.py new file mode 100644 index 00000000..7ec1b9f1 --- /dev/null +++ b/tests/cases/python/optional-class-object-parameter/commands.py @@ -0,0 +1,31 @@ +"""A factory whose class parameter defaults to None: the body puts the default class in and constructs it.""" +import typing as t + + +class Command: + def __init__(self, name): + self.name = name + + +class Group(Command): + pass + + +CmdType = t.TypeVar("CmdType", bound=Command) + + +def command(name, cls: type[CmdType] | None = None): + if cls is None: + cls = t.cast("type[CmdType]", Command) + + def decorator(f): + return cls(name) + return decorator + + +def option(name, cls: t.Optional[t.Type[Command]] = None): + return (cls or Command)(name) + + +def fixed(name, cls: type[Command]): + return cls(name)