diff --git a/README.md b/README.md index fd9f74a..ef3962b 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Large codebases reward familiarity over clarity. Docs drift, static graphs show ## What it does -- **Maps** — Python: high-confidence AST call graphs with McCabe complexity and callers (import-gated / return-type factory edges when unique; ambiguous names omit rather than invent). JavaScript/TypeScript: Node TypeScript AST when available (`*-ast`, `ast-cyclomatic`, same-file + relative imports, re-export barrels, and nearest `package.json` `"exports"` subpaths); otherwise regex fallback. Go: `go/ast` when available; otherwise regex. Java: `javalang` when installed; otherwise regex. Rust: `syn` AST when cargo is available (`rust-ast`, `ast-cyclomatic`); otherwise `rust-best-effort` regex — **not** rustc typechecked/Python parity. C#: Roslyn AST when a .NET SDK is available (`csharp-ast`, `ast-cyclomatic`); otherwise `csharp-best-effort` regex — **not** typechecked/Python parity. Unsupported languages are counted and skipped (never scanned as Python or empty fake maps). +- **Maps** — Python: high-confidence AST call graphs with McCabe complexity and callers (import-gated / return-type factory edges when unique; ambiguous names omit rather than invent). JavaScript/TypeScript: Node TypeScript AST when available (`*-ast`, `ast-cyclomatic`, same-file + relative imports, re-export barrels, and nearest `package.json` `"exports"` subpaths); otherwise regex fallback. Go: `go/ast` when available; otherwise regex. Java: `javalang` when installed; otherwise regex. Rust: `syn` AST when cargo is available (`rust-ast`, `ast-cyclomatic`); otherwise `rust-best-effort` regex — same-file plus invent-free `mod`/`use` edges for unique scanned targets; **not** rustc typechecked/Python parity. C#: Roslyn AST when a .NET SDK is available (`csharp-ast`, `ast-cyclomatic`); otherwise `csharp-best-effort` regex — **not** typechecked/Python parity. Unsupported languages are counted and skipped (never scanned as Python or empty fake maps). - **Invariants & effects** — Contract-linked structure and pre/post hints where configured; map `side_effects` tags are AST heuristics on Python, not proven purity (JS maps leave `side_effects` empty). - **Hot-path fixtures** — Small, runnable scaffolds tied to real execution. - **Reading plans** — Module-oriented summaries and questions before you edit. @@ -337,7 +337,7 @@ More detail: `docs/usage.md` and `docs/onboarding.md`. ## Roadmap -- Language adapters: Kotlin, deeper crate-graph Rust resolve, richer C# `using`/project-reference edges, etc. Bare npm package-name resolve stays out of scope by design. +- Language adapters: Kotlin, deeper crate-graph Rust resolve beyond invent-free `mod`/`use`, richer C# `using`/project-reference edges, etc. Bare npm package-name resolve stays out of scope by design. - Map-delta visualizations and richer PR comment flows. - Invariant DSL with optional Lean scaffold stubs (presence-only; not compiled). - Deeper IDE integration beyond the current VS Code extension. diff --git a/cli/ucli/analyzers/rust_analyzer.py b/cli/ucli/analyzers/rust_analyzer.py index a9112a5..9ddce23 100644 --- a/cli/ucli/analyzers/rust_analyzer.py +++ b/cli/ucli/analyzers/rust_analyzer.py @@ -1,4 +1,4 @@ -"""Rust analyzer (Wave 22–23). +"""Rust analyzer (Wave 22–26). Preferred path (Wave 23): ``cargo run`` + ``syn`` worker → ``rust-ast``, ``ast-cyclomatic``. Requires cargo on PATH. @@ -6,8 +6,10 @@ Fallback (Wave 22): conservative regex → ``rust-best-effort``, ``keyword-heuristic``. Override with ``UF_RUST_ANALYZER=regex|ast|auto``. -Call edges: same-file unique short names only — never invents cross-module / -``use`` / crate edges. Not Python/Go/Java-AST parity. +Call edges (Wave 26): same-file unique short names, plus invent-free +cross-module edges when ``mod`` / ``use`` evidence uniquely resolves a +target inside the scanned tree. Ambiguous names are omitted (left bare). +Not rustc typechecked / not Python-parity. """ from __future__ import annotations @@ -116,6 +118,13 @@ ) _RE_CALL = re.compile(r"(?|\&\&|\|\||\?(?![?.])") +# Top-level mod name; / mod name { +_RE_MOD = re.compile( + r"(?m)^[ \t]*(?:pub(?:\s*\([^)]*\))?\s+)?mod\s+([A-Za-z_][\w]*)\s*[;{]", +) +_RE_USE = re.compile( + r"(?m)^[ \t]*(?:pub(?:\s*\([^)]*\))?\s+)?use\s+([^;]+);", +) def _is_rust_file(path: pathlib.Path) -> bool: @@ -205,20 +214,99 @@ def _complexity_heuristic(body: str) -> int: def _calls_in_body(body: str) -> list[str]: calls: list[str] = [] for m in _RE_CALL.finditer(body): - # Take last path segment for Foo::bar( → bar; bare foo( → foo raw = m.group(0) name = m.group(1) if "::" in raw: - # Prefer method/fn after :: parts = re.findall(r"[A-Za-z_][\w]*", raw) if parts: - name = parts[-1] - if name in _KEYWORD_CALLEES: + # Preserve mod::fn paths for Wave 26; same-file uses last segment. + name = "::".join(parts) if len(parts) > 1 else parts[-1] + if name in _KEYWORD_CALLEES or name.rsplit("::", 1)[-1] in _KEYWORD_CALLEES: continue calls.append(name) return list(dict.fromkeys(calls)) +def _parse_use_bindings(use_body: str) -> list[dict[str, Any]]: + """Flatten a ``use`` path body into invent-free bindings (globs omitted).""" + body = use_body.strip() + if not body or "*" in body: + return [] + # Drop leading crate/super/self path roots for module matching. + body = re.sub(r"^(?:crate|super|self)\s*::\s*", "", body) + + bindings: list[dict[str, Any]] = [] + + def walk(prefix: list[str], fragment: str) -> None: + fragment = fragment.strip() + if not fragment or "*" in fragment: + return + if fragment.startswith("{"): + close = fragment.rfind("}") + if close < 0: + return + inner = fragment[1:close] + depth = 0 + start = 0 + for i, ch in enumerate(inner): + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + elif ch == "," and depth == 0: + walk(prefix, inner[start:i]) + start = i + 1 + walk(prefix, inner[start:]) + return + if "::" in fragment: + head, rest = fragment.split("::", 1) + head = head.strip() + if not head: + return + walk([*prefix, head], rest) + return + # Terminal: name or name as alias + as_match = re.match( + r"^([A-Za-z_][\w]*)\s+as\s+([A-Za-z_][\w]*)$", + fragment.strip(), + ) + if as_match: + bindings.append( + { + "name": as_match.group(2), + "imported": as_match.group(1), + "module": list(prefix), + } + ) + return + name = fragment.strip() + if not re.match(r"^[A-Za-z_][\w]*$", name): + return + if name == "self" and prefix: + bindings.append( + {"name": prefix[-1], "imported": prefix[-1], "module": prefix[:-1]} + ) + return + bindings.append({"name": name, "imported": name, "module": list(prefix)}) + + walk([], body) + return bindings + + +def _extract_file_mod_use(cleaned: str) -> tuple[list[str], list[dict[str, Any]]]: + mods = list(dict.fromkeys(m.group(1) for m in _RE_MOD.finditer(cleaned))) + uses: list[dict[str, Any]] = [] + seen: set[tuple[str, tuple[str, ...]]] = set() + for m in _RE_USE.finditer(cleaned): + for binding in _parse_use_bindings(m.group(1)): + key = (binding["name"], tuple(binding["module"])) + if key in seen: + continue + seen.add(key) + uses.append(binding) + return mods, uses + + def _parse_rust_source(src: str, file_path: pathlib.Path) -> dict[str, Any]: cleaned = _strip_comments_and_strings(src) out: dict[str, Any] = {} @@ -274,15 +362,26 @@ def in_impl(idx: int) -> tuple[int, int, str] | None: return out -def _parse_file(file_path: pathlib.Path) -> dict[str, Any]: +def _parse_file( + file_path: pathlib.Path, +) -> tuple[dict[str, Any], list[str], list[dict[str, Any]]]: try: src = file_path.read_text(encoding="utf-8") except (OSError, UnicodeError): - return {} + return {}, [], [] try: - return _parse_rust_source(src, file_path) + cleaned = _strip_comments_and_strings(src) + mods, uses = _extract_file_mod_use(cleaned) + return _parse_rust_source(src, file_path), mods, uses except Exception: - return {} + return {}, [], [] + + +def _module_name_for_file(file_posix: str) -> str: + p = pathlib.PurePosixPath(file_posix) + if p.name == "mod.rs": + return p.parent.name + return p.stem def _build_file_indexes( @@ -300,6 +399,15 @@ def _build_file_indexes( return by_file_short +def _pick_unique(candidates: list[str], token: str, caller_qn: str) -> str | None: + uniq = list(dict.fromkeys(candidates)) + exact = [c for c in uniq if c.rsplit(":", 1)[-1] == token] + pick = exact if len(exact) == 1 else (uniq if len(uniq) == 1 else []) + if len(pick) == 1 and pick[0] != caller_qn: + return pick[0] + return None + + def _attach_and_qualify_same_file(functions: dict[str, Any]) -> None: by_file_short = _build_file_indexes(functions) for caller_qn, meta in functions.items(): @@ -307,16 +415,22 @@ def _attach_and_qualify_same_file(functions: dict[str, Any]) -> None: local_map = by_file_short.get(caller_file, {}) qualified: list[str] = [] for token in meta.get("calls") or []: - if "." in token and ":" not in token: + if ":" in token and "::" not in token: + # Already a qname (file:local) + qualified.append(token) + continue + if "." in token and ":" not in token and "::" not in token: + qualified.append(token) + continue + # Path tokens (mod::fn) do not invent same-file Type::method edges. + if "::" in token: qualified.append(token) continue candidates = list(dict.fromkeys(local_map.get(token, []))) - exact = [c for c in candidates if c.rsplit(":", 1)[-1] == token] - pick = exact if len(exact) == 1 else (candidates if len(candidates) == 1 else []) - if len(pick) == 1 and pick[0] != caller_qn: - callee = pick[0] - qualified.append(callee) - functions[callee].setdefault("callers", []).append(caller_qn) + picked = _pick_unique(candidates, token, caller_qn) + if picked is not None: + qualified.append(picked) + functions[picked].setdefault("callers", []).append(caller_qn) else: qualified.append(token) meta["calls"] = list(dict.fromkeys(qualified)) @@ -324,6 +438,145 @@ def _attach_and_qualify_same_file(functions: dict[str, Any]) -> None: meta["callers"] = sorted(set(meta.get("callers") or [])) +def _index_files_by_module_stem( + file_paths: list[str], +) -> dict[str, list[str]]: + by_stem: dict[str, list[str]] = {} + for fp in file_paths: + stem = _module_name_for_file(fp) + if stem in {"lib", "main"}: + continue + by_stem.setdefault(stem, []).append(fp) + return by_stem + + +def _resolve_mod_target_file( + caller_file: str, + mod_name: str, + by_stem: dict[str, list[str]], +) -> str | None: + """Map ``mod name`` to a unique scanned ``name.rs`` / ``name/mod.rs``.""" + candidates = list(dict.fromkeys(by_stem.get(mod_name, []))) + if not candidates: + return None + parent = str(pathlib.PurePosixPath(caller_file).parent) + preferred = [ + c + for c in candidates + if pathlib.PurePosixPath(c).parent.as_posix() == parent + or ( + pathlib.PurePosixPath(c).name == "mod.rs" + and pathlib.PurePosixPath(c).parent.name == mod_name + and pathlib.PurePosixPath(c).parent.parent.as_posix() == parent + ) + ] + pool = preferred if preferred else candidates + if len(pool) == 1: + return pool[0] + return None + + +def _attach_cross_module_edges( + functions: dict[str, Any], + file_mods: dict[str, list[str]], + file_uses: dict[str, list[dict[str, Any]]], +) -> None: + """Qualify remaining bare / path calls via unique mod/use targets (fail closed).""" + by_file_short = _build_file_indexes(functions) + all_files = sorted({meta.get("file", "") for meta in functions.values() if meta.get("file")}) + by_stem = _index_files_by_module_stem(all_files) + + # Precompute child-module file → functions available via caller's `mod` decls. + child_files_by_caller: dict[str, set[str]] = {} + for caller_file, mods in file_mods.items(): + kids: set[str] = set() + for mod_name in mods: + target = _resolve_mod_target_file(caller_file, mod_name, by_stem) + if target: + kids.add(target) + child_files_by_caller[caller_file] = kids + + for caller_qn, meta in functions.items(): + caller_file = meta.get("file", "") + uses = file_uses.get(caller_file) or [] + use_by_name: dict[str, list[dict[str, Any]]] = {} + for u in uses: + use_by_name.setdefault(u["name"], []).append(u) + + new_calls: list[str] = [] + for token in meta.get("calls") or []: + # Already qualified qname + if ":" in token and "::" not in token: + new_calls.append(token) + continue + + resolved: str | None = None + + if "::" in token: + parts = [p for p in token.split("::") if p] + if len(parts) >= 2: + mod_segs, simple = parts[:-1], parts[-1] + while mod_segs and mod_segs[0] in {"crate", "super", "self"}: + mod_segs = mod_segs[1:] + if mod_segs: + mod_name = mod_segs[-1] + declared = mod_name in (file_mods.get(caller_file) or []) + use_mentions = any( + mod_name in (u.get("module") or []) or u.get("name") == mod_name + for u in uses + ) + if declared or use_mentions: + target_file = _resolve_mod_target_file( + caller_file, mod_name, by_stem + ) + if target_file: + local_map = by_file_short.get(target_file, {}) + candidates = list( + dict.fromkeys(local_map.get(simple, [])) + ) + resolved = _pick_unique(candidates, simple, caller_qn) + + if resolved is None and "::" not in token: + # use-gated bare name + bindings = use_by_name.get(token) or [] + if len(bindings) == 1: + binding = bindings[0] + imported = str(binding.get("imported") or token) + module = list(binding.get("module") or []) + while module and module[0] in {"crate", "super", "self"}: + module = module[1:] + if module: + mod_name = module[-1] + target_file = _resolve_mod_target_file( + caller_file, mod_name, by_stem + ) + if target_file: + local_map = by_file_short.get(target_file, {}) + candidates = list( + dict.fromkeys(local_map.get(imported, [])) + ) + resolved = _pick_unique( + candidates, imported, caller_qn + ) + elif not bindings: + # Unique among child modules declared via `mod` only. + kids = child_files_by_caller.get(caller_file) or set() + if kids: + candidates = [] + for kid in kids: + candidates.extend(by_file_short.get(kid, {}).get(token, [])) + resolved = _pick_unique(candidates, token, caller_qn) + + if resolved is not None: + new_calls.append(resolved) + functions[resolved].setdefault("callers", []).append(caller_qn) + else: + new_calls.append(token) + meta["calls"] = list(dict.fromkeys(new_calls)) + for meta in functions.values(): + meta["callers"] = sorted(set(meta.get("callers") or [])) + + def _collect_rust_files(root: pathlib.Path) -> list[pathlib.Path]: files: list[pathlib.Path] = [] if root.is_file(): @@ -339,8 +592,14 @@ def _collect_rust_files(root: pathlib.Path) -> list[pathlib.Path]: def _build_regex_map(files: list[pathlib.Path]) -> dict[str, Any]: functions: dict[str, Any] = {} + file_mods: dict[str, list[str]] = {} + file_uses: dict[str, list[dict[str, Any]]] = {} + for file_path in files: - parsed = _parse_file(file_path) + parsed, mods, uses = _parse_file(file_path) + display = file_path.as_posix() + file_mods[display] = mods + file_uses[display] = uses for func, meta in parsed.items(): key = f"{file_path.with_suffix('').as_posix()}:{func}" m = dict(meta) @@ -349,6 +608,7 @@ def _build_regex_map(files: list[pathlib.Path]) -> dict[str, Any]: functions[key] = m _attach_and_qualify_same_file(functions) + _attach_cross_module_edges(functions, file_mods, file_uses) return { "language": "rust", "functions": functions, @@ -357,19 +617,39 @@ def _build_regex_map(files: list[pathlib.Path]) -> dict[str, Any]: "complexity_kind": "keyword-heuristic", "analyzer_note": ( "Rust map via regex heuristics — not syn/rustc AST; complexity is " - "keyword-based; call edges are same-file unique names only. No use/" - "crate-path invent; not Python/Go/Java-AST parity. " + "keyword-based; call edges are same-file unique names, plus invent-free " + "cross-module edges when mod/use uniquely resolves a scanned target. " + "Ambiguous or unproven edges stay bare. Not Python-parity. " "Set UF_RUST_ANALYZER=auto with cargo on PATH for rust-ast." ), } +def _normalize_worker_uses(raw_uses: Any) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + if not isinstance(raw_uses, list): + return out + for item in raw_uses: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip() + if not name: + continue + imported = str(item.get("imported") or name).strip() or name + module_raw = item.get("module") or [] + module = [str(x) for x in module_raw] if isinstance(module_raw, list) else [] + out.append({"name": name, "imported": imported, "module": module}) + return out + + def _build_ast_map( root: pathlib.Path, files: list[pathlib.Path], worker_payload: dict[str, Any], ) -> dict[str, Any]: functions: dict[str, Any] = {} + file_mods: dict[str, list[str]] = {} + file_uses: dict[str, list[dict[str, Any]]] = {} root_posix = root.as_posix().rstrip("/") for entry in worker_payload.get("files") or []: @@ -400,6 +680,12 @@ def _build_ast_map( ) stem = str(pathlib.PurePosixPath(display_file).with_suffix("")) + mods_raw = entry.get("mods") or [] + file_mods[display_file] = ( + [str(m) for m in mods_raw] if isinstance(mods_raw, list) else [] + ) + file_uses[display_file] = _normalize_worker_uses(entry.get("uses")) + locals_map = entry.get("functions") or {} for local, meta in locals_map.items(): if not isinstance(meta, dict): @@ -417,6 +703,7 @@ def _build_ast_map( functions[qn] = m _attach_and_qualify_same_file(functions) + _attach_cross_module_edges(functions, file_mods, file_uses) formula = worker_payload.get("complexity_formula") or ( "1 + If/While/For/Loop/MatchArm/Question/&&/||; nested fn/closure excluded" ) @@ -429,8 +716,9 @@ def _build_ast_map( "analyzer_note": ( "Rust map via syn (parse only, not rustc typecheck). " f"Complexity is ast-cyclomatic: {formula}. " - "Call edges: same-file unique names only (method calls use bare method " - "name). No use/crate-path invent; not Python-parity. " + "Call edges: same-file unique names, plus invent-free cross-module " + "edges when mod/use uniquely resolves a scanned target. " + "Ambiguous edges omitted; not Python-parity. " "Falls back to rust-best-effort when cargo is unavailable." ), } @@ -466,7 +754,8 @@ class RustAdapter: note = ( "Prefers syn AST via cargo run worker (rust-ast, ast-cyclomatic); " "degrades to regex (rust-best-effort, keyword-heuristic) if cargo missing. " - "Same-file call edges only. Not rustc typechecked / not Python-parity." + "Same-file + invent-free mod/use cross-module unique call edges. " + "Not rustc typechecked / not Python-parity." ) def build_map(self, root: pathlib.Path) -> dict[str, Any]: diff --git a/cli/ucli/analyzers/rust_ast/README.md b/cli/ucli/analyzers/rust_ast/README.md index 13a4d1a..8342add 100644 --- a/cli/ucli/analyzers/rust_ast/README.md +++ b/cli/ucli/analyzers/rust_ast/README.md @@ -1,4 +1,4 @@ -# Rust AST worker (Wave 23) +# Rust AST worker (Wave 23–26) Parse-only [`syn`](https://docs.rs/syn) extractor used when `cargo` is on PATH. @@ -9,4 +9,8 @@ printf '%s' '{"root":"/abs","files":["math.rs"]}' | cargo run --quiet --manifest Requires Rust 1.70+ / cargo. When unavailable, the Python adapter falls back to `rust-best-effort` regex (`UF_RUST_ANALYZER=regex|ast|auto`). +Emits per-file `mods` / `uses` so the Python adapter can qualify invent-free +cross-module call edges for unique scanned targets (Wave 26). Glob `use` and +ambiguous names are omitted. + `target/` is build output — safe to delete; first run compiles dependencies. diff --git a/cli/ucli/analyzers/rust_ast/src/main.rs b/cli/ucli/analyzers/rust_ast/src/main.rs index 5dca7d2..c569ce4 100644 --- a/cli/ucli/analyzers/rust_ast/src/main.rs +++ b/cli/ucli/analyzers/rust_ast/src/main.rs @@ -1,9 +1,12 @@ -//! Understand-First Rust AST worker (Wave 23). +//! Understand-First Rust AST worker (Wave 23–26). //! //! Parse-only via `syn` (not rustc typecheck / MIR). //! Complexity (ast-cyclomatic): 1 + If/While/For/Loop/MatchArm/Binary &&|| / //! Question (?)/IfLet/WhileLet; nested fn/closure bodies excluded. //! +//! Wave 26: also emits top-level `mod` / `use` declarations so the Python +//! adapter can qualify invent-free cross-module edges for unique targets. +//! //! stdin JSON: { "root": "", "files": ["rel.rs", ...] } //! stdout JSON: { "ok": true, "files": [...], "complexity_formula": "..." } @@ -14,7 +17,7 @@ use std::io::{self, Read}; use std::path::{Path, PathBuf}; use syn::spanned::Spanned; use syn::visit::Visit; -use syn::{BinOp, Expr, ImplItem, Item, Pat}; +use syn::{BinOp, Expr, ImplItem, Item, Pat, UseTree}; const COMPLEXITY_FORMULA: &str = "1 + If/While/For/Loop/MatchArm/IfLet/WhileLet/Question/?=/&&/||; nested fn/closure bodies excluded"; @@ -35,10 +38,24 @@ struct FuncMeta { language: String, } +#[derive(Serialize)] +struct UseBinding { + /// Local binding name in scope (after `as`, or last path segment). + name: String, + /// Original imported item name (differs from ``name`` when renamed with ``as``). + imported: String, + /// Module path segments before the imported name (e.g. helper for helper::fn). + module: Vec, +} + #[derive(Serialize)] struct FileEntry { file: String, functions: serde_json::Map, + /// Top-level `mod name;` / `mod name { ... }` identifiers. + mods: Vec, + /// Flattened `use` bindings (globs omitted — ambiguous). + uses: Vec, } #[derive(Serialize)] @@ -84,6 +101,8 @@ fn main() { files_out.push(FileEntry { file: rel, functions: serde_json::Map::new(), + mods: vec![], + uses: vec![], }); } } @@ -116,6 +135,8 @@ fn parse_file(abs: &Path, display: &str) -> Result { let src = fs::read_to_string(abs).map_err(|e| e.to_string())?; let file = syn::parse_file(&src).map_err(|e| e.to_string())?; let mut funcs = serde_json::Map::new(); + let mut mods = Vec::new(); + let mut uses = Vec::new(); for item in &file.items { match item { @@ -137,16 +158,70 @@ fn parse_file(abs: &Path, display: &str) -> Result { } } } + Item::Mod(m) => { + mods.push(m.ident.to_string()); + } + Item::Use(u) => { + collect_use_bindings(&u.tree, &mut Vec::new(), &mut uses); + } _ => {} } } + mods.sort(); + mods.dedup(); + Ok(FileEntry { file: display.replace('\\', "/"), functions: funcs, + mods, + uses, }) } +fn collect_use_bindings(tree: &UseTree, prefix: &mut Vec, out: &mut Vec) { + match tree { + UseTree::Path(p) => { + prefix.push(p.ident.to_string()); + collect_use_bindings(&p.tree, prefix, out); + prefix.pop(); + } + UseTree::Name(n) => { + let name = n.ident.to_string(); + if name == "self" { + if let Some(mod_name) = prefix.last() { + out.push(UseBinding { + name: mod_name.clone(), + imported: mod_name.clone(), + module: prefix[..prefix.len().saturating_sub(1)].to_vec(), + }); + } + } else { + out.push(UseBinding { + name: name.clone(), + imported: name, + module: prefix.clone(), + }); + } + } + UseTree::Rename(r) => { + out.push(UseBinding { + name: r.rename.to_string(), + imported: r.ident.to_string(), + module: prefix.clone(), + }); + } + UseTree::Group(g) => { + for item in &g.items { + collect_use_bindings(item, prefix, out); + } + } + UseTree::Glob(_) => { + // Ambiguous — omit (invent-free). + } + } +} + fn type_path_name(ty: &syn::Type) -> Option { match ty { syn::Type::Path(p) => p.path.segments.last().map(|s| s.ident.to_string()), @@ -229,14 +304,12 @@ impl<'ast> Visit<'ast> for BodyVisitor { } fn visit_pat(&mut self, node: &'ast Pat) { - // if let / while let patterns counted via ExprIf/ExprWhile when let present — - // also bump for IfLetExpr-style via Expr::If with let condition is already If. syn::visit::visit_pat(self, node); } fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) { if let Some(name) = call_name(&node.func) { - if !is_builtin(&name) { + if !is_builtin(simple_call_name(&name)) { self.calls.insert(name); } } @@ -252,15 +325,31 @@ impl<'ast> Visit<'ast> for BodyVisitor { } } +/// Keep `mod::fn` paths for Wave 26; bare names stay single-segment. fn call_name(expr: &Expr) -> Option { match expr { - Expr::Path(p) => p.path.segments.last().map(|s| s.ident.to_string()), + Expr::Path(p) => { + let segs: Vec = p + .path + .segments + .iter() + .map(|s| s.ident.to_string()) + .collect(); + if segs.is_empty() { + return None; + } + Some(segs.join("::")) + } Expr::Paren(p) => call_name(&p.expr), Expr::Group(g) => call_name(&g.expr), _ => None, } } +fn simple_call_name(name: &str) -> &str { + name.rsplit("::").next().unwrap_or(name) +} + fn is_builtin(name: &str) -> bool { matches!( name, diff --git a/docs/usage.md b/docs/usage.md index 2bc96a5..ef1713f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -80,7 +80,9 @@ cargo is on PATH; otherwise **best-effort regex** (`rust-best-effort`). Force wi `dotnet run` in `cli/ucli/analyzers/csharp_ast` (`csharp-ast`, `ast-cyclomatic`) when a .NET SDK is on PATH (`dotnet --list-sdks` non-empty); otherwise **best-effort regex** (`csharp-best-effort`). Force with `UF_CSHARP_ANALYZER=regex|ast|auto`. -JS, Go, Java, Rust, and C# are not Python-parity. Unsupported source extensions +Rust call edges are same-file unique names plus invent-free `mod`/`use` targets +inside the scan root (ambiguous edges omitted). JS, Go, Java, Rust, and C# are +not Python-parity. Unsupported source extensions (e.g. `.rb`) are counted as not analyzed — they are never fed to the Python analyzer or emitted as empty fake maps. 2) Create a task lens from seeds (files, functions, or labels) diff --git a/examples/rust_toy/helper.rs b/examples/rust_toy/helper.rs new file mode 100644 index 0000000..8086350 --- /dev/null +++ b/examples/rust_toy/helper.rs @@ -0,0 +1,9 @@ +//! Cross-module helper for Wave 26 invent-free mod/use edges. + +pub fn helper_fn() -> i32 { + 1 +} + +pub fn other_fn() -> i32 { + helper_fn() +} diff --git a/examples/rust_toy/lib.rs b/examples/rust_toy/lib.rs new file mode 100644 index 0000000..5f7cca5 --- /dev/null +++ b/examples/rust_toy/lib.rs @@ -0,0 +1,19 @@ +//! Multi-file crate fixture for Understand-First Wave 26. +//! +//! Declares child modules and a ``use`` import so call edges can resolve +//! uniquely within the scanned tree (no invent without mod/use evidence). + +mod math; +mod helper; + +use helper::helper_fn; + +/// Bare call gated by ``use helper::helper_fn``. +pub fn run_imported() -> i32 { + helper_fn() +} + +/// Path call gated by ``mod helper``. +pub fn run_mod_path() -> i32 { + helper::other_fn() +} diff --git a/schemas/SCHEMA_REFERENCE.md b/schemas/SCHEMA_REFERENCE.md index 369e853..1b85abc 100644 --- a/schemas/SCHEMA_REFERENCE.md +++ b/schemas/SCHEMA_REFERENCE.md @@ -122,7 +122,7 @@ Each function object also includes `simple_name`: the bare basename (`helper` fo | JavaScript / TypeScript (`.js`, `.mjs`, `.cjs`, `.ts`, `.tsx`, `.jsx`) | **AST preferred** (`javascript-ast` / `typescript-ast`) via Node + TypeScript compiler API; **regex fallback** (`*-best-effort`) if Node or `cli/ucli/analyzers/js_ast` deps missing | Defs + calls; `ast-cyclomatic` or keyword-heuristic; same-file unique edges; AST path may qualify **relative** `./` / `../` imports (incl. re-export barrels ≤2 hops) and nearest **`package.json` `"exports"`** subpaths when uniquely resolved | Not Python parity: no typed `obj.method`, no bare npm package invent, no typechecking. Override with `UF_JS_ANALYZER=regex\|ast\|auto` | | Go (`.go`) | **AST preferred** (`go-ast`) via `go run` + `go/parser`; **regex fallback** (`go-best-effort`) if Go toolchain missing | Defs + methods; `ast-cyclomatic` or keyword-heuristic; same-file unique edges; AST path may qualify **same-package** unique names across scanned files | Not Python parity: no cross-package invent, no typed method resolve. Override with `UF_GO_ANALYZER=regex\|ast\|auto` | | Java (`.java`) | **AST preferred** (`java-ast`) via optional ``javalang``; **regex fallback** (`java-best-effort`) | Class/method defs; `ast-cyclomatic` or keyword-heuristic; same-file + same-package unique edges | Not typechecked; no cross-package invent. Install `javalang` / `understand-first[analyzers]`. Override with `UF_JAVA_ANALYZER=regex\|ast\|auto` | -| Rust (`.rs`) | **AST preferred** (`rust-ast`) via `cargo run` + `syn`; **regex fallback** (`rust-best-effort`) if cargo missing | `fn` / `impl` methods; `ast-cyclomatic` or keyword-heuristic; same-file unique edges only | Not rustc typecheck; no `use`/crate-path invent. Override with `UF_RUST_ANALYZER=regex\|ast\|auto` | +| Rust (`.rs`) | **AST preferred** (`rust-ast`) via `cargo run` + `syn`; **regex fallback** (`rust-best-effort`) if cargo missing | `fn` / `impl` methods; `ast-cyclomatic` or keyword-heuristic; same-file unique edges, plus invent-free cross-module edges when `mod`/`use` uniquely resolves a scanned target | Not rustc typecheck; ambiguous or unproven `mod`/`use` edges omitted (no crate-graph invent). Override with `UF_RUST_ANALYZER=regex\|ast\|auto` | | C# (`.cs`) | **AST preferred** (`csharp-ast`) via `dotnet run` + Roslyn; **regex fallback** (`csharp-best-effort`) if .NET SDK missing | Class/method defs; `ast-cyclomatic` or keyword-heuristic; same-file + same-namespace unique edges | Not typechecked / not MSBuild; no cross-namespace / `using` invent. Requires SDK (`dotnet --list-sdks` non-empty). Override with `UF_CSHARP_ANALYZER=regex\|ast\|auto` | | Other source (`.rb`, `.php`, …) | none | Counted under `unsupported` | **Not analyzed** — never fed to Python as empty maps | diff --git a/tests/test_wave26.py b/tests/test_wave26.py new file mode 100644 index 0000000..6e0509c --- /dev/null +++ b/tests/test_wave26.py @@ -0,0 +1,142 @@ +"""Wave 26: Rust invent-free cross-module mod/use edges.""" + +from __future__ import annotations + +import pathlib + +import pytest +from cli.ucli.analyzers.rust_analyzer import build_rust_map +from cli.ucli.analyzers.rust_ast_bridge import ast_backend_available + + +@pytest.fixture +def force_regex(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("UF_RUST_ANALYZER", "regex") + + +@pytest.fixture +def force_ast(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("UF_RUST_ANALYZER", "ast") + + +def test_rust_use_gated_cross_module_edge(force_regex): + m = build_rust_map(pathlib.Path("examples/rust_toy")) + run = next(k for k in m["functions"] if k.endswith(":run_imported")) + assert any( + isinstance(c, str) and c.endswith(":helper_fn") + for c in m["functions"][run]["calls"] + ) + + +def test_rust_mod_path_cross_module_edge(force_regex): + m = build_rust_map(pathlib.Path("examples/rust_toy")) + run = next(k for k in m["functions"] if k.endswith(":run_mod_path")) + assert any( + isinstance(c, str) and c.endswith(":other_fn") + for c in m["functions"][run]["calls"] + ) + + +def test_rust_same_file_baseline_still_works(force_regex): + m = build_rust_map(pathlib.Path("examples/rust_toy")) + compute = next(k for k in m["functions"] if k.endswith(":compute")) + assert any( + isinstance(c, str) and c.endswith(":add") + for c in m["functions"][compute]["calls"] + ) + + +def test_rust_does_not_invent_without_mod_use( + tmp_path: pathlib.Path, force_regex +): + (tmp_path / "a.rs").write_text("pub fn helper() -> i32 { 1 }\n", encoding="utf-8") + (tmp_path / "b.rs").write_text( + "pub fn run() -> i32 { helper() }\n", + encoding="utf-8", + ) + m = build_rust_map(tmp_path) + run = next(k for k in m["functions"] if k.endswith(":run")) + assert "helper" in m["functions"][run]["calls"] + assert not any( + isinstance(c, str) and c.endswith(":helper") and ":" in c + for c in m["functions"][run]["calls"] + ) + + +def test_rust_omits_ambiguous_cross_module_edges( + tmp_path: pathlib.Path, force_regex +): + (tmp_path / "lib.rs").write_text( + "\n".join( + [ + "mod left;", + "mod right;", + "use left::shared;", + "use right::shared as shared_r;", + "pub fn run() -> i32 { shared() }", + "", + ] + ), + encoding="utf-8", + ) + (tmp_path / "left.rs").write_text( + "pub fn shared() -> i32 { 1 }\n", + encoding="utf-8", + ) + (tmp_path / "right.rs").write_text( + "pub fn shared() -> i32 { 2 }\n", + encoding="utf-8", + ) + m = build_rust_map(tmp_path) + run = next(k for k in m["functions"] if k.endswith(":run")) + # use left::shared uniquely binds `shared` → left::shared only. + assert any( + isinstance(c, str) and "left" in c and c.endswith(":shared") + for c in m["functions"][run]["calls"] + ) + assert not any( + isinstance(c, str) and "right" in c and c.endswith(":shared") + for c in m["functions"][run]["calls"] + ) + + +def test_rust_omits_when_two_mods_export_same_bare_name( + tmp_path: pathlib.Path, force_regex +): + """Bare call with only `mod` evidence and duplicate names → omit.""" + (tmp_path / "lib.rs").write_text( + "\n".join( + [ + "mod left;", + "mod right;", + "pub fn run() -> i32 { twin() }", + "", + ] + ), + encoding="utf-8", + ) + (tmp_path / "left.rs").write_text("pub fn twin() -> i32 { 1 }\n", encoding="utf-8") + (tmp_path / "right.rs").write_text("pub fn twin() -> i32 { 2 }\n", encoding="utf-8") + m = build_rust_map(tmp_path) + run = next(k for k in m["functions"] if k.endswith(":run")) + assert "twin" in m["functions"][run]["calls"] + assert not any( + isinstance(c, str) and c.endswith(":twin") and ":" in c + for c in m["functions"][run]["calls"] + ) + + +@pytest.mark.skipif(not ast_backend_available(), reason="cargo Rust AST worker not available") +def test_rust_ast_cross_module_edges(force_ast): + m = build_rust_map(pathlib.Path("examples/rust_toy")) + assert m["analyzer"] == "rust-ast" + run_imported = next(k for k in m["functions"] if k.endswith(":run_imported")) + assert any( + isinstance(c, str) and c.endswith(":helper_fn") + for c in m["functions"][run_imported]["calls"] + ) + run_mod = next(k for k in m["functions"] if k.endswith(":run_mod_path")) + assert any( + isinstance(c, str) and c.endswith(":other_fn") + for c in m["functions"][run_mod]["calls"] + )