Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 56 additions & 9 deletions plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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')
Expand Down Expand Up @@ -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))
Expand All @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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


Expand Down
21 changes: 21 additions & 0 deletions tests/test_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading