Now I have all the context I need. Let me generate the section content.

# Section 05: Project Index

## Overview

This section implements `src/godotiq/parsers/project_index.py`, a module that scans a Godot project directory and builds a complete cross-reference index of all scenes, scripts, and assets. The index enables project-wide queries such as "which scenes use this script?", "what assets are unused?", and "what is the full dependency chain for this scene?".

This section depends on:
- **Section 02 (Test Fixtures):** The `tests/fixtures/sample_project/` directory structure and `sample_project_root` conftest fixture must exist before integration tests can run.
- **Sprint 1A (existing):** The `parse_tscn()` function from `src/godotiq/parsers/tscn_parser.py` and the `FileCache` class from `src/godotiq/cache/file_cache.py` are used directly.

---

## Tests

All tests go in `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_parsers/test_project_index.py`. They exercise the `project_index` module in isolation. The fixture `sample_project_root` (defined in section 02's conftest additions) provides the path to `tests/fixtures/sample_project/`.

A module-scoped fixture `sample_project_index` should be defined (either in this test file or in conftest) that calls `scan_project(sample_project_root)` once and caches the result for all tests that need it.

### Unit Tests -- project.godot Parsing

**`test_parse_project_name`** -- Call `_parse_project_godot()` on the fixture `project.godot` file at `tests/fixtures/sample_project/project.godot`. Assert the returned project name is `"Sample Project"`.

**`test_parse_autoloads`** -- Same fixture file. Assert the returned autoloads dict contains the key `"GameManager"` mapped to `"res://scripts/entities/printer.gd"`. The leading `*` must be stripped from the value.

**`test_parse_empty_autoloads`** -- Create a temporary `project.godot` file (via `tmp_path`) that has an `[application]` section but no `[autoload]` section. Assert the returned autoloads dict is empty.

**`test_parse_project_godot_missing`** -- Call `_parse_project_godot()` with a non-existent path. Assert it raises `FileNotFoundError` or returns empty defaults (choose one strategy and be consistent; the plan recommends raising `FileNotFoundError` for missing `project.godot`).

### Unit Tests -- Path Conversion

**`test_to_res_path`** -- Given an absolute filesystem path like `/some/project/scenes/main.tscn` and a project root `/some/project`, assert the result is `"res://scenes/main.tscn"`. Forward slashes must be used regardless of OS.

**`test_to_res_path_nested`** -- Given a deeply nested path like `/project/assets/models/printers/printer_standard.glb` and project root `/project`, assert the result is `"res://assets/models/printers/printer_standard.glb"`.

### Integration Tests -- Project Scanning

These tests use the `sample_project_index` fixture (or call `scan_project()` directly).

**`test_scan_finds_all_tscn`** -- Assert `total_scenes` matches the expected count of `.tscn` files in the fixture (2: `main.tscn` and `fdm_printer.tscn`). Assert all expected `res://` paths appear as keys in `index.scenes`.

**`test_scan_finds_all_scripts`** -- Assert `total_scripts` matches the expected count (2: `printer.gd` and `worker.gd`). Assert both `res://` paths appear in `index.scripts`.

**`test_scan_finds_assets`** -- Assert `total_assets` matches expected count (at least 2: `printer_standard.glb` and `unused_model.glb`). For each asset entry, assert `type` is `"model"` and `size_bytes` is an integer >= 0.

**`test_scan_skips_godot_dir`** -- Before scanning, create a `.godot/` subdirectory inside the fixture (or use `tmp_path` to build a temporary project) containing a `.tscn` file. Assert that `.tscn` file does not appear in `index.scenes`.

**`test_scan_skips_addons`** -- Same approach with an `addons/` directory containing a `.tscn` file. Assert it is excluded from scan results.

**`test_scan_project_not_found`** -- Call `scan_project()` with a non-existent directory path. Assert `FileNotFoundError` is raised.

### Integration Tests -- Cross-Reference Maps

These use the `sample_project_index` fixture.

**`test_scene_instances_map`** -- Assert `index.scene_instances["res://scenes/main.tscn"]` contains `"res://scenes/equipment/fdm_printer.tscn"`.

**`test_scene_instance_of_map`** -- Assert `index.scene_instance_of["res://scenes/equipment/fdm_printer.tscn"]` contains `"res://scenes/main.tscn"`.

**`test_script_to_scenes_map`** -- Assert `index.script_to_scenes["res://scripts/entities/printer.gd"]` contains `"res://scenes/equipment/fdm_printer.tscn"`.

**`test_resource_usage`** -- Assert `index.resource_usage["res://assets/models/printers/printer_standard.glb"]` contains `"res://scenes/equipment/fdm_printer.tscn"`.

### Integration Tests -- Query Functions

**`test_find_asset_usage`** -- Call `find_asset_usage(index, "res://assets/models/printers/printer_standard.glb")`. Assert non-empty list containing the fdm_printer scene path.

**`test_find_unused_assets`** -- Call `find_unused_assets(index)`. Assert `"res://assets/models/printers/unused_model.glb"` is in the result. Assert `"res://assets/models/printers/printer_standard.glb"` is NOT in the result.

**`test_scene_dependency_chain`** -- Call `get_scene_dependency_chain(index, "res://scenes/main.tscn")`. Assert the result contains `"res://scenes/equipment/fdm_printer.tscn"`.

### Integration Tests -- FileCache

**`test_scan_with_cache`** -- Create a `FileCache` instance. Call `scan_project()` with it. Call `scan_project()` again with the same cache. Assert the second scan succeeds and returns equivalent results. Optionally verify cache hits by checking that the cache has entries for the `.tscn` files.

**`test_incremental_rescan`** -- Scan with a `FileCache`. Touch one `.tscn` file (update its mtime, e.g., by rewriting it or using `os.utime()`). Call `rescan_changed()`. Assert only the touched file is re-parsed (verify by checking that the returned index has updated data for that file while other entries remain equivalent).

---

## Implementation

### File: `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/parsers/project_index.py`

This is a new file. It depends on:
- `godotiq.parsers.tscn_parser.parse_tscn` and `TscnScene`
- `godotiq.cache.file_cache.FileCache` (optional parameter)
- Standard library: `os`, `os.walk`, `pathlib.Path`, `time`, `logging`, `dataclasses`, `collections.defaultdict`

### Data Structure: `ProjectIndex`

A `@dataclass` holding the complete index of a Godot project:

```python
@dataclass
class ProjectIndex:
    """Complete index of a Godot project's files and cross-references."""

    project_root: Path
    project_name: str
    scenes: dict[str, TscnScene]          # res://path -> parsed scene
    scripts: list[str]                      # res://paths of .gd files
    assets: dict[str, dict]                 # res://path -> {"type": str, "size_bytes": int, "used_by": list[str]}
    scene_instances: dict[str, list[str]]   # scene -> scenes it instantiates
    scene_instance_of: dict[str, list[str]] # scene -> scenes that instantiate it
    script_to_scenes: dict[str, list[str]]  # script -> scenes using it
    resource_usage: dict[str, list[str]]    # resource -> scenes referencing it
    autoloads: dict[str, str]               # autoload name -> res:// path
    total_scenes: int
    total_scripts: int
    total_assets: int
    scan_time_ms: float
```

### Function: `scan_project(project_root, cache=None) -> ProjectIndex`

The main entry point. Signature:

```python
def scan_project(project_root: Path, cache: FileCache | None = None) -> ProjectIndex:
    """Scan a Godot project directory and build a complete cross-reference index.

    Args:
        project_root: Path to directory containing project.godot.
        cache: Optional FileCache for caching parsed .tscn files.

    Returns:
        ProjectIndex with all scenes parsed and cross-references built.

    Raises:
        FileNotFoundError: If project_root does not exist or has no project.godot.
    """
```

Algorithm:

1. **Validate project root.** Check that `project_root / "project.godot"` exists. Raise `FileNotFoundError` with a clear message if not.

2. **Walk the filesystem** using `os.walk()`. For each directory entry:
   - **Skip directories** named `.godot`, `addons`, or any name starting with `.` (modify `dirnames` in-place during `os.walk()` to prune).
   - Collect files by extension:
     - `.tscn` files go into a scenes-to-parse list
     - `.gd` files go into the scripts list
     - `.glb`, `.gltf`, `.png`, `.jpg`, `.jpeg`, `.tres`, `.wav`, `.ogg`, `.mp3` go into the assets dict
   - The asset type should be inferred from extension: `.glb`/`.gltf` = `"model"`, `.png`/`.jpg`/`.jpeg` = `"texture"`, `.tres` = `"resource"`, `.wav`/`.ogg`/`.mp3` = `"audio"`.

3. **Convert all collected paths to `res://` format.** Use a helper function `_to_res_path(abs_path, project_root)` that computes the relative path from project_root using forward slashes and prepends `res://`.

4. **Parse `.tscn` files.** For each scene:
   - If a `FileCache` is provided, check `cache.is_stale(fs_path)` first. If not stale, use `cache.get(fs_path)`.
   - Otherwise (or if stale/missing), call `parse_tscn(fs_path)` and store the result. If cache is provided, call `cache.put(fs_path, parsed_scene)`.
   - Wrap parsing in try/except to handle malformed files gracefully (log a warning, skip the file).

5. **Build cross-reference maps** by iterating over each parsed scene's `ext_resources`:
   - For each `ExtResource` in `scene.ext_resources.values()`:
     - Add `scene_res_path` to `resource_usage[ext.path]`
     - If `ext.type == "PackedScene"` and `ext.path` ends with `.tscn`:
       - Add `ext.path` to `scene_instances[scene_res_path]`
       - Add `scene_res_path` to `scene_instance_of[ext.path]`
     - If `ext.type == "Script"`:
       - Add `scene_res_path` to `script_to_scenes[ext.path]`

6. **Parse `project.godot`** via the helper `_parse_project_godot()` to extract project name and autoloads.

7. **Populate asset `used_by` fields.** For each asset in the assets dict, set `assets[path]["used_by"] = resource_usage.get(path, [])`.

8. **Record timing.** Use `time.monotonic()` or `time.time()` to compute `scan_time_ms`.

9. **Return** a `ProjectIndex` dataclass instance with all fields populated.

### Helper: `_to_res_path(abs_path, project_root) -> str`

```python
def _to_res_path(abs_path: Path, project_root: Path) -> str:
    """Convert an absolute filesystem path to a res:// path.

    Uses forward slashes regardless of OS.
    """
```

Implementation: compute `abs_path.relative_to(project_root)`, convert to string with forward slashes via `.as_posix()`, prepend `"res://"`.

### Helper: `_parse_project_godot(path) -> tuple[str, dict[str, str]]`

```python
def _parse_project_godot(path: Path) -> tuple[str, dict[str, str]]:
    """Parse project.godot for project name and autoloads.

    Args:
        path: Filesystem path to project.godot.

    Returns:
        Tuple of (project_name, autoloads_dict).

    Raises:
        FileNotFoundError: If the file does not exist.
    """
```

Parsing approach:
- Read the file line by line.
- Track the current section name (text between `[` and `]` on a line).
- In the `[application]` section, look for `config/name="..."` and extract the value (strip outer quotes).
- In the `[autoload]` section, parse lines of the form `Name="*res://path/to/script.gd"`:
  - Split on `=` (first occurrence only).
  - Strip outer quotes from the value.
  - If value starts with `*`, strip it (the `*` indicates singleton registration).
  - Store as `{name: res_path}`.

### Function: `rescan_changed(previous_index, cache) -> ProjectIndex`

```python
def rescan_changed(previous_index: ProjectIndex, cache: FileCache) -> ProjectIndex:
    """Perform an incremental rescan, re-parsing only changed files.

    Args:
        previous_index: The previous ProjectIndex to update from.
        cache: FileCache used during the previous scan (required for staleness checks).

    Returns:
        A new ProjectIndex with updated data.
    """
```

Algorithm:
1. Walk the filesystem again to detect new/removed files.
2. For each existing `.tscn` file, check `cache.is_stale()`. Only re-parse stale files; reuse cached results for unchanged ones.
3. Rebuild all cross-reference maps from scratch (simpler and more correct than trying to do incremental map updates).
4. Return a new `ProjectIndex`.

### Query Functions

Three standalone functions that operate on a `ProjectIndex`:

```python
def find_asset_usage(index: ProjectIndex, asset_path: str) -> list[str]:
    """Return list of scene res:// paths that reference the given asset."""

def find_unused_assets(index: ProjectIndex) -> list[str]:
    """Return asset res:// paths that are not referenced by any scene."""

def get_scene_dependency_chain(index: ProjectIndex, scene_path: str) -> list[str]:
    """Return all scenes transitively instanced by the given scene (BFS)."""
```

`find_asset_usage`: Simply return `index.resource_usage.get(asset_path, [])`.

`find_unused_assets`: Iterate over `index.assets` keys, return those where `index.resource_usage.get(path, [])` is empty.

`get_scene_dependency_chain`: BFS starting from `scene_path`. Use a queue initialized with `scene_path`. For each dequeued scene, look up `index.scene_instances.get(scene, [])` and enqueue any not-yet-visited scenes. Collect all visited scenes (excluding the start) into the result. Use a `visited` set to prevent cycles.

---

## Asset Type Mapping

The following extension-to-type mapping should be used when building the assets dict:

| Extension(s) | Type |
|---|---|
| `.glb`, `.gltf` | `"model"` |
| `.png`, `.jpg`, `.jpeg` | `"texture"` |
| `.tres` | `"resource"` |
| `.wav`, `.ogg`, `.mp3` | `"audio"` |

Each asset entry in the dict should have the structure:
```python
{"type": str, "size_bytes": int, "used_by": list[str]}
```

Where `size_bytes` is obtained via `os.path.getsize()` on the filesystem path.

---

## Error Handling

- **Missing `project.godot`:** `scan_project()` raises `FileNotFoundError` with message like `"No project.godot found in {project_root}"`.
- **Malformed `.tscn` files:** Wrapped in try/except during parsing. Log a warning, skip the file, continue scanning.
- **Malformed `project.godot`:** Return empty project name and empty autoloads dict. Log a warning.
- **Empty project (no `.tscn` files):** Return a valid `ProjectIndex` with empty `scenes` dict and zero counts.
- **Permission errors during filesystem walk:** Let OS errors propagate naturally (standard behavior for `os.walk()`).

---

## Directory Skipping Logic

During `os.walk()`, modify `dirnames` in-place to skip unwanted directories:

```python
for dirpath, dirnames, filenames in os.walk(project_root):
    # Skip hidden directories, .godot, and addons
    dirnames[:] = [
        d for d in dirnames
        if not d.startswith(".") and d != "addons"
    ]
    # ... process filenames
```

Note that `.godot` is already covered by the `d.startswith(".")` check since `.godot` starts with a dot. The `addons` check handles the non-hidden `addons/` directory specifically.

---

## Conftest Additions

The following fixture should be added to `/Users/salvatorefarruggio/Desktop/godotiq/tests/conftest.py` (or defined in the test file itself). This depends on section 02 having already created the `sample_project_root` fixture and the fixture directory:

```python
@pytest.fixture(scope="module")
def sample_project_index(sample_project_root):
    """Scan the sample project once and cache for all tests in the module."""
    from godotiq.parsers.project_index import scan_project
    return scan_project(sample_project_root)
```

The `sample_project_root` fixture (from section 02) returns `Path("tests/fixtures/sample_project/")`.

---

## File Checklist

| File | Action |
|---|---|
| `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/parsers/project_index.py` | CREATE -- new module with `ProjectIndex`, `scan_project`, helpers, and query functions |
| `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_parsers/test_project_index.py` | CREATE -- all tests described above |
| `/Users/salvatorefarruggio/Desktop/godotiq/tests/conftest.py` | MODIFY -- add `sample_project_index` fixture |