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

# Section 06: script_ops -- Filesystem Script Tool

## Overview

This section implements `godotiq_script_ops`, a **Python-side filesystem tool** that provides intelligent script reading, writing, patching, and creation. It does **not** require the Godot addon or WebSocket bridge -- it operates entirely on the local filesystem.

The tool lives at `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/tools/bridge/script_ops.py`. Despite the `bridge/` directory name, this is a filesystem-only tool grouped here by sprint convention (the `bridge/` directory already contains `editor_context.py` which also has a filesystem-only path).

**Dependencies**: None. This section is fully independent and can be implemented in parallel with any other section.

**Blocked by this section**: Section 08 (tool registration) imports `script_ops` from this module.

---

## Tests First

Create `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_script_ops.py`.

All tests use `tmp_path` (pytest fixture for temp directories) and `monkeypatch.setenv` for `GODOTIQ_PROJECT_ROOT`. Never use raw `os.environ` manipulation.

```python
"""Tests for script_ops filesystem tool."""

from __future__ import annotations

import pytest
from pathlib import Path

from godotiq.tools.bridge.script_ops import script_ops, _resolve_path, _is_protected, _validate_gdscript


class TestResolvePath:
    """Path resolution and containment validation."""

    def test_res_prefix_resolves_relative_to_project_root(self, tmp_path: Path, monkeypatch) -> None:
        """_resolve_path with res:// prefix resolves relative to GODOTIQ_PROJECT_ROOT."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        result = _resolve_path("res://scripts/player.gd")
        assert result == tmp_path / "scripts" / "player.gd"

    def test_relative_path_resolves_relative_to_project_root(self, tmp_path: Path, monkeypatch) -> None:
        """_resolve_path with relative path resolves relative to GODOTIQ_PROJECT_ROOT."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        result = _resolve_path("scripts/player.gd")
        assert result == tmp_path / "scripts" / "player.gd"

    def test_path_traversal_raises_error(self, tmp_path: Path, monkeypatch) -> None:
        """_resolve_path with ../../etc/passwd raises error (containment check)."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        with pytest.raises(ValueError, match="outside project"):
            _resolve_path("../../etc/passwd")

    def test_res_traversal_raises_error(self, tmp_path: Path, monkeypatch) -> None:
        """_resolve_path with res://../.. traversal raises error."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        with pytest.raises(ValueError, match="outside project"):
            _resolve_path("res://../../etc/passwd")


class TestIsProtected:
    """Protected file pattern matching."""

    def test_project_godot_is_protected(self) -> None:
        """_is_protected returns True for project.godot."""
        assert _is_protected("project.godot") is True

    def test_godot_dir_is_protected(self) -> None:
        """_is_protected returns True for paths inside .godot/."""
        assert _is_protected(".godot/imported/thing.res") is True

    def test_import_files_are_protected(self) -> None:
        """_is_protected returns True for .import files."""
        assert _is_protected("icon.png.import") is True

    def test_saves_dir_is_protected(self) -> None:
        """_is_protected returns True for paths inside saves/."""
        assert _is_protected("saves/game.sav") is True

    def test_regular_gd_file_not_protected(self) -> None:
        """_is_protected returns False for regular .gd files."""
        assert _is_protected("scripts/player.gd") is False


class TestReadMode:
    """Read operation tests."""

    async def test_read_returns_content_and_metadata(self, tmp_path: Path, monkeypatch) -> None:
        """Read mode returns file content, line count, and size."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        f = tmp_path / "test.gd"
        f.write_text("extends Node\n\nfunc _ready():\n\tpass\n", encoding="utf-8")
        result = await script_ops(op="read", path="test.gd")
        assert result["status"] == "OK"
        assert "extends Node" in result["content"]
        assert result["lines"] == 4
        assert result["size_bytes"] > 0

    async def test_read_missing_file_returns_error(self, tmp_path: Path, monkeypatch) -> None:
        """Read mode raises error for missing file."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        result = await script_ops(op="read", path="nonexistent.gd")
        assert result["status"] == "ERROR"
        assert "not found" in result["error"].lower() or "Not found" in result["error"]


class TestWriteMode:
    """Write operation tests."""

    async def test_write_creates_file(self, tmp_path: Path, monkeypatch) -> None:
        """Write mode creates/overwrites file with content."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        result = await script_ops(op="write", path="new_script.gd", content="extends Node\n", validate_before_write=False)
        assert result["status"] == "OK"
        assert result["written"] is True
        assert (tmp_path / "new_script.gd").read_text() == "extends Node\n"

    async def test_write_rejects_protected_files(self, tmp_path: Path, monkeypatch) -> None:
        """Write mode rejects protected files."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        result = await script_ops(op="write", path="project.godot", content="test")
        assert result["status"] == "BLOCKED"

    async def test_write_with_validation(self, tmp_path: Path, monkeypatch) -> None:
        """Write mode with validate_before_write=True runs GDScript validation."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        content = "extends Node\nvar speed = 10\n"
        result = await script_ops(op="write", path="test.gd", content=content, validate_before_write=True)
        assert result["status"] == "OK"
        # validation_issues may or may not be empty depending on the content
        assert "validation_issues" in result

    async def test_write_dry_run_does_not_write(self, tmp_path: Path, monkeypatch) -> None:
        """Write mode with dry_run=True validates but doesn't write."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        result = await script_ops(op="write", path="test.gd", content="extends Node\n", dry_run=True, validate_before_write=False)
        assert result["status"] == "DRY_RUN"
        assert result["written"] is False
        assert not (tmp_path / "test.gd").exists()

    async def test_write_path_traversal_blocked(self, tmp_path: Path, monkeypatch) -> None:
        """Write mode path traversal blocked."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        result = await script_ops(op="write", path="../../etc/evil.gd", content="test")
        assert result["status"] == "ERROR"


class TestPatchMode:
    """Patch operation tests."""

    async def test_patch_single_replacement(self, tmp_path: Path, monkeypatch) -> None:
        """Patch mode applies single find-and-replace."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        f = tmp_path / "test.gd"
        f.write_text("var speed = 10\n", encoding="utf-8")
        result = await script_ops(
            op="patch", path="test.gd",
            patches=[{"search": "speed = 10", "replace": "speed: float = 20.0"}],
            validate_before_write=False,
        )
        assert result["status"] == "OK"
        assert result["written"] is True
        assert "speed: float = 20.0" in f.read_text()

    async def test_patch_multiple_sequential(self, tmp_path: Path, monkeypatch) -> None:
        """Patch mode applies multiple patches sequentially."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        f = tmp_path / "test.gd"
        f.write_text("var a = 1\nvar b = 2\n", encoding="utf-8")
        result = await script_ops(
            op="patch", path="test.gd",
            patches=[
                {"search": "var a = 1", "replace": "var a: int = 1"},
                {"search": "var b = 2", "replace": "var b: int = 2"},
            ],
            validate_before_write=False,
        )
        assert result["status"] == "OK"
        content = f.read_text()
        assert "var a: int = 1" in content
        assert "var b: int = 2" in content

    async def test_patch_rejects_non_unique_search(self, tmp_path: Path, monkeypatch) -> None:
        """Patch mode rejects non-unique search string (appears 0 or 2+ times)."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        f = tmp_path / "test.gd"
        f.write_text("pass\npass\n", encoding="utf-8")
        result = await script_ops(
            op="patch", path="test.gd",
            patches=[{"search": "pass", "replace": "return"}],
            validate_before_write=False,
        )
        assert result["status"] == "ERROR"
        assert len(result["failed"]) > 0

    async def test_patch_line_numbers_before_replacement(self, tmp_path: Path, monkeypatch) -> None:
        """Patch mode returns line numbers calculated BEFORE replacement."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        f = tmp_path / "test.gd"
        f.write_text("line1\nTARGET\nline3\n", encoding="utf-8")
        result = await script_ops(
            op="patch", path="test.gd",
            patches=[{"search": "TARGET", "replace": "REPLACED"}],
            validate_before_write=False,
        )
        assert result["status"] == "OK"
        assert result["applied"][0]["line"] == 2

    async def test_patch_rejects_protected_files(self, tmp_path: Path, monkeypatch) -> None:
        """Patch mode rejects protected files."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        f = tmp_path / "project.godot"
        f.write_text("config_version=5\n", encoding="utf-8")
        result = await script_ops(
            op="patch", path="project.godot",
            patches=[{"search": "5", "replace": "4"}],
        )
        assert result["status"] == "BLOCKED"

    async def test_patch_empty_patches_returns_no_changes(self, tmp_path: Path, monkeypatch) -> None:
        """Patch mode with empty patches list returns unchanged file."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        f = tmp_path / "test.gd"
        f.write_text("extends Node\n", encoding="utf-8")
        result = await script_ops(op="patch", path="test.gd", patches=[])
        assert result["written"] is False or result["status"] == "ERROR"


class TestCreateMode:
    """Create operation tests."""

    async def test_create_new_file(self, tmp_path: Path, monkeypatch) -> None:
        """Create mode creates new file."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        result = await script_ops(op="create", path="new.gd", content="extends Node\n")
        assert result["status"] == "OK"
        assert result["created"] is True
        assert (tmp_path / "new.gd").exists()

    async def test_create_fails_if_exists(self, tmp_path: Path, monkeypatch) -> None:
        """Create mode fails if file already exists."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        (tmp_path / "existing.gd").write_text("old", encoding="utf-8")
        result = await script_ops(op="create", path="existing.gd", content="new")
        assert result["status"] == "ERROR"
        assert "exists" in result["error"].lower()

    async def test_create_rejects_protected_paths(self, tmp_path: Path, monkeypatch) -> None:
        """Create mode rejects protected paths."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        result = await script_ops(op="create", path=".godot/cache.gd", content="test")
        assert result["status"] == "BLOCKED"


class TestValidation:
    """GDScript convention validation."""

    def test_validate_warns_on_missing_type_hints(self) -> None:
        """_validate_gdscript checks for type hints (basic convention check)."""
        content = "extends Node\nvar speed = 10\n"
        issues = _validate_gdscript(content, "test.gd")
        assert len(issues) > 0
        assert any(i["rule"] == "type_hint" for i in issues)
```

