So for a single-line `_data = { "idle": SubResource("..."), ... }`, the bracket depth change is 0 (balanced braces), so it goes through `parse_value()` and becomes a proper Python dict. The `_` prefix raw-string optimization only applies to multiline values. This confirms the plan's dual-format handling.

Now I have everything I need. Let me produce the section content.

# Section 02: Animation Extraction Helpers

## Overview

This section implements the core extraction functions that parse animation data from Godot .tscn scene files and .gd script files. All helpers live in a single module: `src/godotiq/tools/animation/__init__.py`. These functions are private building blocks used by the `godotiq_animation_info` and `godotiq_animation_audit` tools (Sections 03 and 04).

**Depends on:** Section 01 (test fixtures must exist for tests to run)
**Blocks:** Sections 03, 04, 06, 07 (tools and test suites rely on these helpers)

## File to Create/Modify

- **Create or extend:** `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/tools/animation/__init__.py`

This file currently exists as a placeholder with only a docstring. This section adds six private helper functions to it.

## Background: Parser Data Structures

The helpers operate on `TscnScene` objects returned by the existing `parse_tscn()` function. Key data structures from `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/parsers/tscn_parser.py`:

- **`TscnScene`** has:
  - `sub_resources: dict[str, SubResource]` -- keyed by sub_resource id (e.g., `"Animation_idle"`)
  - `nodes: list[TscnNode]` -- flat list of all nodes
  - `get_nodes_by_type(type_name: str) -> list[TscnNode]` -- filter nodes by Godot type
  - `get_all_scripts() -> list[str]` -- returns `res://` paths for all attached scripts

- **`SubResource`** has:
  - `id: str` -- e.g., `"Animation_idle"`
  - `type: str` -- e.g., `"Animation"`, `"AnimationLibrary"`, `"AnimationNodeStateMachine"`
  - `properties: dict[str, object]` -- parsed property values

- **`TscnNode`** has:
  - `name: str`, `type: str`, `parent: str`
  - `children: list[TscnNode]`
  - `properties: dict[str, object]`
  - `script: str | None` -- ext_resource id for attached script

### Value Parser Behaviors

The value parser (`parse_value()`) transforms TSCN values as follows:
- `SubResource("Animation_idle")` becomes `{"type": "sub_resource", "id": "Animation_idle"}`
- `ExtResource("1_abc")` becomes `{"type": "ext_resource", "id": "1_abc"}`
- `NodePath("../AnimationPlayer")` becomes the string `"../AnimationPlayer"` (unwrapped)
- `&"idle"` (StringName) in the **value parser context** becomes `"idle"` (unwrapped)
- Dicts like `{ "idle": SubResource("Animation_idle") }` become proper Python dicts when on a single line

### Critical: The `_data` Property Dual Format

The TSCN parser has an optimization: **keys starting with `_` are stored as raw strings when the value spans multiple lines** (to avoid parsing huge PackedByteArray data). However, when `_data` fits on a single line (balanced braces on one line), it goes through `parse_value()` and becomes a proper Python dict.

This means `_data` on an `AnimationLibrary` sub_resource can be either:
- A **Python dict** (single-line): keys are strings, values are `{"type": "sub_resource", "id": "..."}` dicts
- A **raw string** (multiline): needs regex parsing to extract `"name": SubResource("id")` pairs

Both formats must be handled.

## Tests (Write First)

All tests go in a new test file or can be added to the existing animation test files. However, since these are unit tests of private helpers, they are best placed in a dedicated file. Because Sections 06 and 07 define the full test suites for the tools, the extraction helper tests below should be included inline in those test files or in a separate helper test file. The TDD stubs are provided here for implementation guidance.

### Test file: `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_tools/test_extraction_helpers.py`

