# Section 02: Session Layer

## Overview

Implement `src/godotiq/session.py` with the `GodotIQSession` class. This is the critical bridge between the existing parsers and the MCP tools. The session orchestrates project scanning, autoload-aware .gd parsing, lazy scene resolution, and cross-reference queries. It also implements project root discovery.

**File to create:** `src/godotiq/session.py`
**Test file to create:** `tests/test_tools/test_session.py`

---

## Dependencies

- **Upstream:** Section 01 (test fixtures -- needed for session tests)
- **Downstream:** Section 03 (server integration uses GodotIQSession)

---

## Existing API Reference (DO NOT CHANGE)

```python
# These are the existing parser APIs the session will call:
from godotiq.parsers.project_index import scan_project, ProjectIndex
from godotiq.parsers.gd_parser import parse_gd, GdScript
from godotiq.parsers.tscn_parser import parse_tscn, TscnScene
from godotiq.parsers.scene_resolver import resolve_scene, calculate_world_transforms, ResolvedScene, WorldNode
from godotiq.cache.file_cache import FileCache
from godotiq.config import load_config, GodotIQConfig
```

---

## Implementation Details

### `src/godotiq/session.py`

```python
"""GodotIQ session — unified project state for MCP tools.

Bridges the parsers into a single cached state object. Created once
at server startup, provides all cross-reference queries the tools need.
"""

from __future__ import annotations

import os
from pathlib import Path

from godotiq.cache.file_cache import FileCache
from godotiq.config import load_config, GodotIQConfig
from godotiq.parsers.gd_parser import parse_gd, GdScript
from godotiq.parsers.project_index import scan_project, ProjectIndex
from godotiq.parsers.scene_resolver import (
    resolve_scene,
    calculate_world_transforms,
    ResolvedScene,
    WorldNode,
)
from godotiq.parsers.tscn_parser import parse_tscn, TscnScene


class GodotIQSession:
    """Holds loaded project state. Created once, cached, refreshed on file changes."""

    def __init__(self, project_root: str | Path) -> None: ...
    def load(self) -> None: ...
    def get_script(self, res_path: str) -> GdScript | None: ...
    def get_scene(self, res_path: str) -> TscnScene | None: ...
    def resolve_scene_spatial(self, res_path: str) -> tuple[ResolvedScene, list[WorldNode]] | None: ...
    def get_all_signal_definitions(self) -> dict[str, list[str]]: ...
    def get_all_signal_emissions(self) -> dict[str, list[str]]: ...
    def get_all_signal_connections(self) -> dict[str, list[str]]: ...
    def get_autoload_dependents(self, autoload_name: str) -> list[str]: ...
    def to_res_path(self, path: str) -> str: ...
    def resolve_file_path(self, path: str) -> str | None: ...


def discover_project_root() -> Path | None: ...
```

### Class: `GodotIQSession`

#### `__init__(self, project_root: str | Path) -> None`

Store project_root as Path. Initialize:
- `self.project_root: Path`
- `self.cache: FileCache` (new instance)
- `self.config: GodotIQConfig` (load from project_root/.godotiq.json if exists)
- `self._index: ProjectIndex | None = None`
- `self._gd_scripts: dict[str, GdScript] = {}` -- key is res:// path
- `self._tscn_scenes: dict[str, TscnScene] = {}` -- key is res:// path (lazy loaded)
- `self._resolved_scenes: dict[str, tuple[ResolvedScene, list[WorldNode]]] = {}` -- lazy cache

#### `load(self) -> None`

Full project scan + parse all .gd files with autoload awareness:

1. Call `scan_project(self.project_root, self.cache)` to get `ProjectIndex`
2. Extract autoload names: `autoload_names = list(self._index.autoloads.keys())`
3. For each script in `self._index.scripts`:
   - Resolve the res:// path to absolute filesystem path
   - If file exists, call `parse_gd(str(abs_path), known_autoloads=autoload_names)`
   - Store in `self._gd_scripts[script_path]`
4. Load config: `self.config = load_config(str(self.project_root / ".godotiq.json"))`

#### `get_script(self, res_path: str) -> GdScript | None`

Return the parsed GdScript for the given res:// path, or None if not found.

#### `get_scene(self, res_path: str) -> TscnScene | None`

Lazy parse + cache. If res_path is in `self._index.scenes`, return it directly (already parsed during scan_project). Otherwise try to parse from disk.

#### `resolve_scene_spatial(self, res_path: str) -> tuple[ResolvedScene, list[WorldNode]] | None`

Lazy resolve + cache. Gets the scene from get_scene(), calls resolve_scene() and calculate_world_transforms(), caches the result.

#### `get_all_signal_definitions(self) -> dict[str, list[str]]`

Return a mapping of `{signal_name: [res://script_path, ...]}` -- which scripts define each signal. Iterates over `self._gd_scripts`.

#### `get_all_signal_emissions(self) -> dict[str, list[str]]`

Return a mapping of `{signal_name: [res://script_path, ...]}` -- which scripts emit each signal. Iterates over `self._gd_scripts`, checks `signal_emissions`.

#### `get_all_signal_connections(self) -> dict[str, list[str]]`

Return a mapping of `{signal_source: [res://script_path, ...]}` -- which scripts connect to signals. Iterates over `self._gd_scripts`, checks `signal_connections`.

#### `get_autoload_dependents(self, autoload_name: str) -> list[str]`

