diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..d43fcfde --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "python.defaultInterpreterPath": "${workspaceFolder}/backend/.venv/bin/python", + "python.terminal.activateEnvironment": true, + "terminal.integrated.enablePersistentSessions": false +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3959860a..90cad290 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -16,7 +16,7 @@ "runOn": "folderOpen" }, "presentation": { - "reveal": "never", + "reveal": "always", "panel": "dedicated", "close": true }, diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index 383d07e4..2699f405 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -10,6 +10,12 @@ pytest==8.3.4 pytest-asyncio==0.25.2 -# Used by linter/lint.py (the vulture dead-code check). watchfiles, also -# needed by lint.py, already comes in transitively via uvicorn[standard]. +# Used by linter/lint.py. watchfiles, also needed by lint.py, already comes in +# transitively via uvicorn[standard]. +# - vulture: whole-program dead code (dead functions/methods/classes/attrs). +# - ruff: per-file/per-scope checks (F401 unused imports, F811 redefinitions, +# F841 unused locals, ARG001/ARG002 unused args) that ruff does AST-accurately +# and vulture either gets wrong or rates as noise. vulture is narrowed to defer +# imports/locals to ruff (see linter/checks/vulture.py). vulture==2.16 +ruff==0.15.17 diff --git a/backend/tests/test_outputs_runtime_cleanup.py b/backend/tests/test_outputs_runtime_cleanup.py index 5b032351..b345550b 100644 --- a/backend/tests/test_outputs_runtime_cleanup.py +++ b/backend/tests/test_outputs_runtime_cleanup.py @@ -20,6 +20,8 @@ import sys import tempfile import time +import pytest + sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) from backend.apps.outputs.runtime import ( @@ -115,6 +117,7 @@ def test_write_env_value(): # --- Test 2: stop_all reaps an active runtime (real spawn). --- +@pytest.mark.asyncio async def test_stop_all_kills_active(): with tempfile.TemporaryDirectory() as tmp: port = _find_free_port() @@ -151,6 +154,7 @@ async def test_stop_all_kills_active(): # --- Test 3: stop_all reaps an idle (LRU + SIGSTOP'd) runtime. --- +@pytest.mark.asyncio async def test_stop_all_kills_idle(): with tempfile.TemporaryDirectory() as tmp: port = _find_free_port() @@ -182,6 +186,7 @@ async def test_stop_all_kills_idle(): # --- Test 4: persisted port collision triggers .env rewrite + new spawn. --- +@pytest.mark.asyncio async def test_port_collision_reallocates_env(): with tempfile.TemporaryDirectory() as tmp: squatted_port = _find_free_port() @@ -215,6 +220,7 @@ async def test_port_collision_reallocates_env(): # --- Test 5: stop_all is idempotent. --- +@pytest.mark.asyncio async def test_stop_all_idempotent(): m = AppRuntimeManager() n = await m.stop_all() @@ -225,6 +231,7 @@ async def test_stop_all_idempotent(): # --- Test 6: vite-like grandchild dies even with EXIT-only trap. --- +@pytest.mark.asyncio async def test_descendant_tree_killed_despite_exit_only_trap(): """Regression for the actual prod bug: webapp_template run.sh has only `trap cleanup EXIT` (no TERM), so a flat SIGTERM to bash exits bash diff --git a/backend/tests/test_phase1_stress.py b/backend/tests/test_phase1_stress.py index c60aac36..5f8767e6 100644 --- a/backend/tests/test_phase1_stress.py +++ b/backend/tests/test_phase1_stress.py @@ -279,15 +279,3 @@ async def test_concurrent_send_message_unique_client_ids(): actual = [p[1] for p in pairs] assert expected == actual, "client_message_id must round-trip exactly" assert len(set(actual)) == 100, "all unique" - - -# --------------------------------------------------------------------------- -# Pytest config: register asyncio mode so we don't need the plugin. -# --------------------------------------------------------------------------- - - -def pytest_collection_modifyitems(config, items): - """Auto-mark async tests so they run under pytest-asyncio.""" - for item in items: - if asyncio.iscoroutinefunction(getattr(item, "function", None)): - item.add_marker(pytest.mark.asyncio) diff --git a/linter/checks/__init__.py b/linter/checks/__init__.py index 8baad9ce..3816d4ed 100644 --- a/linter/checks/__init__.py +++ b/linter/checks/__init__.py @@ -9,6 +9,21 @@ from pathlib import Path LINTIGNORE_PREFIX = ".lintignore" +class CheckError(Exception): + """Raised when a check could not complete (missing tool, timeout, crash). + + This is deliberately distinct from "the check ran and found zero problems": + the orchestrator catches it and surfaces the failure loudly so a partial run + is never silently reported as a clean one. Returning ``[]`` on failure (the + old behavior) made a timed-out or crashed tool look identical to a passing + one, which let the reported error count silently undercount the real total. + """ + + def __init__(self, reason: str) -> None: + super().__init__(reason) + self.reason = reason + + def _matches_any(text: str, patterns: list[str]) -> bool: return any(fnmatch.fnmatch(text, p) for p in patterns) diff --git a/linter/checks/eslint.py b/linter/checks/eslint.py index ebd730d9..54422f5a 100644 --- a/linter/checks/eslint.py +++ b/linter/checks/eslint.py @@ -6,7 +6,9 @@ import json import subprocess from pathlib import Path -from . import is_lintignored +from . import CheckError, is_lintignored + +_TIMEOUT = 120 def run_eslint(root: Path, ignores: dict[Path, set[str]] | None = None) -> list[str]: @@ -14,21 +16,24 @@ def run_eslint(root: Path, ignores: dict[Path, set[str]] | None = None) -> list[ frontend_dir = root / "frontend" eslint_bin = frontend_dir / "node_modules" / ".bin" / "eslint" if not eslint_bin.exists(): - return [] + raise CheckError("eslint binary not found (run npm install in frontend/)") cmd = [str(eslint_bin), "src/", "--format", "json", "--no-warn-ignored"] try: result = subprocess.run( cmd, capture_output=True, text=True, - cwd=str(frontend_dir), timeout=60, + cwd=str(frontend_dir), timeout=_TIMEOUT, ) - except (OSError, subprocess.TimeoutExpired): - return [] + except subprocess.TimeoutExpired as e: + raise CheckError(f"timed out after {_TIMEOUT}s") from e + except OSError as e: + raise CheckError(f"failed to launch eslint ({e})") from e try: data = json.loads(result.stdout) - except (json.JSONDecodeError, ValueError): - return [] + except (json.JSONDecodeError, ValueError) as e: + detail = result.stderr.strip()[:300] or "non-JSON output" + raise CheckError(f"eslint produced unparseable output: {detail}") from e errors: list[str] = [] for entry in data: diff --git a/linter/checks/knip.py b/linter/checks/knip.py index 08c34fec..cc420c56 100644 --- a/linter/checks/knip.py +++ b/linter/checks/knip.py @@ -6,7 +6,9 @@ import json import subprocess from pathlib import Path -from . import is_lintignored +from . import CheckError, is_lintignored + +_TIMEOUT = 120 KIND_LABELS = { "dependencies": "Unused dependency", @@ -25,21 +27,24 @@ def run_knip(root: Path, ignores: dict[Path, set[str]] | None = None) -> list[st frontend_dir = root / "frontend" knip_bin = frontend_dir / "node_modules" / ".bin" / "knip" if not knip_bin.exists(): - return [] + raise CheckError("knip binary not found (run npm install in frontend/)") cmd = [str(knip_bin), "--reporter", "json"] try: result = subprocess.run( cmd, capture_output=True, text=True, - cwd=str(frontend_dir), timeout=60, + cwd=str(frontend_dir), timeout=_TIMEOUT, ) - except (OSError, subprocess.TimeoutExpired): - return [] + except subprocess.TimeoutExpired as e: + raise CheckError(f"timed out after {_TIMEOUT}s") from e + except OSError as e: + raise CheckError(f"failed to launch knip ({e})") from e try: data = json.loads(result.stdout) - except (json.JSONDecodeError, ValueError): - return [] + except (json.JSONDecodeError, ValueError) as e: + detail = result.stderr.strip()[:300] or "non-JSON output" + raise CheckError(f"knip produced unparseable output: {detail}") from e errors: list[str] = [] for entry in data.get("issues", []): diff --git a/linter/checks/ruff.py b/linter/checks/ruff.py new file mode 100644 index 00000000..0b3dfbeb --- /dev/null +++ b/linter/checks/ruff.py @@ -0,0 +1,92 @@ +"""Ruff runner: per-file, scope-aware lint that vulture's global name-set can't do. + +Owns the checks ruff is strictly better at than vulture: + - F401 unused imports (real per-file scope; honors __all__ / redundant-alias re-exports) + - F811 redefinition of an unused name + - F841 unused local variable assignment + - ARG001/ARG002 unused function/method arguments + +Vulture is narrowed (see checks/vulture.py) to only emit dead functions, methods, +classes, and attributes, the whole-program reachability that ruff structurally +does not attempt. The two are complementary, not redundant. +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from pathlib import Path + +from . import CheckError, is_excepted, is_lintignored + +# Generous because the first run after a window reload races the editor's +# startup load with a cold ruff cache; a tight limit there is exactly what made +# the check time out and silently report zero. +_TIMEOUT = 120 + +# Ruff emits ANSI color into its concise output even on a non-tty / NO_COLOR; +# strip the escapes before parsing so the regex sees plain text. +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + +# `path:line:col: CODE message`. The optional `[*]` fixable marker is dropped. +_LINE_RE = re.compile(r"^(?P.+?):(?P\d+):(?P\d+): (?P[A-Z]+\d+) (?P.+)$") + + +def run_ruff( + root: Path, + select: str, + exceptions: dict[str, list[str]], + ignores: dict[Path, set[str]] | None = None, +) -> list[str]: + """Run ruff on the Python backend and return errors.""" + ruff_bin = root / "backend" / ".venv" / "bin" / "ruff" + if not ruff_bin.exists(): + found = shutil.which("ruff") + if not found: + raise CheckError("ruff executable not found in backend/.venv/bin or PATH") + ruff_bin = Path(found) + + targets = ["backend"] + if (root / "debug.py").exists(): + targets.append("debug.py") + cmd = [ + str(ruff_bin), "check", *targets, + "--isolated", # ignore any stray pyproject/ruff.toml so the linter is hermetic + "--select", select, + "--output-format", "concise", + "--no-fix", + "--exclude", ".venv,__pycache__,data,uv-bin,webapp_template", + ] + + try: + result = subprocess.run( + cmd, capture_output=True, text=True, cwd=str(root), timeout=_TIMEOUT, + ) + except subprocess.TimeoutExpired as e: + raise CheckError(f"timed out after {_TIMEOUT}s (machine under load or cold cache)") from e + except OSError as e: + raise CheckError(f"failed to launch ruff ({e})") from e + + # ruff exits 0 (no findings) or 1 (findings) on success; anything else with + # no parseable output means ruff itself errored (e.g. could not write its + # cache) — surface it instead of treating the empty stdout as "clean". + if result.returncode not in (0, 1) and not result.stdout.strip(): + detail = _ANSI_RE.sub("", result.stderr).strip()[:300] or "no output" + raise CheckError(f"ruff exited with code {result.returncode}: {detail}") + + errors: list[str] = [] + for line in result.stdout.strip().splitlines(): + m = _LINE_RE.match(_ANSI_RE.sub("", line).strip()) + if not m: + continue + filepath = m.group("path") + if is_excepted(filepath, "ruff", exceptions): + continue + if ignores and is_lintignored(root / filepath, root, "ruff", ignores): + continue + errors.append( + f"{filepath}:{m.group('line')}:{m.group('col')}: " + f"error: [ruff] {m.group('code')} {m.group('msg')}" + ) + return errors diff --git a/linter/checks/vulture.py b/linter/checks/vulture.py index 5d0acfce..a17b0919 100644 --- a/linter/checks/vulture.py +++ b/linter/checks/vulture.py @@ -1,5 +1,11 @@ """Vulture dead-code detection runner. +Scoped to whole-program reachability only: dead functions, methods, classes, +and attributes. Unused imports and unused local variables are dropped here and +owned by ruff (F401/F841), whose per-file scope analysis is strictly more +accurate than vulture's global name-set matching (which hides an import that is +dead in one file whenever the same name is used in any other file). + Class-body findings (fields, methods inside a class) are filtered out here and handled separately by checks/classes.py which understands Pydantic. """ @@ -13,14 +19,22 @@ import subprocess from functools import lru_cache from pathlib import Path -from . import is_excepted, is_lintignored +from . import CheckError, is_excepted, is_lintignored CONFIG_DIR = Path(__file__).resolve().parent.parent / "config" +# Generous so the first run after a window reload (editor startup load) does not +# time out and get reported as zero findings. +_TIMEOUT = 120 -@lru_cache(maxsize=64) -def _class_line_ranges(filepath: str) -> list[tuple[int, int]]: - """Return (start, end) line ranges for all class bodies in *filepath*.""" + +@lru_cache(maxsize=256) +def _class_line_ranges_cached(filepath: str, _mtime: float) -> list[tuple[int, int]]: + """Return (start, end) line ranges for all class bodies in *filepath*. + + Keyed on *(filepath, mtime)* so the long-lived watch process re-parses a file + after it is edited instead of returning stale ranges from an earlier version. + """ try: tree = ast.parse(Path(filepath).read_text()) except (OSError, SyntaxError): @@ -33,6 +47,14 @@ def _class_line_ranges(filepath: str) -> list[tuple[int, int]]: return ranges +def _class_line_ranges(filepath: str) -> list[tuple[int, int]]: + try: + mtime = Path(filepath).stat().st_mtime + except OSError: + return [] + return _class_line_ranges_cached(filepath, mtime) + + def _is_inside_class(filepath: str, lineno: int) -> bool: """True when *lineno* is strictly inside a class body. @@ -52,7 +74,7 @@ def run_vulture( if not vulture_bin.exists(): found = shutil.which("vulture") if not found: - return [] + raise CheckError("vulture executable not found in backend/.venv/bin or PATH") vulture_bin = Path(found) whitelist = CONFIG_DIR / "vulture_whitelist.py" @@ -71,10 +93,19 @@ def run_vulture( try: result = subprocess.run( - cmd, capture_output=True, text=True, cwd=str(root), timeout=30, + cmd, capture_output=True, text=True, cwd=str(root), timeout=_TIMEOUT, ) - except (OSError, subprocess.TimeoutExpired): - return [] + except subprocess.TimeoutExpired as e: + raise CheckError(f"timed out after {_TIMEOUT}s (machine under load or large tree)") from e + except OSError as e: + raise CheckError(f"failed to launch vulture ({e})") from e + + # vulture exits 0 (clean) or 1 (findings) on success; a higher code with no + # findings on stdout means vulture errored — surface it rather than reporting + # an empty result as "clean". + if result.returncode not in (0, 1) and not result.stdout.strip(): + detail = result.stderr.strip()[:300] or "no output" + raise CheckError(f"vulture exited with code {result.returncode}: {detail}") errors: list[str] = [] for line in result.stdout.strip().splitlines(): @@ -82,6 +113,11 @@ def run_vulture( if not m: continue filepath, lineno, message = m.groups() + # Imports and local variables are ruff's domain (F401/F841); skip them + # here so we don't double-report and so vulture's weaker import logic + # never gets a vote. + if re.search(r"unused (import|variable)", message): + continue if is_excepted(filepath, "vulture", exceptions): continue if ignores and is_lintignored(root / filepath, root, "vulture", ignores): diff --git a/linter/config/config.json b/linter/config/config.json index cbf22b10..9e794375 100644 --- a/linter/config/config.json +++ b/linter/config/config.json @@ -5,6 +5,7 @@ "import-cycles": false, "no-nested-imports": false, "vulture": true, + "ruff": true, "eslint": false, "knip": false, "endpoints": false, @@ -14,6 +15,7 @@ "no-nested-imports": "Off on purpose: this codebase uses function-level/lazy imports to break import cycles (400+ sites). Flagging them all is wrong for us.", "eslint-knip": "Node tooling deferred to a later pass.", "classes": "Placeholder check, not wired up. endpoints: orphaned-endpoint triage deferred.", + "ruff-vulture-split": "ruff owns per-file/per-scope checks (F401 unused imports, F811 redefinitions, F841 unused locals, ARG001/ARG002 unused args), which it does AST-accurately and which vulture either gets wrong (global name-set hides per-file dead imports) or rates as noisy 60% findings. vulture is narrowed in checks/vulture.py to whole-program reachability only (dead functions/methods/classes/attributes), the one thing ruff structurally cannot do since it never builds a cross-module symbol graph. F401 honors __all__ and the redundant-alias form (import x as x) for intentional re-exports; add one of those if F401 flags a deliberate re-export.", "max-file-lines-exceptions": "Grandfather list of pre-existing >300-line files (existing debt, not new). Paths updated after the folder-tree restructure moved several of them.", "max-folder-items-exceptions": "Exact-path allow for folders intentionally over the cap. The rule trips at >7 (7 items is fine, the 8th tips it), so only genuinely 8+ folders are listed. backend/ and backend/apps are FastAPI feature-package registries (each child is an app mounted in main.py); agents/ aggregates agent subsystems; core/, tools_lib/, tests/ are conventionally flat. Frontend: app/pages is the page registry, AgentChat/ChatInput/Settings-sections/Onboarding are organizational parents, and shared/state (Redux slices) plus hooks/steps/mcp-cards/Views are flat peer collections. scripts/, electron/, linter/checks/ are flat tool dirs. These replaced blanket .lintignore-max-folder-items sentinels (backend, frontend, scripts, electron, linter/checks) so the rule still catches NEW unplanned bloat everywhere else. Kept as whole-subtree sentinels on purpose: debugger/ (self-contained injected sub-tool with its own Vite GUI), webapp_template (Vite scaffold payload), and vendored mcp-bundles.", "import-cycles": "Flags RUNTIME circular imports only (SCC>1). Skips type-only imports (import type / export type) and dynamic import() since neither runs at module init, which is why the idiomatic Redux store<->hooks type cycle is not flagged. Frontend alias resolution comes from import-cycle-aliases. Zero cycles today; the check keeps it that way." @@ -24,6 +26,7 @@ "import-cycle-aliases": { "@/": "frontend/src/" }, "vulture-min-confidence": 1, "vulture-error-threshold": 1, + "ruff-select": "F401,F811,F841,ARG001,ARG002", "no-nested-imports": true, "endpoint-ignore-routes": ["*/callback", "*/callback/*"] }, @@ -59,6 +62,7 @@ "no-nested-imports": [], "import-cycles": [], "vulture": [], + "ruff": [], "endpoints": [], "classes": [] } diff --git a/linter/lint.py b/linter/lint.py index da1df3ea..9c27ffe7 100644 --- a/linter/lint.py +++ b/linter/lint.py @@ -7,12 +7,14 @@ import argparse import json import os import sys +from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from typing import Any, Callable -from checks import is_excluded, is_excepted, is_lintignored, collect_lintignores +from checks import CheckError, is_excluded, is_excepted, is_lintignored, collect_lintignores from checks.structural import check_file_lines, check_folder_items, check_nested_imports from checks.vulture import run_vulture +from checks.ruff import run_ruff from checks.eslint import run_eslint from checks.knip import run_knip from checks.endpoints import run_endpoint_check @@ -23,21 +25,44 @@ from watchfiles import watch, DefaultFilter SCRIPT_DIR = Path(__file__).resolve().parent CONFIG_FILE = SCRIPT_DIR / "config" / "config.json" +# Print order for the sections; also the order they run in. +SECTION_ORDER = [ + "structural", "vulture", "ruff", "eslint", + "knip", "endpoints", "classes", "import-cycles", +] + + +@dataclass +class LintResult: + """Outcome of one full lint pass. + + ``sections`` maps each section name to its (sorted) error lines. ``incomplete`` + maps a section name to the reason it could not run; such a section is *not* + the same as a clean one, so callers must surface it rather than trusting its + empty error list as a zero count. + """ + + sections: dict[str, list[str]] = field(default_factory=dict) + incomplete: dict[str, str] = field(default_factory=dict) + + def has_findings(self) -> bool: + return any(self.sections.values()) + def load_config() -> dict[str, Any]: with open(CONFIG_FILE) as f: return json.load(f) -def run_checks(root: Path) -> tuple[list[str], list[str], list[str], list[str], list[str], list[str], list[str]]: - config = load_config() - enabled: dict[str, bool] = config.get("enabled", {}) - rules: dict[str, int] = config["rules"] - excludes: list[str] = config["exclude"] - exceptions: dict[str, list[str]] = config["exceptions"] - extensions: list[str] = config["include_extensions"] - ignores = collect_lintignores(root, excludes) - +def _structural_checks( + root: Path, + rules: dict[str, Any], + enabled: dict[str, bool], + excludes: list[str], + exceptions: dict[str, list[str]], + extensions: list[str], + ignores: dict[Path, set[str]], +) -> list[str]: max_lines: int = rules["max-file-lines"] max_items: int = rules["max-folder-items"] check_imports: bool = rules.get("no-nested-imports", False) @@ -88,53 +113,87 @@ def run_checks(root: Path) -> tuple[list[str], list[str], list[str], list[str], ): structural_errors.extend(check_nested_imports(fp, root)) - vulture_errors: list[str] = [] - if enabled.get("vulture", True): - vulture_confidence = rules.get("vulture-min-confidence") - if vulture_confidence is not None: - vulture_error_threshold = rules.get("vulture-error-threshold", 100) - vulture_errors = run_vulture( - root, vulture_confidence, vulture_error_threshold, exceptions, ignores, - ) - - eslint_errors = run_eslint(root, ignores) if enabled.get("eslint", True) else [] - knip_errors = run_knip(root, ignores) if enabled.get("knip", True) else [] - endpoint_ignore_routes: list[str] = rules.get("endpoint-ignore-routes", []) - endpoint_errors = run_endpoint_check(root, exceptions, endpoint_ignore_routes, ignores) if enabled.get("endpoints", True) else [] - class_errors = run_class_check(root, exceptions, excludes, ignores) if enabled.get("classes", True) else [] - aliases: dict[str, str] = rules.get("import-cycle-aliases", {}) - cycle_errors = run_cycle_check(root, excludes, aliases, exceptions, ignores) if enabled.get("import-cycles", True) else [] - - return sorted(structural_errors), sorted(vulture_errors), sorted(eslint_errors), sorted(knip_errors), sorted(endpoint_errors), sorted(class_errors), sorted(cycle_errors) + return structural_errors -def _print_section(name: str, errors: list[str]) -> None: +def run_checks(root: Path) -> LintResult: + config = load_config() + enabled: dict[str, bool] = config.get("enabled", {}) + rules: dict[str, Any] = config["rules"] + excludes: list[str] = config["exclude"] + exceptions: dict[str, list[str]] = config["exceptions"] + extensions: list[str] = config["include_extensions"] + ignores = collect_lintignores(root, excludes) + + result = LintResult() + + def run_section(name: str, fn: Callable[[], list[str]]) -> None: + """Run one section, recording a failure as *incomplete* rather than + letting an empty list masquerade as a clean result.""" + try: + result.sections[name] = sorted(fn()) + except CheckError as e: + result.sections[name] = [] + result.incomplete[name] = e.reason + + def _vulture() -> list[str]: + if not enabled.get("vulture", True): + return [] + confidence = rules.get("vulture-min-confidence") + if confidence is None: + return [] + threshold = rules.get("vulture-error-threshold", 100) + return run_vulture(root, confidence, threshold, exceptions, ignores) + + run_section("structural", lambda: _structural_checks( + root, rules, enabled, excludes, exceptions, extensions, ignores, + )) + run_section("vulture", _vulture) + run_section("ruff", lambda: run_ruff( + root, rules.get("ruff-select", "F401,F811,F841,ARG001,ARG002"), exceptions, ignores, + ) if enabled.get("ruff", True) else []) + run_section("eslint", lambda: run_eslint(root, ignores) if enabled.get("eslint", True) else []) + run_section("knip", lambda: run_knip(root, ignores) if enabled.get("knip", True) else []) + run_section("endpoints", lambda: run_endpoint_check( + root, exceptions, rules.get("endpoint-ignore-routes", []), ignores, + ) if enabled.get("endpoints", True) else []) + run_section("classes", lambda: run_class_check( + root, exceptions, excludes, ignores, + ) if enabled.get("classes", True) else []) + run_section("import-cycles", lambda: run_cycle_check( + root, excludes, rules.get("import-cycle-aliases", {}), exceptions, ignores, + ) if enabled.get("import-cycles", True) else []) + + return result + + +def _print_section(name: str, errors: list[str], reason: str | None) -> None: print(f"{name}: checking...", flush=True) for e in errors: print(e, flush=True) - print(f"{name}: done. {len(errors)} error(s) found.", flush=True) + if reason is not None: + # Emit a problemMatcher-catchable line so the IDE Problems panel shows the + # run was partial, then a human-readable status line for the terminal. + print( + f"linter/lint.py:1:1: error: [linter] '{name}' check INCOMPLETE: " + f"{reason} — error counts are unreliable until this is resolved", + flush=True, + ) + print(f"{name}: done. INCOMPLETE — {reason}.", flush=True) + else: + print(f"{name}: done. {len(errors)} error(s) found.", flush=True) -def print_results( - structural_errors: list[str], vulture_errors: list[str], - eslint_errors: list[str], knip_errors: list[str], - endpoint_errors: list[str], class_errors: list[str], - cycle_errors: list[str], -) -> None: - _print_section("structural", structural_errors) - _print_section("vulture", vulture_errors) - _print_section("eslint", eslint_errors) - _print_section("knip", knip_errors) - _print_section("endpoints", endpoint_errors) - _print_section("classes", class_errors) - _print_section("import-cycles", cycle_errors) +def print_results(result: LintResult) -> None: + for name in SECTION_ORDER: + _print_section(name, result.sections.get(name, []), result.incomplete.get(name)) def watch_loop(root: Path) -> None: config_dir = SCRIPT_DIR / "config" - print_results(*run_checks(root)) + print_results(run_checks(root)) class SourceFilter(DefaultFilter): allowed_extensions = (".py", ".ts", ".tsx", ".js", ".jsx") @@ -152,7 +211,7 @@ def watch_loop(root: Path) -> None: return Path(path).is_dir() for _changes in watch(root, watch_filter=SourceFilter()): - print_results(*run_checks(root)) + print_results(run_checks(root)) def main() -> None: @@ -166,9 +225,11 @@ def main() -> None: if args.watch: watch_loop(root) else: - results = run_checks(root) - print_results(*results) - sys.exit(1 if any(results) else 0) + result = run_checks(root) + print_results(result) + # Exit non-zero on findings OR on an incomplete run, so a partial pass is + # never mistaken for a clean one by CI or print_errors.sh. + sys.exit(1 if result.has_findings() or result.incomplete else 0) if __name__ == "__main__": diff --git a/linter/print_errors.sh b/linter/print_errors.sh index ac6c8d89..0ca2e3d0 100755 --- a/linter/print_errors.sh +++ b/linter/print_errors.sh @@ -14,13 +14,15 @@ LINT_OUTPUT=$(python3 "$SCRIPT_DIR/lint.py" --root "$ROOT_DIR" 2>&1) LINT_EXIT=$? if [ $LINT_EXIT -ne 0 ]; then - STRUCT_LINES=$(echo "$LINT_OUTPUT" | grep -v "^structural:" | grep -v "^vulture:" | grep -v "^eslint:" | grep -v "^knip:" | grep -v '\[vulture\]' | grep -v '\[eslint\]' | grep -v '\[knip\]') + STRUCT_LINES=$(echo "$LINT_OUTPUT" | grep -v "^structural:" | grep -v "^vulture:" | grep -v "^ruff:" | grep -v "^eslint:" | grep -v "^knip:" | grep -v '\[vulture\]' | grep -v '\[ruff\]' | grep -v '\[eslint\]' | grep -v '\[knip\]') VULTURE_LINES=$(echo "$LINT_OUTPUT" | grep '\[vulture\]') + RUFF_LINES=$(echo "$LINT_OUTPUT" | grep '\[ruff\]') ESLINT_LINES=$(echo "$LINT_OUTPUT" | grep '\[eslint\]') KNIP_LINES=$(echo "$LINT_OUTPUT" | grep '\[knip\]') STRUCT_COUNT=$(echo "$STRUCT_LINES" | grep -cE ':\s+(error|warning):\s+') VULTURE_COUNT=$(echo "$VULTURE_LINES" | grep -cE ':\s+(error|warning):\s+') + RUFF_COUNT=$(echo "$RUFF_LINES" | grep -cE ':\s+(error|warning):\s+') ESLINT_COUNT=$(echo "$ESLINT_LINES" | grep -cE ':\s+(error|warning):\s+') KNIP_COUNT=$(echo "$KNIP_LINES" | grep -cE ':\s+(error|warning):\s+') @@ -42,6 +44,15 @@ if [ $LINT_EXIT -ne 0 ]; then echo -e "${CYAN}${BOLD} ${VULTURE_COUNT} finding(s) — fix or add to linter/config/vulture_whitelist.py${RESET}" fi + if [ "$RUFF_COUNT" -gt 0 ]; then + echo "" + echo -e "${CYAN}${BOLD}[ruff] Lint errors found:${RESET}" + echo "$RUFF_LINES" | while IFS= read -r line; do + [ -n "$line" ] && echo -e "${CYAN} $line${RESET}" + done + echo -e "${CYAN}${BOLD} ${RUFF_COUNT} finding(s) — fix, add a noqa, or except in linter/config/config.json${RESET}" + fi + if [ "$ESLINT_COUNT" -gt 0 ]; then echo "" echo -e "${YELLOW}${BOLD}[eslint] Lint errors found:${RESET}"