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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion docs/classification-cascade.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion site/src/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions src/analysis/units/facts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ use tree_sitter::Node;
#[derive(Default)]
pub(super) struct Facts {
pub(super) calls: BTreeSet<String>,
/// Functions named by a path where it is not called (`parse`).
pub(super) paths: BTreeSet<String>,
pub(super) refs: BTreeSet<String>,
pub(super) idents: BTreeSet<String>,
}
Expand All @@ -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") {
Expand Down Expand Up @@ -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<String> {
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.
Expand Down
10 changes: 10 additions & 0 deletions src/analysis/units/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@
/// Errors the body creates with their message arguments, for error-detail questions.
pub errors: Vec<super::errors::CreatedError>,
pub calls: BTreeSet<String>,
/// 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<String>,
/// 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.
Expand Down Expand Up @@ -195,6 +199,11 @@
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)
}

Expand Down Expand Up @@ -287,329 +296,329 @@
.is_some_and(|name| name.starts_with("next.config."))
}

fn walk(node: Node<'_>, source: &str, owner: &str, file: &mut FileUnits) {
// What a parser could not read holds no definitions to judge.
if node.is_error() {
return;
}
match node.kind() {
// Bend 2: `import ./main.bend as Sort` names its module `Sort`.
"import_declaration" if file.bend.is_some() => {
if let Some(alias) = node.child_by_field_name("alias") {
file.imports.insert(text(alias, source).to_string());
}
}
"type_declaration" if file.bend.is_some() => {
let name = name_of(node, source);
push(Definition::whole(node), &name, "", Kind::Type, source, file);
}
"law_declaration" => {
let name = name_of(node, source);
push(Definition::whole(node), &name, "", Kind::Law, source, file);
}
"use_declaration" | "import_statement" | "import_from_statement" => {
imports(node, source, &mut file.imports);
}
// Go's import block, or one Java `import a.b.Name;`.
"import_declaration" => {
go_imports(node, source, &mut file.imports);
java_import(node, source, &mut file.imports);
}
"using_directive" => csharp_import(node, source, &mut file.imports),
"namespace_use_declaration" => super::php::imports(node, source, &mut file.imports),
// PHP: `namespace App { … }` holds its declarations in a block.
"namespace_definition" => {
if let Some(body) = node.child_by_field_name("body") {
children(body, source, owner, file);
}
}
// PHP: `return function (App $app) { … };` configures its includer.
"return_statement" if super::php::returned_closure(node).is_some() => {
if let Some(closure) = super::php::returned_closure(node) {
let definition = Definition {
outer: node,
node: closure,
body: closure.child_by_field_name("body"),
};
let name = super::php::RETURNED_CLOSURE;
push(definition, name, owner, Kind::Function, source, file);
}
}
// Go: `func (s *Store) Find(…)` is a method of `Store`; a C# or Java
// method belongs to the class, struct, record, interface or enum
// around it.
"method_declaration" => {
let receiver = node
.child_by_field_name("receiver")
.and_then(|r| r.named_child(0))
.and_then(|p| p.child_by_field_name("type"))
.map(|t| base_type(text(t, source).trim_start_matches('*')));
function(
node,
node,
source,
receiver.as_deref().unwrap_or(owner),
file,
);
}
"constructor_declaration"
| "destructor_declaration"
| "compact_constructor_declaration" => {
function(node, node, source, owner, file);
}
// A C# property or indexer whose accessors have statement bodies.
"property_declaration" | "indexer_declaration" => {
let accessors = node.child_by_field_name("accessors").filter(|list| {
let mut cursor = list.walk();
list.named_children(&mut cursor).any(|accessor| {
accessor
.child_by_field_name("body")
.is_some_and(|b| b.kind() == "block")
})
});
if let Some(accessors) = accessors {
let name = match node.kind() {
"indexer_declaration" => "this".to_string(),
_ => name_of(node, source),
};
let definition = Definition {
outer: node,
node,
body: Some(accessors),
};
push(definition, &name, owner, Kind::Method, source, file);
}
}
"namespace_declaration" => {
if let Some(body) = node.child_by_field_name("body") {
children(body, source, owner, file);
}
}
// C# top-level statements: minimal API route handlers and middleware
// written inline, and local functions.
"global_statement" => {
let Some(statement) = node.named_child(0) else {
return;
};
if statement.kind() == "local_function_statement" {
function(node, statement, source, owner, file);
return;
}
let callbacks = csharp_callbacks(statement, source);
let single = callbacks.len() == 1;
for (name, lambda) in callbacks {
let definition = Definition {
outer: if single { node } else { lambda },
node: lambda,
body: lambda.child_by_field_name("body"),
};
push(definition, &name, owner, Kind::Function, source, file);
}
}
"type_declaration" => {
let mut cursor = node.walk();
let specs: Vec<Node<'_>> = node
.named_children(&mut cursor)
.filter(|c| matches!(c.kind(), "type_spec" | "type_alias"))
.collect();
let single = specs.len() == 1;
for spec in specs {
let definition = Definition {
outer: if single { node } else { spec },
node: spec,
body: None,
};
push(
definition,
&name_of(spec, source),
"",
Kind::Type,
source,
file,
);
}
}
// Ruby: `module Billing` and `class Invoice < Base` own their methods.
"module" | "class"
if node
.child_by_field_name("name")
.is_some_and(|n| matches!(n.kind(), "constant" | "scope_resolution")) =>
{
owning_type(node, &base_type(&name_of(node, source)), source, file);
}
// `class << self` holds its owner's singleton methods.
"singleton_class" => {
if let Some(body) = node.child_by_field_name("body") {
children(body, source, owner, file);
}
}
"method" | "singleton_method" => function(node, node, source, owner, file),
"call" => ruby_call(node, source, owner, file),
"assignment" => ruby_assignment(node, source, owner, file),
// Definitions made under a condition, such as `unless method_defined?(:x)`.
"if" | "unless" | "then" | "else" | "begin" => children(node, source, owner, file),
"source_file"
| "program"
| "module"
| "declaration_list"
| "class_body"
| "export_statement"
| "statement_block"
| "compilation_unit"
| "interface_body"
| "enum_body"
| "enum_body_declarations" => children(node, source, owner, file),
// A Java enum constant with its own body, such as a state machine's
// `Data { void read(…) { … } }`: its methods belong to the constant.
"enum_constant" => {
if let Some(body) = node.child_by_field_name("body") {
children(body, source, &name_of(node, source), file);
}
}
// `export default { async fetch(request, env) { … } }`, as Cloudflare Workers write it.
"object"
if node
.parent()
.is_some_and(|p| p.kind() == "export_statement") =>
{
children(node, source, owner, file)
}
"expression_statement" => {
if let Some((object, name, function)) = assigned_function(node, source) {
let definition = Definition {
outer: node,
node: function,
body: function.child_by_field_name("body"),
};
push(definition, name, object, Kind::Method, source, file);
return;
}
let callbacks = if node.named_child(0).is_some_and(super::php::registers) {
super::php::registered_callbacks(node, source)
} else {
registered_callbacks(node, source)
};
let single = callbacks.len() == 1;
for (name, function) in callbacks {
let definition = Definition {
outer: if single { node } else { function },
node: function,
body: function.child_by_field_name("body"),
};
push(definition, &name, owner, Kind::Function, source, file);
}
}
"block"
if node
.parent()
.is_some_and(|p| p.kind() == "class_definition") =>
{
children(node, source, owner, file)
}
"impl_item" => {
let name = node
.child_by_field_name("type")
.map(|n| base_type(text(n, source)))
.unwrap_or_default();
if let Some(body) = node.child_by_field_name("body") {
children(body, source, &name, file);
}
}
"mod_item" => {
if let Some(body) = node.child_by_field_name("body") {
children(body, source, owner, file);
}
}
// A C# or PHP interface or enum is one type: its members have no
// bodies to judge. A Java interface's default methods and a Java
// enum's methods have them, so those are read like classes below.
"interface_declaration" | "enum_declaration"
if node.child_by_field_name("body").is_some_and(|b| {
matches!(
b.kind(),
"declaration_list" | "enum_member_declaration_list" | "enum_declaration_list"
)
}) =>
{
let name = name_of(node, source);
if !name.is_empty() {
push(Definition::whole(node), &name, "", Kind::Type, source, file);
}
}
"class_declaration"
| "class_definition"
| "class"
| "abstract_class_declaration"
| "struct_declaration"
| "record_declaration"
| "trait_declaration"
| "interface_declaration"
| "enum_declaration" => {
owning_type(node, &name_of(node, source), source, file);
}
"decorated_definition" => {
if let Some(definition) = node.child_by_field_name("definition") {
if definition.kind() == "class_definition" {
walk(definition, source, owner, file);
} else if definition.kind() == "function_definition" {
function(node, definition, source, owner, file);
}
}
}
"function_item"
| "function_definition"
| "function_declaration"
| "generator_function_declaration"
| "method_definition" => {
function(node, node, source, owner, file);
}
"lexical_declaration" | "variable_declaration" => {
let mut cursor = node.walk();
for declarator in node.named_children(&mut cursor) {
if declarator.kind() != "variable_declarator" {
continue;
}
let Some(value) = declarator.child_by_field_name("value") else {
continue;
};
// `const f = () => …`, or a callback registered through a call such as
// `const view = database.view(options, (ctx) => …)` or `memo(forwardRef(…))`.
let name = declarator
.child_by_field_name("name")
.map(|n| text(n, source).to_string())
.unwrap_or_default();
let Some(function) = callback(value, 2) else {
// `export const actions = { default: async (event) => … }`, as
// SvelteKit form actions and handler maps write it.
if let Some(object) = object_literal(value) {
object_functions(object, source, &name, file);
}
continue;
};
let definition = Definition {
outer: node,
node: value,
body: function.child_by_field_name("body"),
};
push(definition, &name, owner, Kind::Function, source, file);
}
}
"struct_item"
| "enum_item"
| "trait_item"
| "type_item"
| "union_item"
| "type_alias_declaration"
| "delegate_declaration"
| "annotation_type_declaration" => {
let name = name_of(node, source);
if !name.is_empty() {
push(Definition::whole(node), &name, "", Kind::Type, source, file);
}
}
_ => {}
}
}