---

## Implementation Details

### File to Create

`/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/tools/bridge/script_ops.py`

### Module Purpose

The `script_ops` tool provides four modes of operation on GDScript files:

| Mode | Description |
|------|-------------|
| `read` | Read file content, return with line count and size |
| `write` | Write entire file with optional convention validation |
| `patch` | Find-and-replace with uniqueness checking (safest mode) |
| `create` | Create new file (fails if file already exists) |

### Function Signature

```python
async def script_ops(
    op: str = "read",
    path: str = "",
    content: str = "",
    patches: list[dict] | None = None,
    validate_before_write: bool = True,
    auto_fix: bool = False,
    dry_run: bool = False,
) -> dict:
    """Read, write, patch, or create a script file."""
```

### Helper Functions

Four internal helper functions are needed:

**`_resolve_path(path: str) -> Path`** -- Resolves `res://` or relative paths to an absolute filesystem path using the `GODOTIQ_PROJECT_ROOT` environment variable. After resolution, performs a **containment check**: calls `Path.resolve()` on both the project root and the resolved path, then verifies the resolved path starts with the project root. Raises `ValueError` if the path escapes the project root (e.g., `../../etc/passwd`).

```python
def _resolve_path(path: str) -> Path:
    """Resolve a res:// or relative path to absolute filesystem path.

    Raises ValueError if the resolved path is outside the project root.
    """
```

