Option C Tools
Loading...
Searching...
No Matches
oct_health.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/health/oct_health.py
4
5"""
6Option C project health dashboard implementation.
7
8Purpose
9-------
10Provide a single-command compliance dashboard that aggregates lint results,
11fixable violations, documentation status, test status, and oc_diagnostics
12compatibility into one report.
13
14Responsibilities
15----------------
16- Run linter checks on all Python files and aggregate statistics.
17- Run formatter checks in dry-run mode to count fixable violations.
18- Validate presence of required documentation files.
19- Run project tests and capture pass/fail status.
20- Check oc_diagnostics version compatibility.
21- Output results as terminal report or JSON.
22
23Diagnostics
24-----------
25Domain: OCT.HEALTH
26Levels:
27 L2 — health check lifecycle and command routing
28 L3 — individual check categories (lint, docs, tests, compat)
29 L4 — per-file analysis and detailed aggregation
30
31Contracts
32---------
33- Health checks are read-only; no files are modified.
34- All checks are independent; failure of one does not prevent others.
35- JSON output is always valid JSON with a stable schema.
36- Terminal output uses ASCII-safe characters for Windows compatibility.
37
38Dependencies
39------------
40- oct (package version)
41- oct.core.progress (terminal dashboard renderer)
42- oct.core.parsing (safe AST parsing helper)
43- oct.core.paths (project-relative path helper)
44- oct.linter.oct_lint (reused validation checks and profile defaults)
45- oct.core.exclusions (directory and wildcard exclusion lists)
46- oct.core.project_root (project root detection)
47- oct.core.compat (oc_diagnostics version compatibility check)
48- oct.core.octrc (.octrc.json loader)
49- oct.core.terminal (ANSI colour helpers)
50"""
51
52import json
53import re
54import subprocess
55import sys
56from dataclasses import dataclass, field, asdict
57from datetime import datetime
58from pathlib import Path
59from typing import Dict, List, Any, Optional
60
61from oct import __version__
62from oct.core.progress import Dashboard
63from oct.core.parsing import safe_parse
64from oct.core.paths import to_project_relative
65from oct.linter.oct_lint import (
66 validate_header_block,
67 check_docstring,
68 check_dependencies_section,
69 check_diagnostics_profile,
70 check_dbg_usage,
71 check_dbg_import,
72 check_func_pattern,
73 check_syntax_warnings,
74 find_python_files,
75 LinterContext,
76 _DEFAULT_RULES,
77 _PROFILES,
78)
79from oct.core.exclusions import LINTER_EXCLUDE_DIRS, WILDCARD_EXCLUDE_DIRS
80from oct.core.project_root import find_project_root
81from oct.core.compat import check_oc_diagnostics_compat
82from oct.core.octrc import load_octrc_safe
83from oct.core.terminal import CYAN, GREEN, RED, YELLOW, RESET
84
85
86# ============================================================================
87# Health Check Functions
88# ============================================================================
89
90
92 project_root: Path,
93 ctx: LinterContext,
94 verbose: bool = False,
95 exclude_dirs: Optional[set] = None,
96 wildcard_exclude: Optional[list] = None,
97 active_rules: Optional[dict] = None,
98) -> Dict[str, Any]:
99 """
100 Run linter checks on all Python files and aggregate pass/fail statistics.
101
102 Returns a dict with total_files, fully_compliant count, per-check pass
103 counts, and compliance percentage.
104 """
105 func = "check_lint_health"
106
107 if exclude_dirs is None:
108 exclude_dirs = set(LINTER_EXCLUDE_DIRS)
109 if wildcard_exclude is None:
110 wildcard_exclude = list(WILDCARD_EXCLUDE_DIRS)
111 if active_rules is None:
112 active_rules = dict(_DEFAULT_RULES)
113
114 python_files = list(find_python_files(project_root, exclude_dirs, wildcard_exclude))
115
116 # Build counters only for active rules (excluding tests_exist which health doesn't check)
117 health_checks = [
118 "header", "docstring", "dependencies_section", "diagnostics_profile",
119 "dbg_usage", "dbg_import", "func_pattern", "syntax_warnings",
120 ]
121 active_checks = [c for c in health_checks if active_rules.get(c, True)]
122
123 total = len(python_files)
124 counters = {c: 0 for c in active_checks}
125 fully_compliant = 0
126 file_details = []
127
128 check_funcs = {
129 "header": lambda text, lines, py_file, tree, sw: validate_header_block(
130 lines[:4] if len(lines) >= 4 else lines, py_file, ctx),
131 "docstring": lambda text, lines, py_file, tree, sw: check_docstring(text),
132 "dependencies_section": lambda text, lines, py_file, tree, sw: check_dependencies_section(text),
133 "diagnostics_profile": lambda text, lines, py_file, tree, sw: check_diagnostics_profile(text),
134 "dbg_usage": lambda text, lines, py_file, tree, sw: check_dbg_usage(text, tree=tree),
135 "dbg_import": lambda text, lines, py_file, tree, sw: check_dbg_import(text, tree=tree),
136 "func_pattern": lambda text, lines, py_file, tree, sw: check_func_pattern(text, tree=tree),
137 "syntax_warnings": lambda text, lines, py_file, tree, sw: check_syntax_warnings(text, warnings_list=sw),
138 }
139
140 for py_file in python_files:
141 try:
142 text = py_file.read_text(encoding="utf-8")
143 except Exception:
144 continue
145
146 lines = text.split('\n')
147 tree, syntax_warnings_list = safe_parse(text)
148
149 checks = {}
150 for check_name in active_checks:
151 ok, _ = check_funcs[check_name](text, lines, py_file, tree, syntax_warnings_list)
152 checks[check_name] = ok
153 if ok:
154 counters[check_name] += 1
155
156 if all(checks.values()):
157 fully_compliant += 1
158
159 if verbose:
160 rel_path = to_project_relative(py_file, project_root)
161 file_details.append({
162 "path": rel_path,
163 "checks": checks,
164 "compliant": all(checks.values()),
165 })
166
167 compliance_pct = round((fully_compliant / total * 100), 1) if total > 0 else 0.0
168
169 result = {
170 "total_files": total,
171 "fully_compliant": fully_compliant,
172 "compliance_pct": compliance_pct,
173 "checks": {},
174 }
175
176 for check_name, count in counters.items():
177 pct = round((count / total * 100), 1) if total > 0 else 0.0
178 result["checks"][check_name] = {
179 "pass": count,
180 "total": total,
181 "pct": pct,
182 }
183
184 if verbose:
185 result["files"] = file_details
186
187 return result
188
189
191 project_root: Path,
192 ctx: LinterContext,
193 exclude_dirs: Optional[set] = None,
194 wildcard_exclude: Optional[list] = None,
195 active_rules: Optional[dict] = None,
196 verbose: bool = False,
197) -> Dict[str, Any]:
198 """
199 Count violations that oct format could fix automatically.
200
201 Runs formatter checks in dry-run mode (no modifications).
202 Only counts fixes for rules that are active.
203
204 When *verbose* is True the result includes a ``"files"`` key mapping
205 to a list of ``{"path": <rel_path>, "fixes": [<fix_name>, ...]}``
206 entries so the caller can show which files have fixable violations.
207 """
208 func = "check_fixable_health"
209
210 from oct.formatter.oct_format import (
211 fix_header_block,
212 fix_docstring,
213 fix_dbg_import,
214 fix_func_pattern,
215 FormatterContext,
216 )
217
218 if active_rules is None:
219 active_rules = dict(_DEFAULT_RULES)
220
221 fmt_ctx = FormatterContext(
222 project_root=project_root,
223 project_name=project_root.name,
224 diagnostics_dir=ctx.diagnostics_dir,
225 tests_dir=ctx.tests_dir,
226 docs_dir=ctx.docs_dir,
227 fix_mode=False,
228 dry_run_mode=True,
229 json_mode=False,
230 verbose=False,
231 )
232
233 if exclude_dirs is None:
234 exclude_dirs = set(LINTER_EXCLUDE_DIRS)
235 if wildcard_exclude is None:
236 wildcard_exclude = list(WILDCARD_EXCLUDE_DIRS)
237 python_files = list(find_python_files(project_root, exclude_dirs, wildcard_exclude))
238
239 # Map fixable check names to their corresponding lint rule names
240 fix_rule_map = {
241 "header": "header",
242 "docstring": "docstring",
243 "dbg_import": "dbg_import",
244 "func_pattern": "func_pattern",
245 }
246 fixable = {k: 0 for k in fix_rule_map if active_rules.get(fix_rule_map[k], True)}
247
248 fix_funcs = {
249 "header": fix_header_block,
250 "docstring": fix_docstring,
251 "dbg_import": fix_dbg_import,
252 "func_pattern": fix_func_pattern,
253 }
254
255 file_details: list[dict] = []
256
257 for py_file in python_files:
258 try:
259 text = py_file.read_text(encoding="utf-8")
260 except Exception:
261 continue
262
263 file_fixes: list[str] = []
264 for fix_name in fixable:
265 changed, _, _ = fix_funcs[fix_name](py_file, text, fmt_ctx)
266 if changed:
267 fixable[fix_name] += 1
268 file_fixes.append(fix_name)
269
270 if verbose and file_fixes:
271 rel_path = to_project_relative(py_file, project_root)
272 file_details.append({"path": rel_path, "fixes": file_fixes})
273
274 fixable["total"] = sum(fixable.values())
275 if verbose:
276 fixable["files"] = file_details
277 return fixable
278
279
281 project_root: Path,
282 *,
283 production_mode: bool = False,
284) -> Dict[str, Any]:
285 """
286 Validate presence of required documentation files.
287
288 Standard required set: ARCHITECTURE.md, CHANGELOG.md, Tests_README.md,
289 run_tests.py, debug_config.json. When ``production_mode=True`` (Appendix H
290 Level 2), SECURITY.md is also required; otherwise it is reported as
291 optional alongside the required count.
292 """
293 func = "check_docs_health"
294
295 base_required = [
296 ("ARCHITECTURE.md", project_root / "docs" / "ARCHITECTURE.md"),
297 ("CHANGELOG.md", project_root / "CHANGELOG.md"),
298 ("Tests_README.md", project_root / "tests" / "Tests_README.md"),
299 ("run_tests.py", project_root / "tests" / "run_tests.py"),
300 ("debug_config.json", project_root / "oc_diagnostics" / "debug_config.json"),
301 ]
302 production_only = [
303 ("SECURITY.md", project_root / "SECURITY.md"),
304 ]
305
306 required_files = base_required + (production_only if production_mode else [])
307 optional_files = [] if production_mode else production_only
308
309 files_status = []
310 present_count = 0
311
312 for name, path in required_files:
313 exists = path.is_file()
314 files_status.append({"name": name, "present": exists, "required": True})
315 if exists:
316 present_count += 1
317
318 for name, path in optional_files:
319 exists = path.is_file()
320 files_status.append({"name": name, "present": exists, "required": False})
321
322 return {
323 "total_required": len(required_files),
324 "total_present": present_count,
325 "files": files_status,
326 "production_mode": production_mode,
327 }
328
329
330_COVERAGE_TOTAL_RE = re.compile(r"^TOTAL\s+\d+\s+\d+\s+(\d+(?:\.\d+)?)%", re.MULTILINE)
331
332
334 project_root: Path,
335 timeout: int = 300,
336 *,
337 coverage_threshold: Optional[float] = None,
338 production_mode: bool = False,
339 coverage_target: Optional[str] = None,
340) -> Dict[str, Any]:
341 """
342 Run project tests and capture pass/fail status.
343
344 Uses pytest subprocess to run tests and captures the exit code
345 and summary line.
346
347 Parameters
348 ----------
349 project_root:
350 Path to the project root containing a ``tests/`` directory.
351 timeout:
352 Maximum seconds to wait for pytest to complete. Default 300 (OI-413).
353 coverage_threshold:
354 FS-C3 / Drift item C3 — when set, run pytest with ``--cov`` and
355 compare the resulting TOTAL coverage percentage against this
356 threshold. ``None`` (default) skips the coverage measurement
357 entirely so existing CI keeps its prior behaviour.
358 production_mode:
359 When ``True`` together with a ``coverage_threshold``, a coverage
360 result below the threshold flips ``status`` to ``"fail"``.
361 Outside production mode, below-threshold coverage is reported
362 but never blocks the dashboard.
363 coverage_target:
364 Module / package name to measure (passed as ``--cov=<target>``).
365 Defaults to the project root directory name when ``None``.
366 """
367 func = "check_test_health"
368
369 import importlib.util
370
371 tests_dir = project_root / "tests"
372 if not tests_dir.is_dir():
373 return {
374 "status": "not_run",
375 "exit_code": -1,
376 "summary": "No tests/ directory found",
377 "details": [],
378 "coverage_pct": None,
379 "coverage_threshold": coverage_threshold,
380 "coverage_blocking": production_mode,
381 }
382
383 # Detect missing pytest before attempting subprocess — avoids silent FAIL
384 if importlib.util.find_spec("pytest") is None:
385 return {
386 "status": "fail",
387 "exit_code": -1,
388 "summary": "pytest not installed — run: pip install pytest",
389 "details": [],
390 "coverage_pct": None,
391 "coverage_threshold": coverage_threshold,
392 "coverage_blocking": production_mode,
393 }
394
395 # FS-C3: when coverage measurement is requested, ensure pytest-cov is
396 # available; degrade gracefully (run without --cov) when missing.
397 coverage_requested = coverage_threshold is not None
398 coverage_available = (
399 coverage_requested
400 and importlib.util.find_spec("pytest_cov") is not None
401 )
402
403 pytest_args = [
404 sys.executable, "-m", "pytest", str(tests_dir),
405 "-n", "auto", "--dist=worksteal", "--tb=line", "--no-header",
406 ]
407 if coverage_available:
408 target = coverage_target or project_root.name
409 pytest_args.extend([f"--cov={target}", "--cov-report=term"])
410
411 try:
412 result = subprocess.run(
413 pytest_args,
414 capture_output=True,
415 text=True,
416 encoding="utf-8",
417 errors="replace",
418 timeout=timeout,
419 cwd=str(project_root),
420 )
421
422 # Extract summary line — scan from end since pytest always puts it last.
423 # Match on keywords OR the timing pattern ("N passed in 0.31s") as a fallback
424 # since encoding issues can occasionally mangle keyword characters on Windows.
425 _SUMMARY_RE = re.compile(r"\b\d+ \w+ in [\d.]+s")
426 _DECORATION_RE = re.compile(r"^=+\s*|\s*=+$")
427 summary = ""
428 for line in reversed(result.stdout.strip().split('\n')):
429 line = line.strip()
430 if "passed" in line or "failed" in line or "error" in line or _SUMMARY_RE.search(line):
431 summary = _DECORATION_RE.sub("", line).strip()
432 break
433
434 # Extract actionable error detail lines (max 5) from --tb=line output.
435 # pytest --tb=line produces lines like:
436 # path/to/test.py - ErrorType: message
437 # FAILED tests/test_foo.py::test_bar - AssertionError
438 detail_lines = []
439 if result.returncode != 0:
440 for line in result.stdout.strip().split('\n'):
441 line = line.strip()
442 if not line or line.startswith("=") or line.startswith("."):
443 continue
444 if ("Error" in line or "Exception" in line
445 or line.startswith("FAILED ")
446 or line.startswith("ERROR ")):
447 detail_lines.append(line)
448 if len(detail_lines) >= 5:
449 break
450
451 # Fall back to stderr when stdout has no useful summary (e.g. import errors)
452 if not summary and result.stderr.strip():
453 err_lines = result.stderr.strip().split('\n')
454 summary = err_lines[-1].strip()
455
456 # Tertiary fallback — always provide a summary even if regex + stderr both fail
457 if not summary:
458 summary = f"pytest exited with code {result.returncode}"
459
460 status = "pass" if result.returncode == 0 else "fail"
461
462 # FS-C3: parse pytest-cov "TOTAL ... NN%" line when coverage was requested.
463 coverage_pct: Optional[float] = None
464 if coverage_available:
465 cov_match = _COVERAGE_TOTAL_RE.search(result.stdout)
466 if cov_match:
467 try:
468 coverage_pct = float(cov_match.group(1))
469 except ValueError:
470 coverage_pct = None
471
472 # Production-mode gating: a coverage result below threshold flips
473 # status to "fail" only when production_mode is set. Outside
474 # production, below-threshold coverage is informational.
475 if (
476 production_mode
477 and coverage_pct is not None
478 and coverage_threshold is not None
479 and coverage_pct < coverage_threshold
480 and status == "pass"
481 ):
482 status = "fail"
483 summary = (
484 f"{summary} | coverage {coverage_pct:.1f}% below "
485 f"production threshold {coverage_threshold:.1f}%"
486 ).strip()
487
488 # Surface a clear diagnostic when the user asked for coverage but
489 # pytest-cov isn't installed.
490 coverage_msg: Optional[str] = None
491 if coverage_requested and not coverage_available:
492 coverage_msg = (
493 "pytest-cov not installed — install with: "
494 "pip install -e .[test]"
495 )
496
497 return {
498 "status": status,
499 "exit_code": result.returncode,
500 "summary": summary,
501 "details": detail_lines,
502 "coverage_pct": coverage_pct,
503 "coverage_threshold": coverage_threshold,
504 "coverage_blocking": production_mode,
505 "coverage_message": coverage_msg,
506 }
507
508 except subprocess.TimeoutExpired:
509 return {
510 "status": "timeout",
511 "exit_code": -1,
512 "summary": f"Tests timed out after {timeout} seconds",
513 "details": [],
514 "coverage_pct": None,
515 "coverage_threshold": coverage_threshold,
516 "coverage_blocking": production_mode,
517 }
518 except Exception as e:
519 return {
520 "status": "error",
521 "exit_code": -1,
522 "summary": str(e),
523 "details": [],
524 "coverage_pct": None,
525 "coverage_threshold": coverage_threshold,
526 "coverage_blocking": production_mode,
527 }
528
529
530def check_compat_health() -> Dict[str, Any]:
531 """
532 Check oc_diagnostics version compatibility.
533 """
534 func = "check_compat_health"
535
536 msg = check_oc_diagnostics_compat()
537
538 try:
539 from importlib.metadata import version as _pkg_version
540 version = _pkg_version("oc_diagnostics")
541 installed = True
542 except Exception:
543 version = "not installed"
544 installed = False
545
546 return {
547 "installed": installed,
548 "version": version,
549 "compatible": msg is None or msg == "",
550 "message": msg or "",
551 }
552
553
554def check_venv_health(project_root: Path) -> Dict[str, Any]:
555 """
556 Check virtual environment status for the project.
557
558 Performs three layered checks:
559 1. Whether the current process is running inside *any* venv.
560 2. Whether a canonical ``.venv`` directory exists at the project root or its
561 immediate parent (monorepo pattern: shared venv one level above subproject).
562 3. Whether the active venv is the project's own ``.venv`` (not a foreign env).
563
564 Returns a dict with ``status`` (OK / WARN / FAIL), ``active`` (bool),
565 ``venv_found`` (bool), ``active_path`` (str | None), and ``message`` (str).
566 """
567 func = "check_venv_health" # noqa: F841 - Option C func pattern
568
569 in_venv: bool = sys.prefix != sys.base_prefix
570 active_path: Optional[str] = sys.prefix if in_venv else None
571
572 # Probe subproject root first, then repo root (monorepo: oct/ lives inside
573 # Option_C/ which owns the shared .venv).
574 canonical_venv: Optional[Path] = None
575 for _candidate in (project_root / ".venv", project_root.parent / ".venv"):
576 if _candidate.is_dir():
577 canonical_venv = _candidate
578 break
579 venv_found: bool = canonical_venv is not None
580
581 # Is the active venv the project's own .venv?
582 own_venv = False
583 if in_venv and venv_found and canonical_venv is not None:
584 try:
585 own_venv = Path(sys.prefix).resolve() == canonical_venv.resolve()
586 except Exception:
587 own_venv = False
588
589 if not in_venv:
590 status = "WARN"
591 message = (
592 "Not running inside a virtual environment. "
593 "Run: .venv\\Scripts\\activate (Windows) or source .venv/bin/activate (Unix)"
594 )
595 elif not venv_found:
596 status = "WARN"
597 message = (
598 f"Active venv: {active_path}. "
599 "No .venv/ found at project root or repo root -- "
600 "create one with: python -m venv .venv"
601 )
602 elif not own_venv:
603 status = "WARN"
604 message = (
605 f"Active venv ({active_path}) is not the project .venv "
606 f"({canonical_venv}). Activate the correct venv for dependency isolation."
607 )
608 else:
609 status = "OK"
610 message = f"Project .venv active ({active_path})"
611
612 return {
613 "status": status,
614 "active": in_venv,
615 "venv_found": venv_found,
616 "own_venv": own_venv,
617 "active_path": active_path,
618 "venv_path": str(canonical_venv) if canonical_venv else None,
619 "message": message,
620 }
621
622
623# ============================================================================
624# Git Health (Phase 4E — G-E5)
625# ============================================================================
626
627
628def check_git_health(project_root: Path) -> Dict[str, Any]:
629 """Run git-related health checks.
630
631 Returns a dict of checks, each with ``status`` (OK / WARN / SKIP)
632 and ``detail``. If git is not installed or the project is not a
633 repo, all items return SKIP with an explanatory message.
634 """
635 import shutil
636
637 checks: Dict[str, Dict[str, str]] = {}
638
639 # Guard: git installed?
640 if shutil.which("git") is None:
641 skip = {"status": "SKIP", "detail": "Git is not installed"}
642 return {
643 "is_repo": skip,
644 "branch": skip,
645 "naming_conformant": skip,
646 "clean_worktree": skip,
647 "ahead_behind": skip,
648 "hooks_installed": skip,
649 "last_commit_conventional": skip,
650 }
651
652 try:
653 from oct.core.git import (
654 is_git_repo,
655 current_branch,
656 has_uncommitted_changes,
657 last_commit_message,
658 ahead_behind as _ahead_behind,
659 )
660 except ImportError:
661 skip = {"status": "SKIP", "detail": "oct.core.git not available"}
662 return {
663 "is_repo": skip,
664 "branch": skip,
665 "naming_conformant": skip,
666 "clean_worktree": skip,
667 "ahead_behind": skip,
668 "hooks_installed": skip,
669 "last_commit_conventional": skip,
670 }
671
672 # is_repo
673 if not is_git_repo(project_root):
674 skip = {"status": "SKIP", "detail": "Not a git repository"}
675 return {
676 "is_repo": {"status": "FAIL", "detail": "Not a git repository. Run 'oct git init' to initialise."},
677 "branch": skip,
678 "naming_conformant": skip,
679 "clean_worktree": skip,
680 "ahead_behind": skip,
681 "hooks_installed": skip,
682 "last_commit_conventional": skip,
683 }
684
685 checks["is_repo"] = {"status": "OK", "detail": "Git repository detected"}
686
687 # branch
688 try:
689 branch = current_branch(project_root)
690 if branch == "HEAD":
691 checks["branch"] = {"status": "WARN", "detail": "Detached HEAD"}
692 else:
693 checks["branch"] = {"status": "OK", "detail": branch}
694 except Exception:
695 branch = None
696 checks["branch"] = {"status": "WARN", "detail": "Could not determine branch"}
697
698 # naming_conformant
699 try:
700 from oct.git.policy import validate_branch_name
701 valid, msg = validate_branch_name(branch, _load_octrc_for_health(project_root))
702 if valid:
703 checks["naming_conformant"] = {"status": "OK", "detail": "Branch name conforms to naming convention"}
704 else:
705 checks["naming_conformant"] = {"status": "WARN", "detail": msg}
706 except Exception:
707 checks["naming_conformant"] = {"status": "SKIP", "detail": "Could not validate branch name"}
708
709 # clean_worktree
710 try:
711 dirty = has_uncommitted_changes(project_root)
712 if dirty:
713 checks["clean_worktree"] = {"status": "WARN", "detail": "Uncommitted changes present"}
714 else:
715 checks["clean_worktree"] = {"status": "OK", "detail": "Working tree is clean"}
716 except Exception:
717 checks["clean_worktree"] = {"status": "SKIP", "detail": "Could not check worktree"}
718
719 # ahead_behind
720 try:
721 ahead, behind = _ahead_behind(project_root)
722 if ahead == 0 and behind == 0:
723 checks["ahead_behind"] = {"status": "OK", "detail": "Up to date with remote"}
724 else:
725 parts = []
726 if ahead:
727 parts.append(f"{ahead} ahead")
728 if behind:
729 parts.append(f"{behind} behind")
730 checks["ahead_behind"] = {"status": "WARN", "detail": ", ".join(parts)}
731 except Exception:
732 checks["ahead_behind"] = {"status": "OK", "detail": "No upstream configured"}
733
734 # hooks_installed
735 config_path = project_root / ".pre-commit-config.yaml"
736 if config_path.is_file():
737 try:
738 content = config_path.read_text(encoding="utf-8")
739 oct_hooks = ["oct-quality-gate", "oct-secrets-check", "oct-commit-msg", "oct-pre-push"]
740 found = [h for h in oct_hooks if f"id: {h}" in content]
741 if len(found) == len(oct_hooks):
742 checks["hooks_installed"] = {"status": "OK", "detail": f"All {len(oct_hooks)} hooks installed"}
743 else:
744 missing = [h for h in oct_hooks if h not in found]
745 checks["hooks_installed"] = {"status": "WARN", "detail": f"Missing: {', '.join(missing)}"}
746 except Exception:
747 checks["hooks_installed"] = {"status": "WARN", "detail": "Could not read hooks config"}
748 else:
749 checks["hooks_installed"] = {"status": "WARN", "detail": "No .pre-commit-config.yaml found"}
750
751 # last_commit_conventional
752 try:
753 from oct.git.conventional import parse_commit_message
754 msg = last_commit_message(project_root)
755 if not msg:
756 checks["last_commit_conventional"] = {"status": "SKIP", "detail": "No commits yet"}
757 elif parse_commit_message(msg) is not None:
758 checks["last_commit_conventional"] = {"status": "OK", "detail": "Last commit follows Conventional Commits"}
759 else:
760 checks["last_commit_conventional"] = {"status": "WARN", "detail": "Last commit is not Conventional Commits format"}
761 except Exception:
762 checks["last_commit_conventional"] = {"status": "SKIP", "detail": "Could not check last commit"}
763
764 return checks
765
766
767def _load_octrc_for_health(project_root: Path) -> Optional[dict]:
768 """Load ``.octrc.json`` for health checks (best-effort).
769
770 OI-519: thin wrapper around :func:`oct.core.octrc.load_octrc_safe`;
771 retains the historical ``None`` return on missing/invalid so existing
772 callers keep their truthy-check semantics.
773 """
774 from oct.core.octrc import load_octrc_safe
775 from oct.core.option_c_dir import resolve_octrc
776 # FS-539: the octrc may live at .option_c/octrc.json (modern) or
777 # .octrc.json (legacy) — resolve_octrc picks whichever exists and
778 # returns the legacy path when neither does, giving us a truthy
779 # existence check either way.
780 if not resolve_octrc(project_root).is_file():
781 return None
782 data = load_octrc_safe(project_root)
783 return data or None
784
785
786# ============================================================================
787# Output Formatting
788# ============================================================================
789
790
791# Lint checks shown in the health dashboard (subset of all lint rules).
792_HEALTH_CHECKS = [
793 "header", "docstring", "diagnostics_profile",
794 "dbg_usage", "dbg_import", "func_pattern", "syntax_warnings",
795]
796
797# Fixable categories and their display labels.
798_FIX_LABELS: Dict[str, str] = {
799 "header": "Header blocks",
800 "docstring": "Docstrings",
801 "dbg_import": "_dbg imports",
802 "func_pattern": "func patterns",
803}
804
805# Documentation files surfaced in the dashboard. CHANGELOG.md is required at
806# Standard maturity; SECURITY.md is required only at Production maturity but is
807# always shown in the dashboard so its absence is visible.
808_DOC_FILES = [
809 "ARCHITECTURE.md",
810 "CHANGELOG.md",
811 "Tests_README.md",
812 "run_tests.py",
813 "debug_config.json",
814 "SECURITY.md",
815]
816
817# Git check keys and display labels.
818_GIT_LABELS: Dict[str, str] = {
819 "is_repo": "Repository",
820 "branch": "Branch",
821 "naming_conformant": "Branch naming",
822 "clean_worktree": "Working tree",
823 "ahead_behind": "Remote sync",
824 "hooks_installed": "Hooks",
825 "last_commit_conventional": "Last commit",
826}
827
828
829@dataclass
831 """Maps dashboard sections to line indices for in-place updates."""
832 lint: int = 0
833 lint_checks: Dict[str, int] = field(default_factory=dict)
834 fixable: int = 0
835 fixable_items: Dict[str, int] = field(default_factory=dict)
836 docs: int = 0
837 docs_files: Dict[str, int] = field(default_factory=dict)
838 tests: int = 0
839 compat: int = 0
840 venv: int = 0
841 git: int = 0
842 git_checks: Dict[str, int] = field(default_factory=dict)
843 footer: int = 0
844
845
847 project_name: str,
848 excluded_dirs: List[str],
849 disabled_rules: List[str],
850 active_checks: List[str],
851 fixable_names: List[str],
852 use_color: bool,
853) -> tuple:
854 """Build the dashboard template lines and line-index map.
855
856 Returns ``(lines, line_map)`` where *lines* is a list of strings and
857 *line_map* is a :class:`_HealthLineMap` with indices into that list.
858 """
859 C = "\033[96m" if use_color else ""
860 X = "\033[0m" if use_color else ""
861 lm = _HealthLineMap()
862 lines: List[str] = []
863
864 def add(text: str = "") -> int:
865 idx = len(lines)
866 lines.append(text)
867 return idx
868
869 # Header
870 add(f"{C}{'=' * 70}{X}")
871 add(f"{C}OCT HEALTH REPORT -- {project_name}{X}")
872 add(f"{C}{'=' * 70}{X}")
873 add()
874
875 # Config overrides
876 if excluded_dirs:
877 add(f"Excluded directories: {', '.join(excluded_dirs)} (via .octrc.json)")
878 if disabled_rules:
879 labels = [r.replace("_", " ") for r in disabled_rules]
880 add(f"Disabled rules: {', '.join(labels)} (via .octrc.json)")
881 if excluded_dirs or disabled_rules:
882 add()
883
884 # Lint compliance
885 lm.lint = add("[ ] Lint Compliance:")
886 for ck in active_checks:
887 label = ck.replace("_", " ").title()
888 lm.lint_checks[ck] = add(f" [ ] {label + ':':<20}")
889 add()
890
891 # Fixable violations
892 lm.fixable = add("[ ] Fixable Violations:")
893 for fn in fixable_names:
894 lm.fixable_items[fn] = add(f" [ ] {_FIX_LABELS.get(fn, fn) + ':':<20}")
895 add()
896
897 # Documentation
898 lm.docs = add("[ ] Documentation:")
899 for df in _DOC_FILES:
900 lm.docs_files[df] = add(f" [ ] {df}")
901 add()
902
903 # Tests
904 lm.tests = add("[ ] Tests:")
905 add()
906
907 # Compatibility
908 lm.compat = add("[ ] oc_diagnostics:")
909 add()
910
911 # Virtual environment
912 lm.venv = add("[ ] Virtual Env:")
913 add()
914
915 # Git integration
916 lm.git = add("[ ] Git Integration:")
917 for gk, gl in _GIT_LABELS.items():
918 lm.git_checks[gk] = add(f" [ ] {gl + ':':<20}")
919 add()
920
921 # Footer
922 lm.footer = add(" Waiting to start...")
923 add()
924 add(f"{C}{'=' * 70}{X}")
925
926 return lines, lm
927
928
929def _pct_str(count: int, total: int) -> str:
930 """Format count/total with percentage."""
931 if total == 0:
932 return "0/0 (0.0%)"
933 pct = round(count / total * 100, 1)
934 return f"{count}/{total} ({pct}%)"
935
936
937def _print_terminal_report(report: Dict[str, Any], verbose: bool) -> None:
938 """Print human-readable terminal health report."""
939 func = "_print_terminal_report"
940
941 lint = report["lint"]
942 fixable = report["fixable"]
943 docs = report["docs"]
944 tests = report["tests"]
945 compat = report["compat"]
946
947 print(f"\n{CYAN}{'=' * 70}{RESET}")
948 print(f"{CYAN}OCT HEALTH REPORT -- {report['project']}{RESET}")
949 print(f"{CYAN}{'=' * 70}{RESET}\n")
950
951 # FS-504: prominent top-of-report banner when running outside a venv.
952 # Kept as WARN (not FAIL) so CI consumers can still exit 0; the banner
953 # is what turns "venv missing" from an easily-missed per-section note
954 # into something the operator cannot overlook.
955 venv_data = report.get("venv") or {}
956 if venv_data.get("active") is False:
957 print(f"{YELLOW}{'!' * 70}{RESET}")
958 print(f"{YELLOW}WARNING: not running inside a virtual environment.{RESET}")
959 print(f"{YELLOW}Create one with: python -m venv .venv && activate{RESET}")
960 print(f"{YELLOW}{'!' * 70}{RESET}\n")
961
962 # Config overrides (from .octrc.json)
963 excluded = report.get("excluded_dirs", [])
964 disabled = report.get("disabled_rules", [])
965 if excluded:
966 print(f"Excluded directories: {YELLOW}{', '.join(excluded)}{RESET} (via .octrc.json)")
967 if disabled:
968 labels = [r.replace("_", " ") for r in disabled]
969 print(f"Disabled rules: {YELLOW}{', '.join(labels)}{RESET} (via .octrc.json)")
970 if excluded or disabled:
971 print()
972
973 # Lint compliance
974 total = lint["total_files"]
975 compliant = lint["fully_compliant"]
976 pct = lint["compliance_pct"]
977 pct_color = GREEN if pct == 100.0 else RED
978 print(f"Lint Compliance: {pct_color}{pct}%{RESET} ({compliant}/{total} files fully compliant)")
979
980 for check_name, check_data in lint["checks"].items():
981 label = check_name.replace("_", " ").title()
982 c = GREEN if check_data["pass"] == check_data["total"] else RED
983 status = _pct_str(check_data["pass"], check_data["total"])
984 print(f" {label + ':':<22}{c}{status}{RESET}")
985
986 # Fixable violations
987 fix_total = fixable["total"]
988 fix_color = GREEN if fix_total == 0 else YELLOW
989 print(f"\nFixable Violations: {fix_color}{fix_total} total{RESET}")
990 if fix_total > 0:
991 for fix_name in ("header", "docstring", "dbg_import", "func_pattern"):
992 if fix_name in fixable:
993 fix_labels = {
994 "header": "Header blocks",
995 "docstring": "Docstrings",
996 "dbg_import": "_dbg imports",
997 "func_pattern": "func patterns",
998 }
999 print(f" {fix_labels[fix_name] + ':':<22}{YELLOW}{fixable[fix_name]}{RESET}")
1000
1001 # Documentation
1002 doc_color = GREEN if docs["total_present"] == docs["total_required"] else RED
1003 doc_status = f"{docs['total_present']}/{docs['total_required']} present"
1004 print(f"\nDocumentation: {doc_color}{doc_status}{RESET}")
1005 for file_info in docs["files"]:
1006 if file_info["present"]:
1007 print(f" {file_info['name']:<22}{GREEN}[OK]{RESET}")
1008 else:
1009 print(f" {file_info['name']:<22}{RED}[MISSING]{RESET}")
1010
1011 # Tests
1012 test_label = tests["status"].upper().replace("_", " ")
1013 test_color = GREEN if tests["status"] == "pass" else RED
1014 test_summary = f" ({tests['summary']})" if tests["summary"] else ""
1015 print(f"\nTests: {test_color}{test_label}{RESET}{test_summary}")
1016 for detail in tests.get("details", []):
1017 print(f" {detail}")
1018
1019 # Compatibility
1020 if compat["installed"]:
1021 if compat["compatible"]:
1022 print(f"\noc_diagnostics: v{compat['version']} -- {GREEN}compatible{RESET}")
1023 else:
1024 print(f"\noc_diagnostics: v{compat['version']} -- {RED}INCOMPATIBLE{RESET}")
1025 else:
1026 print(f"\noc_diagnostics: {RED}NOT INSTALLED{RESET}")
1027 if compat["message"]:
1028 print(f" {compat['message']}")
1029
1030 # Virtual environment
1031 venv_data = report.get("venv")
1032 if venv_data:
1033 venv_color = GREEN if venv_data["status"] == "OK" else YELLOW
1034 venv_label = "[OK]" if venv_data["status"] == "OK" else "[WARN]"
1035 print(f"\nVirtual Env: {venv_color}{venv_label}{RESET} {venv_data['message']}")
1036
1037 # Git
1038 git_data = report.get("git")
1039 if git_data:
1040 print(f"\nGit Integration:")
1041 _STATUS_COLORS = {"OK": GREEN, "WARN": YELLOW, "FAIL": RED, "SKIP": YELLOW}
1042 _STATUS_LABELS = {
1043 "is_repo": "Repository",
1044 "branch": "Branch",
1045 "naming_conformant": "Branch naming",
1046 "clean_worktree": "Working tree",
1047 "ahead_behind": "Remote sync",
1048 "hooks_installed": "Hooks",
1049 "last_commit_conventional": "Last commit",
1050 }
1051 for key in _STATUS_LABELS:
1052 check = git_data.get(key)
1053 if check is None:
1054 continue
1055 label = _STATUS_LABELS[key]
1056 status = check["status"]
1057 detail = check["detail"]
1058 color = _STATUS_COLORS.get(status, RESET)
1059 print(f" {label + ':':<22}{color}[{status}]{RESET} {detail}")
1060
1061 # Verbose: per-file breakdown
1062 if verbose and "files" in lint:
1063 print(f"\n{CYAN}{'-' * 70}{RESET}")
1064 print(f"{CYAN}Per-file breakdown:{RESET}")
1065 print(f"{CYAN}{'-' * 70}{RESET}\n")
1066
1067 for file_info in lint["files"]:
1068 if not file_info["compliant"]:
1069 failed = [k for k, v in file_info["checks"].items() if not v]
1070 print(f" {file_info['path']}")
1071 print(f" Failed: {RED}{', '.join(failed)}{RESET}")
1072
1073 print(f"\n{CYAN}{'=' * 70}{RESET}\n")
1074
1075
1076def _print_json_report(report: Dict[str, Any]) -> None:
1077 """Print JSON health report."""
1078 print(json.dumps(report, indent=2))
1079
1080
1081# ============================================================================
1082# Main Entry Point
1083# ============================================================================
1084
1085
1087 project_root: Path,
1088 json_mode: bool = False,
1089 verbose: bool = False,
1090 test_timeout: int | None = None,
1091 log_output: bool = False,
1092) -> None:
1093 """
1094 Run the project health dashboard.
1095
1096 Parameters
1097 ----------
1098 project_root : Path
1099 The detected Option C project root directory.
1100 json_mode : bool
1101 Output machine-readable JSON instead of terminal report.
1102 verbose : bool
1103 Show per-file details in addition to summary.
1104 test_timeout : int | None
1105 Maximum seconds to wait for pytest. Overrides ``health.test_timeout``
1106 in ``.octrc.json``. Defaults to 300 when neither is set (OI-413).
1107 """
1108 func = "run_health"
1109
1110 # Build linter context for check functions
1111 diagnostics_dir = project_root / "oc_diagnostics"
1112 if not diagnostics_dir.is_dir():
1113 diagnostics_dir = project_root / "diagnostics"
1114 if diagnostics_dir.is_dir():
1115 print(
1116 "Warning: 'diagnostics/' is deprecated and will be removed in v1.0.0. "
1117 "Rename to 'oc_diagnostics/'.",
1118 file=sys.stderr,
1119 )
1120
1121 ctx = LinterContext(
1122 project_root=project_root,
1123 project_name=project_root.name,
1124 diagnostics_dir=diagnostics_dir,
1125 tests_dir=project_root / "tests",
1126 docs_dir=project_root / "docs",
1127 )
1128
1129 # Load per-project overrides from .octrc.json
1130 exclude_dirs = set(LINTER_EXCLUDE_DIRS)
1131 wildcard_exclude = list(WILDCARD_EXCLUDE_DIRS)
1132 excluded_by_config: List[str] = []
1133 active_rules = dict(_DEFAULT_RULES)
1134 disabled_rules: List[str] = []
1135
1136 # Default timeout for tests; overridable via .octrc.json health.test_timeout
1137 # or explicit test_timeout parameter (OI-413).
1138 # OI-519: routed through central oct.core.octrc loader.
1139 effective_test_timeout: int = 300
1140 from oct.core.octrc import load_octrc_safe
1141 octrc = load_octrc_safe(project_root)
1142 linter_cfg = octrc.get("linter", {})
1143 if isinstance(linter_cfg, dict):
1144 for d in linter_cfg.get("exclude_dirs_add", []):
1145 exclude_dirs.add(d)
1146 excluded_by_config.append(d)
1147 for d in linter_cfg.get("exclude_dirs_remove", []):
1148 exclude_dirs.discard(d)
1149 for d in linter_cfg.get("wildcard_exclude_add", []):
1150 if d not in wildcard_exclude:
1151 wildcard_exclude.append(d)
1152 # Resolve rule profile and per-rule overrides
1153 profile_name = linter_cfg.get("profile", "default")
1154 active_rules = dict(_PROFILES.get(profile_name, _DEFAULT_RULES))
1155 rule_overrides = linter_cfg.get("rules", {})
1156 if isinstance(rule_overrides, dict):
1157 active_rules.update(rule_overrides)
1158 disabled_rules = [r for r, v in active_rules.items() if not v]
1159 health_cfg = octrc.get("health", {})
1160 if isinstance(health_cfg, dict):
1161 cfg_timeout = health_cfg.get("test_timeout")
1162 if isinstance(cfg_timeout, int) and cfg_timeout > 0:
1163 effective_test_timeout = cfg_timeout
1164
1165 # Explicit parameter takes precedence over config
1166 if test_timeout is not None:
1167 effective_test_timeout = test_timeout
1168
1169 # Compute dashboard parameters from loaded config
1170 active_checks = [c for c in _HEALTH_CHECKS if active_rules.get(c, True)]
1171 _fix_rule_map = {
1172 "header": "header", "docstring": "docstring",
1173 "dbg_import": "dbg_import", "func_pattern": "func_pattern",
1174 }
1175 fixable_names = [k for k in _fix_rule_map if active_rules.get(_fix_rule_map[k], True)]
1176
1177 from oct.core.terminal import supports_ansi_on
1178 use_color = supports_ansi_on(sys.stdout) and not json_mode
1179 G = "\033[92m" if use_color else ""
1180 R = "\033[91m" if use_color else ""
1181 Y = "\033[93m" if use_color else ""
1182 X = "\033[0m" if use_color else ""
1183
1184 template_lines, lm = _build_health_template(
1185 project_name=project_root.name,
1186 excluded_dirs=excluded_by_config,
1187 disabled_rules=disabled_rules,
1188 active_checks=active_checks,
1189 fixable_names=fixable_names,
1190 use_color=use_color,
1191 )
1192
1193 dashboard = Dashboard(quiet=json_mode)
1194 dashboard.render(template_lines, footer_line=lm.footer, tasks_total=7)
1195
1196 # --- Check 1: Lint compliance ---
1197 dashboard.set_task("Lint compliance")
1198 dashboard.update_line(lm.lint, f"{Y}[>]{X} Lint Compliance: running...")
1199 lint_result = check_lint_health(
1200 project_root, ctx, verbose=verbose,
1201 exclude_dirs=exclude_dirs, wildcard_exclude=wildcard_exclude,
1202 active_rules=active_rules,
1203 )
1204 pct = lint_result["compliance_pct"]
1205 pc = G if pct == 100.0 else R
1206 dashboard.update_line(
1207 lm.lint,
1208 f"{G}[x]{X} Lint Compliance: "
1209 f"{pc}{pct}%{X} ({lint_result['fully_compliant']}/{lint_result['total_files']}"
1210 f" files fully compliant)",
1211 )
1212 for ck, idx in lm.lint_checks.items():
1213 cd = lint_result["checks"].get(ck)
1214 label = ck.replace("_", " ").title()
1215 if cd:
1216 c = G if cd["pass"] == cd["total"] else R
1217 dashboard.update_line(
1218 idx, f" {G}[x]{X} {label + ':':<20}{c}{_pct_str(cd['pass'], cd['total'])}{X}",
1219 )
1220 else:
1221 dashboard.update_line(idx, f" {Y}[-]{X} {label + ':':<20}disabled")
1222 dashboard.complete_task()
1223
1224 # --- Check 2: Fixable violations ---
1225 dashboard.set_task("Auto-fixable checks")
1226 dashboard.update_line(lm.fixable, f"{Y}[>]{X} Fixable Violations: running...")
1227 fixable_result = check_fixable_health(
1228 project_root, ctx,
1229 exclude_dirs=exclude_dirs, wildcard_exclude=wildcard_exclude,
1230 active_rules=active_rules,
1231 verbose=verbose,
1232 )
1233 fix_total = fixable_result.get("total", 0)
1234 fc = G if fix_total == 0 else Y
1235 dashboard.update_line(lm.fixable, f"{G}[x]{X} Fixable Violations: {fc}{fix_total} total{X}")
1236 for fn, idx in lm.fixable_items.items():
1237 val = fixable_result.get(fn, 0)
1238 vc = G if val == 0 else Y
1239 dashboard.update_line(
1240 idx, f" {G}[x]{X} {_FIX_LABELS.get(fn, fn) + ':':<20}{vc}{val}{X}",
1241 )
1242 dashboard.complete_task()
1243
1244 # --- Check 3: Documentation ---
1245 dashboard.set_task("Documentation")
1246 dashboard.update_line(lm.docs, f"{Y}[>]{X} Documentation: running...")
1247 _octrc_cfg = load_octrc_safe(project_root)
1248 _production_mode = bool(
1249 (_octrc_cfg.get("health") or {}).get("production_mode", False)
1250 )
1251 docs_result = check_docs_health(project_root, production_mode=_production_mode)
1252 dc = G if docs_result["total_present"] == docs_result["total_required"] else R
1253 dashboard.update_line(
1254 lm.docs,
1255 f"{G}[x]{X} Documentation: "
1256 f"{dc}{docs_result['total_present']}/{docs_result['total_required']} present{X}",
1257 )
1258 for fi in docs_result["files"]:
1259 idx = lm.docs_files.get(fi["name"])
1260 if idx is None:
1261 continue
1262 if fi["present"]:
1263 dashboard.update_line(idx, f" {G}[x]{X} {fi['name']:<20}{G}[OK]{X}")
1264 elif fi.get("required", True):
1265 dashboard.update_line(idx, f" {R}[!]{X} {fi['name']:<20}{R}[MISSING]{X}")
1266 else:
1267 dashboard.update_line(idx, f" {Y}[-]{X} {fi['name']:<20}{Y}[OPTIONAL]{X}")
1268 dashboard.complete_task()
1269
1270 # --- Check 4: Test suite ---
1271 dashboard.set_task("Test suite")
1272 dashboard.update_line(lm.tests, f"{Y}[>]{X} Tests: running...")
1273 # FS-C3: read coverage threshold from .octrc.json health section.
1274 _health_cfg = _octrc_cfg.get("health") or {}
1275 _coverage_threshold_raw = _health_cfg.get("coverage_threshold")
1276 _coverage_threshold: Optional[float] = None
1277 if isinstance(_coverage_threshold_raw, (int, float)):
1278 _coverage_threshold = float(_coverage_threshold_raw)
1279 test_result = check_test_health(
1280 project_root,
1281 timeout=effective_test_timeout,
1282 coverage_threshold=_coverage_threshold,
1283 production_mode=_production_mode,
1284 )
1285 ts = test_result["status"]
1286 cov_pct = test_result.get("coverage_pct")
1287 cov_thr = test_result.get("coverage_threshold")
1288 cov_msg = test_result.get("coverage_message")
1289 cov_suffix = ""
1290 if cov_pct is not None and cov_thr is not None:
1291 cov_state = "OK" if cov_pct >= cov_thr else "below"
1292 cov_suffix = f" | cov {cov_pct:.1f}%/{cov_thr:.0f}% [{cov_state}]"
1293 elif cov_msg:
1294 cov_suffix = f" | {cov_msg}"
1295 tsm = f" ({test_result['summary']}{cov_suffix})" if test_result["summary"] else cov_suffix
1296 if ts == "pass":
1297 dashboard.update_line(lm.tests, f"{G}[x]{X} Tests: {G}PASS{X}{tsm}")
1298 elif ts == "not_run":
1299 dashboard.update_line(lm.tests, f"{Y}[-]{X} Tests: {Y}NOT RUN{X}{tsm}")
1300 else:
1301 dashboard.update_line(lm.tests, f"{R}[!]{X} Tests: {R}{ts.upper()}{X}{tsm}")
1302 dashboard.complete_task()
1303
1304 # --- Check 5: Compatibility ---
1305 dashboard.set_task("Compatibility check")
1306 dashboard.update_line(lm.compat, f"{Y}[>]{X} oc_diagnostics: checking...")
1307 compat_result = check_compat_health()
1308 if compat_result["installed"]:
1309 if compat_result["compatible"]:
1310 dashboard.update_line(
1311 lm.compat,
1312 f"{G}[x]{X} oc_diagnostics: v{compat_result['version']} -- {G}compatible{X}",
1313 )
1314 else:
1315 dashboard.update_line(
1316 lm.compat,
1317 f"{R}[!]{X} oc_diagnostics: v{compat_result['version']} -- {R}INCOMPATIBLE{X}",
1318 )
1319 else:
1320 dashboard.update_line(lm.compat, f"{R}[!]{X} oc_diagnostics: {R}NOT INSTALLED{X}")
1321 dashboard.complete_task()
1322
1323 # --- Check 6: Virtual environment ---
1324 dashboard.set_task("Virtual environment")
1325 dashboard.update_line(lm.venv, f"{Y}[>]{X} Virtual Env: checking...")
1326 venv_result = check_venv_health(project_root)
1327 if venv_result["status"] == "OK":
1328 dashboard.update_line(
1329 lm.venv, f"{G}[x]{X} Virtual Env: {G}[OK]{X} {venv_result['message']}",
1330 )
1331 else:
1332 dashboard.update_line(
1333 lm.venv, f"{R}[!]{X} Virtual Env: {Y}[WARN]{X} {venv_result['message']}",
1334 )
1335 dashboard.complete_task()
1336
1337 # --- Check 7: Git integration ---
1338 dashboard.set_task("Git integration")
1339 dashboard.update_line(lm.git, f"{Y}[>]{X} Git Integration: checking...")
1340 git_result = check_git_health(project_root)
1341 _GC = {"OK": G, "WARN": Y, "FAIL": R, "SKIP": Y}
1342 git_ok = 0
1343 git_total = 0
1344 for gk, gl in _GIT_LABELS.items():
1345 idx = lm.git_checks.get(gk)
1346 check = git_result.get(gk)
1347 if idx is None or check is None:
1348 continue
1349 git_total += 1
1350 status = check["status"]
1351 color = _GC.get(status, X)
1352 if status == "OK":
1353 git_ok += 1
1354 mk = f"{G}[x]{X}"
1355 elif status in ("WARN", "SKIP"):
1356 mk = f"{Y}[-]{X}"
1357 else:
1358 mk = f"{R}[!]{X}"
1359 dashboard.update_line(idx, f" {mk} {gl + ':':<20}{color}[{status}]{X} {check['detail']}")
1360 gc = G if git_ok == git_total else Y
1361 dashboard.update_line(lm.git, f"{G}[x]{X} Git Integration: {gc}{git_ok}/{git_total} checks OK{X}")
1362 dashboard.complete_task()
1363
1364 dashboard.finish()
1365
1366 # Build report dict for JSON output
1367 report = {
1368 "project": project_root.name,
1369 "oct_version": __version__,
1370 "timestamp": datetime.now().isoformat(),
1371 "excluded_dirs": excluded_by_config,
1372 "disabled_rules": disabled_rules,
1373 "lint": lint_result,
1374 "fixable": fixable_result,
1375 "docs": docs_result,
1376 "tests": test_result,
1377 "compat": compat_result,
1378 "venv": venv_result,
1379 "git": git_result,
1380 }
1381
1382 if json_mode:
1383 _print_json_report(report)
1384
1385 if log_output:
1386 from oct.core.log_writer import write_command_log
1387 log_path = write_command_log(project_root, "health", report)
1388 print(f"Log written to: {log_path}", file=sys.stderr)
1389
1390 if verbose and not json_mode and "files" in lint_result:
1391 print(f"\n{Y}{'-' * 70}{X}")
1392 print(f"{Y}Per-file breakdown:{X}")
1393 print(f"{Y}{'-' * 70}{X}\n")
1394 for file_info in lint_result["files"]:
1395 if not file_info["compliant"]:
1396 failed = [k for k, v in file_info["checks"].items() if not v]
1397 print(f" {file_info['path']}")
1398 print(f" Failed: {R}{', '.join(failed)}{X}")
1399
1400 if verbose and not json_mode and fixable_result.get("files"):
1401 print(f"\n{Y}{'-' * 70}{X}")
1402 print(f"{Y}Fixable violations per file:{X}")
1403 print(f"{Y}{'-' * 70}{X}\n")
1404 for file_info in fixable_result["files"]:
1405 print(f" {file_info['path']}")
1406 print(f" Fixable: {Y}{', '.join(file_info['fixes'])}{X}")
str _pct_str(int count, int total)
None _print_terminal_report(Dict[str, Any] report, bool verbose)
Dict[str, Any] check_lint_health(Path project_root, LinterContext ctx, bool verbose=False, Optional[set] exclude_dirs=None, Optional[list] wildcard_exclude=None, Optional[dict] active_rules=None)
Definition oct_health.py:98
Dict[str, Any] check_git_health(Path project_root)
Dict[str, Any] check_docs_health(Path project_root, *, bool production_mode=False)
Dict[str, Any] check_venv_health(Path project_root)
Dict[str, Any] check_fixable_health(Path project_root, LinterContext ctx, Optional[set] exclude_dirs=None, Optional[list] wildcard_exclude=None, Optional[dict] active_rules=None, bool verbose=False)
Dict[str, Any] check_test_health(Path project_root, int timeout=300, *, Optional[float] coverage_threshold=None, bool production_mode=False, Optional[str] coverage_target=None)
None run_health(Path project_root, bool json_mode=False, bool verbose=False, int|None test_timeout=None, bool log_output=False)
Dict[str, Any] check_compat_health()
Optional[dict] _load_octrc_for_health(Path project_root)
None _print_json_report(Dict[str, Any] report)
tuple _build_health_template(str project_name, List[str] excluded_dirs, List[str] disabled_rules, List[str] active_checks, List[str] fixable_names, bool use_color)