diff --git a/CHANGELOG.md b/CHANGELOG.md index 899e876..b40fc57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Notable changes to JevGate. Versions follow [Semantic Versioning](https://semver ## [Unreleased] +- File organization: in Rust, a function another file passes by path, such as `compose::unconfirmed_units` in `follow_ups(plan, files, compose::unconfirmed_units)`, counts as used by that file, so a file's outline names the files that use each member (`used_by`). JevGate's own `compose.rs`, before it was split, listed `follow_ups.rs` as the user of 3 of its 8 follow-up selectors, and the file stayed clear; with all 8 listed, its outline is a consider (0.80). Only outlines whose members are passed by path change: on the 26 Rust projects of the corpus, 37 outline requests were asked again (under $0.01), one wrong review (a SpacetimeDB conversion module, one job laid out in sections) is a consider, one right consider (zoxide's `util.rs`, a grab bag of helpers) is a review, and two units are undecided; nothing else changed. Other languages are unchanged. + ## [0.24.0] - 2026-09-27 - File organization: a long application file (400 lines or more) whose outline, recheck and kind of file raised no finding is asked about its candidate parts, one request per part with the part's source: whether it does a job of its own that a reader would look for apart from the rest, and what it is within the file. A part of 100 lines or more whose answer reaches 0.65 and whose role leans to a job of its own is a consider naming its members. The kind of file cleared long single-type files wholesale: of 50 such files labeled from the code, 15 were worth splitting (a URL scraper inside lobsters' `Story`, a diff engine inside a renderer, a JSON parser inside a protocol module), and asking each part found 4 of them, at the part the labeler named, and no file to keep. The parts are the outline's groups without the links that merged every method of a large class into one group and without links through a helper most members call, with links for neighbours and for names sharing a distinctive word. On the corpus, 11 considers were added and nothing else changed: 9 right and 2 wrong (a demo page's placeholder table, a class's public API), 5 of 7 right outside the files used for tuning and 1 of 1 on the held-out projects; on 9 projects never used before (click, rich, zod, hono, viper, ripgrep, sinatra, jsoup, guzzle), 2 of 2 (Guzzle's `WWW-Authenticate` parser inside `DigestAuth`, ripgrep's `--hyperlink-format` language). File-organization considers were 63% right before. Benchmarks, examples and `scripts` and `docs` directories are not asked (5 of 5 such findings were wrong), nor is a part holding `main`. About $0.08 on the corpus. JevGate's own large files stay clear: their parts read as one job, and what decided them by hand, which other files use a part, is missing for Rust functions passed by name. diff --git a/docs/classification-cascade.md b/docs/classification-cascade.md index b646555..abd310e 100644 --- a/docs/classification-cascade.md +++ b/docs/classification-cascade.md @@ -75,7 +75,10 @@ signatures, or one candidate pair. module-level route handlers named by their registration (`app.post('/pages')`); member groups by average linkage; callers that import the file (application code only, since tests calling a group do not - make it a dependency); for file organization, each member's line count and + make it a dependency), counting a Rust function a file passes by path as + called (`follow_ups(plan, files, compose::unconfirmed_units)`): JevGate's + own `compose.rs` listed 3 of the 8 follow-up selectors only + `follow_ups.rs` uses; for file organization, each member's line count and the file's, and for a test file, its cases with their enclosing `describe`, class or module and the functions under test they call, grouped by shared suite, subject or helper. A subject that one type in scope owns diff --git a/site/src/how-it-works.md b/site/src/how-it-works.md index 02dea1e..5d85194 100644 --- a/site/src/how-it-works.md +++ b/site/src/how-it-works.md @@ -88,7 +88,10 @@ signatures, or one candidate pair. module-level route handlers named by their registration (`app.post('/pages')`); member groups by average linkage; callers that import the file (application code only, since tests calling a group do not - make it a dependency); for file organization, each member's line count and + make it a dependency), counting a Rust function a file passes by path as + called (`follow_ups(plan, files, compose::unconfirmed_units)`): JevGate's + own `compose.rs` listed 3 of the 8 follow-up selectors only + `follow_ups.rs` uses; for file organization, each member's line count and the file's, and for a test file, its cases with their enclosing `describe`, class or module and the functions under test they call, grouped by shared suite, subject or helper. A subject that one type in scope owns diff --git a/src/analysis/units/facts.rs b/src/analysis/units/facts.rs index 5a16a79..2539dcc 100644 --- a/src/analysis/units/facts.rs +++ b/src/analysis/units/facts.rs @@ -6,6 +6,8 @@ use tree_sitter::Node; #[derive(Default)] pub(super) struct Facts { pub(super) calls: BTreeSet, + /// Functions named by a path where it is not called (`parse`). + pub(super) paths: BTreeSet, pub(super) refs: BTreeSet, pub(super) idents: BTreeSet, } @@ -21,6 +23,9 @@ impl Facts { self.calls.insert(name); } } + "scoped_identifier" => { + self.paths.extend(passed_path(node, source)); + } // Java: `repository.findById(id)`. "method_invocation" => { if let Some(name) = node.child_by_field_name("name") { @@ -86,6 +91,37 @@ impl Facts { } } +/// The function a Rust path names where the path is a value, not the callee +/// of a call: `unconfirmed_units` in +/// `follow_ups(plan, files, compose::unconfirmed_units)` or `helper` in +/// `.map(Self::helper)`. The name starts in lower case, since Rust names +/// types, variants and constants in upper case (`Outcome::Clear`); a path +/// inside a longer path or a `use` is not one. +fn passed_path(node: Node<'_>, source: &str) -> Option { + let parent = node.parent()?; + let callee = |n: Node<'_>| { + n.parent().is_some_and(|p| { + p.kind() == "call_expression" && p.child_by_field_name("function") == Some(n) + }) + }; + if callee(node) + || parent.kind() == "generic_function" && callee(parent) + || matches!( + parent.kind(), + "scoped_identifier" + | "use_declaration" + | "use_list" + | "scoped_use_list" + | "use_as_clause" + ) + { + return None; + } + let name = text(node.child_by_field_name("name")?, source); + name.starts_with(|c: char| c.is_ascii_lowercase() || c == '_') + .then(|| name.to_string()) +} + /// A C# identifier that names a type or a member rather than a local: a /// member access (`_repository.ListAsync`), a type argument, base type, /// declared type or pattern type. diff --git a/src/analysis/units/mod.rs b/src/analysis/units/mod.rs index 6833462..40e12aa 100644 --- a/src/analysis/units/mod.rs +++ b/src/analysis/units/mod.rs @@ -69,6 +69,10 @@ pub struct Unit { /// Errors the body creates with their message arguments, for error-detail questions. pub errors: Vec, pub calls: BTreeSet, + /// Functions it passes by path without calling them, in Rust: a callback + /// named as `compose::unconfirmed_units` or `Self::helper`. A file's + /// callers count them as calls; nothing else reads them. + pub passed: BTreeSet, /// A Java `equals(Object)` or `hashCode()` override: boilerplate whose /// field-by-field copies and hash multipliers are the idiom, so it offers /// no copies or literal values to judge. @@ -195,6 +199,11 @@ pub fn parse(path: &Path, source: &str) -> Result { file.template_code = super::template_code::template_code(path, source); } calls_by_name(&mut file.units); + if path.extension().is_none_or(|e| e != "rs") { + for unit in &mut file.units { + unit.passed.clear(); + } + } Ok(file) } @@ -818,6 +827,7 @@ fn push( sites: body.map_or_else(Vec::new, |b| super::sites::in_node(b, source, file.django)), errors: body.map_or_else(Vec::new, |b| super::errors::created_errors(b, source)), calls: facts.calls, + passed: facts.paths, equality, routes: super::routes::spring(node, source), refs, diff --git a/src/analysis/units/tests/rust.rs b/src/analysis/units/tests/rust.rs index c400fd6..4e9df66 100644 --- a/src/analysis/units/tests/rust.rs +++ b/src/analysis/units/tests/rust.rs @@ -52,3 +52,19 @@ fn empty_bodies_are_too_small() { assert!(!store_unit("Store::open").too_small()); assert!(store_unit("Store::touch").too_small()); } + +#[test] +fn a_rust_function_passed_by_path_is_recorded_apart_from_its_calls() { + let source = "use super::compose;\n\nfn plan(files: &[File]) -> Vec {\n let due = follow_ups(files, compose::unconfirmed_units);\n let named = files.iter().map(Self::name_of);\n let kinds = kinds::(compose::unkinded_units);\n let status = Status::Error;\n compose::untraced_units(files);\n due\n}\n"; + let file = parse(Path::new("follow_ups.rs"), source).unwrap(); + let plan = &file.units[0]; + let passed: Vec<&str> = plan.passed.iter().map(String::as_str).collect(); + assert_eq!(passed, ["name_of", "unconfirmed_units", "unkinded_units"]); + assert!(plan.calls.contains("untraced_units") && !plan.calls.contains("unconfirmed_units")); + let python = parse( + Path::new("follow_ups.py"), + "def plan(files):\n return follow_ups(files, compose.unconfirmed_units)\n", + ) + .unwrap(); + assert!(python.units[0].passed.is_empty(), "Rust only"); +} diff --git a/src/catalog.rs b/src/catalog.rs index 7200554..0207197 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -287,7 +287,7 @@ pub fn rules() -> Vec { pub fn rule_version(key: &str) -> &'static str { match key { - FILE_ORGANIZATION => "22", + FILE_ORGANIZATION => "23", FUNCTION_SIMPLIFICATION => "16", SHARED_LOGIC => "22", TEST_VALUE => "7", diff --git a/src/units/plan/file.rs b/src/units/plan/file.rs index 99d4614..3c03745 100644 --- a/src/units/plan/file.rs +++ b/src/units/plan/file.rs @@ -417,7 +417,8 @@ fn callers(scope: &Scope<'_>, links: &Links, target: usize) -> BTreeMap i32 {\n apply(values, selectors::warm0)\n}\n\nfn apply(values: &[i32], f: fn(&[i32]) -> i32) -> i32 {\n f(values)\n}\n", + ); + let mut options = args(); + only(&mut options, catalog::FILE_ORGANIZATION); + let (_, plan) = planned(&project, &options); + let outline = plan + .requests + .iter() + .map(|p| &p.request) + .find(|r| r["state"]["file"]["path"] == "src/selectors.rs") + .unwrap(); + let used_by = |name: &str| { + outline["state"]["members"] + .as_array() + .unwrap() + .iter() + .find(|m| m["name"] == name) + .unwrap()["used_by"] + .clone() + }; + assert_eq!(used_by("warm0"), json!(["src/runner.rs"])); + assert!(used_by("warm1").is_null()); +} + #[test] fn outlines_carry_member_and_file_sizes() { let (project, options) = rule_project(&two_concerns(), catalog::FILE_ORGANIZATION);