**`_is_protected(path: str) -> bool`** -- Checks a path string against protected patterns: `project.godot`, `.godot/`, `*.import`, `saves/`. Returns `True` if the file should not be modified.

```python
def _is_protected(path: str) -> bool:
    """Check if path is protected (should not be modified)."""
```

**`_find_line_number(text: str, search: str) -> int`** -- Finds the 1-based line number where a search string starts within text. Returns -1 if not found. Uses `text.find(search)` then counts newlines before that index.

```python
def _find_line_number(text: str, search: str) -> int:
    """Find the line number where search string starts."""
```

**`_validate_gdscript(content: str, path: str) -> list[dict]`** -- Basic GDScript convention validation. Currently checks for `var` declarations without type hints (simple heuristic). Returns a list of issue dicts with `severity`, `rule`, `line`, and `detail` keys.

Important: The validation logic must avoid fragile ternary/operator-precedence expressions. Use explicit `if/else` conditionals instead of inline ternary operators for the `@onready` check and similar logic.

```python
def _validate_gdscript(content: str, path: str) -> list[dict]:
    """Basic GDScript convention validation."""
```

### Key Design Decisions

1. **Path resolution with containment validation**: After resolving `res://` or relative paths against `GODOTIQ_PROJECT_ROOT`, the resolved absolute path must be verified to start with the project root's resolved absolute path. This prevents path traversal attacks like `res://../../etc/passwd`. Use `Path.resolve()` on both paths for symlink-safe comparison.

2. **Protected file patterns**: The following patterns are protected and cannot be modified (write, patch, create, or delete):
   - `project.godot` -- exact filename match
   - `.godot/` -- any path containing the `.godot` directory
   - `*.import` -- any file ending in `.import`
   - `saves/` -- any path containing the `saves` directory

3. **Patch uniqueness enforcement**: Each search string in a patch must appear **exactly once** in the file. Zero matches means "not found" (error). Two or more matches means "ambiguous" (error). This prevents accidental multi-replacements.

