# Phase 3: Static Analysis — Core Infrastructure and Python Adapter

## Goal

Build the static analysis pipeline infrastructure and the Python reference adapter. After this phase, the system can: walk a git repository to discover source files, parse Python files for imports/definitions/entry points, construct an import graph, detect module boundaries via clustering, score files for LLM priority selection, and detect test files. The output is a structured `AnalysisResult` that Phase 6 (LLM integration) consumes to generate briefings and missions.

## Dependencies

- **Phase 1** — `config.py` (campaign dir, repo path validation), `constants.py` (enums, priority scores)
- **Phase 2** — `models/analysis.py` (Module, FileSnapshot models), `db/` (repositories for persisting results)

## Research Notes

### Python `ast` module patterns

- `ast.parse(source)` produces a syntax tree. Walk with `ast.walk()` or `ast.NodeVisitor`.
- Import extraction: `ast.Import` nodes (e.g., `import os`) and `ast.ImportFrom` nodes (e.g., `from pathlib import Path`).
- Function/class definitions: `ast.FunctionDef`, `ast.AsyncFunctionDef`, `ast.ClassDef` nodes.
- Entry point detection heuristics:
  - `if __name__ == "__main__"` — `ast.If` with `ast.Compare` against `__main__`
  - Flask/FastAPI app instances — assignment to `app = Flask(...)` / `app = FastAPI(...)`
  - Typer apps — `app = typer.Typer()`
  - Click decorators — `@click.command()` / `@click.group()`
  - Celery tasks — `@app.task` / `@shared_task`

### Tree-sitter compatibility (Phase 4)

The adapter interface must support tree-sitter's pattern: load a `Language` object from a grammar package, create a `Parser`, parse bytes, walk nodes. Key difference from `ast`: tree-sitter works with bytes and `Node` objects with `type`, `children`, `child_by_field_name()`. The `LanguageAdapter` protocol abstracts these differences — both `ast` and tree-sitter adapters return the same `ParsedFile` dataclass.

### File discovery via git

