Files

454 lines
16 KiB
Python

"""Dependency-free Windows/WSL environment diagnostics for repository gates."""
from __future__ import annotations
import importlib.util
import os
import sqlite3
import tempfile
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Final, Literal
from urllib.parse import quote
DIAGNOSTIC_SCHEMA: Final = "sigmax.environment-diagnostics/1"
PlatformId = Literal["windows", "linux"]
CacheIntegrity = Literal["ok", "corrupt", "locked"]
FilesystemStatus = Literal["ok", "locked", "cleanup_failed"]
OptionalLane = Literal["none", "plot", "reference"]
_OPTIONAL_MODULES: Final[dict[OptionalLane, tuple[str, ...]]] = {
"none": (),
"plot": ("matplotlib",),
"reference": ("diffusers",),
}
@dataclass(frozen=True, slots=True, kw_only=True)
class EnvironmentIssue:
"""One stable environment failure with a direct recovery action."""
code: str
summary: str
remediation: str
def projection(self) -> dict[str, str]:
return {
"code": self.code,
"remediation": self.remediation,
"summary": self.summary,
}
@dataclass(frozen=True, slots=True, kw_only=True)
class EnvironmentObservation:
"""Pure observations consumed by the policy evaluator."""
platform: PlatformId
local_venv: bool
cache_contained: bool
cache_writable: bool
cache_integrity: CacheIntegrity
precommit_candidate_count: int
precommit_foreign: bool
filesystem_status: FilesystemStatus
unicode_roundtrip: bool
temp_contained: bool
temp_writable: bool
anonymous_temp_compatible: bool
temp_capture_mitigated: bool
optional_lane: OptionalLane
missing_optional_modules: tuple[str, ...]
@dataclass(frozen=True, slots=True, kw_only=True)
class EnvironmentDiagnosticReport:
"""Deterministic diagnostic result without private absolute paths."""
platform: PlatformId
optional_lane: OptionalLane
issues: tuple[EnvironmentIssue, ...]
mitigations: tuple[str, ...]
@property
def status(self) -> str:
return "FAIL" if self.issues else "PASS"
def projection(self) -> dict[str, object]:
return {
"issues": [issue.projection() for issue in self.issues],
"mitigations": list(self.mitigations),
"optional_lane": self.optional_lane,
"platform": self.platform,
"schema": DIAGNOSTIC_SCHEMA,
"status": self.status,
}
@dataclass(frozen=True, slots=True, kw_only=True)
class EnvironmentProbeConfig:
"""Resolved runtime inputs for a bounded live probe."""
repository_root: Path
environment_prefix: Path
expected_environment: Path
cache_root: Path
temp_root: Path
platform: PlatformId
optional_lane: OptionalLane
path_value: str
temp_capture_mitigated: bool = False
def _venv_label(platform: PlatformId) -> str:
return ".venv" if platform == "windows" else ".venv-wsl"
def _cache_label(platform: PlatformId) -> str:
return ".tmp/pre-commit-win" if platform == "windows" else ".tmp/pre-commit-linux"
def _temp_label(platform: PlatformId) -> str:
return ".tmp/runtime-win" if platform == "windows" else ".tmp/runtime-linux"
def _venv_install_command(platform: PlatformId) -> str:
if platform == "windows":
return 'python -m venv .venv; .venv\\Scripts\\python.exe -m pip install -e ".[dev]"'
return "python3 -m venv .venv-wsl && .venv-wsl/bin/python -m pip install -e '.[dev]'"
def evaluate_environment(observation: EnvironmentObservation) -> EnvironmentDiagnosticReport:
"""Classify collected observations into stable actionable failures."""
issues: list[EnvironmentIssue] = []
cache_label = _cache_label(observation.platform)
temp_label = _temp_label(observation.platform)
if not observation.local_venv:
issues.append(
EnvironmentIssue(
code="venv.not_local",
summary=f"The active interpreter is not owned by {_venv_label(observation.platform)}.",
remediation=(
"Create it and install '.[dev]' before retrying. Run: "
f"{_venv_install_command(observation.platform)}."
),
)
)
if not observation.cache_contained:
issues.append(
EnvironmentIssue(
code="cache.outside_workspace",
summary="The configured pre-commit cache is outside repository .tmp.",
remediation=(
f"Set PRE_COMMIT_HOME to {cache_label} and rerun with the local venv."
),
)
)
elif not observation.cache_writable:
issues.append(
EnvironmentIssue(
code="cache.not_writable",
summary="The repository-local pre-commit cache is not writable.",
remediation=(
f"Close hook processes, restore write permission on {cache_label}, and retry."
),
)
)
if observation.cache_integrity == "corrupt":
issues.append(
EnvironmentIssue(
code="cache.corrupt",
summary="The repository-local pre-commit SQLite manifest is corrupt.",
remediation=(
f"Close pre-commit, remove only {cache_label}, then rerun to rebuild the cache."
),
)
)
elif observation.cache_integrity == "locked":
issues.append(
EnvironmentIssue(
code="cache.locked",
summary="The repository-local pre-commit SQLite manifest is locked.",
remediation=(
"Close other pre-commit/Python processes, verify antivirus exclusions for "
f"{cache_label}, and retry serially."
),
)
)
if observation.precommit_candidate_count > 1 or observation.precommit_foreign:
issues.append(
EnvironmentIssue(
code="tooling.precommit_conflict",
summary="PATH exposes multiple or non-venv pre-commit executables.",
remediation=(
"Remove conflicting PATH entries and run the selected local interpreter with "
"python -m pre_commit."
),
)
)
if observation.filesystem_status == "locked":
issues.append(
EnvironmentIssue(
code="filesystem.locked",
summary="The gate cannot complete an owned file write/replace/delete cycle.",
remediation=(
"Close processes holding the file, inspect antivirus or sync-tool locks, restore "
"permissions, and rerun the preflight."
),
)
)
elif observation.filesystem_status == "cleanup_failed":
issues.append(
EnvironmentIssue(
code="filesystem.cleanup_failed",
summary="The owned environment probe directory could not be removed.",
remediation=(
f"Close file handles, remove only the probe directory below {temp_label}, and retry."
),
)
)
if not observation.unicode_roundtrip:
issues.append(
EnvironmentIssue(
code="unicode.roundtrip_failed",
summary="The filesystem failed a non-ASCII create/read/rename round-trip.",
remediation=(
"Use a Unicode-capable filesystem and UTF-8 locale, then recreate the local venv "
"and rerun."
),
)
)
if not observation.temp_contained:
issues.append(
EnvironmentIssue(
code="temp.outside_workspace",
summary="The configured gate temp directory is outside repository .tmp.",
remediation=f"Set SIGMAX_TEMP_ROOT, TMPDIR, TMP, and TEMP to {temp_label}.",
)
)
elif not observation.temp_writable:
issues.append(
EnvironmentIssue(
code="temp.not_writable",
summary="The repository-local gate temp directory is not writable.",
remediation=(
f"Create {temp_label}, restore write permission, and rerun the local wrapper."
),
)
)
if not observation.anonymous_temp_compatible and not observation.temp_capture_mitigated:
issues.append(
EnvironmentIssue(
code="temp.incompatible",
summary="The configured temp path cannot sustain anonymous file operations.",
remediation=(
"On WSL mounted workspaces, run the canonical Linux wrapper so pytest uses "
"SIGMAX_PYTEST_CAPTURE_MODE=sys; otherwise choose a compatible writable temp path."
),
)
)
if observation.missing_optional_modules:
modules = ", ".join(observation.missing_optional_modules)
issues.append(
EnvironmentIssue(
code="optional.missing",
summary=f"Optional lane {observation.optional_lane} is missing: {modules}.",
remediation=(
"Install the reviewed extra with the selected local interpreter: "
f'python -m pip install -e ".[{observation.optional_lane}]".'
),
)
)
mitigations = (
("pytest.capture_sys",)
if not observation.anonymous_temp_compatible and observation.temp_capture_mitigated
else ()
)
return EnvironmentDiagnosticReport(
platform=observation.platform,
optional_lane=observation.optional_lane,
issues=tuple(issues),
mitigations=mitigations,
)
def _is_within(path: Path, parent: Path) -> bool:
try:
path.resolve().relative_to(parent.resolve())
except (OSError, ValueError):
return False
return True
def _probe_writable_directory(path: Path) -> bool:
probe: Path | None = None
try:
path.mkdir(parents=True, exist_ok=True)
descriptor, name = tempfile.mkstemp(prefix=".sigmax-write-", dir=path)
os.close(descriptor)
probe = Path(name)
probe.write_bytes(b"sigmax")
return probe.read_bytes() == b"sigmax"
except OSError:
return False
finally:
if probe is not None:
with suppress(OSError):
probe.unlink(missing_ok=True)
def _cache_integrity(cache_root: Path) -> CacheIntegrity:
database = cache_root / "db.db"
if not database.is_file():
return "ok"
# IMPORTANT: use SQLite read-only mode; diagnostics must never repair or recreate a cache DB.
uri = f"file:{quote(str(database.resolve()))}?mode=ro"
try:
connection = sqlite3.connect(uri, uri=True, timeout=0)
try:
result = connection.execute("PRAGMA quick_check").fetchone()
finally:
connection.close()
except sqlite3.Error as error:
lowered = str(error).lower()
return "locked" if "locked" in lowered or "busy" in lowered else "corrupt"
return "ok" if result == ("ok",) else "corrupt"
def _find_precommit_candidates(path_value: str, platform: PlatformId) -> tuple[Path, ...]:
names = (
("pre-commit.exe", "pre-commit.cmd", "pre-commit.bat", "pre-commit")
if platform == "windows"
else ("pre-commit",)
)
found: dict[str, Path] = {}
for raw_directory in path_value.split(os.pathsep):
if not raw_directory:
continue
directory = Path(raw_directory)
for name in names:
candidate = directory / name
if candidate.is_file():
try:
resolved = candidate.resolve()
except OSError:
resolved = candidate.absolute()
found[os.path.normcase(str(resolved))] = resolved
return tuple(found[key] for key in sorted(found))
def _probe_file_operations(temp_root: Path) -> tuple[FilesystemStatus, bool, bool, bool]:
probe_directory: Path | None = None
source: Path | None = None
target: Path | None = None
filesystem_status: FilesystemStatus = "ok"
unicode_roundtrip = True
temp_writable = True
anonymous_temp_compatible = True
try:
temp_root.mkdir(parents=True, exist_ok=True)
probe_directory = Path(tempfile.mkdtemp(prefix=".sigmax-environment-", dir=temp_root))
source = probe_directory / "probe.txt"
target = probe_directory / "環境-探測.txt"
source.write_text("Sigmax 路徑", encoding="utf-8")
if source.read_text(encoding="utf-8") != "Sigmax 路徑":
unicode_roundtrip = False
source.replace(target)
if target.read_text(encoding="utf-8") != "Sigmax 路徑":
unicode_roundtrip = False
target.unlink()
target = None
try:
with tempfile.TemporaryFile(dir=temp_root) as anonymous:
anonymous.write(b"sigmax")
anonymous.seek(0)
if anonymous.read() != b"sigmax":
anonymous_temp_compatible = False
anonymous.truncate()
except OSError:
anonymous_temp_compatible = False
except UnicodeError:
unicode_roundtrip = False
except OSError:
filesystem_status = "locked"
temp_writable = False
finally:
cleanup_failed = False
for candidate in (target, source):
if candidate is not None:
try:
candidate.unlink(missing_ok=True)
except OSError:
cleanup_failed = True
if probe_directory is not None:
try:
probe_directory.rmdir()
except OSError:
cleanup_failed = True
if cleanup_failed:
filesystem_status = "cleanup_failed"
return filesystem_status, unicode_roundtrip, temp_writable, anonymous_temp_compatible
def collect_environment_observation(
config: EnvironmentProbeConfig,
*,
module_available: Callable[[str], bool] | None = None,
file_operation_probe: Callable[[Path], tuple[FilesystemStatus, bool, bool, bool]] | None = None,
) -> EnvironmentObservation:
"""Collect bounded observations without importing optional modules."""
owned_root = config.repository_root / ".tmp"
cache_contained = _is_within(config.cache_root, owned_root)
temp_contained = _is_within(config.temp_root, owned_root)
# CRITICAL: never touch cache/temp paths until containment below repository .tmp is proven.
cache_writable = cache_contained and _probe_writable_directory(config.cache_root)
cache_integrity: CacheIntegrity = (
_cache_integrity(config.cache_root) if cache_contained else "ok"
)
if temp_contained:
probe = file_operation_probe or _probe_file_operations
(
filesystem_status,
unicode_roundtrip,
temp_writable,
anonymous_temp_compatible,
) = probe(config.temp_root)
else:
filesystem_status = "ok"
unicode_roundtrip = True
temp_writable = False
anonymous_temp_compatible = True
candidates = _find_precommit_candidates(config.path_value, config.platform)
precommit_foreign = any(
not _is_within(candidate, config.expected_environment) for candidate in candidates
)
available = module_available or (lambda name: importlib.util.find_spec(name) is not None)
missing_optional = tuple(
name for name in _OPTIONAL_MODULES[config.optional_lane] if not available(name)
)
local_venv = _is_within(config.environment_prefix, config.expected_environment)
return EnvironmentObservation(
platform=config.platform,
local_venv=local_venv,
cache_contained=cache_contained,
cache_writable=cache_writable,
cache_integrity=cache_integrity,
precommit_candidate_count=len(candidates),
precommit_foreign=precommit_foreign,
filesystem_status=filesystem_status,
unicode_roundtrip=unicode_roundtrip,
temp_contained=temp_contained,
temp_writable=temp_writable,
anonymous_temp_compatible=anonymous_temp_compatible,
temp_capture_mitigated=config.temp_capture_mitigated,
optional_lane=config.optional_lane,
missing_optional_modules=missing_optional,
)