diff --git a/.github/workflows/architecture-analysis-reusable.yml b/.github/workflows/architecture-analysis-reusable.yml index 57ce166..0697615 100644 --- a/.github/workflows/architecture-analysis-reusable.yml +++ b/.github/workflows/architecture-analysis-reusable.yml @@ -24,7 +24,12 @@ on: type: string default: "." language: - description: Optional language override (java, python, typescript, c, go, kotlin). + description: Optional language override (java, python, typescript, c, go, kotlin, multi). + required: false + type: string + default: "" + languages: + description: Optional comma-separated polyglot languages (e.g. java,kotlin). required: false type: string default: "" @@ -142,12 +147,15 @@ jobs: env: SOURCE_PATH: ${{ inputs.source-path }} LANGUAGE: ${{ inputs.language }} + LANGUAGES: ${{ inputs.languages }} REPO_NAME: ${{ inputs.repo-name }} PRIMARY_ALGORITHM: ${{ inputs.primary-algorithm }} FILTER_NON_ARCHITECTURAL_HELPERS: ${{ inputs.filter-non-architectural-helpers }} run: | ARGS=(--source "target-repo/${SOURCE_PATH}") - if [ -n "${LANGUAGE}" ]; then + if [ -n "${LANGUAGES}" ]; then + ARGS+=(--languages "${LANGUAGES}") + elif [ -n "${LANGUAGE}" ]; then ARGS+=(--language "${LANGUAGE}") fi if [ -n "${REPO_NAME}" ]; then @@ -167,12 +175,15 @@ jobs: env: SOURCE_PATH: ${{ inputs.source-path }} LANGUAGE: ${{ inputs.language }} + LANGUAGES: ${{ inputs.languages }} REPO_NAME: ${{ inputs.repo-name }} WCA_NUM_CLUSTERS: ${{ inputs.wca-num-clusters }} FILTER_NON_ARCHITECTURAL_HELPERS: ${{ inputs.filter-non-architectural-helpers }} run: | ARGS=(--source "target-repo/${SOURCE_PATH}") - if [ -n "${LANGUAGE}" ]; then + if [ -n "${LANGUAGES}" ]; then + ARGS+=(--languages "${LANGUAGES}") + elif [ -n "${LANGUAGE}" ]; then ARGS+=(--language "${LANGUAGE}") fi if [ -n "${REPO_NAME}" ]; then diff --git a/README.md b/README.md index 939a1a3..4a58382 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ Provides composable tools for parsing source code, recovering architecture, dete ```bash pip install -e ".[dev]" -# With MCP server support (for AI agent integration) -pip install -e ".[mcp,dev]" +# With MCP and all optional language parsers (for polyglot AI agent integration) +pip install -e ".[mcp,languages,dev]" ``` ## Quick Start @@ -206,6 +206,36 @@ Agent: call get_full_result(session_id="a1b2c3", max_tokens=2000) → full graph data, truncated to fit token budget ``` +For a polyglot repository, let `ingest` preserve its file selection and pass +the returned session directly to `parse`: + +``` +Agent: call ingest(source="/path/to/project", languages=["java", "kotlin"]) + → {session_id: "p1q2r3", languages: ["java", "kotlin"], ...} + +Agent: call parse(source_path="p1q2r3") + → {session_id: "a1b2c3", num_entities: 420, num_edges: 960, ...} +``` + +Use `language="multi"` instead when every detected supported language should +be parsed automatically. + +**Supported polyglot pairs (MVP).** Every requested language is parsed, but +cross-language edges are only created *within a language family*: + +| Family | Languages | Cross-language relinking | +|--------|-----------|--------------------------| +| `jvm` | `java`, `kotlin` | Yes — validated pair (shared FQN space, extends/implements across languages) | +| every other language | `python`, `go`, `typescript`, `c`, ... | No — parsed and merged into one graph, but never linked to another family | + +The relink heuristics are dotted-name tuned (packages, unique leaf names, +`import a.b.C`) and only the JVM pair is covered by tests, so a Python +`com.auth.service` module and a Java `com.auth.service` class are never +confused for each other. When such an FQN coincidence happens across families +both entities are kept — the later one re-keyed as `#` — and the +counts appear in the graph's `metadata` (`fqn_collisions`, +`fqn_collisions_cross_family`, …), so nothing is silently dropped. + ## LLM-Powered Analysis Pass `--use-llm` to enable Claude-powered concern detection. Requires the `claude` CLI installed and authenticated. @@ -318,7 +348,7 @@ arcade-agent ports and extends the capabilities of the original [ARCADE](https:/ | 6 quality metrics | Done | RCI, TurboMQ, BasicMQ, IntraConnectivity, InterConnectivity, TwoWayPairRatio | | Balanced architecture score | Done | Derived reporting score combining core metrics, principle signals, and smell burden | | A2A architecture comparison | Done | Hungarian algorithm on Jaccard similarity | -| Multi-language parsing | Done | Java, Python, C/C++, TypeScript/JavaScript, Go (full); Kotlin (structural) | +| Multi-language parsing | Done | Java, Python, C/C++, TypeScript/JavaScript, Go (full); Kotlin (structural); polyglot merge+relink via `languages=[...]` / `language="multi"` (cross-language edges within the JVM family only) | | 5 export formats | Done | HTML, DOT, JSON, RSF, Mermaid | | LLM concern extraction | Done | Claude CLI for semantic BCO/SPF detection | | MCP server | Done | Expose tools to AI agents via Model Context Protocol with session store | diff --git a/ROADMAP.md b/ROADMAP.md index 469311f..b826e30 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -45,7 +45,7 @@ Handle real-world polyglot monorepos. - [x] **16a2. Kotlin parser** — Shipped (`parsers/kotlin.py`) for JVM/Kotlin-first repos (e.g. embabel-agent). - [ ] **16b. Rust parser** — Still open. High-demand language for agent-assisted development. - [x] **17. Incremental parsing** — Content-hash extract cache shipped in #9 (`incremental.py`), wired for the Python parser only; extending to the other two-pass parsers is follow-up. -- [ ] **18. Cross-language dependency tracking** — Java↔Python via gRPC, TS frontend↔Java backend, etc. +- [x] **18. Cross-language dependency tracking** — MVP: multi-language ingest/parse (`languages=[...]` / `language="multi"`) merges per-language graphs and relinks import/extends/implements across FQN space. Relinking is **family-scoped**: the `jvm` family (Java↔Kotlin) is the supported and validated pair; every other language is its own family and is merged without cross-language edges. Extending relinking to further families (and broader RPC/IDL bridges — gRPC stubs, OpenAPI) remains follow-up. ## Phase 6 — Agent Protocol Integration @@ -60,7 +60,7 @@ Work everywhere agents work. | Priority | Items | Rationale | |----------|-------|-----------| -| **Done** | 1–9, 12, 13, 14, 15, 16a, 17 | Phases 1–2 + TS/JS & Go parsers, incremental parsing (Python), `diff_impact`, `context_for_task`, `api_surface` | +| **Done** | 1–9, 12, 13, 14, 15, 16a, 16a2, 17, 18 (MVP) | Phases 1–2 + TS/JS & Go & Kotlin parsers, incremental parsing (Python), `diff_impact`, `context_for_task`, `api_surface`, polyglot merge+relink | | **Now** | 10 | Architectural changelog | | **Next** | 11, 16b | Component ownership, Rust parser | -| **Then** | 18–22 | Cross-language tracking, ecosystem breadth | +| **Then** | 19–22 | Ecosystem breadth (OpenAI / LangChain / Claude SDK / IDE) | diff --git a/actions/analyze/action.yml b/actions/analyze/action.yml index 70a2107..24912a3 100644 --- a/actions/analyze/action.yml +++ b/actions/analyze/action.yml @@ -23,7 +23,15 @@ inputs: required: false default: "." language: - description: Optional language override (java, python, typescript, c, go, kotlin). + description: > + Optional language override (java, python, typescript, c, go, kotlin, or multi + for every detected language with cross-language edge relinking). + required: false + default: "" + languages: + description: > + Optional comma-separated polyglot languages (e.g. java,kotlin). Mutually + exclusive with language when both would be set by the caller. required: false default: "" repo-name: @@ -135,12 +143,15 @@ runs: env: SOURCE_PATH: ${{ inputs.source-path }} LANGUAGE: ${{ inputs.language }} + LANGUAGES: ${{ inputs.languages }} REPO_NAME: ${{ inputs.repo-name }} PRIMARY_ALGORITHM: ${{ inputs.primary-algorithm }} FILTER_NON_ARCHITECTURAL_HELPERS: ${{ inputs.filter-non-architectural-helpers }} run: | ARGS=(--source "${SOURCE_PATH}") - if [ -n "${LANGUAGE}" ]; then + if [ -n "${LANGUAGES}" ]; then + ARGS+=(--languages "${LANGUAGES}") + elif [ -n "${LANGUAGE}" ]; then ARGS+=(--language "${LANGUAGE}") fi if [ -n "${REPO_NAME}" ]; then @@ -161,12 +172,15 @@ runs: env: SOURCE_PATH: ${{ inputs.source-path }} LANGUAGE: ${{ inputs.language }} + LANGUAGES: ${{ inputs.languages }} REPO_NAME: ${{ inputs.repo-name }} WCA_NUM_CLUSTERS: ${{ inputs.wca-num-clusters }} FILTER_NON_ARCHITECTURAL_HELPERS: ${{ inputs.filter-non-architectural-helpers }} run: | ARGS=(--source "${SOURCE_PATH}") - if [ -n "${LANGUAGE}" ]; then + if [ -n "${LANGUAGES}" ]; then + ARGS+=(--languages "${LANGUAGES}") + elif [ -n "${LANGUAGE}" ]; then ARGS+=(--language "${LANGUAGE}") fi if [ -n "${REPO_NAME}" ]; then diff --git a/src/arcade_agent/ci/run_self_analysis.py b/src/arcade_agent/ci/run_self_analysis.py index 3e137f3..91b15b2 100644 --- a/src/arcade_agent/ci/run_self_analysis.py +++ b/src/arcade_agent/ci/run_self_analysis.py @@ -100,7 +100,15 @@ def main() -> None: parser.add_argument( "--language", default="", - help="Optional language override (java, python, typescript, c, go, kotlin)", + help=( + "Optional language override (java, python, typescript, c, go, kotlin, " + "or multi for every detected language)" + ), + ) + parser.add_argument( + "--languages", + default="", + help="Comma-separated polyglot languages (e.g. java,kotlin)", ) parser.add_argument( "--repo-name", @@ -140,21 +148,33 @@ def main() -> None: source = str(Path(args.source).resolve()) language = args.language or None + languages = [part.strip() for part in args.languages.split(",") if part.strip()] or None print(f"[1/5] Ingesting {source}...") - repo = ingest(source, language=language) - print(f" Found {len(repo.source_files)} source files") + repo = ingest(source, language=language, languages=languages) + print( + f" Found {len(repo.source_files)} source files " + f"(languages={repo.languages or [repo.language]})" + ) if not repo.source_files: print(" No source files found. Exiting.") sys.exit(1) print("[2/5] Parsing dependencies...") - raw_graph = parse( - str(repo.path), - language=repo.language or language or "python", - files=[str(f) for f in repo.source_files], - ) + parse_files = [str(f) for f in repo.source_files] + if len(repo.languages) > 1: + raw_graph = parse( + str(repo.path), + languages=repo.languages, + files=parse_files, + ) + else: + raw_graph = parse( + str(repo.path), + language=repo.language or language or "python", + files=parse_files, + ) graph = ( _filter_non_architectural_entities(raw_graph) if args.filter_non_architectural_helpers @@ -182,6 +202,7 @@ def main() -> None: "timestamp": datetime.now(timezone.utc).isoformat(), "repo_name": args.repo_name or repo.name, "language": repo.language or language, + "languages": repo.languages, "commit_sha": os.environ.get("GITHUB_SHA", "local"), "ref": os.environ.get("GITHUB_REF", "local"), "algorithm": args.algorithm, diff --git a/src/arcade_agent/parsers/graph.py b/src/arcade_agent/parsers/graph.py index 115615c..93c24e6 100644 --- a/src/arcade_agent/parsers/graph.py +++ b/src/arcade_agent/parsers/graph.py @@ -1,6 +1,7 @@ """Dependency graph data models.""" from dataclasses import dataclass, field +from typing import Any @dataclass @@ -35,6 +36,9 @@ class DependencyGraph: entities: dict[str, Entity] = field(default_factory=dict) edges: list[Edge] = field(default_factory=list) packages: dict[str, list[str]] = field(default_factory=dict) + # Parse-time notes for consumers (e.g. cross-language FQN collision counts + # from multilang.merge_and_relink). Empty for single-language parses. + metadata: dict[str, Any] = field(default_factory=dict) @property def num_entities(self) -> int: @@ -65,4 +69,10 @@ def merge(self, other: "DependencyGraph") -> "DependencyGraph": packages.setdefault(pkg, []).extend(fqns) for pkg, fqns in other.packages.items(): packages.setdefault(pkg, []).extend(fqns) - return DependencyGraph(entities=entities, edges=edges, packages=packages) + packages = {pkg: list(dict.fromkeys(fqns)) for pkg, fqns in packages.items()} + return DependencyGraph( + entities=entities, + edges=edges, + packages=packages, + metadata={**self.metadata, **other.metadata}, + ) diff --git a/src/arcade_agent/parsers/multilang.py b/src/arcade_agent/parsers/multilang.py new file mode 100644 index 0000000..87413ea --- /dev/null +++ b/src/arcade_agent/parsers/multilang.py @@ -0,0 +1,333 @@ +"""Cross-language graph merge and edge relinking (roadmap #18). + +Scope — supported polyglot pairs +-------------------------------- +Merging and relinking only ever happens *within a language family*. A family is +a set of languages that genuinely share one fully-qualified-name space, so that +a name resolved in one of them may legitimately denote an entity written in +another: + +- ``jvm``: ``java`` + ``kotlin`` — the validated MVP pair. Both compile to the + same JVM namespace, use dotted package FQNs, and routinely extend/implement + each other's types. +- every other language is its own family (``python``, ``go``, ``typescript``, + ``c``, ...). Their graphs are still merged into one ``DependencyGraph``, but + no edge is ever fabricated between two different families, because a + ``com.auth.service`` Python module and a ``com.auth.service`` Java class are + unrelated things that merely spell alike. + +The resolution heuristics here are dotted-name tuned (packages, leaf names, +``import a.b.C``) and only the JVM pair has test coverage. ``language="multi"`` +on a repository containing, say, Go and TypeScript will therefore parse each +language correctly but will not attempt cross-language linking between them. +""" + +from __future__ import annotations + +import logging +from dataclasses import replace +from typing import Any + +from arcade_agent.parsers.graph import DependencyGraph, Edge, Entity + +logger = logging.getLogger(__name__) + +# Languages that share a single FQN space. Anything not listed forms its own +# single-member family (see language_family). Only add a language here when its +# names really do resolve against the other members at compile/runtime. +_LANGUAGE_FAMILIES: dict[str, str] = { + "java": "jvm", + "kotlin": "jvm", +} + +#: Language pairs whose cross-language relinking is exercised by tests. +SUPPORTED_POLYGLOT_PAIRS: tuple[tuple[str, str], ...] = (("java", "kotlin"),) + + +def language_family(language: str | None) -> str: + """Return the FQN-space family of *language* (its own name when unknown). + + Args: + language: Entity language such as "java", "kotlin" or "python". + + Returns: + Family name; "jvm" for Java/Kotlin, the language itself otherwise. + """ + if not language: + return "unknown" + lang = language.lower() + return _LANGUAGE_FAMILIES.get(lang, lang) + + +def resolve_name( + simple_name: str, + source_entity: Entity, + fqn_index: dict[str, dict[str, str]], + entities: dict[str, Entity], + aliases: dict[str, str] | None = None, +) -> str | None: + """Resolve a simple or qualified type name to an entity FQN. + + Resolution never crosses a language family boundary: a Python class named + ``Base`` can only bind to another Python entity, never to a Java one that + happens to share the leaf name. + + Args: + simple_name: Simple or dotted type name as written in the source. + source_entity: Entity that referenced *simple_name*. + fqn_index: Family-scoped unique-leaf index from ``_build_fqn_index``. + entities: All entities of the merged graph, keyed by FQN. + aliases: Optional import aliases declared by *source_entity*. + + Returns: + The resolved FQN, or None when no family-compatible entity matches. + """ + family = language_family(source_entity.language) + + def compatible(fqn: str) -> bool: + target = entities.get(fqn) + return target is not None and language_family(target.language) == family + + if compatible(simple_name): + return simple_name + + if aliases and simple_name in aliases: + aliased = aliases[simple_name] + if compatible(aliased): + return aliased + + for imp in source_entity.imports: + if imp.endswith(f".{simple_name}") and compatible(imp): + return imp + + if source_entity.package: + same_pkg_fqn = f"{source_entity.package}.{simple_name}" + if compatible(same_pkg_fqn): + return same_pkg_fqn + + # A qualified name is already explicit. Falling back to its leaf could link + # an unavailable external type (e.g. external.Base) to an unrelated local + # Base entity, creating a false cross-language dependency. + if "." in simple_name: + return None + + # Unqualified fallback: only same-family entities are candidates, and the + # leaf name must be unique inside that family. + return fqn_index.get(family, {}).get(simple_name) + + +def _aliases_for(entity: Entity) -> dict[str, str]: + raw = entity.properties.get("import_aliases") + if isinstance(raw, dict): + return {str(k): str(v) for k, v in raw.items()} + return {} + + +def _build_fqn_index(entities: dict[str, Entity]) -> dict[str, dict[str, str]]: + """Index leaf name -> FQN, scoped per language family. + + Uniqueness is evaluated inside a family: a Java ``Base`` and a Python + ``Base`` do not make each other ambiguous, and neither can resolve to the + other. + + Args: + entities: All entities of the merged graph, keyed by FQN. + + Returns: + Mapping family -> {leaf name: FQN} for leaves unique in that family. + """ + candidates: dict[str, dict[str, list[str]]] = {} + for entity in entities.values(): + family = language_family(entity.language) + candidates.setdefault(family, {}).setdefault(entity.name, []).append(entity.fqn) + # Unqualified fallback is only safe when the leaf name is unique in-family. + return { + family: {name: fqns[0] for name, fqns in names.items() if len(fqns) == 1} + for family, names in candidates.items() + } + + +def relink_edges(graph: DependencyGraph) -> DependencyGraph: + """Add import/extends/implements edges resolvable against the full entity set. + + Language parsers only resolve against their own entities. After merging + Java+Kotlin (the supported polyglot pair) re-run resolution so same-package + and imported cross-language types become real edges. Every resolution path + is gated on language-family compatibility, so no edge is ever created + between entities of unrelated languages. + + Args: + graph: Merged graph whose entities may come from several languages. + + Returns: + New DependencyGraph with the additional resolvable edges. + """ + entities = graph.entities + fqn_index = _build_fqn_index(entities) + seen = {(e.source, e.target, e.relation) for e in graph.edges} + new_edges: list[Edge] = list(graph.edges) + + def add(source: str, target: str, relation: str) -> None: + key = (source, target, relation) + if key in seen or source == target: + return + seen.add(key) + new_edges.append(Edge(source=source, target=target, relation=relation)) + + for entity in entities.values(): + family = language_family(entity.language) + aliases = _aliases_for(entity) + for imp in entity.imports: + imported = entities.get(imp) + if imported is not None: + # An FQN that merely coincides across families (a Python module + # com.auth.service vs a Java class of the same name) is not an + # import edge. + if language_family(imported.language) == family: + add(entity.fqn, imp, "import") + elif "." not in imp: + resolved = fqn_index.get(family, {}).get(imp) + if resolved and resolved != entity.fqn: + add(entity.fqn, resolved, "import") + + if entity.superclass: + target = resolve_name( + entity.superclass, entity, fqn_index, entities, aliases + ) + if target: + add(entity.fqn, target, "extends") + + for iface in entity.interfaces: + target = resolve_name(iface, entity, fqn_index, entities, aliases) + if target: + add(entity.fqn, target, "implements") + + packages: dict[str, list[str]] = { + pkg: list(dict.fromkeys(fqns)) for pkg, fqns in graph.packages.items() + } + return DependencyGraph( + entities=entities, + edges=new_edges, + packages=packages, + metadata=dict(graph.metadata), + ) + + +def _disambiguate_fqn(fqn: str, language: str, taken: dict[str, Entity]) -> str: + """Return an unused graph key for a cross-family collision on *fqn*.""" + candidate = f"{fqn}#{language}" + suffix = 2 + while candidate in taken: + candidate = f"{fqn}#{language}{suffix}" + suffix += 1 + return candidate + + +def merge_and_relink(*graphs: DependencyGraph) -> DependencyGraph: + """Union graphs then relink edges across the combined entity set. + + Entities are merged inside language families only. When two *different* + families produce the same FQN (e.g. Python ``com/auth/service.py::login`` + and Java ``com.auth.service.login``) neither entity is dropped: the later + one is re-keyed as ``#`` and its own edges and package + listings are remapped, so a cross-family name coincidence no longer causes + silent data loss. Within a family the first entity still wins. + + Collision counts land in ``graph.metadata`` (``fqn_collisions``, + ``fqn_collisions_same_family``, ``fqn_collisions_cross_family`` and + ``fqn_collision_details``) so agents see them without reading log output. + + Args: + *graphs: Per-language graphs to union, in priority order. + + Returns: + Merged DependencyGraph with relinked edges and collision metadata. + """ + if not graphs: + return DependencyGraph() + + entities: dict[str, Entity] = {} + edges: list[Edge] = [] + packages: dict[str, list[str]] = {} + collision_details: list[dict[str, str]] = [] + same_family_collisions = 0 + cross_family_collisions = 0 + + for graph in graphs: + renamed: dict[str, str] = {} + for fqn, entity in graph.entities.items(): + existing = entities.get(fqn) + if existing is None: + entities[fqn] = entity + continue + if language_family(existing.language) == language_family(entity.language): + if existing.language != entity.language: + same_family_collisions += 1 + collision_details.append( + { + "fqn": fqn, + "kept": existing.language, + "other": entity.language, + "resolution": "kept_first", + } + ) + logger.warning( + "FQN collision within language family at %s (%s vs %s); " + "keeping first", + fqn, + existing.language, + entity.language, + ) + continue + new_fqn = _disambiguate_fqn(fqn, entity.language, entities) + renamed[fqn] = new_fqn + entities[new_fqn] = replace(entity, fqn=new_fqn) + cross_family_collisions += 1 + collision_details.append( + { + "fqn": fqn, + "kept": existing.language, + "other": entity.language, + "resolution": "renamed", + "renamed_to": new_fqn, + } + ) + logger.warning( + "FQN collision across language families at %s (%s vs %s); " + "keeping both, re-keyed the %s entity as %s", + fqn, + existing.language, + entity.language, + entity.language, + new_fqn, + ) + if renamed: + edges.extend( + Edge( + source=renamed.get(edge.source, edge.source), + target=renamed.get(edge.target, edge.target), + relation=edge.relation, + ) + for edge in graph.edges + ) + else: + edges.extend(graph.edges) + for pkg, fqns in graph.packages.items(): + packages.setdefault(pkg, []).extend(renamed.get(f, f) for f in fqns) + + packages = {pkg: list(dict.fromkeys(fqns)) for pkg, fqns in packages.items()} + metadata: dict[str, Any] = { + "fqn_collisions": same_family_collisions + cross_family_collisions, + "fqn_collisions_same_family": same_family_collisions, + "fqn_collisions_cross_family": cross_family_collisions, + } + if collision_details: + metadata["fqn_collision_details"] = collision_details + return relink_edges( + DependencyGraph( + entities=entities, + edges=edges, + packages=packages, + metadata=metadata, + ) + ) diff --git a/src/arcade_agent/serialization.py b/src/arcade_agent/serialization.py index 9e1ea49..01b42ed 100644 --- a/src/arcade_agent/serialization.py +++ b/src/arcade_agent/serialization.py @@ -98,8 +98,12 @@ def load_graph(path: Path) -> DependencyGraph: def graph_to_dict(graph: DependencyGraph) -> dict: - """Convert a DependencyGraph to a JSON-serializable dict.""" - return { + """Convert a DependencyGraph to a JSON-serializable dict. + + ``metadata`` is emitted only when non-empty so single-language parse output + stays byte-identical to previous releases. + """ + data = { "entities": { fqn: { "fqn": e.fqn, @@ -121,6 +125,9 @@ def graph_to_dict(graph: DependencyGraph) -> dict: ], "packages": graph.packages, } + if graph.metadata: + data["metadata"] = graph.metadata + return data def dict_to_graph(data: dict) -> DependencyGraph: @@ -145,7 +152,12 @@ def dict_to_graph(data: dict) -> DependencyGraph: for e in data.get("edges", []) ] packages: dict[str, list[str]] = data.get("packages", {}) - return DependencyGraph(entities=entities, edges=edges, packages=packages) + return DependencyGraph( + entities=entities, + edges=edges, + packages=packages, + metadata=data.get("metadata", {}), + ) def architecture_to_dict(arch: Architecture) -> dict: diff --git a/src/arcade_agent/tools/adapters/mcp.py b/src/arcade_agent/tools/adapters/mcp.py index 52b8d90..2f65b0f 100644 --- a/src/arcade_agent/tools/adapters/mcp.py +++ b/src/arcade_agent/tools/adapters/mcp.py @@ -75,6 +75,15 @@ def _make_summary(obj: Any, label: str) -> dict: summary["num_files"] = len(obj.source_files) if hasattr(obj, "language"): summary["language"] = obj.language + if hasattr(obj, "languages"): + langs = getattr(obj, "languages") + if langs: + summary["languages"] = list(langs) + metadata = getattr(obj, "metadata", None) if hasattr(obj, "num_entities") else None + if isinstance(metadata, dict) and metadata: + # Agents never see log lines; surface parse notes such as polyglot + # FQN-collision counts directly in the summary. + summary["metadata"] = metadata if hasattr(obj, "name") and isinstance(getattr(obj, "name", None), str): summary["name"] = obj.name if hasattr(obj, "version"): @@ -101,6 +110,41 @@ def _apply_budget(data: Any, max_tokens: int | None) -> Any: return enforce_budget(data, max_tokens) +def _resolve_parse_source( + source_path: str, + language: str | None, + languages: list[str] | None, + files: list[str] | None, +) -> tuple[str, str | None, list[str] | None, list[str] | None]: + """Resolve an ingest session into the concrete inputs required by parse. + + MCP clients should not need to discover a temporary clone path or repeat the + language/file selection already made by ``ingest``. Explicit parse arguments + still take precedence when a caller intentionally wants a narrower parse. + """ + if source_path not in _session: + return source_path, language, languages, files + + from arcade_agent.tools.ingest import IngestedRepo + + ingested = _session[source_path]["value"] + if not isinstance(ingested, IngestedRepo): + label = _session[source_path]["label"] + raise ValueError( + f"source_path session {source_path!r} contains {label}, not IngestedRepo" + ) + + if language is None and languages is None: + if ingested.languages: + languages = list(ingested.languages) + elif ingested.language: + language = ingested.language + if files is None: + files = [str(path) for path in ingested.source_files] + + return str(ingested.path), language, languages, files + + def _build_server(): # type: ignore[no-untyped-def] """Build and return the FastMCP server instance. @@ -123,8 +167,13 @@ def _build_server(): # type: ignore[no-untyped-def] "Use 'analyze' for a one-call end-to-end pipeline (offloaded from the event loop), " "or compose individual tools. " "Use session IDs from previous tool outputs as inputs to subsequent tools. " - "For example: call 'parse' to get a session_id, then pass that session_id " - "as dep_graph to 'recover'." + "For example: call 'ingest' and pass its session_id as source_path to " + "'parse', then pass the parse session_id as dep_graph to 'recover'. " + "For polyglot repositories, pass languages such as ['java', 'kotlin'] " + "to ingest; parse inherits that selection from the ingest session. " + "Cross-language edges are only linked within a language family " + "(java+kotlin today); other language pairs are parsed and merged " + "but never linked to each other." ), ) @@ -134,6 +183,7 @@ def _build_server(): # type: ignore[no-untyped-def] def ingest( source: str, language: str | None = None, + languages: list[str] | None = None, work_dir: str | None = None, exclude_tests: bool = True, source_root: str | None = None, @@ -146,7 +196,10 @@ def ingest( Args: source: Git repo URL or local directory path. - language: Override language detection (java, python, c, typescript, go, kotlin). + language: Override language detection (java, python, c, typescript, + go, kotlin, or "multi" for every detected language). + languages: Explicit polyglot language list (e.g. ["java", "kotlin"]). + Mutually exclusive with language. work_dir: Directory to clone into. Uses temp dir if None. exclude_tests: Exclude test/vendor/build directories (default True). source_root: Override source root (e.g. 'src/main/java'). @@ -157,6 +210,7 @@ def ingest( result = _ingest( source=source, language=language, + languages=languages, work_dir=work_dir, exclude_tests=exclude_tests, source_root=source_root, @@ -170,6 +224,7 @@ def ingest( def parse( source_path: str, language: str | None = None, + languages: list[str] | None = None, files: list[str] | None = None, use_cache: bool = True, max_tokens: int | None = None, @@ -179,19 +234,34 @@ def parse( Returns a session_id referencing the parsed DependencyGraph. Pass this session_id as the dep_graph argument to recover, detect_smells, etc. + Cross-language relinking is family-scoped: java+kotlin is the supported + (and validated) pair. Any other combination is parsed and merged into + one graph without edges between the two languages. FQN collisions + across families keep both entities (the later one re-keyed as + "#") and are counted in the summary's metadata. + Args: - source_path: Root directory of the project. - language: Language to parse (java, python, c, typescript, go, kotlin). - Auto-detected if None. + source_path: Root directory of the project, or a session ID returned by + ingest. An ingest session carries its selected files and languages + into this parse call unless explicitly overridden. + language: Language to parse (java, python, c, typescript, go, kotlin), + or "multi" to parse every detected language and relink + cross-language edges. + languages: Explicit polyglot language list (e.g. ["java", "kotlin"]). + Mutually exclusive with language. files: Specific files to parse. Discovers all if None. use_cache: Return cached results when source files haven't changed. max_tokens: Optional token budget for the response. """ from arcade_agent.tools.parse import parse as _parse + source_path, language, languages, files = _resolve_parse_source( + source_path, language, languages, files + ) graph = _parse( source_path=source_path, language=language, + languages=languages, files=files, use_cache=use_cache, ) diff --git a/src/arcade_agent/tools/ingest.py b/src/arcade_agent/tools/ingest.py index 6280cec..0978ca0 100644 --- a/src/arcade_agent/tools/ingest.py +++ b/src/arcade_agent/tools/ingest.py @@ -24,6 +24,7 @@ class IngestedRepo: is_temp: bool = False source_files: list[Path] = field(default_factory=list) language: str | None = None + languages: list[str] = field(default_factory=list) versions: list[str] = field(default_factory=list) def cleanup(self) -> None: @@ -48,20 +49,12 @@ def cleanup(self) -> None: for ext in exts: _EXT_TO_LANG[ext] = lang - -def _detect_language(path: Path) -> str | None: - """Auto-detect the primary language from file extensions.""" - ext_counts: dict[str, int] = {} - for f in path.rglob("*"): - if f.is_file() and f.suffix in _EXT_TO_LANG: - ext_counts[f.suffix] = ext_counts.get(f.suffix, 0) + 1 - - if not ext_counts: - return None - - best_ext = max(ext_counts, key=ext_counts.get) # type: ignore[arg-type] - return _EXT_TO_LANG.get(best_ext) - +# Per-language source roots. Only languages with a parser belong here; generic +# roots (including src/main/scala) are still probed via _SOURCE_ROOTS. +_LANG_PREFERRED_ROOTS: dict[str, str] = { + "java": "src/main/java", + "kotlin": "src/main/kotlin", +} # Well-known source root directories (tried in order) _SOURCE_ROOTS = [ @@ -99,12 +92,40 @@ def _detect_language(path: Path) -> str | None: } +def _detect_language(path: Path) -> str | None: + """Auto-detect the primary language from file extensions.""" + ext_counts: dict[str, int] = {} + for f in path.rglob("*"): + if f.is_file() and f.suffix in _EXT_TO_LANG: + ext_counts[f.suffix] = ext_counts.get(f.suffix, 0) + 1 + + if not ext_counts: + return None + + best_ext = max(ext_counts, key=ext_counts.get) # type: ignore[arg-type] + return _EXT_TO_LANG.get(best_ext) + + +def _detect_languages(path: Path) -> list[str]: + """Detect all languages present under path (sorted).""" + found: set[str] = set() + for f in path.rglob("*"): + if f.is_file() and f.suffix in _EXT_TO_LANG: + found.add(_EXT_TO_LANG[f.suffix]) + return sorted(found) + + def _detect_source_root(path: Path, language: str | None = None) -> Path: """Detect the main source root directory. - Checks for well-known source root patterns (e.g., src/main/java for Maven). - Falls back to the project root. + Prefers a language-specific Maven/Gradle root when *language* is set. + Falls back to well-known roots, then the project root. """ + if language: + preferred = _LANG_PREFERRED_ROOTS.get(language) + if preferred and (path / preferred).is_dir(): + return path / preferred + for candidate in _SOURCE_ROOTS: root = path / candidate if root.is_dir(): @@ -124,7 +145,6 @@ def _should_exclude(file_path: Path, root: Path) -> bool: subpath = "/".join(parts[: i + 1]) if subpath in _EXCLUDE_DIRS: return True - # Also check just the directory name if parts[i] in _EXCLUDE_DIRS: return True return False @@ -160,6 +180,39 @@ def _discover_files( return files +def _validate_known_languages(languages: list[str]) -> list[str]: + """Reject unknown language names before discovery expands to all extensions.""" + unknown = [lang for lang in languages if lang not in _LANG_EXTENSIONS] + if unknown: + available = ", ".join(sorted(_LANG_EXTENSIONS)) + raise ValueError( + f"Unknown language(s): {', '.join(unknown)}. Supported: {available}" + ) + return languages + + +def _resolve_languages( + path: Path, + language: str | None, + languages: list[str] | None, +) -> list[str]: + if language is not None and languages is not None: + raise ValueError("Pass only one of language and languages") + if languages is not None: + if not languages: + raise ValueError("languages must be non-empty") + return _validate_known_languages(list(languages)) + if language == "multi": + detected = _detect_languages(path) + if not detected: + raise ValueError(f"Could not detect languages in {path}") + return detected + if language: + return _validate_known_languages([language]) + primary = _detect_language(path) + return [primary] if primary else [] + + def _detect_version(repo: "Repo") -> str: """Detect the latest version tag from a repo.""" try: @@ -196,6 +249,7 @@ def _repo_name_from_url(url: str) -> str: def ingest( source: str, language: str | None = None, + languages: list[str] | None = None, work_dir: str | None = None, exclude_tests: bool = True, source_root: str | None = None, @@ -204,7 +258,10 @@ def ingest( Args: source: Git repo URL or local directory path. - language: Override language detection (java, python, typescript, c, go, kotlin). + language: Override language detection (java, python, typescript, c, go, + kotlin, or "multi" to ingest every detected language). + languages: Explicit language list for polyglot ingest (e.g. ["java", "kotlin"]). + Mutually exclusive with *language*. work_dir: Directory to clone into. Uses temp dir if None. exclude_tests: Exclude test/vendor/build directories (default: True). source_root: Override source root (e.g., 'src/main/java'). Auto-detected if None. @@ -215,15 +272,21 @@ def ingest( source_path = Path(source) sr = Path(source_root) if source_root else None if source_path.is_dir(): - return _ingest_local(source_path, language, exclude_tests, sr) + return _ingest_local(source_path, language, languages, exclude_tests, sr) return _clone_and_ingest( - source, language, Path(work_dir) if work_dir else None, exclude_tests, sr, + source, + language, + languages, + Path(work_dir) if work_dir else None, + exclude_tests, + sr, ) def _ingest_local( path: Path, language: str | None = None, + languages: list[str] | None = None, exclude_tests: bool = True, source_root: Path | None = None, ) -> IngestedRepo: @@ -240,32 +303,23 @@ def _ingest_local( except Exception: pass - if not language: - language = _detect_language(path) - - # Auto-detect source root if not provided - effective_root = source_root - if effective_root is None and exclude_tests: - detected = _detect_source_root(path, language) - if detected != path: - effective_root = detected - - source_files = _discover_files(path, language, exclude_tests, effective_root) - - return IngestedRepo( - path=effective_root if effective_root else path, + resolved = _resolve_languages(path, language, languages) + return _build_ingested_repo( + project_root=path, name=name, version=version, - is_temp=False, - source_files=source_files, - language=language, versions=versions, + is_temp=False, + languages=resolved, + exclude_tests=exclude_tests, + source_root=source_root, ) def _clone_and_ingest( url: str, language: str | None = None, + languages: list[str] | None = None, work_dir: Path | None = None, exclude_tests: bool = True, source_root: Path | None = None, @@ -289,23 +343,80 @@ def _clone_and_ingest( except GitCommandError: pass - if not language: - language = _detect_language(clone_path) + resolved = _resolve_languages(clone_path, language, languages) + return _build_ingested_repo( + project_root=clone_path, + name=name, + version=version, + versions=versions, + is_temp=True, + languages=resolved, + exclude_tests=exclude_tests, + source_root=source_root, + ) + - effective_root = source_root - if effective_root is None and exclude_tests: - detected = _detect_source_root(clone_path, language) - if detected != clone_path: +def _build_ingested_repo( + *, + project_root: Path, + name: str, + version: str, + versions: list[str], + is_temp: bool, + languages: list[str], + exclude_tests: bool, + source_root: Path | None, +) -> IngestedRepo: + multilang = len(languages) > 1 + + if source_root is not None: + effective_root = source_root + search_root = source_root + result_path = source_root + elif multilang: + # Keep the project root so every language-specific tree stays visible. + effective_root = None + search_root = None + result_path = project_root + elif exclude_tests and languages: + detected = _detect_source_root(project_root, languages[0]) + if detected != project_root: effective_root = detected + search_root = detected + result_path = detected + else: + effective_root = None + search_root = None + result_path = project_root + else: + effective_root = None + search_root = None + result_path = project_root + + source_files: list[Path] = [] + if languages: + for lang in languages: + source_files.extend( + _discover_files(project_root, lang, exclude_tests, search_root) + ) + # Preserve stable order while dropping duplicates across languages. + source_files = list(dict.fromkeys(source_files)) + else: + source_files = _discover_files( + project_root, None, exclude_tests, effective_root + ) - source_files = _discover_files(clone_path, language, exclude_tests, effective_root) + primary = languages[0] if len(languages) == 1 else ( + "multi" if languages else None + ) return IngestedRepo( - path=effective_root if effective_root else clone_path, + path=result_path, name=name, version=version, - is_temp=True, + is_temp=is_temp, source_files=source_files, - language=language, + language=primary, + languages=languages, versions=versions, ) diff --git a/src/arcade_agent/tools/parse.py b/src/arcade_agent/tools/parse.py index f9b3d23..fe0d1f4 100644 --- a/src/arcade_agent/tools/parse.py +++ b/src/arcade_agent/tools/parse.py @@ -3,14 +3,103 @@ import logging from pathlib import Path +import arcade_agent.parsers # noqa: F401 — register language parsers from arcade_agent.cache import cache_key, get_cached_graph, put_cached_graph from arcade_agent.parsers.base import detect_language, get_parser from arcade_agent.parsers.graph import DependencyGraph +from arcade_agent.parsers.multilang import merge_and_relink from arcade_agent.tools.registry import tool logger = logging.getLogger(__name__) +def _cache_language_key( + language: str | None, + languages: list[str] | None, +) -> str | None: + if languages: + return ",".join(sorted(languages)) + return language + + +def _resolve_languages( + root: Path, + language: str | None, + languages: list[str] | None, + file_paths: list[Path] | None, +) -> list[str]: + if language is not None and languages is not None: + raise ValueError("Pass only one of language and languages") + if languages is not None: + if not languages: + raise ValueError("languages must be non-empty") + # Sorted + de-duplicated so ["kotlin", "java"] and ["java", "kotlin"] + # produce the same graph (merge order decides collision winners). + return sorted(dict.fromkeys(languages)) + if language == "multi": + discover = file_paths if file_paths is not None else [ + f for f in root.rglob("*") if f.is_file() + ] + detected_languages = detect_languages_from_files(discover) + if not detected_languages: + raise ValueError(f"Could not detect languages in {root}") + return detected_languages + if language: + return [language] + discover = file_paths if file_paths is not None else [ + f for f in root.rglob("*") if f.is_file() + ] + detected_language = detect_language(discover) + if not detected_language: + raise ValueError(f"Could not detect language in {root}") + return [detected_language] + + +def detect_languages_from_files(files: list[Path]) -> list[str]: + """Return sorted language names present among *files* (by suffix).""" + found: set[str] = set() + for path in files: + ext = path.suffix.lower() + if not ext: + continue + try: + parser = get_parser(ext) + except KeyError: + continue + found.add(parser.language) + return sorted(found) + + +def _files_for_language(files: list[Path], language: str) -> list[Path]: + parser = get_parser(language) + exts = set(parser.file_extensions) + return [f for f in files if f.suffix in exts] + + +def _parse_one( + language: str, + file_paths: list[Path], + root: Path, + use_cache: bool, +) -> DependencyGraph: + parser = get_parser(language) + if not file_paths: + return DependencyGraph() + if use_cache and hasattr(parser, "parse_incremental"): + from arcade_agent.incremental import ExtractCache + return parser.parse_incremental(file_paths, root, ExtractCache(root)) + return parser.parse(file_paths, root) + + +def _discover_files(root: Path, languages: list[str]) -> list[Path]: + file_paths: list[Path] = [] + for language in languages: + parser = get_parser(language) + for ext in parser.file_extensions: + file_paths.extend(sorted(root.rglob(f"*{ext}"))) + return list(dict.fromkeys(file_paths)) + + @tool( name="parse", description=( @@ -21,70 +110,79 @@ def parse( source_path: str, language: str | None = None, + languages: list[str] | None = None, files: list[str] | None = None, use_cache: bool = True, ) -> DependencyGraph: """Parse source code and extract a dependency graph. + Polyglot support (MVP): every requested language is parsed, but + cross-language edges are only linked *within a language family* — currently + just the JVM family (``java`` + ``kotlin``), the one validated pair. Other + languages each form their own family, so a Python and a Java graph are + unioned without inventing edges between them (see + ``arcade_agent.parsers.multilang``). Cross-family FQN collisions are kept + (re-keyed as ``#``) and counted in ``graph.metadata``. + Args: source_path: Root directory of the project. - language: Language to parse (java, python, etc.). Auto-detected if None. - files: Specific files to parse. If None, discovers all files. + language: Language to parse (java, python, etc.), or "multi" to parse + every detected language and merge+relink cross-language edges. + languages: Explicit language list for polyglot parse + (e.g. ["java", "kotlin"]). Sorted internally, so ordering does not + change the result. Mutually exclusive with *language*. + files: Specific files to parse. If None, discovers all files. Files not + matching any resolved language are skipped (with a warning). use_cache: If True, return cached results when source files haven't changed. Returns: - DependencyGraph with entities, edges, and package info. + DependencyGraph with entities, edges, package info and, for polyglot + parses, FQN-collision counts in ``metadata``. """ root = Path(source_path) + provided_files = [Path(f) for f in files] if files else None + resolved = _resolve_languages(root, language, languages, provided_files) + cache_lang = _cache_language_key( + language if language != "multi" else "multi", + resolved if len(resolved) > 1 else None, + ) - # Check cache before doing expensive parsing if use_cache: - key = cache_key(source_path, language, files) + key = cache_key(source_path, cache_lang, files) cached = get_cached_graph(source_path, key) if cached is not None: return cached - if files: - file_paths = [Path(f) for f in files] + if provided_files is not None: + file_paths = provided_files else: - # Discover files - if language: - parser = get_parser(language) - file_paths = [] - for ext in parser.file_extensions: - file_paths.extend(sorted(root.rglob(f"*{ext}"))) - else: - # Try to detect language from files - all_files = list(root.rglob("*")) - source_files = [f for f in all_files if f.is_file()] - detected = detect_language(source_files) - if not detected: - raise ValueError(f"Could not detect language in {source_path}") - language = detected - parser = get_parser(language) - file_paths = [] - for ext in parser.file_extensions: - file_paths.extend(sorted(root.rglob(f"*{ext}"))) - - if not language: - raise ValueError("No language specified and auto-detection failed") + file_paths = _discover_files(root, resolved) - parser = get_parser(language) + per_language = {lang: _files_for_language(file_paths, lang) for lang in resolved} + selected = {f for files_ in per_language.values() for f in files_} + dropped = [f for f in file_paths if f not in selected] + if dropped: + # Same filtering rule for one or many languages: a file whose extension + # no resolved parser claims is not parseable and is reported, not + # silently handed to an arbitrary parser. + logger.warning( + "Skipping %d file(s) not matching languages %s (e.g. %s)", + len(dropped), + ",".join(resolved), + dropped[0], + ) - # Two cache layers: the whole-graph cache above returns instantly when NOTHING - # changed; when some files changed we fall here and parse incrementally — - # re-extracting only the changed files (by content hash) and re-linking. Edges - # are recomputed every link, so the incremental graph is identical to a full - # parse. Parsers that don't support it (or use_cache=False) take the full path. - if use_cache and hasattr(parser, "parse_incremental"): - from arcade_agent.incremental import ExtractCache - graph = parser.parse_incremental(file_paths, root, ExtractCache(root)) + if len(resolved) == 1: + graph = _parse_one(resolved[0], per_language[resolved[0]], root, use_cache) else: - graph = parser.parse(file_paths, root) + graphs = [ + _parse_one(lang, per_language[lang], root, use_cache) for lang in resolved + ] + graphs = [g for g in graphs if g.num_entities or g.num_edges] + graph = merge_and_relink(*graphs) if graphs else DependencyGraph() - # Store in cache for next time if use_cache: - key = cache_key(source_path, language, files) + key = cache_key(source_path, cache_lang, files) put_cached_graph(source_path, key, graph) return graph diff --git a/tests/fixtures/java_kotlin_mixed/com/example/mixed/JavaBaseService.java b/tests/fixtures/java_kotlin_mixed/com/example/mixed/JavaBaseService.java new file mode 100644 index 0000000..e860f85 --- /dev/null +++ b/tests/fixtures/java_kotlin_mixed/com/example/mixed/JavaBaseService.java @@ -0,0 +1,7 @@ +package com.example.mixed; + +public class JavaBaseService { + public String name() { + return "java"; + } +} diff --git a/tests/fixtures/java_kotlin_mixed/com/example/mixed/KotlinService.kt b/tests/fixtures/java_kotlin_mixed/com/example/mixed/KotlinService.kt new file mode 100644 index 0000000..51a1d64 --- /dev/null +++ b/tests/fixtures/java_kotlin_mixed/com/example/mixed/KotlinService.kt @@ -0,0 +1,5 @@ +package com.example.mixed + +class KotlinService : JavaBaseService(), SharedContract { + override fun run() {} +} diff --git a/tests/fixtures/java_kotlin_mixed/com/example/mixed/SharedContract.java b/tests/fixtures/java_kotlin_mixed/com/example/mixed/SharedContract.java new file mode 100644 index 0000000..ff5df8d --- /dev/null +++ b/tests/fixtures/java_kotlin_mixed/com/example/mixed/SharedContract.java @@ -0,0 +1,5 @@ +package com.example.mixed; + +public interface SharedContract { + void run(); +} diff --git a/tests/fixtures/maven_java_kotlin/src/main/java/com/example/JavaGreeter.java b/tests/fixtures/maven_java_kotlin/src/main/java/com/example/JavaGreeter.java new file mode 100644 index 0000000..d44ffe1 --- /dev/null +++ b/tests/fixtures/maven_java_kotlin/src/main/java/com/example/JavaGreeter.java @@ -0,0 +1,7 @@ +package com.example; + +public class JavaGreeter { + public String greet() { + return "hello"; + } +} diff --git a/tests/fixtures/maven_java_kotlin/src/main/kotlin/com/example/KotlinGreeter.kt b/tests/fixtures/maven_java_kotlin/src/main/kotlin/com/example/KotlinGreeter.kt new file mode 100644 index 0000000..bf88cdd --- /dev/null +++ b/tests/fixtures/maven_java_kotlin/src/main/kotlin/com/example/KotlinGreeter.kt @@ -0,0 +1,3 @@ +package com.example + +class KotlinGreeter : JavaGreeter() diff --git a/tests/fixtures/python_java_mixed/app/service.py b/tests/fixtures/python_java_mixed/app/service.py new file mode 100644 index 0000000..74d0700 --- /dev/null +++ b/tests/fixtures/python_java_mixed/app/service.py @@ -0,0 +1,10 @@ +"""Python service that must not be linked to the Java entities.""" + +import com.auth.service + + +class PaymentHandler(Base): # noqa: F821 — unresolvable on purpose (fixture) + """Extends a Base that does not exist in Python land.""" + + def login(self): + return com.auth.service diff --git a/tests/fixtures/python_java_mixed/com/auth/service.py b/tests/fixtures/python_java_mixed/com/auth/service.py new file mode 100644 index 0000000..36666aa --- /dev/null +++ b/tests/fixtures/python_java_mixed/com/auth/service.py @@ -0,0 +1,5 @@ +"""Python module whose FQN coincides with the Java com.auth.service class.""" + + +def login(user): + return user diff --git a/tests/fixtures/python_java_mixed/src/main/java/com/auth/service.java b/tests/fixtures/python_java_mixed/src/main/java/com/auth/service.java new file mode 100644 index 0000000..df2d43b --- /dev/null +++ b/tests/fixtures/python_java_mixed/src/main/java/com/auth/service.java @@ -0,0 +1,5 @@ +package com.auth; + +public class service { + public void login() {} +} diff --git a/tests/fixtures/python_java_mixed/src/main/java/com/example/core/Base.java b/tests/fixtures/python_java_mixed/src/main/java/com/example/core/Base.java new file mode 100644 index 0000000..1601401 --- /dev/null +++ b/tests/fixtures/python_java_mixed/src/main/java/com/example/core/Base.java @@ -0,0 +1,5 @@ +package com.example.core; + +public class Base { + public void handle() {} +} diff --git a/tests/test_mcp_multilang_e2e.py b/tests/test_mcp_multilang_e2e.py new file mode 100644 index 0000000..d4487ff --- /dev/null +++ b/tests/test_mcp_multilang_e2e.py @@ -0,0 +1,138 @@ +"""MCP end-to-end tests for polyglot Java+Kotlin ingest/parse/recover.""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest + +pytest.importorskip("tree_sitter_kotlin") +pytest.importorskip("tree_sitter_java") + +_FIXTURES = Path(__file__).parent / "fixtures" +_MIXED = str(_FIXTURES / "java_kotlin_mixed") +_MAVEN = str(_FIXTURES / "maven_java_kotlin") + + +def _call(server, tool: str, args: dict) -> dict: + """Invoke a FastMCP tool (async) and return the parsed JSON response.""" + + async def _run(): + result = await server.call_tool(tool, args) + if isinstance(result, tuple): + content_list, _is_error = result + text = content_list[0].text if content_list else "" + elif isinstance(result, list): + text = result[0].text + elif isinstance(result, str): + text = result + else: + raise TypeError(f"Unexpected call_tool result type: {type(result)}") + return json.loads(text) + + return asyncio.run(_run()) + + +@pytest.fixture(scope="module") +def server(): + """Return the FastMCP server singleton (requires mcp extra).""" + pytest.importorskip("mcp", reason="mcp extra not installed") + from arcade_agent.tools.adapters.mcp import _session, get_server + + _session.clear() + return get_server() + + +class TestMcpMultilangE2E: + """MCP ingest/parse/recover against Java+Kotlin fixtures.""" + + def test_parse_languages_java_kotlin_then_recover(self, server): + parse_result = _call( + server, + "parse", + { + "source_path": _MIXED, + "languages": ["java", "kotlin"], + "use_cache": False, + }, + ) + assert parse_result.get("type") == "DependencyGraph" + assert parse_result["num_entities"] >= 2 + assert "session_id" in parse_result + + full = _call(server, "get_full_result", {"session_id": parse_result["session_id"]}) + entities = full["data"]["entities"] + languages = {e["language"] for e in entities.values()} + assert "java" in languages + assert "kotlin" in languages + + edges = full["data"]["edges"] + assert any( + edge["relation"] in {"extends", "implements", "import"} + and entities[edge["source"]]["language"] != entities[edge["target"]]["language"] + for edge in edges + if edge["source"] in entities and edge["target"] in entities + ) + + recover_result = _call( + server, + "recover", + {"dep_graph": parse_result["session_id"], "algorithm": "pkg"}, + ) + assert recover_result.get("type") == "Architecture" + assert recover_result["num_components"] > 0 + + def test_parse_language_multi_on_mixed_fixture(self, server): + result = _call( + server, + "parse", + {"source_path": _MIXED, "language": "multi", "use_cache": False}, + ) + assert result["num_entities"] >= 2 + full = _call(server, "get_full_result", {"session_id": result["session_id"]}) + languages = {e["language"] for e in full["data"]["entities"].values()} + assert languages == {"java", "kotlin"} + + def test_ingest_session_chains_languages_and_files_into_parse(self, server): + ingest_result = _call( + server, + "ingest", + {"source": _MAVEN, "languages": ["java", "kotlin"]}, + ) + assert ingest_result.get("type") == "IngestedRepo" + assert sorted(ingest_result.get("languages", [])) == ["java", "kotlin"] + assert ingest_result["num_files"] >= 2 + + parse_result = _call( + server, + "parse", + { + "source_path": ingest_result["session_id"], + "use_cache": False, + }, + ) + assert parse_result["num_entities"] >= 2 + full = _call(server, "get_full_result", {"session_id": parse_result["session_id"]}) + entity_fqns = set(full["data"]["entities"]) + assert "com.example.JavaGreeter" in entity_fqns + assert "com.example.KotlinGreeter" in entity_fqns + + def test_parse_rejects_non_ingest_source_session(self, server): + from mcp.server.fastmcp.exceptions import ToolError + + parsed = _call( + server, + "parse", + {"source_path": _MIXED, "language": "multi", "use_cache": False}, + ) + + async def _run(): + return await server.call_tool( + "parse", + {"source_path": parsed["session_id"], "use_cache": False}, + ) + + with pytest.raises(ToolError, match="not IngestedRepo"): + asyncio.run(_run()) diff --git a/tests/test_parsers/test_graph_merge.py b/tests/test_parsers/test_graph_merge.py new file mode 100644 index 0000000..d9a071e --- /dev/null +++ b/tests/test_parsers/test_graph_merge.py @@ -0,0 +1,204 @@ +"""Tests for DependencyGraph.merge and cross-language relink (roadmap #18).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +pytest.importorskip("tree_sitter_kotlin") +pytest.importorskip("tree_sitter_java") + +from arcade_agent.parsers.graph import DependencyGraph, Edge, Entity # noqa: E402 +from arcade_agent.parsers.java import JavaParser # noqa: E402 +from arcade_agent.parsers.kotlin import KotlinParser # noqa: E402 +from arcade_agent.parsers.multilang import merge_and_relink, relink_edges # noqa: E402 + + +def test_merge_unions_entities_and_edges(): + left = DependencyGraph( + entities={ + "a.A": Entity( + fqn="a.A", + name="A", + package="a", + file_path="A.java", + kind="class", + language="java", + ) + }, + edges=[Edge(source="a.A", target="a.B", relation="import")], + packages={"a": ["a.A"]}, + ) + right = DependencyGraph( + entities={ + "a.B": Entity( + fqn="a.B", + name="B", + package="a", + file_path="B.kt", + kind="class", + language="kotlin", + ) + }, + edges=[], + packages={"a": ["a.B"]}, + ) + + merged = left.merge(right) + assert set(merged.entities) == {"a.A", "a.B"} + assert merged.num_edges == 1 + assert sorted(merged.packages["a"]) == ["a.A", "a.B"] + + +def test_naive_merge_misses_cross_language_extends(fixtures_dir: Path): + root = fixtures_dir / "java_kotlin_mixed" + java_files = sorted(root.rglob("*.java")) + kotlin_files = sorted(root.rglob("*.kt")) + + java_graph = JavaParser().parse(java_files, root) + kotlin_graph = KotlinParser().parse(kotlin_files, root) + naive = java_graph.merge(kotlin_graph) + + edge_tuples = set(naive.to_edge_tuples()) + assert ( + "com.example.mixed.KotlinService", + "com.example.mixed.JavaBaseService", + "extends", + ) not in edge_tuples + + +def test_relink_adds_cross_language_extends_and_implements(fixtures_dir: Path): + root = fixtures_dir / "java_kotlin_mixed" + java_files = sorted(root.rglob("*.java")) + kotlin_files = sorted(root.rglob("*.kt")) + + java_graph = JavaParser().parse(java_files, root) + kotlin_graph = KotlinParser().parse(kotlin_files, root) + linked = merge_and_relink(java_graph, kotlin_graph) + + edge_tuples = set(linked.to_edge_tuples()) + assert ( + "com.example.mixed.KotlinService", + "com.example.mixed.JavaBaseService", + "extends", + ) in edge_tuples + assert ( + "com.example.mixed.KotlinService", + "com.example.mixed.SharedContract", + "implements", + ) in edge_tuples + + +def test_relink_is_idempotent(fixtures_dir: Path): + root = fixtures_dir / "java_kotlin_mixed" + java_files = sorted(root.rglob("*.java")) + kotlin_files = sorted(root.rglob("*.kt")) + linked = merge_and_relink( + JavaParser().parse(java_files, root), + KotlinParser().parse(kotlin_files, root), + ) + again = relink_edges(linked) + assert set(again.to_edge_tuples()) == set(linked.to_edge_tuples()) + + +def test_merge_and_relink_keeps_first_on_cross_language_fqn_collision(): + left = DependencyGraph( + entities={ + "a.Shared": Entity( + fqn="a.Shared", + name="Shared", + package="a", + file_path="Shared.java", + kind="class", + language="java", + ) + }, + packages={"a": ["a.Shared"]}, + ) + right = DependencyGraph( + entities={ + "a.Shared": Entity( + fqn="a.Shared", + name="Shared", + package="a", + file_path="Shared.kt", + kind="class", + language="kotlin", + ) + }, + packages={"a": ["a.Shared"]}, + ) + + merged = merge_and_relink(left, right) + assert merged.entities["a.Shared"].language == "java" + assert merged.entities["a.Shared"].file_path == "Shared.java" + + +def test_relink_does_not_resolve_qualified_external_name_to_local_leaf(): + graph = DependencyGraph( + entities={ + "app.Consumer": Entity( + fqn="app.Consumer", + name="Consumer", + package="app", + file_path="Consumer.kt", + kind="class", + language="kotlin", + superclass="external.Base", + imports=["external.Contract"], + ), + "app.Base": Entity( + fqn="app.Base", + name="Base", + package="app", + file_path="Base.java", + kind="class", + language="java", + ), + "app.Contract": Entity( + fqn="app.Contract", + name="Contract", + package="app", + file_path="Contract.java", + kind="interface", + language="java", + ), + } + ) + + assert relink_edges(graph).edges == [] + + +def test_relink_does_not_guess_when_simple_name_is_ambiguous(): + graph = DependencyGraph( + entities={ + "consumer.Child": Entity( + fqn="consumer.Child", + name="Child", + package="consumer", + file_path="Child.kt", + kind="class", + language="kotlin", + superclass="Base", + ), + "one.Base": Entity( + fqn="one.Base", + name="Base", + package="one", + file_path="Base.java", + kind="class", + language="java", + ), + "two.Base": Entity( + fqn="two.Base", + name="Base", + package="two", + file_path="Base.kt", + kind="class", + language="kotlin", + ), + } + ) + + assert relink_edges(graph).edges == [] diff --git a/tests/test_parsers/test_multilang_family_scoping.py b/tests/test_parsers/test_multilang_family_scoping.py new file mode 100644 index 0000000..4cfc890 --- /dev/null +++ b/tests/test_parsers/test_multilang_family_scoping.py @@ -0,0 +1,272 @@ +"""Language-family scoping of merge+relink (review findings 1 and 2). + +Before family scoping, three resolution paths in ``multilang`` were +language-blind and fabricated cross-language edges on a Python+Java fixture: + +- a Python ``class PaymentHandler(Base)`` with no local ``Base`` linked to the + globally unique Java ``com.example.core.Base`` via the leaf fallback; +- a Python ``import com.auth.service`` (its own module) linked to the Java class + with the coinciding FQN; +- the same coincidence at method level silently dropped one entity on merge. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from arcade_agent.parsers.graph import DependencyGraph, Edge, Entity +from arcade_agent.parsers.multilang import ( + language_family, + merge_and_relink, + relink_edges, +) +from arcade_agent.serialization import graph_to_dict +from arcade_agent.tools.parse import parse + + +def _entity(fqn: str, language: str, **kwargs) -> Entity: + name = fqn.split(".")[-1] + package = fqn.rsplit(".", 1)[0] if "." in fqn else "" + return Entity( + fqn=fqn, + name=name, + package=package, + file_path=f"{fqn}.src", + kind=kwargs.pop("kind", "class"), + language=language, + **kwargs, + ) + + +def _cross_family_edges(graph: DependencyGraph) -> list[tuple[str, str, str]]: + bad = [] + for edge in graph.edges: + source = graph.entities.get(edge.source) + target = graph.entities.get(edge.target) + if source is None or target is None: + continue + if language_family(source.language) != language_family(target.language): + bad.append((edge.source, edge.target, edge.relation)) + return bad + + +def test_language_family_groups_jvm_only(): + assert language_family("java") == language_family("kotlin") == "jvm" + assert language_family("python") == "python" + assert language_family("go") != language_family("typescript") + assert language_family(None) == "unknown" + + +def test_unique_leaf_fallback_does_not_cross_family(): + """Python subclass of an unresolved Base must not link to a Java Base.""" + graph = DependencyGraph( + entities={ + "app.service.PaymentHandler": _entity( + "app.service.PaymentHandler", "python", superclass="Base" + ), + "com.example.core.Base": _entity("com.example.core.Base", "java"), + } + ) + + linked = relink_edges(graph) + assert linked.to_edge_tuples() == [] + assert _cross_family_edges(linked) == [] + + +def test_unique_leaf_fallback_still_works_inside_a_family(): + """The same fallback remains available for Kotlin -> Java (the MVP pair).""" + graph = DependencyGraph( + entities={ + "app.KotlinChild": _entity("app.KotlinChild", "kotlin", superclass="Base"), + "com.example.core.Base": _entity("com.example.core.Base", "java"), + } + ) + + assert relink_edges(graph).to_edge_tuples() == [ + ("app.KotlinChild", "com.example.core.Base", "extends") + ] + + +def test_leaf_import_fallback_does_not_cross_family(): + graph = DependencyGraph( + entities={ + "app.service.Handler": _entity( + "app.service.Handler", "python", imports=["Base"] + ), + "com.example.core.Base": _entity("com.example.core.Base", "java"), + } + ) + + assert relink_edges(graph).to_edge_tuples() == [] + + +def test_exact_fqn_import_match_does_not_cross_family(): + """Python `import com.auth.service` must not bind to a Java class.""" + graph = DependencyGraph( + entities={ + "app.service.PaymentHandler": _entity( + "app.service.PaymentHandler", "python", imports=["com.auth.service"] + ), + "com.auth.service": _entity("com.auth.service", "java"), + } + ) + + assert relink_edges(graph).to_edge_tuples() == [] + + +def test_exact_fqn_import_match_still_links_inside_a_family(): + graph = DependencyGraph( + entities={ + "app.KotlinCaller": _entity( + "app.KotlinCaller", "kotlin", imports=["com.auth.Service"] + ), + "com.auth.Service": _entity("com.auth.Service", "java"), + } + ) + + assert relink_edges(graph).to_edge_tuples() == [ + ("app.KotlinCaller", "com.auth.Service", "import") + ] + + +def test_same_package_fallback_does_not_cross_family(): + graph = DependencyGraph( + entities={ + "com.auth.PyHandler": _entity( + "com.auth.PyHandler", "python", superclass="Service" + ), + "com.auth.Service": _entity("com.auth.Service", "java"), + } + ) + + assert relink_edges(graph).to_edge_tuples() == [] + + +def test_cross_family_fqn_collision_keeps_both_entities(): + java = DependencyGraph( + entities={"com.auth.service.login": _entity("com.auth.service.login", "java")}, + packages={"com.auth.service": ["com.auth.service.login"]}, + ) + python = DependencyGraph( + entities={ + "com.auth.service.login": _entity("com.auth.service.login", "python") + }, + edges=[ + Edge( + source="com.auth.service.login", + target="com.auth.service.login", + relation="calls", + ) + ], + packages={"com.auth.service": ["com.auth.service.login"]}, + ) + + merged = merge_and_relink(java, python) + + assert merged.entities["com.auth.service.login"].language == "java" + renamed = merged.entities["com.auth.service.login#python"] + assert renamed.language == "python" + assert renamed.fqn == "com.auth.service.login#python" + assert merged.metadata["fqn_collisions"] == 1 + assert merged.metadata["fqn_collisions_cross_family"] == 1 + assert merged.metadata["fqn_collisions_same_family"] == 0 + assert merged.metadata["fqn_collision_details"] == [ + { + "fqn": "com.auth.service.login", + "kept": "java", + "other": "python", + "resolution": "renamed", + "renamed_to": "com.auth.service.login#python", + } + ] + # The Python graph's own edges follow the renamed entity. + assert ( + "com.auth.service.login#python", + "com.auth.service.login#python", + "calls", + ) in merged.to_edge_tuples() + assert sorted(merged.packages["com.auth.service"]) == [ + "com.auth.service.login", + "com.auth.service.login#python", + ] + + +def test_same_family_fqn_collision_keeps_first_and_is_counted(): + java = DependencyGraph( + entities={"a.Shared": _entity("a.Shared", "java")}, + packages={"a": ["a.Shared"]}, + ) + kotlin = DependencyGraph( + entities={"a.Shared": _entity("a.Shared", "kotlin")}, + packages={"a": ["a.Shared"]}, + ) + + merged = merge_and_relink(java, kotlin) + + assert merged.entities["a.Shared"].language == "java" + assert merged.metadata["fqn_collisions"] == 1 + assert merged.metadata["fqn_collisions_same_family"] == 1 + assert merged.metadata["fqn_collisions_cross_family"] == 0 + + +def test_merge_reports_zero_collisions_when_there_are_none(): + merged = merge_and_relink( + DependencyGraph(entities={"a.A": _entity("a.A", "java")}), + DependencyGraph(entities={"b.B": _entity("b.B", "kotlin")}), + ) + assert merged.metadata["fqn_collisions"] == 0 + assert "fqn_collision_details" not in merged.metadata + + +def test_polyglot_fixture_has_no_fabricated_cross_language_edges(fixtures_dir: Path): + """End-to-end reproduction of the review's Python+Java fabrication.""" + root = fixtures_dir / "python_java_mixed" + graph = parse(str(root), languages=["java", "python"], use_cache=False) + + assert _cross_family_edges(graph) == [] + tuples = set(graph.to_edge_tuples()) + assert ( + "app.service.PaymentHandler", + "com.example.core.Base", + "extends", + ) not in tuples + assert ("app.service.PaymentHandler", "com.auth.service", "import") not in tuples + # No entity is silently dropped by the com.auth.service.login coincidence. + assert "com.auth.service.login" in graph.entities + assert "com.auth.service.login#python" in graph.entities + assert graph.metadata["fqn_collisions_cross_family"] == 1 + + +def test_language_order_does_not_change_the_result(fixtures_dir: Path): + root = fixtures_dir / "python_java_mixed" + a = parse(str(root), languages=["java", "python"], use_cache=False) + b = parse(str(root), languages=["python", "java"], use_cache=False) + assert graph_to_dict(a) == graph_to_dict(b) + + +def test_single_language_parse_carries_no_metadata(fixtures_dir: Path): + """Single-language output must stay byte-identical: no metadata key.""" + root = fixtures_dir / "python_java_mixed" + graph = parse(str(root), language="python", use_cache=False) + assert graph.metadata == {} + assert "metadata" not in graph_to_dict(graph) + + +def test_single_language_parse_matches_direct_parser_output(fixtures_dir: Path): + from arcade_agent.parsers.java import JavaParser + + root = fixtures_dir / "python_java_mixed" + files = sorted(root.rglob("*.java")) + direct = JavaParser().parse(files, root) + viaparse = parse(str(root), language="java", use_cache=False) + assert graph_to_dict(viaparse) == graph_to_dict(direct) + + +@pytest.mark.parametrize("languages", [["java", "java"], ["java"]]) +def test_duplicate_language_list_is_deduplicated(fixtures_dir: Path, languages): + root = fixtures_dir / "python_java_mixed" + graph = parse(str(root), languages=languages, use_cache=False) + assert graph.metadata == {} + assert all(e.language == "java" for e in graph.entities.values()) diff --git a/tests/test_tools/test_ingest_multilang.py b/tests/test_tools/test_ingest_multilang.py new file mode 100644 index 0000000..b3decf4 --- /dev/null +++ b/tests/test_tools/test_ingest_multilang.py @@ -0,0 +1,56 @@ +"""Multi-language ingest discovery (roadmap #18).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from arcade_agent.tools.ingest import ingest + + +def test_ingest_languages_discovers_both_maven_roots(fixtures_dir: Path): + root = fixtures_dir / "maven_java_kotlin" + repo = ingest(str(root), languages=["java", "kotlin"]) + + suffixes = {p.suffix for p in repo.source_files} + assert ".java" in suffixes + assert ".kt" in suffixes + assert sorted(repo.languages) == ["java", "kotlin"] + # Project root kept so both Maven source trees remain visible. + assert repo.path.resolve() == root.resolve() + + +def test_ingest_language_multi_finds_java_and_kotlin(fixtures_dir: Path): + root = fixtures_dir / "maven_java_kotlin" + repo = ingest(str(root), language="multi") + assert "java" in repo.languages + assert "kotlin" in repo.languages + assert any(p.name == "JavaGreeter.java" for p in repo.source_files) + assert any(p.name == "KotlinGreeter.kt" for p in repo.source_files) + + +def test_ingest_single_language_still_narrows_to_matching_root(fixtures_dir: Path): + root = fixtures_dir / "maven_java_kotlin" + java_repo = ingest(str(root), language="java") + assert all(p.suffix == ".java" for p in java_repo.source_files) + assert java_repo.language == "java" + assert java_repo.languages == ["java"] + + +def test_ingest_rejects_language_and_languages_together(fixtures_dir: Path): + root = fixtures_dir / "maven_java_kotlin" + with pytest.raises(ValueError, match="language and languages"): + ingest(str(root), language="java", languages=["kotlin"]) + + +def test_ingest_rejects_unknown_languages(fixtures_dir: Path): + root = fixtures_dir / "maven_java_kotlin" + with pytest.raises(ValueError, match="Unknown language"): + ingest(str(root), languages=["java", "cobol"]) + + +def test_ingest_rejects_unknown_single_language(fixtures_dir: Path): + root = fixtures_dir / "maven_java_kotlin" + with pytest.raises(ValueError, match="Unknown language"): + ingest(str(root), language="cobol") diff --git a/tests/test_tools/test_parse_multilang.py b/tests/test_tools/test_parse_multilang.py new file mode 100644 index 0000000..eb5b838 --- /dev/null +++ b/tests/test_tools/test_parse_multilang.py @@ -0,0 +1,91 @@ +"""End-to-end multi-language parse (roadmap #18).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +pytest.importorskip("tree_sitter_kotlin") +pytest.importorskip("tree_sitter_java") + +from arcade_agent.tools.parse import parse # noqa: E402 + + +def test_parse_languages_java_kotlin_merges_and_relinks(fixtures_dir: Path): + root = fixtures_dir / "java_kotlin_mixed" + graph = parse(str(root), languages=["java", "kotlin"], use_cache=False) + + assert "com.example.mixed.JavaBaseService" in graph.entities + assert "com.example.mixed.KotlinService" in graph.entities + assert graph.entities["com.example.mixed.JavaBaseService"].language == "java" + assert graph.entities["com.example.mixed.KotlinService"].language == "kotlin" + + edge_tuples = set(graph.to_edge_tuples()) + assert ( + "com.example.mixed.KotlinService", + "com.example.mixed.JavaBaseService", + "extends", + ) in edge_tuples + assert ( + "com.example.mixed.KotlinService", + "com.example.mixed.SharedContract", + "implements", + ) in edge_tuples + + +def test_parse_language_multi_auto_detects_present_languages(fixtures_dir: Path): + root = fixtures_dir / "java_kotlin_mixed" + graph = parse(str(root), language="multi", use_cache=False) + + languages = {e.language for e in graph.entities.values()} + assert languages == {"java", "kotlin"} + assert any( + e.relation == "extends" + and e.source.endswith("KotlinService") + and e.target.endswith("JavaBaseService") + for e in graph.edges + ) + + +def test_parse_single_language_unchanged(fixtures_dir: Path): + root = fixtures_dir / "java_kotlin_mixed" + java_only = parse(str(root), language="java", use_cache=False) + assert all(e.language == "java" for e in java_only.entities.values()) + assert "com.example.mixed.KotlinService" not in java_only.entities + + +def test_parse_rejects_language_and_languages_together(fixtures_dir: Path): + root = fixtures_dir / "java_kotlin_mixed" + with pytest.raises(ValueError, match="language and languages"): + parse(str(root), language="java", languages=["kotlin"], use_cache=False) + + +def test_detect_languages_from_files_uses_suffix_without_existing_file(): + """Relative/missing paths still detect languages from suffixes.""" + from arcade_agent.tools.parse import detect_languages_from_files + + files = [ + Path("does/not/exist/Foo.java"), + Path("relative/Bar.kt"), + Path("no_suffix"), + Path("src/main/kotlin"), + ] + assert detect_languages_from_files(files) == ["java", "kotlin"] + + +def test_parse_multi_with_relative_file_list_detects_languages(fixtures_dir: Path): + """language='multi' + files=[...] must not require paths to exist on disk.""" + from arcade_agent.tools.parse import _resolve_languages + + root = fixtures_dir / "java_kotlin_mixed" + resolved = _resolve_languages( + root, + language="multi", + languages=None, + file_paths=[ + Path("src/main/java/com/example/mixed/JavaBaseService.java"), + Path("src/main/kotlin/com/example/mixed/KotlinService.kt"), + ], + ) + assert resolved == ["java", "kotlin"] diff --git a/tests/test_tools/test_pipeline_multilang_e2e.py b/tests/test_tools/test_pipeline_multilang_e2e.py new file mode 100644 index 0000000..63f6691 --- /dev/null +++ b/tests/test_tools/test_pipeline_multilang_e2e.py @@ -0,0 +1,74 @@ +"""Full ingest → parse → recover E2E for Java+Kotlin polyglot projects.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +pytest.importorskip("tree_sitter_kotlin") +pytest.importorskip("tree_sitter_java") + +from arcade_agent.tools.ingest import ingest # noqa: E402 +from arcade_agent.tools.parse import parse # noqa: E402 +from arcade_agent.tools.recover import recover # noqa: E402 + +_CROSS_LANG_RELATIONS = frozenset({"extends", "implements", "import"}) + + +def _assert_java_kotlin_graph(graph) -> None: + languages = {e.language for e in graph.entities.values()} + assert "java" in languages + assert "kotlin" in languages + assert any( + e.relation in _CROSS_LANG_RELATIONS + and graph.entities[e.source].language != graph.entities[e.target].language + for e in graph.edges + if e.source in graph.entities and e.target in graph.entities + ) + + +def test_pipeline_java_kotlin_mixed_ingest_parse_recover(fixtures_dir: Path): + root = fixtures_dir / "java_kotlin_mixed" + repo = ingest(str(root), languages=["java", "kotlin"]) + assert sorted(repo.languages) == ["java", "kotlin"] + assert any(p.suffix == ".java" for p in repo.source_files) + assert any(p.suffix == ".kt" for p in repo.source_files) + + graph = parse( + str(repo.path), + languages=repo.languages, + files=[str(f) for f in repo.source_files], + use_cache=False, + ) + _assert_java_kotlin_graph(graph) + assert "com.example.mixed.JavaBaseService" in graph.entities + assert "com.example.mixed.KotlinService" in graph.entities + + arch = recover(graph, algorithm="pkg") + assert len(arch.components) > 0 + assert any(c.entities for c in arch.components) + + +def test_pipeline_maven_java_kotlin_ingest_parse_recover(fixtures_dir: Path): + root = fixtures_dir / "maven_java_kotlin" + repo = ingest(str(root), languages=["java", "kotlin"]) + assert sorted(repo.languages) == ["java", "kotlin"] + + graph = parse( + str(repo.path), + languages=repo.languages, + files=[str(f) for f in repo.source_files], + use_cache=False, + ) + _assert_java_kotlin_graph(graph) + assert "com.example.JavaGreeter" in graph.entities + assert "com.example.KotlinGreeter" in graph.entities + assert ( + "com.example.KotlinGreeter", + "com.example.JavaGreeter", + "extends", + ) in set(graph.to_edge_tuples()) + + arch = recover(graph, algorithm="pkg") + assert len(arch.components) > 0 diff --git a/tests/test_tools/test_self_analysis_multilang.py b/tests/test_tools/test_self_analysis_multilang.py new file mode 100644 index 0000000..1b169eb --- /dev/null +++ b/tests/test_tools/test_self_analysis_multilang.py @@ -0,0 +1,48 @@ +"""Thin CLI E2E for arcade-self-analysis --languages java,kotlin.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +pytest.importorskip("tree_sitter_kotlin") +pytest.importorskip("tree_sitter_java") + +from scripts.run_self_analysis import main as run_self_analysis_main # noqa: E402 + + +def test_self_analysis_languages_java_kotlin_on_mixed_fixture( + fixtures_dir: Path, tmp_path, monkeypatch +): + root = fixtures_dir / "java_kotlin_mixed" + output_json = tmp_path / "results.json" + output_html = tmp_path / "report.html" + monkeypatch.setattr( + sys, + "argv", + [ + "run_self_analysis.py", + "--source", + str(root), + "--languages", + "java,kotlin", + "--algorithm", + "pkg", + "--output-json", + str(output_json), + "--output-html", + str(output_html), + ], + ) + + run_self_analysis_main() + + payload = json.loads(output_json.read_text()) + assert output_html.exists() + assert sorted(payload["languages"]) == ["java", "kotlin"] + assert payload["num_entities"] >= 2 + assert payload["num_components"] > 0 + assert payload["num_edges"] >= 1