orchestrata_ultimate.agents.volks.qa._autofix

Reference › orchestrata_ultimate › agents › volks › qa › _autofix

_autofix provides AutofixMixin for the QA volk (volk): mechanical repairs applied to the generated workspace before the review gates run, so the model is not asked to fix what a script can fix. _iter_workspace_py_files and _apply_py_file_fix apply a content transform to every workspace Python file and count the changes. The transforms cover Redis usage (_autofix_aioredis, _autofix_sync_redis, _autofix_redis_decode, _autofix_xadd_dict, _autofix_hset_dict, and _autofix_redis_host, which replaces hardcoded local Redis settings in test files with environment reads via _replace_redis_host_in_content), missing imports (_autofix_f821_datetime, _autofix_missing_stdlib_imports, _autofix_missing_barename_imports), relative imports (_autofix_relative_imports, with _detect_package_name), and formatting (_autofix_line_length with black, _autofix_import_order with isort, _autofix_unused_imports with ruff), plus _autofix_parse_args, which lets parse_args() accept an optional argument list.

At a glance

Source src/orchestrata_ultimate/agents/volks/qa/_autofix.py
Lines of code 935
Classes 1
Functions 0
Imports (internal) orchestrata_ultimate.agents.volks.qa._host, orchestrata_ultimate.core.base.traces
Imported by orchestrata_ultimate.agents.volks.qa.volk
Tests that import it none

Class diagram

Class diagram orchestrata_ultimate.agents.volks.qa._autofix

Dependency diagram

Dependency diagram orchestrata_ultimate.agents.volks.qa._autofix

API

Classes

AutofixMixin

Bases: _MixinBase · Line 26

Source autofix routines (redis/imports/formatting) for U:Qa.

Method Signature Summary
_iter_workspace_py_files _iter_workspace_py_files() Yield absolute paths of workspace .py files (standard dir filter).
_apply_py_file_fix _apply_py_file_fix(transform: Callable[[str], str], report: str | None = None) -> int Apply a content transform to every workspace .py file.
_autofix_aioredis _autofix_aioredis() -> None G131-1: Replace deprecated redis with redis.asyncio.
_autofix_sync_redis _autofix_sync_redis() -> None G138-3: Replace sync redis.Redis with redis.asyncio.Redis.
_autofix_f821_datetime _autofix_f821_datetime() -> None G132-1: Add missing datetime import when datetime.X is used.
_autofix_xadd_dict _autofix_xadd_dict() -> None G134-1: Fix XADD calls that pass dicts with non-string values.
_autofix_hset_dict _autofix_hset_dict() -> None G136-1: Fix HSET mapping with non-string values.
_autofix_redis_decode _autofix_redis_decode() -> None G158-1: Ensure Redis clients use decode_responses=True.
_autofix_missing_stdlib_imports _autofix_missing_stdlib_imports() -> None G134-2: Add missing stdlib imports when module.X is used.
_autofix_missing_barename_imports _autofix_missing_barename_imports() -> None G150-3: Add missing bare-name imports (timedelta, Optional, etc.).
_autofix_redis_host _autofix_redis_host() -> None G125-1 + G141-1: Replace hardcoded localhost Redis in ALL test files.
_collect_test_files _collect_test_files() -> list Collect workspace test_*.py and conftest.py paths.
_replace_redis_host_in_content _replace_redis_host_in_content(content: str) -> str Replace hardcoded localhost/port Redis config with env reads.
_autofix_relative_imports _autofix_relative_imports() -> None G125-2: Convert relative imports to absolute in source files.
_detect_package_name _detect_package_name() -> str Detect the project's top-level package name.
_autofix_line_length _autofix_line_length() -> None G127-3: Auto-format source files with black to fix LINE_LENGTH.
_autofix_import_order _autofix_import_order() -> None G129-3: Auto-fix import ordering with isort.
_autofix_unused_imports _autofix_unused_imports() -> None G194-2: Remove unused imports with ruff after isort/black.
_autofix_parse_args _autofix_parse_args() -> None G129-2: Patch parse_args() to accept optional args parameter.

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/src/orchestrata_ultimate/agents/volks/qa/_autofix.py

