From fb4bde0d2f22bafab1b0332b4df66209d2ec945a Mon Sep 17 00:00:00 2001 From: Stephan Schubert Date: Tue, 20 Jan 2026 14:21:32 +0100 Subject: [PATCH 1/4] feat(related): add --related flag for running affected tests Add new --related, --related-files, and --related-base CLI options that analyze code dependencies to run only tests affected by changes. Includes: - CLI argument parsing - Runner integration - busted/modules/related/ module suite - Rockspec updates --- busted-scm-1.rockspec | 6 + busted/modules/cli.lua | 3 + busted/modules/related/dependency_graph.lua | 135 +++++++++++++ busted/modules/related/git_changes.lua | 137 +++++++++++++ busted/modules/related/init.lua | 201 ++++++++++++++++++++ busted/modules/related/path_resolver.lua | 97 ++++++++++ busted/modules/related/require_parser.lua | 169 ++++++++++++++++ busted/runner.lua | 27 +++ 8 files changed, 775 insertions(+) create mode 100644 busted/modules/related/dependency_graph.lua create mode 100644 busted/modules/related/git_changes.lua create mode 100644 busted/modules/related/init.lua create mode 100644 busted/modules/related/path_resolver.lua create mode 100644 busted/modules/related/require_parser.lua diff --git a/busted-scm-1.rockspec b/busted-scm-1.rockspec index 7e6e79c7..d3430df7 100644 --- a/busted-scm-1.rockspec +++ b/busted-scm-1.rockspec @@ -79,6 +79,12 @@ build = { ['busted.modules.filter_loader'] = 'busted/modules/filter_loader.lua', ['busted.modules.cli'] = 'busted/modules/cli.lua', + ['busted.modules.related'] = 'busted/modules/related/init.lua', + ['busted.modules.related.dependency_graph'] = 'busted/modules/related/dependency_graph.lua', + ['busted.modules.related.git_changes'] = 'busted/modules/related/git_changes.lua', + ['busted.modules.related.path_resolver'] = 'busted/modules/related/path_resolver.lua', + ['busted.modules.related.require_parser'] = 'busted/modules/related/require_parser.lua', + ['busted.modules.files.lua'] = 'busted/modules/files/lua.lua', ['busted.modules.files.moonscript'] = 'busted/modules/files/moonscript.lua', ['busted.modules.files.terra'] = 'busted/modules/files/terra.lua', diff --git a/busted/modules/cli.lua b/busted/modules/cli.lua index 64e50d40..650fb724 100644 --- a/busted/modules/cli.lua +++ b/busted/modules/cli.lua @@ -163,6 +163,9 @@ return function(options) cli:flag('--[no-]sort-tests', 'sort test order within a file', processOption) cli:flag('--[no-]suppress-pending', 'suppress `pending` test output', false, processOption) cli:flag('--[no-]defer-print', 'defer print to when test suite is complete', false, processOption) + cli:flag('--related', 'only run tests related to uncommitted git changes. Note: dynamic requires like require(var) are not detected.', false, processOption) + cli:option('--related-files=FILES', 'run tests related to specified files (comma-separated). Skips git detection.', nil, processOption) + cli:option('--related-base=REF', 'git ref to compare against for --related (default: HEAD)', 'HEAD', processOption) local function parse(args) -- Parse the cli arguments diff --git a/busted/modules/related/dependency_graph.lua b/busted/modules/related/dependency_graph.lua new file mode 100644 index 00000000..df9a5b78 --- /dev/null +++ b/busted/modules/related/dependency_graph.lua @@ -0,0 +1,135 @@ +local RequireParser = require 'busted.modules.related.require_parser' + +local DependencyGraph = {} +DependencyGraph.__index = DependencyGraph + +function DependencyGraph.new() + local self = setmetatable({}, DependencyGraph) + self.forward = {} + self.reverse = {} + self.module_to_path = {} + return self +end + +function DependencyGraph:build(files, path_resolver) + for _, filepath in ipairs(files) do + local parsed = RequireParser.parse_file(filepath) + if parsed then + self.forward[filepath] = {} + + for _, module_name in ipairs(parsed.requires) do + local resolved = self.module_to_path[module_name] + if not resolved then + resolved = path_resolver:resolve(module_name) + self.module_to_path[module_name] = resolved + end + + if resolved then + self.forward[filepath][#self.forward[filepath] + 1] = resolved + end + end + + for _, file_path in ipairs(parsed.loadfiles) do + local resolved = path_resolver:resolve_file(file_path) + if resolved then + self.forward[filepath][#self.forward[filepath] + 1] = resolved + end + end + end + end + + for filepath, deps in pairs(self.forward) do + for _, dep in ipairs(deps) do + if not self.reverse[dep] then + self.reverse[dep] = {} + end + self.reverse[dep][#self.reverse[dep] + 1] = filepath + end + end +end + +function DependencyGraph:get_direct_dependents(filepath) + return self.reverse[filepath] or {} +end + +function DependencyGraph:get_direct_dependencies(filepath) + return self.forward[filepath] or {} +end + +function DependencyGraph:get_affected_files(changed_files) + local affected = {} + local visited = {} + local queue = {} + + for _, filepath in ipairs(changed_files) do + queue[#queue + 1] = filepath + end + + local queue_start = 1 + while queue_start <= #queue do + local current = queue[queue_start] + queue_start = queue_start + 1 + + if not visited[current] then + visited[current] = true + affected[current] = true + + local dependents = self.reverse[current] or {} + for _, dependent in ipairs(dependents) do + if not visited[dependent] then + queue[#queue + 1] = dependent + end + end + end + end + + return affected +end + +function DependencyGraph:get_affected_tests(changed_files, test_files) + local affected = self:get_affected_files(changed_files) + local affected_tests = {} + + for filepath in pairs(affected) do + if test_files[filepath] then + affected_tests[filepath] = true + end + end + + return affected_tests +end + +function DependencyGraph:stats() + local num_files = 0 + local num_edges = 0 + + for _, deps in pairs(self.forward) do + num_files = num_files + 1 + num_edges = num_edges + #deps + end + + return { + files = num_files, + edges = num_edges, + } +end + +function DependencyGraph:dump() + print("=== Forward edges (file -> dependencies) ===") + for filepath, deps in pairs(self.forward) do + print(filepath) + for _, dep in ipairs(deps) do + print(" -> " .. dep) + end + end + + print("\n=== Reverse edges (file -> dependents) ===") + for filepath, deps in pairs(self.reverse) do + print(filepath) + for _, dep in ipairs(deps) do + print(" <- " .. dep) + end + end +end + +return DependencyGraph diff --git a/busted/modules/related/git_changes.lua b/busted/modules/related/git_changes.lua new file mode 100644 index 00000000..a826119a --- /dev/null +++ b/busted/modules/related/git_changes.lua @@ -0,0 +1,137 @@ +local path = require 'pl.path' + +local GitChanges = {} + +-- Normalize paths to forward slashes for cross-platform consistency +local function normalize(p) + return p:gsub('\\', '/') +end + +local function default_run_command(cmd, cwd) + local full_cmd + if cwd then + full_cmd = string.format('cd %q && %s 2>&1', cwd, cmd) + else + full_cmd = cmd .. ' 2>&1' + end + + local handle = io.popen(full_cmd) + if not handle then + return nil, 'Failed to execute command' + end + + local lines = {} + local has_error = false + for line in handle:lines() do + if line:match('^fatal:') or line:match('^error:') then + has_error = true + end + lines[#lines + 1] = line + end + + local _, _, exit_code = handle:close() + + if has_error or (exit_code and exit_code ~= 0) then + local err_msg = table.concat(lines, '\n') + return nil, err_msg ~= '' and err_msg or 'Git command failed' + end + + return lines +end + +-- Injectable for testing +GitChanges._run_command = default_run_command + +local function run_git_command(cmd, cwd) + return GitChanges._run_command(cmd, cwd) +end + +function GitChanges.is_git_repo(cwd) + local lines = run_git_command('git rev-parse --is-inside-work-tree', cwd) + return lines and #lines > 0 and lines[1] == 'true' +end + +function GitChanges.get_git_root(cwd) + local lines = run_git_command('git rev-parse --show-toplevel', cwd) + return lines and lines[1] +end + +function GitChanges.get_changed_files(cwd) + if not GitChanges.is_git_repo(cwd) then + return nil, 'Not a git repository' + end + + local files = {} + + local unstaged = run_git_command('git diff --name-only', cwd) + if unstaged then + for _, file in ipairs(unstaged) do + files[normalize(path.normpath(path.join(cwd, file)))] = true + end + end + + local staged = run_git_command('git diff --cached --name-only', cwd) + if staged then + for _, file in ipairs(staged) do + files[normalize(path.normpath(path.join(cwd, file)))] = true + end + end + + local untracked = run_git_command('git ls-files --others --exclude-standard', cwd) + if untracked then + for _, file in ipairs(untracked) do + files[normalize(path.normpath(path.join(cwd, file)))] = true + end + end + + local result = {} + for file in pairs(files) do + result[#result + 1] = file + end + table.sort(result) + + return result +end + +function GitChanges.get_changes_since(cwd, base_ref) + if not GitChanges.is_git_repo(cwd) then + return nil, 'Not a git repository' + end + + local files = {} + + local cmd = string.format('git diff --name-only %q', base_ref) + local diff = run_git_command(cmd, cwd) + if diff then + for _, file in ipairs(diff) do + files[normalize(path.normpath(path.join(cwd, file)))] = true + end + end + + local uncommitted = GitChanges.get_changed_files(cwd) + if uncommitted then + for _, file in ipairs(uncommitted) do + files[file] = true + end + end + + local result = {} + for file in pairs(files) do + result[#result + 1] = file + end + table.sort(result) + + return result +end + +function GitChanges.filter_lua_files(files) + local result = {} + for _, file in ipairs(files) do + if file:match('%.lua$') then + result[#result + 1] = file + end + end + return result +end + +return GitChanges diff --git a/busted/modules/related/init.lua b/busted/modules/related/init.lua new file mode 100644 index 00000000..60d66a27 --- /dev/null +++ b/busted/modules/related/init.lua @@ -0,0 +1,201 @@ +local path = require 'pl.path' +local dir = require 'pl.dir' + +return function() + local PathResolver = require 'busted.modules.related.path_resolver' + local DependencyGraph = require 'busted.modules.related.dependency_graph' + local GitChanges = require 'busted.modules.related.git_changes' + + -- Normalize paths to forward slashes for cross-platform consistency + local function normalize(p) + return p:gsub('\\', '/') + end + + local function parse_file_list(files_str, cwd) + local files = {} + for file in files_str:gmatch('[^,]+') do + file = file:match('^%s*(.-)%s*$') + if file ~= '' then + if not path.isabs(file) then + file = path.join(cwd, file) + end + file = normalize(path.normpath(file)) + if path.isfile(file) then + files[#files + 1] = file + end + end + end + return files + end + + return function(rootFiles, patterns, options) + options = options or {} + local cwd = normalize(path.normpath(options.directory or './')) + local verbose = options.verbose + + local changed_files, err + local explicit_files = options.files + + if explicit_files and type(explicit_files) == 'string' and explicit_files ~= '' then + changed_files = parse_file_list(explicit_files, cwd) + + if #changed_files == 0 then + if verbose then + io.stdout:write('No valid files found in --related list.\n') + end + return {} + end + + if verbose then + io.stdout:write('Using explicit file list:\n') + for _, f in ipairs(changed_files) do + io.stdout:write(' ' .. f .. '\n') + end + end + else + if not GitChanges.is_git_repo(cwd) then + return nil, 'Not a git repository. Use --related=file1,file2,... to specify files directly.' + end + + if options.base and options.base ~= 'HEAD' then + changed_files, err = GitChanges.get_changes_since(cwd, options.base) + else + changed_files, err = GitChanges.get_changed_files(cwd) + end + + if not changed_files then + return nil, err + end + + changed_files = GitChanges.filter_lua_files(changed_files) + + if #changed_files == 0 then + if verbose then + io.stdout:write('No Lua files changed.\n') + end + return {} + end + + if verbose then + io.stdout:write('Changed Lua files:\n') + for _, f in ipairs(changed_files) do + io.stdout:write(' ' .. f .. '\n') + end + end + end + + local exclude_dirs = { + ['.git'] = true, ['node_modules'] = true, ['vendor'] = true, + ['.luarocks'] = true, ['luarocks'] = true, ['.cache'] = true, + } + + local function is_test_file(filepath, test_patterns) + local basename = path.basename(filepath) + for _, patt in ipairs(test_patterns) do + if basename:find(patt) then + return true + end + end + return false + end + + local function is_excluded(filepath, excludes) + if not excludes then return false end + local basename = path.basename(filepath) + for _, patt in ipairs(excludes) do + if patt ~= '' and basename:find(patt) then + return true + end + end + return false + end + + local all_files = {} + local all_files_set = {} + local test_files = {} + + local function add_file(normalized) + if all_files_set[normalized] then + return + end + + all_files_set[normalized] = true + all_files[#all_files + 1] = normalized + + if not is_excluded(normalized, options.excludes) and is_test_file(normalized, patterns) then + test_files[normalized] = true + end + end + + local function collect_from_path(root) + if path.isfile(root) then + if root:match('%.lua$') then + add_file(normalize(path.normpath(root))) + end + elseif path.isdir(root) then + local getfiles = options.recursive ~= false and dir.getallfiles or dir.getfiles + local files = getfiles(root) + for _, file in ipairs(files) do + if file:match('%.lua$') then + add_file(normalize(path.normpath(file))) + end + end + end + end + + for _, root in ipairs(rootFiles) do + collect_from_path(root) + end + + local entries = dir.getdirectories(cwd) + for _, entry in ipairs(entries) do + local dirname = path.basename(entry) + if not exclude_dirs[dirname] then + collect_from_path(entry) + end + end + + local cwd_files = dir.getfiles(cwd) + for _, file in ipairs(cwd_files) do + if file:match('%.lua$') then + add_file(normalize(path.normpath(file))) + end + end + + local lpath = options.lpath or package.path + local path_resolver = PathResolver.new(lpath, cwd) + path_resolver:set_known_files(all_files) + + local graph = DependencyGraph.new() + graph:build(all_files, path_resolver) + + if verbose then + local stats = graph:stats() + io.stdout:write(string.format('Dependency graph: %d files, %d edges\n', + stats.files, stats.edges)) + end + + local affected = graph:get_affected_tests(changed_files, test_files) + + for _, file in ipairs(changed_files) do + if test_files[file] then + affected[file] = true + end + end + + local result = {} + for file in pairs(affected) do + result[#result + 1] = file + end + table.sort(result) + + if verbose then + io.stdout:write(string.format('Found %d related test files:\n', #result)) + for _, f in ipairs(result) do + io.stdout:write(' ' .. f .. '\n') + end + end + + return result + end +end diff --git a/busted/modules/related/path_resolver.lua b/busted/modules/related/path_resolver.lua new file mode 100644 index 00000000..576c0ecf --- /dev/null +++ b/busted/modules/related/path_resolver.lua @@ -0,0 +1,97 @@ +local path = require 'pl.path' + +local PathResolver = {} +PathResolver.__index = PathResolver + +-- Normalize paths to forward slashes for cross-platform consistency +local function normalize(p) + return p:gsub('\\', '/') +end + +function PathResolver.new(package_path, cwd) + local self = setmetatable({}, PathResolver) + self.templates = {} + self.cwd = cwd or './' + self.known_files = {} + + package_path = package_path or package.path + for template in package_path:gmatch('[^;]+') do + local prefix, suffix = template:match('^(.-)%?(.*)$') + if prefix then + self.templates[#self.templates + 1] = { + prefix = prefix, + suffix = suffix, + } + end + end + + return self +end + +function PathResolver:set_known_files(files) + self.known_files = files or {} +end + +function PathResolver:resolve(module_name) + -- Always use forward slashes (all paths are normalized) + local path_name = module_name:gsub('%.', '/') + + for _, template in ipairs(self.templates) do + local candidate = template.prefix .. path_name .. template.suffix + candidate = path.normpath(path.join(self.cwd, candidate)) + + if path.isfile(candidate) then + return normalize(candidate) + end + end + + for _, template in ipairs(self.templates) do + if template.suffix == '.lua' then + local candidate = template.prefix .. path_name .. '/init' .. template.suffix + candidate = path.normpath(path.join(self.cwd, candidate)) + + if path.isfile(candidate) then + return normalize(candidate) + end + end + end + + -- Fallback: match against known files (all normalized to forward slashes) + if #self.known_files > 0 then + local suffix = '/' .. path_name .. '.lua' + local init_suffix = '/' .. path_name .. '/init.lua' + + for _, filepath in ipairs(self.known_files) do + if filepath:sub(-#suffix) == suffix then + return filepath + end + if filepath:sub(-#init_suffix) == init_suffix then + return filepath + end + if filepath == path_name .. '.lua' then + return filepath + end + end + end + + return nil +end + +function PathResolver:resolve_file(file_path) + if path.isabs(file_path) then + return normalize(path.normpath(file_path)) + end + + local candidate = path.normpath(path.join(self.cwd, file_path)) + if path.isfile(candidate) then + return normalize(candidate) + end + + return nil +end + +function PathResolver:get_templates() + return self.templates +end + +return PathResolver diff --git a/busted/modules/related/require_parser.lua b/busted/modules/related/require_parser.lua new file mode 100644 index 00000000..2336c958 --- /dev/null +++ b/busted/modules/related/require_parser.lua @@ -0,0 +1,169 @@ +local RequireParser = {} + +local MAX_FILE_SIZE = 1024 * 1024 + +local require_patterns = { + 'require%s*%(%s*["\']([^"\']+)["\']%s*%)', + 'require%s+["\']([^"\']+)["\']', +} + +local load_patterns = { + 'loadfile%s*%(%s*["\']([^"\']+)["\']%s*%)', + 'dofile%s*%(%s*["\']([^"\']+)["\']%s*%)', +} + +local function update_multiline_string_state(line, in_string, string_delim) + if in_string then + local close_pattern = '%]' .. string_delim .. '%]' + if line:find(close_pattern) then + return false, nil + end + return true, string_delim + else + local open_start, open_end, equals = line:find('%[(%=*)%[') + if open_start then + local close_pattern = '%]' .. equals .. '%]' + if not line:find(close_pattern, open_end + 1) then + return true, equals + end + end + return false, nil + end +end + +local function update_multiline_comment_state(line, in_comment, comment_delim) + if in_comment then + local close_pattern = '%]' .. comment_delim .. '%]' + if line:find(close_pattern) then + return false, nil + end + return true, comment_delim + else + local open_start, open_end, equals = line:find('%-%-%[(%=*)%[') + if open_start then + local close_pattern = '%]' .. equals .. '%]' + if not line:find(close_pattern, open_end + 1) then + return true, equals + end + end + return false, nil + end +end + +local function strip_single_line_comment(line) + local pos = 1 + while true do + local dash_start = line:find('%-%-', pos) + if not dash_start then + return line + end + if line:sub(dash_start, dash_start + 3):match('%-%-%[%=*%[') then + pos = dash_start + 2 + else + return line:sub(1, dash_start - 1) + end + end +end + +local function get_file_size(filepath) + local file = io.open(filepath, 'r') + if not file then + return nil + end + local size = file:seek('end') + file:close() + return size +end + +local function extract_requires_from_line(line, patterns) + local requires = {} + for _, pattern in ipairs(patterns) do + for module_name in line:gmatch(pattern) do + requires[module_name] = true + end + end + return requires +end + +function RequireParser.parse_file(filepath) + local size = get_file_size(filepath) + if size and size > MAX_FILE_SIZE then + return { + requires = {}, + loadfiles = {}, + skipped = true, + reason = 'File exceeds size limit (' .. size .. ' bytes > ' .. MAX_FILE_SIZE .. ' bytes)', + } + end + + local requires = {} + local loadfiles = {} + + local file = io.open(filepath, 'r') + if not file then + return nil, 'Cannot open file: ' .. filepath + end + + local in_multiline_string = false + local string_delim = nil + local in_multiline_comment = false + local comment_delim = nil + + for line in file:lines() do + in_multiline_comment, comment_delim = update_multiline_comment_state( + line, in_multiline_comment, comment_delim + ) + + if not in_multiline_comment then + in_multiline_string, string_delim = update_multiline_string_state( + line, in_multiline_string, string_delim + ) + + if not in_multiline_string then + local processed = strip_single_line_comment(line) + + local req = extract_requires_from_line(processed, require_patterns) + for module_name in pairs(req) do + requires[module_name] = true + end + + local load = extract_requires_from_line(processed, load_patterns) + for module_name in pairs(load) do + loadfiles[module_name] = true + end + end + end + end + + file:close() + + local require_list = {} + for module_name in pairs(requires) do + require_list[#require_list + 1] = module_name + end + table.sort(require_list) + + local loadfile_list = {} + for module_name in pairs(loadfiles) do + loadfile_list[#loadfile_list + 1] = module_name + end + table.sort(loadfile_list) + + return { + requires = require_list, + loadfiles = loadfile_list, + } +end + +function RequireParser.parse_files(filepaths) + local results = {} + for _, filepath in ipairs(filepaths) do + local parsed = RequireParser.parse_file(filepath) + if parsed then + results[filepath] = parsed + end + end + return results +end + +return RequireParser diff --git a/busted/runner.lua b/busted/runner.lua index 2b62ff77..73d839ae 100644 --- a/busted/runner.lua +++ b/busted/runner.lua @@ -199,6 +199,33 @@ return function(options) -- Load test directories/files local rootFiles = cliArgs.ROOT local patterns = cliArgs.pattern + + -- If --related flag or --related-files is set, filter to only related test files + if cliArgs.related or cliArgs['related-files'] then + local relatedLoader = require 'busted.modules.related'() + local filteredFiles, err = relatedLoader(rootFiles, patterns, { + excludes = cliArgs['exclude-pattern'], + recursive = cliArgs['recursive'], + lpath = cliArgs.lpath, + directory = cliArgs.directory, + base = cliArgs['related-base'], + verbose = cliArgs.verbose, + files = cliArgs['related-files'], + }) + + if err then + io.stderr:write(appName .. ': error: ' .. err .. '\n') + exit(1, forceExit) + end + + if #filteredFiles == 0 then + io.stdout:write('No tests related to git changes.\n') + exit(0, forceExit) + end + + rootFiles = filteredFiles + end + local testFileLoader = require 'busted.modules.test_file_loader'(busted, cliArgs.loaders) testFileLoader(rootFiles, patterns, { excludes = cliArgs['exclude-pattern'], From fa0860180f1eb0685f56273fee863b7a24e9fe7d Mon Sep 17 00:00:00 2001 From: Stephan Schubert Date: Tue, 20 Jan 2026 14:21:48 +0100 Subject: [PATCH 2/4] test(related): add unit and integration tests - 43 unit tests for RequireParser, PathResolver, DependencyGraph, GitChanges - 7 git integration tests with real temporary repos - Mock-based tests for deterministic git behavior - Test fixtures demonstrating dependency tracking --- spec/modules/related_git_integration_spec.lua | 106 +++ spec/modules/related_spec.lua | 730 ++++++++++++++++++ spec/related_fixtures/spec/core_spec.lua | 7 + spec/related_fixtures/spec/feature_spec.lua | 7 + spec/related_fixtures/spec/utils_spec.lua | 11 + spec/related_fixtures/src/core.lua | 9 + spec/related_fixtures/src/feature.lua | 9 + spec/related_fixtures/src/utils.lua | 11 + 8 files changed, 890 insertions(+) create mode 100644 spec/modules/related_git_integration_spec.lua create mode 100644 spec/modules/related_spec.lua create mode 100644 spec/related_fixtures/spec/core_spec.lua create mode 100644 spec/related_fixtures/spec/feature_spec.lua create mode 100644 spec/related_fixtures/spec/utils_spec.lua create mode 100644 spec/related_fixtures/src/core.lua create mode 100644 spec/related_fixtures/src/feature.lua create mode 100644 spec/related_fixtures/src/utils.lua diff --git a/spec/modules/related_git_integration_spec.lua b/spec/modules/related_git_integration_spec.lua new file mode 100644 index 00000000..281439c8 --- /dev/null +++ b/spec/modules/related_git_integration_spec.lua @@ -0,0 +1,106 @@ +local path = require 'pl.path' +local dir = require 'pl.dir' +local GitChanges = require 'busted.modules.related.git_changes' + +describe('GitChanges integration', function() + local test_dir + + local function run(cmd) + os.execute('cd ' .. test_dir .. ' && ' .. cmd .. ' 2>/dev/null') + end + + local function write_file(name, content) + local f = io.open(path.join(test_dir, name), 'w') + f:write(content or '') + f:close() + end + + before_each(function() + test_dir = os.tmpname() .. '_git_test' + os.remove(test_dir) + dir.makepath(test_dir) + run('git init') + run('git config user.email "test@test.com"') + run('git config user.name "Test"') + end) + + after_each(function() + dir.rmtree(test_dir) + end) + + it('detects no changes in clean repo', function() + write_file('init.lua', 'return {}') + run('git add . && git commit -m "init"') + + local files = GitChanges.get_changed_files(test_dir) + assert.equals(0, #files) + end) + + it('detects unstaged changes', function() + write_file('foo.lua', 'return 1') + run('git add . && git commit -m "init"') + write_file('foo.lua', 'return 2') + + local files = GitChanges.get_changed_files(test_dir) + assert.equals(1, #files) + assert.matches('foo%.lua$', files[1]) + end) + + it('detects staged changes', function() + write_file('foo.lua', 'return 1') + run('git add . && git commit -m "init"') + write_file('foo.lua', 'return 2') + run('git add foo.lua') + + local files = GitChanges.get_changed_files(test_dir) + assert.equals(1, #files) + end) + + it('detects untracked files', function() + write_file('tracked.lua', '') + run('git add . && git commit -m "init"') + write_file('untracked.lua', '') + + local files = GitChanges.get_changed_files(test_dir) + assert.equals(1, #files) + assert.matches('untracked%.lua$', files[1]) + end) + + it('detects changes since commit', function() + write_file('old.lua', '') + run('git add . && git commit -m "old"') + write_file('new.lua', '') + run('git add . && git commit -m "new"') + + local files = GitChanges.get_changes_since(test_dir, 'HEAD~1') + assert.equals(1, #files) + assert.matches('new%.lua$', files[1]) + end) + + it('handles multiple changes at once', function() + write_file('existing.lua', 'return 1') + run('git add . && git commit -m "init"') + + -- Make various types of changes + write_file('existing.lua', 'return 2') -- Modified (unstaged) + write_file('staged.lua', 'return {}') + run('git add staged.lua') -- Staged new file + write_file('untracked.lua', '') -- Untracked + + local files = GitChanges.get_changed_files(test_dir) + assert.equals(3, #files) + end) + + it('returns paths normalized to forward slashes', function() + dir.makepath(path.join(test_dir, 'src', 'nested')) + write_file('src/nested/file.lua', 'return 1') + run('git add . && git commit -m "init"') + write_file('src/nested/file.lua', 'return 2') + + local files = GitChanges.get_changed_files(test_dir) + assert.equals(1, #files) + -- Should contain forward slashes, not backslashes + assert.truthy(files[1]:match('src/nested/file%.lua$')) + assert.falsy(files[1]:match('\\')) + end) +end) diff --git a/spec/modules/related_spec.lua b/spec/modules/related_spec.lua new file mode 100644 index 00000000..c5cb4f51 --- /dev/null +++ b/spec/modules/related_spec.lua @@ -0,0 +1,730 @@ +local path = require 'pl.path' +local dir = require 'pl.dir' + +describe('Related tests feature', function() + local RequireParser = require 'busted.modules.related.require_parser' + local PathResolver = require 'busted.modules.related.path_resolver' + local DependencyGraph = require 'busted.modules.related.dependency_graph' + local GitChanges = require 'busted.modules.related.git_changes' + + describe('RequireParser', function() + describe('parse_file', function() + it('parses basic require statements', function() + local test_file = path.tmpname() + local f = io.open(test_file, 'w') + f:write('local foo = require("mymodule")\n') + f:close() + + local result = RequireParser.parse_file(test_file) + os.remove(test_file) + + assert.is_not_nil(result) + assert.are.equal(1, #result.requires) + assert.are.equal('mymodule', result.requires[1]) + end) + + it('parses require with single quotes', function() + local test_file = path.tmpname() + local f = io.open(test_file, 'w') + f:write("local foo = require('othermodule')\n") + f:close() + + local result = RequireParser.parse_file(test_file) + os.remove(test_file) + + assert.is_not_nil(result) + assert.are.equal(1, #result.requires) + assert.are.equal('othermodule', result.requires[1]) + end) + + it('parses require without parentheses', function() + local test_file = path.tmpname() + local f = io.open(test_file, 'w') + f:write('local foo = require "noparens"\n') + f:close() + + local result = RequireParser.parse_file(test_file) + os.remove(test_file) + + assert.is_not_nil(result) + assert.are.equal(1, #result.requires) + assert.are.equal('noparens', result.requires[1]) + end) + + it('handles dotted module names', function() + local test_file = path.tmpname() + local f = io.open(test_file, 'w') + f:write('local foo = require("module.submodule.deep")\n') + f:close() + + local result = RequireParser.parse_file(test_file) + os.remove(test_file) + + assert.is_not_nil(result) + assert.are.equal(1, #result.requires) + assert.are.equal('module.submodule.deep', result.requires[1]) + end) + + it('parses multiple requires in one file', function() + local test_file = path.tmpname() + local f = io.open(test_file, 'w') + f:write('local a = require("mod_a")\n') + f:write('local b = require("mod_b")\n') + f:write('local c = require("mod_c")\n') + f:close() + + local result = RequireParser.parse_file(test_file) + os.remove(test_file) + + assert.is_not_nil(result) + assert.are.equal(3, #result.requires) + end) + + it('ignores requires in single-line comments', function() + local test_file = path.tmpname() + local f = io.open(test_file, 'w') + f:write('-- local foo = require("commented")\n') + f:write('local real = require("real_module")\n') + f:close() + + local result = RequireParser.parse_file(test_file) + os.remove(test_file) + + assert.is_not_nil(result) + assert.are.equal(1, #result.requires) + assert.are.equal('real_module', result.requires[1]) + end) + + it('ignores requires in multiline comments', function() + local test_file = path.tmpname() + local f = io.open(test_file, 'w') + f:write('--[[\n') + f:write('local foo = require("in_comment")\n') + f:write(']]\n') + f:write('local real = require("real_module")\n') + f:close() + + local result = RequireParser.parse_file(test_file) + os.remove(test_file) + + assert.is_not_nil(result) + assert.are.equal(1, #result.requires) + assert.are.equal('real_module', result.requires[1]) + end) + + it('ignores requires in multiline comments with equals', function() + local test_file = path.tmpname() + local f = io.open(test_file, 'w') + f:write('--[=[\n') + f:write('local foo = require("in_comment")\n') + f:write(']=]\n') + f:write('local real = require("real_module")\n') + f:close() + + local result = RequireParser.parse_file(test_file) + os.remove(test_file) + + assert.is_not_nil(result) + assert.are.equal(1, #result.requires) + assert.are.equal('real_module', result.requires[1]) + end) + + it('parses loadfile statements', function() + local test_file = path.tmpname() + local f = io.open(test_file, 'w') + f:write('local chunk = loadfile("other.lua")\n') + f:close() + + local result = RequireParser.parse_file(test_file) + os.remove(test_file) + + assert.is_not_nil(result) + assert.are.equal(1, #result.loadfiles) + assert.are.equal('other.lua', result.loadfiles[1]) + end) + + it('parses dofile statements', function() + local test_file = path.tmpname() + local f = io.open(test_file, 'w') + f:write('dofile("helper.lua")\n') + f:close() + + local result = RequireParser.parse_file(test_file) + os.remove(test_file) + + assert.is_not_nil(result) + assert.are.equal(1, #result.loadfiles) + assert.are.equal('helper.lua', result.loadfiles[1]) + end) + + it('handles empty files', function() + local test_file = path.tmpname() + local f = io.open(test_file, 'w') + f:write('') + f:close() + + local result = RequireParser.parse_file(test_file) + os.remove(test_file) + + assert.is_not_nil(result) + assert.are.equal(0, #result.requires) + assert.are.equal(0, #result.loadfiles) + end) + + it('handles files with only comments', function() + local test_file = path.tmpname() + local f = io.open(test_file, 'w') + f:write('-- This is a comment\n') + f:write('-- Another comment\n') + f:write('--[[ Block comment ]]\n') + f:close() + + local result = RequireParser.parse_file(test_file) + os.remove(test_file) + + assert.is_not_nil(result) + assert.are.equal(0, #result.requires) + end) + + it('handles files with syntax that might confuse the parser', function() + local test_file = path.tmpname() + local f = io.open(test_file, 'w') + f:write('local x = "--require(\\"fake\\")"\n') -- String containing require + f:write('local y = require("real")\n') + f:close() + + local result = RequireParser.parse_file(test_file) + os.remove(test_file) + + assert.is_not_nil(result) + -- Should find at least the real require + local found_real = false + for _, req in ipairs(result.requires) do + if req == 'real' then found_real = true end + end + assert.is_true(found_real) + end) + + it('returns nil for non-existent files', function() + local result, err = RequireParser.parse_file('/nonexistent/file.lua') + assert.is_nil(result) + assert.is_not_nil(err) + end) + + it('handles mixed comment styles', function() + local test_file = path.tmpname() + local f = io.open(test_file, 'w') + f:write('-- Single line comment\n') + f:write('local a = require("mod_a")\n') + f:write('--[[\nBlock comment\n]]\n') + f:write('local b = require("mod_b")\n') + f:write('-- Another single line\n') + f:write('--[=[\nEquals block\n]=]\n') + f:write('local c = require("mod_c")\n') + f:close() + + local result = RequireParser.parse_file(test_file) + os.remove(test_file) + + assert.is_not_nil(result) + assert.are.equal(3, #result.requires) + end) + end) + end) + + describe('PathResolver', function() + it('resolves simple module names', function() + local tmpdir = path.tmpname() + os.remove(tmpdir) + dir.makepath(tmpdir) + + local module_file = path.join(tmpdir, 'mymodule.lua') + local f = io.open(module_file, 'w') + f:write('return {}\n') + f:close() + + local resolver = PathResolver.new(tmpdir .. '/?.lua', tmpdir) + local resolved = resolver:resolve('mymodule') + + os.remove(module_file) + dir.rmtree(tmpdir) + + assert.is_not_nil(resolved) + assert.truthy(resolved:match('mymodule%.lua$')) + end) + + it('resolves dotted module names to paths', function() + local tmpdir = path.tmpname() + os.remove(tmpdir) + dir.makepath(tmpdir .. '/sub') + + local module_file = path.join(tmpdir, 'sub', 'module.lua') + local f = io.open(module_file, 'w') + f:write('return {}\n') + f:close() + + local resolver = PathResolver.new(tmpdir .. '/?.lua', tmpdir) + local resolved = resolver:resolve('sub.module') + + os.remove(module_file) + dir.rmtree(tmpdir) + + assert.is_not_nil(resolved) + assert.truthy(resolved:match('sub/module%.lua$') or resolved:match('sub\\module%.lua$')) + end) + + it('returns nil for external modules', function() + local resolver = PathResolver.new('./?.lua', './') + local resolved = resolver:resolve('nonexistent_module_xyz') + + assert.is_nil(resolved) + end) + + it('resolves init.lua convention', function() + local tmpdir = path.tmpname() + os.remove(tmpdir) + dir.makepath(tmpdir .. '/mymodule') + + local init_file = path.join(tmpdir, 'mymodule', 'init.lua') + local f = io.open(init_file, 'w') + f:write('return {}\n') + f:close() + + local resolver = PathResolver.new(tmpdir .. '/?.lua;' .. tmpdir .. '/?/init.lua', tmpdir) + local resolved = resolver:resolve('mymodule') + + os.remove(init_file) + dir.rmtree(tmpdir) + + assert.is_not_nil(resolved) + assert.truthy(resolved:match('init%.lua$')) + end) + + it('uses fallback known files when template matching fails', function() + local tmpdir = path.tmpname() + os.remove(tmpdir) + dir.makepath(tmpdir .. '/nonstandard') + + local module_file = path.join(tmpdir, 'nonstandard', 'mymod.lua') + local f = io.open(module_file, 'w') + f:write('return {}\n') + f:close() + + local resolver = PathResolver.new('./?.lua', tmpdir) + resolver:set_known_files({ path.normpath(module_file) }) + + local resolved = resolver:resolve('mymod') + + os.remove(module_file) + dir.rmtree(tmpdir) + + assert.is_not_nil(resolved) + assert.truthy(resolved:match('mymod%.lua$')) + end) + + it('resolves file paths for loadfile', function() + local tmpdir = path.tmpname() + os.remove(tmpdir) + dir.makepath(tmpdir) + + local test_file = path.join(tmpdir, 'helper.lua') + local f = io.open(test_file, 'w') + f:write('return {}\n') + f:close() + + local resolver = PathResolver.new('./?.lua', tmpdir) + local resolved = resolver:resolve_file('helper.lua') + + os.remove(test_file) + dir.rmtree(tmpdir) + + assert.is_not_nil(resolved) + assert.truthy(resolved:match('helper%.lua$')) + end) + end) + + describe('DependencyGraph', function() + it('builds forward edges correctly', function() + local graph = DependencyGraph.new() + + -- Manually set up forward edges for testing + graph.forward['/a.lua'] = { '/b.lua', '/c.lua' } + graph.forward['/b.lua'] = { '/c.lua' } + graph.forward['/c.lua'] = {} + + local deps_a = graph:get_direct_dependencies('/a.lua') + assert.are.equal(2, #deps_a) + + local deps_c = graph:get_direct_dependencies('/c.lua') + assert.are.equal(0, #deps_c) + end) + + it('builds reverse edges correctly', function() + local graph = DependencyGraph.new() + + graph.forward['/a.lua'] = { '/b.lua' } + graph.forward['/b.lua'] = { '/c.lua' } + graph.forward['/c.lua'] = {} + graph.reverse['/b.lua'] = { '/a.lua' } + graph.reverse['/c.lua'] = { '/b.lua' } + + local dependents_c = graph:get_direct_dependents('/c.lua') + assert.are.equal(1, #dependents_c) + assert.are.equal('/b.lua', dependents_c[1]) + end) + + it('finds transitively affected tests', function() + local graph = DependencyGraph.new() + + -- Set up: a_spec -> a -> b -> c + graph.forward['/spec/a_spec.lua'] = { '/src/a.lua' } + graph.forward['/src/a.lua'] = { '/src/b.lua' } + graph.forward['/src/b.lua'] = { '/src/c.lua' } + graph.forward['/src/c.lua'] = {} + + graph.reverse['/src/a.lua'] = { '/spec/a_spec.lua' } + graph.reverse['/src/b.lua'] = { '/src/a.lua' } + graph.reverse['/src/c.lua'] = { '/src/b.lua' } + + local test_files = { ['/spec/a_spec.lua'] = true } + local affected = graph:get_affected_tests({ '/src/c.lua' }, test_files) + + assert.is_not_nil(affected['/spec/a_spec.lua']) + end) + + it('handles circular dependencies', function() + local graph = DependencyGraph.new() + + -- Circular: a -> b -> c -> a + graph.forward['/a.lua'] = { '/b.lua' } + graph.forward['/b.lua'] = { '/c.lua' } + graph.forward['/c.lua'] = { '/a.lua' } + + graph.reverse['/b.lua'] = { '/a.lua' } + graph.reverse['/c.lua'] = { '/b.lua' } + graph.reverse['/a.lua'] = { '/c.lua' } + + -- Should not infinite loop + local affected = graph:get_affected_files({ '/a.lua' }) + assert.is_not_nil(affected['/a.lua']) + assert.is_not_nil(affected['/b.lua']) + assert.is_not_nil(affected['/c.lua']) + end) + + it('handles diamond dependencies', function() + local graph = DependencyGraph.new() + + -- Diamond: A -> B -> D, A -> C -> D + graph.forward['/a.lua'] = { '/b.lua', '/c.lua' } + graph.forward['/b.lua'] = { '/d.lua' } + graph.forward['/c.lua'] = { '/d.lua' } + graph.forward['/d.lua'] = {} + + graph.reverse['/b.lua'] = { '/a.lua' } + graph.reverse['/c.lua'] = { '/a.lua' } + graph.reverse['/d.lua'] = { '/b.lua', '/c.lua' } + + local affected = graph:get_affected_files({ '/d.lua' }) + assert.is_not_nil(affected['/a.lua']) + assert.is_not_nil(affected['/b.lua']) + assert.is_not_nil(affected['/c.lua']) + assert.is_not_nil(affected['/d.lua']) + end) + + it('handles self-referential files', function() + local graph = DependencyGraph.new() + + -- Self-referential: a -> a + graph.forward['/a.lua'] = { '/a.lua' } + graph.reverse['/a.lua'] = { '/a.lua' } + + -- Should not infinite loop + local affected = graph:get_affected_files({ '/a.lua' }) + assert.is_not_nil(affected['/a.lua']) + end) + + it('handles large graphs efficiently', function() + local graph = DependencyGraph.new() + + -- Create a large linear chain + local count = 100 + for i = 1, count do + local file = '/file_' .. i .. '.lua' + if i < count then + graph.forward[file] = { '/file_' .. (i + 1) .. '.lua' } + else + graph.forward[file] = {} + end + if i > 1 then + graph.reverse[file] = { '/file_' .. (i - 1) .. '.lua' } + end + end + + -- Changing the last file should affect all files + local affected = graph:get_affected_files({ '/file_' .. count .. '.lua' }) + + -- All files should be affected + for i = 1, count do + assert.is_not_nil(affected['/file_' .. i .. '.lua']) + end + end) + + it('returns empty for unresolved externals', function() + local graph = DependencyGraph.new() + + graph.forward['/a.lua'] = {} -- Has no dependencies + + local affected = graph:get_affected_files({ '/external.lua' }) + assert.is_not_nil(affected['/external.lua']) -- Changed file is always affected + assert.is_nil(affected['/a.lua']) -- But unrelated files are not + end) + + it('provides correct stats', function() + local graph = DependencyGraph.new() + + graph.forward['/a.lua'] = { '/b.lua', '/c.lua' } + graph.forward['/b.lua'] = { '/c.lua' } + graph.forward['/c.lua'] = {} + + local stats = graph:stats() + assert.are.equal(3, stats.files) + assert.are.equal(3, stats.edges) -- a->b, a->c, b->c + end) + end) + + describe('GitChanges', function() + it('detects if directory is a git repo', function() + -- The busted repo itself should be a git repo + local is_repo = GitChanges.is_git_repo('./') + assert.is_true(is_repo) + end) + + it('returns false for non-git directory', function() + local is_repo = GitChanges.is_git_repo('/tmp') + -- /tmp is usually not a git repo - returns false or nil + assert.falsy(is_repo) + end) + + it('filters lua files correctly', function() + local files = { + '/a.lua', + '/b.txt', + '/c.lua', + '/d.md', + '/e.luac', -- Compiled Lua, not source + '/f.lua.bak', -- Backup file + } + local lua_files = GitChanges.filter_lua_files(files) + + assert.are.equal(2, #lua_files) + assert.are.equal('/a.lua', lua_files[1]) + assert.are.equal('/c.lua', lua_files[2]) + end) + + it('handles empty file list', function() + local lua_files = GitChanges.filter_lua_files({}) + assert.are.equal(0, #lua_files) + end) + + it('gets git root directory', function() + local root = GitChanges.get_git_root('./') + assert.is_not_nil(root) + assert.truthy(path.isdir(root)) + end) + end) + + describe('GitChanges with mocks', function() + local original_run_command + + before_each(function() + original_run_command = GitChanges._run_command + end) + + after_each(function() + GitChanges._run_command = original_run_command + end) + + it('returns error for non-git repo', function() + GitChanges._run_command = function(cmd) + if cmd:match('rev%-parse') then + return nil, 'fatal: not a git repository' + end + return {} + end + + local files, err = GitChanges.get_changed_files('/tmp') + assert.is_nil(files) + assert.matches('Not a git', err) + end) + + it('returns changed files from git output', function() + GitChanges._run_command = function(cmd) + if cmd:match('rev%-parse.*is%-inside') then + return { 'true' } + elseif cmd:match('diff %-%-name%-only$') then + return { 'src/foo.lua', 'src/bar.lua' } + elseif cmd:match('diff %-%-cached') then + return { 'src/staged.lua' } + elseif cmd:match('ls%-files') then + return { 'new_file.lua' } + end + return {} + end + + local files = GitChanges.get_changed_files('/project') + assert.is_not_nil(files) + assert.equals(4, #files) + end) + + it('handles git command failure gracefully', function() + GitChanges._run_command = function(cmd) + if cmd:match('rev%-parse.*is%-inside') then + return { 'true' } + end + return nil, 'fatal: ambiguous argument' + end + + -- When individual git commands fail, the function continues and returns + -- empty results rather than failing entirely + local files = GitChanges.get_changes_since('/project', 'bad-ref') + assert.is_not_nil(files) + assert.equals(0, #files) + end) + + it('normalizes paths with backslashes', function() + GitChanges._run_command = function(cmd) + if cmd:match('rev%-parse.*is%-inside') then + return { 'true' } + elseif cmd:match('diff %-%-name%-only$') then + return { 'src\\foo.lua' } -- Windows-style path + elseif cmd:match('diff %-%-cached') then + return {} + elseif cmd:match('ls%-files') then + return {} + end + return {} + end + + local files = GitChanges.get_changed_files('/project') + assert.is_not_nil(files) + assert.equals(1, #files) + -- Path should be normalized to forward slashes + assert.truthy(files[1]:match('/src/foo%.lua$')) + assert.falsy(files[1]:match('\\')) + end) + + it('deduplicates files across staged and unstaged', function() + GitChanges._run_command = function(cmd) + if cmd:match('rev%-parse.*is%-inside') then + return { 'true' } + elseif cmd:match('diff %-%-name%-only$') then + return { 'src/same.lua' } -- Unstaged + elseif cmd:match('diff %-%-cached') then + return { 'src/same.lua' } -- Also staged + elseif cmd:match('ls%-files') then + return {} + end + return {} + end + + local files = GitChanges.get_changed_files('/project') + assert.is_not_nil(files) + assert.equals(1, #files) -- Should be deduplicated + end) + end) + + describe('Integration: related module', function() + local relatedLoader + + -- Try to load the module, skip if not available + local ok, relatedModule = pcall(require, 'busted.modules.related') + if ok then + relatedLoader = relatedModule() + end + + it('returns empty array when no changes detected', function() + if not relatedLoader then + pending('busted.modules.related not installed') + return + end + + -- This test may vary depending on git state + -- We're mainly testing that the function runs without error + local result, err = relatedLoader({ 'spec' }, { '_spec' }, { + directory = './', + verbose = false, + }) + + if err then + -- If there's an error (e.g., git issues), that's acceptable + assert.is_string(err) + else + assert.is_table(result) + end + end) + + it('handles non-existent root directories gracefully', function() + if not relatedLoader then + pending('busted.modules.related not installed') + return + end + + local result, err = relatedLoader({ '/nonexistent/path' }, { '_spec' }, { + directory = './', + verbose = false, + }) + + -- Should still return a result (possibly empty) + if err then + assert.is_string(err) + else + assert.is_table(result) + end + end) + end) + + describe('Integration: fixtures', function() + local fixtures_dir = 'spec/related_fixtures' + + -- Only run these tests if fixtures exist + local function fixtures_exist() + return path.isdir(fixtures_dir) + end + + it('discovers dependencies from fixtures', function() + if not fixtures_exist() then + pending('Fixtures directory not found') + return + end + + local graph = DependencyGraph.new() + local resolver = PathResolver.new( + fixtures_dir .. '/src/?.lua;' .. fixtures_dir .. '/spec/?.lua', + fixtures_dir + ) + + -- Get all Lua files in fixtures + local all_files = {} + if path.isdir(fixtures_dir .. '/src') then + for _, f in ipairs(dir.getallfiles(fixtures_dir .. '/src')) do + if f:match('%.lua$') then + all_files[#all_files + 1] = path.normpath(f) + end + end + end + if path.isdir(fixtures_dir .. '/spec') then + for _, f in ipairs(dir.getallfiles(fixtures_dir .. '/spec')) do + if f:match('%.lua$') then + all_files[#all_files + 1] = path.normpath(f) + end + end + end + + resolver:set_known_files(all_files) + graph:build(all_files, resolver) + + local stats = graph:stats() + assert.is_true(stats.files > 0, 'Should have discovered some files') + end) + end) +end) diff --git a/spec/related_fixtures/spec/core_spec.lua b/spec/related_fixtures/spec/core_spec.lua new file mode 100644 index 00000000..ea2fd4c4 --- /dev/null +++ b/spec/related_fixtures/spec/core_spec.lua @@ -0,0 +1,7 @@ +local core = require 'spec.related_fixtures.src.core' + +describe('core', function() + it('calculates correctly', function() + assert.are.equal(10, core.calculate(2, 3)) + end) +end) diff --git a/spec/related_fixtures/spec/feature_spec.lua b/spec/related_fixtures/spec/feature_spec.lua new file mode 100644 index 00000000..b4277417 --- /dev/null +++ b/spec/related_fixtures/spec/feature_spec.lua @@ -0,0 +1,7 @@ +local feature = require 'spec.related_fixtures.src.feature' + +describe('feature', function() + it('processes correctly', function() + assert.are.equal(20, feature.process(2, 3)) + end) +end) diff --git a/spec/related_fixtures/spec/utils_spec.lua b/spec/related_fixtures/spec/utils_spec.lua new file mode 100644 index 00000000..0c52cedb --- /dev/null +++ b/spec/related_fixtures/spec/utils_spec.lua @@ -0,0 +1,11 @@ +local utils = require 'spec.related_fixtures.src.utils' + +describe('utils', function() + it('adds numbers', function() + assert.are.equal(5, utils.add(2, 3)) + end) + + it('subtracts numbers', function() + assert.are.equal(1, utils.subtract(3, 2)) + end) +end) diff --git a/spec/related_fixtures/src/core.lua b/spec/related_fixtures/src/core.lua new file mode 100644 index 00000000..1a10a93f --- /dev/null +++ b/spec/related_fixtures/src/core.lua @@ -0,0 +1,9 @@ +local utils = require 'spec.related_fixtures.src.utils' + +local core = {} + +function core.calculate(a, b) + return utils.add(a, b) * 2 +end + +return core diff --git a/spec/related_fixtures/src/feature.lua b/spec/related_fixtures/src/feature.lua new file mode 100644 index 00000000..21e43049 --- /dev/null +++ b/spec/related_fixtures/src/feature.lua @@ -0,0 +1,9 @@ +local core = require 'spec.related_fixtures.src.core' + +local feature = {} + +function feature.process(a, b) + return core.calculate(a, b) + 10 +end + +return feature diff --git a/spec/related_fixtures/src/utils.lua b/spec/related_fixtures/src/utils.lua new file mode 100644 index 00000000..8b306edc --- /dev/null +++ b/spec/related_fixtures/src/utils.lua @@ -0,0 +1,11 @@ +local utils = {} + +function utils.add(a, b) + return a + b +end + +function utils.subtract(a, b) + return a - b +end + +return utils From d28962b1c4758e6d878264ec9f9086e2329f08f5 Mon Sep 17 00:00:00 2001 From: Stephan Schubert Date: Tue, 20 Jan 2026 14:21:59 +0100 Subject: [PATCH 3/4] docs: document --related flag usage in README --- README.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/README.md b/README.md index 708bb48e..3766deef 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,50 @@ luarocks make busted spec ``` +Running Related Tests +-------------------- + +The `--related` option allows you to run only the tests that are affected by recent code changes. This is useful for faster feedback during development. + +### Basic Usage + +Run tests related to uncommitted git changes: + +```bash +busted --related +``` + +Run tests related to specific files (skips git, works anywhere): + +```bash +busted --related-files=src/mymodule.lua,src/utils.lua +``` + +Compare against a specific git ref instead of HEAD: + +```bash +busted --related --related-base=main +``` + +### How It Works + +1. **Detects changed files** - Either from git (staged, unstaged, and untracked) or from an explicit file list +2. **Builds a dependency graph** - Parses all Lua files to find `require()`, `loadfile()`, and `dofile()` statements +3. **Finds affected tests** - Uses transitive closure to find all test files that depend on the changed files + +### Known Limitations + +- **Dynamic requires are not detected** - Calls like `require(variable)` or `require("prefix" .. name)` cannot be statically analyzed +- **External modules are ignored** - Only project-local files are tracked in the dependency graph +- **Large files skipped** - Files over 1MB are skipped to avoid performance issues (likely generated/minified code) + +### Performance Considerations + +For large codebases: +- First run may take a few seconds to build the dependency graph +- Use `--verbose` to see dependency graph statistics +- Consider using `--related=files` with explicit file lists for faster execution in CI + Docker ------ From 0f1b28f2a56958886f1797008bed57b3f5e5996b Mon Sep 17 00:00:00 2001 From: Stephan Schubert Date: Tue, 20 Jan 2026 14:45:35 +0100 Subject: [PATCH 4/4] refactor(related): improve error handling and test consistency Add command context to error messages in git_changes for better debugging. Add input validation for rootFiles/patterns parameters in init.lua. Add optional verbose logging for parse failures in dependency_graph. Fix assertion style in integration tests (assert.equals -> assert.are.equal). --- busted/modules/related/dependency_graph.lua | 10 ++++++++-- busted/modules/related/git_changes.lua | 4 ++-- busted/modules/related/init.lua | 9 ++++++++- spec/modules/related_git_integration_spec.lua | 14 +++++++------- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/busted/modules/related/dependency_graph.lua b/busted/modules/related/dependency_graph.lua index df9a5b78..c6f13c7a 100644 --- a/busted/modules/related/dependency_graph.lua +++ b/busted/modules/related/dependency_graph.lua @@ -11,9 +11,15 @@ function DependencyGraph.new() return self end -function DependencyGraph:build(files, path_resolver) +function DependencyGraph:build(files, path_resolver, options) + options = options or {} + local verbose = options.verbose + for _, filepath in ipairs(files) do - local parsed = RequireParser.parse_file(filepath) + local parsed, err = RequireParser.parse_file(filepath) + if not parsed and verbose then + io.stderr:write('Warning: Failed to parse ' .. filepath .. ': ' .. (err or 'unknown error') .. '\n') + end if parsed then self.forward[filepath] = {} diff --git a/busted/modules/related/git_changes.lua b/busted/modules/related/git_changes.lua index a826119a..def41c02 100644 --- a/busted/modules/related/git_changes.lua +++ b/busted/modules/related/git_changes.lua @@ -17,7 +17,7 @@ local function default_run_command(cmd, cwd) local handle = io.popen(full_cmd) if not handle then - return nil, 'Failed to execute command' + return nil, 'Failed to execute command: ' .. cmd end local lines = {} @@ -33,7 +33,7 @@ local function default_run_command(cmd, cwd) if has_error or (exit_code and exit_code ~= 0) then local err_msg = table.concat(lines, '\n') - return nil, err_msg ~= '' and err_msg or 'Git command failed' + return nil, err_msg ~= '' and err_msg or ('Git command failed: ' .. cmd) end return lines diff --git a/busted/modules/related/init.lua b/busted/modules/related/init.lua index 60d66a27..c495af11 100644 --- a/busted/modules/related/init.lua +++ b/busted/modules/related/init.lua @@ -29,6 +29,13 @@ return function() end return function(rootFiles, patterns, options) + if type(rootFiles) ~= 'table' then + return nil, 'rootFiles must be a table' + end + if type(patterns) ~= 'table' then + return nil, 'patterns must be a table' + end + options = options or {} local cwd = normalize(path.normpath(options.directory or './')) local verbose = options.verbose @@ -167,7 +174,7 @@ return function() path_resolver:set_known_files(all_files) local graph = DependencyGraph.new() - graph:build(all_files, path_resolver) + graph:build(all_files, path_resolver, { verbose = verbose }) if verbose then local stats = graph:stats() diff --git a/spec/modules/related_git_integration_spec.lua b/spec/modules/related_git_integration_spec.lua index 281439c8..c9f4afc4 100644 --- a/spec/modules/related_git_integration_spec.lua +++ b/spec/modules/related_git_integration_spec.lua @@ -33,7 +33,7 @@ describe('GitChanges integration', function() run('git add . && git commit -m "init"') local files = GitChanges.get_changed_files(test_dir) - assert.equals(0, #files) + assert.are.equal(0, #files) end) it('detects unstaged changes', function() @@ -42,7 +42,7 @@ describe('GitChanges integration', function() write_file('foo.lua', 'return 2') local files = GitChanges.get_changed_files(test_dir) - assert.equals(1, #files) + assert.are.equal(1, #files) assert.matches('foo%.lua$', files[1]) end) @@ -53,7 +53,7 @@ describe('GitChanges integration', function() run('git add foo.lua') local files = GitChanges.get_changed_files(test_dir) - assert.equals(1, #files) + assert.are.equal(1, #files) end) it('detects untracked files', function() @@ -62,7 +62,7 @@ describe('GitChanges integration', function() write_file('untracked.lua', '') local files = GitChanges.get_changed_files(test_dir) - assert.equals(1, #files) + assert.are.equal(1, #files) assert.matches('untracked%.lua$', files[1]) end) @@ -73,7 +73,7 @@ describe('GitChanges integration', function() run('git add . && git commit -m "new"') local files = GitChanges.get_changes_since(test_dir, 'HEAD~1') - assert.equals(1, #files) + assert.are.equal(1, #files) assert.matches('new%.lua$', files[1]) end) @@ -88,7 +88,7 @@ describe('GitChanges integration', function() write_file('untracked.lua', '') -- Untracked local files = GitChanges.get_changed_files(test_dir) - assert.equals(3, #files) + assert.are.equal(3, #files) end) it('returns paths normalized to forward slashes', function() @@ -98,7 +98,7 @@ describe('GitChanges integration', function() write_file('src/nested/file.lua', 'return 2') local files = GitChanges.get_changed_files(test_dir) - assert.equals(1, #files) + assert.are.equal(1, #files) -- Should contain forward slashes, not backslashes assert.truthy(files[1]:match('src/nested/file%.lua$')) assert.falsy(files[1]:match('\\'))