mirror of
https://github.com/microsoft/vscode.git
synced 2026-10-28 13:26:52 +08:00
ripple: depend on the content of files that root paths name, and add ripple why (#344148)
ripple: depend on the content of files that root paths name A string such as `'src/main.ts'` in a test's fake GitHub tree names an existing module from the repository root, and ripple linked it like a dynamic import: to the node standing for the whole module, which depends on all of it and on everything it loads. So that test depended on all that the Electron main process starts, the agent host included, and #344129, which only changes agent host code, ran it alone in WebKit on macOS. Of the 670 commits of the last week, 175 selected that test this way, and 31 selected `fuzzyScorer.test.ts`, whose `URI.file(...)` fixtures name workbench modules. No module specifier is a path from the repository root, so code reads the file that such a string names, or the string only looks like a path. It now reads the file, like the folders that tests read by path: the code depends on the file's content, also for a module, but not on what the module loads. Relative and path alias strings, such as entry points, still reach what the module loads. On the same 670 commits, 190 select fewer tests and none select more; on 63 of them, the browser test runners have nothing left to run. The README's replay table had drifted since earlier changes; it now shows the current numbers. `ripple why --test PATH --base REV [--head REV]` prints how a change reaches a test that it selects: the path from the test's code to what the change changed, through declarations, whole modules, packages and globals, and the code whose use of a name opened each instance method on the way. It shares the search of `reaches_from`, so it follows the same rules as selection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
1 parent
b208bc2501
commit
471b308a3d
4 files changed
+336
-63
No files matched your search
+12
-7
@@ -39,13 +39,13 @@ The configuration is strict: unknown fields are errors. Glob patterns use `*` wi
|
||||
- Mutable module state depends on the load-time code that writes or mutates it: assignments and updates, property writes and `delete`, calls of methods that may mutate their receiver (any but known read-only ones such as `map` or `slice`), and values passed to calls. Writes in the functions, constructors and methods that load-time code calls count as writes of that code, and so do those of local functions passed to calls at load time (`[1].map(write)`). `Object` methods that change their argument (`Object.assign`, `Object.defineProperties`, `Object.freeze`, ...) write it. A write to the whole object is a write to each of its members, which code reaches directly (`config.mode`). A write to `X.member` is a write to that member.
|
||||
- Members as separate nodes: enum members, `const` object literal properties, namespace exports and static class members are reached through explicit `X.member` references; instance methods are gated by name (see below). The parent's hash records the order of its properties, enum members, namespace members and static fields, which decides enumeration and initialization order; moving methods changes nothing. Members with the same name, such as a getter and its setter, are one dependency. A spread or computed key can overwrite the object literal properties before it (`{ a: 1, ...overrides }`), so those stay with the object: reading one depends on all of it. A static or object literal member that uses `this` or `super` depends on the sibling that `this.name` names, or else on its class or object.
|
||||
- Load-time effects: a declaration is pinned to its module initializer only if loading it has effects outside the module: calling or constructing an import or unknown global, changing an import, a global or one of their members (assigning, updating or deleting it, or changing it with a method of `Object`), or calling an impure local function, constructor or member, found by a small fixpoint. Constructing a class runs its constructor, instance fields, superclass and the methods they call through `this`, not its other methods. Methods of local values without tracked members, pure globals and functions, and dead `if (CONST_FALSE)` blocks do not count. Changing a pure global does: calling `console.log` has no effect, but assigning it, `Error.stackTraceLimit` or `Array.prototype.last` changes what every module that uses them does. npm packages are not analyzed, so loading one counts as an effect.
|
||||
- Path strings: relative or starting with a path alias (worker entry points, fixture folders), or naming an existing repository file or folder from the root (such as a grammar a test reads, `'extensions/json/syntaxes/JSON.tmLanguage.json'`). Also dynamic imports, `require` and the configured module loaders, and the globals the test runners inject.
|
||||
- Path strings: relative or starting with a path alias (worker entry points, fixture folders), or naming an existing repository file or folder from the root (such as a grammar a test reads, `'extensions/json/syntaxes/JSON.tmLanguage.json'`). A relative or path alias string that names a module depends on the module and what it loads, as an entry point does. No module specifier is a path from the root, so code reads the file that such a path names: it depends on the file's content, also when the file is a module, but not on what the module loads. The `'src/main.ts'` of a test's fake file tree does not reach everything that the main process starts. Also dynamic imports, `require` and the configured module loaders, and the globals the test runners inject.
|
||||
- Test data folders: the folders next to test files that contain no module, such as `__snapshots__` and `fixtures`. Tests read them by a path they compute, so the folder's name appears in a string or template literal of the test, or of a module it loads: a snapshot helper's `'__snapshots__'`, or the `test-cases` of `` `${dir}${sep}test-cases${sep}${name}` ``. Those tests depend on the folder. No configuration names the folders.
|
||||
- Computed paths: a template literal whose static start names a folder reads the data files (not modules) of that folder that have the template's static start and end, such as the keyboard mapper's layouts and golden files through `` `vs/workbench/services/keybinding/test/node/${file}.js` `` and `` `vs/workbench/services/keybinding/test/node/${file}` ``, or the webview's `pre/index.html` through `` `vs/workbench/contrib/webview/browser/pre${uri.path}` ``. A name like `` `./file${i}.ts` `` names no data file and reads nothing. The folder must exist and be at least three levels deep, so `` `src/${name}` `` reads nothing.
|
||||
3. **Link** all modules into one graph (about 160k nodes and 500k edges for this repository), with npm packages and injected globals as nodes of their own. The whole tree analyzes in about 0.5 s and links in about 0.1 s on an 18-core machine.
|
||||
4. **Select.** Changed nodes are the declarations whose hash changed between the two snapshots, plus:
|
||||
- npm packages whose resolved version or install script permission (`allowScripts`) changed, from a field-level diff of `package.json` and `package-lock.json`, with the packages that depend on them according to the lockfile (also when only `package.json` changed), and injected globals whose file changed. A change to a package that a `runEverything` script loads, such as the test runners' web server, runs every unit test (and one that only a `runEveryIntegrationTest` script loads, every integration test);
|
||||
- files under folders that tests read by path (fixtures), under a test data folder (snapshots), or that a computed path can name (golden files);
|
||||
- files that a path from the repository root names (grammars), under folders that tests read by path (fixtures), under a test data folder (snapshots), or that a computed path can name (golden files);
|
||||
- module initializers whose evaluation runs other modules with global load-time effects or npm packages, or the same ones in another order: the modules a module loads are evaluated depth-first in source order, each once, so loading a module directly that a later import loaded before moves its effects earlier.
|
||||
|
||||
A module that does not parse has no dependencies, so it runs every unit test. So does a change that reaches what the test runners (`tests.runners`) use of the modules that they name by a path alias, such as `assertCleanState` of `'vs/workbench/test/common/utils'`, which the Electron renderer calls in every run, or the protocol handlers that the Electron main process serves tests with: tests cannot import these dependencies.
|
||||
@@ -81,11 +81,15 @@ head=$(git stash create)
|
||||
|
||||
# Culprit finding: the commits in a range whose changes can affect a failing test
|
||||
./target/release/ripple blame --config vscode.jsonc --test src/vs/.../foo.test.ts --since <last green> --until <first red>
|
||||
|
||||
# Why a change selects a test: a path from the test's code to what the change changed, through the
|
||||
# declarations, whole modules, packages and globals in between
|
||||
./target/release/ripple why --config vscode.jsonc --test src/vs/.../foo.test.ts --base "$(git merge-base origin/main HEAD)" --head HEAD
|
||||
```
|
||||
|
||||
All commands accept `--repo <path>`. oxc requires Rust 1.97 or newer; `rust-toolchain.toml` pins the version CI uses.
|
||||
|
||||
Selection works per declaration, so what an edit selects depends on what it changes. Comments, formatting and types select nothing. A change to a function body selects the tests that can reach that function: changing `MoveCaretCommand.getEditOperations` selects only `moveCarretCommand.test.ts`, while changing the static `Position.isBefore`, which most of the editor uses, selects 836 test files (file-level analysis would select 1,077, or 1,531 counting type-only imports). A change to a file that every unit test depends on (`runEverything`) lists every test and prints the reason; one that only the integration tests depend on (`runEveryIntegrationTest`) lists every integration test. `affected` lists unit and integration tests; `plan` lists the unit and integration tests that macOS and Windows would run, separately (`unit` and `integration` in plan.json), and each test runner then runs the listed files it supports.
|
||||
Selection works per declaration, so what an edit selects depends on what it changes. Comments, formatting and types select nothing, except in files that code reads by path. A change to a function body selects the tests that can reach that function: changing `MoveCaretCommand.getEditOperations` selects only `moveCarretCommand.test.ts`, while changing the static `Position.isBefore`, which most of the editor uses, selects 836 test files (file-level analysis would select 1,077, or 1,531 counting type-only imports). A change to a file that every unit test depends on (`runEverything`) lists every test and prints the reason; one that only the integration tests depend on (`runEveryIntegrationTest`) lists every integration test. `affected` lists unit and integration tests; `plan` lists the unit and integration tests that macOS and Windows would run, separately (`unit` and `integration` in plan.json), and each test runner then runs the listed files it supports.
|
||||
|
||||
## Validation
|
||||
|
||||
@@ -94,9 +98,9 @@ Selection works per declaration, so what an edit selects depends on what it chan
|
||||
| Selection | Mean | Median | p90 | Selects nothing |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Today: everything | 100% | 100% | 100% | 0% |
|
||||
| File-level imports, including types | 21.3% | 7.8% | 66.5% | 13.6% |
|
||||
| File-level runtime loads | 13.2% | 2.8% | 40.1% | 13.6% |
|
||||
| **ripple, declaration-level** | **9.0%** | **1.9%** | **24.1%** | **14.5%** |
|
||||
| File-level imports, including types | 21.5% | 8.0% | 66.4% | 12.6% |
|
||||
| File-level runtime loads | 13.2% | 2.9% | 40.0% | 12.6% |
|
||||
| **ripple, declaration-level** | **9.1%** | **2.0%** | **24.1%** | **13.4%** |
|
||||
|
||||
16 of the 708 commits needed everything: 12 changed the unit test job definitions, 2 the test runners, 1 the transpiler and 1 `.npmrc`.
|
||||
- **Recall on real CI failures.** 75 pull request runs with failing Linux unit test jobs (Oct 17 to 20): the failing test files were taken from the job logs and checked against what ripple selects for the pull request's own change. Of 36 failures caused by the pull request, ripple selected 34. The two misses were tests that only passed because other test files had leaked registrations into a global registry earlier in the same process; the pull request (a test-only change) stopped the leak. Two further Linux-only misses were timing flakes unrelated to their change. A test failing on 31 unrelated branches at the same time (a broken `main`) was correctly not attributed to those pull requests.
|
||||
@@ -109,6 +113,7 @@ Selection works per declaration, so what an edit selects depends on what it chan
|
||||
- Modules named by a path relative to the output folder without a path alias are not found: `FileAccess.asFileUri('bootstrap-fork')` names `src/bootstrap-fork.ts`, which the code that starts the pty host, the file watcher and the Agent Host forks, and so do two integration tests (`ipc.cp` and `processes`). So [vscode.jsonc](vscode.jsonc) lists `src/bootstrap-*.ts` in `runEveryIntegrationTest`: changing one runs every integration test, but selects no unit test through that path. A `"bootstrap-": "src/bootstrap-"` path alias would select exactly the tests that reach this code, but `src/bootstrap-meta.ts` reads `package.json` with `require`, so every `package.json` change would then select them too: on 303 commits of `main`, 9 commits would select more unit tests, 3 of them `package.json`-only changes that would go from 3 to 16 test files.
|
||||
- Moving a method changes nothing, although `Object.getOwnPropertyNames(C.prototype)` lists methods in source order. Methods are not enumerable, so only code that asks for own property names sees the order, and no test does. Counting the order would select the users of a class whenever a method moves: on 708 commits of `main`, 44 commits would select more tests.
|
||||
- Methods that code outside the analysis calls are only found through `extends` and `implements`. An object that only matches the shape of a package's interface, without declaring it, keeps its methods gated by name.
|
||||
- A computed path stands for every data file it can name, so a string that only looks like a path selects a few extra tests: the diffing fixture test displays file names as `` `./${name}` ``, so editing the diffing `README.md` selects it.
|
||||
- A string that only looks like a path selects a few extra tests. A computed path stands for every data file it can name: the diffing fixture test displays file names as `` `./${name}` ``, so editing the diffing `README.md` selects it. A path from the root reads its file: the fake tree of `githubFileSystemProvider.test.ts` lists `'src/main.ts'`, so every edit of `src/main.ts`, even of a comment, selects that test. A relative or path alias string that names a module stands for what the module loads, as an entry point does, also where it is only an expected value, such as `'vs/platform/agentHost/node/agentHostMain'` in `nodeAgentHostStarter.test.ts`.
|
||||
- A path from the root never reaches what the module it names loads, so code that runs a module through a path computed from the repository root (`fork(join(root, 'src/worker.ts'))`) does not depend on that module's dependencies. The unit tests run the compiled modules in `out`, which is not in the repository, so no path from the root names one.
|
||||
- Selected test files that no longer exist (deleted or renamed tests) are dropped from the plan; their surviving dependents are still found through the base snapshot.
|
||||
- Only unit tests and integration tests (the node.js integration tests and the Agent Host E2E suites) are selected. The extension host, browser, remote and smoke suites run in full.
|
||||
+99
-14
@@ -473,8 +473,8 @@ pub fn changed_nodes(
|
||||
let mut base_seeds = Vec::new();
|
||||
for path in paths {
|
||||
let data = !base.role(path).is_module() && !head.role(path).is_module();
|
||||
head_seeds.extend(hg.dir_dependents(path, data).0);
|
||||
base_seeds.extend(bg.dir_dependents(path, data).0);
|
||||
head_seeds.extend(hg.readers(path, data).0);
|
||||
base_seeds.extend(bg.readers(path, data).0);
|
||||
let bi = base.find(path);
|
||||
let hi = head.find(path);
|
||||
let bf = bi.and_then(|i| base.entries[i as usize].facts.clone());
|
||||
@@ -576,6 +576,10 @@ pub struct Selection {
|
||||
pub run_all_integration: Option<String>,
|
||||
/// npm packages whose resolved version changed.
|
||||
pub packages: usize,
|
||||
/// The nodes that the change changed in the head and in the base graph, which the selected
|
||||
/// tests depend on: declarations (see `changed_nodes`), packages and injected globals.
|
||||
pub head_seeds: Vec<u32>,
|
||||
pub base_seeds: Vec<u32>,
|
||||
}
|
||||
|
||||
fn collect(snap: &Snapshot, marks: &[bool], out: &mut HashSet<Arc<str>>) {
|
||||
@@ -692,27 +696,21 @@ pub fn select(
|
||||
run_all,
|
||||
run_all_integration,
|
||||
packages,
|
||||
head_seeds,
|
||||
base_seeds,
|
||||
};
|
||||
dependent_tests(head, hg, &head_seeds, &mut sel.decl);
|
||||
dependent_tests(base, bg, &base_seeds, &mut sel.decl);
|
||||
dependent_tests(head, hg, &sel.head_seeds, &mut sel.decl);
|
||||
dependent_tests(base, bg, &sel.base_seeds, &mut sel.decl);
|
||||
let is_data = |p: &str| !base.role(p).is_module() && !head.role(p).is_module();
|
||||
let head_files: Vec<u32> = paths
|
||||
.iter()
|
||||
.filter_map(|p| head.find(p))
|
||||
.chain(
|
||||
paths
|
||||
.iter()
|
||||
.flat_map(|p| hg.dir_dependents(p, is_data(p)).1),
|
||||
)
|
||||
.chain(paths.iter().flat_map(|p| hg.readers(p, is_data(p)).1))
|
||||
.collect();
|
||||
let base_files: Vec<u32> = paths
|
||||
.iter()
|
||||
.filter_map(|p| base.find(p))
|
||||
.chain(
|
||||
paths
|
||||
.iter()
|
||||
.flat_map(|p| bg.dir_dependents(p, is_data(p)).1),
|
||||
)
|
||||
.chain(paths.iter().flat_map(|p| bg.readers(p, is_data(p)).1))
|
||||
.collect();
|
||||
for runtime in [false, true] {
|
||||
let out = if runtime {
|
||||
@@ -1595,6 +1593,56 @@ mod tests {
|
||||
assert!(!graph.packages.contains_key("text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_paths_depend_on_the_file_and_module_paths_on_what_it_loads() {
|
||||
let files = [
|
||||
("src/app/dep.ts", "export function start() { return 1; }\n"),
|
||||
(
|
||||
"src/app/main.ts",
|
||||
"import { start } from './dep.js';\nstart();\n",
|
||||
),
|
||||
// A fake tree whose file names happen to be repository paths: code does not run a
|
||||
// module that a path from the repository root names.
|
||||
(
|
||||
"src/app/test/tree.test.ts",
|
||||
"const tree = [{ path: 'src/app/main.ts' }];\ntest('a', () => tree);\n",
|
||||
),
|
||||
// An entry point by path alias, which a process runs.
|
||||
(
|
||||
"src/lib/starter.ts",
|
||||
"export function spawn() { return fork('lib/worker'); }\n",
|
||||
),
|
||||
(
|
||||
"src/lib/worker.ts",
|
||||
"import { start } from '../app/dep.js';\nstart();\n",
|
||||
),
|
||||
(
|
||||
"src/lib/test/starter.test.ts",
|
||||
"import { spawn } from '../starter.js';\ntest('a', () => spawn());\n",
|
||||
),
|
||||
];
|
||||
let changed = |path: &str, content: &'static str| -> Vec<String> {
|
||||
let head: Vec<(&str, &str)> = files
|
||||
.iter()
|
||||
.map(|&(p, c)| (p, if p == path { content } else { c }))
|
||||
.collect();
|
||||
selected(&files, &head)
|
||||
};
|
||||
assert_eq!(
|
||||
(
|
||||
changed("src/app/dep.ts", "export function start() { return 2; }\n"),
|
||||
changed(
|
||||
"src/app/main.ts",
|
||||
"// Starts.\nimport { start } from './dep.js';\nstart();\n"
|
||||
),
|
||||
),
|
||||
(
|
||||
vec!["src/lib/test/starter.test.ts".to_owned()],
|
||||
vec!["src/app/test/tree.test.ts".to_owned()]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reordering_members_with_load_time_effects_selects_their_users() {
|
||||
let test = (
|
||||
@@ -2212,4 +2260,41 @@ mod tests {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_explain_how_a_test_reaches_a_change() {
|
||||
let test = (
|
||||
"src/app/test/a.test.ts",
|
||||
"test('a', async () => { const { Service } = await import('../lib.js'); return new Service().send(); });\n",
|
||||
);
|
||||
let lib = |body| ("src/app/lib.ts", body);
|
||||
let base = [lib("export class Service { send() { return 1; } }\n"), test];
|
||||
let head = [lib("export class Service { send() { return 2; } }\n"), test];
|
||||
let (snap, seeds) = (snapshot(&head), selection(&base, &head).head_seeds);
|
||||
let graph = Graph::build(&snap, for_tests());
|
||||
let mut targets = vec![false; graph.node_count()];
|
||||
for s in seeds {
|
||||
targets[s as usize] = true;
|
||||
}
|
||||
let roots: Vec<u32> = graph
|
||||
.file_nodes(snap.find(test.0).unwrap() as usize)
|
||||
.collect();
|
||||
let path: Vec<(String, Option<String>)> = graph
|
||||
.path_from(&roots, &[], &targets)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|(v, opener)| (graph.label(&snap, v), opener.map(|o| graph.label(&snap, o))))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
path,
|
||||
vec![
|
||||
("src/app/test/a.test.ts :: <init>".to_owned(), None),
|
||||
("src/app/lib.ts (whole module)".to_owned(), None),
|
||||
(
|
||||
"src/app/lib.ts :: Service.prototype.send".to_owned(),
|
||||
Some("src/app/test/a.test.ts :: <init>".to_owned())
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
+152
-41
@@ -74,8 +74,9 @@ impl Snapshot {
|
||||
enum Res {
|
||||
None,
|
||||
File(u32),
|
||||
/// A directory, such as a fixture folder read by a test: index into `Linker::dirs`.
|
||||
Dir(u32),
|
||||
/// A file or directory that code reads by path, such as a grammar or a fixture folder that a
|
||||
/// test reads: index into `Linker::reads`.
|
||||
Read(u32),
|
||||
/// The data files (not modules) of a directory that code reads by a computed path: index into
|
||||
/// `Linker::data_dirs`.
|
||||
DataDir(u32),
|
||||
@@ -218,8 +219,8 @@ struct Linker<'s> {
|
||||
new_surfaces: Vec<(u32, u32)>,
|
||||
packages: HashMap<String, u32>,
|
||||
globals: HashMap<String, u32>,
|
||||
dirs: Vec<Box<str>>,
|
||||
dir_index: HashMap<Box<str>, u32>,
|
||||
reads: Vec<Box<str>>,
|
||||
read_index: HashMap<Box<str>, u32>,
|
||||
/// Computed paths: (directory, static start, static end of the path in the directory).
|
||||
data_dirs: Vec<DataPattern>,
|
||||
data_dir_index: HashMap<DataPattern, u32>,
|
||||
@@ -285,8 +286,8 @@ impl<'s> Linker<'s> {
|
||||
let p = p.trim_end_matches('/');
|
||||
let range = self.snap.under(p);
|
||||
if !range.is_empty() {
|
||||
if let Some(&d) = self.dir_index.get(p) {
|
||||
return Res::Dir(d);
|
||||
if let Some(&d) = self.read_index.get(p) {
|
||||
return Res::Read(d);
|
||||
}
|
||||
// Tests that read a folder read data (fixtures, media, scripts). Strings that name a
|
||||
// folder of source code are almost always path manipulation.
|
||||
@@ -297,19 +298,19 @@ impl<'s> Linker<'s> {
|
||||
if code * 2 > range.len() && range.len() > 30 {
|
||||
return Res::None;
|
||||
}
|
||||
return Res::Dir(self.dir(p));
|
||||
return Res::Read(self.read(p));
|
||||
}
|
||||
Res::None
|
||||
}
|
||||
|
||||
/// The id of a directory read by path.
|
||||
fn dir(&mut self, p: &str) -> u32 {
|
||||
if let Some(&d) = self.dir_index.get(p) {
|
||||
/// The id of a file or directory read by path.
|
||||
fn read(&mut self, p: &str) -> u32 {
|
||||
if let Some(&d) = self.read_index.get(p) {
|
||||
return d;
|
||||
}
|
||||
let d = self.dirs.len() as u32;
|
||||
self.dirs.push(p.into());
|
||||
self.dir_index.insert(p.into(), d);
|
||||
let d = self.reads.len() as u32;
|
||||
self.reads.push(p.into());
|
||||
self.read_index.insert(p.into(), d);
|
||||
d
|
||||
}
|
||||
|
||||
@@ -375,7 +376,14 @@ impl<'s> Linker<'s> {
|
||||
if let Some((start, end)) = &s.data {
|
||||
self.data_dir(&entry.path, &s.specifier, s.root, start, end)
|
||||
} else if s.root {
|
||||
self.resolve_path(&s.specifier)
|
||||
// No module specifier is a path from the repository root, so code reads the
|
||||
// file that such a string names, or the string only looks like a path (the
|
||||
// files of a fake tree). It depends on the file's content, never on what a
|
||||
// module it names loads.
|
||||
match self.resolve_path(&s.specifier) {
|
||||
Res::File(t) => Res::Read(self.read(&snap.entries[t as usize].path)),
|
||||
r => r,
|
||||
}
|
||||
} else {
|
||||
self.resolve_specifier(&entry.path, &s.specifier)
|
||||
}
|
||||
@@ -609,13 +617,26 @@ pub struct Scratch {
|
||||
pending: HashMap<u64, Vec<u32>>,
|
||||
}
|
||||
|
||||
/// How a search reached the nodes it visited (see `Graph::path_from`).
|
||||
#[derive(Default)]
|
||||
struct Trace {
|
||||
/// The node whose edge led to each visited or waiting node.
|
||||
from: HashMap<u32, u32>,
|
||||
/// The node that used each name first, which opened the instance methods of that name.
|
||||
named_by: HashMap<u64, u32>,
|
||||
}
|
||||
|
||||
pub struct Graph {
|
||||
/// First node of each snapshot entry.
|
||||
pub base: Vec<u32>,
|
||||
/// Owning entry of each node, or `u32::MAX` for packages.
|
||||
/// Owning entry of each node, or `u32::MAX` for the nodes standing for packages, injected
|
||||
/// globals and whole modules.
|
||||
pub node_file: Vec<u32>,
|
||||
pub packages: HashMap<String, u32>,
|
||||
/// Number of nodes owned by files; package nodes follow.
|
||||
/// The module that each node standing for a whole module stands for (see `Linker::surface`).
|
||||
surfaces: HashMap<u32, u32>,
|
||||
/// Number of nodes owned by files; the nodes standing for packages, injected globals and whole
|
||||
/// modules follow.
|
||||
file_nodes_end: u32,
|
||||
nodes: Csr,
|
||||
forward: Csr,
|
||||
@@ -627,8 +648,8 @@ pub struct Graph {
|
||||
files_all: Csr,
|
||||
/// File-level reverse runtime loads.
|
||||
files_runtime: Csr,
|
||||
/// Nodes and files that read a directory by path, keyed by directory.
|
||||
dirs: HashMap<Box<str>, (Vec<u32>, Vec<u32>)>,
|
||||
/// Nodes and files that read a file or directory by path, keyed by path.
|
||||
reads: HashMap<Box<str>, (Vec<u32>, Vec<u32>)>,
|
||||
/// Nodes and files that read data files of a directory by a computed path, by directory, with
|
||||
/// the static start and end of the path in the directory.
|
||||
data_dirs: HashMap<Box<str>, Vec<(Box<str>, Box<str>, u32, u32)>>,
|
||||
@@ -665,8 +686,8 @@ impl Graph {
|
||||
new_surfaces: Vec::new(),
|
||||
packages: HashMap::new(),
|
||||
globals: HashMap::new(),
|
||||
dirs: Vec::new(),
|
||||
dir_index: HashMap::new(),
|
||||
reads: Vec::new(),
|
||||
read_index: HashMap::new(),
|
||||
data_dirs: Vec::new(),
|
||||
data_dir_index: HashMap::new(),
|
||||
file_nodes_end: next,
|
||||
@@ -675,7 +696,7 @@ impl Graph {
|
||||
let mut loads: Vec<Box<[u32]>> = vec![Box::new([]); n];
|
||||
let mut load_order: Vec<Box<[Load]>> = vec![Box::new([]); n];
|
||||
let mut load_edges: Vec<(u32, u32)> = Vec::new();
|
||||
let mut dir_refs: Vec<(u32, u32, u32)> = Vec::new();
|
||||
let mut read_refs: Vec<(u32, u32, u32)> = Vec::new();
|
||||
let mut data_dir_refs: Vec<(u32, u32, u32)> = Vec::new();
|
||||
let mut edges: Vec<(u32, u32)> = Vec::new();
|
||||
let mut files_all: Vec<(u32, u32)> = Vec::new();
|
||||
@@ -732,7 +753,7 @@ impl Graph {
|
||||
}
|
||||
Ref::All(spec) => match linker.spec_res(f, *spec) {
|
||||
Res::File(t) => edges.push((u, linker.surface(t))),
|
||||
Res::Dir(d) => dir_refs.push((d, u, f)),
|
||||
Res::Read(d) => read_refs.push((d, u, f)),
|
||||
Res::DataDir(d) => data_dir_refs.push((d, u, f)),
|
||||
Res::Pkg(p) => edges.push((u, p)),
|
||||
_ => {}
|
||||
@@ -783,7 +804,7 @@ impl Graph {
|
||||
let reads = loaders.reach(roots);
|
||||
for (folder, tests) in folders {
|
||||
for f in tests.into_iter().filter(|&f| reads[f as usize]) {
|
||||
dir_refs.push((linker.dir(folder), base[f as usize], f));
|
||||
read_refs.push((linker.read(folder), base[f as usize], f));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -932,9 +953,10 @@ impl Graph {
|
||||
}
|
||||
let packages = std::mem::take(&mut linker.packages);
|
||||
let globals = std::mem::take(&mut linker.globals);
|
||||
let mut dirs: HashMap<Box<str>, (Vec<u32>, Vec<u32>)> = HashMap::new();
|
||||
for (d, u, f) in dir_refs {
|
||||
let entry = dirs.entry(linker.dirs[d as usize].clone()).or_default();
|
||||
let surfaces = linker.surfaces.iter().map(|(&f, &s)| (s, f)).collect();
|
||||
let mut reads: HashMap<Box<str>, (Vec<u32>, Vec<u32>)> = HashMap::new();
|
||||
for (d, u, f) in read_refs {
|
||||
let entry = reads.entry(linker.reads[d as usize].clone()).or_default();
|
||||
entry.0.push(u);
|
||||
entry.1.push(f);
|
||||
}
|
||||
@@ -944,7 +966,7 @@ impl Graph {
|
||||
data_dirs.entry(dir).or_default().push((start, end, u, f));
|
||||
}
|
||||
if std::env::var_os("RIPPLE_DEBUG").is_some() {
|
||||
let mut list: Vec<(&Box<str>, &(Vec<u32>, Vec<u32>))> = dirs.iter().collect();
|
||||
let mut list: Vec<(&Box<str>, &(Vec<u32>, Vec<u32>))> = reads.iter().collect();
|
||||
list.sort_by_key(|(d, _)| (d.matches('/').count(), d.len()));
|
||||
for (d, (_, files)) in list.iter().take(40) {
|
||||
let names: Vec<&str> = files
|
||||
@@ -953,7 +975,7 @@ impl Graph {
|
||||
.map(|&f| &*snap.entries[f as usize].path)
|
||||
.collect();
|
||||
eprintln!(
|
||||
"dir ref: {d} <- {} refs, e.g. {}",
|
||||
"read by path: {d} <- {} refs, e.g. {}",
|
||||
files.len(),
|
||||
names.join(", ")
|
||||
);
|
||||
@@ -961,7 +983,7 @@ impl Graph {
|
||||
}
|
||||
let swapped: Vec<(u32, u32)> = edges.iter().copied().collect();
|
||||
Self {
|
||||
dirs,
|
||||
reads,
|
||||
data_dirs,
|
||||
globals,
|
||||
loads,
|
||||
@@ -983,6 +1005,7 @@ impl Graph {
|
||||
base,
|
||||
node_file,
|
||||
packages,
|
||||
surfaces,
|
||||
unresolved,
|
||||
}
|
||||
}
|
||||
@@ -1074,6 +1097,47 @@ impl Graph {
|
||||
targets: &[bool],
|
||||
scratch: &mut Scratch,
|
||||
) -> bool {
|
||||
self.search::<false>(roots, names, targets, scratch, &mut Trace::default())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// How code that starts at `roots` reaches a target node (see `reaches_from`), if it does: a
|
||||
/// path from a root to the target, each node with, for an instance method, the node whose use
|
||||
/// of the method's name opened it (none for names that `names` or `IMPLICIT_METHODS` give). The
|
||||
/// search is breadth first, so the path is short.
|
||||
pub fn path_from(
|
||||
&self,
|
||||
roots: &[u32],
|
||||
names: &[u64],
|
||||
targets: &[bool],
|
||||
) -> Option<Vec<(u32, Option<u32>)>> {
|
||||
let mut trace = Trace::default();
|
||||
let target =
|
||||
self.search::<true>(roots, names, targets, &mut Scratch::default(), &mut trace)?;
|
||||
let mut path = Vec::new();
|
||||
let mut v = Some(target);
|
||||
while let Some(n) = v {
|
||||
let gate = self.gate[n as usize];
|
||||
let opener = (gate != 0)
|
||||
.then(|| trace.named_by.get(&gate).copied())
|
||||
.flatten();
|
||||
path.push((n, opener));
|
||||
v = trace.from.get(&n).copied();
|
||||
}
|
||||
path.reverse();
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// The search of `reaches_from`, which returns the target node it reaches. It searches depth
|
||||
/// first, or with `TRACE`, breadth first while recording into `trace` how it reaches nodes.
|
||||
fn search<const TRACE: bool>(
|
||||
&self,
|
||||
roots: &[u32],
|
||||
names: &[u64],
|
||||
targets: &[bool],
|
||||
scratch: &mut Scratch,
|
||||
trace: &mut Trace,
|
||||
) -> Option<u32> {
|
||||
scratch.stamp += 1;
|
||||
if scratch.visited.len() < self.node_file.len() {
|
||||
scratch.visited.resize(self.node_file.len(), 0);
|
||||
@@ -1083,21 +1147,31 @@ impl Graph {
|
||||
scratch.used.extend(self.implicit.iter().copied());
|
||||
scratch.used.extend(names.iter().copied());
|
||||
scratch.pending.clear();
|
||||
let mut stack: Vec<u32> = roots.to_vec();
|
||||
for &n in &stack {
|
||||
// A stack, or a queue from `next` on when searching breadth first.
|
||||
let mut queue: Vec<u32> = roots.to_vec();
|
||||
let mut next = 0;
|
||||
for &n in &queue {
|
||||
scratch.visited[n as usize] = stamp;
|
||||
}
|
||||
while let Some(n) = stack.pop() {
|
||||
while let Some(n) = if TRACE {
|
||||
next += 1;
|
||||
queue.get(next - 1).copied()
|
||||
} else {
|
||||
queue.pop()
|
||||
} {
|
||||
if targets[n as usize] {
|
||||
return true;
|
||||
return Some(n);
|
||||
}
|
||||
for &name in self.uses.of(n) {
|
||||
if scratch.used.insert(name) {
|
||||
if TRACE {
|
||||
trace.named_by.insert(name, n);
|
||||
}
|
||||
if let Some(waiting) = scratch.pending.remove(&name) {
|
||||
for v in waiting {
|
||||
if scratch.visited[v as usize] != stamp {
|
||||
scratch.visited[v as usize] = stamp;
|
||||
stack.push(v);
|
||||
queue.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1107,25 +1181,62 @@ impl Graph {
|
||||
if scratch.visited[v as usize] == stamp {
|
||||
continue;
|
||||
}
|
||||
if TRACE {
|
||||
trace.from.entry(v).or_insert(n);
|
||||
}
|
||||
let g = self.gate[v as usize];
|
||||
if g != 0 && !scratch.used.contains(&g) {
|
||||
scratch.pending.entry(g).or_default().push(v);
|
||||
continue;
|
||||
}
|
||||
scratch.visited[v as usize] = stamp;
|
||||
stack.push(v);
|
||||
queue.push(v);
|
||||
}
|
||||
}
|
||||
false
|
||||
None
|
||||
}
|
||||
|
||||
/// Nodes and files that read a directory containing `path`, including, for a data file (not
|
||||
/// a module), the directories that computed paths read.
|
||||
pub fn dir_dependents(&self, path: &str, data: bool) -> (Vec<u32>, Vec<u32>) {
|
||||
let (mut nodes, mut files) = (Vec::new(), Vec::new());
|
||||
/// A readable name of a node: the path and key of a declaration, the path of a file that is
|
||||
/// not analyzed or of a module that code references as a whole, a package or a global.
|
||||
pub fn label(&self, snap: &Snapshot, v: u32) -> String {
|
||||
let named = |nodes: &HashMap<String, u32>| {
|
||||
nodes
|
||||
.iter()
|
||||
.find(|&(_, &n)| n == v)
|
||||
.map(|(name, _)| name.clone())
|
||||
};
|
||||
let f = self.node_file[v as usize];
|
||||
if let Some(entry) = snap.entries.get(f as usize) {
|
||||
return match &entry.facts {
|
||||
Some(facts) => format!(
|
||||
"{} :: {}",
|
||||
entry.path,
|
||||
facts.decls[(v - self.base[f as usize]) as usize].key
|
||||
),
|
||||
None => entry.path.to_string(),
|
||||
};
|
||||
}
|
||||
if let Some(&m) = self.surfaces.get(&v) {
|
||||
format!("{} (whole module)", snap.entries[m as usize].path)
|
||||
} else if let Some(name) = named(&self.packages) {
|
||||
format!("package {name}")
|
||||
} else if let Some(name) = named(&self.globals) {
|
||||
format!("global {name}")
|
||||
} else {
|
||||
format!("node {v}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Nodes and files that read `path` or a directory containing it, including, for a data file
|
||||
/// (not a module), the directories that computed paths read.
|
||||
pub fn readers(&self, path: &str, data: bool) -> (Vec<u32>, Vec<u32>) {
|
||||
let (mut nodes, mut files) = match self.reads.get(path) {
|
||||
Some((n, f)) => (n.clone(), f.clone()),
|
||||
None => (Vec::new(), Vec::new()),
|
||||
};
|
||||
for (i, _) in path.match_indices('/') {
|
||||
let dir = &path[..i];
|
||||
if let Some((n, f)) = self.dirs.get(dir) {
|
||||
if let Some((n, f)) = self.reads.get(dir) {
|
||||
nodes.extend_from_slice(n);
|
||||
files.extend_from_slice(f);
|
||||
}
|
||||
|
||||
@@ -31,8 +31,14 @@ const USAGE: &str = "usage: ripple <command> --config FILE [--repo PATH] [option
|
||||
\tanalyze --file PATH: analysis summary of one module\n\
|
||||
\tplan --base REV [--head REV] [--out-dir DIR]: the unit test files to run for a change\n\
|
||||
\tblame --test PATH --since REV [--until REV]: the commits in a range that can affect a test\n\
|
||||
\twhy --test PATH --base REV [--head REV]: how a change reaches a test that it selects\n\
|
||||
\treplay --since DATE [--until REV] [--out FILE] [--hubs 1]: selection statistics over first-parent history";
|
||||
|
||||
/// The commands of `USAGE`.
|
||||
const COMMANDS: &[&str] = &[
|
||||
"stats", "affected", "analyze", "plan", "blame", "why", "replay",
|
||||
];
|
||||
|
||||
/// The options of all commands. Each one takes a value.
|
||||
const OPTIONS: &[&str] = &[
|
||||
"config", "repo", "rev", "base", "head", "file", "out-dir", "test", "since", "until", "out",
|
||||
@@ -87,7 +93,7 @@ fn weight<S: AsRef<str>>(snap: &Snapshot, files: impl Iterator<Item = S>) -> f64
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse(std::env::args().skip(1))?;
|
||||
let command = args.command.as_str();
|
||||
if !["stats", "affected", "analyze", "plan", "blame", "replay"].contains(&command) {
|
||||
if !COMMANDS.contains(&command) {
|
||||
bail!("{USAGE}");
|
||||
}
|
||||
let config = args
|
||||
@@ -130,6 +136,13 @@ fn main() -> Result<()> {
|
||||
args.get("since").context("--since is required")?,
|
||||
args.get("until").unwrap_or("HEAD"),
|
||||
),
|
||||
"why" => why(
|
||||
&config,
|
||||
repo,
|
||||
args.get("test").context("--test is required")?,
|
||||
args.get("base").context("--base is required")?,
|
||||
args.get("head").unwrap_or("HEAD"),
|
||||
),
|
||||
"replay" => replay(
|
||||
&config,
|
||||
repo,
|
||||
@@ -551,6 +564,65 @@ fn blame(config: &Config, repo: PathBuf, test: &str, since: &str, until: &str) -
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Explains why a change selects a test: a path from the test's code to what the change changed,
|
||||
/// through the declarations, whole modules, packages and globals in between.
|
||||
fn why(config: &Config, repo: PathBuf, test: &str, base: &str, head: &str) -> Result<()> {
|
||||
let mut engine = Engine::new(repo.clone(), config);
|
||||
let mut blobs = blob_texts(&repo)?;
|
||||
let bs = engine.snapshot(base)?;
|
||||
let hs = engine.snapshot(head)?;
|
||||
let (bg, hg) = (Graph::build(&bs, config), Graph::build(&hs, config));
|
||||
let changes = git::diff_tree(&repo, base, head)?;
|
||||
let sel = select(config, &mut blobs, &bs, &bg, &hs, &hg, &changes)?;
|
||||
if let Some(reason) = &sel.run_all {
|
||||
println!("every unit test runs: {reason}");
|
||||
return Ok(());
|
||||
}
|
||||
if !sel.decl.iter().any(|p| &**p == test) {
|
||||
println!("the change does not select {test}");
|
||||
return Ok(());
|
||||
}
|
||||
// Only the base graph has the code that the change removed.
|
||||
for (snap, graph, seeds) in [(&hs, &hg, &sel.head_seeds), (&bs, &bg, &sel.base_seeds)] {
|
||||
let Some(f) = snap.find(test) else {
|
||||
continue;
|
||||
};
|
||||
let mut targets = vec![false; graph.node_count()];
|
||||
for &s in seeds {
|
||||
targets[s as usize] = true;
|
||||
}
|
||||
let roots: Vec<u32> = graph.file_nodes(f as usize).collect();
|
||||
let Some(path) = graph.path_from(&roots, &[], &targets) else {
|
||||
continue;
|
||||
};
|
||||
let seed = path[path.len() - 1].0;
|
||||
let data = |p: &str| !bs.role(p).is_module() && !hs.role(p).is_module();
|
||||
let read: Vec<&str> = changes
|
||||
.iter()
|
||||
.map(|c| c.path.as_str())
|
||||
.filter(|p| graph.readers(p, data(p)).0.contains(&seed))
|
||||
.collect();
|
||||
for (i, &(v, opener)) in path.iter().enumerate() {
|
||||
let mut line = format!(
|
||||
"{}{}",
|
||||
if i == 0 { "" } else { " -> " },
|
||||
graph.label(snap, v)
|
||||
);
|
||||
if let Some(o) = opener {
|
||||
line += &format!(" (method opened by its name in {})", graph.label(snap, o));
|
||||
}
|
||||
if v == seed && read.is_empty() {
|
||||
line += " (changed)";
|
||||
} else if v == seed {
|
||||
line += &format!(" (reads {} by path)", read.join(", "));
|
||||
}
|
||||
println!("{line}");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
bail!("found no path from {test} to the change")
|
||||
}
|
||||
|
||||
/// Replays first-parent history and compares the share of unit test work that file-level and
|
||||
/// declaration-level selection pick for each commit.
|
||||
fn replay(
|
||||
|
||||
Reference in new issue
Block a user