4. **Patch line numbers calculated BEFORE replacement**: When reporting which line a patch was applied to, calculate the line number from the **original file content** (or the content as it was before that specific patch), not from the post-replacement content. The spec in the sprint-4b-spec.md initially had a bug where it read the original file for every patch -- the correct approach is to calculate the line number from `text` (the current working copy) BEFORE performing the replacement.

5. **Patch sequential application**: Patches are applied one at a time in order. Earlier patches can change the file content that later patches search against. This is a documented limitation. Each patch's search string is validated against the current state of the file after prior patches have been applied.

6. **No addon needed**: This tool reads `GODOTIQ_PROJECT_ROOT` from the environment and operates purely on the filesystem. It does not import from `godotiq.bridge.session` or use `get_bridge()`.

7. **Error handling via return values**: Unlike bridge tools that raise exceptions, this filesystem tool returns error information in the response dict with appropriate `status` values (`"OK"`, `"ERROR"`, `"BLOCKED"`, `"PARTIAL"`, `"DRY_RUN"`). The MCP wrapper in section-08 will catch any unexpected exceptions.

### Operation-Specific Behavior

**Read mode**:
- Validate that path is not empty
- Resolve path with containment check
- Check file exists; return `ERROR` if not
- Read file as UTF-8
- Return `status`, `path`, `content`, `lines` (newline count + 1), `size_bytes`

**Write mode**:
- Validate path not empty, content not empty
- Resolve path with containment check; return `ERROR` on path traversal
- Check protected files; return `BLOCKED` if protected
- Optionally run `_validate_gdscript` if `validate_before_write=True`
- If `dry_run=True`, return `DRY_RUN` status with validation issues but do not write
- Create parent directories (`mkdir(parents=True, exist_ok=True)`)
- Write content as UTF-8
- Return `status`, `path`, `lines`, `validation_issues`, `written`

**Patch mode**:
- Validate path not empty, file exists, file not protected, patches list not empty
- Resolve path with containment check
- Read current file content
- For each patch in order:
  1. Validate search string is not empty
  2. Check search string appears in current text
  3. Check search string appears exactly once (count == 1)
  4. Calculate line number BEFORE performing the replacement
  5. Apply the replacement: `text = text.replace(search, replace, 1)`
  6. Record in `applied` list
- If any patches failed, return `PARTIAL` (if some succeeded) or `ERROR` (if all failed)
- If all succeeded, optionally validate, handle dry_run, then write

**Create mode**:
- Validate path not empty, content not empty
- Resolve path with containment check
- Check file does NOT already exist; return `ERROR` if it does
- Check not protected; return `BLOCKED` if protected
- Create parent directories
- Write content as UTF-8
- Return `status`, `path`, `created: True`, `lines`

### MCP Annotations (for Section 08)

When this tool is registered in `__init__.py` (Section 08), it uses:

```python
@mcp.tool(annotations={
    "readOnlyHint": False,
    "destructiveHint": True,
    "idempotentHint": False,
    "openWorldHint": False,
})
```

The `openWorldHint: False` reflects that this tool operates only on the local filesystem, not on external systems.

### Module Structure

The module should follow the established pattern seen in other bridge tool files (e.g., `screenshot.py`, `run.py`):

- `from __future__ import annotations` at the top
- Standard library imports only (`os`, `pathlib.Path`)
- Module-level constants (protected patterns)
- Helper functions (not exported, prefixed with `_`)
- Single async public function matching the tool name

The module does NOT import anything from `godotiq.bridge` -- it is purely a filesystem tool that happens to live in the `tools/bridge/` directory for organizational reasons.

---

## Implementation Notes

**Status:** Complete

**Files created:**
- `src/godotiq/tools/bridge/script_ops.py` (310 lines)
- `tests/test_bridge/test_script_ops.py` (255 lines)

**Deviations from plan:**
- **Security fix**: Replaced `str.startswith()` path containment check with `Path.relative_to()` to prevent bypass when project root is a prefix of another directory name.
- **Protected path check**: Rewrote `_is_protected` to use `Path.as_posix()` parts-based matching instead of string contains. Removed dead `PROTECTED_PATTERNS` constant.
- **Create dry_run**: Added `dry_run` support to `_op_create` (user requested for API consistency).
- Added `test_create_dry_run_does_not_write` and `test_unknown_op_returns_error` tests.

**Review decisions:**
- PARTIAL patch status intentionally discards applied patches (plan-specified).
- `auto_fix` parameter kept as reserved/unused per plan.
- Regex validation kept basic (doesn't catch `@export var`) per plan.

**Final test count:** 28 (26 planned + 2 added during review)