```python
"""Tests for animation extraction helpers."""

from __future__ import annotations

import pytest

from godotiq.parsers.tscn_parser import parse_tscn


# ---------------------------------------------------------------------------
# _strip_string_name
# ---------------------------------------------------------------------------

class TestStripStringName:
    """Tests for _strip_string_name utility."""

    def test_strips_ampersand_syntax(self):
        """&"idle" should become "idle"."""
        from godotiq.tools.animation import _strip_string_name
        assert _strip_string_name('&"idle"') == "idle"

    def test_passthrough_clean_string(self):
        """Already clean string returned as-is."""
        from godotiq.tools.animation import _strip_string_name
        assert _strip_string_name("idle") == "idle"

    def test_non_string_passthrough(self):
        """Non-string values returned unchanged."""
        from godotiq.tools.animation import _strip_string_name
        assert _strip_string_name(42) == 42


# ---------------------------------------------------------------------------
# _extract_tscn_animations
# ---------------------------------------------------------------------------

class TestExtractTscnAnimations:
    """Tests for inline animation extraction from .tscn sub_resources."""

    def test_four_animations_from_character(self, anim_session):
        """animated_character.tscn should yield 4 inline animations."""
        from godotiq.tools.animation import _extract_tscn_animations
        scene = anim_session.get_scene("res://scenes/animated_character.tscn")
        anims = _extract_tscn_animations(scene)
        names = {a["name"] for a in anims}
        assert names == {"idle", "walk", "attack", "idle_ground"}

    def test_idle_metadata(self, anim_session):
        """idle animation: length=2.0, loop_mode="linear", track_count=4, all value type."""
        from godotiq.tools.animation import _extract_tscn_animations
        scene = anim_session.get_scene("res://scenes/animated_character.tscn")
        anims = _extract_tscn_animations(scene)
        idle = next(a for a in anims if a["name"] == "idle")
        assert idle["length"] == 2.0
        assert idle["loop_mode"] == "linear"
        assert idle["track_count"] == 4
        assert idle["tracks_by_type"].get("value", 0) == 4

    def test_walk_mixed_tracks(self, anim_session):
        """walk animation: 3 value + 1 method track."""
        from godotiq.tools.animation import _extract_tscn_animations
        scene = anim_session.get_scene("res://scenes/animated_character.tscn")
        anims = _extract_tscn_animations(scene)
        walk = next(a for a in anims if a["name"] == "walk")
        assert walk["tracks_by_type"]["value"] == 3
        assert walk["tracks_by_type"]["method"] == 1

    def test_attack_broken_track_path(self, anim_session):
        """attack animation should have "NonExistent:position" in track_paths."""
        from godotiq.tools.animation import _extract_tscn_animations
        scene = anim_session.get_scene("res://scenes/animated_character.tscn")
        anims = _extract_tscn_animations(scene)
        attack = next(a for a in anims if a["name"] == "attack")
        assert any("NonExistent" in p for p in attack["track_paths"])

    def test_no_animations_returns_empty(self, anim_session):
        """no_animation_scene.tscn should return empty list."""
        from godotiq.tools.animation import _extract_tscn_animations
        scene = anim_session.get_scene("res://scenes/no_animation_scene.tscn")
        anims = _extract_tscn_animations(scene)
        assert anims == []


# ---------------------------------------------------------------------------
# _extract_animation_libraries
# ---------------------------------------------------------------------------

class TestExtractAnimationLibraries:
    """Tests for AnimationLibrary sub_resource extraction."""

    def test_one_library_four_refs(self, anim_session):
        """animated_character.tscn should have 1 library with 4 animation refs."""
        from godotiq.tools.animation import _extract_animation_libraries
        scene = anim_session.get_scene("res://scenes/animated_character.tscn")
        libs = _extract_animation_libraries(scene)
        assert len(libs) == 1
        lib_data = list(libs.values())[0]
        assert len(lib_data) == 4

    def test_library_maps_idle(self, anim_session):
        """Library should map "idle" to "Animation_idle" sub_resource id."""
        from godotiq.tools.animation import _extract_animation_libraries
        scene = anim_session.get_scene("res://scenes/animated_character.tscn")
        libs = _extract_animation_libraries(scene)
        lib_data = list(libs.values())[0]
        assert lib_data["idle"] == "Animation_idle"


# ---------------------------------------------------------------------------
# _extract_animation_players
# ---------------------------------------------------------------------------

class TestExtractAnimationPlayers:
    """Tests for AnimationPlayer node extraction."""

    def test_one_player_named(self, anim_session):
        """animated_character.tscn should have 1 player named "AnimationPlayer"."""
        from godotiq.tools.animation import _extract_animation_players
        scene = anim_session.get_scene("res://scenes/animated_character.tscn")
        players = _extract_animation_players(scene)
        assert len(players) == 1
        assert players[0]["name"] == "AnimationPlayer"

    def test_player_has_default_library(self, anim_session):
        """Player should have library_refs with empty string key (default)."""
        from godotiq.tools.animation import _extract_animation_players
        scene = anim_session.get_scene("res://scenes/animated_character.tscn")
        players = _extract_animation_players(scene)
        assert "" in players[0]["library_refs"]

    def test_no_player_returns_empty(self, anim_session):
        """no_animation_scene.tscn should return empty list."""
        from godotiq.tools.animation import _extract_animation_players
        scene = anim_session.get_scene("res://scenes/no_animation_scene.tscn")
        players = _extract_animation_players(scene)
        assert players == []


# ---------------------------------------------------------------------------
# _extract_animation_trees
# ---------------------------------------------------------------------------

class TestExtractAnimationTrees:
    """Tests for AnimationTree + StateMachine extraction."""

    def test_one_tree_two_states(self, anim_session):
        """animated_scene_tree.tscn should have 1 tree with 2 states."""
        from godotiq.tools.animation import _extract_animation_trees
        scene = anim_session.get_scene("res://scenes/animated_scene_tree.tscn")
        trees = _extract_animation_trees(scene)
        assert len(trees) == 1
        assert set(trees[0]["states"]) == {"idle", "walk"}

    def test_tree_two_transitions(self, anim_session):
        """Tree should have 2 transitions: idle->walk and walk->idle."""
        from godotiq.tools.animation import _extract_animation_trees
        scene = anim_session.get_scene("res://scenes/animated_scene_tree.tscn")
        trees = _extract_animation_trees(scene)
        transitions = trees[0]["transitions"]
        assert len(transitions) == 2
        pairs = {(t["from"], t["to"]) for t in transitions}
        assert ("idle", "walk") in pairs
        assert ("walk", "idle") in pairs

    def test_animation_refs(self, anim_session):
        """animation_refs should include "idle" and "walk"."""
        from godotiq.tools.animation import _extract_animation_trees
        scene = anim_session.get_scene("res://scenes/animated_scene_tree.tscn")
        trees = _extract_animation_trees(scene)
        assert "idle" in trees[0]["animation_refs"]
        assert "walk" in trees[0]["animation_refs"]


# ---------------------------------------------------------------------------
# _extract_code_animations
# ---------------------------------------------------------------------------

class TestExtractCodeAnimations:
    """Tests for GDScript animation pattern extraction."""

    def test_play_calls_from_character(self, anim_root):
        """character.gd should have play_calls including "idle", "attack", "special_move"."""
        from godotiq.tools.animation import _extract_code_animations
        content = (anim_root / "scripts" / "entities" / "character.gd").read_text()
        result = _extract_code_animations(content)
        assert "idle" in result["play_calls"]
        assert "attack" in result["play_calls"]
        assert "special_move" in result["play_calls"]

    def test_anim_maps_from_character(self, anim_root):
        """character.gd should have ANIM_MAP with 4 entries."""
        from godotiq.tools.animation import _extract_code_animations
        content = (anim_root / "scripts" / "entities" / "character.gd").read_text()
        result = _extract_code_animations(content)
        assert len(result["anim_maps"]) > 0
        anim_map = result["anim_maps"][0]
        assert len(anim_map["entries"]) == 4

    def test_looping_lists_from_character(self, anim_root):
        """character.gd should have LOOPING_ANIMS with 2 entries."""
        from godotiq.tools.animation import _extract_code_animations
        content = (anim_root / "scripts" / "entities" / "character.gd").read_text()
        result = _extract_code_animations(content)
        assert len(result["looping_lists"]) > 0
        assert len(result["looping_lists"][0]["entries"]) == 2

    def test_glb_nlatrack_entries(self, anim_root):
        """glb_animated_entity.gd should have ANIM_MAP with NlaTrack values."""
        from godotiq.tools.animation import _extract_code_animations
        content = (anim_root / "scripts" / "entities" / "glb_animated_entity.gd").read_text()
        result = _extract_code_animations(content)
        assert len(result["anim_maps"]) > 0
        entries = result["anim_maps"][0]["entries"]
        assert any("NlaTrack" in v for v in entries.values())

    def test_stringname_play_detected(self):
        """`.play(&"name")` syntax should be detected."""
        from godotiq.tools.animation import _extract_code_animations
        result = _extract_code_animations('anim_player.play(&"jump")')
        assert "jump" in result["play_calls"]

    def test_queue_included_in_play_calls(self):
        """`.queue("name")` should appear in play_calls."""
        from godotiq.tools.animation import _extract_code_animations
        result = _extract_code_animations('anim_player.queue("fade_out")')
        assert "fade_out" in result["play_calls"]

    def test_empty_script_no_crash(self):
        """Empty script should return empty results without errors."""
        from godotiq.tools.animation import _extract_code_animations
        result = _extract_code_animations("")
        assert result["play_calls"] == []
        assert result["travel_calls"] == []
        assert result["anim_maps"] == []
        assert result["looping_lists"] == []
```

