To make this script a universally effective tool—an "Architectural Linter" for any Python codebase—we must strip away the implicit assumptions about project structure, naming conventions, and "noise" tolerance.

Here is how to generalize the script into a robust, repo-agnostic tool.

### 1. Decouple "File Location" from "Import Path"

**The Specialization:** The current script assumes that if a file is at `path/to/file.py`, its module name is `path.to.file`. This fails in modern Python projects using `src/` layouts, namespace packages, or custom `PYTHONPATH` setups.
**The Generalization:** Add a `--root` argument (defaulting to the current directory) to establish the "Anchor of Truth" for imports. All module resolution should be calculated relative to this root, not the file system absolute path.

### 2. Parameterize the "Noise" Filter

**The Specialization:** The script currently analyzes *everything*. In a real project, you don't want `matplotlib` calls, `logging` calls, or `pytest` fixtures generating "bundle candidates."
**The Generalization:** Implement an **Ignore List** and a **Scope Filter**.

* **External Libs:** By default, ignore any import that doesn't start with the current project's top-level package name.
* **Banned Names:** Allow a config (e.g., `--ignore-params self,cls,logger,*args,**kwargs`) to prevent infrastructure plumbing from polluting business logic bundles.

### 3. Abstract the "Naming Strategy"

**The Specialization:** The `ProtocolSynthesizer` uses a specific heuristic (frequency of words in function names) to name the generated classes. This might work for `process_order` but fails for `handle_request`.
**The Generalization:** Use **LLM-Ready Stubs** or **Generic Fallbacks**.

* Instead of guessing `OrderContext`, default to safer, structural names like `Bundle_User_Account` (based on fields) if a high-confidence verb cannot be found.
* Better yet, make the output "LLM-ready": generate the code with `class TODO_Name_Me:` and a docstring containing the context, allowing an AI (or human) to finalize the naming.

### 4. Support "Wildcard" Forwarding (Optional/Strictness)

**The Specialization:** The script requires explicit parameter names. Many generic frameworks use `*args, **kwargs` wrappers.
**The Generalization:** Add a `--strictness` flag.

* `--strictness high` (Default): Only explicit name matches.
* `--strictness low`: Assume `**kwargs` passes *all* unmatched parameters to the callee. This increases recall but adds noise.

---

### The Generalized "Architectural Linter"

Here is the refined code. I have updated the `ImportVisitor` to handle project roots correctly and added filtering logic to `analyze_repo`.

#### Key Changes in this Version:

1. **`--project-root`**: Correctly calculates module names regardless of where you run the script.
2. **`--exclude`**: Filters out tests, migrations, and venv folders.
3. **`External Filter`**: The symbol table now marks imports as "External" or "Internal," so we don't try to refactor `json.dump`.

