Skip to content

Commit 130d7d2

Browse files
Merge pull request #1736 from AxiomCodeAI/loop/to-t2-2
test-impact: by-name fallback offers only real tests of the changed file's language
2 parents ebcc41d + 5aa6a66 commit 130d7d2

2 files changed

Lines changed: 77 additions & 9 deletions

File tree

‎plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact‎

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1544,6 +1544,36 @@ def family(rel):
15441544
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))
15451545

15461546

1547+
# Being under a test directory and sharing a basename is not being a test. `test/fixtures/basic/index.js` is an
1548+
# INPUT a test reads, `tests/test_index.py` is another language's test, and the changed file shares its own name:
1549+
# all three were offered as "the tests of src/index.js", with a runner command. A by-name candidate is a test
1550+
# only by the convention of the changed file's own ecosystem -- the pattern its runner collects by default.
1551+
_CODE_EXT = ('.py', '.java', '.cs', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs')
1552+
_TEST_AFFIX = { # (prefixes, suffixes) of the file stem, per runner default
1553+
'py': (('test_',), ('_test',)), # pytest python_files
1554+
'java': (('Test', 'IT'), ('Test', 'Tests', 'TestCase', 'IT')), # surefire / failsafe includes
1555+
'cs': ((), ('Test', 'Tests')),
1556+
'js': ((), ('.test', '.spec')), # jest / vitest testMatch
1557+
}
1558+
# a file DIRECTLY in one of these is collected whatever its name (mocha ./test/*.js, jest __tests__/);
1559+
# one nested deeper (test/fixtures/x/index.js) is not
1560+
_TEST_DIR_COLLECTS = {'js': ('__tests__', 'test', 'tests')}
1561+
1562+
1563+
def tested_stems(rel):
1564+
"""The module names `rel` is the test of by its ecosystem's own convention; empty when it is no test."""
1565+
fam = family(rel)
1566+
s = os.path.splitext(os.path.basename(rel))[0]
1567+
pre, suf = _TEST_AFFIX.get(fam, ((), ()))
1568+
# a Java prefix is a word of its own (TestParser), not the start of one (Testimonial)
1569+
out = {s[len(p):] for p in pre if s.startswith(p) and len(s) > len(p)
1570+
and (fam != 'java' or s[len(p)].isupper())}
1571+
out |= {s[:-len(x)] for x in suf if s.endswith(x) and len(s) > len(x)}
1572+
if os.path.basename(os.path.dirname(rel)) in _TEST_DIR_COLLECTS.get(fam, ()):
1573+
out.add(s)
1574+
return {x for x in out if len(x) > 2}
1575+
1576+
15471577
def package_tier(repo, changed_files, limit=6000):
15481578
"""Two tiers of proximity, for when the call graph named nothing. Neither is a call-graph answer.
15491579
@@ -1557,27 +1587,35 @@ def package_tier(repo, changed_files, limit=6000):
15571587
"""
15581588
pkgs = {package_of(f) for f in changed_files if f}
15591589
pkgs.discard('')
1590+
# a candidate is a test of a changed file when it is a test by the convention of THAT file's language and
1591+
# names that file's module; anything else sharing the name is counted, not offered
1592+
changed = {f for f in changed_files if f}
1593+
want = {(family(f), os.path.splitext(os.path.basename(f))[0]) for f in changed}
15601594
want_stems = set()
1561-
for f in changed_files:
1562-
if f: want_stems |= stems_of(f)
1595+
for f in changed:
1596+
want_stems |= stems_of(f)
15631597
if not pkgs and not want_stems: return {}, {}, pkgs
1564-
fams = {family(f) for f in changed_files if f}
15651598
by_pkg = collections.defaultdict(list)
1566-
by_name = []
1599+
by_name, not_tests = [], 0
15671600
seen = 0
1601+
1602+
def named():
1603+
return {'files': sorted(by_name), **({'not_tests': not_tests} if not_tests else {})} if by_name or not_tests else {}
15681604
for root, dirs, fs in os.walk(repo):
15691605
dirs[:] = [d for d in dirs if d not in ('node_modules', '.git', 'dist', 'build', 'target', '.axiomcode')]
15701606
for f in fs:
1571-
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)
1607+
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)
15721608
rel = os.path.relpath(os.path.join(root, f), repo).replace(os.sep, '/')
15731609
if not TESTY.search(rel) or rel in changed_files: continue # a changed test file is run as edited, not found here
15741610
seen += 1
1575-
if seen > limit: return dict(by_pkg), {'files': sorted(by_name)}, pkgs
1576-
# a test named after a changed file in ANOTHER language is not its test (server.js for server.py)
1577-
if stems_of(rel) & want_stems and family(rel) in fams: by_name.append(rel)
1611+
if seen > limit: return dict(by_pkg), named(), pkgs
1612+
# a test of a changed file by the convention of that file's own ecosystem (TypeScript and JavaScript read
1613+
# each other); a same-named file that is not one -- fixture input, another language's test -- is only counted
1614+
if any((family(rel), s) in want for s in tested_stems(rel)): by_name.append(rel)
1615+
elif stems_of(rel) & want_stems: not_tests += 1
15781616
p = package_of(rel)
15791617
if p in pkgs: by_pkg[p].append(rel)
1580-
return dict(by_pkg), ({'files': sorted(by_name)} if by_name else {}), pkgs
1618+
return dict(by_pkg), named(), pkgs
15811619

