# Sprint 3e — Hardening: 5 Fixes from Real-World Audit

## Objective
Fix 5 concrete problems found during real-world testing on Print Farm Tycoon (86 scripts, 41 scenes, 876 nodes, 28 autoloads). These aren't feature additions — they fix wrong output, excessive noise, and UX gaps that make the tools unreliable for an AI agent.

## Context
GodotIQ MCP server. Sprint 3d complete: 425 tests, 13 tools.

Session API (unchanged):
```python
session._index: ProjectIndex
    .scenes: dict[str, TscnScene]
    .scripts: list[str]
    .assets: list[str]
    .autoloads: dict[str, str]       # name -> res:// path
    .resource_usage: dict[str, list[str]]
    .scene_instances: dict[str, list[str]]
    .scene_instance_of: dict[str, list[str]]
    .script_to_scenes: dict[str, list[str]]

session._gd_scripts: dict[str, GdScript]
session.get_script(res_path) -> GdScript | None
session.get_scene(res_path) -> TscnScene | None
session.resolve_file_path(path) -> str | None
session._resolve_res_path(res_path) -> Path
session.resolve_scene_spatial(scene_path) -> tuple[ResolvedScene, list[WorldNode]] | None

# GdScript fields: class_name, extends, signals, functions, variables, enums,
#   signal_connections, signal_emissions, autoload_refs, preload_refs
# GdSignal: name, args, line
# GdSignalEmission: signal_name, arg_count, line
# GdSignalConnection: signal_source, target, has_bind, bind_args, line
# GdAutoloadRef: autoload_name, access, pattern, line
```

---

## Fix 1 — signal_map: Eliminate Events Bus False Positives (CRITICAL)

**File**: `src/godotiq/tools/code/__init__.py` (the `godotiq_signal_map` tool)

### Problem
The `find="missing"` mode flags 52 false positives on PFT. These are signals emitted via the Events bus (`Events.emit_signal("order_accepted")`) where the signal declaration `signal order_accepted` lives in `events.gd`, not in the emitting file. The current check only looks for the signal definition in the **same file** that emits it.

This breaks on ANY project using the event bus pattern (extremely common in Godot).

### Fix
When building the "missing" list, a signal emission should NOT be flagged as missing if the signal name is defined in **any** script in the project. The check should be:

```
For each emission in file X:
    signal_name = emission.signal_name
    found = False
    for all scripts in session._gd_scripts.values():
        if signal_name in [s.name for s in script.signals]:
            found = True
            break
    if not found:
        flag as missing
```

This is simple and correct: a signal that's defined somewhere in the project is not "missing", regardless of which file emits it.

**Implementation detail:** Build a project-wide set of all defined signal names once at the start of the tool execution (before the per-file loop). Then check each emission against this set. This is O(1) lookup per emission.

```python
# Build once:
all_defined_signals: set[str] = set()
for gd in session._gd_scripts.values():
    for sig in gd.signals:
        all_defined_signals.add(sig.name)

# Then in the missing check:
if emission.signal_name not in all_defined_signals:
    missing_list.append(...)
```

### Tests to add: `tests/test_tools/test_signal_map.py`

```python
def test_signal_map_events_bus_not_missing():
    """Signal emitted via Events bus but defined in events.gd should NOT be flagged as missing.

    Setup:
    - events.gd: defines signal 'order_accepted'
    - order_manager.gd: emits 'order_accepted' (via Events autoload reference)
    Expected: find="missing" returns 0 missing signals.
    """

def test_signal_map_truly_missing():
    """Signal emitted but not defined ANYWHERE in the project → should be flagged as missing.

    Setup:
    - script.gd: emits 'nonexistent_signal'
    - No file in project defines 'signal nonexistent_signal'
    Expected: find="missing" returns 1 missing signal.
    """
```

Fixtures needed: Create simple .gd fixtures that demonstrate the Events bus pattern:
- A mock `events.gd` with `signal order_accepted` and `signal day_started`
- A mock `order_manager.gd` that emits `order_accepted` (via `Events.emit_signal("order_accepted")` or `Events.order_accepted.emit()`)
- A mock `broken_emitter.gd` that emits `totally_fake_signal` not defined anywhere

---

## Fix 2 — spatial_audit: Reduce Z-Fighting Noise (HIGH)

**File**: `src/godotiq/tools/spatial/__init__.py` (the `godotiq_spatial_audit` tool)

### Problem
336 out of 435 issues (77%) are z-fighting between floor tiles and walls at Y=0.0. This is structural in any project using modular tile kits (Kenney, etc.). The tool output is unusable because the real problems (4 scale mismatches) are buried under noise.

### Fix — Two changes:

#### 2a. Add `min_severity` parameter

Add a parameter to filter output by minimum severity level:

```python
async def godotiq_spatial_audit(
    scene: str,
    checks: list[str] | None = None,
    min_severity: str = "warning",     # NEW — "critical", "warning", "info"
    ctx: Context = None,
) -> dict:
    """...

    Args:
        ...
        min_severity: Minimum severity to include in results. Default "warning"
            filters out info-level issues. Use "info" to see everything.
    """
```

Apply the filter AFTER all checks run but BEFORE building the output. The `by_severity` counts in the output should still show ALL counts (so the agent knows info issues exist), but the `issues` list only includes issues at or above `min_severity`.

Severity ordering: critical > warning > info.

```python
SEVERITY_ORDER = {"critical": 3, "warning": 2, "info": 1}
min_level = SEVERITY_ORDER.get(min_severity, 2)
filtered_issues = [i for i in all_issues if SEVERITY_ORDER.get(i["severity"], 0) >= min_level]
```

The output should include a note when filtering is active:
```python
{
    "total_issues": 435,           # total BEFORE filter
    "shown_issues": 99,            # total AFTER filter
    "filtered_by": "warning",      # which min_severity was applied
    "by_severity": {"critical": 0, "warning": 340, "info": 95},  # full counts always
    "issues": [...]                # only warning + critical
}
```

#### 2b. Downgrade z-fighting between siblings to "info"

In the z-fighting check, when both nodes share the same parent node in the scene tree, downgrade severity from "warning" to "info". Sibling nodes at the same Y are almost always modular kit pieces (floor tiles next to walls).

**How to determine same parent:** The `WorldNode` has `full_path` (e.g., `"Building/Floor/FloorTile_3_2"`). Extract parent by splitting on `/` and taking all but last segment. If two z-fighting nodes have the same parent path → downgrade to "info".

If they DON'T share a parent (e.g., a rug placed on a floor from a different branch of the scene tree), keep as "warning" — that's likely a real issue.

### Tests to add: `tests/test_tools/test_spatial_audit.py`

```python
def test_spatial_audit_min_severity_filter():
    """min_severity='warning' filters out info issues but shows them in counts."""
    # Use a scene with known z-fighting (info after 2b) + scale mismatch (warning)
    # Result: issues list has only warnings+critical, but by_severity shows info count too

def test_spatial_audit_min_severity_info():
    """min_severity='info' shows everything (same as before)."""

def test_spatial_audit_sibling_z_fighting_downgraded():
    """Two nodes with same parent at same Y → z_fighting severity is 'info' not 'warning'."""

def test_spatial_audit_non_sibling_z_fighting_stays_warning():
    """Two nodes with different parents at same Y → z_fighting stays 'warning'."""
```

---

## Fix 3 — suggest_scale: Work Without Specific Model Path (MEDIUM)

**File**: `src/godotiq/tools/assets/__init__.py` (the `godotiq_suggest_scale` tool)