Return list of res:// script paths that reference the given autoload name. Iterates over `self._gd_scripts`, checks `autoload_refs`.

#### `to_res_path(self, path: str) -> str`

Convert a file path (absolute or relative to project root) to a res:// path. If path already starts with `res://`, return as-is.

#### `resolve_file_path(self, path: str) -> str | None`

Convert a file identifier (res:// path or relative path) to a res:// path. Verify the file exists in the index. Return None if not found.

### Function: `discover_project_root() -> Path | None`

Project root discovery algorithm:
1. Check env var `GODOTIQ_PROJECT_ROOT` -- if set and contains project.godot, return it
2. Check cwd for `.godotiq.json` -- if exists, parse and return cwd
3. Walk up from cwd looking for `project.godot` -- return the directory containing it
4. Return None if not found

---

## Tests: `tests/test_tools/test_session.py`

```python
"""Tests for the GodotIQ session layer."""

from __future__ import annotations

from pathlib import Path

import pytest

from godotiq.session import GodotIQSession, discover_project_root


@pytest.fixture
def sample_root() -> Path:
    return Path(__file__).resolve().parent.parent / "fixtures" / "sample_project"


@pytest.fixture
def session(sample_root: Path) -> GodotIQSession:
    s = GodotIQSession(sample_root)
    s.load()
    return s


class TestSessionLoadsProject:
    def test_session_loads_project(self, session: GodotIQSession) -> None:
        """Session.load() populates the project index."""
        assert session._index is not None
        assert session._index.project_name == "Sample Project"
        assert session._index.total_scenes >= 2
        assert session._index.total_scripts >= 4

    def test_session_finds_autoloads(self, session: GodotIQSession) -> None:
        """Session discovers all autoloads from project.godot."""
        assert session._index is not None
        assert "GameManager" in session._index.autoloads
        assert "EconomyManager" in session._index.autoloads
        assert "ProgressionManager" in session._index.autoloads

    def test_session_parses_gd_with_autoloads(self, session: GodotIQSession) -> None:
        """Session parses all .gd files with known autoload names."""
        assert len(session._gd_scripts) >= 4
        # Verify scripts were parsed (not None)
        for path, gd in session._gd_scripts.items():
            assert isinstance(gd, GdScript)

    def test_session_autoload_pattern1(self, session: GodotIQSession) -> None:
        """Pattern 1 autoload detection works when session provides known_autoloads."""
        # progression_manager.gd references GameManager.game_time and EconomyManager.get_profit
        prog_path = "res://scripts/autoloads/progression_manager.gd"
        gd = session.get_script(prog_path)
        assert gd is not None
        autoload_names = [ref.autoload_name for ref in gd.autoload_refs]
        assert "GameManager" in autoload_names
        assert "EconomyManager" in autoload_names


class TestSessionQueries:
    def test_session_get_script(self, session: GodotIQSession) -> None:
        """get_script() returns parsed GdScript for valid res:// path."""
        gd = session.get_script("res://scripts/autoloads/economy_manager.gd")
        assert gd is not None
        assert gd.class_name == "EconomyManagerClass"

    def test_session_get_script_missing(self, session: GodotIQSession) -> None:
        """get_script() returns None for unknown path."""
        assert session.get_script("res://nonexistent.gd") is None

    def test_session_caches_scenes(self, session: GodotIQSession) -> None:
        """get_scene() returns TscnScene and caches on second call."""
        scene = session.get_scene("res://scenes/main.tscn")
        assert scene is not None
        assert scene.root is not None
        # Second call should return same object (cached)
        scene2 = session.get_scene("res://scenes/main.tscn")
        assert scene2 is scene

    def test_signal_definitions(self, session: GodotIQSession) -> None:
        """get_all_signal_definitions() maps signal names to defining scripts."""
        defs = session.get_all_signal_definitions()
        assert "cash_changed" in defs
        assert any("economy_manager" in p for p in defs["cash_changed"])

    def test_signal_emissions(self, session: GodotIQSession) -> None:
        """get_all_signal_emissions() maps signal names to emitting scripts."""
        emissions = session.get_all_signal_emissions()
        assert "cash_changed" in emissions

    def test_autoload_dependents(self, session: GodotIQSession) -> None:
        """get_autoload_dependents() returns scripts that reference an autoload."""
        deps = session.get_autoload_dependents("GameManager")
        assert len(deps) >= 1  # at least progression_manager references it


class TestProjectRootDiscovery:
    def test_discover_from_env(self, sample_root: Path, monkeypatch) -> None:
        """Discovers project root from GODOTIQ_PROJECT_ROOT env var."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(sample_root))
        result = discover_project_root()
        assert result is not None
        assert result == sample_root

    def test_discover_returns_none_when_not_found(self, tmp_path: Path, monkeypatch) -> None:
        """Returns None when no project root can be found."""
        monkeypatch.delenv("GODOTIQ_PROJECT_ROOT", raising=False)
        monkeypatch.chdir(tmp_path)
        result = discover_project_root()
        assert result is None
```

---

## Checklist

- [ ] `src/godotiq/session.py` created with GodotIQSession class
- [ ] `discover_project_root()` function implemented
- [ ] Session.load() calls scan_project() + parse_gd() with autoload names
- [ ] get_script() and get_scene() work correctly
- [ ] Cross-reference methods (get_all_signal_*) return correct data
- [ ] `tests/test_tools/test_session.py` created with all tests
- [ ] All tests pass: `uv run pytest tests/ -v`
