# Sprint 3e — Hardening Implementation Plan

## Overview

This sprint fixes 5 concrete reliability problems found during real-world testing of GodotIQ on Print Farm Tycoon (86 scripts, 41 scenes, 876 nodes, 28 autoloads). Every fix addresses wrong output, excessive noise, or UX gaps that make the MCP tools unreliable for an AI agent. No new tools are added — this is pure hardening.

**Current state:** 425 tests, 13 tools, Sprint 3d complete.
**Target state:** ~444+ tests, same 13 tools, all producing correct/usable output.

---

## Architecture Decisions

### AD-1: Project-wide signal lookup (Fix 1)
The signal_map `find="missing"` mode currently checks only the emitting file for a signal definition. This produces false positives whenever the Godot Event Bus pattern is used (signals defined in an autoload, emitted from other files). The fix builds a project-wide set of all defined signal names once, then checks emissions against this set. This is O(S) to build (S = total signals across all scripts) and O(1) per emission lookup. No class hierarchy resolution is needed — if a signal name exists anywhere in the project, it is not "missing".

### AD-2: Severity-based filtering with sibling downgrade (Fix 2)
The spatial_audit z-fighting check floods output with sibling tile pairs (77% of all issues). Two changes: (1) downgrade z-fighting severity to "info" when both nodes share the same parent in the scene tree (determined by `full_path`), and (2) add a `min_severity` parameter defaulting to "warning" that filters the issues list but preserves full severity counts in summary. This means the default output automatically hides tile noise while still showing the agent that info-level issues exist.

### AD-3: Reference mode for suggest_scale (Fix 3)
Making `model` optional enables a "reference mode" where the tool searches for existing objects matching an `intended_use` query and returns their scale statistics. This covers the real use case where an agent doesn't have a model file yet but wants to know what scale existing similar objects use. Scale computation uses median-per-axis, consistent with the existing model mode.

### AD-4: Detail-dependent focus caps (Fix 4)
The scene_map nearby node cap changes from a flat 30 to detail-dependent values: brief=10, normal=20, full=30. This only affects focus mode — non-focus mode keeps its cap of 50. Output includes `nearby_capped` and `nearby_total_in_radius` so the agent knows when results are truncated. Brief/normal detail levels are verified to exclude verbose fields (properties, metadata).

### AD-5: Architectural enrichment for project_summary (Fix 5)
The "full" detail level gains three new sections: enriched autoloads (signal/reference counts), signal_health (totals, orphan count, top 5 most connected), and project health (orphan signals, scripts without class_name). All computed from existing session data — no new parsing. Only affects `detail="full"`.

---

## Fix 1 — signal_map: Project-Wide Missing Signal Check

**File:** `src/godotiq/tools/code/__init__.py`

### What Changes

The `_build_signal_map` function (or wherever the missing-signal logic lives) needs modification in the `find="missing"` code path.

**Before the per-file loop**, build a set of all signal names defined across every script in the project:

```python
def _collect_all_defined_signals(session) -> set[str]:
    """Build project-wide set of all defined signal names."""
```

This iterates `session._gd_scripts.values()`, collecting `sig.name` from each script's `.signals` list.

**In the missing check**, replace the per-emitter-file lookup with a check against this project-wide set. A signal emission is only "missing" if the signal name does not appear in the project-wide set.

### What Doesn't Change

- The "all", "orphans", and "busiest" find modes are unaffected
- The scope filtering logic is unaffected
- The output format is unchanged — missing entries still have `name`, `emitted_in`, and `note`
- The note text should update to say "Emitted but no signal definition found in the project" (was "...in that file")

### Test Strategy — Programmatic Injection (MANDATORY)

**Do NOT add fixture files to `tests/fixtures/sample_project/`.** This would change signal counts that existing tests assert on (e.g., `test_signal_map_all` asserts exactly 6 defined signals). Adding new scripts would break those assertions, violating the "no existing test assertion changes" constraint.

Instead, use **programmatic injection** into `session._gd_scripts`. The existing `test_signal_map_missing` test (lines 52-61 of test_signal_map.py) already demonstrates this pattern — it creates `GdScript` objects with specific signals/emissions and injects them into the session.

For the event bus test:
1. Load the standard `session` fixture
2. Inject a mock `events_bus.gd` GdScript with `signals=[GdSignal("order_accepted", ...), GdSignal("day_started", ...)]`
3. Inject a mock `bus_emitter.gd` GdScript with `signal_emissions=[GdSignalEmission("order_accepted", ...)]`
4. Run the signal_map tool with `find="missing"`
5. Verify 0 missing signals

