diff --git a/cli/ucli/analyzers/go_analyzer.py b/cli/ucli/analyzers/go_analyzer.py index 97bfbea..e3930dc 100644 --- a/cli/ucli/analyzers/go_analyzer.py +++ b/cli/ucli/analyzers/go_analyzer.py @@ -1,4 +1,4 @@ -"""Go analyzer (Wave 19). +"""Go analyzer (Wave 19 / Wave 28). Preferred path: ``go run`` + ``go/ast`` worker → ``go-ast``, fidelity ``ast``, complexity ``ast-cyclomatic``. Requires Go 1.21+ on PATH. @@ -7,8 +7,10 @@ ``keyword-heuristic``. Override with ``UF_GO_ANALYZER=regex|ast|auto``. Call edges: same-file unique short-name qualification. AST path may also -qualify **same-package** unique names across files in the scan set — never -invented cross-package / import-path edges. Not Python-parity. +qualify **same-package** unique names across files in the scan set, and +**cross-package** selector calls (``alias.Func``) when the import path +uniquely maps to a package directory under the scan root (nearest +``go.mod``). Regex stays same-file-only. Not Python-parity. """ from __future__ import annotations @@ -105,6 +107,7 @@ _RE_CALL = re.compile(r"(? bool: @@ -345,6 +348,248 @@ def _attach_same_package_edges( meta["callers"] = sorted(set(meta.get("callers", []))) +def _read_module_path(gomod: pathlib.Path) -> str | None: + try: + text = gomod.read_text(encoding="utf-8") + except (OSError, UnicodeError): + return None + m = _RE_GOMOD_MODULE.search(text) + if not m: + return None + return m.group(1).strip() + + +def _nearest_go_mod(file_path: pathlib.Path, scan_root: pathlib.Path) -> pathlib.Path | None: + """Walk parents from file dir up to scan_root inclusive for go.mod.""" + try: + start = file_path if file_path.is_dir() else file_path.parent + root = scan_root.resolve() + cur = start.resolve() + except OSError: + return None + while True: + candidate = cur / "go.mod" + if candidate.is_file(): + return candidate + if cur == root or cur.parent == cur: + break + if root not in cur.parents and cur != root: + break + cur = cur.parent + root_mod = scan_root / "go.mod" + if root_mod.is_file(): + return root_mod + return None + + +def _package_dir_of(file_posix: str) -> str: + return str(pathlib.PurePosixPath(file_posix).parent) + + +def _resolve_scan_path(sample: str, scan_root: pathlib.Path) -> pathlib.Path: + """Resolve a display/file path that may be cwd-relative or scan-root-relative.""" + p = pathlib.Path(sample) + if p.is_absolute(): + return p.resolve() + if p.exists(): + return p.resolve() + cand = scan_root / sample + if cand.exists(): + return cand.resolve() + # Display sometimes embeds the scan root prefix already (cwd-relative walk). + try: + root_res = scan_root.resolve() + joined = (pathlib.Path.cwd() / sample).resolve() + if root_res == joined or root_res in joined.parents: + return joined + except OSError: + pass + return p.resolve() + + +def _import_path_for_dir( + pkg_dir_posix: str, + scan_root: pathlib.Path, + file_packages: dict[str, str], +) -> str | None: + """Compute Go import path for a package directory via nearest go.mod. + + Returns None when no module file is found under the scan root. + """ + sample = next( + (f for f in file_packages if _package_dir_of(f) == pkg_dir_posix), + None, + ) + if sample is None: + return None + sample_path = _resolve_scan_path(sample, scan_root) + gomod = _nearest_go_mod(sample_path, scan_root) + if gomod is None: + return None + module = _read_module_path(gomod) + if not module: + return None + try: + mod_root = gomod.parent.resolve() + pkg_abs = sample_path.resolve().parent + rel = pkg_abs.relative_to(mod_root).as_posix() + except (ValueError, OSError): + return None + if rel in {".", ""}: + return module + return f"{module}/{rel}" + + +def _build_import_path_index( + functions: dict[str, Any], + file_packages: dict[str, str], + scan_root: pathlib.Path, +) -> tuple[dict[str, str], dict[str, str], dict[str, dict[str, list[str]]]]: + """Return (import_path→pkg_dir, pkg_dir→package_name, pkg_dir→short→[qns]). + + Import paths that map to more than one package directory are omitted + (invent-free). + """ + pkg_dirs: set[str] = set() + pkg_dir_name: dict[str, str] = {} + by_dir_short: dict[str, dict[str, list[str]]] = {} + + for qn, meta in functions.items(): + file = str(meta.get("file") or "") + if not file: + continue + pkg_dir = _package_dir_of(file) + pkg_dirs.add(pkg_dir) + pkg = str(meta.get("package") or file_packages.get(file) or "") + if pkg and pkg_dir not in pkg_dir_name: + pkg_dir_name[pkg_dir] = pkg + short = qn.rsplit(":", 1)[-1] + simple = meta.get("simple_name") or short.rsplit(".", 1)[-1] + bucket = by_dir_short.setdefault(pkg_dir, {}) + bucket.setdefault(short, []).append(qn) + if simple != short: + bucket.setdefault(simple, []).append(qn) + + path_to_dirs: dict[str, list[str]] = {} + for pkg_dir in pkg_dirs: + ipath = _import_path_for_dir(pkg_dir, scan_root, file_packages) + if not ipath: + continue + path_to_dirs.setdefault(ipath, []).append(pkg_dir) + + unique_path_to_dir: dict[str, str] = {} + for ipath, dirs in path_to_dirs.items(): + uniq = list(dict.fromkeys(dirs)) + if len(uniq) == 1: + unique_path_to_dir[ipath] = uniq[0] + + return unique_path_to_dir, pkg_dir_name, by_dir_short + + +def _unique_pick(candidates: list[str], token: str) -> str | None: + candidates = list(dict.fromkeys(candidates)) + 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 []) + return pick[0] if len(pick) == 1 else None + + +def _attach_cross_package_import_edges( + functions: dict[str, Any], + file_packages: dict[str, str], + file_imports: dict[str, list[dict[str, str]]], + scan_root: pathlib.Path, +) -> None: + """Qualify ``alias.Func`` (and unique dot-import bare names) invent-free. + + Rules (fail closed): + - Import path must uniquely map to one package directory under the scan + root via nearest ``go.mod`` module path + relative dir. + - Selector local name must match the import binding (explicit alias, or + default = package clause name of the resolved package). + - Callee short/simple name must be unique within that package directory. + - Blank imports are ignored; ambiguous paths / names stay bare. + """ + path_to_dir, pkg_dir_name, by_dir_short = _build_import_path_index( + functions, file_packages, scan_root + ) + if not path_to_dir: + return + + for caller_qn, meta in functions.items(): + caller_file = str(meta.get("file") or "") + imports = file_imports.get(caller_file) or [] + binding_to_dir: dict[str, str] = {} + dot_dirs: list[str] = [] + for imp in imports: + ipath = str(imp.get("path") or "").strip() + if not ipath: + continue + pkg_dir = path_to_dir.get(ipath) + if not pkg_dir: + continue + name = str(imp.get("name") or "") + if name == "_": + continue + if name == ".": + dot_dirs.append(pkg_dir) + continue + if name: + prev = binding_to_dir.get(name) + if prev is not None and prev != pkg_dir: + binding_to_dir.pop(name, None) + continue + binding_to_dir[name] = pkg_dir + continue + pkg_name = pkg_dir_name.get(pkg_dir) or "" + if not pkg_name: + continue + prev = binding_to_dir.get(pkg_name) + if prev is not None and prev != pkg_dir: + binding_to_dir.pop(pkg_name, None) + continue + binding_to_dir[pkg_name] = pkg_dir + + new_calls: list[str] = [] + for token in meta.get("calls") or []: + if ":" in token: + new_calls.append(token) + continue + if "." in token: + alias, _, sel = token.partition(".") + if not alias or not sel or "." in sel: + new_calls.append(token) + continue + pkg_dir = binding_to_dir.get(alias) + if not pkg_dir: + new_calls.append(token) + continue + pick = _unique_pick(by_dir_short.get(pkg_dir, {}).get(sel, []), sel) + if pick and pick != caller_qn: + new_calls.append(pick) + functions[pick].setdefault("callers", []).append(caller_qn) + else: + new_calls.append(token) + continue + if not dot_dirs: + new_calls.append(token) + continue + picks: list[str] = [] + for d in dict.fromkeys(dot_dirs): + p = _unique_pick(by_dir_short.get(d, {}).get(token, []), token) + if p: + picks.append(p) + picks = list(dict.fromkeys(picks)) + if len(picks) == 1 and picks[0] != caller_qn: + callee = picks[0] + new_calls.append(callee) + functions[callee].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", []))) + + def _collect_go_files(root: pathlib.Path) -> list[pathlib.Path]: files: list[pathlib.Path] = [] if root.is_file(): @@ -387,6 +632,29 @@ def _build_regex_map(root: pathlib.Path, files: list[pathlib.Path]) -> dict[str, } +def _remap_display_file( + rel_file: str, + files: list[pathlib.Path], + root_posix: str, +) -> tuple[str, str]: + rel_norm = rel_file.replace("\\", "/").lstrip("./") + walk_match = next( + (p for p in files if p.as_posix() == rel_norm or p.as_posix().endswith("/" + rel_norm)), + None, + ) + if walk_match is not None: + display_file = walk_match.as_posix() + stem = walk_match.with_suffix("").as_posix() + else: + display_file = ( + f"{root_posix}/{rel_norm}" + if root_posix and not rel_norm.startswith(root_posix) + else rel_norm + ) + stem = str(pathlib.PurePosixPath(display_file).with_suffix("")) + return display_file, stem + + def _build_ast_map( root: pathlib.Path, files: list[pathlib.Path], @@ -394,39 +662,29 @@ def _build_ast_map( ) -> dict[str, Any]: functions: dict[str, Any] = {} file_packages: dict[str, str] = {} + file_imports: dict[str, list[dict[str, str]]] = {} root_posix = root.as_posix().rstrip("/") + scan_root = root if root.is_dir() else root.parent for entry in worker_payload.get("files") or []: if not isinstance(entry, dict): continue rel_file = str(entry.get("file") or "") - walk_match = next( - ( - p - for p in files - if p.as_posix() == rel_file - or p.as_posix().endswith("/" + rel_file) - or ( - p.name == pathlib.Path(rel_file).name - and p.as_posix().endswith(rel_file.replace("\\", "/")) - ) - ), - None, - ) - if walk_match is not None: - display_file = walk_match.as_posix() - stem = walk_match.with_suffix("").as_posix() - else: - display_file = ( - f"{root_posix}/{rel_file}" - if root_posix and not rel_file.startswith(root_posix) - else rel_file - ) - stem = str(pathlib.PurePosixPath(display_file).with_suffix("")) + display_file, stem = _remap_display_file(rel_file, files, root_posix) pkg = str(entry.get("package") or "") file_packages[display_file] = pkg + remapped: list[dict[str, str]] = [] + for imp in entry.get("imports") or []: + if not isinstance(imp, dict): + continue + path = str(imp.get("path") or "").strip() + if not path: + continue + remapped.append({"path": path, "name": str(imp.get("name") or "")}) + file_imports[display_file] = remapped + locals_map = entry.get("functions") or {} for local, meta in locals_map.items(): if not isinstance(meta, dict): @@ -453,6 +711,7 @@ def _build_ast_map( _attach_and_qualify_same_file(functions) _attach_same_package_edges(functions, file_packages) + _attach_cross_package_import_edges(functions, file_packages, file_imports, scan_root) formula = worker_payload.get("complexity_formula") or ( "1 + IfStmt/ForStmt/RangeStmt/CaseClause + &&/|| extras; nested FuncLit excluded" @@ -466,8 +725,10 @@ def _build_ast_map( "analyzer_note": ( "Go map via go/parser + go/ast (parse only). " f"Complexity is ast-cyclomatic: {formula}. " - "Call edges: same-file unique names, plus high-confidence same-package " - "unique names across scanned files. No cross-package invent; not Python-parity. " + "Call edges: same-file unique names, same-package unique names across " + "scanned files, and invent-free cross-package selector edges when an " + "import path uniquely maps under the scan root (nearest go.mod). " + "Not Python-parity; not typechecked / no packages.Load. " "Falls back to go-best-effort regex when the Go toolchain is unavailable." ), } @@ -503,7 +764,8 @@ class GoAdapter: note = ( "Prefers go/parser AST via `go run` worker (go-ast, ast-cyclomatic); " "degrades to regex (go-best-effort, keyword-heuristic) if Go toolchain missing. " - "Same-file call edges; AST path may qualify unique same-package names. " + "Same-file + same-package unique edges; AST path invent-free cross-package " + "import-path selector edges when uniquely resolved under scan root. " "Not Python-parity." ) diff --git a/cli/ucli/analyzers/go_ast/README.md b/cli/ucli/analyzers/go_ast/README.md index dd9e6f2..5e8adb6 100644 --- a/cli/ucli/analyzers/go_ast/README.md +++ b/cli/ucli/analyzers/go_ast/README.md @@ -1,7 +1,9 @@ -# Go AST worker (Wave 19) +# Go AST worker (Wave 19 / Wave 28) Small `go/parser` + `go/ast` program used by Understand-First when the -`go` toolchain is on PATH. +`go` toolchain is on PATH. Emits per-file functions plus import specs +(Wave 28). Cross-package call edges are resolved invent-free in Python when +an import path uniquely maps under the scan root (via nearest `go.mod`). ```bash # From repo root (or any cwd); the Python bridge sets cwd to this directory. @@ -10,3 +12,4 @@ printf '%s' '{"root":"/abs/path","files":["pkg/math.go"]}' | go run . Requires Go 1.21+. When unavailable, the Python adapter falls back to `go-best-effort` regex heuristics (`UF_GO_ANALYZER=regex|ast|auto`). +Regex path stays same-file-only (no import-path invent). diff --git a/cli/ucli/analyzers/go_ast/parse_worker.go b/cli/ucli/analyzers/go_ast/parse_worker.go index 1857066..715105b 100644 --- a/cli/ucli/analyzers/go_ast/parse_worker.go +++ b/cli/ucli/analyzers/go_ast/parse_worker.go @@ -1,6 +1,8 @@ -// Understand-First Go AST worker (Wave 19). +// Understand-First Go AST worker (Wave 19 / Wave 28). // -// Uses go/parser + go/ast (parse only — no typecheck / packages.Load). +// Uses go/parser + go/ast (parse only — no typecheck). +// Wave 28 emits import specs so the Python adapter can invent-free-qualify +// selector calls when an import path uniquely maps inside the scan root. // // Complexity formula (ast-cyclomatic), documented for map consumers: // 1 + count of decision points in the function body, not descending into @@ -22,6 +24,7 @@ import ( "io" "os" "path/filepath" + "strconv" "strings" ) @@ -41,9 +44,18 @@ type funcMeta struct { Name string `json:"name"` } +// importSpec is a single import path binding (Wave 28). +// Name is "" for default imports (caller uses the package clause name), +// "." for dot-import, "_" for blank, or an explicit alias. +type importSpec struct { + Path string `json:"path"` + Name string `json:"name"` +} + type fileResult struct { File string `json:"file"` Package string `json:"package,omitempty"` + Imports []importSpec `json:"imports,omitempty"` Functions map[string]funcMeta `json:"functions"` } @@ -149,7 +161,34 @@ func parseFile(absPath, display string) (fileResult, error) { if f.Name != nil { pkg = f.Name.Name } - return fileResult{File: display, Package: pkg, Functions: funcs}, nil + return fileResult{ + File: display, + Package: pkg, + Imports: extractImports(f), + Functions: funcs, + }, nil +} + +func extractImports(f *ast.File) []importSpec { + out := []importSpec{} + if f == nil { + return out + } + for _, imp := range f.Imports { + if imp == nil || imp.Path == nil { + continue + } + path, err := strconv.Unquote(imp.Path.Value) + if err != nil || path == "" { + continue + } + name := "" + if imp.Name != nil { + name = imp.Name.Name + } + out = append(out, importSpec{Path: path, Name: name}) + } + return out } func recvTypeName(expr ast.Expr) string { diff --git a/examples/go_crosspkg/aliasapp/run.go b/examples/go_crosspkg/aliasapp/run.go new file mode 100644 index 0000000..b5b7bcb --- /dev/null +++ b/examples/go_crosspkg/aliasapp/run.go @@ -0,0 +1,9 @@ +// Package aliasapp imports mathutil under an explicit alias. +package aliasapp + +import mu "example.com/uf/go_crosspkg/mathutil" + +// RunAliased calls Add via an import alias. +func RunAliased(a, b int) int { + return mu.Add(a, b) +} diff --git a/examples/go_crosspkg/app/run.go b/examples/go_crosspkg/app/run.go new file mode 100644 index 0000000..ba22769 --- /dev/null +++ b/examples/go_crosspkg/app/run.go @@ -0,0 +1,14 @@ +// Package app imports mathutil and calls it via the default package name. +package app + +import "example.com/uf/go_crosspkg/mathutil" + +// Run calls mathutil.Add across packages (AST invent-free when unique). +func Run(a, b int) int { + return mathutil.Add(a, b) +} + +// RunScale calls mathutil.Scale. +func RunScale(n int) int { + return mathutil.Scale(n, 2) +} diff --git a/examples/go_crosspkg/go.mod b/examples/go_crosspkg/go.mod new file mode 100644 index 0000000..399d1d3 --- /dev/null +++ b/examples/go_crosspkg/go.mod @@ -0,0 +1,3 @@ +module example.com/uf/go_crosspkg + +go 1.21 diff --git a/examples/go_crosspkg/mathutil/add.go b/examples/go_crosspkg/mathutil/add.go new file mode 100644 index 0000000..79c0adb --- /dev/null +++ b/examples/go_crosspkg/mathutil/add.go @@ -0,0 +1,12 @@ +// Package mathutil is a cross-package callee fixture (Wave 28). +package mathutil + +// Add returns the sum of a and b. +func Add(a, b int) int { + return a + b +} + +// Scale multiplies n by factor. +func Scale(n, factor int) int { + return n * factor +} diff --git a/schemas/SCHEMA_REFERENCE.md b/schemas/SCHEMA_REFERENCE.md index 369e853..7aedfa9 100644 --- a/schemas/SCHEMA_REFERENCE.md +++ b/schemas/SCHEMA_REFERENCE.md @@ -120,7 +120,7 @@ Each function object also includes `simple_name`: the bare basename (`helper` fo | --- | --- | --- | --- | | Python (`.py`) | AST | McCabe `complexity`, high-confidence `calls`/`callers`, heuristic `side_effects` | Same honesty rules as above | | 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` | +| 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 and **cross-package** `alias.Func` when the import path uniquely maps under the scan root (nearest `go.mod`) | Not Python parity: no typed method resolve; no invent when import path is ambiguous or outside the scan root; not typechecked / no `packages.Load`. 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` | | 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` | diff --git a/tests/test_wave28.py b/tests/test_wave28.py new file mode 100644 index 0000000..8a7afcf --- /dev/null +++ b/tests/test_wave28.py @@ -0,0 +1,187 @@ +"""Wave 28: Go invent-free cross-package import edges (AST path).""" + +from __future__ import annotations + +import pathlib + +import pytest +from cli.ucli.analyzers.go_analyzer import build_go_map +from cli.ucli.analyzers.go_ast_bridge import ast_backend_available + +CROSSPKG = pathlib.Path("examples/go_crosspkg") + + +@pytest.fixture +def force_regex(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("UF_GO_ANALYZER", "regex") + + +@pytest.fixture +def force_ast(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("UF_GO_ANALYZER", "ast") + + +def _qn_ending(fns: dict, suffix: str) -> str: + return next(k for k in fns if k.endswith(suffix)) + + +@pytest.mark.skipif(not ast_backend_available(), reason="Go toolchain AST worker not available") +def test_ast_cross_package_default_import_edge(force_ast): + m = build_go_map(CROSSPKG) + assert m["analyzer"] == "go-ast" + fns = m["functions"] + run = _qn_ending(fns, ":Run") + # Prefer the app.Run (not aliasapp) — file path contains /app/ + run = next( + k for k in fns if k.endswith(":Run") and "/app/" in fns[k]["file"].replace("\\", "/") + ) + assert any(c.endswith(":Add") for c in fns[run]["calls"]) + add = next(c for c in fns[run]["calls"] if c.endswith(":Add")) + assert "mathutil" in fns[add]["file"].replace("\\", "/") + assert run in fns[add]["callers"] + + +@pytest.mark.skipif(not ast_backend_available(), reason="Go toolchain AST worker not available") +def test_ast_cross_package_alias_import_edge(force_ast): + m = build_go_map(CROSSPKG) + fns = m["functions"] + caller = _qn_ending(fns, ":RunAliased") + assert any(c.endswith(":Add") for c in fns[caller]["calls"]) + add = next(c for c in fns[caller]["calls"] if c.endswith(":Add")) + assert "mathutil" in fns[add]["file"].replace("\\", "/") + + +@pytest.mark.skipif(not ast_backend_available(), reason="Go toolchain AST worker not available") +def test_ast_cross_package_scale_edge(force_ast): + m = build_go_map(CROSSPKG) + fns = m["functions"] + caller = _qn_ending(fns, ":RunScale") + assert any(c.endswith(":Scale") for c in fns[caller]["calls"]) + + +def test_regex_stays_same_file_only_no_cross_package(force_regex): + m = build_go_map(CROSSPKG) + assert m["analyzer"] == "go-best-effort" + fns = m["functions"] + run = next( + k for k in fns if k.endswith(":Run") and "/app/" in fns[k]["file"].replace("\\", "/") + ) + # Regex does not emit selector tokens as qualified package edges. + assert not any( + isinstance(c, str) and c.endswith(":Add") and ":" in c for c in fns[run]["calls"] + ) + + +@pytest.mark.skipif(not ast_backend_available(), reason="Go toolchain AST worker not available") +def test_ast_does_not_invent_stdlib_selector(force_ast, tmp_path: pathlib.Path): + (tmp_path / "go.mod").write_text("module example.com/uf/tmp\n\ngo 1.21\n", encoding="utf-8") + (tmp_path / "main.go").write_text( + 'package main\n\nimport "fmt"\n\nfunc Hello() { fmt.Println("x") }\n', + encoding="utf-8", + ) + m = build_go_map(tmp_path) + hello = _qn_ending(m["functions"], ":Hello") + calls = m["functions"][hello]["calls"] + assert "fmt.Println" in calls or any(c == "fmt.Println" for c in calls) + assert not any(isinstance(c, str) and c.endswith(":Println") and ":" in c for c in calls) + + +@pytest.mark.skipif(not ast_backend_available(), reason="Go toolchain AST worker not available") +def test_ast_ambiguous_import_path_no_edge(force_ast, tmp_path: pathlib.Path): + """Two modules exposing the same import path string → omit (invent-free).""" + # Nested layout is awkward for duplicate import paths; instead: two packages + # imported under the same alias name from different paths — alias collision. + (tmp_path / "go.mod").write_text("module example.com/uf/ambig\n\ngo 1.21\n", encoding="utf-8") + a = tmp_path / "a" + b = tmp_path / "b" + a.mkdir() + b.mkdir() + (a / "a.go").write_text("package autil\n\nfunc Shared() int { return 1 }\n", encoding="utf-8") + (b / "b.go").write_text("package butil\n\nfunc Shared() int { return 2 }\n", encoding="utf-8") + (tmp_path / "main.go").write_text( + "package main\n\n" + "import (\n" + ' autil "example.com/uf/ambig/a"\n' + ' butil "example.com/uf/ambig/b"\n' + ")\n\n" + # Same selector local name cannot bind two packages; use distinct aliases + # and call a name that exists in both — still unique per alias. + "func RunA() int { return autil.Shared() }\n" + "func RunB() int { return butil.Shared() }\n", + encoding="utf-8", + ) + m = build_go_map(tmp_path) + fns = m["functions"] + run_a = _qn_ending(fns, ":RunA") + run_b = _qn_ending(fns, ":RunB") + assert any(c.endswith(":Shared") for c in fns[run_a]["calls"]) + assert any(c.endswith(":Shared") for c in fns[run_b]["calls"]) + shared_a = next(c for c in fns[run_a]["calls"] if c.endswith(":Shared")) + shared_b = next(c for c in fns[run_b]["calls"] if c.endswith(":Shared")) + assert shared_a != shared_b + assert "/a/" in fns[shared_a]["file"].replace("\\", "/") or fns[shared_a]["file"].replace( + "\\", "/" + ).endswith("/a/a.go") + assert "/b/" in fns[shared_b]["file"].replace("\\", "/") or fns[shared_b]["file"].replace( + "\\", "/" + ).endswith("/b/b.go") + + +@pytest.mark.skipif(not ast_backend_available(), reason="Go toolchain AST worker not available") +def test_ast_no_go_mod_no_cross_package_invent(force_ast, tmp_path: pathlib.Path): + """Without go.mod, import paths cannot resolve invent-free → leave bare.""" + pkg = tmp_path / "mathutil" + app = tmp_path / "app" + pkg.mkdir() + app.mkdir() + (pkg / "add.go").write_text( + "package mathutil\n\nfunc Add(a, b int) int { return a + b }\n", + encoding="utf-8", + ) + (app / "run.go").write_text( + 'package app\n\nimport "example.com/missing/mathutil"\n\n' + "func Run() int { return mathutil.Add(1, 2) }\n", + encoding="utf-8", + ) + m = build_go_map(tmp_path) + run = _qn_ending(m["functions"], ":Run") + calls = m["functions"][run]["calls"] + assert "mathutil.Add" in calls + assert not any(isinstance(c, str) and c.endswith(":Add") and ":" in c for c in calls) + + +@pytest.mark.skipif(not ast_backend_available(), reason="Go toolchain AST worker not available") +def test_ast_ambiguous_callee_name_in_package_omits(force_ast, tmp_path: pathlib.Path): + """Two funcs with same simple name in one package dir cannot be — use method collision via short name. + + Go forbids two funcs with the same name in one package; simulate ambiguity by + having Add as both a function and leaving selector unresolved when the import + path itself is not unique (duplicate module roots under scan). + """ + m1 = tmp_path / "m1" + m2 = tmp_path / "m2" + m1.mkdir() + m2.mkdir() + (m1 / "go.mod").write_text("module example.com/dup\n\ngo 1.21\n", encoding="utf-8") + (m2 / "go.mod").write_text("module example.com/dup\n\ngo 1.21\n", encoding="utf-8") + (m1 / "x.go").write_text("package dup\n\nfunc Shared() int { return 1 }\n", encoding="utf-8") + (m2 / "x.go").write_text("package dup\n\nfunc Shared() int { return 2 }\n", encoding="utf-8") + app = tmp_path / "app" + app.mkdir() + (app / "go.mod").write_text("module example.com/app\n\ngo 1.21\n", encoding="utf-8") + (app / "main.go").write_text( + 'package app\n\nimport "example.com/dup"\n\nfunc Run() int { return dup.Shared() }\n', + encoding="utf-8", + ) + m = build_go_map(tmp_path) + run = _qn_ending(m["functions"], ":Run") + calls = m["functions"][run]["calls"] + # Import path example.com/dup maps to two dirs → omit. + assert "dup.Shared" in calls + assert not any(isinstance(c, str) and c.endswith(":Shared") and ":" in c for c in calls) + + +def test_schema_documents_go_cross_package(): + ref = pathlib.Path("schemas/SCHEMA_REFERENCE.md").read_text(encoding="utf-8") + assert "go-ast" in ref + assert "cross-package" in ref.lower() or "import path" in ref.lower()