15821620

15831621
CODE = ('.ts', '.tsx', '.js', '.mjs', '.java', '.py', '.cs')
@@ -1869,6 +1907,7 @@ def main(argv):
18691907
'named_in_test_text': {f: {'needle': n, 'tests': ts} for f, (n, ts) in loaded.items()},
18701908
'named_in_other_language_tests': len(other_lang),
18711909
'range_note': changed.get('range_note'),
1910+
'same_name_not_tests': name_hits.get('not_tests') or 0,
18721911
'bound': 'a lower bound: a test reached only by reflection, a service loader, a '
18731912
'framework instantiating by name, a subprocess, or a case built at runtime is not here'},
18741913
indent=1))
@@ -1891,6 +1930,7 @@ def main(argv):
18911930
+ ', '.join(fs[:3]) + (f" … +{len(fs) - 3}" if len(fs) > 3 else '') + "): no test there was looked at."
18921931
" Index without --src (or with one that holds the tests) before reading 'no test' as an answer.")
18931932
nf = name_hits.get('files') or []
1933+
cmd = None
18941934
if nf:
18951935
print(f"\n BY NAME ONLY — {len(nf)} test file(s) are NAMED AFTER a changed file")
18961936
print(" (test_x.py for x.py, XTest.java for X.java). This is the naming convention, not the")
@@ -1904,6 +1944,9 @@ def main(argv):
19041944
if cmd:
19051945
print("\n " + cmd.replace("\n", "\n "))
19061946
print_unrun(nlang, repo, nf[:limit])
1947+
if name_hits.get('not_tests'):
1948+
print(f"\n not a test: {name_hits['not_tests']} same-named file(s) under test data or in another"
1949+
" language — left out, and no command runs them")
19071950
if pkg_hits:
19081951
n = sum(len(v) for v in pkg_hits.values())
19091952
print(f"\n BY PACKAGE ONLY — {n} test file(s) live under the same package as a changed file")
@@ -2013,6 +2056,10 @@ def main(argv):
20132056
print("\nbound: this is a LOWER bound. A test that reaches the change only through something the graph "
20142057
"does not encode — reflection, a service loader, a framework that instantiates by name, a subprocess, a case "
20152058
"built at runtime — does not appear here. Run these first; do not skip the rest on this alone.")
2059+
if not tests and name_hits.get('files') and cmd:
2060+
# the generic next step says "only the tests above", which a by-name lead has not earned
2061+
print(f"next: run {cmd} first — named after the changed file, not reached through the graph; "
2062+
"then run the rest of the suite")
20162063
return 0
20172064

20182065

‎tests/test_command.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,27 @@ def tree(files):
373373
check("an edit to pricing.py still names test_pricing.py (control)", ti.package_tier(d, ['app/pricing.py'])[1],
374374
{'files': ['tests/test_pricing.py']})
375375
shutil.rmtree(d)
376+
# the by-name tier holds only tests by the changed file's own ecosystem: a fixture input under a test tree, another
377+
# language's test and the changed file itself share the name but are counted, never offered or run
378+
d = tree({'src/index.js': '', 'test/fixtures/basic/index.js': '', 'test/fixtures/esm/index.js': '',
379+
'tests/test_index.py': '', 'test/index.test.js': '', 'test/server.js': '', 'test/fixtures/app/server.js': '',
380+
'src/server.ts': '', 'src/schema.ts': '', 'test/fixtures/schema.ts': '', 'test/schema.spec.ts': '',
381+
'test/app.ts': '', 'test/fixtures/app/index.js': ''})
382+
check("fixture inputs and another language's test are not the tests of src/index.js",
383+
ti.package_tier(d, ['src/index.js'])[1], {'files': ['test/index.test.js'], 'not_tests': 4})
384+
check("a .spec.ts names schema.ts; the fixture schema.ts is counted, not offered",
385+
ti.package_tier(d, ['src/schema.ts'])[1], {'files': ['test/schema.spec.ts'], 'not_tests': 1})
386+
check("a file directly in test/ is a mocha test of server.ts (control); the nested fixture server.js is not",
387+
ti.package_tier(d, ['src/server.ts'])[1], {'files': ['test/server.js'], 'not_tests': 1})
388+
check("a changed test-tree file is not its own test", ti.package_tier(d, ['test/fixtures/basic/index.js'])[1].get('files'),
389+
['test/index.test.js'])
390+
shutil.rmtree(d)
391+
d = tree({'src/main/java/a/Parser.java': '', 'src/test/java/a/ParserTest.java': '', 'src/test/java/a/TestParser.java': '',
392+
'src/test/resources/cases/Parser.java': '', 'tests/test_parser.py': '', 'src/test/java/a/Testimonial.java': ''})
393+
check("java: ParserTest and TestParser are Parser's tests; a resource Parser.java is not",
394+
ti.package_tier(d, ['src/main/java/a/Parser.java'])[1],
395+
{'files': ['src/test/java/a/ParserTest.java', 'src/test/java/a/TestParser.java'], 'not_tests': 1})
396+
shutil.rmtree(d)
376397
check("a runner given only another language's files prints no command", ti.command_for('python', ['tests/Foo.cs'], [], None, '.'), None)
377398
check("java drops a .py file from a file-named selection", ti.command_for('java', ['src/test/java/ATest.java', 'tests/test_a.py'], []),
378399
'mvn test -Dtest=ATest')

0 commit comments

Comments
 (0)