These tests use the `anim_session` and `anim_root` fixtures defined in Section 01 (added to `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_tools/conftest.py`).

## Implementation Details

### Module Structure

The file `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/tools/animation/__init__.py` should start with:

```python
"""Animation Intelligence tools -- animation data extraction and analysis.

These tools provide complete animation understanding for Godot 4 projects,
extracting data from inline .tscn animations, code-referenced GLB animations,
.tres resources, and AnimationTree state machines.
"""

from __future__ import annotations

import re

from godotiq.parsers.tscn_parser import TscnScene
```

No `mcp` or `server` imports are needed for the helpers in this section. The `@mcp.tool()` wrappers come in Sections 03 and 04.

### 2.0 `_strip_string_name(value: object) -> object`

A small utility used across all extraction helpers. Strips Godot StringName syntax (`&"name"`) from values.

**Logic:**
1. If `value` is not a `str`, return it unchanged.
2. If the string matches the pattern `&"..."`, strip the `&"` prefix and trailing `"`.
3. If the string is already clean (just `"..."` or a bare name), strip surrounding quotes if present and return.

**Regex pattern:** `r'^&"(.+)"$'`

This is needed because the value parser handles `StringName(&"name")` (the function call form) but NOT the bare `&"name"` form that appears inside arrays and other inline contexts.