For the truly-missing test:
1. Inject a mock `broken_emitter.gd` with `signal_emissions=[GdSignalEmission("totally_fake_signal", ...)]`
2. No script defines `signal totally_fake_signal`
3. Verify 1 missing signal

**Note on parser:** The GD parser regex `(\w+)\.emit\(` matches `signal_name.emit()` (Godot 4 style). It does NOT match `emit_signal("name")` (Godot 3 style). Since we use programmatic injection, the parser is bypassed entirely — we directly construct GdScript objects with the emissions we need.

### Tests

1. **test_signal_map_events_bus_not_missing** — Load session with events_bus.gd and bus_emitter.gd. Call signal_map with `find="missing"`. Verify no missing signals returned (the `order_accepted` emission in bus_emitter.gd is covered by the definition in events_bus.gd).

2. **test_signal_map_truly_missing** — Load session with broken_emitter.gd. Call signal_map with `find="missing"`. Verify exactly 1 missing signal: `totally_fake_signal`.

---

## Fix 2 — spatial_audit: Severity Filtering + Sibling Z-Fighting Downgrade

**File:** `src/godotiq/tools/spatial/__init__.py`

### What Changes — Part A: min_severity Parameter

Add `min_severity: str = "warning"` parameter to `godotiq_spatial_audit`.

Define severity ordering as a module-level constant using **descending rank values** for ascending sort compatibility:
```python
_SEVERITY_ORDER: dict[str, int]  # {"critical": 0, "warning": 1, "info": 2}
```

**Important:** The existing sort uses ascending order (`severity_order.get(...)`) to put warnings before info. The new constant must preserve this: lower value = higher severity. The `min_severity` filter uses `<=` threshold: `_SEVERITY_ORDER.get(issue_severity, 99) <= _SEVERITY_ORDER.get(min_severity, 1)`.

**Input validation:** If `min_severity` is not a recognized value ("critical", "warning", "info"), fall back to "warning" (the default).

After all checks run and `all_issues` is built, apply the filter:
1. Count all issues by severity → `by_severity` dict (computed BEFORE filtering and capping)
2. Filter `all_issues` to only include issues at or above `min_severity` level
3. Update output to include: `total_issues` (pre-filter count — note: this key already exists in current output with same semantics), `shown_issues` (post-filter count), `filtered_by` (the min_severity value), `by_severity` (full counts), and `issues` (filtered list)

The existing `_MAX_AUDIT_ISSUES` cap should apply AFTER severity filtering (cap the filtered list, not the full list).

### What Changes — Part B: Sibling Z-Fighting Downgrade

In the `_check_z_fighting` function, after detecting a z-fighting pair, determine if they share a parent:

```python
def _parent_path(full_path: str) -> str:
    """Extract parent path from full_path (e.g., 'A/B/C' → 'A/B')."""
```

If both nodes have the same parent path → severity = "info". Otherwise → severity = "warning".

Handle root-level nodes (no `/` in path) — these share "no parent" and should be treated as siblings (downgrade to info, since root-level meshes next to each other are typically intentional).

### What Doesn't Change

- All other checks (floating_objects, scale_mismatch, empty_markers, overlapping, extreme_positions) keep their current severity
- The "critical" level exists in the ordering but no checks use it yet (future-proofing)
- Check selection via `checks` parameter is unchanged
- The existing `_MAX_AUDIT_ISSUES` constant stays at 50

### Tests

1. **test_spatial_audit_min_severity_filter** — Create a scene with both warning-level and info-level issues. Call with default `min_severity="warning"`. Verify: issues list has no info entries, but `by_severity` shows info count > 0.

2. **test_spatial_audit_min_severity_info** — Same scene, call with `min_severity="info"`. Verify: all issues returned including info-level.

3. **test_spatial_audit_sibling_z_fighting_downgraded** — Create a scene with two mesh nodes under the same parent at the same Y position. Verify z-fighting issue has severity "info".

4. **test_spatial_audit_non_sibling_z_fighting_stays_warning** — Two mesh nodes at same Y but under different parents. Verify severity stays "warning".

For test fixtures: Construct WorldNode objects programmatically with specific full_paths and positions. The existing test pattern for spatial tools likely already does this.

---

## Fix 3 — suggest_scale: Reference Mode Without Model

**File:** `src/godotiq/tools/assets/__init__.py`

### What Changes

The `godotiq_suggest_scale` MCP tool function signature changes: `model: str` becomes `model: str | None = None`. Since this is the `@mcp.tool()` decorated function, FastMCP will generate the correct JSON schema making `model` optional in the MCP protocol. There is only ONE function to change (the MCP tool IS the implementation entry point, which then calls internal helpers).

Add a guard at the top: if both `model` is None and `intended_use` is empty → return error dict with message.

When `model` is None, enter reference mode:

```python
def _reference_mode(session, scene_path, intended_use, resolved_scene, world_nodes) -> dict:
    """Search scene for nodes matching intended_use query, return scale statistics."""
```

**Matching logic:** For each WorldNode in the scene:
- Check if `intended_use` appears in `wn.node.name` (case-insensitive)
- Check if `intended_use` appears in `wn.instance_source` filename (case-insensitive)
- Can also check ext_resource references if easily available from the parsed TscnScene

**Output construction:**
- `mode: "reference"` (vs `"model"` when model is provided)
- `references`: list of matching nodes with name, scale, position, instance_source
- `scale_range`: min/max across all axes
- `recommended_scale`: median per axis (reuse existing median logic)
- `note`: human-readable summary of findings

When `model` IS provided, the existing code path runs unchanged. Add `"mode": "model"` to its output for consistency.

### What Doesn't Change

- The 3-tier matching system when model is provided
- Position suggestion logic
- Scale calculation method (median per axis)
- All existing parameters keep their defaults

### Edge Cases

- Zero matches: return `matches_found: 0`, `recommended_scale: None`, note suggesting to try a broader query
- One match: return that single node's scale as recommended
- All matches have identical scale: scale_range min == max, recommended = that scale

### Tests

1. **test_suggest_scale_reference_mode** — Scene with nodes named "PrinterModel" at known scales. Call with `model=None, intended_use="printer"`. Verify references found, recommended_scale is median.

2. **test_suggest_scale_reference_mode_no_match** — Same scene, `intended_use="dragon"`. Verify `matches_found: 0`.

3. **test_suggest_scale_both_empty_error** — `model=None, intended_use=""`. Verify error message returned.

4. **test_suggest_scale_model_still_works** — Provide `model` as before. Verify existing behavior unchanged.

For test fixtures: Use the existing sample_project scene which has printer/equipment nodes, or create WorldNode objects programmatically.

---

## Fix 4 — scene_map: Detail-Dependent Caps and Token Hints

**File:** `src/godotiq/tools/spatial/__init__.py`

### What Changes — Part A: Detail-Dependent Caps

Replace the single `_MAX_NODES_WITH_FOCUS = 30` with:

```python
_MAX_NEARBY: dict[str, int]  # {"brief": 10, "normal": 20, "full": 30}
```

In the focus-mode code path, after sorting by distance, cap at `_MAX_NEARBY[detail]` instead of the flat 30.

Non-focus mode keeps `_MAX_NODES_NO_FOCUS = 50` unchanged.

### What Changes — Part B: Compact Output Verification

Research shows the detail levels already differentiate fields:
- `"brief"`: name, type, position, depth
- `"normal"`: adds full_path, rotation_deg, scale, is_instance, instance_source, groups, script

For focus mode (nearby nodes), verify that:
- Brief nearby entries only include: name, type, `distance_to_focus`, `direction_from_focus` (no groups, no script, no properties, no position, no depth)
- Normal nearby entries include: name, type, `distance_to_focus`, `direction_from_focus`, groups, has_script (not full script path, not properties)

**Key naming:** Keep existing key names `distance_to_focus` and `direction_from_focus` — do NOT rename to `distance`/`direction`. This preserves backward compatibility.

If the current implementation includes extra fields for brief/normal, strip them. The existing non-focus node building may already handle this, but the focus-mode nearby node building might use a different code path.

### What Changes — Part C: Token Hints

Add to output dict:
- `token_hint`: echo back the detail level
- `nearby_count`: length of nearby list (after cap)
- `nearby_capped`: boolean, True if nodes were truncated (`total_before_cap > cap`, not `len >= cap`)
- `nearby_total_in_radius`: total node count before cap (only present when `nearby_capped` is True)

### What Doesn't Change

- Non-focus mode cap (50)
- Focus node matching (4-tier fuzzy search)
- Spatial summary, groups_summary, distance_matrix behavior
- The `radius` parameter default and behavior

### Tests

1. **test_scene_map_nearby_cap_brief** — Create a scene with 15+ nodes within radius of a focus node. Call with `detail="brief"`. Verify exactly 10 returned. Verify `nearby_capped=True` and `nearby_total_in_radius >= 15`.

2. **test_scene_map_nearby_cap_normal** — Same scene, `detail="normal"`. Verify at most 20 returned.

3. **test_scene_map_brief_compact_output** — Call with `detail="brief"` and focus. Inspect nearby node dicts. Verify keys are ONLY name, type, `distance_to_focus`, `direction_from_focus`. No groups, script, properties, instance_source, position, depth.

4. **test_scene_map_capped_shows_total** — When capped, verify `nearby_total_in_radius` key exists with value > cap.

