diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index 07b2bc1c2..43d92afdb 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -1533,6 +1533,36 @@ def family(rel): return {'.py': 'py', '.pyi': 'py', '.java': 'java', '.cs': 'cs'}.get(e, 'js' if e in ('.ts', '.tsx', '.js', '.mjs', '.cjs', '.jsx') else ('py' if not e else e)) +# Being under a test directory and sharing a basename is not being a test. `test/fixtures/basic/index.js` is an +# INPUT a test reads, `tests/test_index.py` is another language's test, and the changed file shares its own name: +# all three were offered as "the tests of src/index.js", with a runner command. A by-name candidate is a test +# only by the convention of the changed file's own ecosystem -- the pattern its runner collects by default. +_CODE_EXT = ('.py', '.java', '.cs', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs') +_TEST_AFFIX = { # (prefixes, suffixes) of the file stem, per runner default + 'py': (('test_',), ('_test',)), # pytest python_files + 'java': (('Test', 'IT'), ('Test', 'Tests', 'TestCase', 'IT')), # surefire / failsafe includes + 'cs': ((), ('Test', 'Tests')), + 'js': ((), ('.test', '.spec')), # jest / vitest testMatch +} +# a file DIRECTLY in one of these is collected whatever its name (mocha ./test/*.js, jest __tests__/); +# one nested deeper (test/fixtures/x/index.js) is not +_TEST_DIR_COLLECTS = {'js': ('__tests__', 'test', 'tests')} + + +def tested_stems(rel): + """The module names `rel` is the test of by its ecosystem's own convention; empty when it is no test.""" + fam = family(rel) + s = os.path.splitext(os.path.basename(rel))[0] + pre, suf = _TEST_AFFIX.get(fam, ((), ())) + # a Java prefix is a word of its own (TestParser), not the start of one (Testimonial) + out = {s[len(p):] for p in pre if s.startswith(p) and len(s) > len(p) + and (fam != 'java' or s[len(p)].isupper())} + out |= {s[:-len(x)] for x in suf if s.endswith(x) and len(s) > len(x)} + if os.path.basename(os.path.dirname(rel)) in _TEST_DIR_COLLECTS.get(fam, ()): + out.add(s) + return {x for x in out if len(x) > 2} + + def package_tier(repo, changed_files, limit=6000): """Two tiers of proximity, for when the call graph named nothing. Neither is a call-graph answer. @@ -1546,27 +1576,35 @@ def package_tier(repo, changed_files, limit=6000): """ pkgs = {package_of(f) for f in changed_files if f} pkgs.discard('') + # a candidate is a test of a changed file when it is a test by the convention of THAT file's language and + # names that file's module; anything else sharing the name is counted, not offered + changed = {f for f in changed_files if f} + want = {(family(f), os.path.splitext(os.path.basename(f))[0]) for f in changed} want_stems = set() - for f in changed_files: - if f: want_stems |= stems_of(f) + for f in changed: + want_stems |= stems_of(f) if not pkgs and not want_stems: return {}, {}, pkgs - fams = {family(f) for f in changed_files if f} by_pkg = collections.defaultdict(list) - by_name = [] + by_name, not_tests = [], 0 seen = 0 + + def named(): + return {'files': sorted(by_name), **({'not_tests': not_tests} if not_tests else {})} if by_name or not_tests else {} for root, dirs, fs in os.walk(repo): dirs[:] = [d for d in dirs if d not in ('node_modules', '.git', 'dist', 'build', 'target', '.axiomcode')] for f in fs: - if not f.endswith(('.ts', '.tsx', '.js', '.mjs', '.java', '.py', '.cs')): continue # a list that omits an extension makes both tiers below silently inert for that language (#1083's shape) + if not f.endswith(_CODE_EXT): continue # a list that omits an extension makes both tiers below silently inert for that language (#1083's shape) rel = os.path.relpath(os.path.join(root, f), repo).replace(os.sep, '/') if not TESTY.search(rel) or rel in changed_files: continue # a changed test file is run as edited, not found here seen += 1 - if seen > limit: return dict(by_pkg), {'files': sorted(by_name)}, pkgs - # a test named after a changed file in ANOTHER language is not its test (server.js for server.py) - if stems_of(rel) & want_stems and family(rel) in fams: by_name.append(rel) + if seen > limit: return dict(by_pkg), named(), pkgs + # a test of a changed file by the convention of that file's own ecosystem (TypeScript and JavaScript read + # each other); a same-named file that is not one -- fixture input, another language's test -- is only counted + if any((family(rel), s) in want for s in tested_stems(rel)): by_name.append(rel) + elif stems_of(rel) & want_stems: not_tests += 1 p = package_of(rel) if p in pkgs: by_pkg[p].append(rel) - return dict(by_pkg), ({'files': sorted(by_name)} if by_name else {}), pkgs + return dict(by_pkg), named(), pkgs CODE = ('.ts', '.tsx', '.js', '.mjs', '.java', '.py', '.cs') @@ -1858,6 +1896,7 @@ def main(argv): 'named_in_test_text': {f: {'needle': n, 'tests': ts} for f, (n, ts) in loaded.items()}, 'named_in_other_language_tests': len(other_lang), 'range_note': changed.get('range_note'), + 'same_name_not_tests': name_hits.get('not_tests') or 0, 'bound': 'a lower bound: a test reached only by reflection, a service loader, a ' 'framework instantiating by name, a subprocess, or a case built at runtime is not here'}, indent=1)) @@ -1880,6 +1919,7 @@ def main(argv): + ', '.join(fs[:3]) + (f" … +{len(fs) - 3}" if len(fs) > 3 else '') + "): no test there was looked at." " Index without --src (or with one that holds the tests) before reading 'no test' as an answer.") nf = name_hits.get('files') or [] + cmd = None if nf: print(f"\n BY NAME ONLY — {len(nf)} test file(s) are NAMED AFTER a changed file") print(" (test_x.py for x.py, XTest.java for X.java). This is the naming convention, not the") @@ -1893,6 +1933,9 @@ def main(argv): if cmd: print("\n " + cmd.replace("\n", "\n ")) print_unrun(nlang, repo, nf[:limit]) + if name_hits.get('not_tests'): + print(f"\n not a test: {name_hits['not_tests']} same-named file(s) under test data or in another" + " language — left out, and no command runs them") if pkg_hits: n = sum(len(v) for v in pkg_hits.values()) print(f"\n BY PACKAGE ONLY — {n} test file(s) live under the same package as a changed file") @@ -2002,6 +2045,10 @@ def main(argv): print("\nbound: this is a LOWER bound. A test that reaches the change only through something the graph " "does not encode — reflection, a service loader, a framework that instantiates by name, a subprocess, a case " "built at runtime — does not appear here. Run these first; do not skip the rest on this alone.") + if not tests and name_hits.get('files') and cmd: + # the generic next step says "only the tests above", which a by-name lead has not earned + print(f"next: run {cmd} first — named after the changed file, not reached through the graph; " + "then run the rest of the suite") return 0 diff --git a/tests/test_command.py b/tests/test_command.py index 99ce42975..e9a227aec 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -373,6 +373,27 @@ def tree(files): check("an edit to pricing.py still names test_pricing.py (control)", ti.package_tier(d, ['app/pricing.py'])[1], {'files': ['tests/test_pricing.py']}) shutil.rmtree(d) +# the by-name tier holds only tests by the changed file's own ecosystem: a fixture input under a test tree, another +# language's test and the changed file itself share the name but are counted, never offered or run +d = tree({'src/index.js': '', 'test/fixtures/basic/index.js': '', 'test/fixtures/esm/index.js': '', + 'tests/test_index.py': '', 'test/index.test.js': '', 'test/server.js': '', 'test/fixtures/app/server.js': '', + 'src/server.ts': '', 'src/schema.ts': '', 'test/fixtures/schema.ts': '', 'test/schema.spec.ts': '', + 'test/app.ts': '', 'test/fixtures/app/index.js': ''}) +check("fixture inputs and another language's test are not the tests of src/index.js", + ti.package_tier(d, ['src/index.js'])[1], {'files': ['test/index.test.js'], 'not_tests': 4}) +check("a .spec.ts names schema.ts; the fixture schema.ts is counted, not offered", + ti.package_tier(d, ['src/schema.ts'])[1], {'files': ['test/schema.spec.ts'], 'not_tests': 1}) +check("a file directly in test/ is a mocha test of server.ts (control); the nested fixture server.js is not", + ti.package_tier(d, ['src/server.ts'])[1], {'files': ['test/server.js'], 'not_tests': 1}) +check("a changed test-tree file is not its own test", ti.package_tier(d, ['test/fixtures/basic/index.js'])[1].get('files'), + ['test/index.test.js']) +shutil.rmtree(d) +d = tree({'src/main/java/a/Parser.java': '', 'src/test/java/a/ParserTest.java': '', 'src/test/java/a/TestParser.java': '', + 'src/test/resources/cases/Parser.java': '', 'tests/test_parser.py': '', 'src/test/java/a/Testimonial.java': ''}) +check("java: ParserTest and TestParser are Parser's tests; a resource Parser.java is not", + ti.package_tier(d, ['src/main/java/a/Parser.java'])[1], + {'files': ['src/test/java/a/ParserTest.java', 'src/test/java/a/TestParser.java'], 'not_tests': 1}) +shutil.rmtree(d) check("a runner given only another language's files prints no command", ti.command_for('python', ['tests/Foo.cs'], [], None, '.'), None) check("java drops a .py file from a file-named selection", ti.command_for('java', ['src/test/java/ATest.java', 'tests/test_a.py'], []), 'mvn test -Dtest=ATest')