@@ -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+
15471577def 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
15831621CODE = ('.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 ("\n bound: 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
0 commit comments