"""Syntax-lint and LSP-diagnostics tier for ``tools.file_operations``. Extracted from ``ShellFileOperations`` as ``LintMixin``; the class inherits it so every ``self._check_lint(...)`` call resolves unchanged via the MRO. Module constants are re-imported into ``tools.file_operations`` for back-compat. """ import ast import json import os import tomllib from typing import Callable, Dict, Optional from tools.file_operations_common import LintResult # Shell linters by extension, run via _exec() for languages whose check needs an # external toolchain. ``.tsx`` is deliberately absent: it has never had a shell # linter (it hits the "No linter" skip) and LSP covers it when enabled. LINTERS = { '.py': 'python -m py_compile {file} 2>&1', '.js': 'node --check {file} 2>&1', '.ts': 'npx tsc --noEmit {file} 2>&1', '.go': 'go vet {file} 2>&1', '.rs': 'rustfmt --check {file} 2>&1', } # Extensions whose per-file shell linter is structurally weaker than a real LSP # server and floods phantom errors on real projects: single-file ``tsc`` ignores # tsconfig (no-lib/ES5 → every ES2015+ stdlib name "missing"), ``go vet`` fails # outside a module, ``rustfmt --check`` is style-only and rejects non-Cargo files. # When an LSP server claims the file, ``_check_lint`` skips the shell linter for # these; py_compile / node --check are file-local and correct so always run. _SHELL_LINTER_LSP_REDUNDANT = frozenset({'.ts', '.go', '.rs'}) # Output substrings meaning the linter binary exists but could not actually run # (tooling gap, not a lint failure). ``_check_lint`` then returns ``skipped`` so # the write isn't flagged and the LSP tier (which gates on ok/skipped) still runs. # Matched case-insensitively. _LINTER_UNUSABLE_PATTERNS = { 'npx': ( 'this is not the tsc command you are looking for', # tsc not installed locally 'could not determine executable to run', 'not found in npm registry', ), 'rustfmt': ( 'no input filename given', # outside a Cargo project 'error: not a workspace', ), 'go': ( 'cannot find package', # outside a module / GOPATH 'go: cannot find main module', ), } def _looks_like_linter_unusable(base_cmd: str, output: str) -> bool: """True iff ``output`` from ``base_cmd`` (first word of the linter cmd) says the tool itself couldn't run.""" patterns = _LINTER_UNUSABLE_PATTERNS.get(base_cmd) if not patterns: return False lower = output.lower() return any(p in lower for p in patterns) def _lint_json_inproc(content: str) -> tuple[bool, str]: """In-process JSON syntax check. Returns (ok, error_message).""" try: json.loads(content) return True, "" except json.JSONDecodeError as e: return False, f"JSONDecodeError: {e.msg} (line {e.lineno}, column {e.colno})" except Exception as e: # noqa: BLE001 — any parse failure is a lint failure return False, f"{type(e).__name__}: {e}" def _lint_yaml_inproc(content: str) -> tuple[bool, str]: """In-process YAML syntax check; ``__SKIP__`` when PyYAML is missing. Syntax-only (``yaml.parse``), NOT ``safe_load``: loading rejects valid YAML that isn't one plain document — multi-doc ``---`` streams (ComposerError) and app tags like CloudFormation ``!Sub`` / Ansible ``!vault`` (ConstructorError). This verdict is a fail-closed WRITE gate, so a false positive refuses a legitimate write; ``parse`` still catches real scanner/parser errors. """ try: import yaml as _yaml except ImportError: return True, "__SKIP__" try: for _event in _yaml.parse(content): pass return True, "" except _yaml.YAMLError as e: return False, f"YAMLError: {e}" except Exception as e: # noqa: BLE001 return False, f"{type(e).__name__}: {e}" def _lint_toml_inproc(content: str) -> tuple[bool, str]: """In-process TOML syntax check (stdlib tomllib).""" try: tomllib.loads(content) return True, "" except Exception as e: # TOMLDecodeError is a ValueError subclass return False, f"{type(e).__name__}: {e}" def _lint_python_inproc(content: str) -> tuple[bool, str]: """In-process Python syntax check via ast.parse (py_compile's scope, no subprocess).""" try: ast.parse(content) return True, "" except SyntaxError as e: loc = f" (line {e.lineno}, column {e.offset})" if e.lineno else "" return False, f"{type(e).__name__}: {e.msg}{loc}" except Exception as e: # noqa: BLE001 return False, f"{type(e).__name__}: {e}" # In-process linters, preferred over shell linters (microseconds, no subprocess). # Each takes content and returns (ok, error); error ``"__SKIP__"`` means the # linter is unavailable (missing dependency) and counts as "no linter". LINTERS_INPROC: Dict[str, Callable[[str], tuple[bool, str]]] = { '.py': _lint_python_inproc, '.json': _lint_json_inproc, '.yaml': _lint_yaml_inproc, '.yml': _lint_yaml_inproc, '.toml': _lint_toml_inproc, } # Extensions where write_file REFUSES on a parse failure (fail-closed gate) rather # than merely reporting. ``.py`` is excluded on purpose: test fixtures use ``*.py`` # paths as a generic stand-in for arbitrary text, so hard-refusing invalid Python # would break exercised patterns. Python keeps the non-blocking lint-delta report. _FAIL_CLOSED_INPROC_EXTS = frozenset({'.json', '.yaml', '.yml', '.toml'}) class LintMixin: """Post-write syntax lint + LSP diagnostics. Requires ``_exec``, ``_has_command``, ``_escape_shell_arg``, ``_escape_native_tool_arg`` and ``env`` from the host class.""" def _check_lint(self, path: str, content: Optional[str] = None) -> LintResult: """Syntax-check ``path``: in-process linter when one matches the extension (``content`` avoids a re-read), else the shell linter table.""" ext = os.path.splitext(path)[1].lower() inproc = LINTERS_INPROC.get(ext) if inproc is not None: if content is None: read_result = self._exec(f"cat {self._escape_shell_arg(path)} 2>/dev/null") if read_result.exit_code != 0: return LintResult(skipped=True, message=f"Failed to read {path} for lint") content = read_result.stdout ok, err = inproc(content) if err == "__SKIP__": return LintResult(skipped=True, message=f"No linter available for {ext} (missing dependency)") return LintResult(success=ok, output="" if ok else err) if ext not in LINTERS: return LintResult(skipped=True, message=f"No linter for {ext} files") # Single-file tsc can't read the project's tsconfig.json, so for project # .ts files it floods phantom TS2307/TS2339 errors the delta filter then # misreports as "pre-existing"; skip and let the LSP tier speak. if ext == '.ts' and self._has_ancestor_tsconfig(path): return LintResult( skipped=True, message=( "Project tsconfig.json detected — per-file tsc skipped " "(single-file tsc can't resolve project aliases/globals; " "use the LSP tier or `tsc -p tsconfig.json` for real " "diagnostics)." ), ) if ext in _SHELL_LINTER_LSP_REDUNDANT and self._lsp_will_handle(path): return LintResult( skipped=True, message=f"LSP server handles {ext} — shell linter skipped", ) linter_cmd = LINTERS[ext] base_cmd = linter_cmd.split()[0] if not self._has_command(base_cmd): return LintResult(skipped=True, message=f"{base_cmd} not available") # Linters are native Windows binaries on Windows: they need the C:/... # form, not MSYS /c/... (node would resolve it as C:\c\Users\... → phantom ENOENT). cmd = linter_cmd.replace("{file}", self._escape_native_tool_arg(path)) result = self._exec(cmd, timeout=30) if result.exit_code != 0 and _looks_like_linter_unusable(base_cmd, result.stdout): from tools.ansi_strip import strip_ansi cleaned = strip_ansi(result.stdout).strip() # Collapse to one line — the npx banner is multi-line ASCII art. first_line = next( (ln.strip() for ln in cleaned.splitlines() if ln.strip()), cleaned[:120], ) return LintResult( skipped=True, message=f"{base_cmd} not usable: {first_line[:200]}", ) return LintResult( success=result.exit_code == 0, output=result.stdout.strip() if result.stdout.strip() else "" ) def _check_lint_delta(self, path: str, pre_content: Optional[str], post_content: Optional[str] = None) -> LintResult: """Post-write lint; when it fails and ``pre_content`` is known, report only errors this edit introduced (pre-existing lines filtered out). Semantic (LSP) diagnostics are a separate channel — see ``_maybe_lsp_diagnostics`` — so syntax and semantic signals stay distinct. """ post = self._check_lint(path, content=post_content) if post.success or post.skipped or pre_content is None: return post pre = self._check_lint(path, content=pre_content) if pre.success or pre.skipped or not pre.output: return post # pre-write was clean (or unlintable): all post errors are new # Single-error parsers (ast.parse, json.loads) stop at the first error, so # if every post error already existed we can't prove the edit is clean — # report the file as still broken but say nothing new was introduced. pre_lines = {ln.strip() for ln in pre.output.splitlines() if ln.strip()} post_lines = [ln for ln in post.output.splitlines() if ln.strip() and ln.strip() not in pre_lines] if not post_lines: return LintResult( success=False, output=post.output, message="Pre-existing lint errors — this edit didn't introduce new ones but the file is still broken.", ) return LintResult( success=False, output=( "New lint errors introduced by this edit " "(pre-existing errors filtered out):\n" + "\n".join(post_lines) ) ) def _lsp_local_only(self) -> bool: """True iff wired to a local backend. LSP servers run on the host and can't see files inside Docker/Modal/SSH/Daytona sandboxes.""" env = getattr(self, "env", None) # tests may build via __new__ without __init__ if env is None: return False try: from tools.environments.local import LocalEnvironment except Exception: # noqa: BLE001 return False return isinstance(env, LocalEnvironment) def _lsp_service(self): """The active LSPService, or None on a non-local backend / any failure. Shared best-effort probe: LSP is an enrichment layer and must never break a write, so every failure path collapses to None. """ if not self._lsp_local_only(): return None try: from agent.lsp import get_service return get_service() except Exception: # noqa: BLE001 return None def _lsp_handles_extension(self, ext: str) -> bool: """True iff some registered LSP server claims ``ext`` (static registry only; safe on remote backends). Decides whether pre-write content is worth capturing for the line-shift map.""" if not ext: return False try: from agent.lsp.servers import SERVERS except Exception: # noqa: BLE001 return False ext_lower = ext.lower() return any(ext_lower in srv.extensions for srv in SERVERS) def _has_ancestor_tsconfig(self, path: str) -> bool: """True iff a tsconfig.json exists in ``path``'s directory or any ancestor. Host-side walk, local backend only: on a remote backend the tree isn't here, so this answers False and the shell linter runs as before — never suppress lint based on a probe that couldn't answer. """ if not self._lsp_local_only(): return False try: d = os.path.dirname(os.path.abspath(path)) while True: if os.path.isfile(os.path.join(d, "tsconfig.json")): return True parent = os.path.dirname(d) if parent == d: return False d = parent except Exception: # noqa: BLE001 return False def _lsp_will_handle(self, path: str) -> bool: """True iff the LSP service is active AND ``enabled_for(path)`` (workspace detection, disabled-server set, broken-pair short-circuit). Any failure → False so the shell linter still runs.""" svc = self._lsp_service() if svc is None: return False try: return bool(svc.enabled_for(path)) except Exception: # noqa: BLE001 return False def _snapshot_lsp_baseline(self, path: str) -> None: """Capture pre-edit LSP diagnostics so the post-write delta is correct. Silent on failure.""" svc = self._lsp_service() if svc is None: return try: svc.snapshot_baseline(path) except Exception: # noqa: BLE001 pass def _maybe_lsp_diagnostics( self, path: str, *, pre_content: Optional[str] = None, post_content: Optional[str] = None, ) -> str: """Formatted LSP diagnostics introduced by this edit, or "" when LSP is unavailable/disabled/clean. Never raises past the service probe. With both pre and post content a line-shift map remaps baseline diagnostics into post-edit coordinates; otherwise every pre-existing diagnostic below an inserted/deleted line would look newly introduced. """ svc = self._lsp_service() if svc is None or not svc.enabled_for(path): return "" line_shift = None if pre_content is not None and post_content is not None and pre_content != post_content: try: from agent.lsp.range_shift import build_line_shift line_shift = build_line_shift(pre_content, post_content) except Exception: # noqa: BLE001 line_shift = None try: diagnostics = svc.get_diagnostics_sync(path, delta=True, line_shift=line_shift) except Exception: # noqa: BLE001 return "" if not diagnostics: return "" try: from agent.lsp.reporter import report_for_file, truncate block = report_for_file(path, diagnostics) if not block: return "" return truncate("LSP diagnostics introduced by this edit:\n" + block) except Exception: # noqa: BLE001 return ""