### 2.1 `_extract_tscn_animations(scene: TscnScene) -> list[dict]`

Iterates `scene.sub_resources` (a `dict[str, SubResource]`) and filters entries where `sub.type == "Animation"`.

**For each Animation sub_resource, extract:**

1. **Name:** From `properties.get("resource_name", "")`. Apply `_strip_string_name()`. If empty, fall back to the sub_resource `id` field.
2. **Length:** From `properties.get("length", 1.0)`, cast to `float`.
3. **Loop mode:** From `properties.get("loop_mode", 0)`. Map integer to string: `{0: "none", 1: "linear", 2: "pingpong"}`.
4. **Track counting:** Scan property keys using regex `r'^tracks/(\d+)/type$'`. Each match is one track. Collect unique track indices.
5. **Track details:** For each track index N:
   - Type from `properties[f"tracks/{N}/type"]` (e.g., `"value"`, `"method"`)
   - Path from `properties[f"tracks/{N}/path"]` (the value parser already unwraps `NodePath(...)` to a plain string)
6. **Tracks by type:** Group track count by type name (e.g., `{"value": 3, "method": 1}`).
7. **Track paths:** Collect unique path strings from all tracks.

**Return value per animation:**
```python
{
    "name": str,
    "length": float,
    "loop_mode": str,        # "none", "linear", or "pingpong"
    "track_count": int,
    "tracks_by_type": dict,  # e.g., {"value": 3, "method": 1}
    "track_paths": list,     # e.g., ["Sprite2D:position", "Sprite2D:scale"]
    "sub_resource_id": str,  # e.g., "Animation_idle"
    "source": "tscn_inline",
}
```

### 2.2 `_extract_animation_libraries(scene: TscnScene) -> dict[str, dict[str, str]]`

Iterates sub_resources where `type == "AnimationLibrary"`.

**For each library, read the `_data` property and handle two formats:**

**Format A -- Python dict (single-line `_data`):**
The value parser produced a `dict` where keys are animation names (strings) and values are `{"type": "sub_resource", "id": "Animation_idle"}` dicts. Iterate and extract the `"id"` field from each value.

**Format B -- Raw string (multiline `_data`):**
The parser stored the raw text. Use regex `r'"(\w+)":\s*SubResource\("([^"]+)"\)'` to extract `(animation_name, sub_resource_id)` pairs.