# mypy: ignore-errors
"""Source autofix routines (redis/imports/formatting) for U:Qa."""

import logging
import os
import re
import shutil
import subprocess
from collections.abc import Callable
from typing import TYPE_CHECKING

from orchestrata_ultimate.agents.volks.qa._host import _QaHost
from orchestrata_ultimate.core.base.traces import traces

_logger = logging.getLogger(__name__)

# W6.4b: typing-only host contract so pyright can see across mixin
# composition (see _host.py). No runtime effect — AutofixMixin still
# inherits from plain ``object`` outside TYPE_CHECKING.
if TYPE_CHECKING:
    _MixinBase = _QaHost
else:
    _MixinBase = object


class AutofixMixin(_MixinBase):
    """Source autofix routines (redis/imports/formatting) for U:Qa."""

    @traces(spec="031", story="US-031-001", reqs=["REQ-031-015"])
    def _iter_workspace_py_files(self):
        """Yield absolute paths of workspace .py files (standard dir filter).

        Skips dot-dirs, ``__pycache__`` and ``site-packages`` — the dir
        filter shared by every per-file source auto-fix.
        """
        for root, _dirs, files in os.walk(self.workspace):
            _dirs[:] = [
                d
                for d in _dirs
                if not d.startswith(".") and d != "__pycache__" and d != "site-packages"
            ]
            for fname in files:
                if not fname.endswith(".py"):
                    continue
                yield os.path.join(root, fname)

    @traces(spec="031", story="US-031-001", reqs=["REQ-031-015"])
    def _apply_py_file_fix(
        self, transform: Callable[[str], str], report: str | None = None
    ) -> int:
        """Apply a content transform to every workspace .py file.

        Reads each file, runs ``transform(content) -> new_content`` and
        writes the result back when it changed. Files that cannot be read
        are skipped. When ``report`` is given it is printed (with the
        changed-file count) once one or more files were rewritten.

        Args:
            transform: Callable mapping file content to new content.
            report: Format string with a single ``{count}`` placeholder,
                or None to suppress the summary print.

        Returns:
            Number of files that were rewritten.
        """
        fixed_count = 0
        for fpath in self._iter_workspace_py_files():
            try:
                with open(fpath, encoding="utf-8") as f:
                    content = f.read()
            except OSError, UnicodeDecodeError:
                continue
            new_content = transform(content)
            if new_content != content:
                with open(fpath, "w", encoding="utf-8") as f:
                    f.write(new_content)
                fixed_count += 1
        if fixed_count and report is not None:
            print(report.format(count=fixed_count), flush=True)
        return fixed_count

    @traces(
        spec="031",
        story="US-031-001",
        reqs=["REQ-031-001", "REQ-031-002"],
    )
    def _autofix_aioredis(self) -> None:
        """G131-1: Replace deprecated redis with redis.asyncio.

        DeepSeek sometimes uses the deprecated `redis` package instead
        of `redis.asyncio` (included in redis>=5.0). This auto-fix
        replaces all redis imports and usage patterns.
        """
        fixed_count = 0
        for root, _dirs, files in os.walk(self.workspace):
            _dirs[:] = [
                d
                for d in _dirs
                if not d.startswith(".") and d != "__pycache__" and d != "site-packages"
            ]
            for fname in files:
                if not fname.endswith(".py"):
                    continue
                fpath = os.path.join(root, fname)
                try:
                    with open(fpath, encoding="utf-8") as f:
                        content = f.read()
                except OSError, UnicodeDecodeError:
                    continue
                if "redis" not in content:
                    continue
                original = content
                # Replace import statements
                content = re.sub(
                    r"^import redis$",
                    "import redis.asyncio as redis",
                    content,
                    flags=re.MULTILINE,
                )
                content = re.sub(
                    r"^from redis import",
                    "from redis.asyncio import",
                    content,
                    flags=re.MULTILINE,
                )
                # Replace usage patterns
                content = content.replace("redis.from_url", "redis.from_url")
                content = content.replace("redis.Redis", "redis.Redis")
                content = content.replace(
                    "redis.ConnectionError",
                    "redis.ConnectionError",
                )
                content = content.replace(
                    "redis.ResponseError",
                    "redis.ResponseError",
                )
                # Catch any remaining redis references
                content = re.sub(
                    r"\baioredis\b",
                    "redis",
                    content,
                )
                if content != original:
                    with open(fpath, "w", encoding="utf-8") as f:
                        f.write(content)
                    fixed_count += 1
        if fixed_count:
            print(
                f"  [U:Qa] G131-1: auto-fixed redis→redis.asyncio "
                f"in {fixed_count} files",
                flush=True,
            )

    @traces(
        spec="031",
        story="US-031-001",
        reqs=["REQ-031-003"],
    )
    def _autofix_sync_redis(self) -> None:
        """G138-3: Replace sync redis.Redis with redis.asyncio.Redis.

        DeepSeek sometimes uses the synchronous redis.Redis client
        instead of redis.asyncio.Redis. This causes blocking in async
        code. Detect `import redis` (bare) + `redis.Redis(` usage and
        convert to async Redis.
        """

        @traces(
            spec="031",
            story="US-031-001",
            reqs=["REQ-031-003"],
        )
        def transform(content: str) -> str:
            # Skip if already using async redis
            if "redis.asyncio" in content:
                return content
            # Only fix files that use sync redis.Redis
            if not re.search(r"^import redis\b", content, re.MULTILINE):
                return content
            if "redis.Redis(" not in content:
                return content
            # Replace: import redis → from redis.asyncio import Redis
            content = re.sub(
                r"^import redis$",
                "from redis.asyncio import Redis",
                content,
                flags=re.MULTILINE,
            )
            # Replace: redis.Redis( → Redis(
            content = content.replace("redis.Redis(", "Redis(")
            # Replace type hints: redis.Redis → Redis
            content = re.sub(r"\bredis\.Redis\b", "Redis", content)
            # Replace exception refs
            content = content.replace(
                "redis.exceptions.",
                "redis.exceptions.",
            )
            # Add redis.exceptions import if needed
            if "redis.exceptions" in content and (
                "from redis" not in content or "exceptions" not in content
            ):
                content = re.sub(
                    r"^(from redis\.asyncio import Redis)$",
                    r"\1\nfrom redis.exceptions import RedisError",
                    content,
                    count=1,
                    flags=re.MULTILINE,
                )
            return content

        self._apply_py_file_fix(
            transform,
            report="  [U:Qa] G138-3: auto-fixed sync redis→"
            "redis.asyncio in {count} files",
        )

    @traces(
        spec="031",
        story="US-031-001",
        reqs=["REQ-031-004"],
    )
    def _autofix_f821_datetime(self) -> None:
        """G132-1: Add missing datetime import when datetime.X is used.

        DeepSeek often uses datetime.now(), datetime.fromisoformat(),
        etc. but only imports timezone from the datetime module,
        omitting the datetime class itself. This causes F821.
        """

        @traces(
            spec="031",
            story="US-031-001",
            reqs=["REQ-031-004"],
        )
        def transform(content: str) -> str:
            # Check if datetime.X is used as a standalone name
            if not re.search(
                r"\bdatetime\.(now|utcnow|fromisoformat|"
                r"strptime|combine|min|max|today)\b",
                content,
            ):
                return content
            # Check if datetime class is already imported
            if re.search(
                r"^from datetime import .*\bdatetime\b",
                content,
                re.MULTILINE,
            ):
                return content
            if re.search(r"^import datetime$", content, re.MULTILINE):
                return content
            original = content
            # Case 1: from datetime import timezone → add datetime
            content = re.sub(
                r"^(from datetime import )((?!.*\bdatetime\b).*)",
                r"\1datetime, \2",
                content,
                count=1,
                flags=re.MULTILINE,
            )
            # Case 2: no datetime import at all → add one
            if content == original:
                content = re.sub(
                    r"^(import os\b.*\n)",
                    r"\1from datetime import datetime\n",
                    content,
                    count=1,
                    flags=re.MULTILINE,
                )
            # Case 3: fallback — prepend import
            if content == original:
                content = "from datetime import datetime\n" + content
            return content

        self._apply_py_file_fix(
            transform,
            report="  [U:Qa] G132-1: auto-fixed missing datetime "
            "import in {count} files",
        )

    @traces(
        spec="031",
        story="US-031-001",
        reqs=["REQ-031-007"],
    )
    def _autofix_xadd_dict(self) -> None:
        """G134-1: Fix XADD calls that pass dicts with non-string values.

        DeepSeek often writes `xadd(key, **event.to_dict())` or passes
        dicts containing nested objects. Redis XADD requires all field
        values to be strings. This auto-fix wraps dict values with
        json.dumps for non-primitive types.
        """
        fixed_count = 0
        for root, _dirs, files in os.walk(self.workspace):
            _dirs[:] = [
                d
                for d in _dirs
                if not d.startswith(".") and d != "__pycache__" and d != "site-packages"
            ]
            for fname in files:
                if not fname.endswith(".py"):
                    continue
                fpath = os.path.join(root, fname)
                try:
                    with open(fpath, encoding="utf-8") as f:
                        content = f.read()
                except OSError, UnicodeDecodeError:
                    continue
                if "xadd" not in content.lower():
                    continue
                original = content
                # Fix: xadd(key, **obj.to_dict()) → xadd(key, {k: str(v) for ...})
                content = re.sub(
                    r"\.xadd\(\s*(\w+)\s*,\s*\*\*(\w+)\.to_dict\(\)",
                    r".xadd(\1, {k: str(v) for k, v in \2.to_dict().items()}",
                    content,
                )
                # Fix: xadd(key, **dict_var) → xadd(key, {k: str(v)...})
                content = re.sub(
                    r"\.xadd\(\s*(\w+)\s*,\s*\*\*(\w+)\s*\)",
                    r".xadd(\1, {k: str(v) for k, v in \2.items()})",
                    content,
                )
                if content != original:
                    with open(fpath, "w", encoding="utf-8") as f:
                        f.write(content)
                    fixed_count += 1
        if fixed_count:
            print(
                f"  [U:Qa] G134-1: auto-fixed XADD dict "
                f"encoding in {fixed_count} files",
                flush=True,
            )

    @traces(
        spec="031",
        story="US-031-001",
        reqs=["REQ-031-008"],
    )
    def _autofix_hset_dict(self) -> None:
        """G136-1: Fix HSET mapping with non-string values.

        DeepSeek often writes `hset(key, mapping=obj.to_dict())` where
        to_dict() returns dicts with datetime objects, Enum values, or
        nested dicts. Redis HSET requires all field values to be
        strings, bytes, or numbers. This auto-fix wraps mapping values
        with str() conversion.
        """
        fixed_count = 0
        for root, _dirs, files in os.walk(self.workspace):
            _dirs[:] = [
                d
                for d in _dirs
                if not d.startswith(".") and d != "__pycache__" and d != "site-packages"
            ]
            for fname in files:
                if not fname.endswith(".py"):
                    continue
                fpath = os.path.join(root, fname)
                try:
                    with open(fpath, encoding="utf-8") as f:
                        content = f.read()
                except OSError, UnicodeDecodeError:
                    continue
                if "hset" not in content.lower():
                    continue
                original = content
                # Fix: hset(key, mapping=obj.to_dict())
                content = re.sub(
                    r"\.hset\(([^,]+),\s*mapping\s*=\s*" r"(\w+)\.to_dict\(\)\s*\)",
                    r".hset(\1, mapping="
                    r"{k: str(v) for k, v in \2.to_dict().items()})",
                    content,
                )
                # Fix: hset(key, mapping={**obj.to_dict()})
                content = re.sub(
                    r"\.hset\(([^,]+),\s*mapping\s*=\s*\{"
                    r"\s*\*\*(\w+)\.to_dict\(\)\s*\}\s*\)",
                    r".hset(\1, mapping="
                    r"{k: str(v) for k, v in \2.to_dict().items()})",
                    content,
                )
                if content != original:
                    with open(fpath, "w", encoding="utf-8") as f:
                        f.write(content)
                    fixed_count += 1
        if fixed_count:
            print(
                f"  [U:Qa] G136-1: auto-fixed HSET dict "
                f"encoding in {fixed_count} files",
                flush=True,
            )

    @traces(
        spec="031",
        story="US-031-001",
        reqs=["REQ-031-009"],
    )
    def _autofix_redis_decode(self) -> None:
        """G158-1: Ensure Redis clients use decode_responses=True.

        DeepSeek often creates Redis clients without decode_responses,
        causing bytes vs string comparison failures in tests.
        """
        fixed_count = 0
        for root, _dirs, files in os.walk(self.workspace):
            _dirs[:] = [
                d for d in _dirs if d not in {".venv", "__pycache__", "site-packages"}
            ]
            for fname in files:
                if not fname.endswith(".py"):
                    continue
                fpath = os.path.join(root, fname)
                try:
                    with open(fpath, encoding="utf-8") as f:
                        content = f.read()
                except OSError, UnicodeDecodeError:
                    continue
                if "Redis(" not in content and "redis.Redis(" not in content:
                    continue
                original = content
                # Add decode_responses=True if missing
                # Match: Redis(...) without decode_responses
                if "decode_responses" not in content:
                    content = re.sub(
                        r"(redis\.(?:async)?Redis\([^)]*)\)",
                        r"\1, decode_responses=True)",
                        content,
                    )
                    # Fix double comma from empty args
                    content = content.replace(
                        "(, decode_responses",
                        "(decode_responses",
                    )
                if content != original:
                    with open(fpath, "w", encoding="utf-8") as f:
                        f.write(content)
                    fixed_count += 1
        if fixed_count:
            print(
                f"  [U:Qa] G158-1: auto-added decode_responses=True "
                f"in {fixed_count} files",
                flush=True,
            )

    @traces(
        spec="031",
        story="US-031-001",
        reqs=["REQ-031-005"],
    )
    def _autofix_missing_stdlib_imports(self) -> None:
        """G134-2: Add missing stdlib imports when module.X is used.

        Handles common patterns where DeepSeek or QA-generated tests
        use `dataclasses.FrozenInstanceError`, `json.dumps`, etc.
        without importing the module.
        """
        # Map: module name → (usage pattern regex, import statement)
        patterns = {
            "dataclasses": (
                r"\bdataclasses\.(FrozenInstanceError|fields|" r"asdict|astuple)\b",
                "import dataclasses",
            ),
            "json": (
                r"\bjson\.(dumps|loads|dump|load)\b",
                "import json",
            ),
        }

        @traces(
            spec="031",
            story="US-031-001",
            reqs=["REQ-031-005"],
        )
        def transform(content: str) -> str:
            original = content
            for module, (usage_re, import_stmt) in patterns.items():
                if not re.search(usage_re, content):
                    continue
                # Check if already imported
                if re.search(
                    rf"^import {module}\b|^from {module} import",
                    content,
                    re.MULTILINE,
                ):
                    continue
                # Add import after first import line
                content = re.sub(
                    r"^(import \w+.*\n)",
                    rf"\1{import_stmt}\n",
                    content,
                    count=1,
                    flags=re.MULTILINE,
                )
                # Fallback: prepend
                if content == original:
                    content = import_stmt + "\n" + content
            return content

        self._apply_py_file_fix(
            transform,
            report="  [U:Qa] G134-2: auto-fixed missing stdlib "
            "imports in {count} files",
        )

    @traces(
        spec="031",
        story="US-031-001",
        reqs=["REQ-031-006"],
    )
    def _autofix_missing_barename_imports(self) -> None:
        """G150-3: Add missing bare-name imports (timedelta, Optional, etc.).

        DeepSeek frequently uses bare names like timedelta(days=7) or
        Optional[str] without importing them. These cause F821 lint
        errors that persist through fix cycles because DeepSeek's fix
        responses often fail to extract.

        This auto-fix detects bare-name usage and adds the correct
        from-import statement.
        """
        # Map: bare name → (usage regex, from-module, import name)
        bare_imports: list[tuple[str, str, str, str]] = [
            # (name, usage_regex, module, import_name)
            (
                "timedelta",
                r"\btimedelta\s*\(",
                "datetime",
                "timedelta",
            ),
            (
                "Optional",
                r"\bOptional\s*\[",
                "typing",
                "Optional",
            ),
            (
                "List",
                r"\bList\s*\[",
                "typing",
                "List",
            ),
            (
                "Dict",
                r"\bDict\s*\[",
                "typing",
                "Dict",
            ),
            (
                "Any",
                r"\bAny\b",
                "typing",
                "Any",
            ),
            (
                "Union",
                r"\bUnion\s*\[",
                "typing",
                "Union",
            ),
            (
                "Tuple",
                r"\bTuple\s*\[",
                "typing",
                "Tuple",
            ),
        ]

        @traces(
            spec="031",
            story="US-031-001",
            reqs=["REQ-031-006"],
        )
        def transform(content: str) -> str:
            original = content
            for name, usage_re, module, import_name in bare_imports:
                if not re.search(usage_re, content):
                    continue
                # Already imported?
                if re.search(
                    rf"^from {module} import .*\b{name}\b",
                    content,
                    re.MULTILINE,
                ):
                    continue
                if re.search(
                    rf"^import {module}$",
                    content,
                    re.MULTILINE,
                ):
                    continue
                # Try to extend existing from-import
                existing = re.search(
                    rf"^(from {module} import .+)$",
                    content,
                    re.MULTILINE,
                )
                if existing:
                    old_line = existing.group(1)
                    new_line = old_line + f", {import_name}"
                    content = content.replace(old_line, new_line, 1)
                else:
                    # Add new from-import after first import
                    stmt = f"from {module} import {import_name}"
                    content = re.sub(
                        r"^(import \w+.*\n)",
                        rf"\1{stmt}\n",
                        content,
                        count=1,
                        flags=re.MULTILINE,
                    )
                    if content == original:
                        content = stmt + "\n" + content
            return content

        self._apply_py_file_fix(
            transform,
            report="  [U:Qa] G150-3: auto-fixed missing bare-name "
            "imports in {count} files",
        )

    @traces(
        spec="031",
        story="US-031-001",
        reqs=["REQ-031-010"],
    )
    def _autofix_redis_host(self) -> None:
        """G125-1 + G141-1: Replace hardcoded localhost Redis in ALL test files.

        Scans both QA-generated and DeepSeek-generated test/conftest
        files for patterns like:
          host="localhost"  /  host='localhost'  /  Redis(host="localhost"
        and replaces them with os.environ['REDIS_HOST'].
        Also ensures 'import os' is present when env var reads are injected.
        """
        # G141-1: Scan ALL test/conftest files, not just QA-generated ones
        fixed_count = 0
        for fpath in self._collect_test_files():
            if not os.path.isfile(fpath):
                continue
            try:
                with open(fpath, encoding="utf-8") as f:
                    content = f.read()
            except OSError, UnicodeDecodeError:
                continue
            new_content = self._replace_redis_host_in_content(content)
            if new_content != content:
                with open(fpath, "w", encoding="utf-8") as f:
                    f.write(new_content)
                fixed_count += 1
        if fixed_count:
            print(
                f"  [U:Qa] G125-1: auto-fixed Redis host in {fixed_count} test files",
                flush=True,
            )

    @traces(spec="031", story="US-031-001", reqs=["REQ-031-010"])
    def _collect_test_files(self) -> list:
        """Collect workspace test_*.py and conftest.py paths.

        Uses the redis-host dir filter (also excludes ``.venv``).
        """
        all_test_files = []
        for root, _dirs, files in os.walk(self.workspace):
            _dirs[:] = [
                d
                for d in _dirs
                if not d.startswith(".")
                and d != "__pycache__"
                and d != "site-packages"
                and d != ".venv"
            ]
            for fname in files:
                if not fname.endswith(".py"):
                    continue
                if fname.startswith("test_") or fname == "conftest.py":
                    all_test_files.append(os.path.join(root, fname))
        return all_test_files

    @traces(spec="031", story="US-031-001", reqs=["REQ-031-010"])
    def _replace_redis_host_in_content(self, content: str) -> str:
        """Replace hardcoded localhost/port Redis config with env reads.

        Injects ``import os`` when a replacement was made and the import
        is absent. Returns content unchanged when nothing matched.
        """
        original = content
        # Replace hardcoded localhost in Redis-related contexts
        # Pattern: host="localhost" or host='localhost'
        content = re.sub(
            r"""host\s*=\s*["']localhost["']""",
            "host=os.environ['REDIS_HOST']",
            content,
        )
        # Pattern: "localhost" as standalone arg (e.g. Redis("localhost"))
        content = re.sub(
            r"""Redis\(\s*["']localhost["']""",
            "Redis(os.environ['REDIS_HOST']",
            content,
        )
        # Pattern: port=6379 or 6380 hardcoded (replace with env var)
        content = re.sub(
            r"port\s*=\s*(?:6379|6380)\b",
            "port=int(os.environ['REDIS_PORT'])",
            content,
        )
        if content != original:
            # Ensure 'import os' is present
            if "import os" not in content:
                content = "import os\n" + content
        return content

    @traces(
        spec="031",
        story="US-031-001",
        reqs=["REQ-031-011"],
    )
    def _autofix_relative_imports(self) -> None:
        """G125-2: Convert relative imports to absolute in source files.

        DeepSeek consistently generates `from orchestrata_ultimate.agents.volks.qa.module import X` which
        the deterministic review flags as IMPORTS errors. This auto-fix
        detects the package name from the src/ layout and rewrites.
        """
        pkg_name = self._detect_package_name()
        if not pkg_name:
            return
        fixed_count = 0
        for root, _dirs, files in os.walk(self.workspace):
            # Skip venv and non-source dirs
            if ".venv" in root or "__pycache__" in root:
                continue
            for fname in files:
                if not fname.endswith(".py"):
                    continue
                fpath = os.path.join(root, fname)
                with open(fpath, encoding="utf-8") as f:
                    content = f.read()
                original = content
                # Replace `from orchestrata_ultimate.agents.volks.qa.module import X` with `from pkg.module import X`
                content = re.sub(
                    r"from \.([\w.]+) import",
                    f"from {pkg_name}.\\1 import",
                    content,
                )
                # Replace `from orchestrata_ultimate.agents.volks.qa. import X` with `from pkg import X`
                content = re.sub(
                    r"from \. import",
                    f"from {pkg_name} import",
                    content,
                )
                if content != original:
                    with open(fpath, "w", encoding="utf-8") as f:
                        f.write(content)
                    fixed_count += 1
        if fixed_count:
            print(
                f"  [U:Qa] G125-2: auto-fixed relative imports "
                f"in {fixed_count} files (pkg={pkg_name})",
                flush=True,
            )

    @traces(spec="031", story="US-031-001", reqs=["REQ-031-011"])
    def _detect_package_name(self) -> str:
        """Detect the project's top-level package name.

        Prefers the ``src/`` layout (first package dir with an
        ``__init__.py``), then falls back to a flat layout. Returns an
        empty string when no package directory is found.
        """
        # Find the package name from src/ layout
        src_dir = os.path.join(self.workspace, "src")
        if os.path.isdir(src_dir):
            for entry in os.listdir(src_dir):
                entry_path = os.path.join(src_dir, entry)
                if os.path.isdir(entry_path) and not entry.startswith((".", "__")):
                    init = os.path.join(entry_path, "__init__.py")
                    if os.path.isfile(init):
                        return entry
        # Try flat layout — look for __init__.py in workspace dirs
        for entry in os.listdir(self.workspace):
            entry_path = os.path.join(self.workspace, entry)
            if os.path.isdir(entry_path) and not entry.startswith(
                (".", "__", "tests", "design", "docs")
            ):
                init = os.path.join(entry_path, "__init__.py")
                if os.path.isfile(init):
                    return entry
        return ""

    @traces(
        spec="031",
        story="US-031-001",
        reqs=["REQ-031-012"],
    )
    def _autofix_line_length(self) -> None:
        """G127-3: Auto-format source files with black to fix LINE_LENGTH.

        DeepSeek consistently generates lines >88 chars. Rather than
        burning review cycles asking DeepSeek to fix them, run black
        as post-processing — analogous to F401/Redis host auto-fixes.
        """
        venv_python = self._get_venv_python()
        try:
            subprocess.run(
                [venv_python, "-m", "black", "--line-length", "88", "."],
                cwd=self.workspace,
                capture_output=True,
                text=True,
                timeout=30,
                env=self._venv_env(),
            )
            print(
                "  [U:Qa] G127-3: auto-formatted with black",
                flush=True,
            )
        except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
            _logger.debug("black autoformat skipped: %s", exc)

    @traces(
        spec="031",
        story="US-031-001",
        reqs=["REQ-031-013"],
    )
    def _autofix_import_order(self) -> None:
        """G129-3: Auto-fix import ordering with isort.

        DeepSeek consistently fails to fix import ordering
        (stdlib vs third-party). Running isort as post-processing
        fixes IMPORTS issues structurally.
        """
        venv_python = self._get_venv_python()
        try:
            subprocess.run(
                [venv_python, "-m", "isort", "--profile", "black", "."],
                cwd=self.workspace,
                capture_output=True,
                text=True,
                timeout=30,
                env=self._venv_env(),
            )
            print(
                "  [U:Qa] G129-3: auto-fixed import ordering with isort",
                flush=True,
            )
        except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
            _logger.debug("isort autofix skipped: %s", exc)

    @traces(
        spec="031",
        story="US-031-001",
        reqs=["REQ-031-014"],
    )
    def _autofix_unused_imports(self) -> None:
        """G194-2: Remove unused imports with ruff after isort/black.

        isort can re-add imports that were removed. Running ruff --fix
        F401 as the final auto-fix step ensures unused imports are
        cleaned up without being re-introduced.
        """
        venv_python = self._get_venv_python()
        venv_dir = os.path.dirname(os.path.dirname(venv_python))
        ruff_bin = os.path.join(venv_dir, "bin", "ruff")
        if not os.path.isfile(ruff_bin):
            ruff_bin = shutil.which("ruff") or ""
        if not ruff_bin:
            return
        try:
            # Skip __init__.py — re-exports look unused but are
            # public API. Ruff removing them breaks the package.
            subprocess.run(
                [
                    ruff_bin,
                    "check",
                    "--fix",
                    "--select=F401",
                    "--exclude=__init__.py",
                    ".",
                ],
                cwd=self.workspace,
                capture_output=True,
                text=True,
                timeout=30,
            )
            print(
                "  [U:Qa] G194-2: auto-removed unused imports with ruff F401",
                flush=True,
            )
        except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
            _logger.debug("ruff F401 autofix skipped: %s", exc)

    def _autofix_parse_args(self) -> None:
        """G129-2: Patch parse_args() to accept optional args parameter.

        QA generates tests calling parse_args(["create", ...]) but
        DeepSeek's parse_args() reads sys.argv directly. Standard
        argparse testability pattern: parse_args(args=None) where
        None reads sys.argv and a list uses that directly.
        """
        for root, _dirs, files in os.walk(self.workspace):
            _dirs[:] = [
                d
                for d in _dirs
                if not d.startswith(".") and d != "__pycache__" and d != "site-packages"
            ]
            for fname in files:
                if not fname.endswith(".py"):
                    continue
                fpath = os.path.join(root, fname)
                try:
                    with open(fpath, encoding="utf-8") as f:
                        content = f.read()
                except OSError, UnicodeDecodeError:
                    continue
                original = content
                # Match: def parse_args(args=None) : ... parser.parse_args()
                # Replace with: def parse_args(args=None): ... parser.parse_args(args)
                content = re.sub(
                    r"def parse_args\(\)\s*(->\s*\S+\s*)?:",
                    r"def parse_args(args=None) \1:",
                    content,
                )
                content = re.sub(
                    r"(return\s+\w+\.parse_args)\(\)",
                    r"\1(args)",
                    content,
                )
                if content != original:
                    with open(fpath, "w", encoding="utf-8") as f:
                        f.write(content)
                    print(
                        f"  [U:Qa] G129-2: auto-fixed parse_args in {fname}",
                        flush=True,
                    )