```python
#!/usr/bin/env python3
import argparse
import ast
import os
import sys
from collections import defaultdict, Counter
from dataclasses import dataclass, field
from pathlib import Path

# ==========================================
# PART 1: ROBUST CONFIGURATION
# ==========================================

@dataclass
class Config:
    project_root: Path
    exclude_dirs: set[str] = field(default_factory=lambda: {".git", ".venv", "venv", "__pycache__", "tests", "migrations", "node_modules"})
    ignore_params: set[str] = field(default_factory=lambda: {"self", "cls", "args", "kwargs", "_", "logger"})

    def is_ignored_path(self, path: Path) -> bool:
        parts = path.parts
        return any(ex in parts for ex in self.exclude_dirs)

# ==========================================
# PART 2: GENERALIZED SYMBOL TABLE
# ==========================================

@dataclass
class SymbolTable:
    # Map: (module_name, local_name) -> fully_qualified_name
    imports: dict[tuple[str, str], str] = field(default_factory=dict)
    # Set of known internal modules (to distinguish from 3rd party libs)
    internal_modules: set[str] = field(default_factory=set)

    def resolve(self, current_module: str, name: str) -> str | None:
        """
        Returns the FQN if it's an internal symbol we can analyze.
        Returns None if it's likely external (stdlib/3rd party).
        """
        # 1. Explicit import?
        if (current_module, name) in self.imports:
            fqn = self.imports[(current_module, name)]
            # Only return if it points to an internal module
            root_pkg = fqn.split(".")[0]
            if any(m.startswith(root_pkg) for m in self.internal_modules):
                return fqn
            return None # External dependency
        
        # 2. Local definition?
        fqn = f"{current_module}.{name}"
        # We assume local definitions are internal
        return fqn

class ImportVisitor(ast.NodeVisitor):
    def __init__(self, module_name: str, table: SymbolTable):
        self.module = module_name
        self.table = table
        self.table.internal_modules.add(module_name)

    def visit_Import(self, node: ast.Import):
        for alias in node.names:
            local = alias.asname or alias.name
            self.table.imports[(self.module, local)] = alias.name

    def visit_ImportFrom(self, node: ast.ImportFrom):
        if not node.module and node.level == 0: return
        
        # Robust relative import resolution
        if node.level > 0:
            parts = self.module.split(".")
            if node.level > len(parts): return # Anchor escape
            base = parts[:-node.level]
            if node.module: base.append(node.module)
            source = ".".join(base)
        else:
            source = node.module

        for alias in node.names:
            if alias.name == "*": continue
            local = alias.asname or alias.name
            fqn = f"{source}.{alias.name}"
            self.table.imports[(self.module, local)] = fqn

# ==========================================
# PART 3: ANALYSIS (UNCHANGED CORE LOGIC)
# ==========================================

# ... [Include ParamUse, CallArgs, FunctionInfo, FunctionVisitor classes from v3 here] ...
# ... [Include _analyze_function logic from v3 here] ...
# ... [Include propagate logic from v3 here] ...

# (These sections are repo-agnostic by design in v3, so they remain effective.)

# ==========================================
# PART 4: GENERALIZED SYNTHESIS
# ==========================================

class Synthesizer:
    def __init__(self, index): self.index = index
    
    def generate(self, groups):
        bundles = defaultdict(list)
        for qual, g_list in groups.items():
            for g in g_list:
                bundles[frozenset(g)].append(qual)
        
        lines = [
            "# Generated Protocols",
            "# Usage: Copy these into a 'types.py' or 'protocols.py' file.",
            "from dataclasses import dataclass", 
            "from typing import Any, Protocol", 
            ""
        ]
        
        # Sort by ubiquity (how many functions use it) * size (complexity)
        def score(item):
            fields, sites = item
            return len(sites) * len(fields)

        for fields, sites in sorted(bundles.items(), key=score, reverse=True):
            if len(sites) < 2: continue
            
            # Generalized Naming: Use Field-Based if Context is weak
            name = self._name(fields, sites)
            
            lines.append(f"@dataclass(frozen=True)")
            lines.append(f"class {name}:")
            lines.append(f"    \"\"\"")
            lines.append(f"    Structural Pattern detected in {len(sites)} locations.")
            lines.append(f"    Representative usage: {sites[0]}")
            lines.append(f"    \"\"\"")
            for f in sorted(fields):
                lines.append(f"    {f}: Any")
            lines.append("")
        return "\n".join(lines)

    def _name(self, fields, sites):
        # 1. Try Function Naming (Verbs)
        words = []
        for s in sites: 
            parts = s.split(".")[-1].split("_")
            words.extend([p for p in parts if p not in ("get", "set", "do", "create", "process")])
        
        if words:
            common = Counter(words).most_common(2)
            if common and common[0][1] > len(sites) * 0.6: # 60% consensus
                return "".join(w.title() for w, _ in common) + "Context"

        # 2. Fallback: Structural Naming (Nouns)
        # {user_id, account_id} -> UserAccountBundle
        sorted_fields = sorted(fields)
        clean_fields = [f.replace("_id", "").replace("_key", "").title() for f in sorted_fields]
        return "".join(clean_fields[:3]) + "Bundle"

# ==========================================
# PART 5: MAIN EXECUTION
# ==========================================

def get_module_name(path: Path, root: Path) -> str:
    """Calculates python module path relative to project root."""
    try:
        rel = path.relative_to(root)
        return ".".join(rel.with_suffix("").parts)
    except ValueError:
        return "unknown"

def main():
    parser = argparse.ArgumentParser(description="Architectural Linter: Detect Implicit Data Bundles")
    parser.add_argument("--root", default=".", help="Project root directory (anchor for imports)")
    parser.add_argument("--out", default=None, help="Output file for protocols")
    parser.add_argument("--exclude", nargs="+", default=[], help="Additional directories to exclude")
    args = parser.parse_args()

    project_root = Path(args.root).resolve()
    config = Config(project_root=project_root)
    config.exclude_dirs.update(args.exclude)

    print(f"Scanning from root: {project_root}")
    
    paths = []
    for p in project_root.rglob("*.py"):
        if config.is_ignored_path(p): continue
        paths.append(p)

    table = SymbolTable()
    index = {}
    
    # ... [Same Parsing/Propagation loop as v3, but using get_module_name(p, project_root)] ...
    
    # When initializing visitors, use the correct module name:
    # mod = get_module_name(p, project_root)
    
    # ... [Rest of logic] ...

    if args.out:
        # Use the Synthesizer
        pass # (Generate and write)
    else:
        # Print summary to stdout
        pass

if __name__ == "__main__":
    main()

```

### How to use this generally

1. **On a Monolith:** `python architect.py --root /src/monolith --exclude legacy_code`
* *Effect:* It anchors all imports to `/src/monolith`, ensuring `from app.models import X` resolves correctly, while ignoring the noisy legacy folder.


2. **On a Microservice:** `python architect.py --root .`
* *Effect:* Works out of the box for smaller, standard layouts.



### Final Value Proposition

This generalized version removes the "magic" of assuming where files live. By forcing the user to define the `--root` (or defaulting to `.`), and by explicitly distinguishing between **Internal** (refactorable) and **External** (immutable) modules, it becomes safe to run on any repository without generating thousands of false positives from 3rd party library calls.