**Return format:**
```python
{
    "AnimationLibrary_main": {
        "idle": "Animation_idle",
        "walk": "Animation_walk",
        ...
    }
}
```

The outer key is the library's sub_resource id. The inner dict maps animation names to their Animation sub_resource ids.

### 2.3 `_extract_animation_players(scene: TscnScene) -> list[dict]`

Uses `scene.get_nodes_by_type("AnimationPlayer")` to find all AnimationPlayer nodes.

**For each player node:**

1. **Name:** `node.name`
2. **Path reconstruction:** Build the full node path from the parent chain. The `node.parent` field is `"."` for direct children of root or a path like `"Parent/Child"`. The full path is:
   - If `node.parent == "."`: `node.name`
   - Otherwise: `f"{node.parent}/{node.name}"`
3. **Library refs:** Read `node.properties.get("libraries")`. When present and is a dict, keys are library names (empty string `""` for default library) and values are `{"type": "sub_resource", "id": "..."}` dicts. Extract the `"id"` from each. If `libraries` is missing or not a dict, set `library_refs` to an empty dict (the empty_player case).

**Return value per player:**
```python
{
    "name": str,
    "path": str,             # full node path within scene
    "library_refs": dict,    # e.g., {"": "AnimationLibrary_main"}
}
```

### 2.4 `_extract_animation_trees(scene: TscnScene) -> list[dict]`

Finds AnimationTree nodes and correlates them with AnimationNodeStateMachine and AnimationNodeAnimation sub_resources.

**Step 1: Find AnimationTree nodes** via `scene.get_nodes_by_type("AnimationTree")`.

For each node:
- `tree_root`: from `node.properties.get("tree_root")`. Expected to be `{"type": "sub_resource", "id": "StateMachine_main"}` -- extract the `"id"`.
- `anim_player`: from `node.properties.get("anim_player")`. Already a plain string (NodePath unwrapped by parser), e.g., `"../AnimationPlayer"`.

**Step 2: Find AnimationNodeStateMachine sub_resources.**

For each matching sub_resource:
- **States:** Extract state names from property keys matching `r'^states/(\w+)/node$'`. Collect unique state name strings.
- **Transitions:** Read the `transitions` property. It is a Python list (parsed by value parser) of mixed types in triplets:
  - Index 0 (mod 3): `from_state` string, may need `_strip_string_name()` (e.g., `&"idle"` becomes `"idle"`)
  - Index 1 (mod 3): `to_state` string, same treatment
  - Index 2 (mod 3): transition sub_resource ref dict `{"type": "sub_resource", "id": "..."}`
  - Process in groups of 3. If the list length is not divisible by 3, skip trailing elements.

**Step 3: Find AnimationNodeAnimation sub_resources.**

For each, extract the `animation` property and apply `_strip_string_name()`. This gives the animation name that the state references.

**Return value per tree:**
```python
{
    "node_name": str,
    "node_path": str,
    "anim_player_path": str,       # e.g., "../AnimationPlayer"
    "states": list[str],           # e.g., ["idle", "walk"]
    "transitions": list[dict],     # e.g., [{"from": "idle", "to": "walk"}, ...]
    "animation_refs": list[str],   # e.g., ["idle", "walk"]
}
```

### 2.5 `_extract_code_animations(gd_content: str) -> dict`

Performs regex scanning of raw GDScript text (a plain string, not a parsed `GdScript` object). This function does NOT require a session or any parsed data -- it works purely on text.

**Patterns to detect:**

1. **`.play("name")` and `.play(&"name")`:**
   Pattern: `r'\.play\(\s*(?:&)?"([^"]+)"'`
   Collect into `play_calls` list.

2. **`.queue("name")`:**
   Pattern: `r'\.queue\(\s*(?:&)?"([^"]+)"'`
   Also add to `play_calls` (queue is semantically a play variant).

3. **`.travel("name")`:**
   Pattern: `r'\.travel\(\s*(?:&)?"([^"]+)"'`
   Collect into `travel_calls` list.

4. **AnimationPlayer variable detection:**
   Pattern: `r'(?:var|@onready\s+var)\s+(\w+)\s*[:=].*AnimationPlayer'`
   Sets `has_anim_player = True` and collects variable names into `anim_player_vars`.