For test fixtures: Need a scene with many nodes clustered near a focus point. Programmatically construct WorldNode objects or use/extend existing fixtures.

---

## Fix 5 — project_summary: Architectural Enrichment

**File:** `src/godotiq/tools/memory/__init__.py`

### What Changes

Three new sections added to `detail="full"` output only.

### 5a: Enriched Autoloads

In the existing `autoload_analysis` section of the full output, add numeric summary fields per autoload:
- `signals_defined`: count of signals in the autoload's script
- `signals_emitted`: count of signal emissions in the autoload's script
- `referenced_by_count`: count of other scripts that reference this autoload (via `autoload_refs`)

To compute `referenced_by_count`: use the existing `session.get_autoload_dependents(name)` method which already returns the list of scripts referencing an autoload. Simply take `len()` of the result. No new iteration needed.

### 5b: Signal Health Summary

Add a `signal_health` key to the full output:

```python
def _compute_signal_health(session) -> dict:
    """Compute project-wide signal health metrics."""
```

Algorithm:
1. Iterate all scripts → count total signal definitions, total emissions, total connections
2. Build a dict of signal_name → connection_count (from all signal_connections across all scripts, keyed by target signal name)
3. For each emitted signal name, check if it has > 0 connections → orphan if 0
4. Sort by connection count → top 5 most connected

The connection counting needs care: `GdSignalConnection.signal_source` contains the signal name. Count how many connections exist per signal name across all scripts.

### 5c: Project Health

Add a `health` key to the full output:

```python
def _compute_project_health(session) -> dict:
    """Quick health assessment of the project."""
```

Fields:
- `orphan_signals`: same as signal_health.orphan_count (or reference it)
- `scripts_without_class_name`: count of scripts where `gd.class_name` is falsy AND the script is not an autoload. Autoloads are exempt because Godot convention doesn't require class_name on autoloads.
- `note`: human-readable summary string

### What Doesn't Change

- `detail="brief"` output is completely unchanged
- `detail="normal"` output is completely unchanged
- Existing fields in `detail="full"` are unchanged
- `_MAX_SCENE_STRUCTURE = 10` and `_MAX_PUBLIC_FUNCTIONS = 5` caps stay

### Tests

1. **test_project_summary_full_has_autoload_details** — Call with `detail="full"`. Verify autoload_analysis entries contain `signals_defined` and `referenced_by_count` as integers.

2. **test_project_summary_full_has_signal_health** — Call with `detail="full"`. Verify `signal_health` key exists with `total_defined`, `total_emissions`, `total_connections`, `orphan_count`, `top_signals`.

3. **test_project_summary_full_has_health** — Call with `detail="full"`. Verify `health` key exists with `orphan_signals` and `scripts_without_class_name`.

4. **test_project_summary_normal_no_extras** — Call with `detail="normal"`. Verify `signal_health` and `health` keys are absent.

5. **test_project_summary_brief_unchanged** — Call with `detail="brief"`. Verify output structure matches existing expectations. No new keys.

---

## Implementation Order

1. **Fix 1** (signal_map) — most critical, wrong output, independent file
2. **Fix 2** (spatial_audit) — noise reduction, shares file with Fix 4 but different functions
3. **Fix 3** (suggest_scale) — UX improvement, independent file
4. **Fix 4** (scene_map) — token optimization, same file as Fix 2 but different function
5. **Fix 5** (project_summary) — enrichment, independent file

Fixes 1, 3, and 5 modify different files and can be done in any order. Fixes 2 and 4 share `spatial/__init__.py` but modify different functions (audit vs map) — they can be done in either order as long as they don't create merge conflicts.

## Files Modified

| File | Fixes |
|------|-------|
| `src/godotiq/tools/code/__init__.py` | Fix 1 |
| `src/godotiq/tools/spatial/__init__.py` | Fixes 2, 4 |
| `src/godotiq/tools/assets/__init__.py` | Fix 3 |
| `src/godotiq/tools/memory/__init__.py` | Fix 5 |
| `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 |
| `tests/test_tools/test_scene_map.py` | +4 tests |
| `tests/test_tools/test_project_summary.py` | +5 tests |
| (No new fixture files — programmatic injection used for Fix 1 tests) | |

## Completion Criteria

1. `pytest tests/ -v` → ALL pass (existing 425 + ~19 new ≈ 444+)
2. signal_map `find="missing"` with event bus scripts → 0 false positives
3. spatial_audit default output → sibling z-fighting downgraded, filtered by default
4. suggest_scale `model=None, intended_use="printer"` → reference scales returned
5. scene_map `detail="brief"` with focus → capped at 10, compact fields
6. project_summary `detail="full"` → includes signal_health, health, enriched autoloads