Using `git ls-files` (subprocess) gives us tracked files, automatically respecting `.gitignore`. This is reliable since the CLI validates the target is a git repo. Falls back to `os.walk` for non-git directories (defensive, shouldn't happen in MVP).

### Module clustering approach

**Label propagation** on the import graph with directory-based weighting:
1. Initialize each file as its own label (community)
2. Iteratively: each node adopts the most frequent label among its neighbors, with edges weighted by import count and a directory-proximity bonus
3. Converge when labels stabilize (typically 5-15 iterations for codebases <1000 files)
4. Post-process: merge very small clusters (< 3 files) into their most-connected neighbor

This is simple, requires no external dependencies, and naturally respects the requirements ("import graph is the primary signal, directory structure is a weight").

### Centrality scoring

In-degree centrality (count of files that import a given file) is the simplest useful metric. Files imported by many others are "hub" files. No need for betweenness centrality in MVP — in-degree correlates well with importance and is O(E) to compute.

### Cyclomatic complexity

For Python, we approximate cyclomatic complexity by counting branching nodes in the AST: `if`, `elif`, `for`, `while`, `except`, `with`, `and`, `or`, `assert`, comprehensions. This is a rough heuristic but sufficient for file prioritization (we're ranking, not reporting exact numbers).

### Test file detection

**Tier 1 (naming convention):** Match against patterns: `test_*.py`, `*_test.py`, `tests/`, `__tests__/`, `*.spec.ts`, `*.spec.js`, `*.test.ts`, `*.test.js`. Import analysis maps test files to their imports to identify which source files have test coverage.

**Tier 2 (coverage reports):** Check for `.coverage`, `coverage.xml`, `coverage/lcov.info`, `htmlcov/` at repo root. Parse `coverage.xml` (Cobertura format) for per-file line coverage. Lightweight — just checks if files exist and parses XML if found.

### Security considerations

- File paths from `git ls-files` are relative to repo root — no path traversal risk
- `ast.parse()` on untrusted code is safe (parsing, not executing)
- Subprocess calls to `git` use list arguments (no shell injection)
- File content is read as bytes for hashing, decoded for parsing with error handling for encoding issues

---

## Tasks

### Task 1: Analysis data structures

**Files:** `src/unfamiliar/analysis/models.py`

**Action:** Define dataclasses for intermediate analysis results that flow between pipeline stages. These are internal to the analysis package (not persisted to DB — the orchestrator converts them to Module/FileSnapshot models for persistence).

Dataclasses:

- `FileInfo` — discovered file metadata: `path` (relative to repo), `language` (str), `size_bytes` (int), `content_hash` (str)
- `ParsedImport` — a single import statement: `source_path` (str, the importing file), `target` (str, the imported module/path), `is_relative` (bool), `names` (list of imported names)
- `FunctionDef` — a function/class definition: `name` (str), `line_start` (int), `line_end` (int), `is_async` (bool), `kind` (literal "function" | "class" | "method")
- `EntryPoint` — detected entry point: `file_path` (str), `entry_type` (EntryType enum), `description` (str), `line` (int)
- `ParsedFile` — result of parsing one file: `path` (str), `language` (str), `imports` (list[ParsedImport]), `definitions` (list[FunctionDef]), `entry_points` (list[EntryPoint]), `complexity` (int), `line_count` (int)
- `ImportGraph` — the full import graph: `edges` (dict mapping source_path to set of target_paths), `files` (dict mapping path to FileInfo), `in_degree` (dict mapping path to int)
- `DetectedModule` — a cluster result: `files` (list of paths), `primary_path` (str, the directory or dominant file), `file_count` (int), `confidence` (float 0-1), `category_hints` (list[InvestigationCategory])
- `TestInfo` — test detection results: `test_files` (set of paths), `source_to_tests` (dict mapping source path to list of test paths), `untested_files` (set of paths), `tier` (literal "basic" | "enhanced"), `coverage_summary` (dict of path to float, only in enhanced tier)
- `ScoredFile` — file with priority score: `path` (str), `score` (int), `reasons` (list[str])
- `AnalysisResult` — top-level output: `files` (list[FileInfo]), `parsed_files` (dict mapping path to ParsedFile), `import_graph` (ImportGraph), `modules` (list[DetectedModule]), `test_info` (TestInfo), `scored_files` (list[ScoredFile], sorted by score desc), `entry_points` (list[EntryPoint]), `languages` (dict mapping language name to file count), `repo_path` (str)

**Verify:** `ruff check src/unfamiliar/analysis/models.py`; `mypy src/unfamiliar/analysis/models.py`

**Done when:** All dataclasses are importable and type-check cleanly.

---

### Task 2: Language adapter protocol

**Files:** `src/unfamiliar/analysis/adapters/protocol.py`

**Action:** Define the `LanguageAdapter` protocol that all language-specific adapters implement. Use `typing.Protocol` for structural subtyping (no inheritance required — adapters just need to implement the methods).

Protocol methods:

- `supported_extensions(self) -> frozenset[str]` — file extensions this adapter handles (e.g., `{".py"}`)
- `can_handle(self, path: str) -> bool` — whether this adapter can parse the given file (typically extension check)
- `parse_file(self, path: Path, content: bytes) -> ParsedFile` — parse a file's contents and return structured results
- `resolve_import(self, importing_file: str, import_target: str, repo_files: set[str]) -> str | None` — resolve a relative/module import to an actual file path in the repo. Returns None if the import is external (not in the repo)

Also define an `AdapterRegistry` class that:
- Holds a list of registered adapters
- `register(adapter: LanguageAdapter) -> None`
- `get_adapter(path: str) -> LanguageAdapter | None` — returns the first adapter that can handle the file
- `supported_languages() -> dict[str, frozenset[str]]` — maps adapter name to extensions

**Verify:** `ruff check`; `mypy`

**Done when:** Protocol is defined with all methods typed. Registry can register and look up adapters.

---

### Task 3: File discovery

**Files:** `src/unfamiliar/analysis/discovery.py`

**Action:** Implement file discovery that walks a repository and classifies files by language.

Functions:

- `discover_files(repo_path: Path, registry: AdapterRegistry) -> list[FileInfo]` — discovers all source files in the repo:
  1. Run `git ls-files` to get tracked files (respects .gitignore)
  2. For each file: read content, compute SHA-256 hash, determine language from extension
  3. Classify language: use `registry.get_adapter()` for supported languages, "unknown" for others
  4. Skip binary files (files that fail UTF-8 decode are assumed binary)
  5. Return sorted by path for deterministic output

- `_run_git_ls_files(repo_path: Path) -> list[str]` — subprocess call to `git ls-files`, returns list of relative paths. Raises `AnalysisError` if git fails.

- `_classify_language(path: str, registry: AdapterRegistry) -> str` — determine language from file extension. Returns adapter name or "unknown".

Also define `AnalysisError` exception class.

**Verify:** `ruff check`; `mypy`

**Done when:** File discovery works on a git repo, returns FileInfo with hashes, classifies by language.

---

### Task 4: Python adapter

**Files:** `src/unfamiliar/analysis/adapters/python_adapter.py`

**Action:** Implement the Python language adapter using the stdlib `ast` module. This is the reference adapter that demonstrates the interface for Phase 4's tree-sitter adapters.

Class: `PythonAdapter` (implements `LanguageAdapter` protocol)

- `supported_extensions() -> frozenset[str]` — returns `frozenset({".py"})`
- `can_handle(path: str) -> bool` — checks `.py` extension
- `parse_file(path: Path, content: bytes) -> ParsedFile`:
  1. Decode content (UTF-8, replace errors)
  2. Parse with `ast.parse()`
  3. Extract imports: walk AST for `Import` and `ImportFrom` nodes
  4. Extract definitions: walk AST for `FunctionDef`, `AsyncFunctionDef`, `ClassDef`
  5. Detect entry points: `if __name__ == "__main__"`, Flask/FastAPI/Typer app instances, Click decorators
  6. Calculate complexity: count branching nodes
  7. Handle parse errors gracefully (return ParsedFile with empty lists, log warning)
- `resolve_import(importing_file: str, import_target: str, repo_files: set[str]) -> str | None`:
  1. Handle relative imports (`.foo`, `..bar`) by resolving against the importing file's directory
  2. Handle absolute imports by trying: `target.py`, `target/__init__.py`, package path resolution
  3. Return None for external imports (stdlib, third-party)

Also register the adapter: `src/unfamiliar/analysis/adapters/__init__.py` — create `create_default_registry() -> AdapterRegistry` that returns a registry with PythonAdapter registered.

**Verify:** `ruff check`; `mypy`; manual: parse a Python file, check imports/definitions are extracted

**Done when:** Python adapter extracts imports, definitions, entry points from `.py` files. Import resolution maps Python imports to repo file paths.

---

### Task 5: Import graph construction

**Files:** `src/unfamiliar/analysis/graph/import_graph.py`

**Action:** Build a directed import graph from parsed file data. Compute centrality metrics.

Functions:

- `build_import_graph(parsed_files: dict[str, ParsedFile], file_infos: dict[str, FileInfo], registry: AdapterRegistry, repo_files: set[str]) -> ImportGraph`:
  1. For each parsed file, resolve each import to a repo file using the adapter's `resolve_import()`
  2. Build adjacency dict: `{source_path: {target_path, ...}}`
  3. Compute in-degree for every file (count of files that import it)
  4. Return ImportGraph with edges, files, and in_degree

- `get_hub_files(graph: ImportGraph, threshold: int = 3) -> list[str]` — returns files with in-degree >= threshold (highly connected hub files), sorted by in-degree descending

- `get_connected_components(graph: ImportGraph) -> list[set[str]]` — finds connected components treating the graph as undirected (for clustering input)

**Verify:** `ruff check`; `mypy`

**Done when:** Import graph builds from parsed files, in-degree centrality computed, hub files identifiable.

---

### Task 6: Module boundary detection

**Files:** `src/unfamiliar/analysis/graph/clustering.py`

**Action:** Detect module boundaries using weighted label propagation on the import graph.

Functions:

- `detect_modules(graph: ImportGraph, min_module_size: int = 3) -> list[DetectedModule]`:
  1. Initialize: each file gets its own label
  2. Compute edge weights: import edge = 1.0, same directory bonus = 0.3
  3. Label propagation loop (max 50 iterations):
     - For each file (random order): adopt the most frequent weighted label among neighbors
     - Break when no labels change
  4. Post-process: merge clusters smaller than `min_module_size` into their most-connected neighbor
  5. For each cluster, compute: primary_path (most common directory prefix), file_count, confidence (based on internal vs external edge ratio)
  6. Assign category_hints based on file patterns (e.g., `test_` files → TESTING, `auth` in path → security_relevant)

- `_compute_confidence(cluster_files: set[str], graph: ImportGraph) -> float`:
  - Ratio of internal edges (within cluster) to total edges (internal + external)
  - 1.0 = fully self-contained cluster, 0.0 = no internal connections

**Verify:** `ruff check`; `mypy`

**Done when:** Clustering produces module groups from import graph with confidence scores.

---

### Task 7: Test file detection and coverage

**Files:** `src/unfamiliar/analysis/testing.py`

**Action:** Detect test files and optionally parse coverage reports.

Functions:

- `detect_tests(files: list[FileInfo], parsed_files: dict[str, ParsedFile], repo_path: Path) -> TestInfo`:
  1. **Tier 1 — naming convention:** Identify test files by pattern matching (`test_*.py`, `*_test.py`, `*.spec.ts`, `*.test.js`, etc.). Also check directory names (`tests/`, `__tests__/`, `spec/`)
  2. **Source-to-test mapping:** For each test file, check its imports against source files. Build `source_to_tests` dict
  3. **Untested files:** Source files (non-test, non-config) not appearing in any test file's imports
  4. **Tier 2 — coverage reports:** Check for coverage artifacts at repo root. If `coverage.xml` exists, parse Cobertura XML for per-file line coverage percentages. If `.coverage` SQLite DB exists, parse it for the same data
  5. Return TestInfo with tier set to "enhanced" if coverage data found, "basic" otherwise

- `_is_test_file(path: str) -> bool` — check if path matches test file naming patterns

- `_parse_coverage_xml(coverage_path: Path) -> dict[str, float]` — parse Cobertura XML, return dict of relative file path to line coverage percentage (0.0-1.0)

**Verify:** `ruff check`; `mypy`

**Done when:** Test files detected by naming convention, source-to-test mapping works, coverage XML parsed when available.

---

### Task 8: Key file scoring

**Files:** `src/unfamiliar/analysis/scoring.py`

**Action:** Score files for LLM priority selection. Files are ranked by importance — the highest-scored files get their full content sent to the LLM.

Functions:

- `score_files(files: list[FileInfo], parsed_files: dict[str, ParsedFile], graph: ImportGraph, test_info: TestInfo, entry_points: list[EntryPoint]) -> list[ScoredFile]`:
  1. For each file, accumulate score using constants from `constants.py`:
     - `FILE_PRIORITY_ENTRY_POINT` (100) if file contains an entry point
     - `FILE_PRIORITY_HIGH_CENTRALITY` (50) if in-degree >= 3
     - `FILE_PRIORITY_HIGH_COMPLEXITY` (30) if complexity > threshold (e.g., > 15 branching nodes)
     - `FILE_PRIORITY_TEST_FILE` (20) if it's a test file
  2. Track reasons for each score component (for transparency)
  3. Sort by score descending, then by path for stability
  4. Return full sorted list (the caller applies the token budget cutoff)

- `select_key_files(scored_files: list[ScoredFile], files: dict[str, FileInfo], token_budget: int, avg_tokens_per_byte: float = 0.25) -> list[ScoredFile]`:
  1. Walk scored files in priority order
  2. Estimate token count per file: `size_bytes * avg_tokens_per_byte`
  3. Add files until budget exhausted
  4. Return the selected subset

**Verify:** `ruff check`; `mypy`

**Done when:** Files scored by priority, key file selection respects token budget.

---

### Task 9: Analysis orchestrator

**Files:** `src/unfamiliar/analysis/__init__.py` (update), `src/unfamiliar/analysis/orchestrator.py`

**Action:** Implement the top-level `analyze_repository()` function that ties all stages together.

Function:

- `analyze_repository(repo_path: Path, token_budget: int = DEFAULT_TOKEN_BUDGET) -> AnalysisResult`:
  1. Create adapter registry via `create_default_registry()`
  2. Discover files via `discover_files()`
  3. Parse files: for each file with a matching adapter, call `parse_file()`
  4. Collect entry points from all parsed files
  5. Build import graph via `build_import_graph()`
  6. Detect modules via `detect_modules()`
  7. Detect tests via `detect_tests()`
  8. Score files via `score_files()`
  9. Compute language distribution
  10. Assemble and return `AnalysisResult`

Update `src/unfamiliar/analysis/__init__.py` to re-export:
- `analyze_repository`
- `AnalysisResult`
- `AnalysisError`

**Verify:** `ruff check src/unfamiliar/analysis/`; `mypy src/unfamiliar/analysis/`

**Done when:** `analyze_repository()` runs end-to-end and returns a populated AnalysisResult.

---

### Task 10: Full verification

**Files:** All files created in this phase.

**Action:**
1. `ruff check src/unfamiliar/` — full lint pass
2. `mypy src/unfamiliar/` — full type check
3. `python -c "from unfamiliar.analysis import analyze_repository, AnalysisResult, AnalysisError"` — verify import chain
4. `python -c "from unfamiliar.analysis.adapters import create_default_registry"` — verify adapter registry
5. `python -c "from unfamiliar.analysis.models import ParsedFile, ImportGraph, DetectedModule, AnalysisResult"` — verify data structures
6. Verify no circular imports between analysis, models, db, and config modules

**Verify:** All commands above exit 0.

**Done when:** All lint, type checks, and imports pass cleanly. No regressions in existing Phase 1/2 code.

---

## Interface Contracts

These contracts define the public API for the static analysis pipeline. Phase 5 tests will be written against these contracts.

### Analysis Data Structures (`src/unfamiliar/analysis/models.py`)

#### `FileInfo`
```python
@dataclass(frozen=True)
class FileInfo:
    path: str            # Relative to repo root
    language: str        # "python", "typescript", "javascript", "unknown"
    size_bytes: int
    content_hash: str    # SHA-256 hex digest
```
- **Purpose:** Metadata about a discovered source file.
- **Invariants:** `path` is always relative to repo root (no leading `/`). `size_bytes >= 0`. `content_hash` is a 64-character hex string.
- **Edge cases:** Empty file (size_bytes=0, hash of empty bytes). File with non-UTF-8 content (language="unknown" if no adapter matches, content_hash still computed from raw bytes).

#### `ParsedImport`
```python
@dataclass(frozen=True)
class ParsedImport:
    source_path: str     # File containing the import
    target: str          # Module/path being imported
    is_relative: bool    # True for relative imports (from . import x)
    names: list[str]     # Specific names imported (empty for "import x")
```
- **Purpose:** A single import statement extracted from source code.
- **Invariants:** `source_path` matches the path of the file it was extracted from. `target` is the raw import string (e.g., `os.path`, `.utils`, `../helpers`).
- **Edge cases:** Star import (`from x import *` — names=["*"]). Re-export import. Conditional import inside `if TYPE_CHECKING`.

#### `FunctionDef`
```python
@dataclass(frozen=True)
class FunctionDef:
    name: str
    line_start: int      # 1-indexed
    line_end: int        # 1-indexed, inclusive
    is_async: bool
    kind: Literal["function", "class", "method"]
```
- **Purpose:** A function or class definition found in a source file.
- **Invariants:** `line_start <= line_end`. `line_start >= 1`. `kind` is "method" for functions defined inside a class body.
- **Edge cases:** Single-line function. Nested class. Decorated function (line_start is the first decorator line).

#### `EntryPoint`
```python
@dataclass(frozen=True)
class EntryPoint:
    file_path: str       # Relative to repo root
    entry_type: EntryType
    description: str     # Human-readable description
    line: int            # 1-indexed line number
```
- **Purpose:** A detected entry point into the codebase.
- **Invariants:** `entry_type` is a valid `EntryType` enum value. `line >= 1`.
- **Edge cases:** File with multiple entry points (e.g., Flask app + CLI group). Entry point in a non-obvious location (e.g., `__init__.py`).

#### `ParsedFile`
```python
@dataclass
class ParsedFile:
    path: str
    language: str
    imports: list[ParsedImport]
    definitions: list[FunctionDef]
    entry_points: list[EntryPoint]
    complexity: int      # Approximate cyclomatic complexity
    line_count: int
```
- **Purpose:** Complete parse results for one source file.
- **Invariants:** `complexity >= 0`. `line_count >= 0`. All imports have `source_path == self.path`.
- **Error conditions:** If parsing fails (syntax error), returns ParsedFile with empty lists and complexity=0.
- **Edge cases:** Empty file (all lists empty, line_count=0). File with syntax errors (graceful degradation).

#### `ImportGraph`
```python
@dataclass
class ImportGraph:
    edges: dict[str, set[str]]     # source_path -> {target_paths}
    files: dict[str, FileInfo]     # path -> FileInfo
    in_degree: dict[str, int]      # path -> count of importers
```
- **Purpose:** Directed graph of import relationships between repo files.
- **Invariants:** All paths in `edges` keys and values exist in `files`. `in_degree[path]` equals the number of files that import `path`. Files with no importers have `in_degree[path] == 0`.
- **Edge cases:** File that imports itself (self-loop, should be ignored). File imported by all others (high in-degree hub). Disconnected files (no imports or importers).

#### `DetectedModule`
```python
@dataclass
class DetectedModule:
    files: list[str]                              # File paths in this module
    primary_path: str                             # Dominant directory
    file_count: int
    confidence: float                             # 0.0 to 1.0
    category_hints: list[InvestigationCategory]
```
- **Purpose:** A cluster of related files detected as a logical module.
- **Invariants:** `file_count == len(files)`. `confidence` is between 0.0 and 1.0 inclusive. `files` is non-empty.
- **Edge cases:** Single-file module (confidence based on that file's connections). Module spanning multiple directories. Module with all test files (category_hints includes TESTING).

#### `TestInfo`
```python
@dataclass
class TestInfo:
    test_files: set[str]
    source_to_tests: dict[str, list[str]]
    untested_files: set[str]
    tier: Literal["basic", "enhanced"]
    coverage_summary: dict[str, float]  # path -> coverage 0.0-1.0
```
- **Purpose:** Test detection results including optional coverage data.
- **Invariants:** `tier` is "enhanced" only when coverage data was found. `coverage_summary` is empty when `tier == "basic"`. All paths in `test_files` are relative to repo root.
- **Edge cases:** No test files found (all sets empty). Coverage report exists but is malformed (fall back to "basic" tier). 100% coverage on all files.

#### `ScoredFile`
```python
@dataclass
class ScoredFile:
    path: str
    score: int
    reasons: list[str]
```
- **Purpose:** A file with its computed priority score for LLM selection.
- **Invariants:** `score >= 0`. `reasons` is non-empty when `score > 0`. Files are comparable by `(score, path)`.
- **Edge cases:** File with zero score (no special properties). File with max score (entry point + hub + complex).

#### `AnalysisResult`
```python
@dataclass
class AnalysisResult:
    files: list[FileInfo]
    parsed_files: dict[str, ParsedFile]
    import_graph: ImportGraph
    modules: list[DetectedModule]
    test_info: TestInfo
    scored_files: list[ScoredFile]  # Sorted by score desc
    entry_points: list[EntryPoint]
    languages: dict[str, int]        # language -> file count
    repo_path: str
```
- **Purpose:** Complete output of the static analysis pipeline. Consumed by Phase 6 LLM integration.
- **Invariants:** `scored_files` is sorted by score descending. `languages` sums to `len(files)`. All paths are relative to repo root.
- **Edge cases:** Empty repository (all lists empty). Repository with only unsupported languages (parsed_files empty, modules empty).

---

### Adapter Protocol (`src/unfamiliar/analysis/adapters/protocol.py`)

#### `LanguageAdapter` (Protocol)
```python
class LanguageAdapter(Protocol):
    def supported_extensions(self) -> frozenset[str]: ...
    def can_handle(self, path: str) -> bool: ...
    def parse_file(self, path: Path, content: bytes) -> ParsedFile: ...
    def resolve_import(
        self, importing_file: str, import_target: str, repo_files: set[str]
    ) -> str | None: ...
```
- **Purpose:** Interface for language-specific source code parsing.
- **Invariants:** `can_handle(path)` returns True only for files whose extension is in `supported_extensions()`. `parse_file()` never raises — returns empty ParsedFile on failure. `resolve_import()` returns None for external imports.
- **Error conditions:** None raised from protocol methods. Parse errors logged and swallowed.
- **Edge cases:** File that matches extension but has invalid syntax. Import that could resolve to multiple files.

#### `AdapterRegistry`
```python
class AdapterRegistry:
    def register(self, adapter: LanguageAdapter) -> None: ...
    def get_adapter(self, path: str) -> LanguageAdapter | None: ...
    def supported_languages(self) -> dict[str, frozenset[str]]: ...
```
- **Purpose:** Manages registered language adapters and dispatches files to the correct one.
- **Invariants:** `get_adapter()` returns the first matching adapter or None. Order of registration determines priority.
- **Error conditions:** None — returns None for unsupported files.
- **Edge cases:** No adapters registered. Multiple adapters for the same extension (first wins).

---

### File Discovery (`src/unfamiliar/analysis/discovery.py`)

#### `discover_files(repo_path: Path, registry: AdapterRegistry) -> list[FileInfo]`
- **Purpose:** Walk a git repository and collect metadata for all source files.
- **Invariants:** Returns files sorted by path. All paths are relative to `repo_path`. Binary files are excluded. Content hashes are SHA-256 hex digests.
- **Valid inputs:** `repo_path` must be an existing directory containing a `.git` directory.
- **Error conditions:** `AnalysisError` if `git ls-files` fails or `repo_path` is not a git repo.
- **Edge cases:** Empty repo (no tracked files). Repo with symlinks (followed by git ls-files). Very large repo (>10k files).

---

### Python Adapter (`src/unfamiliar/analysis/adapters/python_adapter.py`)

#### `PythonAdapter`
```python
class PythonAdapter:
    def supported_extensions(self) -> frozenset[str]: ...
    def can_handle(self, path: str) -> bool: ...
    def parse_file(self, path: Path, content: bytes) -> ParsedFile: ...
    def resolve_import(
        self, importing_file: str, import_target: str, repo_files: set[str]
    ) -> str | None: ...
```
- **Purpose:** Parse Python source files using the stdlib `ast` module.
- **Invariants:** Returns valid ParsedFile for any `.py` file, even with syntax errors (graceful degradation). Import resolution distinguishes stdlib/third-party (returns None) from repo-internal imports.
- **Valid inputs:** `content` is the raw bytes of a Python file. `path` is relative to repo root.
- **Error conditions:** Never raises. Logs warnings for syntax errors and returns empty ParsedFile.
- **Edge cases:** File with `from __future__` imports. File with wildcard imports. File using `importlib.import_module()` (not detected — this is a known limitation). Circular imports between files. Files in namespace packages (no `__init__.py`).

---

### Import Graph (`src/unfamiliar/analysis/graph/import_graph.py`)

#### `build_import_graph(parsed_files, file_infos, registry, repo_files) -> ImportGraph`
- **Purpose:** Construct the import relationship graph from parse results.
- **Invariants:** Only includes edges between files that exist in the repo. Self-loops excluded. in_degree is accurate for all files (including files with 0 importers).
- **Error conditions:** None — gracefully handles files with no imports.
- **Edge cases:** File that imports only external packages (no edges). Circular imports (both edges included). Re-exports (A imports B imports C → edges A→B and B→C, not A→C).

#### `get_hub_files(graph, threshold=3) -> list[str]`
- **Purpose:** Identify highly-connected files (imported by many others).
- **Invariants:** Returns files sorted by in-degree descending. Only includes files with in-degree >= threshold.
- **Edge cases:** No files meet threshold (empty list). All files meet threshold.

#### `get_connected_components(graph) -> list[set[str]]`
- **Purpose:** Find connected components in the undirected version of the import graph.
- **Invariants:** Every file in the graph appears in exactly one component. Components are disjoint.
- **Edge cases:** All files in one component. Each file in its own component (no imports between files).

---

### Module Clustering (`src/unfamiliar/analysis/graph/clustering.py`)

#### `detect_modules(graph: ImportGraph, min_module_size: int = 3) -> list[DetectedModule]`
- **Purpose:** Group files into logical modules using weighted label propagation.
- **Invariants:** Every file in the graph appears in exactly one module. No empty modules. Confidence is between 0.0 and 1.0.
- **Error conditions:** None — produces valid output for any graph (including empty).
- **Edge cases:** Graph with no edges (one module per directory). Graph with one giant component (may split into multiple modules based on substructure). Graph with < min_module_size files total (single module).

---

### Test Detection (`src/unfamiliar/analysis/testing.py`)

#### `detect_tests(files, parsed_files, repo_path) -> TestInfo`
- **Purpose:** Identify test files and map them to source files.
- **Invariants:** `test_files` and `untested_files` are disjoint. Files in `source_to_tests` values are a subset of `test_files`. `tier` reflects whether coverage data was found.
- **Error conditions:** None — falls back gracefully if coverage files are malformed.
- **Edge cases:** No test files in repo. Test file that imports no source files. Coverage report with paths that don't match repo files.

---

### File Scoring (`src/unfamiliar/analysis/scoring.py`)

#### `score_files(files, parsed_files, graph, test_info, entry_points) -> list[ScoredFile]`
- **Purpose:** Rank files by importance for LLM analysis.
- **Invariants:** Output sorted by score descending, then by path. Score components are additive. Every file in input appears in output.
- **Edge cases:** File matching multiple criteria (scores stack). All files have same score (ordered by path).

#### `select_key_files(scored_files, files, token_budget, avg_tokens_per_byte=0.25) -> list[ScoredFile]`
- **Purpose:** Select highest-priority files that fit within the token budget.
- **Invariants:** Selected files are a prefix of `scored_files` (maintains priority order). Total estimated tokens <= `token_budget`. At least one file is selected if any exist and budget > 0.
- **Error conditions:** None.
- **Edge cases:** Budget too small for any file (select smallest file anyway). Single file exceeds budget (include it — the LLM can handle a bit over budget).

---

### Orchestrator (`src/unfamiliar/analysis/orchestrator.py`)

#### `analyze_repository(repo_path: Path, token_budget: int = DEFAULT_TOKEN_BUDGET) -> AnalysisResult`
- **Purpose:** Run the complete static analysis pipeline on a repository.
- **Invariants:** Returns a complete, consistent AnalysisResult. All intermediate state is internal. Result contains only repo-relative paths.
- **Valid inputs:** `repo_path` must be a valid git repository directory. `token_budget > 0`.
- **Error conditions:** `AnalysisError` if repo_path is not a valid git repo or git is not available.
- **Edge cases:** Empty repository. Repository with only unsupported languages. Very large repository (>10k files — should still complete, just slower).

---

## Property-Based Testing Candidates (for Phase 5)

1. **Import resolution round-trip** — for any valid Python import that maps to a repo file, resolve_import returns a path that exists in repo_files
2. **Graph invariants** — for any ImportGraph, every path in edges (keys and values) exists in files. in_degree is accurate
3. **Clustering coverage** — for any ImportGraph, detect_modules assigns every file to exactly one module
4. **Scoring determinism** — score_files with the same input always produces the same output
5. **File selection budget** — select_key_files never exceeds token_budget (except the single-file-over-budget edge case)
6. **Test detection disjointness** — test_files and untested_files are always disjoint
7. **Confidence bounds** — for any DetectedModule, 0.0 <= confidence <= 1.0

---

## Security Checklist

- [x] **No code execution:** `ast.parse()` parses but does not execute Python code
- [x] **Subprocess safety:** `git ls-files` called with list arguments via `subprocess.run()`, no shell=True
- [x] **Path traversal prevention:** All paths are relative to repo root; no absolute paths stored
- [x] **Encoding safety:** Files decoded with `errors="replace"` to handle non-UTF-8 gracefully
- [x] **No network access:** Entire pipeline is local — no HTTP calls, no package index lookups
- [x] **Resource limits:** No unbounded recursion in graph algorithms (label propagation has max iterations)

---

## Issues Discovered During Verification Stage

None. All tasks completed as planned without issues.

**Verification results:**
- All ruff checks pass on analysis package
- All mypy checks pass on analysis package (13 source files)
- Import chain works without circular imports
- End-to-end test on this repository succeeds:
  - 49 files discovered
  - 34 Python files parsed
  - 1 entry point detected (cli.py)
  - 1 test file detected
  - File scoring works correctly

