diff --git a/Cargo.lock b/Cargo.lock index 3a44077..49b4860 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -568,6 +568,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "shlex" version = "2.0.1" @@ -641,9 +650,11 @@ dependencies = [ "anyhow", "bincode", "blake3", + "globset", "ignore", "serde", "tempfile", + "toml", "tree-sitter", "tree-sitter-typescript", ] @@ -675,6 +686,45 @@ dependencies = [ "tree-sitter-typescript", ] +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tree-sitter" version = "0.26.11" @@ -780,6 +830,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "zmij" version = "1.0.23" diff --git a/README.md b/README.md index e514ebf..5046ec1 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ widens instead of guessing, and never silently skips a test that could fail. | Ecosystem | Command | |---|---| | JS / TS | `npm i -D testless-cli` (bin: `testless`) | -| Go | `curl -fsSL https://testless.itaywol.tools/install.sh \| sh` | +| Go | `curl -fsSL https://testless.itaywol.tools/install.sh \| sh` or `brew install itaywol/testless/testless` | | Rust | `cargo binstall testless` or `cargo install testless` | Prebuilt binaries on [Releases](https://github.com/itaywol/testless/releases). Nix flake repo: `nix develop`. @@ -64,6 +64,15 @@ testless select --from origin/main --format args > cmds.txt || RUN_ALL=1 Live recordings of these runs rotate on [the website](https://testless.itaywol.tools). +## Config + +Optional `testless.toml` at the repo root, for the cases static inference can't cover: + +```toml +always-run = ["tests/smoke/**", "**/*.e2e.test.ts"] # always select these tests +ignore = ["**/generated/**", "*.pb.go"] # never index these files +``` + ## Languages TypeScript / JavaScript (vitest, jest `-t` patterns) · Go (`go test -run`) · Rust (`cargo test`) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index aa6e8f4..9b9958f 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -8,17 +8,18 @@ mod format; use testless_core::cache::{Cache, CachedExtraction}; use testless_core::classify::{classify, ChangeMode, SeedKind}; +use testless_core::config::{self, Config}; use testless_core::gitio; -use testless_core::graph::{CallTarget, DefKind, Edge, Graph}; +use testless_core::graph::{CallTarget, DefId, DefKind, Edge, Graph}; use testless_core::indexer::index_repo_incremental; -use testless_core::walk::impacted_tests; +use testless_core::walk::{impacted_tests, impacted_tests_with_paths, Hop, HopKind}; use testless_core::Registry; #[derive(Parser)] #[command( name = "testless", version, - after_help = "Examples:\n testless index\n testless stats\n testless changes --from origin/main\n testless select --from origin/main\n testless select --from origin/main --format args\n testless completion zsh > _testless" + after_help = "Examples:\n testless index\n testless stats\n testless changes --from origin/main\n testless select --from origin/main\n testless select --from origin/main --format args\n testless select --from v1.0.0 --to v1.1.0\n testless why \"formats a sum\"\n testless completion zsh > _testless" )] struct Cli { #[command(subcommand)] @@ -51,22 +52,30 @@ enum Cmd { /// Classify what changed since `--from` into impacted-def seeds (or a /// run-all fallback) and print them. Changes { - /// Revision to diff from. Compared against the current worktree. + /// Revision to diff from. Compared against `--to` (or, when `--to` + /// is omitted, the current worktree). #[arg(long, default_value = "HEAD")] from: String, - /// Revision to diff to. Not yet supported: v1 always compares - /// `--from` against the worktree. + /// Revision to diff to. When omitted, `--from` is compared against + /// the current worktree. When given, `--from`..`--to` is analyzed + /// as a snapshot: `--to` is checked out into a temporary git + /// worktree (removed again once the command finishes), so it must + /// already exist in the local object store (fetched, if remote). #[arg(long)] to: Option, }, /// Select the tests impacted by what changed since `--from` (or a /// run-all fallback) and print them. Select { - /// Revision to diff from. Compared against the current worktree. + /// Revision to diff from. Compared against `--to` (or, when `--to` + /// is omitted, the current worktree). #[arg(long, default_value = "HEAD")] from: String, - /// Revision to diff to. Not yet supported: v1 only diffs - /// `--from` against the worktree. + /// Revision to diff to. When omitted, `--from` is compared against + /// the current worktree. When given, `--from`..`--to` is analyzed + /// as a snapshot: `--to` is checked out into a temporary git + /// worktree (removed again once the command finishes), so it must + /// already exist in the local object store (fetched, if remote). #[arg(long)] to: Option, /// Output format. Defaults to `json` when stdout is piped, `text` @@ -74,6 +83,21 @@ enum Cmd { #[arg(long, value_enum)] format: Option, }, + /// Explain why a test was selected: prints the hop path from a change + /// seed to the matching test. + Why { + /// Which test to explain. Forgiving substring match against + /// ` :: ` (e.g. a bare test name, a file path + /// prefix, or the full `file :: chain` string all work). + test_id: String, + /// Revision to diff from. Compared against `--to` (or, when `--to` + /// is omitted, the current worktree). + #[arg(long, default_value = "HEAD")] + from: String, + /// Revision to diff to (see `select --to`/`changes --to`). + #[arg(long)] + to: Option, + }, /// Generate a shell completion script and print it to stdout. Completion { /// Shell to generate completions for (bash, zsh, fish, elvish, powershell). @@ -95,6 +119,25 @@ fn cache_for(cwd: &std::path::Path) -> Cache { } } +/// Loads `{cwd}/testless.toml` (see `testless_core::config::Config`) and +/// eagerly validates both glob lists, so a malformed config (bad TOML, or a +/// syntactically invalid glob pattern in either `ignore` or `always-run`) +/// fails loudly right here, in every command that reaches it, rather than +/// only surfacing later when a glob is actually evaluated. This is a hard +/// error (bubbles up through `main`'s `Err` branch, exit 1): a `testless.toml` +/// the user wrote and got wrong deserves a clear failure, not a silent +/// `run_all` degrade. +fn load_config(cwd: &std::path::Path) -> Result { + let config = Config::load(cwd).context("loading testless.toml")?; + config + .ignore_globset() + .context("parsing testless.toml `ignore` globs")?; + config + .always_run_globset() + .context("parsing testless.toml `always-run` globs")?; + Ok(config) +} + fn count_tests(graph: &Graph) -> usize { graph .defs @@ -136,11 +179,15 @@ fn cmd_index(full: bool) -> Result<()> { let cwd = std::env::current_dir().context("getting current directory")?; let cache = cache_for(&cwd); let prev = if full { None } else { cache.load() }; + let config = load_config(&cwd)?; + let ignore = config + .ignore_globset() + .context("parsing testless.toml ignore globs")?; eprintln!("indexing {}...", cwd.display()); let start = Instant::now(); let (graph, extractions, stats) = - index_repo_incremental(&cwd, ®istry(), prev).context("indexing repo")?; + index_repo_incremental(&cwd, ®istry(), Some(&ignore), prev).context("indexing repo")?; let ms = start.elapsed().as_millis() as u64; cache.save(&graph, &extractions).context("saving cache")?; @@ -240,41 +287,101 @@ fn seed_kind_label(kind: SeedKind) -> &'static str { } } -/// Shared `--from ` pipeline for `changes` and `select`: incrementally -/// (re)indexes the repo, diffs the worktree against `from`, classifies the -/// change into a `ChangeMode`, and saves the (possibly freshly-parsed) -/// cache, deliberately in that order. +/// Shared `--from `[`--to `] pipeline for `changes`, `select`, and +/// `why`: incrementally (re)indexes the repo, diffs against `from`, +/// classifies the change into a `ChangeMode`, and saves the (possibly +/// freshly-parsed) cache, deliberately in that order. +/// +/// When `to` is `None` (the default), this is exactly the original +/// `--from`-vs-worktree pipeline: `root` is the current directory, and +/// `changed_files`/`classify` run against its on-disk (possibly dirty) +/// state. +/// +/// When `to` is `Some(rev)`, that revision is materialized into a temporary +/// git worktree (`gitio::TempWorktree`, `git worktree add --detach`) and the +/// *entire* pipeline — indexing, `testless.toml` loading, the cache — runs +/// with that worktree's checkout as `root` instead: an ephemeral analysis of +/// the `--to` snapshot that never touches the caller's actual worktree or +/// its `.testless` cache. `changed_files`/`show_file`, which need repo +/// history rather than a checkout, still run against the current directory +/// (it has the same object store as the worktree, being a worktree of it). +/// The temporary worktree is removed (via `TempWorktree`'s `Drop`) when this +/// function returns, by any path, including an early `?`-propagated error. /// -/// `changed_files`/`classify` must run against the on-disk worktree before -/// the cache is (re)written: saving first would leave a freshly-created (or -/// freshly-modified) `.testless/graph.bin` sitting in the worktree, which -/// `git ls-files --others` would then report as an untracked "changed" file -/// in any repo that hasn't gitignored `.testless/` yet, polluting both the +/// `changed_files`/`classify` must run against `root`'s on-disk state before +/// its cache is (re)written: saving first would leave a freshly-created (or +/// freshly-modified) `.testless/graph.bin` sitting there, which `git +/// ls-files --others` would then report as an untracked "changed" file in +/// any repo that hasn't gitignored `.testless/` yet, polluting both the /// `changed_files` stat and (harmlessly, but wastefully) the importer scan. /// /// Failure to list changed files (bad rev, `git` missing, an unrecognized /// git status token) degrades to a run-all fallback rather than a hard -/// error. See Item 2 on `cmd_changes`'s original doc comment; callers -/// still map that to a distinct exit code, not a `main`-reported `Err`. +/// error, as does a failure to materialize `--to` itself (bad/unfetched +/// rev): both are transient/environmental, same posture as a bad `--from`. +/// See Item 2 on `cmd_changes`'s original doc comment; callers still map +/// that to a distinct exit code, not a `main`-reported `Err`. /// /// Returns the graph, its cached per-file extractions, the classification, -/// and the count of files `changed_files` reported (0 on the degrade-to- -/// run-all path); the last is used only for stats reporting by callers. -fn analyze(from: &str) -> Result<(Graph, Vec, ChangeMode, usize)> { +/// the count of files `changed_files` reported (0 on the degrade-to- +/// run-all path, used only for stats reporting by callers), and the loaded +/// `testless.toml` config (empty defaults if none exists, or if `--to` +/// failed to materialize), so callers that need `always_run` (namely +/// `select`/`why`) don't have to reload it. +fn analyze( + from: &str, + to: Option<&str>, +) -> Result<(Graph, Vec, ChangeMode, usize, Config)> { let cwd = std::env::current_dir().context("getting current directory")?; let reg = registry(); - let cache = cache_for(&cwd); + + let worktree = match to { + None => None, + Some(to_rev) => match gitio::TempWorktree::create(&cwd, to_rev) { + Ok(wt) => Some(wt), + Err(err) => { + let reason = format!("materializing --to rev: {err:#}"); + return Ok(( + Graph::default(), + Vec::new(), + ChangeMode::RunAll { reason }, + 0, + Config::default(), + )); + } + }, + }; + let root: &std::path::Path = worktree.as_ref().map_or(&cwd, |wt| wt.path()); + + let cache = cache_for(root); let prev = cache.load(); + let config = load_config(root)?; + let ignore = config + .ignore_globset() + .context("parsing testless.toml ignore globs")?; + let (graph, extractions, _stats) = - index_repo_incremental(&cwd, ®, prev).context("indexing repo")?; + index_repo_incremental(root, ®, Some(&ignore), prev).context("indexing repo")?; - let (mode, changed_count) = match gitio::changed_files(&cwd, from, None) { + let (mode, changed_count) = match gitio::changed_files(&cwd, from, to) { Ok(changed) => { - let mode = classify(&cwd, &graph, ®, &changed, &extractions, &|p| { + let changed_count = changed.len(); + // Files matching testless.toml's `ignore` globs were dropped at + // discovery time (see `discover`/`index_repo_incremental` + // above), so they're absent from `graph`/`extractions`; routing + // one into `classify` would hit its "indexed file missing from + // new graph" error path and force a spurious `RunAll`. `ignore` + // is discovery-level (see `config` module docs): a matching + // change must contribute zero seeds, not run everything. + let filtered: Vec<_> = changed + .into_iter() + .filter(|c| !ignore.is_match(&c.path)) + .collect(); + let mode = classify(root, &graph, ®, &filtered, &extractions, &|p| { gitio::show_file(&cwd, from, p) }); - (mode, changed.len()) + (mode, changed_count) } Err(err) => { let reason = format!("listing files changed since --from: {err:#}"); @@ -284,7 +391,7 @@ fn analyze(from: &str) -> Result<(Graph, Vec, ChangeMode, usiz cache.save(&graph, &extractions).context("saving cache")?; - Ok((graph, extractions, mode, changed_count)) + Ok((graph, extractions, mode, changed_count, config)) } /// `--from ` diffed against the current worktree, classified into @@ -294,11 +401,7 @@ fn analyze(from: &str) -> Result<(Graph, Vec, ChangeMode, usiz /// saving the cache), which are still surfaced as `Err` so `main` reports /// them. fn cmd_changes(from: String, to: Option) -> Result { - if to.is_some() { - anyhow::bail!("--to is not yet supported (v1 only diffs --from against the worktree)"); - } - - let (graph, _extractions, mode, changed_count) = analyze(&from)?; + let (graph, _extractions, mode, changed_count, _config) = analyze(&from, to.as_deref())?; let exit_code = match &mode { ChangeMode::Selection(_) => 0, @@ -363,6 +466,26 @@ fn cmd_changes(from: String, to: Option) -> Result { Ok(exit_code) } +/// The impact walk's selected `TestCase` defs, unioned with every +/// `TestCase` whose file matches one of `config`'s `always-run` globs (see +/// `testless_core::config::always_run_matches`). This is the escape hatch's +/// selection-level half: a smoke test matching an `always-run` glob is +/// selected even when the walk itself found nothing to seed (e.g. a +/// comment-only edit), because it's never reached via any `Seed` at all. +/// Ascending `DefId` order, matching `impacted_tests`'s own determinism. +fn selected_test_defs( + graph: &Graph, + seeds: &[testless_core::classify::Seed], + config: &Config, +) -> Result> { + let mut selected: std::collections::BTreeSet = + impacted_tests(graph, seeds).into_iter().collect(); + for (id, _glob) in config::always_run_matches(graph, config)? { + selected.insert(id); + } + Ok(selected.into_iter().collect()) +} + /// The test-runner label for a def's file language, per the `select` wire /// contract: `ts` -> `vitest`, `go` -> `gotest`, `rust` -> `cargo`. Any /// other/future registered language degrades to `"unknown"` rather than @@ -398,11 +521,7 @@ struct SelectedTest { /// selection (including an empty one), 2 for run-all, mirroring /// `cmd_changes`'s exit-code contract exactly. fn cmd_select(from: String, to: Option, format: Option) -> Result { - if to.is_some() { - anyhow::bail!("--to is not yet supported (v1 only diffs --from against the worktree)"); - } - - let (graph, _extractions, mode, changed_count) = analyze(&from)?; + let (graph, _extractions, mode, changed_count, config) = analyze(&from, to.as_deref())?; // `--format` always wins; omitted, it sniffs the TTY like `changes` // does. `Args` is never the sniffed default; it must be requested. let resolved_format = format.unwrap_or_else(|| { @@ -438,7 +557,7 @@ fn cmd_select(from: String, to: Option, format: Option) -> Resul let total_known = count_tests(&graph); let seed_count = seeds.len(); - let test_defs = impacted_tests(&graph, &seeds); + let test_defs = selected_test_defs(&graph, &seeds, &config)?; let tests: Vec = test_defs .into_iter() .map(|id| { @@ -513,6 +632,279 @@ fn cmd_select(from: String, to: Option, format: Option) -> Resul Ok(0) } +/// A selected test that matched a `why` query, along with its +/// seed -> test hop path (from `walk::impacted_tests_with_paths`) and the +/// ` :: ` string `test_id` was matched against. +/// +/// `always_run_glob` is `Some()` exactly when this test was selected +/// *only* via `testless.toml`'s `always-run` list (the impact walk itself +/// never reached it at all, not even as a bare/empty-path seed): in that +/// case `path` is empty and the `always-run` glob is the sole explanation +/// for the selection, rendered distinctly from an ordinary empty-path seed. +struct WhyCandidate { + file: std::path::PathBuf, + name: Vec, + path: Vec, + match_str: String, + always_run_glob: Option, +} + +/// Human-facing verb phrase for a hop's edge kind, used as the prefix of +/// every non-final line in `print_why_text` (e.g. `called by fmt (...)`). +fn hop_kind_label(kind: &HopKind) -> String { + match kind { + HopKind::Calls => "called by".to_string(), + HopKind::Reads => "read by".to_string(), + HopKind::Contains => "contained in".to_string(), + HopKind::ImportCloses => "imported by".to_string(), + HopKind::UnknownName(name) => format!("possibly called by (unresolved name \"{name}\")"), + } +} + +/// Machine-readable label for a hop's edge kind, matching the lowercase +/// snake_case convention `seed_kind_label` already established for the +/// wire format. `UnknownName` carries its matched name, so it serializes +/// as a small object instead of a bare string. +fn hop_kind_json(kind: &HopKind) -> serde_json::Value { + match kind { + HopKind::Calls => serde_json::json!("calls"), + HopKind::Reads => serde_json::json!("reads"), + HopKind::Contains => serde_json::json!("contains"), + HopKind::ImportCloses => serde_json::json!("import_closes"), + HopKind::UnknownName(name) => serde_json::json!({ "unknown_name": name }), + } +} + +/// A def's display name and file path, looked up by `DefId`. Shared by both +/// `why` renderers so a hop's endpoints are always rendered identically. +fn def_display(graph: &Graph, id: DefId) -> (String, std::path::PathBuf) { + let def = graph.def(id); + ( + def.name.clone(), + graph.files[def.file.0 as usize].path.clone(), + ) +} + +/// `why`'s human output, one line per hop: the seed (path's first hop's +/// `from`) renders as `changed ()`; every intermediate hop +/// renders as ` ()` (e.g. ` called by fmt (...)`, +/// ` read by ...`); the final hop (arriving at the matched test itself) +/// always renders as ` = test "" ()` regardless of its +/// edge kind, since it's the destination, not another impacted def. A test +/// that's itself a seed (empty path, e.g. a newly `Added` test) has no +/// preceding `changed` line: just the bare `= test ...` line. A test +/// selected only via a `testless.toml` `always-run` glob (also an empty +/// path, but distinguished by `always_run_glob` being `Some`) instead gets a +/// `selected by always-run glob ''` line, since "no walk path" means +/// something different there: the walk never reached this test by any means +/// at all. Returns lines rather than printing directly so it's unit-testable +/// without capturing stdout; `print_why_text` is the printing wrapper +/// callers actually use. +fn why_text_lines(graph: &Graph, candidate: &WhyCandidate) -> Vec { + let test_name = candidate.name.join(" > "); + if let Some(glob) = &candidate.always_run_glob { + return vec![ + format!("selected by always-run glob '{glob}'"), + format!(" = test \"{test_name}\" ({})", candidate.file.display()), + ]; + } + if candidate.path.is_empty() { + return vec![format!( + "= test \"{test_name}\" ({})", + candidate.file.display() + )]; + } + + let mut lines = Vec::with_capacity(candidate.path.len() + 1); + let (seed_name, seed_file) = def_display(graph, candidate.path[0].from); + lines.push(format!("changed {seed_name} ({})", seed_file.display())); + + let last = candidate.path.len() - 1; + for (i, hop) in candidate.path.iter().enumerate() { + if i == last { + lines.push(format!( + " = test \"{test_name}\" ({})", + candidate.file.display() + )); + } else { + let (name, file) = def_display(graph, hop.to); + lines.push(format!( + " {} {name} ({})", + hop_kind_label(&hop.edge), + file.display() + )); + } + } + lines +} + +fn print_why_text(graph: &Graph, candidate: &WhyCandidate) { + for line in why_text_lines(graph, candidate) { + println!("{line}"); + } +} + +/// `why`'s JSON output for an unambiguous match: `{"version":1,"test": +/// {...},"path":[{"from":{...},"edge":...,"to":{...}}, ...]}`, each hop +/// endpoint rendered as `{"name":..., "file":...}` via `def_display`. A test +/// selected only via an `always-run` glob (see `WhyCandidate::always_run_glob`) +/// carries an empty `path` plus an extra top-level `"always_run"` string +/// field naming the matched glob, instead of the usual hop chain. +fn why_json(graph: &Graph, candidate: &WhyCandidate) -> serde_json::Value { + let path_json: Vec<_> = candidate + .path + .iter() + .map(|hop| { + let (from_name, from_file) = def_display(graph, hop.from); + let (to_name, to_file) = def_display(graph, hop.to); + serde_json::json!({ + "from": { "name": from_name, "file": from_file }, + "edge": hop_kind_json(&hop.edge), + "to": { "name": to_name, "file": to_file }, + }) + }) + .collect(); + + let mut out = serde_json::json!({ + "version": 1, + "mode": "explained", + "test": { + "file": candidate.file, + "name": candidate.name, + }, + "path": path_json, + }); + if let Some(glob) = &candidate.always_run_glob { + out["always_run"] = serde_json::json!(glob); + } + out +} + +/// `testless why `: explain why a test was (or wasn't) selected, +/// by running the same analyze+walk pipeline as `select` and printing the +/// seed -> test hop path for whichever selected test matches `test_id`. +/// +/// Matching is deliberately forgiving: `test_id` is a substring match +/// against ` :: ` (so a bare test name, a file path, or +/// the full string all work). Zero matches means the test wasn't among +/// this change's selected tests (exit 1); more than one lists every +/// matching candidate rather than guessing (exit 1). Exactly one match +/// prints its path (exit 0). A run-all classification short-circuits with +/// the same reason/exit-code (2) contract as `select`/`changes`: there's no +/// specific walk to explain when everything runs. +fn cmd_why(test_id: String, from: String, to: Option) -> Result { + let (graph, _extractions, mode, _changed_count, config) = analyze(&from, to.as_deref())?; + let is_tty = std::io::stdout().is_terminal(); + + let seeds = match mode { + ChangeMode::Selection(seeds) => seeds, + ChangeMode::RunAll { reason } => { + if is_tty { + println!("run all: {reason}"); + } else { + let out = serde_json::json!({ + "version": 1, + "mode": "run_all", + "reason": reason, + }); + println!("{out}"); + } + return Ok(2); + } + }; + + // The walk's own selection (possibly with an empty path, for a def + // that's itself a seed) takes precedence over `always-run`: a test is + // only explained via its `always-run` glob when the walk didn't reach + // it by any means at all. + let with_paths: std::collections::HashMap> = + impacted_tests_with_paths(&graph, &seeds) + .into_iter() + .collect(); + let always_run: std::collections::HashMap = + config::always_run_matches(&graph, &config)? + .into_iter() + .collect(); + + let mut all_ids: std::collections::BTreeSet = with_paths.keys().copied().collect(); + all_ids.extend(always_run.keys().copied()); + + let candidates: Vec = all_ids + .into_iter() + .map(|id| { + let def = graph.def(id); + let file = graph.files[def.file.0 as usize].path.clone(); + let name = def + .test_id + .clone() + .unwrap_or_else(|| vec![def.name.clone()]); + let match_str = format!("{} :: {}", file.display(), name.join(" > ")); + let (path, always_run_glob) = match with_paths.get(&id) { + Some(path) => (path.clone(), None), + None => (Vec::new(), always_run.get(&id).cloned()), + }; + WhyCandidate { + file, + name, + path, + match_str, + always_run_glob, + } + }) + .collect(); + + let matches: Vec<&WhyCandidate> = candidates + .iter() + .filter(|c| c.match_str.contains(&test_id)) + .collect(); + + match matches.len() { + 0 => { + if is_tty { + println!("not selected: no impacted test matches \"{test_id}\""); + } else { + let out = serde_json::json!({ + "version": 1, + "mode": "not_selected", + "query": test_id, + }); + println!("{out}"); + } + Ok(1) + } + 1 => { + let candidate = matches[0]; + if is_tty { + print_why_text(&graph, candidate); + } else { + println!("{}", why_json(&graph, candidate)); + } + Ok(0) + } + _ => { + if is_tty { + println!("ambiguous match for \"{test_id}\", candidates:"); + for c in &matches { + println!(" {}", c.match_str); + } + } else { + let candidates_json: Vec<_> = matches + .iter() + .map(|c| serde_json::json!({ "file": c.file, "name": c.name })) + .collect(); + let out = serde_json::json!({ + "version": 1, + "mode": "ambiguous", + "query": test_id, + "candidates": candidates_json, + }); + println!("{out}"); + } + Ok(1) + } + } +} + fn cmd_completion(shell: clap_complete::Shell) -> Result<()> { clap_complete::generate( shell, @@ -530,6 +922,7 @@ fn main() { Cmd::Stats => cmd_stats().map(|()| 0), Cmd::Changes { from, to } => cmd_changes(from, to), Cmd::Select { from, to, format } => cmd_select(from, to, format), + Cmd::Why { test_id, from, to } => cmd_why(test_id, from, to), Cmd::Completion { shell } => cmd_completion(shell).map(|()| 0), }; @@ -548,3 +941,143 @@ fn main() { } } } + +#[cfg(test)] +mod why_tests { + use super::*; + use std::path::PathBuf; + use testless_core::graph::{Def, DefKind, FileNode}; + + fn file(g: &mut Graph, path: &str) -> testless_core::graph::FileId { + g.add_file(FileNode { + path: PathBuf::from(path), + hash: [0; 32], + lang: "ts".into(), + }) + } + + fn def(g: &mut Graph, name: &str, kind: DefKind, file: testless_core::graph::FileId) -> DefId { + g.add_def(Def { + name: name.into(), + kind, + file, + start_line: 1, + end_line: 2, + test_id: None, + computed_name: false, + }) + } + + #[test] + fn why_text_lines_two_hop_path() { + // changed add (src/math.ts) -> called by fmt (src/format.ts) -> + // = test "formats a sum" (src/format.test.ts) + let mut g = Graph::default(); + let math_file = file(&mut g, "src/math.ts"); + let format_file = file(&mut g, "src/format.ts"); + let test_file = file(&mut g, "src/format.test.ts"); + let add = def(&mut g, "add", DefKind::Function, math_file); + let fmt = def(&mut g, "fmt", DefKind::Function, format_file); + + let candidate = WhyCandidate { + file: PathBuf::from("src/format.test.ts"), + name: vec!["formats a sum".to_string()], + path: vec![ + Hop { + from: add, + edge: HopKind::Calls, + to: fmt, + }, + Hop { + from: fmt, + edge: HopKind::Reads, + to: def(&mut g, "formats a sum", DefKind::TestCase, test_file), + }, + ], + match_str: "src/format.test.ts :: formats a sum".to_string(), + always_run_glob: None, + }; + + let lines = why_text_lines(&g, &candidate); + assert_eq!( + lines, + vec![ + "changed add (src/math.ts)".to_string(), + " called by fmt (src/format.ts)".to_string(), + " = test \"formats a sum\" (src/format.test.ts)".to_string(), + ] + ); + } + + #[test] + fn why_text_lines_empty_path_is_bare_test_line() { + let mut g = Graph::default(); + let test_file = file(&mut g, "src/a.test.ts"); + def(&mut g, "t", DefKind::TestCase, test_file); + + let candidate = WhyCandidate { + file: PathBuf::from("src/a.test.ts"), + name: vec!["t".to_string()], + path: vec![], + match_str: "src/a.test.ts :: t".to_string(), + always_run_glob: None, + }; + + let lines = why_text_lines(&g, &candidate); + assert_eq!(lines, vec!["= test \"t\" (src/a.test.ts)".to_string()]); + } + + #[test] + fn why_text_lines_always_run_glob_overrides_empty_path() { + let mut g = Graph::default(); + let test_file = file(&mut g, "tests/smoke/login.test.ts"); + def(&mut g, "logs in", DefKind::TestCase, test_file); + + let candidate = WhyCandidate { + file: PathBuf::from("tests/smoke/login.test.ts"), + name: vec!["logs in".to_string()], + path: vec![], + match_str: "tests/smoke/login.test.ts :: logs in".to_string(), + always_run_glob: Some("tests/smoke/**".to_string()), + }; + + let lines = why_text_lines(&g, &candidate); + assert_eq!( + lines, + vec![ + "selected by always-run glob 'tests/smoke/**'".to_string(), + " = test \"logs in\" (tests/smoke/login.test.ts)".to_string(), + ] + ); + } + + #[test] + fn hop_kind_label_covers_every_variant() { + assert_eq!(hop_kind_label(&HopKind::Calls), "called by"); + assert_eq!(hop_kind_label(&HopKind::Reads), "read by"); + assert_eq!(hop_kind_label(&HopKind::Contains), "contained in"); + assert_eq!(hop_kind_label(&HopKind::ImportCloses), "imported by"); + assert_eq!( + hop_kind_label(&HopKind::UnknownName("add".to_string())), + "possibly called by (unresolved name \"add\")" + ); + } + + #[test] + fn hop_kind_json_labels() { + assert_eq!(hop_kind_json(&HopKind::Calls), serde_json::json!("calls")); + assert_eq!(hop_kind_json(&HopKind::Reads), serde_json::json!("reads")); + assert_eq!( + hop_kind_json(&HopKind::Contains), + serde_json::json!("contains") + ); + assert_eq!( + hop_kind_json(&HopKind::ImportCloses), + serde_json::json!("import_closes") + ); + assert_eq!( + hop_kind_json(&HopKind::UnknownName("add".to_string())), + serde_json::json!({ "unknown_name": "add" }) + ); + } +} diff --git a/crates/cli/tests/changes.rs b/crates/cli/tests/changes.rs index e7a0e76..0bfe347 100644 --- a/crates/cli/tests/changes.rs +++ b/crates/cli/tests/changes.rs @@ -117,7 +117,7 @@ fn body_edit_seeds_exactly_that_defs_body() { ); let registry = registry(); - let (graph, extractions, _) = index_repo_incremental(root, ®istry, None).unwrap(); + let (graph, extractions, _) = index_repo_incremental(root, ®istry, None, None).unwrap(); let add = find_def(&graph, "add"); let changed = vec![ChangedFile { @@ -160,7 +160,7 @@ fn exported_arrow_const_body_edit_does_not_seed_module_init() { ); let registry = registry(); - let (graph, extractions, _) = index_repo_incremental(root, ®istry, None).unwrap(); + let (graph, extractions, _) = index_repo_incremental(root, ®istry, None, None).unwrap(); let mul = find_def(&graph, "mul"); let changed = vec![ChangedFile { @@ -209,7 +209,7 @@ fn export_const_value_edit_seeds_module_init() { ); let registry = registry(); - let (graph, extractions, _) = index_repo_incremental(root, ®istry, None).unwrap(); + let (graph, extractions, _) = index_repo_incremental(root, ®istry, None, None).unwrap(); let changed = vec![ChangedFile { path: PathBuf::from("src/config.ts"), @@ -253,7 +253,7 @@ fn comment_only_edit_yields_empty_selection() { ); let registry = registry(); - let (graph, extractions, _) = index_repo_incremental(root, ®istry, None).unwrap(); + let (graph, extractions, _) = index_repo_incremental(root, ®istry, None, None).unwrap(); let changed = vec![ChangedFile { path: PathBuf::from("src/math.ts"), @@ -280,7 +280,7 @@ fn config_file_change_forces_run_all() { write(root, "src/math.ts", MATH_ADD_ORIGINAL); let registry = registry(); - let (graph, extractions, _) = index_repo_incremental(root, ®istry, None).unwrap(); + let (graph, extractions, _) = index_repo_incremental(root, ®istry, None, None).unwrap(); let changed = vec![ChangedFile { path: PathBuf::from("package.json"), @@ -314,7 +314,7 @@ fn added_test_file_seeds_test_cases_and_module_init_as_added() { ); let registry = registry(); - let (graph, extractions, _) = index_repo_incremental(root, ®istry, None).unwrap(); + let (graph, extractions, _) = index_repo_incremental(root, ®istry, None, None).unwrap(); let file_id = testless_core::FileId( graph .files @@ -363,16 +363,68 @@ fn added_test_file_seeds_test_cases_and_module_init_as_added() { } } -/// (e) A deleted, previously-indexed source file forces `RunAll`: the -/// documented sound-but-coarse decision for Plan 3. +/// (e) A deleted, previously-indexed source file that still has a live +/// importer (its former importer's raw import text still references it, +/// e.g. `import { helper } from "./removed"`) seeds that importer's +/// `ModuleInit`, not `RunAll`: the importer will fail to resolve/compile +/// against the missing module, so its tests need to run, but nothing +/// unrelated does (issue #13). #[test] -fn deleted_source_file_forces_run_all() { +fn deleted_source_file_with_importer_seeds_importer_module_init() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + // src/removed.ts existed before this change and has since been deleted; + // only its former importer remains on disk for the new-tree index. + write( + root, + "src/consumer.ts", + "import { helper } from \"./removed\";\nexport function useHelper(): unknown { return helper(); }\n", + ); + + let registry = registry(); + let (graph, extractions, _) = index_repo_incremental(root, ®istry, None, None).unwrap(); + let consumer_id = testless_core::FileId( + graph + .files + .iter() + .position(|f| f.path.ends_with("consumer.ts")) + .unwrap() as u32, + ); + let consumer_module_init = graph.module_init(consumer_id).expect("module_init present"); + + let changed = vec![ChangedFile { + path: PathBuf::from("src/removed.ts"), + status: FileStatus::Deleted, + }]; + + let mode = classify( + root, + &graph, + ®istry, + &changed, + &extractions, + &no_old_content, + ); + assert_eq!( + mode, + ChangeMode::Selection(vec![Seed { + def: consumer_module_init, + kind: SeedKind::ModuleInit, + }]) + ); +} + +/// (e2) A deleted, previously-indexed source file with no remaining +/// importer contributes zero seeds: its own tests died along with it, and +/// nothing else referenced it (issue #13). +#[test] +fn deleted_source_file_with_no_importers_yields_empty_selection() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); write(root, "src/math.ts", MATH_ADD_ORIGINAL); let registry = registry(); - let (graph, extractions, _) = index_repo_incremental(root, ®istry, None).unwrap(); + let (graph, extractions, _) = index_repo_incremental(root, ®istry, None, None).unwrap(); let changed = vec![ChangedFile { path: PathBuf::from("src/removed.ts"), @@ -387,12 +439,7 @@ fn deleted_source_file_forces_run_all() { &extractions, &no_old_content, ); - match &mode { - ChangeMode::RunAll { reason } => { - assert!(reason.contains("removed.ts"), "reason: {reason}"); - } - other => panic!("expected RunAll, got {other:?}"), - } + assert_eq!(mode, ChangeMode::Selection(vec![])); } /// (f) An unindexed file (no registered `Language`) with no importer @@ -405,7 +452,7 @@ fn unindexed_file_with_no_importers_yields_empty_selection() { write(root, "README.md", "# hello\n"); let registry = registry(); - let (graph, extractions, _) = index_repo_incremental(root, ®istry, None).unwrap(); + let (graph, extractions, _) = index_repo_incremental(root, ®istry, None, None).unwrap(); let changed = vec![ChangedFile { path: PathBuf::from("README.md"), @@ -439,7 +486,7 @@ fn unresolved_json_import_seeds_importers_module_init() { ); let registry = registry(); - let (graph, extractions, _) = index_repo_incremental(root, ®istry, None).unwrap(); + let (graph, extractions, _) = index_repo_incremental(root, ®istry, None, None).unwrap(); let consumer_id = testless_core::FileId( graph .files @@ -487,7 +534,7 @@ fn extensionless_import_matches_changed_file_by_stem() { ); let registry = registry(); - let (graph, extractions, _) = index_repo_incremental(root, ®istry, None).unwrap(); + let (graph, extractions, _) = index_repo_incremental(root, ®istry, None, None).unwrap(); let consumer_id = testless_core::FileId( graph .files @@ -519,11 +566,79 @@ fn extensionless_import_matches_changed_file_by_stem() { ); } +/// (i) Go's imports name package *directories*, never file stems, and +/// same-package sibling files reference each other via nothing at all (no +/// import statement). Deleting `pkg/helper.go` (whose `init()` a sibling +/// test depends on structurally, since `init` folds into the file's +/// ``) must still seed the surviving same-package siblings' +/// `ModuleInit` — the stem-based importer scan alone finds nothing here +/// (no file anywhere imports the literal text `helper`/`helper.go`), so +/// before this fix the selection was empty (issue: Go file deletion +/// under-selects). +#[test] +fn deleted_go_file_seeds_package_siblings_module_init() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write(root, "go.mod", "module example.com/m\n\ngo 1.22\n"); + write( + root, + "pkg/widget.go", + "package pkg\n\nfunc Widget() int { return 1 }\n", + ); + write( + root, + "pkg/widget_test.go", + "package pkg\n\nimport \"testing\"\n\nfunc TestWidget(t *testing.T) {\n\tif Widget() != 1 {\n\t\tt.Fail()\n\t}\n}\n", + ); + + let registry = Registry::new(vec![Box::new(testless_lang_go::GoLanguage)]); + let (graph, extractions, _) = index_repo_incremental(root, ®istry, None, None).unwrap(); + + let test_file_id = testless_core::FileId( + graph + .files + .iter() + .position(|f| f.path.ends_with("widget_test.go")) + .unwrap() as u32, + ); + let test_module_init = graph + .module_init(test_file_id) + .expect("module_init present"); + + // helper.go (a third file in the same package, never written to disk + // here: it's the file being deleted) held the sibling this test's + // `` depended on. Its own `` hash included an `init()` + // whose body a sibling test structurally depended on; deleting it must + // still seed the surviving siblings' `ModuleInit`. + let changed = vec![ChangedFile { + path: PathBuf::from("pkg/helper.go"), + status: FileStatus::Deleted, + }]; + + let mode = classify( + root, + &graph, + ®istry, + &changed, + &extractions, + &no_old_content, + ); + match mode { + ChangeMode::Selection(seeds) => { + assert!(!seeds.is_empty(), "expected a non-empty selection"); + assert!( + seeds.iter().any(|s| s.def == test_module_init), + "expected the package test file's ModuleInit seeded, got {seeds:?}" + ); + } + other => panic!("expected Selection (not RunAll), got {other:?}"), + } +} + // --- `testless changes` CLI e2e coverage (Plan 3, Task 5) -------------- mod cli_changes { use assert_cmd::Command; - use predicates::prelude::*; fn git(dir: &std::path::Path, args: &[&str]) { let status = std::process::Command::new("git") @@ -653,17 +768,107 @@ mod cli_changes { assert!(json["reason"].as_str().unwrap().contains("--from")); } - /// `--to` isn't supported yet in v1: documented punt, hard error exit 1. + fn rev_parse(dir: &std::path::Path, rev: &str) -> String { + let output = std::process::Command::new("git") + .arg("-C") + .arg(dir) + .args(["rev-parse", rev]) + .output() + .expect("failed to spawn git rev-parse"); + assert!(output.status.success(), "git rev-parse {rev} failed"); + String::from_utf8(output.stdout).unwrap().trim().to_string() + } + + /// `--to ` (issue #17): an explicit `--from`..`--to` rev range + /// (rather than `--from` vs. the worktree) reports exactly the seed for + /// a body edit committed between the two revisions. #[test] - fn to_flag_is_rejected() { + fn to_flag_reports_seed_for_rev_range() { let tmp = init_repo(); - Command::cargo_bin("testless") + let root = tmp.path(); + let c1 = rev_parse(root, "HEAD"); + + std::fs::write( + root.join("src/math.ts"), + "export function add(a: number, b: number): number { return a + b + 1; }\n", + ) + .unwrap(); + git(root, &["commit", "-am", "edit add's body"]); + let c2 = rev_parse(root, "HEAD"); + + let assert = Command::cargo_bin("testless") .unwrap() - .args(["changes", "--to", "HEAD"]) + .args(["changes", "--from", &c1, "--to", &c2]) + .current_dir(root) + .assert() + .success(); + let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let json: serde_json::Value = serde_json::from_str(out.trim()).unwrap_or_else(|e| { + panic!("expected JSON stdout, got {out:?} ({e})"); + }); + assert_eq!(json["mode"], "selection"); + let seeds = json["seeds"].as_array().expect("seeds array"); + assert!( + seeds + .iter() + .any(|s| s["def"] == "add" && s["kind"] == "body"), + "expected an add/body seed, got {seeds:?}" + ); + } + + /// A modified file matching `testless.toml`'s `ignore` globs is + /// discovery-level dropped (never indexed, see `discover`), so before + /// this fix it hit `classify`'s "indexed file missing from new graph" + /// error path and forced `run_all`. `ignore`'s contract is that a + /// matching change contributes zero seeds, same as any other change + /// `testless` can prove has no impact — not a blanket run-everything. + #[test] + fn ignored_file_change_yields_empty_selection_not_run_all() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("src/generated")).unwrap(); + std::fs::write(root.join("src/generated/api.ts"), "export const x = 1;\n").unwrap(); + std::fs::write( + root.join("testless.toml"), + "ignore = [\"**/generated/**\"]\n", + ) + .unwrap(); + git(root, &["init", "-b", "main"]); + git(root, &["add", "-A"]); + git(root, &["commit", "-m", "initial"]); + + std::fs::write(root.join("src/generated/api.ts"), "export const x = 2;\n").unwrap(); + + let assert = Command::cargo_bin("testless") + .unwrap() + .arg("changes") + .current_dir(root) + .assert() + .success(); + let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let json: serde_json::Value = serde_json::from_str(out.trim()).unwrap_or_else(|e| { + panic!("expected JSON stdout, got {out:?} ({e})"); + }); + assert_eq!(json["mode"], "selection"); + assert_eq!(json["seeds"].as_array().unwrap().len(), 0); + } + + /// A `--to` rev that doesn't resolve locally degrades to the same + /// `run_all`/exit-2 fallback as a bad `--from` rev, rather than a hard + /// error. + #[test] + fn bad_to_rev_degrades_to_run_all_exit_2() { + let tmp = init_repo(); + let assert = Command::cargo_bin("testless") + .unwrap() + .args(["changes", "--to", "not-a-real-rev"]) .current_dir(tmp.path()) .assert() - .code(1) - .stderr(predicate::str::contains("not yet supported")); + .code(2); + let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let json: serde_json::Value = serde_json::from_str(out.trim()).unwrap(); + assert_eq!(json["mode"], "run_all"); + assert!(json["reason"].as_str().unwrap().contains("--to")); } // --- multi-language e2e coverage (Plan 3, Task 6) ------------------ diff --git a/crates/cli/tests/index.rs b/crates/cli/tests/index.rs index 49736d0..502e6e7 100644 --- a/crates/cli/tests/index.rs +++ b/crates/cli/tests/index.rs @@ -132,7 +132,8 @@ fn incremental_reindex_reuses_unchanged_files_and_reparses_only_the_changed_one( let files_count = |g: &testless_core::Graph| g.files.len(); - let (graph, extractions, stats) = index_repo_incremental(root, ®istry(), None).unwrap(); + let (graph, extractions, stats) = + index_repo_incremental(root, ®istry(), None, None).unwrap(); assert_eq!(stats.parsed, files_count(&graph)); assert_eq!(stats.reused, 0); @@ -144,7 +145,7 @@ fn incremental_reindex_reuses_unchanged_files_and_reparses_only_the_changed_one( std::fs::write(&math_path, src).unwrap(); let (graph2, _extractions2, stats2) = - index_repo_incremental(root, ®istry(), Some((graph, extractions))).unwrap(); + index_repo_incremental(root, ®istry(), None, Some((graph, extractions))).unwrap(); // Only math.ts changed, so only it should have been re-parsed. assert_eq!(stats2.parsed, 1); diff --git a/crates/cli/tests/select.rs b/crates/cli/tests/select.rs index 46c8f53..3dff2ac 100644 --- a/crates/cli/tests/select.rs +++ b/crates/cli/tests/select.rs @@ -219,6 +219,148 @@ fn comment_only_edit_yields_empty_tests() { assert_eq!(json["stats"]["total_known"], 5); } +// --------------------------------------------------------------------- +// `testless.toml` escape hatch (issue #15): `always-run` (selection-level) +// and `ignore` (discovery-level) globs. +// --------------------------------------------------------------------- + +const SMOKE_TEST_TS: &str = "\ +import { it, expect } from \"vitest\"; +it(\"smoke check\", () => { expect(true).toBe(true); }); +"; + +const ALWAYS_RUN_TESTLESS_TOML: &str = "always-run = [\"src/smoke.test.ts\"]\n"; + +/// Same base fixture as `init_repo`, plus an unrelated `src/smoke.test.ts` +/// and a `testless.toml` marking it `always-run`, all committed together +/// (so the config file itself is never part of a later diff). +fn init_repo_with_always_run() -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write( + root.join("package.json"), + "{ \"name\": \"select-fixture\" }\n", + ) + .unwrap(); + std::fs::write(root.join("src/math.ts"), MATH_TS).unwrap(); + std::fs::write(root.join("src/format.ts"), FORMAT_TS).unwrap(); + std::fs::write(root.join("src/math.test.ts"), MATH_TEST_TS).unwrap(); + std::fs::write(root.join("src/format.test.ts"), FORMAT_TEST_TS).unwrap(); + std::fs::write(root.join("src/unrelated.test.ts"), UNRELATED_TEST_TS).unwrap(); + std::fs::write(root.join("src/smoke.test.ts"), SMOKE_TEST_TS).unwrap(); + std::fs::write(root.join("testless.toml"), ALWAYS_RUN_TESTLESS_TOML).unwrap(); + git(root, &["init", "-b", "main"]); + git(root, &["add", "-A"]); + git(root, &["commit", "-m", "initial"]); + tmp +} + +/// `always-run` in `testless.toml` selects a matching test even when the +/// walk itself found nothing to seed: a comment-only edit of `add` (zero +/// seeds, see `comment_only_edit_yields_empty_tests`) still selects exactly +/// `src/smoke.test.ts`'s test, because that file's path matches the +/// `always-run` glob regardless of the walk's own result. +#[test] +fn always_run_glob_selects_smoke_test_even_with_zero_seeds() { + let tmp = init_repo_with_always_run(); + let root = tmp.path(); + std::fs::write(root.join("src/math.ts"), MATH_TS_COMMENT_EDITED).unwrap(); + + let assert = Command::cargo_bin("testless") + .unwrap() + .arg("select") + .current_dir(root) + .assert() + .success(); + let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let json: serde_json::Value = serde_json::from_str(out.trim()).unwrap_or_else(|e| { + panic!("expected JSON stdout, got {out:?} ({e})"); + }); + + assert_eq!(json["mode"], "selection"); + assert_eq!(json["stats"]["seeds"], 0, "comment-only edit seeds nothing"); + // 5 fixture tests (as in comment_only_edit_yields_empty_tests) plus the + // smoke test itself. + assert_eq!(json["stats"]["total_known"], 6); + assert_eq!(json["stats"]["selected"], 1); + + let tests = json["tests"].as_array().expect("tests array"); + assert_eq!( + tests.len(), + 1, + "expected exactly the smoke test, got {tests:?}" + ); + assert_eq!(tests[0]["file"], "src/smoke.test.ts"); + assert_eq!(tests[0]["name"].as_array().unwrap()[0], "smoke check"); +} + +const GENERATED_TEST_TS: &str = "\ +import { it, expect } from \"vitest\"; +it(\"generated check\", () => { expect(1).toBe(1); }); +"; + +const IGNORE_TESTLESS_TOML: &str = "ignore = [\"src/generated/**\"]\n"; + +/// Same base fixture as `init_repo`, plus a `src/generated/models.test.ts` +/// (a file that, absent any config, would be indexed and add one more +/// known test) and a `testless.toml` marking `src/generated/**` `ignore`d. +fn init_repo_with_ignore() -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("src/generated")).unwrap(); + std::fs::write( + root.join("package.json"), + "{ \"name\": \"select-fixture\" }\n", + ) + .unwrap(); + std::fs::write(root.join("src/math.ts"), MATH_TS).unwrap(); + std::fs::write(root.join("src/format.ts"), FORMAT_TS).unwrap(); + std::fs::write(root.join("src/math.test.ts"), MATH_TEST_TS).unwrap(); + std::fs::write(root.join("src/format.test.ts"), FORMAT_TEST_TS).unwrap(); + std::fs::write(root.join("src/unrelated.test.ts"), UNRELATED_TEST_TS).unwrap(); + std::fs::write(root.join("src/generated/models.test.ts"), GENERATED_TEST_TS).unwrap(); + std::fs::write(root.join("testless.toml"), IGNORE_TESTLESS_TOML).unwrap(); + git(root, &["init", "-b", "main"]); + git(root, &["add", "-A"]); + git(root, &["commit", "-m", "initial"]); + tmp +} + +/// `ignore` in `testless.toml` drops a matching file at discovery time: its +/// test never becomes part of `total_known`, and it's never selectable, +/// even though it's present on disk and would otherwise be indexed. +#[test] +fn ignore_glob_excludes_generated_file_from_total_known() { + let tmp = init_repo_with_ignore(); + let root = tmp.path(); + std::fs::write(root.join("src/math.ts"), MATH_TS_BODY_EDITED).unwrap(); + + let assert = Command::cargo_bin("testless") + .unwrap() + .arg("select") + .current_dir(root) + .assert() + .success(); + let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let json: serde_json::Value = serde_json::from_str(out.trim()).unwrap_or_else(|e| { + panic!("expected JSON stdout, got {out:?} ({e})"); + }); + + // Same total as add_body_edit_selects_add_and_formats_tests_excludes_unrelated's + // fixture (5 tests): the generated file's test is excluded entirely, not + // just unselected. + assert_eq!(json["stats"]["total_known"], 5); + + let tests = json["tests"].as_array().expect("tests array"); + assert!( + tests + .iter() + .all(|t| t["file"] != "src/generated/models.test.ts"), + "generated file's test must never be selectable, got {tests:?}" + ); +} + /// (c) A `package.json` edit forces `run_all`, exit 2. #[test] fn config_file_edit_forces_run_all_exit_2() { @@ -238,18 +380,240 @@ fn config_file_edit_forces_run_all_exit_2() { assert!(json["reason"].as_str().unwrap().contains("package.json")); } -/// `--to` isn't supported yet in v1: documented punt, hard error exit 1; -/// mirrors `changes`'s identical rejection. +// --------------------------------------------------------------------- +// `--to ` (issue #17): analyze an arbitrary `--from`..`--to` rev +// range instead of `--from` vs. the live worktree, by materializing `--to` +// in a temporary git worktree and running the whole pipeline rooted there. +// +// Fixture: three commits. +// - C1: baseline, `src/math.ts` (`add` + `unrelatedHelper`) and +// `src/greet.ts` (`greet`), each with its own test file. +// - C2: edits `add`'s body only. +// - C3: (on top of C2) edits `greet`'s body only. +// +// `--from C1 --to C2` must select only `add`'s two tests (the range +// contains no `greet` change at all, even though `greet.ts` in the C2 +// worktree is untouched relative to C1); `--from C1 --to C3` must select +// both `add`'s and `greet`'s tests, since both changes fall in that range. +// `unrelatedHelper`'s test must never be selected in either case. +// --------------------------------------------------------------------- + +const GREET_TS: &str = "\ +export function greet(name: string): string { return `Hello, ${name}!`; } +"; + +const GREET_TS_BODY_EDITED: &str = "\ +export function greet(name: string): string { return `Hi, ${name}!`; } +"; + +const GREET_TEST_TS: &str = "\ +import { it, expect } from \"vitest\"; +import { greet } from \"./greet\"; +it(\"greets by name\", () => { expect(greet(\"Ada\")).toBe(\"Hello, Ada!\"); }); +"; + +fn rev_parse(dir: &std::path::Path, rev: &str) -> String { + let output = std::process::Command::new("git") + .arg("-C") + .arg(dir) + .args(["rev-parse", rev]) + .output() + .expect("failed to spawn git rev-parse"); + assert!(output.status.success(), "git rev-parse {rev} failed"); + String::from_utf8(output.stdout).unwrap().trim().to_string() +} + +/// Builds the three-commit `--to` fixture, returning the tempdir alongside +/// each commit's full sha (C1, C2, C3, in that order). +fn init_to_range_repo() -> (tempfile::TempDir, String, String, String) { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join("package.json"), "{ \"name\": \"to-fixture\" }\n").unwrap(); + // Distinct tests calling this helper (potentially concurrently, since + // `cargo test` runs tests in parallel by default) must not produce + // byte-identical commits: an otherwise-identical tree + the same-second + // author/committer timestamp would hash to the *same* commit sha, and + // since the `--to` temp worktree's path is derived solely from that sha + // (by design: collision-safe across runs against the same rev), two + // unrelated repos racing to the same sha would collide on the same + // filesystem path. This nonce (the tempdir's own, guaranteed-unique + // path) makes every call's tree distinct, hence every commit's sha + // distinct, regardless of timing. + std::fs::write(root.join(".nonce"), root.display().to_string()).unwrap(); + std::fs::write(root.join("src/math.ts"), MATH_TS).unwrap(); + std::fs::write(root.join("src/greet.ts"), GREET_TS).unwrap(); + std::fs::write(root.join("src/math.test.ts"), MATH_TEST_TS).unwrap(); + std::fs::write(root.join("src/greet.test.ts"), GREET_TEST_TS).unwrap(); + git(root, &["init", "-b", "main"]); + git(root, &["add", "-A"]); + git(root, &["commit", "-m", "C1: baseline"]); + let c1 = rev_parse(root, "HEAD"); + + std::fs::write(root.join("src/math.ts"), MATH_TS_BODY_EDITED).unwrap(); + git(root, &["commit", "-am", "C2: edit add's body"]); + let c2 = rev_parse(root, "HEAD"); + + std::fs::write(root.join("src/greet.ts"), GREET_TS_BODY_EDITED).unwrap(); + git(root, &["commit", "-am", "C3: edit greet's body"]); + let c3 = rev_parse(root, "HEAD"); + + (tmp, c1, c2, c3) +} + +/// `--from C1 --to C2`: only `add`'s two tests are selected. `greet`'s test +/// (unchanged in this range) and `unrelatedHelper`'s test (never changed) +/// are both excluded. #[test] -fn to_flag_is_rejected() { - let tmp = init_repo(); +fn to_flag_selects_only_changes_within_from_to_to_range() { + let (tmp, c1, c2, _c3) = init_to_range_repo(); + + let assert = Command::cargo_bin("testless") + .unwrap() + .args(["select", "--from", &c1, "--to", &c2]) + .current_dir(tmp.path()) + .assert() + .success(); + let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let json: serde_json::Value = serde_json::from_str(out.trim()).unwrap_or_else(|e| { + panic!("expected JSON stdout, got {out:?} ({e})"); + }); + + assert_eq!(json["mode"], "selection"); + let tests = json["tests"].as_array().expect("tests array"); + let names: Vec> = tests + .iter() + .map(|t| { + t["name"] + .as_array() + .unwrap() + .iter() + .map(|s| s.as_str().unwrap().to_string()) + .collect() + }) + .collect(); + + assert!( + names.contains(&vec!["add".to_string(), "handles negatives".to_string()]), + "expected add/handles negatives in {names:?}" + ); + assert!( + names.contains(&vec!["add".to_string(), "handles zero".to_string()]), + "expected add/handles zero in {names:?}" + ); + assert!( + !names + .iter() + .any(|n| n.first().map(|s| s.as_str()) == Some("greets by name")), + "greet's test must NOT be selected for a C1..C2 range, got {names:?}" + ); + assert!( + !names + .iter() + .any(|n| n.first().map(|s| s.as_str()) == Some("unrelatedHelper")), + "unrelatedHelper's test must NOT be selected, got {names:?}" + ); + assert_eq!( + tests.len(), + 2, + "expected exactly 2 selected tests, got {tests:?}" + ); +} + +/// `--from C1 --to C3`: both `add`'s and `greet`'s tests are selected +/// (both changes fall inside the range), `unrelatedHelper`'s stays excluded. +#[test] +fn to_flag_selects_both_changes_across_wider_range() { + let (tmp, c1, _c2, c3) = init_to_range_repo(); + + let assert = Command::cargo_bin("testless") + .unwrap() + .args(["select", "--from", &c1, "--to", &c3]) + .current_dir(tmp.path()) + .assert() + .success(); + let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let json: serde_json::Value = serde_json::from_str(out.trim()).unwrap_or_else(|e| { + panic!("expected JSON stdout, got {out:?} ({e})"); + }); + + assert_eq!(json["mode"], "selection"); + let tests = json["tests"].as_array().expect("tests array"); + let names: Vec> = tests + .iter() + .map(|t| { + t["name"] + .as_array() + .unwrap() + .iter() + .map(|s| s.as_str().unwrap().to_string()) + .collect() + }) + .collect(); + + assert!( + names.contains(&vec!["add".to_string(), "handles negatives".to_string()]), + "expected add/handles negatives in {names:?}" + ); + assert!( + names.contains(&vec!["add".to_string(), "handles zero".to_string()]), + "expected add/handles zero in {names:?}" + ); + assert!( + names.contains(&vec!["greets by name".to_string()]), + "expected greet's test in {names:?}" + ); + assert!( + !names + .iter() + .any(|n| n.first().map(|s| s.as_str()) == Some("unrelatedHelper")), + "unrelatedHelper's test must NOT be selected, got {names:?}" + ); + assert_eq!( + tests.len(), + 3, + "expected exactly 3 selected tests, got {tests:?}" + ); +} + +/// The temp worktree materializing `--to` is cleaned up after the run: its +/// directory (`$TMPDIR/testless-to-`) must not exist once +/// the process has exited. +#[test] +fn to_flag_cleans_up_temp_worktree_after_run() { + let (tmp, c1, c2, _c3) = init_to_range_repo(); + let worktree_dir = std::env::temp_dir().join(format!("testless-to-{c2}")); + Command::cargo_bin("testless") .unwrap() - .args(["select", "--to", "HEAD"]) + .args(["select", "--from", &c1, "--to", &c2]) + .current_dir(tmp.path()) + .assert() + .success(); + + assert!( + !worktree_dir.exists(), + "expected temp worktree {} to be cleaned up after the run", + worktree_dir.display() + ); +} + +/// A `--to` rev that doesn't resolve locally degrades to the same +/// `run_all`/exit-2 fallback as a bad `--from` rev, rather than a hard +/// error. +#[test] +fn bad_to_rev_degrades_to_run_all_exit_2() { + let tmp = init_repo(); + let assert = Command::cargo_bin("testless") + .unwrap() + .args(["select", "--to", "not-a-real-rev"]) .current_dir(tmp.path()) .assert() - .code(1) - .stderr(predicates::str::contains("not yet supported")); + .code(2); + let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let json: serde_json::Value = serde_json::from_str(out.trim()).unwrap(); + assert_eq!(json["mode"], "run_all"); + assert!(json["reason"].as_str().unwrap().contains("--to")); } /// `--format text` prints `file :: seg1 > seg2` lines on stdout and a @@ -591,7 +955,9 @@ mod tests { "; const RUST_FMT_RS: &str = "\ -pub fn fmt(a: i64, b: i64) -> String { format!(\"{}\", crate::math::add(a, b)) } +use crate::math::add; + +pub fn fmt(a: i64, b: i64) -> String { format!(\"{}\", add(a, b)) } #[cfg(test)] mod tests { diff --git a/crates/cli/tests/why.rs b/crates/cli/tests/why.rs new file mode 100644 index 0000000..8f9e20e --- /dev/null +++ b/crates/cli/tests/why.rs @@ -0,0 +1,241 @@ +//! `testless why` CLI e2e coverage (#14): mirrors the git/tempdir harness in +//! `select.rs`, but drives the `why` subcommand end to end: index -> diff -> +//! classify -> walk `impacted_tests_with_paths` -> hop-path output. +//! +//! Fixture (TS, one repo, same shape as `select.rs`'s primary scenario): +//! - `src/math.ts`: `add`, called by `format.ts`'s `fmt` and by +//! `math.test.ts`'s `describe("add")` tests. +//! - `src/format.ts`: `fmt`, which calls `add`. +//! - `src/math.test.ts`: `describe("add")` with an `it("handles negatives")`. +//! - `src/format.test.ts`: a single `it("formats a sum")` that calls `fmt`. +//! +//! An `add`-body edit selects both, so `why formats` explains the +//! `add -> fmt -> "formats a sum"` path (exit 0, stdout mentions both +//! `add` and `formats`), while `why nonexistent` matches no selected test +//! (exit 1). + +use assert_cmd::Command; + +fn git(dir: &std::path::Path, args: &[&str]) { + let status = std::process::Command::new("git") + .arg("-C") + .arg(dir) + .args([ + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "-c", + "commit.gpgsign=false", + ]) + .args(args) + .status() + .expect("failed to spawn git"); + assert!(status.success(), "git {args:?} failed in {}", dir.display()); +} + +const MATH_TS: &str = "\ +export function add(a: number, b: number): number { return a + b; } +"; + +const MATH_TS_BODY_EDITED: &str = "\ +export function add(a: number, b: number): number { return a + b + 1; } +"; + +const FORMAT_TS: &str = "\ +import { add } from \"./math\"; +export function fmt(a: number, b: number): string { return `${add(a, b)}`; } +"; + +const MATH_TEST_TS: &str = "\ +import { describe, it, expect } from \"vitest\"; +import { add } from \"./math\"; + +describe(\"add\", () => { + it(\"handles negatives\", () => { expect(add(-1, -2)).toBe(-3); }); +}); +"; + +const FORMAT_TEST_TS: &str = "\ +import { it, expect } from \"vitest\"; +import { fmt } from \"./format\"; +it(\"formats a sum\", () => { expect(fmt(1, 2)).toBe(\"3\"); }); +"; + +fn init_repo() -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join("package.json"), "{ \"name\": \"why-fixture\" }\n").unwrap(); + std::fs::write(root.join("src/math.ts"), MATH_TS).unwrap(); + std::fs::write(root.join("src/format.ts"), FORMAT_TS).unwrap(); + std::fs::write(root.join("src/math.test.ts"), MATH_TEST_TS).unwrap(); + std::fs::write(root.join("src/format.test.ts"), FORMAT_TEST_TS).unwrap(); + git(root, &["init", "-b", "main"]); + git(root, &["add", "-A"]); + git(root, &["commit", "-m", "initial"]); + tmp +} + +/// `why formats` (a substring of `"formats a sum"`) explains the impacted +/// path from `add`'s body edit, through `fmt`, to the matching test. Exit +/// 0; stdout is human text (not piped through anything, so it's the `Text` +/// default) and mentions both the changed def and the destination test. +#[test] +fn why_explains_path_to_selected_test() { + let tmp = init_repo(); + let root = tmp.path(); + std::fs::write(root.join("src/math.ts"), MATH_TS_BODY_EDITED).unwrap(); + + let assert = Command::cargo_bin("testless") + .unwrap() + .args(["why", "formats"]) + .current_dir(root) + .assert() + .success(); + let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + + assert!(out.contains("add"), "expected the changed def in {out:?}"); + assert!( + out.contains("formats a sum"), + "expected the matched test's name in {out:?}" + ); + assert!( + out.contains("src/math.ts"), + "expected the changed def's file in {out:?}" + ); + assert!( + out.contains("src/format.test.ts"), + "expected the matched test's file in {out:?}" + ); +} + +/// A query matching no selected test exits 1 and says so, rather than +/// silently printing nothing or crashing. +#[test] +fn why_nonexistent_test_exits_1() { + let tmp = init_repo(); + let root = tmp.path(); + std::fs::write(root.join("src/math.ts"), MATH_TS_BODY_EDITED).unwrap(); + + Command::cargo_bin("testless") + .unwrap() + .args(["why", "nonexistent"]) + .current_dir(root) + .assert() + .code(1); +} + +/// JSON output (piped stdout) for an unambiguous match: `version`, `mode`, +/// a `test` object naming the matched test, and a non-empty `path` whose +/// hops both mention `add` and `fmt` along the way. +#[test] +fn why_json_output_when_piped() { + let tmp = init_repo(); + let root = tmp.path(); + std::fs::write(root.join("src/math.ts"), MATH_TS_BODY_EDITED).unwrap(); + + // assert_cmd pipes stdout by default (it's captured, not a TTY), so + // `why`'s TTY-sniffing already selects JSON here, same convention as + // `select`/`changes`. + let assert = Command::cargo_bin("testless") + .unwrap() + .args(["why", "formats"]) + .current_dir(root) + .assert() + .success(); + let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let json: serde_json::Value = serde_json::from_str(out.trim()).unwrap_or_else(|e| { + panic!("expected JSON stdout, got {out:?} ({e})"); + }); + + assert_eq!(json["version"], 1); + assert_eq!(json["mode"], "explained"); + assert_eq!(json["test"]["name"][0], "formats a sum"); + assert_eq!(json["test"]["file"], "src/format.test.ts"); + + let path = json["path"].as_array().expect("path array"); + assert!(!path.is_empty(), "expected a non-empty hop path"); + let path_str = format!("{path:?}"); + assert!( + path_str.contains("add"), + "expected the changed def somewhere in the path: {path_str}" + ); + assert!( + path_str.contains("fmt"), + "expected the intermediate caller somewhere in the path: {path_str}" + ); +} + +/// A config-file edit forces `run_all`; `why` mirrors `select`/`changes`'s +/// exit-2 contract rather than pretending to explain a walk that never ran. +#[test] +fn why_config_file_edit_forces_run_all_exit_2() { + let tmp = init_repo(); + let root = tmp.path(); + std::fs::write(root.join("package.json"), "{}\n").unwrap(); + + Command::cargo_bin("testless") + .unwrap() + .args(["why", "formats"]) + .current_dir(root) + .assert() + .code(2); +} + +fn rev_parse(dir: &std::path::Path, rev: &str) -> String { + let output = std::process::Command::new("git") + .arg("-C") + .arg(dir) + .args(["rev-parse", rev]) + .output() + .expect("failed to spawn git rev-parse"); + assert!(output.status.success(), "git rev-parse {rev} failed"); + String::from_utf8(output.stdout).unwrap().trim().to_string() +} + +/// `--to ` (issue #17): `why` explains the same `add -> fmt -> +/// "formats a sum"` path when given an explicit `--from C1 --to C2` rev +/// range instead of `--from` vs. the live worktree. +#[test] +fn why_explains_path_with_to_flag_rev_range() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("src")).unwrap(); + // See `select.rs`'s `init_to_range_repo` for why this nonce matters: + // without it, two parallel test processes committing byte-identical + // content within the same wall-clock second would hash to the same + // commit sha and race on the same `--to` temp worktree path. + std::fs::write(root.join(".nonce"), root.display().to_string()).unwrap(); + std::fs::write( + root.join("package.json"), + "{ \"name\": \"why-to-fixture\" }\n", + ) + .unwrap(); + std::fs::write(root.join("src/math.ts"), MATH_TS).unwrap(); + std::fs::write(root.join("src/format.ts"), FORMAT_TS).unwrap(); + std::fs::write(root.join("src/math.test.ts"), MATH_TEST_TS).unwrap(); + std::fs::write(root.join("src/format.test.ts"), FORMAT_TEST_TS).unwrap(); + git(root, &["init", "-b", "main"]); + git(root, &["add", "-A"]); + git(root, &["commit", "-m", "C1: baseline"]); + let c1 = rev_parse(root, "HEAD"); + + std::fs::write(root.join("src/math.ts"), MATH_TS_BODY_EDITED).unwrap(); + git(root, &["commit", "-am", "C2: edit add's body"]); + let c2 = rev_parse(root, "HEAD"); + + let assert = Command::cargo_bin("testless") + .unwrap() + .args(["why", "formats", "--from", &c1, "--to", &c2]) + .current_dir(root) + .assert() + .success(); + let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + + assert!(out.contains("add"), "expected the changed def in {out:?}"); + assert!( + out.contains("formats a sum"), + "expected the matched test's name in {out:?}" + ); +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 1256703..0e40488 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -13,8 +13,10 @@ categories.workspace = true anyhow = "1.0.104" bincode = "1" blake3 = "1.8.5" +globset = "0.4.19" ignore = "0.4.31" serde = { version = "1.0.229", features = ["derive"] } +toml = "1.1.3" tree-sitter = "0.26.11" [dev-dependencies] diff --git a/crates/core/src/classify.rs b/crates/core/src/classify.rs index fd943cc..ba2d4a9 100644 --- a/crates/core/src/classify.rs +++ b/crates/core/src/classify.rs @@ -7,9 +7,23 @@ //! //! Rule precedence (highest first): //! 1. Any changed path matching [`is_config_file`] -> `RunAll` immediately. -//! 2. Deleted/renamed-away *indexed* source file -> `RunAll { reason: -//! "deleted source file" }`: sound if coarse; a later plan can narrow -//! this to just the file's former importers. +//! 2. Deleted file (indexed or not) -> routed through the same raw-import +//! stem scan as rule 5: any still-present file whose raw import text +//! references the deleted path (by basename or extensionless stem) +//! seeds that importer's `ModuleInit` (`SeedKind::ModuleInit`). A +//! deleted file with no remaining importer contributes zero seeds — +//! its own tests died with it. (A `Renamed` file is handled by rule 4 +//! instead: its old content is diffed directly against its new-path +//! content, so rename semantics are unaffected.) +//! +//! A deleted **Go** file is a special case (Go imports name package +//! *directories*, never file stems, and same-package sibling files +//! reference each other via nothing at all): see [`seed_go_deletion`]. +//! +//! A deleted file that *is* indexed but whose name yields no usable scan +//! needle (see [`stem_needles`]) can't be soundly narrowed by the stem +//! scan at all; rather than silently under-selecting (contributing zero +//! seeds when a real importer might exist), this escalates to `RunAll`. //! 3. Added indexed file -> seed its `TestCase` defs and its `ModuleInit`, //! both `SeedKind::Added` (new exports; nothing referenced them before). //! 4. Modified/Renamed indexed file -> re-parse old vs. new content with @@ -18,7 +32,11 @@ //! *indexed* file's raw import text references it (substring match on //! basename, e.g. an import of `"./config.json"` matches changed path //! `config.json`), seed that importer's `ModuleInit`; otherwise the file -//! contributes zero seeds (e.g. a README edit). +//! contributes zero seeds (e.g. a README edit). Unlike rule 2, a missing +//! scan needle here just yields no *extra* seeds rather than escalating: +//! an unindexed file was never a selection candidate itself, so the +//! stakes of under-selecting its (rare, short-named) importers are +//! accepted. //! 6. Any I/O/parse error anywhere -> `RunAll` with a reason naming the //! file. //! @@ -124,6 +142,10 @@ pub fn classify( match classify_one(repo, new_graph, registry, c, old_src_of) { Ok(PerFile::Seeds(mut s)) => seeds.append(&mut s), Ok(PerFile::ScanImporters(path)) => needs_import_scan.push(path), + Ok(PerFile::SeedsAndScan(mut s, mut paths)) => { + seeds.append(&mut s); + needs_import_scan.append(&mut paths); + } Err(reason) => return ChangeMode::RunAll { reason }, } } @@ -142,9 +164,17 @@ pub fn classify( enum PerFile { Seeds(Vec), - /// `path` isn't recognized by any registered `Language`: batched up for - /// a single pass over every indexed file's raw imports. + /// `path` needs a raw-import stem scan rather than def-level diffing: + /// either it isn't recognized by any registered `Language`, or it was + /// deleted (so there's nothing on disk left to parse, indexed language + /// or not). Batched up for a single pass over every indexed file's raw + /// imports. ScanImporters(PathBuf), + /// A deleted Go file: seeds found directly (its surviving package + /// siblings' `ModuleInit`, see [`seed_go_deletion`]) plus one or more + /// extra paths for the raw-import stem scan (the deleted file's own + /// path, and its package directory). + SeedsAndScan(Vec, Vec), } fn classify_one( @@ -155,8 +185,25 @@ fn classify_one( old_src_of: &dyn Fn(&Path) -> anyhow::Result>, ) -> Result { if c.status == FileStatus::Deleted { - if registry.for_path(&c.path).is_some() { - return Err(format!("deleted source file: {}", c.path.display())); + // Whether or not `path` was itself indexable, it no longer exists + // to parse: fall back to the raw-import stem scan so any surviving + // importer's now-dangling reference still seeds that importer's + // `ModuleInit` (see rule 2's doc comment above). + if let Some(lang) = registry.for_path(&c.path) { + if lang.id() == "go" { + return seed_go_deletion(new_graph, &c.path); + } + if stem_needles(&c.path).is_empty() { + // An indexed file whose name is too short to yield any + // usable scan needle (see `stem_needles`): the stem scan + // below would silently find nothing, which for an + // *indexed* deleted file is an under-select, not a benign + // zero-seed result. Escalate rather than guess. + return Err(format!( + "deleted indexed file {} has no usable stem (<{MIN_STEM_LEN} chars) to find importers", + c.path.display() + )); + } } return Ok(PerFile::ScanImporters(c.path.clone())); } @@ -254,29 +301,49 @@ fn seed_added_file(new_graph: &Graph, file_id: FileId) -> Vec { seeds } +/// Minimum length (in chars) a [`stem_needles`] candidate must have to be +/// used as a substring match in [`scan_importers`]. Below this, a "match" +/// is more likely a false positive (e.g. stem `"io"` inside unrelated +/// `"studio"`) than a real reference, so short candidates are dropped +/// rather than trusted (Item 4). +const MIN_STEM_LEN: usize = 3; + +/// The substring needles [`scan_importers`] uses for `path`: its full +/// basename (e.g. `config.json`) and its extension-stripped stem (e.g. +/// `config`, covering an extensionless import specifier like `import cfg +/// from "./config"`), each kept only when at least [`MIN_STEM_LEN`] chars +/// long. Empty when both candidates are too short (or `path` has no +/// usable file name at all). +fn stem_needles(path: &Path) -> Vec { + let mut out = Vec::new(); + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name.chars().count() >= MIN_STEM_LEN { + out.push(name.to_string()); + } + } + if let Some(stem) = path.file_stem().and_then(|n| n.to_str()) { + if stem.chars().count() >= MIN_STEM_LEN && out.iter().all(|s| s != stem) { + out.push(stem.to_string()); + } + } + out +} + /// Scan every already-indexed file's raw import text (from `extractions`, /// which lines up index-for-index with `new_graph.files`; no re-reading or -/// re-parsing) for a reference to any of `changed_paths` (basename substring -/// match: both the full basename, e.g. `config.json`, and the basename with -/// its extension stripped, e.g. `config`, so an extensionless import -/// specifier like `import cfg from "./config"` still matches a changed -/// `config.json`). Each match seeds that importing file's `ModuleInit`. +/// re-parsing) for a reference to any of `changed_paths` (substring match +/// against each path's [`stem_needles`]). Each match seeds that importing +/// file's `ModuleInit`. `changed_paths` may be files with no registered +/// `Language`, deleted files (indexed or not), or (for a deleted Go file, +/// see [`seed_go_deletion`]) a package directory path — either way there's +/// no def-level diff to run, so this stem scan is the only way to find +/// who's affected. fn scan_importers( new_graph: &Graph, extractions: &[CachedExtraction], changed_paths: &[PathBuf], ) -> Vec { - let mut stems: Vec<&str> = Vec::new(); - for p in changed_paths { - if let Some(name) = p.file_name().and_then(|n| n.to_str()) { - stems.push(name); - } - if let Some(stem) = p.file_stem().and_then(|n| n.to_str()) { - if !stem.is_empty() { - stems.push(stem); - } - } - } + let stems: Vec = changed_paths.iter().flat_map(|p| stem_needles(p)).collect(); if stems.is_empty() { return Vec::new(); } @@ -286,7 +353,7 @@ fn scan_importers( let matched = extraction .imports .iter() - .any(|imp| stems.iter().any(|stem| imp.raw.contains(stem))); + .any(|imp| stems.iter().any(|stem| imp.raw.contains(stem.as_str()))); if matched { let file_id = FileId(i as u32); if let Some(m) = new_graph.module_init(file_id) { @@ -300,6 +367,69 @@ fn scan_importers( seeds } +/// A deleted **Go** source file: Go imports name package *directories* +/// (e.g. `example.com/m/pkg`), never a file's stem, and files within the +/// same package don't import each other at all (no statement references a +/// same-package sibling by name). So neither half of the ordinary deleted- +/// file handling (rule 2) can find the right seeds on its own: +/// +/// - The basename/stem scan (`scan_importers` via `stem_needles`) never +/// matches a same-package sibling, since nothing imports it by name. +/// - It also can't find *other* packages' importers unless it's given the +/// package directory's own name as a needle (an import of +/// `example.com/m/pkg` contains `pkg`, the directory's basename). +/// +/// Fix: (a) directly seed the `ModuleInit` of every surviving `new_graph` +/// file in the same directory as `deleted_path` (its package siblings, +/// including any test file — [`sibling_module_inits`]), and (b) still run +/// the ordinary stem scan, but with the package directory's path folded in +/// as an extra needle source alongside the deleted file's own path. +/// +/// If neither the deleted file's own name nor its package directory's name +/// yields a usable [`stem_needles`] candidate, this can't soundly rule out +/// an importer in another package — same as the non-Go short-stem case, +/// escalates to `RunAll` rather than silently under-selecting. +fn seed_go_deletion(new_graph: &Graph, deleted_path: &Path) -> Result { + let seeds = sibling_module_inits(new_graph, deleted_path); + + let mut scan_paths = vec![deleted_path.to_path_buf()]; + if let Some(dir) = deleted_path.parent() { + if !dir.as_os_str().is_empty() { + scan_paths.push(dir.to_path_buf()); + } + } + + let has_needle = scan_paths.iter().any(|p| !stem_needles(p).is_empty()); + if !has_needle { + return Err(format!( + "deleted go file {} (and its package directory) has no usable stem (<{MIN_STEM_LEN} chars) to find importers", + deleted_path.display() + )); + } + + Ok(PerFile::SeedsAndScan(seeds, scan_paths)) +} + +/// Every `new_graph` file sharing `deleted_path`'s parent directory (i.e. +/// its Go package siblings, including test files), paired with its +/// `ModuleInit` def where present, as `SeedKind::ModuleInit` seeds. +fn sibling_module_inits(new_graph: &Graph, deleted_path: &Path) -> Vec { + let dir = deleted_path.parent(); + let mut seeds = Vec::new(); + for (i, f) in new_graph.files.iter().enumerate() { + if f.path.parent() == dir { + let file_id = FileId(i as u32); + if let Some(m) = new_graph.module_init(file_id) { + seeds.push(Seed { + def: m, + kind: SeedKind::ModuleInit, + }); + } + } + } + seeds +} + fn find_file_id(graph: &Graph, path: &Path) -> Option { graph .files diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs new file mode 100644 index 0000000..016f258 --- /dev/null +++ b/crates/core/src/config.rs @@ -0,0 +1,228 @@ +//! `testless.toml`: an optional, repo-root escape-hatch config for two knobs +//! the spec calls out as needing manual override rather than static +//! inference: +//! +//! ```toml +//! always-run = ["tests/smoke/**", "**/*.e2e.test.ts"] +//! ignore = ["**/generated/**", "*.pb.go"] +//! ``` +//! +//! - `ignore`: discovery-level. Matched (via `globset`) against repo-relative +//! paths; a matching file is dropped before indexing ever sees it, so +//! nothing it defines is a candidate for anything (selection, `why`, +//! `total_known`). +//! - `always-run`: selection-level. After the impact walk, every `TestCase` +//! def whose *file* matches one of these globs is added to the selection +//! regardless of whether the walk reached it; see +//! [`always_run_matches`]. +//! +//! A missing `testless.toml` is not an error: [`Config::load`] returns +//! [`Config::default`] (both lists empty, i.e. a no-op). A `testless.toml` +//! that exists but fails to parse (bad TOML syntax, wrong value types) *is* +//! an error: the file is something the user deliberately wrote, so a broken +//! one deserves a loud failure rather than a silent `run_all` degrade (that +//! degrade is reserved for transient/environmental failures like a missing +//! `git`, not user-authored config). + +use std::path::Path; + +use anyhow::{Context, Result}; +use globset::{Glob, GlobSet, GlobSetBuilder}; +use serde::Deserialize; + +use crate::graph::{DefId, DefKind, Graph}; + +/// Parsed `testless.toml`. Both fields default to empty (a no-op config), +/// so a config with only one of the two keys set is valid. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct Config { + #[serde(default)] + pub always_run: Vec, + #[serde(default)] + pub ignore: Vec, +} + +impl Config { + /// Loads `{repo}/testless.toml`. A missing file yields + /// `Ok(Config::default())`; any other read failure or a parse error + /// (bad TOML, wrong value shapes) yields `Err` with the file path in + /// context, so callers should treat this as a hard failure, not a + /// `run_all` degrade. + pub fn load(repo: &Path) -> Result { + let path = repo.join("testless.toml"); + let src = match std::fs::read_to_string(&path) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Config::default()), + Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())), + }; + toml::from_str(&src).with_context(|| format!("parsing {}", path.display())) + } + + /// Compiles `ignore` into a `GlobSet` for discovery-time filtering. + /// `Err` on any invalid glob pattern (also a "malformed config" failure). + pub fn ignore_globset(&self) -> Result { + build_globset(&self.ignore) + } + + /// Compiles `always_run` into a `GlobSet`. Exposed mainly so callers can + /// eagerly validate the config (fail fast on a bad pattern) even before + /// [`always_run_matches`] is needed. + pub fn always_run_globset(&self) -> Result { + build_globset(&self.always_run) + } +} + +fn build_globset(patterns: &[String]) -> Result { + let mut builder = GlobSetBuilder::new(); + for pattern in patterns { + let glob = Glob::new(pattern) + .with_context(|| format!("invalid glob pattern in testless.toml: {pattern:?}"))?; + builder.add(glob); + } + builder + .build() + .context("building globset from testless.toml") +} + +/// Every `TestCase` def in `graph` whose file matches one of `config`'s +/// `always-run` globs, paired with the glob pattern (as written in +/// `testless.toml`) that matched it. Selection-level: called after the +/// impact walk, so its results get unioned into the walk's own selection +/// rather than replacing it. When a file matches more than one pattern, the +/// first matching pattern (in `always_run`'s declared order) is reported. +pub fn always_run_matches(graph: &Graph, config: &Config) -> Result> { + if config.always_run.is_empty() { + return Ok(Vec::new()); + } + let set = config.always_run_globset()?; + + let mut out = Vec::new(); + for (idx, def) in graph.defs.iter().enumerate() { + if def.kind != DefKind::TestCase { + continue; + } + let path = &graph.files[def.file.0 as usize].path; + if let Some(&first) = set.matches(path).first() { + out.push((DefId(idx as u32), config.always_run[first].clone())); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::graph::{Def, FileNode}; + + #[test] + fn missing_file_yields_default() { + let tmp = tempfile::tempdir().unwrap(); + let config = Config::load(tmp.path()).unwrap(); + assert_eq!(config, Config::default()); + assert!(config.always_run.is_empty()); + assert!(config.ignore.is_empty()); + } + + #[test] + fn valid_file_parses_both_lists() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("testless.toml"), + "always-run = [\"tests/smoke/**\", \"**/*.e2e.test.ts\"]\n\ + ignore = [\"**/generated/**\", \"*.pb.go\"]\n", + ) + .unwrap(); + + let config = Config::load(tmp.path()).unwrap(); + assert_eq!( + config.always_run, + vec!["tests/smoke/**".to_string(), "**/*.e2e.test.ts".to_string()] + ); + assert_eq!( + config.ignore, + vec!["**/generated/**".to_string(), "*.pb.go".to_string()] + ); + } + + #[test] + fn valid_file_with_only_one_key_defaults_the_other() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("testless.toml"), "ignore = [\"*.pb.go\"]\n").unwrap(); + + let config = Config::load(tmp.path()).unwrap(); + assert!(config.always_run.is_empty()); + assert_eq!(config.ignore, vec!["*.pb.go".to_string()]); + } + + #[test] + fn malformed_toml_is_an_error() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("testless.toml"), + "always-run = [not valid toml\n", + ) + .unwrap(); + + let err = Config::load(tmp.path()).unwrap_err(); + assert!( + format!("{err:#}").contains("testless.toml"), + "error should mention testless.toml: {err:#}" + ); + } + + #[test] + fn wrong_value_type_is_an_error() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("testless.toml"), "always-run = \"nope\"\n").unwrap(); + + assert!(Config::load(tmp.path()).is_err()); + } + + fn file(g: &mut Graph, path: &str) -> crate::graph::FileId { + g.add_file(FileNode { + path: path.into(), + hash: [0; 32], + lang: "ts".into(), + }) + } + + fn def(g: &mut Graph, name: &str, kind: DefKind, file: crate::graph::FileId) -> DefId { + g.add_def(Def { + name: name.into(), + kind, + file, + start_line: 1, + end_line: 2, + test_id: None, + computed_name: false, + }) + } + + #[test] + fn always_run_matches_selects_test_files_by_glob() { + let mut g = Graph::default(); + let smoke_file = file(&mut g, "tests/smoke/login.test.ts"); + let other_file = file(&mut g, "src/math.test.ts"); + let smoke_test = def(&mut g, "logs in", DefKind::TestCase, smoke_file); + let _other_test = def(&mut g, "adds", DefKind::TestCase, other_file); + + let config = Config { + always_run: vec!["tests/smoke/**".to_string()], + ignore: vec![], + }; + + let matches = always_run_matches(&g, &config).unwrap(); + assert_eq!(matches, vec![(smoke_test, "tests/smoke/**".to_string())]); + } + + #[test] + fn empty_always_run_matches_nothing() { + let mut g = Graph::default(); + let f = file(&mut g, "src/math.test.ts"); + def(&mut g, "adds", DefKind::TestCase, f); + + let config = Config::default(); + assert_eq!(always_run_matches(&g, &config).unwrap(), Vec::new()); + } +} diff --git a/crates/core/src/discover.rs b/crates/core/src/discover.rs index f7371dd..ce672f4 100644 --- a/crates/core/src/discover.rs +++ b/crates/core/src/discover.rs @@ -1,12 +1,24 @@ use std::path::{Path, PathBuf}; +use globset::GlobSet; use ignore::WalkBuilder; use crate::language::{Language, Registry}; /// Walk `root`, respecting `.gitignore` and hard-skipping known noise /// directories, returning repo-relative sorted paths matched by `registry`. -pub fn discover<'r>(root: &Path, registry: &'r Registry) -> Vec<(PathBuf, &'r dyn Language)> { +/// +/// `ignore`, when given, is `testless.toml`'s `ignore` glob list (see +/// `crate::config`): any repo-relative path it matches is dropped here, at +/// discovery time, so an ignored file never becomes a `FileNode`/`Def` +/// candidate at all (not indexed, not selectable, not counted in +/// `total_known`). `None` means no `testless.toml`-level filtering (every +/// existing caller that predates the config feature). +pub fn discover<'r>( + root: &Path, + registry: &'r Registry, + ignore: Option<&GlobSet>, +) -> Vec<(PathBuf, &'r dyn Language)> { const SKIP_DIRS: &[&str] = &["node_modules", "vendor", "target", ".testless"]; let mut out = Vec::new(); @@ -37,6 +49,9 @@ pub fn discover<'r>(root: &Path, registry: &'r Registry) -> Vec<(PathBuf, &'r dy .strip_prefix(root) .expect("entry under root") .to_path_buf(); + if ignore.is_some_and(|set| set.is_match(&rel)) { + continue; + } out.push((rel, lang)); } } @@ -66,7 +81,10 @@ mod tests { std::fs::write(root.join(".gitignore"), "ignored.fk\n").unwrap(); let r = Registry::new(vec![Box::new(Fake)]); - let found: Vec<_> = discover(root, &r).into_iter().map(|(p, _)| p).collect(); + let found: Vec<_> = discover(root, &r, None) + .into_iter() + .map(|(p, _)| p) + .collect(); assert_eq!( found, vec![ @@ -76,4 +94,29 @@ mod tests { ] ); } + + /// A `testless.toml`-style `ignore` glob (`**/generated/**`) excludes a + /// matching file at discovery time: it never becomes a candidate, so it + /// doesn't show up in `discover`'s output at all, unlike a + /// `.gitignore`d file (already covered above) which is excluded for a + /// different reason but the same observable effect. + #[test] + fn ignore_globset_excludes_matching_files() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("src/generated")).unwrap(); + std::fs::write(root.join("src/a.fk"), "").unwrap(); + std::fs::write(root.join("src/generated/models.fk"), "").unwrap(); + + let r = Registry::new(vec![Box::new(Fake)]); + let mut builder = globset::GlobSetBuilder::new(); + builder.add(globset::Glob::new("**/generated/**").unwrap()); + let ignore = builder.build().unwrap(); + + let found: Vec<_> = discover(root, &r, Some(&ignore)) + .into_iter() + .map(|(p, _)| p) + .collect(); + assert_eq!(found, vec![std::path::PathBuf::from("src/a.fk")]); + } } diff --git a/crates/core/src/gitio.rs b/crates/core/src/gitio.rs index 2b5f567..2f7c084 100644 --- a/crates/core/src/gitio.rs +++ b/crates/core/src/gitio.rs @@ -163,6 +163,120 @@ pub fn show_file(repo: &Path, rev: &str, path: &Path) -> Result> bail!("git show {spec} failed: {}", stderr.trim()); } +/// Resolves `rev` to its full object id (`git rev-parse --verify `), +/// used to build a collision-safe [`TempWorktree`] directory name. `Err` for +/// a rev that doesn't resolve locally (bad rev, or one that exists remotely +/// but hasn't been fetched) — the same failure shape `changed_files` already +/// has for a bad `--from`. +fn resolve_rev(repo: &Path, rev: &str) -> Result { + let output = run_git(repo, &["rev-parse", "--verify", rev])?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("git rev-parse --verify {rev} failed: {}", stderr.trim()); + } + let sha = + String::from_utf8(output.stdout).context("git rev-parse output is not valid UTF-8")?; + Ok(sha.trim().to_string()) +} + +/// A temporary, detached-HEAD git worktree checked out at an arbitrary +/// revision (`git worktree add --detach `), backing `--to ` +/// support: the analysis pipeline runs against this checkout as its repo +/// root instead of the caller's actual worktree, so `--from`/`--to` can name +/// any two revisions rather than only `--from` vs. the live worktree. +/// +/// Lives at `$TMPDIR/testless-to--`: the sha suffix +/// (not the raw `rev` string, which might not be filesystem-safe, e.g. +/// `origin/main`) makes the location deterministic per revision, and the +/// trailing pid suffix keeps two concurrent `testless` processes analyzing +/// the *same* rev (e.g. two CI jobs racing the same PR) from colliding on +/// one worktree — each process gets its own, since `git worktree add` +/// refuses to reuse a path/registration another checkout is still using. +/// +/// Removed on drop (`git worktree remove --force`, then `git worktree +/// prune`) — including on an early return via `?` anywhere between creation +/// and drop — via a hand-rolled `Drop` guard rather than an explicit cleanup +/// call at every exit path (no `scopeguard` dependency in this crate). +#[derive(Debug)] +pub struct TempWorktree { + path: PathBuf, + repo: PathBuf, +} + +impl TempWorktree { + /// Requires `rev` to already exist in `repo`'s local object store: a + /// rev that hasn't been fetched surfaces as an `Err` here (see + /// `resolve_rev`), not a silent no-op. + pub fn create(repo: &Path, rev: &str) -> Result { + let sha = resolve_rev(repo, rev).with_context(|| format!("resolving --to rev {rev:?}"))?; + let pid = std::process::id(); + let path = std::env::temp_dir().join(format!("testless-to-{sha}-{pid}")); + + // A leftover directory/registration from a previous crashed or + // force-killed run would otherwise make `git worktree add` fail + // outright and permanently wedge every future `--to ` + // run; best-effort clear both before adding. Failures here are + // expected (nothing to clear) and intentionally ignored. + let _ = Command::new("git") + .arg("-C") + .arg(repo) + .args(["worktree", "remove", "--force"]) + .arg(&path) + .output(); + let _ = std::fs::remove_dir_all(&path); + + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(["worktree", "add", "--detach"]) + .arg(&path) + .arg(rev) + .output() + .with_context(|| format!("failed to run `git worktree add` for rev {rev:?}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "git worktree add --detach {} {rev} failed: {}", + path.display(), + stderr.trim() + ); + } + + Ok(Self { + path, + repo: repo.to_path_buf(), + }) + } + + /// The checkout directory: the analysis pipeline's repo root for the + /// `--to` rev, valid for exactly as long as `self` is alive. + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempWorktree { + fn drop(&mut self) { + // Best-effort: a `Drop` impl can't propagate an error, and a + // cleanup failure (e.g. the directory already gone) shouldn't panic + // mid-unwind. `worktree remove --force` deletes the checkout + // directory itself; `worktree prune` clears the (now-dangling) + // registration in `repo`'s `.git/worktrees/` in the rare case + // `remove` didn't run (e.g. the directory was already gone). + let _ = Command::new("git") + .arg("-C") + .arg(&self.repo) + .args(["worktree", "remove", "--force"]) + .arg(&self.path) + .output(); + let _ = Command::new("git") + .arg("-C") + .arg(&self.repo) + .args(["worktree", "prune"]) + .output(); + } +} + #[cfg(test)] mod tests { use super::*; @@ -286,4 +400,40 @@ mod tests { other => panic!("expected Renamed, got {other:?}"), } } + + #[test] + fn temp_worktree_checks_out_rev_content_and_cleans_up_on_drop() { + let dir = init_repo(); + fs::write(dir.path().join("a.txt"), "second\n").unwrap(); + git(dir.path(), &["commit", "-am", "second commit"]); + + let path = { + let wt = TempWorktree::create(dir.path(), "HEAD~1").expect("create worktree"); + let checked_out = fs::read_to_string(wt.path().join("a.txt")).expect("read a.txt"); + assert_eq!( + checked_out, "original\n", + "worktree should have HEAD~1's content, not HEAD's" + ); + assert!(wt.path().exists()); + wt.path().to_path_buf() + }; + // Dropped at end of the block above; the checkout directory must be + // gone, and the registration pruned (a second `create` against the + // same rev must succeed cleanly, not collide with a stale entry). + assert!( + !path.exists(), + "worktree directory should be removed after drop, still at {}", + path.display() + ); + + let wt2 = TempWorktree::create(dir.path(), "HEAD~1").expect("recreate worktree"); + assert_eq!(wt2.path(), path, "same rev resolves to the same temp path"); + } + + #[test] + fn temp_worktree_bad_rev_is_err() { + let dir = init_repo(); + let result = TempWorktree::create(dir.path(), "not-a-real-rev"); + assert!(result.is_err(), "expected Err for bad rev, got {result:?}"); + } } diff --git a/crates/core/src/indexer.rs b/crates/core/src/indexer.rs index 7598bc3..47df7f5 100644 --- a/crates/core/src/indexer.rs +++ b/crates/core/src/indexer.rs @@ -2,6 +2,7 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; +use globset::GlobSet; use tree_sitter::Parser; use crate::cache::CachedExtraction; @@ -19,9 +20,10 @@ pub struct IndexStats { } /// Walk `root`, parse every file `registry` matches, and build the full -/// `Graph` from scratch (no previous run to reuse). +/// `Graph` from scratch (no previous run to reuse, no `testless.toml` +/// `ignore` filtering). pub fn index_repo(root: &Path, registry: &Registry) -> Result { - let (graph, _extractions, _stats) = index_repo_incremental(root, registry, None)?; + let (graph, _extractions, _stats) = index_repo_incremental(root, registry, None, None)?; Ok(graph) } @@ -32,12 +34,18 @@ pub fn index_repo(root: &Path, registry: &Registry) -> Result { /// there's no in-place patching and no risk of dangling ids. Deleted files /// drop out naturally since they're no longer in the discovered set; renames /// are just a delete + an add. +/// +/// `ignore` is `testless.toml`'s compiled `ignore` glob set (see +/// `crate::config::Config::ignore_globset`), forwarded straight to +/// `discover`; `None` skips that filtering entirely (no config, or a caller +/// that predates the config feature). pub fn index_repo_incremental( root: &Path, registry: &Registry, + ignore: Option<&GlobSet>, prev: Option<(Graph, Vec)>, ) -> Result<(Graph, Vec, IndexStats)> { - let files = discover(root, registry); + let files = discover(root, registry, ignore); let mut prev_extractions: HashMap = prev .map(|(_, extractions)| { @@ -185,14 +193,19 @@ pub fn index_repo_incremental( // Pass 3: resolve calls/reads to tier-1 candidates. Scope for a ref in // file F is F itself plus every file F `Imports` (reusing `seen`, which - // pass 2 already built as exactly that from/to set). Defs are indexed - // under their *short* name: a method def like `Calc.push` is indexed - // under `push` too, so a bare-identifier ref matches both plain - // functions and qualified methods whether or not `ref.qualifier` is - // set (the receiver variable rarely equals the type name, so this is a - // deliberate over-approximation). `ModuleInit` defs (name ``) - // are excluded from candidacy since nothing ever references them by - // name. + // pass 2 already built as exactly that from/to set) plus, for Go only, + // every sibling file in F's own package directory (see the `lang.id() + // == "go"` scope extension below): Go's unit of visibility is the + // package, not the file, and a file can never `import` its own + // package, so cross-file same-package calls would otherwise always + // resolve as `Unknown` even though they're entirely unambiguous. Defs + // are indexed under their *short* name: a method def like `Calc.push` + // is indexed under `push` too, so a bare-identifier ref matches both + // plain functions and qualified methods whether or not `ref.qualifier` + // is set (the receiver variable rarely equals the type name, so this + // is a deliberate over-approximation). `ModuleInit` defs (name + // ``) are excluded from candidacy since nothing ever + // references them by name. let mut imports_of: HashMap> = HashMap::new(); for (from, to) in &seen { imports_of.entry(*from).or_default().push(*to); @@ -222,11 +235,19 @@ pub fn index_repo_incremental( for (file_idx, extraction) in extractions.iter().enumerate() { let file_id = FileId(file_idx as u32); let base = file_def_base[file_idx]; + let (rel_path, lang) = &files[file_idx]; let mut scope: Vec = vec![file_id]; if let Some(targets) = imports_of.get(&file_id) { scope.extend(targets.iter().copied()); } + if lang.id() == "go" { + if let Some(dir) = rel_path.parent() { + if let Some(siblings) = dir_to_files.get(dir) { + scope.extend(siblings.iter().copied().filter(|&f| f != file_id)); + } + } + } let candidates_for = |name: &str| -> Vec { scope .iter() diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 1dc6cc5..9e3c21f 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,5 +1,6 @@ pub mod cache; pub mod classify; +pub mod config; pub mod diffdef; pub mod discover; pub mod fingerprint; diff --git a/crates/core/src/walk.rs b/crates/core/src/walk.rs index abf5ffa..31b3986 100644 --- a/crates/core/src/walk.rs +++ b/crates/core/src/walk.rs @@ -16,7 +16,23 @@ //! file's (and its transitive importers') tests. //! - `Calls{from, to: Unknown(name)}` where `name == short_name(D)` -> //! `from` is impacted (an unresolved call that could dynamically dispatch -//! to D). +//! to D), PROVIDED D's file is reachable from the caller's file through +//! the forward transitive import closure (the caller's file imports, +//! directly or indirectly, D's file), or they're the same file. Why this +//! scoping is sound: tier-1 name resolution for a ref in file C only +//! ever looks at C's own file plus its direct imports; a ref is left +//! `Unknown` either because the callee is external (not in the repo +//! graph at all) or because the name would only resolve through an +//! import chain longer than one hop that tier-1 didn't attempt, or +//! through re-export indirection. In every one of those cases, the only +//! files C could possibly be naming a callee in are C's own file and +//! every file reachable from C by following `Imports` edges forward +//! (transitively) — that's precisely our import-edge model's full +//! picture of "names C could plausibly see". A def living in a file C's +//! import graph can't reach is a def whose name could never have entered +//! C's resolution scope under our model, so excluding it drops only +//! noise (an unrelated same-named def somewhere unconnected in the +//! repo), never a real ambiguous callee. //! - If D is a `ModuleInit`: every file in the transitive importer closure //! of D's file (including D's own file) has its `ModuleInit` enqueued and //! its `TestCase` defs enqueued too (not just collected; a widened test @@ -29,11 +45,37 @@ //! through them: a test helper (itself a `TestCase`) that's called by other //! tests must keep propagating impact to those callers. +use std::cell::RefCell; use std::collections::{HashMap, HashSet, VecDeque}; +use serde::{Deserialize, Serialize}; + use crate::classify::Seed; use crate::graph::{CallTarget, DefId, DefKind, Edge, FileId, Graph}; +/// Which reverse edge a `Hop` crossed; mirrors the module doc comment's +/// edge-by-edge rule list above, plus `ImportCloses` for the module-init +/// importer-closure widening step (crossing from a changed module's init +/// into a file that transitively imports it, or into that file's own +/// tests). +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum HopKind { + Calls, + Reads, + Contains, + UnknownName(String), + ImportCloses, +} + +/// One step of a reconstructed seed -> test impact path: `from` (already +/// impacted) reached `to` (newly impacted) via `edge`. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct Hop { + pub from: DefId, + pub edge: HopKind, + pub to: DefId, +} + /// All `TestCase` defs impacted by `seeds`, per the spec's reverse- /// reachability rules. Deterministic order (ascending `DefId`). /// @@ -45,14 +87,32 @@ use crate::graph::{CallTarget, DefId, DefKind, Edge, FileId, Graph}; /// eventually skip re-walking callers) and for richer reporting; it's /// reserved, not dead weight. pub fn impacted_tests(graph: &Graph, seeds: &[Seed]) -> Vec { + impacted_tests_with_paths(graph, seeds) + .into_iter() + .map(|(d, _path)| d) + .collect() +} + +/// Same selection semantics as `impacted_tests`, but additionally +/// reconstructs, for each selected test, the shortest seed -> test hop path +/// (the BFS's first-discovery parent pointer chain). Used by `testless why` +/// to explain a selection; `impacted_tests` is a thin wrapper around this +/// that discards the paths. +pub fn impacted_tests_with_paths(graph: &Graph, seeds: &[Seed]) -> Vec<(DefId, Vec)> { let index = ReverseIndex::build(graph); let mut visited: HashSet = HashSet::new(); let mut queue: VecDeque = VecDeque::new(); let mut tests: HashSet = HashSet::new(); + // First-discovery parent pointer per def, recorded the moment a def is + // newly enqueued via some reverse edge (never for seeds, which are the + // roots of the walk's implicit forest). Since `visited` blocks + // rediscovery, the first hop recorded here is always the shortest + // (fewest-hops) path from *some* seed back to that def. + let mut predecessors: HashMap = HashMap::new(); for seed in seeds { - enqueue(&mut queue, &mut visited, seed.def); + enqueue_root(&mut queue, &mut visited, seed.def); } while let Some(d) = queue.pop_front() { @@ -66,12 +126,26 @@ pub fn impacted_tests(graph: &Graph, seeds: &[Seed]) -> Vec { if let Some(callers) = index.callers.get(&d) { for &c in callers { - enqueue(&mut queue, &mut visited, c); + enqueue_hop( + &mut queue, + &mut visited, + &mut predecessors, + d, + HopKind::Calls, + c, + ); } } if let Some(readers) = index.readers.get(&d) { for &r in readers { - enqueue(&mut queue, &mut visited, r); + enqueue_hop( + &mut queue, + &mut visited, + &mut predecessors, + d, + HopKind::Reads, + r, + ); } } if let Some(&parent) = index.containers.get(&d) { @@ -91,19 +165,43 @@ pub fn impacted_tests(graph: &Graph, seeds: &[Seed]) -> Vec { // closure loop enqueuing `ModuleInit`s directly (never through // this `containers` edge). if graph.def(parent).kind != DefKind::ModuleInit { - enqueue(&mut queue, &mut visited, parent); + enqueue_hop( + &mut queue, + &mut visited, + &mut predecessors, + d, + HopKind::Contains, + parent, + ); } } if let Some(unknown_callers) = index.unknown_by_name.get(short_name(&def.name)) { for &c in unknown_callers { - enqueue(&mut queue, &mut visited, c); + let caller_file = graph.def(c).file; + if index.imports_reach(caller_file, def.file) { + enqueue_hop( + &mut queue, + &mut visited, + &mut predecessors, + d, + HopKind::UnknownName(short_name(&def.name).to_string()), + c, + ); + } } } if def.kind == DefKind::ModuleInit { for file in index.importer_closure(def.file) { if let Some(m) = graph.module_init(file) { - enqueue(&mut queue, &mut visited, m); + enqueue_hop( + &mut queue, + &mut visited, + &mut predecessors, + d, + HopKind::ImportCloses, + m, + ); } for (id, file_def) in graph.defs_in_file(file) { if file_def.kind == DefKind::TestCase { @@ -113,7 +211,14 @@ pub fn impacted_tests(graph: &Graph, seeds: &[Seed]) -> Vec { // otherwise a test helper collected here would // silently stop the walk short of any external // caller of that helper. - enqueue(&mut queue, &mut visited, id); + enqueue_hop( + &mut queue, + &mut visited, + &mut predecessors, + d, + HopKind::ImportCloses, + id, + ); } } } @@ -123,14 +228,60 @@ pub fn impacted_tests(graph: &Graph, seeds: &[Seed]) -> Vec { let mut result: Vec = tests.into_iter().collect(); result.sort(); result + .into_iter() + .map(|t| { + let path = reconstruct_path(&predecessors, t); + (t, path) + }) + .collect() } -fn enqueue(queue: &mut VecDeque, visited: &mut HashSet, d: DefId) { +/// Enqueue a seed: the root of a BFS tree, so it never gets a `predecessors` +/// entry (that's how `reconstruct_path` knows to stop climbing). +fn enqueue_root(queue: &mut VecDeque, visited: &mut HashSet, d: DefId) { if visited.insert(d) { queue.push_back(d); } } +/// Enqueue `to`, discovered via a reverse edge of kind `kind` from the +/// already-impacted `from`. Records `to`'s first-discovery parent pointer +/// (skipped if `to` was already visited, e.g. a seed or a def reached +/// earlier via a shorter path). +fn enqueue_hop( + queue: &mut VecDeque, + visited: &mut HashSet, + predecessors: &mut HashMap, + from: DefId, + kind: HopKind, + to: DefId, +) { + if visited.insert(to) { + predecessors.insert( + to, + Hop { + from, + edge: kind, + to, + }, + ); + queue.push_back(to); + } +} + +/// Walks `predecessors` backward from `d` (a selected test) to its seed +/// root, collecting each `Hop` crossed, then reverses the result into +/// seed -> test order. +fn reconstruct_path(predecessors: &HashMap, mut d: DefId) -> Vec { + let mut hops = Vec::new(); + while let Some(hop) = predecessors.get(&d) { + hops.push(hop.clone()); + d = hop.from; + } + hops.reverse(); + hops +} + /// The last `.`-separated segment of a def's name (methods are recorded as /// e.g. `Class.method`; a plain function's short name is its whole name). /// Used to widen the walk through `Calls{to: Unknown(name)}` edges, since an @@ -153,6 +304,16 @@ struct ReverseIndex<'g> { /// `Imports{from, to}` -> `importers[to] = [from, ...]` (files that /// import `to`, i.e. `to`'s importers). importers: HashMap>, + /// `Imports{from, to}` -> `imports_fwd[from] = [to, ...]` (files that + /// `from` imports directly). The forward counterpart of `importers`; + /// used to compute the forward transitive import closure for + /// `Unknown`-call widening (see `imports_reach`). + imports_fwd: HashMap>, + /// Lazy, memoized cache of forward transitive import closures, keyed + /// by the file the closure was computed from. Populated on first use + /// by `imports_reach`; `RefCell` because `ReverseIndex` is otherwise + /// borrowed immutably for the whole walk. + forward_closures: RefCell>>, } impl<'g> ReverseIndex<'g> { @@ -162,6 +323,7 @@ impl<'g> ReverseIndex<'g> { let mut containers: HashMap = HashMap::new(); let mut unknown_by_name: HashMap<&str, Vec> = HashMap::new(); let mut importers: HashMap> = HashMap::new(); + let mut imports_fwd: HashMap> = HashMap::new(); for edge in &graph.edges { match edge { @@ -180,7 +342,10 @@ impl<'g> ReverseIndex<'g> { Edge::Contains { parent, child } => { containers.insert(*child, *parent); } - Edge::Imports { from, to } => importers.entry(*to).or_default().push(*from), + Edge::Imports { from, to } => { + importers.entry(*to).or_default().push(*from); + imports_fwd.entry(*from).or_default().push(*to); + } } } @@ -190,7 +355,46 @@ impl<'g> ReverseIndex<'g> { containers, unknown_by_name, importers, + imports_fwd, + forward_closures: RefCell::new(HashMap::new()), + } + } + + /// Whether `to_file` is reachable from `from_file` through the forward + /// transitive import closure of `from_file` (`from_file` imports, + /// directly or indirectly, `to_file`), or they're the same file. Used + /// to scope `Unknown`-call widening to files the caller could + /// plausibly have resolved a name into; see the module doc comment. + /// The closure is computed by BFS over `imports_fwd` on first use per + /// `from_file` and memoized for the rest of the walk. + fn imports_reach(&self, from_file: FileId, to_file: FileId) -> bool { + if from_file == to_file { + return true; + } + if let Some(closure) = self.forward_closures.borrow().get(&from_file) { + return closure.contains(&to_file); } + + let mut visited: HashSet = HashSet::new(); + let mut queue: VecDeque = VecDeque::new(); + visited.insert(from_file); + queue.push_back(from_file); + while let Some(f) = queue.pop_front() { + if let Some(imps) = self.imports_fwd.get(&f) { + for &imp in imps { + if visited.insert(imp) { + queue.push_back(imp); + } + } + } + } + visited.remove(&from_file); + + let reaches = visited.contains(&to_file); + self.forward_closures + .borrow_mut() + .insert(from_file, visited); + reaches } /// The transitive closure of files that (directly or indirectly) import @@ -504,6 +708,101 @@ mod tests { assert_eq!(result, vec![t]); } + #[test] + fn unknown_widening_respects_import_reachability() { + // File A: test T with Calls{Unknown("foo")}. File B: def foo. With + // no import edge from A to B, B is not in A's forward transitive + // import closure and A != B, so T must NOT be selected. Adding an + // Imports{from: A, to: B} edge brings B into A's closure, and T + // must then be selected. + let build = |with_import: bool| { + let mut g = g(); + let fa = file(&mut g, "a.ts"); + let fb = file(&mut g, "b.ts"); + let foo = def(&mut g, "foo", DefKind::Function, fb); + let t = def(&mut g, "t", DefKind::TestCase, fa); + g.add_edge(Edge::Calls { + from: t, + to: CallTarget::Unknown("foo".into()), + }); + if with_import { + g.add_edge(Edge::Imports { from: fa, to: fb }); + } + (g, foo, t) + }; + + let (g_no_import, foo, _t) = build(false); + let result = impacted_tests(&g_no_import, &[seed(foo)]); + assert_eq!(result, Vec::::new()); + + let (g_with_import, foo2, t2) = build(true); + let result = impacted_tests(&g_with_import, &[seed(foo2)]); + assert_eq!(result, vec![t2]); + } + + #[test] + fn unknown_widening_transitive_imports() { + // A imports B imports C. Test T (in A) has Calls{Unknown("bar")}; + // def bar lives in C. A's forward transitive import closure + // reaches C via A -> B -> C, even though A doesn't import C + // directly, so T must be selected. + let mut g = g(); + let fa = file(&mut g, "a.ts"); + let fb = file(&mut g, "b.ts"); + let fc = file(&mut g, "c.ts"); + let bar = def(&mut g, "bar", DefKind::Function, fc); + let t = def(&mut g, "t", DefKind::TestCase, fa); + g.add_edge(Edge::Calls { + from: t, + to: CallTarget::Unknown("bar".into()), + }); + g.add_edge(Edge::Imports { from: fa, to: fb }); + g.add_edge(Edge::Imports { from: fb, to: fc }); + + let result = impacted_tests(&g, &[seed(bar)]); + assert_eq!(result, vec![t]); + } + + /// `impacted_tests_with_paths` records the shortest seed -> test hop + /// chain: `add <- calculate <- t1` (seed `add`) reconstructs as exactly + /// two `Calls` hops, in seed-to-test order. + #[test] + fn path_recording_two_hops_in_order() { + let mut g = g(); + let f = file(&mut g, "a.ts"); + let add = def(&mut g, "add", DefKind::Function, f); + let calculate = def(&mut g, "calculate", DefKind::Function, f); + let t1 = def(&mut g, "t1", DefKind::TestCase, f); + g.add_edge(Edge::Calls { + from: calculate, + to: CallTarget::Resolved(add), + }); + g.add_edge(Edge::Calls { + from: t1, + to: CallTarget::Resolved(calculate), + }); + + let result = impacted_tests_with_paths(&g, &[seed(add)]); + assert_eq!(result.len(), 1); + let (test_id, path) = &result[0]; + assert_eq!(*test_id, t1); + assert_eq!( + path, + &vec![ + Hop { + from: add, + edge: HopKind::Calls, + to: calculate, + }, + Hop { + from: calculate, + edge: HopKind::Calls, + to: t1, + }, + ] + ); + } + #[test] fn deterministic_order_and_no_dupes() { // two seeds reaching same test -> once, sorted diff --git a/crates/lang-go/src/lib.rs b/crates/lang-go/src/lib.rs index 0d29fe1..3359e2f 100644 --- a/crates/lang-go/src/lib.rs +++ b/crates/lang-go/src/lib.rs @@ -100,9 +100,10 @@ impl Language for GoLanguage { } let mut imports = Vec::new(); - collect_imports(root, src_bytes, &mut imports); + let mut import_aliases = Vec::new(); + collect_imports(root, src_bytes, &mut imports, &mut import_aliases); - let known_names = build_known_names(&defs, &imports); + let known_names = build_known_names(&defs, &imports, &import_aliases); let ctx = RefCtx { src: src_bytes, @@ -534,8 +535,21 @@ fn push_def( /// Recursively collect `import_declaration` specs: both the single-spec /// form (`import "fmt"`) and the parenthesized list form (`import (...)`, -/// an `import_spec_list` of `import_spec`). -fn collect_imports(node: Node, src: &[u8], imports: &mut Vec) { +/// an `import_spec_list` of `import_spec`). Also collects each spec's alias +/// (`import foo "example.com/x/bar"`'s `name` field, when it's a genuine +/// package alias rather than `_` (blank import) or `.` (dot import)) into +/// `aliases`, so `build_known_names` can allow-list the alias qualifier +/// actually used at call sites instead of guessing it from the raw path's +/// last segment (which is wrong whenever the import is aliased). +/// `ImportRef` itself stays alias-unaware: it's a shared cross-language type, +/// and every other caller of `raw` needs the resolvable path, not the local +/// binding name. +fn collect_imports( + node: Node, + src: &[u8], + imports: &mut Vec, + aliases: &mut Vec, +) { if node.kind() == "import_spec" { if let Some(path) = node.child_by_field_name("path") { if path.kind() == "interpreted_string_literal" { @@ -546,19 +560,30 @@ fn collect_imports(node: Node, src: &[u8], imports: &mut Vec) { }); } } + if let Some(name) = node.child_by_field_name("name") { + if name.kind() == "package_identifier" { + if let Ok(alias) = name.utf8_text(src) { + aliases.push(alias.to_string()); + } + } + } } let mut cursor = node.walk(); for child in node.children(&mut cursor) { - collect_imports(child, src, imports); + collect_imports(child, src, imports, aliases); } } /// The cheap allow-list used to filter read extraction down to non-local /// noise: same-file top-level function names, plus each import's local -/// package qualifier (the last `/`-separated segment of its raw path; -/// aliases aren't tracked, an acceptable simplification since none of this -/// crate's fixtures use them). -fn build_known_names(defs: &[ExtractedDef], imports: &[ImportRef]) -> HashSet { +/// package qualifier -- the alias, when the import declares one (`import foo +/// "example.com/x/bar"` is referenced at call sites as `foo.Something`, not +/// `bar.Something`), else the last `/`-separated segment of its raw path. +fn build_known_names( + defs: &[ExtractedDef], + imports: &[ImportRef], + import_aliases: &[String], +) -> HashSet { let mut names: HashSet = defs .iter() .filter(|d| d.kind == DefKind::Function) @@ -571,6 +596,7 @@ fn build_known_names(defs: &[ExtractedDef], imports: &[ImportRef]) -> HashSet`. Method bodies + // still win: `walk_refs` re-resolves `scope_of` at every + // node, so a method_definition's own (more specific) entry, + // inserted below, overrides this one for nodes inside it. + scope_of.insert(node.id(), class_idx); if let Some(body) = node.child_by_field_name("body") { let mut cursor = body.walk(); for member in body.children(&mut cursor) { diff --git a/crates/lang-ts/tests/refs.rs b/crates/lang-ts/tests/refs.rs index 9c921c5..65a0f7f 100644 --- a/crates/lang-ts/tests/refs.rs +++ b/crates/lang-ts/tests/refs.rs @@ -39,6 +39,28 @@ fn test_bodies_attribute_calls_to_testcase() { .any(|c| c.name == "add" && c.from_def == neg)); } +#[test] +fn class_field_initializer_calls_attribute_to_class_not_module() { + let src = r#" +import { compute } from "./util"; +export class Foo { + x = compute(); + bar() { return other(); } +} +"#; + let ex = extract(src); + let foo = ex.defs.iter().position(|d| d.name == "Foo").unwrap(); + let bar = ex.defs.iter().position(|d| d.name == "bar").unwrap(); + assert!(ex + .calls + .iter() + .any(|c| c.name == "compute" && c.from_def == foo)); + assert!(ex + .calls + .iter() + .any(|c| c.name == "other" && c.from_def == bar)); +} + #[test] fn method_calls_carry_qualifier() { let src = r#" diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index 6e22335..fdc73b1 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -93,7 +93,8 @@ const jsonLd = {

Go

-
curl -fsSL https://testless.itaywol.tools/install.sh | sh
+
curl -fsSL https://testless.itaywol.tools/install.sh | sh
+brew install itaywol/testless/testless

Rust

diff --git a/site/src/styles/global.css b/site/src/styles/global.css index fe9a9dc..5b478d3 100644 --- a/site/src/styles/global.css +++ b/site/src/styles/global.css @@ -247,7 +247,7 @@ h2 { border: 1px solid var(--border); border-radius: var(--radius-sm); overflow-x: auto; - white-space: nowrap; + white-space: pre; font-size: 0.8rem; -webkit-overflow-scrolling: touch; mask-image: linear-gradient(to right, black calc(100% - 28px), transparent 100%);