5. **AnimationTree variable detection:**
   Pattern: `r'(?:var|@onready\s+var)\s+(\w+)\s*[:=].*AnimationTree'`
   Sets `has_anim_tree = True` and collects variable names into `anim_tree_vars`.

6. **Animation dicts (ANIM_MAP etc.):**
   Detect lines matching `const/var NAME :?=? {` where NAME (case-insensitive) contains one of: `anim`, `animation`, `motion`, `clip`.
   Pattern for the declaration: `r'(?:const|var)\s+(\w+)\s*:?=?\s*\{'` -- then check if the captured name matches the heuristic.
   For the body: use **brace-counting** to find the matching `}`. Extract string key-value pairs from the content with regex `r'"([^"]+)"\s*:\s*"([^"]+)"'`.
   Collect as `anim_maps` list of dicts: `{"name": "ANIM_MAP", "entries": {"key": "value", ...}}`.
   Must handle both `:=` (typed) and `=` (untyped) GDScript assignment.

7. **Animation arrays (LOOPING_ANIMS etc.):**
   Same name heuristic but detect `[` instead of `{`.
   Pattern: `r'(?:const|var)\s+(\w+)\s*:?=?\s*\['`
   Use **bracket-counting** for multi-line. Extract quoted strings with `r'"([^"]+)"'`.
   Collect as `looping_lists` list of dicts: `{"name": "LOOPING_ANIMS", "entries": ["idle", "walk"]}`.

**Return value:**
```python
{
    "play_calls": list[str],         # includes queue calls
    "travel_calls": list[str],
    "anim_maps": list[dict],         # [{"name": str, "entries": dict}, ...]
    "looping_lists": list[dict],     # [{"name": str, "entries": list}, ...]
    "has_anim_player": bool,
    "has_anim_tree": bool,
    "anim_player_vars": list[str],
    "anim_tree_vars": list[str],
    "source": "code",
}
```

### 2.6 `_build_animation_summary(scene_path, scene, session) -> dict`

This is the aggregation function that ties all extractors together. It is used by both tools.

**Signature:**
```python
def _build_animation_summary(
    scene_path: str,
    scene: TscnScene,
    session: GodotIQSession,
) -> dict:
    """Aggregate all animation data for a single scene."""
```

**Steps:**

1. Call `_extract_tscn_animations(scene)` for inline animations.
2. Call `_extract_animation_libraries(scene)` for library structure.
3. Call `_extract_animation_players(scene)` for player nodes.
4. Call `_extract_animation_trees(scene)` for tree/state machine data.
5. For each script attached to nodes in the scene:
   - Get res:// paths via `scene.get_all_scripts()`
   - Resolve to filesystem path: `session._resolve_res_path(script_res_path)` returns a `Path`
   - Read raw content: `path.read_text(encoding="utf-8")`
   - Call `_extract_code_animations(content)`
6. **Cross-reference:** Link AnimationPlayers to their libraries to their animations. Match `.play()` calls from code against known animation names from the scene.
7. **Identify:**
   - `code_only` animations: referenced via `.play()` but not found in any scene AnimationPlayer/Library
   - `unused` animations: defined inline in the scene but never referenced via `.play()`, `.queue()`, ANIM_MAP values, or AnimationTree state machine animation refs. Skip this check if no scripts are attached AND no AnimationTree references the player.

**Return value:**
```python
{
    "animations": list[dict],        # from _extract_tscn_animations
    "libraries": dict,               # from _extract_animation_libraries
    "players": list[dict],           # from _extract_animation_players
    "trees": list[dict],             # from _extract_animation_trees
    "code_animations": list[dict],   # from _extract_code_animations (one per script)
    "code_only": list[str],          # animation names only in code
    "unused": list[str],             # animation names only in scene
}
```

## Implementation Notes

- All functions are private (prefixed with `_`). They are imported directly in tests but not part of the public API.
- Type hints and docstrings are required on every function (project convention).
- The regex patterns for `_extract_code_animations` should be compiled at module level for performance (use `re.compile()`).
- Error handling: if a script file cannot be read in `_build_animation_summary`, log a warning and skip it. Do not let a missing file crash the entire extraction.
- The `_strip_string_name` utility is small but used everywhere -- define it first and use consistently.