Check warning on line 621 in src/analysis/units/mod.rs

View workflow job for this annotation

GitHub Actions / review

JevGate consider [maintainability/function-simplification]

`walk` likely mixes separate jobs; splitting it may make it easier to understand (0.80). → Consider extracting each separate job into its own named function

/// A function a module assigns to an object's property, as CommonJS modules
/// define their API: `res.status = function status(code) { … }` is `status`
Expand Down Expand Up @@ -818,6 +827,7 @@
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,
Expand Down
16 changes: 16 additions & 0 deletions src/analysis/units/tests/rust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Planned> {\n let due = follow_ups(files, compose::unconfirmed_units);\n let named = files.iter().map(Self::name_of);\n let kinds = kinds::<String>(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");
}
2 changes: 1 addition & 1 deletion src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ pub fn rules() -> Vec<Rule> {

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",
Expand Down
3 changes: 2 additions & 1 deletion src/units/plan/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,8 @@ fn callers(scope: &Scope<'_>, links: &Links, target: usize) -> BTreeMap<String,
let tests = scope.test_lines(owner);
let units = scope.units[&owner].units.iter();
for unit in units.filter(|u| !tests.iter().any(|l| u.overlaps(l))) {
for call in &unit.calls {
// A function passed by path is used like a call.
for call in unit.calls.iter().chain(&unit.passed) {
callers
.entry(call.clone())
.or_default()
Expand Down
31 changes: 31 additions & 0 deletions src/units/tests/organization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,37 @@ fn a_request_answered_once_fits_whatever_the_calibration() {
assert_eq!(status(&report), status(&first));
}

#[test]
fn a_function_another_file_passes_by_path_names_that_file_as_its_user() {
let project = Project::new();
let selectors: String = (0..14).map(|i| function(&format!("warm{i}"))).collect();
project.write("src/selectors.rs", &selectors);
project.write(
"src/runner.rs",
"use crate::selectors;\n\npub fn run(values: &[i32]) -> 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);
Expand Down
Loading