### Problem
An agent asked "I need to add a printer, what scale?" and tried `asset_type: "printer"`. The tool requires `model` (a res:// path), but in the real use case the agent doesn't have the model file yet — they want to know "what scale do printers use in this project?"

### Fix
Make `model` optional. When `model` is None, the tool switches to **reference mode**: searches the scene for existing objects matching the `intended_use` query and returns their scale statistics.

```python
async def godotiq_suggest_scale(
    scene: str,
    model: str | None = None,           # CHANGED: was required, now optional
    near_node: str | None = None,
    intended_use: str = "",
    ctx: Context = None,
) -> dict:
    """Recommend scale and position for placing a model in a scene.

    Two modes:
    - With model: analyzes similar objects in scene to suggest scale for this specific model.
    - Without model: returns scale statistics for objects matching intended_use query
      (e.g., "printer", "furniture", "product"). Useful when you haven't imported the model yet.

    Args:
        scene: Path to .tscn scene.
        model: Optional path to .glb/.gltf/.tscn model file. If omitted, returns reference scales.
        near_node: Optional node name to place near.
        intended_use: Semantic hint like "printer", "furniture", "shelf", "vehicle".
    """
```

**When model is None:**
1. Require `intended_use` to be non-empty. If both model and intended_use are empty, return error: `"Provide either 'model' (path to asset) or 'intended_use' (e.g., 'printer') to get scale recommendations."`
2. Search ALL world nodes in the scene for matches:
   - Node name contains `intended_use` (case-insensitive)
   - OR instance_source filename contains `intended_use`
   - OR ext_resource mesh/model path contains `intended_use`
3. Collect unique scales from matching nodes
4. Return summary:

```python
{
    "mode": "reference",                    # vs "model" when model is provided
    "scene": "res://main.tscn",
    "query": "printer",
    "matches_found": 4,
    "references": [
        {"name": "PrinterModel", "scale": [0.35, 0.35, 0.35], "position": [x, y, z],
         "instance_source": "res://scenes/equipment/fdm_printer.tscn"},
        ...
    ],
    "scale_range": {"min": [0.15, 0.15, 0.15], "max": [0.35, 0.35, 0.35]},
    "recommended_scale": [0.35, 0.35, 0.35],  # median or mode of matches
    "note": "4 printer-related objects found. Scales range 0.15-0.35. Most common: 0.35 (FDM printers on tables at Y=0.49)."
}
```

**When model IS provided:** Existing behavior unchanged. Just ensure it doesn't break.

### Tests to add: `tests/test_tools/test_suggest_scale.py`

```python
def test_suggest_scale_reference_mode():
    """model=None, intended_use="printer" → returns reference scales from matching nodes."""
    # Scene with 2 printer nodes at scale 0.35 → recommended_scale = [0.35, 0.35, 0.35]

def test_suggest_scale_reference_mode_no_match():
    """model=None, intended_use="dragon" → returns 0 matches with helpful message."""

def test_suggest_scale_both_empty_error():
    """model=None, intended_use="" → error message asking for one or the other."""

def test_suggest_scale_model_still_works():
    """Existing model-based behavior unchanged when model is provided."""
```

---

## Fix 4 — scene_map: Reduce Token Bloat (MEDIUM)

**File**: `src/godotiq/tools/spatial/__init__.py` (the `godotiq_scene_map` tool)

### Problem
A single call with `radius=8, detail="full"` generated 10.1k tokens (MCP warning). On large scenes, this fills the agent's context window quickly.

### Fix — Three changes:

#### 4a. Lower default nearby node cap

If there's already a cap on nearby nodes returned (check current implementation), ensure it's ≤ 20 for `detail="normal"` and ≤ 10 for `detail="brief"`. For `detail="full"`, cap at 30.

If there's no cap currently, add one:
```python
MAX_NEARBY = {"brief": 10, "normal": 20, "full": 30}
```

Apply after distance sorting (closest first), before building output.

#### 4b. Compact nearby node output for "brief" and "normal"

For `detail="brief"`, nearby nodes should only include: `name`, `type`, `distance`, `direction`. No properties, no groups, no scripts.

For `detail="normal"`, include: `name`, `type`, `distance`, `direction`, `groups`, `has_script`. No full properties.

Only `detail="full"` should include all properties, instance_source, script path, etc.

Check the current implementation — if brief/normal already omit fields, good. If not, add the filtering.

#### 4c. Add `token_hint` to output

Add a field to help agents understand the response size:

```python
output["token_hint"] = "brief" if detail == "brief" else "normal"  # just echo back
output["nearby_count"] = len(nearby_nodes)
output["nearby_capped"] = len(nearby_nodes) >= MAX_NEARBY[detail]
# If capped:
output["nearby_total_in_radius"] = total_before_cap
```

This way the agent knows there are more nodes it didn't see.

### Tests to add: `tests/test_tools/test_scene_map.py`

```python
def test_scene_map_nearby_cap_brief():
    """detail='brief' caps nearby nodes at 10."""
    # Create scene with 15+ nodes in radius → only 10 returned

def test_scene_map_nearby_cap_normal():
    """detail='normal' caps nearby nodes at 20."""

def test_scene_map_brief_compact_output():
    """detail='brief' nearby nodes only have name, type, distance, direction."""
    # Verify no 'groups', 'script', 'properties' keys in nearby entries

def test_scene_map_capped_shows_total():
    """When capped, output includes nearby_total_in_radius."""
```

---

## Fix 5 — project_summary: Add Architectural Context (MEDIUM)

**File**: `src/godotiq/tools/memory/__init__.py` (the `godotiq_project_summary` tool)

### Problem
The summary returns file counts and autoload names, but not enough architectural intelligence for an agent to start working. Missing: signal health, autoload roles, spatial health. The agent had to read CLAUDE.md to compensate — but most projects don't have a CLAUDE.md.

### Fix
Enrich the `detail="full"` output with three new sections. These use data already available from the session — no new parsing needed.

#### 5a. Autoloads with role hint

Instead of just listing autoload names, add a role hint derived from the class_name and script analysis:

```python
"autoloads": [
    {"name": "GameManager", "path": "res://scripts/managers/game_manager.gd",
     "signals_defined": 5, "signals_emitted": 3,
     "referenced_by_count": 12},
    {"name": "EconomyManager", "path": "res://scripts/managers/economy_manager.gd",
     "signals_defined": 4, "signals_emitted": 8,
     "referenced_by_count": 15},
    ...
]
```

For each autoload, look up the script in `session._gd_scripts`, count its signals, and count how many other scripts reference it (via `autoload_refs`). This gives the agent a quick sense of which autoloads are central.

**Only in detail="full".** For "normal", keep the current list of names. For "brief", keep just names.

#### 5b. Signal health summary

Add a quick health check computed from signal data (reuse the logic already in signal_map):

```python
"signal_health": {
    "total_defined": 135,
    "total_emissions": 211,
    "total_connections": 252,
    "orphan_count": 40,          # emitted but 0 listeners
    "top_signals": [             # top 5 most connected
        {"name": "item_added", "listeners": 6},
        {"name": "research_completed", "listeners": 6},
        {"name": "item_removed", "listeners": 5},
        {"name": "level_up", "listeners": 4},
        {"name": "research_started", "listeners": 4},
    ]
}
```

**Implementation:** This is essentially running the signal_map aggregation logic but only computing the summary stats. Factor out the aggregation into a shared helper if it isn't already, or just compute inline.

To compute this efficiently:
1. Iterate all scripts, collect all signal definitions → total_defined
2. Iterate all scripts, collect all signal_emissions → total_emissions
3. Iterate all scripts, collect all signal_connections → total_connections
4. For each defined signal, count how many connections reference it → find orphans (0 connections) and busiest (top 5)

**Only in detail="full".**

#### 5c. Project health overview

Add a one-line health assessment:

```python
"health": {
    "orphan_signals": 40,
    "scripts_without_class_name": N,   # quick convention check
    "note": "40 orphan signals (signals emitted but never consumed). Consider cleanup."
}
```

To compute `scripts_without_class_name`: iterate `session._gd_scripts`, count scripts where `gd.class_name` is empty/None AND the script is not an autoload (autoloads are exempt per Godot convention).

**Only in detail="full".**

### Tests to add: `tests/test_tools/test_project_summary.py`

```python
def test_project_summary_full_has_autoload_details():
    """detail='full' includes autoload signal counts and reference counts."""
    # Check that autoloads list contains dicts with 'signals_defined' and 'referenced_by_count'

def test_project_summary_full_has_signal_health():
    """detail='full' includes signal_health with totals and top_signals."""

def test_project_summary_full_has_health():
    """detail='full' includes health with orphan_signals count."""

def test_project_summary_normal_no_extras():
    """detail='normal' does NOT include signal_health or health sections."""
    # Verify these keys are absent

def test_project_summary_brief_unchanged():
    """detail='brief' output unchanged from before."""
```

---

## Implementation Order

These fixes are independent (different files). Implement in this order:

1. **Fix 1** (signal_map) — most critical, wrong output
2. **Fix 2** (spatial_audit) — second most critical, noise
3. **Fix 3** (suggest_scale) — UX improvement
4. **Fix 4** (scene_map) — token optimization
5. **Fix 5** (project_summary) — enrichment

## Files Modified

```
src/godotiq/tools/code/__init__.py       # Fix 1: signal_map missing check
src/godotiq/tools/spatial/__init__.py     # Fix 2: spatial_audit + Fix 4: scene_map
src/godotiq/tools/assets/__init__.py      # Fix 3: suggest_scale optional model
src/godotiq/tools/memory/__init__.py      # Fix 5: project_summary enrichment

tests/test_tools/test_signal_map.py       # +2 tests
tests/test_tools/test_spatial_audit.py    # +4 tests
tests/test_tools/test_suggest_scale.py    # +4 tests (or modify existing)
tests/test_tools/test_scene_map.py        # +4 tests
tests/test_tools/test_project_summary.py  # +5 tests
```

New fixtures may be needed for the signal_map Events bus test. Create minimal .gd files:
```
tests/fixtures/scripts/events_bus.gd      # signal order_accepted\nsignal day_started
tests/fixtures/scripts/bus_emitter.gd     # Events.order_accepted.emit()
tests/fixtures/scripts/broken_emitter.gd  # emit_signal("totally_fake_signal")
```

## DO NOT
- DO NOT modify any parser files (tscn_parser.py, gd_parser.py, scene_resolver.py, project_index.py)
- DO NOT modify session.py unless absolutely necessary
- DO NOT change existing test assertions — only ADD new tests
- DO NOT add external dependencies
- DO NOT change tool names or remove existing parameters
- DO NOT break backward compatibility — all changes are additive (new params have defaults matching old behavior)

## Completion Criteria
1. `pytest tests/ -v` → ALL pass (existing 425 + new ~19 tests ≈ 444+)
2. signal_map find="missing" on Events bus project → 0 false positives
3. spatial_audit default output on PFT → no z-fighting flood (most downgraded to info, filtered by default)
4. suggest_scale with model=None, intended_use="printer" → returns reference scales
5. scene_map detail="brief" → compact output, capped at 10 nearby
6. project_summary detail="full" → includes autoload details, signal_health, health
