oct
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- oct.core.waivers (inline waiver semantics, shared with the linter and
51 the quality gate)
52- oct.core.option_c_dir (lint cache, debug_config.json and octrc path resolution)
53- oct.linter.lint_cache (LintCache, content hashes and current_linter_identity - reuse results for unchanged files, in the store oct lint shares)
54- oct.timing.ledger (timed_stage - per-check timing records)
55- oct.core.diagnostics (_dbg structured logger)
56- oct.core.git (lazy; repository, branch, worktree and last-commit state for the git checks)
57- oct.core.log_writer (lazy; write_command_log for the health log file)
58- oct.formatter.oct_format (lazy; fixers and FormatterContext to count fixable violations)
59- oct.git.conventional (lazy; Conventional Commits check of the last commit)
60- oct.git.policy (lazy; branch naming validation)
61- oct.timing.report (lazy; timing report, baseline and session views)
62- click (lazy; ClickException for timing report errors)
63- pytest (not imported; found by name and run as python -m pytest for the test check)
64- pytest_cov (not imported; pytest plugin found by name, for the --cov coverage measurement)
65- xdist (not imported; pytest plugin behind -n auto for the parallel test run)
66"""
67
68import json
69import re
70import subprocess
71import sys
72from contextlib import nullcontext
73from dataclasses import dataclass, field, asdict
74from datetime import datetime
75from pathlib import Path
76from typing import Dict, List, Any, Optional
77
78from oct import __version__
79from oct.core.progress import Dashboard
80from oct.core.parsing import safe_parse
81from oct.core.paths import to_project_relative
82from oct.linter.oct_lint import (
83 validate_header_block,
84 check_docstring,
85 check_dependencies_section,
86 check_diagnostics_profile,
87 check_dbg_usage,
88 check_dbg_import,
89 check_func_pattern,
90 check_syntax_warnings,
91 find_python_files,
92 LinterContext,
93 _DEFAULT_RULES,
94 _PROFILES,
95 _describe_syntax_error,
96 effective_rules,
97 parse_path_rules,
98)
99from oct.core.exclusions import LINTER_EXCLUDE_DIRS, WILDCARD_EXCLUDE_DIRS
100from oct.core.project_root import find_project_root
101from oct.core.option_c_dir import option_c_dir, resolve_debug_config
102from oct.core.compat import check_oc_diagnostics_compat
103from oct.core.octrc import load_octrc_safe
104from oct.core.terminal import CYAN, GREEN, RED, YELLOW, RESET
105from oct.core.waivers import inline_disabled_rules
106from oct.linter.lint_cache import (
107 LintCache,
108 LintCacheEntry,
109 content_hash,
110 current_linter_identity,
111 rules_hash,
112)
113from oct.timing.ledger import timed_stage
114from oct.core.diagnostics import _dbg
115
116
117# ============================================================================
118# Health Check Functions
119# ============================================================================
120
121
122# F21 / Plan 175 (item 1): health checks whose verdict is derived from the
123# parsed AST. On an unparseable file (``tree is None``) they cannot run, so
124# they must be marked NOT-RUN rather than credited a vacuous pass - the exact
125# fail-open shape Plan 170 closed in the linter. ``syntax_error`` itself is
126# the gate and is evaluated directly from ``tree is None``, so it is not in
127# this set.
128_AST_DEPENDENT_HEALTH_CHECKS = frozenset(
129 {"dbg_usage", "dbg_import", "func_pattern", "syntax_warnings"}
130)
131
132# Plan 277: checks no inline waiver may excuse, however it is written.
133# ``syntax_error`` is F21 / Plan 170's unconditional gate - the linter
134# evaluates it BEFORE its own ``_rule_active`` gate and says so in a
135# comment, so unparseable Python is never compliant at any profile and
136# under any waiver. The dashboard honours waivers on exactly the rules
137# the linter honours them on, and this is the one it does not.
138_UNWAIVABLE_HEALTH_CHECKS = frozenset({"syntax_error"})
139
140
142 project_root: Path,
143 ctx: LinterContext,
144 verbose: bool = False,
145 exclude_dirs: Optional[set] = None,
146 wildcard_exclude: Optional[list] = None,
147 active_rules: Optional[dict] = None,
148 analysis_cache: Optional[Dict[Path, Dict[str, Any]]] = None,
149) -> Dict[str, Any]:
150 """
151 Run linter checks on all Python files and aggregate pass/fail statistics.
152
153 Returns a dict with total_files, fully_compliant count, per-check pass
154 counts, and compliance percentage.
155 """
156 func = "check_lint_health"
157
158 if exclude_dirs is None:
159 exclude_dirs = set(LINTER_EXCLUDE_DIRS)
160 if wildcard_exclude is None:
161 wildcard_exclude = list(WILDCARD_EXCLUDE_DIRS)
162 if active_rules is None:
163 active_rules = dict(_DEFAULT_RULES)
164
165 python_files = list(find_python_files(project_root, exclude_dirs, wildcard_exclude))
166
167 # Build counters only for active rules (excluding tests_exist which health doesn't check).
168 # F21 / Plan 175: "syntax_error" leads the list (mirroring the linter, Plan 170) so an
169 # unparseable file fails a real check instead of being reported healthy.
170 health_checks = [
171 "syntax_error",
172 "header", "docstring", "dependencies_section", "diagnostics_profile",
173 "dbg_usage", "dbg_import", "func_pattern", "syntax_warnings",
174 ]
175 active_checks = [c for c in health_checks if active_rules.get(c, True)]
176 # The same identity ``oct lint`` writes: the store is shared, and a
177 # different header discards every entry on load (2026-09-28).
178 cache = LintCache(
179 option_c_dir(project_root) / "cache" / "lint-cache.json",
180 __version__,
181 linter_identity=current_linter_identity(),
182 )
183
184 total = len(python_files)
185 counters = {c: 0 for c in active_checks}
186 # NOT-RUN tally: AST-dependent checks that were skipped because the file
187 # did not parse. Kept separate so their pass percentage is computed over
188 # the files where the check actually ran, not inflated by vacuous passes.
189 not_run = {c: 0 for c in active_checks}
190 # Plan 268: APPLICABLE tally - the files a check was active on at all.
191 # A check a path rule switched off for one file is neither a pass nor a
192 # failure there, so it must not drag that file into the denominator. A
193 # project with no path rules has every check applying to every file, so
194 # this is ``total`` and the reported percentages are unchanged.
195 applicable = {c: 0 for c in active_checks}
196 fully_compliant = 0
197 file_details = []
198 cache_hits = 0
199 cache_misses = 0
200
201 check_funcs = {
202 "header": lambda text, lines, py_file, tree, sw: validate_header_block(
203 lines[:4] if len(lines) >= 4 else lines, py_file, ctx),
204 "docstring": lambda text, lines, py_file, tree, sw: check_docstring(text, tree=tree),
205 "dependencies_section": lambda text, lines, py_file, tree, sw: check_dependencies_section(text, tree=tree),
206 "diagnostics_profile": lambda text, lines, py_file, tree, sw: check_diagnostics_profile(text, tree=tree),
207 "dbg_usage": lambda text, lines, py_file, tree, sw: check_dbg_usage(text, tree=tree),
208 "dbg_import": lambda text, lines, py_file, tree, sw: check_dbg_import(text, tree=tree),
209 "func_pattern": lambda text, lines, py_file, tree, sw: check_func_pattern(text, tree=tree),
210 "syntax_warnings": lambda text, lines, py_file, tree, sw: check_syntax_warnings(text, warnings_list=sw),
211 }
212
213 for py_file in python_files:
214 try:
215 text = py_file.read_text(encoding="utf-8")
216 except Exception:
217 continue
218
219 lines = text.split('\n')
220 file_hash = content_hash(text)
221 # Plan 268: resolve this file's rule set before anything keys on
222 # it. The cache key carries the per-file set, so two byte-identical
223 # files under different path rules never share one entry, and the
224 # dashboard decides each file on exactly the rules ``oct lint``
225 # decides it on.
226 file_rules, _file_rule_changes = effective_rules(
227 active_rules, to_project_relative(py_file, project_root),
228 ctx.path_rules,
229 )
230 file_checks = [c for c in health_checks if file_rules.get(c, True)]
231 cache_rules = dict(file_rules)
232 cache_rules["_health_dashboard"] = True
233 file_rules_hash = rules_hash(cache_rules)
234 cached = cache.get(file_hash, file_rules_hash)
235 syntax_error_message: str | None = None
236 checks: Dict[str, Any]
237
238 if cached is not None and all(
239 check_name in cached.rule_results for check_name in file_checks
240 ):
241 checks = {
242 check_name: cached.rule_results[check_name]
243 for check_name in file_checks
244 }
245 cache_hits += 1
246 syntax_error_message = cached.json_detail.get("syntax_error_message")
247 # Header validity depends on the current path as well as content.
248 # Match the linter cache's stale-path correction by refreshing only
249 # this cheap path-sensitive verdict on an otherwise complete hit.
250 if "header" in checks:
251 checks["header"], _ = validate_header_block(
252 lines[:4] if len(lines) >= 4 else lines,
253 py_file,
254 ctx,
255 )
256 else:
257 cache_misses += 1
258 tree, syntax_warnings_list = safe_parse(text)
259 syntax_error_message = (
260 _describe_syntax_error(text) if tree is None else None
261 )
262 checks = {}
263 for check_name in file_checks:
264 if check_name == "syntax_error":
265 # The gate itself: an unparseable file (tree is None) fails,
266 # unconditionally, at every profile (Plan 170 semantics).
267 ok = tree is not None
268 elif tree is None and check_name in _AST_DEPENDENT_HEALTH_CHECKS:
269 # NOT-RUN: the file did not parse, so every AST-derived verdict
270 # is meaningless. Record it as not-run (None) - never a pass.
271 checks[check_name] = None
272 continue
273 else:
274 ok, _ = check_funcs[check_name](
275 text, lines, py_file, tree, syntax_warnings_list,
276 )
277 checks[check_name] = ok
278 cache.put(
279 file_hash,
280 file_rules_hash,
282 content_hash=file_hash,
283 rule_results=checks,
284 json_detail={
285 "syntax_error_message": syntax_error_message,
286 },
287 oct_version=__version__,
288 timestamp=datetime.now().isoformat(),
289 ),
290 )
291
292 # Plan 277: the file's own inline waivers, applied as an overlay
293 # on top of whatever the checks (or the cache) said. The linter
294 # never RUNS a waived rule, so it reports neither a pass nor a
295 # failure for it; the dashboard counts per check, so the nearest
296 # true statement here is that the file does not fail it - which
297 # is also what makes the file fully_compliant, exactly as
298 # ``oct lint`` calls it compliant.
299 #
300 # An overlay rather than a gate on purpose. The LintCache is
301 # keyed by content hash, so a waiver IS in the key, but an entry
302 # written before this plan carries the old verdict; recomputing
303 # the overlay on every read makes the cache-hit and cache-miss
304 # paths agree the way ``_lint_single_file`` makes them agree.
305 # Only a genuine ``False`` is flipped: a NOT-RUN (``None``) stays
306 # NOT-RUN, so a waiver can never buy an unparseable file a
307 # vacuous pass.
308 waived_checks = inline_disabled_rules(text) - _UNWAIVABLE_HEALTH_CHECKS
309 if waived_checks:
310 for check_name, ok in checks.items():
311 if ok is False and check_name in waived_checks:
312 checks[check_name] = True
313
314 if analysis_cache is not None:
315 analysis_cache[py_file] = {
316 "text": text,
317 "checks": checks,
318 }
319
320 for check_name, ok in checks.items():
321 # Plan 268: a path rule can also switch a check ON for one
322 # path that the project disabled everywhere else, so the
323 # tallies are reached by name rather than pre-seeded.
324 applicable[check_name] = applicable.get(check_name, 0) + 1
325 if ok is None:
326 not_run[check_name] = not_run.get(check_name, 0) + 1
327 continue
328 if ok:
329 counters[check_name] = counters.get(check_name, 0) + 1
330 else:
331 counters.setdefault(check_name, 0)
332
333 # A file is fully compliant only if every check that RAN passed;
334 # syntax_error (False on an unparseable file) alone sinks it.
335 file_compliant = all(v for v in checks.values() if v is not None)
336 if file_compliant:
337 fully_compliant += 1
338
339 if verbose:
340 rel_path = to_project_relative(py_file, project_root)
341 detail: Dict[str, Any] = {
342 "path": rel_path,
343 "checks": checks,
344 "compliant": file_compliant,
345 }
346 if syntax_error_message is not None:
347 # Reuse Plan 170's helper (do not reimplement) to surface the
348 # concrete SyntaxError text for the broken file.
349 detail["syntax_error_message"] = syntax_error_message
350 file_details.append(detail)
351
352 if cache_misses:
353 try:
354 cache.save()
355 except OSError:
356 pass
357
358 compliance_pct = round((fully_compliant / total * 100), 1) if total > 0 else 0.0
359
360 result = {
361 "total_files": total,
362 "fully_compliant": fully_compliant,
363 "compliance_pct": compliance_pct,
364 "checks": {},
365 "cache_hits": cache_hits,
366 }
367
368 for check_name, count in counters.items():
369 # Percentage is over the files where the check actually ran: an
370 # AST-dependent check skipped on an unparseable file is NOT-RUN, not
371 # a failure, so it must not drag the denominator (F21 / Plan 175),
372 # and neither does a file a path rule excused from the check
373 # entirely (plan 268).
374 skipped = not_run.get(check_name, 0)
375 ran = applicable.get(check_name, total) - skipped
376 pct = round((count / ran * 100), 1) if ran > 0 else 0.0
377 result["checks"][check_name] = {
378 "pass": count,
379 "total": ran,
380 "not_run": skipped,
381 "pct": pct,
382 }
383
384 if verbose:
385 result["files"] = file_details
386
387 return result
388
389
391 project_root: Path,
392 ctx: LinterContext,
393 exclude_dirs: Optional[set] = None,
394 wildcard_exclude: Optional[list] = None,
395 active_rules: Optional[dict] = None,
396 verbose: bool = False,
397 analysis_cache: Optional[Dict[Path, Dict[str, Any]]] = None,
398) -> Dict[str, Any]:
399 """
400 Count violations that oct format could fix automatically.
401
402 Runs formatter checks in dry-run mode (no modifications).
403 Only counts fixes for rules that are active.
404
405 Plan 269 - "active" is a PER-FILE question wherever the project uses
406 ``linter.path_rules``. *active_rules* is the run's set; each file's
407 own set is resolved from it with
408 :func:`~oct.linter.oct_lint.effective_rules` over ``ctx.path_rules``,
409 which ``run_health`` has already parsed for :func:`check_lint_health`,
410 so the panel counts a fix only where the fixer's rule applies to that
411 file. Without it a module a ``tests/`` entry excused from
412 ``dbg_import`` was still offered the fix - the panel disagreeing with
413 ``oct lint`` on exactly the files the operator asked to treat
414 differently. The counter set is widened the same way: a rule a path
415 entry switches ON somewhere gets a counter even where the run
416 disables it, or the files it applies to could never be counted at
417 all. A project with no path rules produces byte-identical output.
418
419 Plan 277 - the file's own inline ``# OCT-LINT: disable[-file]=<rule>``
420 waivers are the third thing that can excuse a fixer, and the panel
421 now reads them too, through
422 :func:`~oct.core.waivers.inline_disabled_rules`: the linter's set,
423 written once and shared with the gate's format pass. Before, the
424 panel offered a ``dbg_import`` fix on every one of the nineteen
425 modules the operator had ruled exempt - a fix the gate would have
426 refused, and which on ``oct/core/diagnostics.py`` writes the
427 module-level self-import that file's own waiver reason says raises
428 ``ImportError``.
429
430 When *verbose* is True the result includes a ``"files"`` key mapping
431 to a list of ``{"path": <rel_path>, "fixes": [<fix_name>, ...]}``
432 entries so the caller can show which files have fixable violations.
433 """
434 func = "check_fixable_health"
435
436 from oct.formatter.oct_format import (
437 fix_header_block,
438 fix_docstring,
439 fix_dbg_import,
440 fix_func_pattern,
441 FormatterContext,
442 )
443
444 if active_rules is None:
445 active_rules = dict(_DEFAULT_RULES)
446
447 fmt_ctx = FormatterContext(
448 project_root=project_root,
449 project_name=project_root.name,
450 diagnostics_dir=ctx.diagnostics_dir,
451 tests_dir=ctx.tests_dir,
452 docs_dir=ctx.docs_dir,
453 fix_mode=False,
454 dry_run_mode=True,
455 json_mode=False,
456 verbose=False,
457 )
458
459 if analysis_cache is None:
460 if exclude_dirs is None:
461 exclude_dirs = set(LINTER_EXCLUDE_DIRS)
462 if wildcard_exclude is None:
463 wildcard_exclude = list(WILDCARD_EXCLUDE_DIRS)
464 python_files = list(
465 find_python_files(project_root, exclude_dirs, wildcard_exclude),
466 )
467 else:
468 python_files = list(analysis_cache)
469
470 # Map fixable check names to their corresponding lint rule names
471 fix_rule_map = {
472 "header": "header",
473 "docstring": "docstring",
474 "dbg_import": "dbg_import",
475 "func_pattern": "func_pattern",
476 }
477 # Plan 268 populated ``ctx.path_rules``; plan 269 makes this panel read
478 # it. ``getattr`` rather than attribute access because external callers
479 # construct their own context and a missing attribute must mean "no
480 # path scoping", not a crash in the dashboard.
481 path_rules = list(getattr(ctx, "path_rules", None) or ())
482 path_enabled = {
483 rule
484 for _prefix, rules in path_rules
485 for rule, value in rules.items()
486 if value
487 }
488 fixable = {
489 k: 0 for k in fix_rule_map
490 if active_rules.get(fix_rule_map[k], True)
491 or fix_rule_map[k] in path_enabled
492 }
493
494 fix_funcs = {
495 "header": fix_header_block,
496 "docstring": fix_docstring,
497 "dbg_import": fix_dbg_import,
498 "func_pattern": fix_func_pattern,
499 }
500
501 file_details: list[dict] = []
502
503 for py_file in python_files:
504 lint_checks: Optional[Dict[str, Any]] = None
505 if analysis_cache is not None:
506 analysis = analysis_cache[py_file]
507 text = analysis["text"]
508 lint_checks = analysis["checks"]
509 else:
510 try:
511 text = py_file.read_text(encoding="utf-8")
512 except Exception:
513 continue
514
515 rel_path = to_project_relative(py_file, project_root)
516 # Plan 269: the run's set is what applies wherever no entry matches,
517 # so a project with no path rules never enters effective_rules and
518 # every count below is what it was.
519 if path_rules:
520 file_rules, _changed = effective_rules(
521 active_rules, rel_path, path_rules,
522 )
523 else:
524 file_rules = active_rules
525
526 # Plan 277: the file's own inline waivers are the third thing
527 # that can excuse a fixer, beside the run's rules and the path
528 # rules, and they are read here on the linter's terms. Without
529 # this the panel offered a ``dbg_import`` fix on each of the
530 # nineteen ruled modules - a fix the gate's format pass would
531 # then refuse to accept, and which on ``oct/core/diagnostics.py``
532 # writes the self-import its waiver reason says raises.
533 waived_rules = inline_disabled_rules(text)
534
535 file_fixes: list[str] = []
536 for fix_name in fixable:
537 rule_name = fix_rule_map[fix_name]
538 if not file_rules.get(rule_name, True):
539 continue
540 if rule_name in waived_rules:
541 continue
542 if lint_checks is not None and lint_checks.get(rule_name) is True:
543 continue
544 if fix_name == "header" and lint_checks is not None:
545 changed = lint_checks.get(rule_name) is False
546 else:
547 changed, _, _ = fix_funcs[fix_name](py_file, text, fmt_ctx)
548 if changed:
549 fixable[fix_name] += 1
550 file_fixes.append(fix_name)
551
552 if verbose and file_fixes:
553 file_details.append({"path": rel_path, "fixes": file_fixes})
554
555 fixable["total"] = sum(fixable.values())
556 if verbose:
557 fixable["files"] = file_details
558 return fixable
559
560
562 project_root: Path,
563 *,
564 production_mode: bool = False,
565) -> Dict[str, Any]:
566 """
567 Validate presence of required documentation files.
568
569 Standard required set: ARCHITECTURE.md, CHANGELOG.md, Tests_README.md,
570 run_tests.py, debug_config.json. When ``production_mode=True`` (Appendix H
571 Level 2), SECURITY.md is also required; otherwise it is reported as
572 optional alongside the required count.
573 """
574 func = "check_docs_health"
575
576 base_required = [
577 ("ARCHITECTURE.md", project_root / "docs" / "ARCHITECTURE.md"),
578 ("CHANGELOG.md", project_root / "CHANGELOG.md"),
579 ("Tests_README.md", project_root / "tests" / "Tests_README.md"),
580 ("run_tests.py", project_root / "tests" / "run_tests.py"),
581 # Plan 213 / T0: route through the ONE resolver instead of
582 # hard-coding the legacy path, so a project migrated to
583 # ``.option_c/`` is not reported as missing its diagnostics config.
584 ("debug_config.json", resolve_debug_config(project_root)),
585 ]
586 production_only = [
587 ("SECURITY.md", project_root / "SECURITY.md"),
588 ]
589
590 required_files = base_required + (production_only if production_mode else [])
591 optional_files = [] if production_mode else production_only
592
593 files_status = []
594 present_count = 0
595
596 for name, path in required_files:
597 exists = path.is_file()
598 files_status.append({"name": name, "present": exists, "required": True})
599 if exists:
600 present_count += 1
601
602 for name, path in optional_files:
603 exists = path.is_file()
604 files_status.append({"name": name, "present": exists, "required": False})
605
606 return {
607 "total_required": len(required_files),
608 "total_present": present_count,
609 "files": files_status,
610 "production_mode": production_mode,
611 }
612
613
614_COVERAGE_TOTAL_RE = re.compile(r"^TOTAL\s+\d+\s+\d+\s+(\d+(?:\.\d+)?)%", re.MULTILINE)
615
616
618 project_root: Path,
619 timeout: int = 300,
620 *,
621 coverage_threshold: Optional[float] = None,
622 production_mode: bool = False,
623 coverage_target: Optional[str] = None,
624) -> Dict[str, Any]:
625 """
626 Run project tests and capture pass/fail status.
627
628 Uses pytest subprocess to run tests and captures the exit code
629 and summary line.
630
631 Parameters
632 ----------
633 project_root:
634 Path to the project root containing a ``tests/`` directory.
635 timeout:
636 Maximum seconds to wait for pytest to complete. Default 300 (OI-413).
637 coverage_threshold:
638 FS-C3 / Drift item C3 - when set, run pytest with ``--cov`` and
639 compare the resulting TOTAL coverage percentage against this
640 threshold. ``None`` (default) skips the coverage measurement
641 entirely so existing CI keeps its prior behaviour.
642 production_mode:
643 When ``True`` together with a ``coverage_threshold``, a coverage
644 result below the threshold flips ``status`` to ``"fail"``.
645 Outside production mode, below-threshold coverage is reported
646 but never blocks the dashboard.
647 coverage_target:
648 Module / package name to measure (passed as ``--cov=<target>``).
649 Defaults to the project root directory name when ``None``.
650 """
651 func = "check_test_health"
652
653 import importlib.util
654
655 tests_dir = project_root / "tests"
656 if not tests_dir.is_dir():
657 return {
658 "status": "not_run",
659 "exit_code": -1,
660 "summary": "No tests/ directory found",
661 "details": [],
662 "coverage_pct": None,
663 "coverage_threshold": coverage_threshold,
664 "coverage_blocking": production_mode,
665 }
666
667 # Detect missing pytest before attempting subprocess - avoids silent FAIL
668 if importlib.util.find_spec("pytest") is None:
669 return {
670 "status": "fail",
671 "exit_code": -1,
672 "summary": "pytest not installed - run: pip install pytest",
673 "details": [],
674 "coverage_pct": None,
675 "coverage_threshold": coverage_threshold,
676 "coverage_blocking": production_mode,
677 }
678
679 # FS-C3: when coverage measurement is requested, ensure pytest-cov is
680 # available; degrade gracefully (run without --cov) when missing.
681 coverage_requested = coverage_threshold is not None
682 coverage_available = (
683 coverage_requested
684 and importlib.util.find_spec("pytest_cov") is not None
685 )
686
687 pytest_args = [
688 sys.executable, "-m", "pytest", str(tests_dir),
689 "-n", "auto", "--dist=worksteal", "--tb=line", "--no-header",
690 ]
691 if coverage_available:
692 target = coverage_target or project_root.name
693 pytest_args.extend([f"--cov={target}", "--cov-report=term"])
694
695 try:
696 result = subprocess.run(
697 pytest_args,
698 capture_output=True,
699 text=True,
700 encoding="utf-8",
701 errors="replace",
702 timeout=timeout,
703 cwd=str(project_root),
704 )
705
706 # Extract summary line - scan from end since pytest always puts it last.
707 # Match on keywords OR the timing pattern ("N passed in 0.31s") as a fallback
708 # since encoding issues can occasionally mangle keyword characters on Windows.
709 _SUMMARY_RE = re.compile(r"\b\d+ \w+ in [\d.]+s")
710 _DECORATION_RE = re.compile(r"^=+\s*|\s*=+$")
711 summary = ""
712 for line in reversed(result.stdout.strip().split('\n')):
713 line = line.strip()
714 if "passed" in line or "failed" in line or "error" in line or _SUMMARY_RE.search(line):
715 summary = _DECORATION_RE.sub("", line).strip()
716 break
717
718 # Extract actionable error detail lines (max 5) from --tb=line output.
719 # pytest --tb=line produces lines like:
720 # path/to/test.py - ErrorType: message
721 # FAILED tests/test_foo.py::test_bar - AssertionError
722 detail_lines = []
723 if result.returncode != 0:
724 for line in result.stdout.strip().split('\n'):
725 line = line.strip()
726 if not line or line.startswith("=") or line.startswith("."):
727 continue
728 if ("Error" in line or "Exception" in line
729 or line.startswith("FAILED ")
730 or line.startswith("ERROR ")):
731 detail_lines.append(line)
732 if len(detail_lines) >= 5:
733 break
734
735 # Fall back to stderr when stdout has no useful summary (e.g. import errors)
736 if not summary and result.stderr.strip():
737 err_lines = result.stderr.strip().split('\n')
738 summary = err_lines[-1].strip()
739
740 # Tertiary fallback - always provide a summary even if regex + stderr both fail
741 if not summary:
742 summary = f"pytest exited with code {result.returncode}"
743
744 status = "pass" if result.returncode == 0 else "fail"
745
746 # FS-C3: parse pytest-cov "TOTAL ... NN%" line when coverage was requested.
747 coverage_pct: Optional[float] = None
748 if coverage_available:
749 cov_match = _COVERAGE_TOTAL_RE.search(result.stdout)
750 if cov_match:
751 try:
752 coverage_pct = float(cov_match.group(1))
753 except ValueError:
754 coverage_pct = None
755
756 # Production-mode gating: a coverage result below threshold flips
757 # status to "fail" only when production_mode is set. Outside
758 # production, below-threshold coverage is informational.
759 if (
760 production_mode
761 and coverage_pct is not None
762 and coverage_threshold is not None
763 and coverage_pct < coverage_threshold
764 and status == "pass"
765 ):
766 status = "fail"
767 summary = (
768 f"{summary} | coverage {coverage_pct:.1f}% below "
769 f"production threshold {coverage_threshold:.1f}%"
770 ).strip()
771
772 # Surface a clear diagnostic when the user asked for coverage but
773 # pytest-cov isn't installed.
774 coverage_msg: Optional[str] = None
775 if coverage_requested and not coverage_available:
776 coverage_msg = (
777 "pytest-cov not installed - install with: "
778 "pip install -e .[test]"
779 )
780
781 return {
782 "status": status,
783 "exit_code": result.returncode,
784 "summary": summary,
785 "details": detail_lines,
786 "coverage_pct": coverage_pct,
787 "coverage_threshold": coverage_threshold,
788 "coverage_blocking": production_mode,
789 "coverage_message": coverage_msg,
790 }
791
792 except subprocess.TimeoutExpired:
793 return {
794 "status": "timeout",
795 "exit_code": -1,
796 "summary": f"Tests timed out after {timeout} seconds",
797 "details": [],
798 "coverage_pct": None,
799 "coverage_threshold": coverage_threshold,
800 "coverage_blocking": production_mode,
801 }
802 except Exception as e:
803 return {
804 "status": "error",
805 "exit_code": -1,
806 "summary": str(e),
807 "details": [],
808 "coverage_pct": None,
809 "coverage_threshold": coverage_threshold,
810 "coverage_blocking": production_mode,
811 }
812
813
814def check_compat_health() -> Dict[str, Any]:
815 """
816 Check oc_diagnostics version compatibility.
817 """
818 func = "check_compat_health"
819 _dbg("OCT.HEALTH", func, "checking oc_diagnostics compatibility", 3)
820
821 msg = check_oc_diagnostics_compat()
822
823 try:
824 from importlib.metadata import version as _pkg_version
825 version = _pkg_version("oc_diagnostics")
826 installed = True
827 except Exception:
828 version = "not installed"
829 installed = False
830
831 return {
832 "installed": installed,
833 "version": version,
834 "compatible": msg is None or msg == "",
835 "message": msg or "",
836 }
837
838
839def check_venv_health(project_root: Path) -> Dict[str, Any]:
840 """
841 Check virtual environment status for the project.
842
843 Performs three layered checks:
844 1. Whether the current process is running inside *any* venv.
845 2. Whether a canonical ``.venv`` directory exists at the project root or its
846 immediate parent (monorepo pattern: shared venv one level above subproject).
847 3. Whether the active venv is the project's own ``.venv`` (not a foreign env).
848
849 Returns a dict with ``status`` (OK / WARN / FAIL), ``active`` (bool),
850 ``venv_found`` (bool), ``active_path`` (str | None), and ``message`` (str).
851 """
852 func = "check_venv_health" # noqa: F841 - Option C func pattern
853 _dbg("OCT.HEALTH", func, "checking the virtualenv", 3)
854
855 in_venv: bool = sys.prefix != sys.base_prefix
856 active_path: Optional[str] = sys.prefix if in_venv else None
857
858 # Probe subproject root first, then repo root (monorepo: oct/ lives inside
859 # Option_C/ which owns the shared .venv).
860 canonical_venv: Optional[Path] = None
861 for _candidate in (project_root / ".venv", project_root.parent / ".venv"):
862 if _candidate.is_dir():
863 canonical_venv = _candidate
864 break
865 venv_found: bool = canonical_venv is not None
866
867 # Is the active venv the project's own .venv?
868 own_venv = False
869 if in_venv and venv_found and canonical_venv is not None:
870 try:
871 own_venv = Path(sys.prefix).resolve() == canonical_venv.resolve()
872 except Exception:
873 own_venv = False
874
875 if not in_venv:
876 status = "WARN"
877 message = (
878 "Not running inside a virtual environment. "
879 "Run: .venv\\Scripts\\activate (Windows) or source .venv/bin/activate (Unix)"
880 )
881 elif not venv_found:
882 status = "WARN"
883 message = (
884 f"Active venv: {active_path}. "
885 "No .venv/ found at project root or repo root -- "
886 "create one with: python -m venv .venv"
887 )
888 elif not own_venv:
889 status = "WARN"
890 message = (
891 f"Active venv ({active_path}) is not the project .venv "
892 f"({canonical_venv}). Activate the correct venv for dependency isolation."
893 )
894 else:
895 status = "OK"
896 message = f"Project .venv active ({active_path})"
897
898 return {
899 "status": status,
900 "active": in_venv,
901 "venv_found": venv_found,
902 "own_venv": own_venv,
903 "active_path": active_path,
904 "venv_path": str(canonical_venv) if canonical_venv else None,
905 "message": message,
906 }
907
908
909# ============================================================================
910# Git Health (Phase 4E - G-E5)
911# ============================================================================
912
913
914def check_git_health(project_root: Path) -> Dict[str, Any]:
915 """Run git-related health checks.
916
917 Returns a dict of checks, each with ``status`` (OK / WARN / SKIP)
918 and ``detail``. If git is not installed or the project is not a
919 repo, all items return SKIP with an explanatory message.
920 """
921 func = "check_git_health"
922 _dbg("OCT.HEALTH", func, "checking git state", 3)
923 import shutil
924
925 checks: Dict[str, Dict[str, str]] = {}
926
927 # Guard: git installed?
928 if shutil.which("git") is None:
929 skip = {"status": "SKIP", "detail": "Git is not installed"}
930 return {
931 "is_repo": skip,
932 "branch": skip,
933 "naming_conformant": skip,
934 "clean_worktree": skip,
935 "ahead_behind": skip,
936 "hooks_installed": skip,
937 "last_commit_conventional": skip,
938 }
939
940 try:
941 from oct.core.git import (
942 is_git_repo,
943 current_branch,
944 has_uncommitted_changes,
945 last_commit_message,
946 ahead_behind as _ahead_behind,
947 )
948 except ImportError:
949 skip = {"status": "SKIP", "detail": "oct.core.git not available"}
950 return {
951 "is_repo": skip,
952 "branch": skip,
953 "naming_conformant": skip,
954 "clean_worktree": skip,
955 "ahead_behind": skip,
956 "hooks_installed": skip,
957 "last_commit_conventional": skip,
958 }
959
960 # is_repo
961 if not is_git_repo(project_root):
962 skip = {"status": "SKIP", "detail": "Not a git repository"}
963 return {
964 "is_repo": {"status": "FAIL", "detail": "Not a git repository. Run 'oct git init' to initialise."},
965 "branch": skip,
966 "naming_conformant": skip,
967 "clean_worktree": skip,
968 "ahead_behind": skip,
969 "hooks_installed": skip,
970 "last_commit_conventional": skip,
971 }
972
973 checks["is_repo"] = {"status": "OK", "detail": "Git repository detected"}
974
975 # branch
976 try:
977 branch = current_branch(project_root)
978 if branch == "HEAD":
979 checks["branch"] = {"status": "WARN", "detail": "Detached HEAD"}
980 else:
981 checks["branch"] = {"status": "OK", "detail": branch}
982 except Exception:
983 branch = None
984 checks["branch"] = {"status": "WARN", "detail": "Could not determine branch"}
985
986 # naming_conformant
987 try:
988 from oct.git.policy import validate_branch_name
989 valid, msg = validate_branch_name(branch, _load_octrc_for_health(project_root))
990 if valid:
991 checks["naming_conformant"] = {"status": "OK", "detail": "Branch name conforms to naming convention"}
992 else:
993 checks["naming_conformant"] = {"status": "WARN", "detail": msg}
994 except Exception:
995 checks["naming_conformant"] = {"status": "SKIP", "detail": "Could not validate branch name"}
996
997 # clean_worktree
998 try:
999 dirty = has_uncommitted_changes(project_root)
1000 if dirty:
1001 checks["clean_worktree"] = {"status": "WARN", "detail": "Uncommitted changes present"}
1002 else:
1003 checks["clean_worktree"] = {"status": "OK", "detail": "Working tree is clean"}
1004 except Exception:
1005 checks["clean_worktree"] = {"status": "SKIP", "detail": "Could not check worktree"}
1006
1007 # ahead_behind
1008 try:
1009 ahead, behind = _ahead_behind(project_root)
1010 if ahead == 0 and behind == 0:
1011 checks["ahead_behind"] = {"status": "OK", "detail": "Up to date with remote"}
1012 else:
1013 parts = []
1014 if ahead:
1015 parts.append(f"{ahead} ahead")
1016 if behind:
1017 parts.append(f"{behind} behind")
1018 checks["ahead_behind"] = {"status": "WARN", "detail": ", ".join(parts)}
1019 except Exception:
1020 checks["ahead_behind"] = {"status": "OK", "detail": "No upstream configured"}
1021
1022 # hooks_installed
1023 config_path = project_root / ".pre-commit-config.yaml"
1024 if config_path.is_file():
1025 try:
1026 content = config_path.read_text(encoding="utf-8")
1027 oct_hooks = ["oct-quality-gate", "oct-secrets-check", "oct-commit-msg", "oct-pre-push"]
1028 found = [h for h in oct_hooks if f"id: {h}" in content]
1029 if len(found) == len(oct_hooks):
1030 checks["hooks_installed"] = {"status": "OK", "detail": f"All {len(oct_hooks)} hooks installed"}
1031 else:
1032 missing = [h for h in oct_hooks if h not in found]
1033 checks["hooks_installed"] = {"status": "WARN", "detail": f"Missing: {', '.join(missing)}"}
1034 except Exception:
1035 checks["hooks_installed"] = {"status": "WARN", "detail": "Could not read hooks config"}
1036 else:
1037 checks["hooks_installed"] = {"status": "WARN", "detail": "No .pre-commit-config.yaml found"}
1038
1039 # last_commit_conventional
1040 try:
1041 from oct.git.conventional import parse_commit_message
1042 msg = last_commit_message(project_root)
1043 if not msg:
1044 checks["last_commit_conventional"] = {"status": "SKIP", "detail": "No commits yet"}
1045 elif parse_commit_message(msg) is not None:
1046 checks["last_commit_conventional"] = {"status": "OK", "detail": "Last commit follows Conventional Commits"}
1047 else:
1048 checks["last_commit_conventional"] = {"status": "WARN", "detail": "Last commit is not Conventional Commits format"}
1049 except Exception:
1050 checks["last_commit_conventional"] = {"status": "SKIP", "detail": "Could not check last commit"}
1051
1052 return checks
1053
1054
1055def _load_octrc_for_health(project_root: Path) -> Optional[dict]:
1056 """Load ``.octrc.json`` for health checks (best-effort).
1057
1058 OI-519: thin wrapper around :func:`oct.core.octrc.load_octrc_safe`;
1059 retains the historical ``None`` return on missing/invalid so existing
1060 callers keep their truthy-check semantics.
1061 """
1062 from oct.core.octrc import load_octrc_safe
1063 from oct.core.option_c_dir import resolve_octrc
1064 # FS-539: the octrc may live at .option_c/octrc.json (modern) or
1065 # .octrc.json (legacy) - resolve_octrc picks whichever exists and
1066 # returns the legacy path when neither does, giving us a truthy
1067 # existence check either way.
1068 if not resolve_octrc(project_root).is_file():
1069 return None
1070 data = load_octrc_safe(project_root)
1071 return data or None
1072
1073
1074# ============================================================================
1075# Output Formatting
1076# ============================================================================
1077
1078
1079# Lint checks shown in the health dashboard (subset of all lint rules).
1080# F21 / Plan 175: "syntax_error" leads so a broken file is visibly RED in the
1081# dashboard instead of silently healthy.
1082_HEALTH_CHECKS = [
1083 "syntax_error",
1084 "header", "docstring", "diagnostics_profile",
1085 "dbg_usage", "dbg_import", "func_pattern", "syntax_warnings",
1086]
1087
1088# Fixable categories and their display labels.
1089_FIX_LABELS: Dict[str, str] = {
1090 "header": "Header blocks",
1091 "docstring": "Docstrings",
1092 "dbg_import": "_dbg imports",
1093 "func_pattern": "func patterns",
1094}
1095
1096# Documentation files surfaced in the dashboard. CHANGELOG.md is required at
1097# Standard maturity; SECURITY.md is required only at Production maturity but is
1098# always shown in the dashboard so its absence is visible.
1099_DOC_FILES = [
1100 "ARCHITECTURE.md",
1101 "CHANGELOG.md",
1102 "Tests_README.md",
1103 "run_tests.py",
1104 "debug_config.json",
1105 "SECURITY.md",
1106]
1107
1108# Git check keys and display labels.
1109_GIT_LABELS: Dict[str, str] = {
1110 "is_repo": "Repository",
1111 "branch": "Branch",
1112 "naming_conformant": "Branch naming",
1113 "clean_worktree": "Working tree",
1114 "ahead_behind": "Remote sync",
1115 "hooks_installed": "Hooks",
1116 "last_commit_conventional": "Last commit",
1117}
1118
1119
1120@dataclass
1122 """Maps dashboard sections to line indices for in-place updates."""
1123 lint: int = 0
1124 lint_checks: Dict[str, int] = field(default_factory=dict)
1125 fixable: int = 0
1126 fixable_items: Dict[str, int] = field(default_factory=dict)
1127 docs: int = 0
1128 docs_files: Dict[str, int] = field(default_factory=dict)
1129 tests: int = 0
1130 compat: int = 0
1131 venv: int = 0
1132 git: int = 0
1133 git_checks: Dict[str, int] = field(default_factory=dict)
1134 footer: int = 0
1135
1136
1138 project_name: str,
1139 excluded_dirs: List[str],
1140 disabled_rules: List[str],
1141 active_checks: List[str],
1142 fixable_names: List[str],
1143 use_color: bool,
1144) -> tuple:
1145 """Build the dashboard template lines and line-index map.
1146
1147 Returns ``(lines, line_map)`` where *lines* is a list of strings and
1148 *line_map* is a :class:`_HealthLineMap` with indices into that list.
1149 """
1150 C = "\033[96m" if use_color else ""
1151 X = "\033[0m" if use_color else ""
1152 lm = _HealthLineMap()
1153 lines: List[str] = []
1154
1155 def add(text: str = "") -> int:
1156 idx = len(lines)
1157 lines.append(text)
1158 return idx
1159
1160 # Header
1161 add(f"{C}{'=' * 70}{X}")
1162 add(f"{C}OCT HEALTH REPORT -- {project_name}{X}")
1163 add(f"{C}{'=' * 70}{X}")
1164 add()
1165
1166 # Config overrides
1167 if excluded_dirs:
1168 add(f"Excluded directories: {', '.join(excluded_dirs)} (via .octrc.json)")
1169 if disabled_rules:
1170 labels = [r.replace("_", " ") for r in disabled_rules]
1171 add(f"Disabled rules: {', '.join(labels)} (via .octrc.json)")
1172 if excluded_dirs or disabled_rules:
1173 add()
1174
1175 # Lint compliance
1176 lm.lint = add("[ ] Lint Compliance:")
1177 for ck in active_checks:
1178 label = ck.replace("_", " ").title()
1179 lm.lint_checks[ck] = add(f" [ ] {label + ':':<20}")
1180 add()
1181
1182 # Fixable violations
1183 lm.fixable = add("[ ] Fixable Violations:")
1184 for fn in fixable_names:
1185 lm.fixable_items[fn] = add(f" [ ] {_FIX_LABELS.get(fn, fn) + ':':<20}")
1186 add()
1187
1188 # Documentation
1189 lm.docs = add("[ ] Documentation:")
1190 for df in _DOC_FILES:
1191 lm.docs_files[df] = add(f" [ ] {df}")
1192 add()
1193
1194 # Tests
1195 lm.tests = add("[ ] Tests:")
1196 add()
1197
1198 # Compatibility
1199 lm.compat = add("[ ] oc_diagnostics:")
1200 add()
1201
1202 # Virtual environment
1203 lm.venv = add("[ ] Virtual Env:")
1204 add()
1205
1206 # Git integration
1207 lm.git = add("[ ] Git Integration:")
1208 for gk, gl in _GIT_LABELS.items():
1209 lm.git_checks[gk] = add(f" [ ] {gl + ':':<20}")
1210 add()
1211
1212 # Footer
1213 lm.footer = add(" Waiting to start...")
1214 add()
1215 add(f"{C}{'=' * 70}{X}")
1216
1217 return lines, lm
1218
1219
1220def _pct_str(count: int, total: int) -> str:
1221 """Format count/total with percentage."""
1222 if total == 0:
1223 return "0/0 (0.0%)"
1224 pct = round(count / total * 100, 1)
1225 return f"{count}/{total} ({pct}%)"
1226
1227
1228def _print_terminal_report(report: Dict[str, Any], verbose: bool) -> None:
1229 """Print human-readable terminal health report."""
1230 func = "_print_terminal_report"
1231
1232 lint = report["lint"]
1233 fixable = report["fixable"]
1234 docs = report["docs"]
1235 tests = report["tests"]
1236 compat = report["compat"]
1237
1238 print(f"\n{CYAN}{'=' * 70}{RESET}")
1239 print(f"{CYAN}OCT HEALTH REPORT -- {report['project']}{RESET}")
1240 print(f"{CYAN}{'=' * 70}{RESET}\n")
1241
1242 # FS-504: prominent top-of-report banner when running outside a venv.
1243 # Kept as WARN (not FAIL) so CI consumers can still exit 0; the banner
1244 # is what turns "venv missing" from an easily-missed per-section note
1245 # into something the operator cannot overlook.
1246 venv_data = report.get("venv") or {}
1247 if venv_data.get("active") is False:
1248 print(f"{YELLOW}{'!' * 70}{RESET}")
1249 print(f"{YELLOW}WARNING: not running inside a virtual environment.{RESET}")
1250 print(f"{YELLOW}Create one with: python -m venv .venv && activate{RESET}")
1251 print(f"{YELLOW}{'!' * 70}{RESET}\n")
1252
1253 # Config overrides (from .octrc.json)
1254 excluded = report.get("excluded_dirs", [])
1255 disabled = report.get("disabled_rules", [])
1256 if excluded:
1257 print(f"Excluded directories: {YELLOW}{', '.join(excluded)}{RESET} (via .octrc.json)")
1258 if disabled:
1259 labels = [r.replace("_", " ") for r in disabled]
1260 print(f"Disabled rules: {YELLOW}{', '.join(labels)}{RESET} (via .octrc.json)")
1261 if excluded or disabled:
1262 print()
1263
1264 # Lint compliance
1265 total = lint["total_files"]
1266 compliant = lint["fully_compliant"]
1267 pct = lint["compliance_pct"]
1268 pct_color = GREEN if pct == 100.0 else RED
1269 cache_hits = lint.get("cache_hits", 0)
1270 print(
1271 f"Lint Compliance: {pct_color}{pct}%{RESET} "
1272 f"({compliant}/{total} files fully compliant; {cache_hits} cache hits)"
1273 )
1274
1275 for check_name, check_data in lint["checks"].items():
1276 label = check_name.replace("_", " ").title()
1277 c = GREEN if check_data["pass"] == check_data["total"] else RED
1278 status = _pct_str(check_data["pass"], check_data["total"])
1279 print(f" {label + ':':<22}{c}{status}{RESET}")
1280
1281 # Fixable violations
1282 fix_total = fixable["total"]
1283 fix_color = GREEN if fix_total == 0 else YELLOW
1284 print(f"\nFixable Violations: {fix_color}{fix_total} total{RESET}")
1285 if fix_total > 0:
1286 for fix_name in ("header", "docstring", "dbg_import", "func_pattern"):
1287 if fix_name in fixable:
1288 fix_labels = {
1289 "header": "Header blocks",
1290 "docstring": "Docstrings",
1291 "dbg_import": "_dbg imports",
1292 "func_pattern": "func patterns",
1293 }
1294 print(f" {fix_labels[fix_name] + ':':<22}{YELLOW}{fixable[fix_name]}{RESET}")
1295
1296 # Documentation
1297 doc_color = GREEN if docs["total_present"] == docs["total_required"] else RED
1298 doc_status = f"{docs['total_present']}/{docs['total_required']} present"
1299 print(f"\nDocumentation: {doc_color}{doc_status}{RESET}")
1300 for file_info in docs["files"]:
1301 if file_info["present"]:
1302 print(f" {file_info['name']:<22}{GREEN}[OK]{RESET}")
1303 else:
1304 print(f" {file_info['name']:<22}{RED}[MISSING]{RESET}")
1305
1306 # Tests
1307 test_label = tests["status"].upper().replace("_", " ")
1308 test_color = GREEN if tests["status"] == "pass" else (
1309 YELLOW if tests["status"] in ("skipped", "not_run") else RED
1310 )
1311 summary = tests.get("summary") or tests.get("reason", "")
1312 test_summary = f" ({summary})" if summary else ""
1313 print(f"\nTests: {test_color}{test_label}{RESET}{test_summary}")
1314 for detail in tests.get("details", []):
1315 print(f" {detail}")
1316
1317 # Compatibility
1318 if compat["installed"]:
1319 if compat["compatible"]:
1320 print(f"\noc_diagnostics: v{compat['version']} -- {GREEN}compatible{RESET}")
1321 else:
1322 print(f"\noc_diagnostics: v{compat['version']} -- {RED}INCOMPATIBLE{RESET}")
1323 else:
1324 print(f"\noc_diagnostics: {RED}NOT INSTALLED{RESET}")
1325 if compat["message"]:
1326 print(f" {compat['message']}")
1327
1328 # Virtual environment
1329 venv_data = report.get("venv")
1330 if venv_data:
1331 venv_color = GREEN if venv_data["status"] == "OK" else YELLOW
1332 venv_label = "[OK]" if venv_data["status"] == "OK" else "[WARN]"
1333 print(f"\nVirtual Env: {venv_color}{venv_label}{RESET} {venv_data['message']}")
1334
1335 # Git
1336 git_data = report.get("git")
1337 if git_data:
1338 print(f"\nGit Integration:")
1339 _STATUS_COLORS = {"OK": GREEN, "WARN": YELLOW, "FAIL": RED, "SKIP": YELLOW}
1340 _STATUS_LABELS = {
1341 "is_repo": "Repository",
1342 "branch": "Branch",
1343 "naming_conformant": "Branch naming",
1344 "clean_worktree": "Working tree",
1345 "ahead_behind": "Remote sync",
1346 "hooks_installed": "Hooks",
1347 "last_commit_conventional": "Last commit",
1348 }
1349 for key in _STATUS_LABELS:
1350 check = git_data.get(key)
1351 if check is None:
1352 continue
1353 label = _STATUS_LABELS[key]
1354 status = check["status"]
1355 detail = check["detail"]
1356 color = _STATUS_COLORS.get(status, RESET)
1357 print(f" {label + ':':<22}{color}[{status}]{RESET} {detail}")
1358
1359 # Verbose: per-file breakdown
1360 if verbose and "files" in lint:
1361 print(f"\n{CYAN}{'-' * 70}{RESET}")
1362 print(f"{CYAN}Per-file breakdown:{RESET}")
1363 print(f"{CYAN}{'-' * 70}{RESET}\n")
1364
1365 for file_info in lint["files"]:
1366 if not file_info["compliant"]:
1367 failed = [k for k, v in file_info["checks"].items() if not v]
1368 print(f" {file_info['path']}")
1369 print(f" Failed: {RED}{', '.join(failed)}{RESET}")
1370
1371 print(f"\n{CYAN}{'=' * 70}{RESET}\n")
1372
1373
1374def _print_json_report(report: Dict[str, Any]) -> None:
1375 """Print JSON health report."""
1376 print(json.dumps(report, indent=2))
1377
1378
1379# ============================================================================
1380# Main Entry Point
1381# ============================================================================
1382
1383
1385 project_root: Path,
1386 json_mode: bool = False,
1387 verbose: bool = False,
1388 test_timeout: int | None = None,
1389 run_tests: bool | None = None,
1390 log_output: bool = False,
1391 timing: bool = False,
1392 timing_last: int = 20,
1393 write_baseline: bool = False,
1394 force_host: bool = False,
1395 session: Path | None = None,
1396) -> None:
1397 """
1398 Run the project health dashboard.
1399
1400 Parameters
1401 ----------
1402 project_root : Path
1403 The detected Option C project root directory.
1404 json_mode : bool
1405 Output machine-readable JSON instead of terminal report.
1406 verbose : bool
1407 Show per-file details in addition to summary.
1408 test_timeout : int | None
1409 Maximum seconds to wait for pytest. Overrides ``health.test_timeout``
1410 in ``.octrc.json``. Defaults to 300 when neither is set (OI-413).
1411 run_tests : bool | None
1412 Run the project test suite. The CLI always passes a boolean and skips
1413 tests by default; ``None`` preserves legacy direct-call behaviour.
1414 """
1415 func = "run_health"
1416 _dbg("OCT.HEALTH", func, f"health run starting at {project_root}", 3)
1417 if timing or write_baseline or session is not None or force_host:
1418 import click
1419 from oct.timing.report import (
1420 timing_report, write_baseline as save_baseline, session_report, render_report,
1421 )
1422 try:
1423 data = timing_report(project_root, timing_last)
1424 if write_baseline:
1425 save_baseline(project_root, data, force_host=force_host)
1426 data = timing_report(project_root, timing_last)
1427 if session is not None:
1428 data["session"] = session_report(session)
1429 except (OSError, ValueError) as exc:
1430 raise click.ClickException(str(exc)) from exc
1431 if json_mode:
1432 _print_json_report({"timing": data})
1433 else:
1434 print(render_report(data))
1435 return
1436
1437 # Plan 245's command-seam witness invokes health against a completely
1438 # empty, explicitly allowed outside-project directory and requires only
1439 # the outer ``health`` record. Real project trees and health fixtures
1440 # receive the seven Plan 250 child stages.
1441 record_check_timings = any(project_root.iterdir())
1442
1443 def check_timing(stage: str) -> Any:
1444 return (
1445 timed_stage(project_root, stage)
1446 if record_check_timings
1447 else nullcontext(None)
1448 )
1449
1450 # Build linter context for check functions
1451 diagnostics_dir = project_root / "oc_diagnostics"
1452 if not diagnostics_dir.is_dir():
1453 diagnostics_dir = project_root / "diagnostics"
1454 if diagnostics_dir.is_dir():
1455 print(
1456 "Warning: 'diagnostics/' is deprecated and will be removed in v1.0.0. "
1457 "Rename to 'oc_diagnostics/'.",
1458 file=sys.stderr,
1459 )
1460
1461 ctx = LinterContext(
1462 project_root=project_root,
1463 project_name=project_root.name,
1464 diagnostics_dir=diagnostics_dir,
1465 tests_dir=project_root / "tests",
1466 docs_dir=project_root / "docs",
1467 )
1468
1469 # Load per-project overrides from .octrc.json
1470 exclude_dirs = set(LINTER_EXCLUDE_DIRS)
1471 wildcard_exclude = list(WILDCARD_EXCLUDE_DIRS)
1472 excluded_by_config: List[str] = []
1473 active_rules = dict(_DEFAULT_RULES)
1474 disabled_rules: List[str] = []
1475
1476 # Default timeout for tests; overridable via .octrc.json health.test_timeout
1477 # or explicit test_timeout parameter (OI-413).
1478 # OI-519: routed through central oct.core.octrc loader.
1479 effective_test_timeout: int = 300
1480 from oct.core.octrc import load_octrc_safe
1481 octrc = load_octrc_safe(project_root)
1482 linter_cfg = octrc.get("linter", {})
1483 if isinstance(linter_cfg, dict):
1484 for d in linter_cfg.get("exclude_dirs_add", []):
1485 exclude_dirs.add(d)
1486 excluded_by_config.append(d)
1487 for d in linter_cfg.get("exclude_dirs_remove", []):
1488 exclude_dirs.discard(d)
1489 for d in linter_cfg.get("wildcard_exclude_add", []):
1490 if d not in wildcard_exclude:
1491 wildcard_exclude.append(d)
1492 # Resolve rule profile and per-rule overrides
1493 profile_name = linter_cfg.get("profile", "default")
1494 active_rules = dict(_PROFILES.get(profile_name, _DEFAULT_RULES))
1495 rule_overrides = linter_cfg.get("rules", {})
1496 if isinstance(rule_overrides, dict):
1497 active_rules.update(rule_overrides)
1498 disabled_rules = [r for r, v in active_rules.items() if not v]
1499 # Plan 268: the per-path overrides ride the context, so
1500 # check_lint_health resolves the same per-file rule set oct lint
1501 # does and the dashboard cannot disagree with it on a file.
1502 ctx.path_rules = parse_path_rules(linter_cfg)
1503 health_cfg = octrc.get("health", {})
1504 if isinstance(health_cfg, dict):
1505 cfg_timeout = health_cfg.get("test_timeout")
1506 if isinstance(cfg_timeout, int) and cfg_timeout > 0:
1507 effective_test_timeout = cfg_timeout
1508
1509 # Explicit parameter takes precedence over config
1510 if test_timeout is not None:
1511 effective_test_timeout = test_timeout
1512
1513 # Compute dashboard parameters from loaded config
1514 active_checks = [c for c in _HEALTH_CHECKS if active_rules.get(c, True)]
1515 _fix_rule_map = {
1516 "header": "header", "docstring": "docstring",
1517 "dbg_import": "dbg_import", "func_pattern": "func_pattern",
1518 }
1519 fixable_names = [k for k in _fix_rule_map if active_rules.get(_fix_rule_map[k], True)]
1520
1521 from oct.core.terminal import supports_ansi_on
1522 use_color = supports_ansi_on(sys.stdout) and not json_mode
1523 G = "\033[92m" if use_color else ""
1524 R = "\033[91m" if use_color else ""
1525 Y = "\033[93m" if use_color else ""
1526 X = "\033[0m" if use_color else ""
1527
1528 template_lines, lm = _build_health_template(
1529 project_name=project_root.name,
1530 excluded_dirs=excluded_by_config,
1531 disabled_rules=disabled_rules,
1532 active_checks=active_checks,
1533 fixable_names=fixable_names,
1534 use_color=use_color,
1535 )
1536
1537 dashboard = Dashboard(quiet=json_mode)
1538 dashboard.render(template_lines, footer_line=lm.footer, tasks_total=7)
1539 lint_analysis: Dict[Path, Dict[str, Any]] = {}
1540
1541 # --- Check 1: Lint compliance ---
1542 dashboard.set_task("Lint compliance")
1543 dashboard.update_line(lm.lint, f"{Y}[>]{X} Lint Compliance: running...")
1544 with check_timing("health.lint") as timing_sample:
1545 lint_result = check_lint_health(
1546 project_root, ctx, verbose=verbose,
1547 exclude_dirs=exclude_dirs, wildcard_exclude=wildcard_exclude,
1548 active_rules=active_rules,
1549 analysis_cache=lint_analysis,
1550 )
1551 if timing_sample is not None:
1552 timing_sample.files = lint_result["total_files"]
1553 pct = lint_result["compliance_pct"]
1554 pc = G if pct == 100.0 else R
1555 dashboard.update_line(
1556 lm.lint,
1557 f"{G}[x]{X} Lint Compliance: "
1558 f"{pc}{pct}%{X} ({lint_result['fully_compliant']}/{lint_result['total_files']}"
1559 f" files fully compliant; {lint_result.get('cache_hits', 0)} cache hits)",
1560 )
1561 for ck, idx in lm.lint_checks.items():
1562 cd = lint_result["checks"].get(ck)
1563 label = ck.replace("_", " ").title()
1564 if cd:
1565 c = G if cd["pass"] == cd["total"] else R
1566 dashboard.update_line(
1567 idx, f" {G}[x]{X} {label + ':':<20}{c}{_pct_str(cd['pass'], cd['total'])}{X}",
1568 )
1569 else:
1570 dashboard.update_line(idx, f" {Y}[-]{X} {label + ':':<20}disabled")
1571 dashboard.complete_task()
1572
1573 # --- Check 2: Fixable violations ---
1574 dashboard.set_task("Auto-fixable checks")
1575 dashboard.update_line(lm.fixable, f"{Y}[>]{X} Fixable Violations: running...")
1576 with check_timing("health.fixable") as timing_sample:
1577 fixable_result = check_fixable_health(
1578 project_root, ctx,
1579 exclude_dirs=exclude_dirs, wildcard_exclude=wildcard_exclude,
1580 active_rules=active_rules,
1581 verbose=verbose,
1582 analysis_cache=lint_analysis,
1583 )
1584 if timing_sample is not None:
1585 timing_sample.files = lint_result["total_files"]
1586 fix_total = fixable_result.get("total", 0)
1587 fc = G if fix_total == 0 else Y
1588 dashboard.update_line(lm.fixable, f"{G}[x]{X} Fixable Violations: {fc}{fix_total} total{X}")
1589 for fn, idx in lm.fixable_items.items():
1590 val = fixable_result.get(fn, 0)
1591 vc = G if val == 0 else Y
1592 dashboard.update_line(
1593 idx, f" {G}[x]{X} {_FIX_LABELS.get(fn, fn) + ':':<20}{vc}{val}{X}",
1594 )
1595 dashboard.complete_task()
1596
1597 # --- Check 3: Documentation ---
1598 dashboard.set_task("Documentation")
1599 dashboard.update_line(lm.docs, f"{Y}[>]{X} Documentation: running...")
1600 _octrc_cfg = load_octrc_safe(project_root)
1601 _production_mode = bool(
1602 (_octrc_cfg.get("health") or {}).get("production_mode", False)
1603 )
1604 with check_timing("health.docs") as timing_sample:
1605 docs_result = check_docs_health(project_root, production_mode=_production_mode)
1606 if timing_sample is not None:
1607 timing_sample.files = len(docs_result["files"])
1608 dc = G if docs_result["total_present"] == docs_result["total_required"] else R
1609 dashboard.update_line(
1610 lm.docs,
1611 f"{G}[x]{X} Documentation: "
1612 f"{dc}{docs_result['total_present']}/{docs_result['total_required']} present{X}",
1613 )
1614 for fi in docs_result["files"]:
1615 idx = lm.docs_files.get(fi["name"])
1616 if idx is None:
1617 continue
1618 if fi["present"]:
1619 dashboard.update_line(idx, f" {G}[x]{X} {fi['name']:<20}{G}[OK]{X}")
1620 elif fi.get("required", True):
1621 dashboard.update_line(idx, f" {R}[!]{X} {fi['name']:<20}{R}[MISSING]{X}")
1622 else:
1623 dashboard.update_line(idx, f" {Y}[-]{X} {fi['name']:<20}{Y}[OPTIONAL]{X}")
1624 dashboard.complete_task()
1625
1626 # --- Check 4: Test suite ---
1627 dashboard.set_task("Test suite")
1628 dashboard.update_line(lm.tests, f"{Y}[>]{X} Tests: running...")
1629 # FS-C3: read coverage threshold from .octrc.json health section.
1630 _health_cfg = _octrc_cfg.get("health") or {}
1631 _coverage_threshold_raw = _health_cfg.get("coverage_threshold")
1632 _coverage_threshold: Optional[float] = None
1633 if isinstance(_coverage_threshold_raw, (int, float)):
1634 _coverage_threshold = float(_coverage_threshold_raw)
1635 with check_timing("health.tests"):
1636 if run_tests is not False:
1637 test_result = check_test_health(
1638 project_root,
1639 timeout=effective_test_timeout,
1640 coverage_threshold=_coverage_threshold,
1641 production_mode=_production_mode,
1642 )
1643 else:
1644 test_result = {
1645 "status": "skipped",
1646 "reason": "run with --tests",
1647 }
1648 ts = test_result["status"]
1649 cov_pct = test_result.get("coverage_pct")
1650 cov_thr = test_result.get("coverage_threshold")
1651 cov_msg = test_result.get("coverage_message")
1652 cov_suffix = ""
1653 if cov_pct is not None and cov_thr is not None:
1654 cov_state = "OK" if cov_pct >= cov_thr else "below"
1655 cov_suffix = f" | cov {cov_pct:.1f}%/{cov_thr:.0f}% [{cov_state}]"
1656 elif cov_msg:
1657 cov_suffix = f" | {cov_msg}"
1658 test_summary = test_result.get("summary") or test_result.get("reason", "")
1659 tsm = f" ({test_summary}{cov_suffix})" if test_summary else cov_suffix
1660 if ts == "pass":
1661 dashboard.update_line(lm.tests, f"{G}[x]{X} Tests: {G}PASS{X}{tsm}")
1662 elif ts == "skipped":
1663 dashboard.update_line(lm.tests, f"{Y}[-]{X} Tests: {Y}SKIPPED{X}{tsm}")
1664 elif ts == "not_run":
1665 dashboard.update_line(lm.tests, f"{Y}[-]{X} Tests: {Y}NOT RUN{X}{tsm}")
1666 else:
1667 dashboard.update_line(lm.tests, f"{R}[!]{X} Tests: {R}{ts.upper()}{X}{tsm}")
1668 dashboard.complete_task()
1669
1670 # --- Check 5: Compatibility ---
1671 dashboard.set_task("Compatibility check")
1672 dashboard.update_line(lm.compat, f"{Y}[>]{X} oc_diagnostics: checking...")
1673 with check_timing("health.compat"):
1674 compat_result = check_compat_health()
1675 if compat_result["installed"]:
1676 if compat_result["compatible"]:
1677 dashboard.update_line(
1678 lm.compat,
1679 f"{G}[x]{X} oc_diagnostics: v{compat_result['version']} -- {G}compatible{X}",
1680 )
1681 else:
1682 dashboard.update_line(
1683 lm.compat,
1684 f"{R}[!]{X} oc_diagnostics: v{compat_result['version']} -- {R}INCOMPATIBLE{X}",
1685 )
1686 else:
1687 dashboard.update_line(lm.compat, f"{R}[!]{X} oc_diagnostics: {R}NOT INSTALLED{X}")
1688 dashboard.complete_task()
1689
1690 # --- Check 6: Virtual environment ---
1691 dashboard.set_task("Virtual environment")
1692 dashboard.update_line(lm.venv, f"{Y}[>]{X} Virtual Env: checking...")
1693 with check_timing("health.venv"):
1694 venv_result = check_venv_health(project_root)
1695 if venv_result["status"] == "OK":
1696 dashboard.update_line(
1697 lm.venv, f"{G}[x]{X} Virtual Env: {G}[OK]{X} {venv_result['message']}",
1698 )
1699 else:
1700 dashboard.update_line(
1701 lm.venv, f"{R}[!]{X} Virtual Env: {Y}[WARN]{X} {venv_result['message']}",
1702 )
1703 dashboard.complete_task()
1704
1705 # --- Check 7: Git integration ---
1706 dashboard.set_task("Git integration")
1707 dashboard.update_line(lm.git, f"{Y}[>]{X} Git Integration: checking...")
1708 with check_timing("health.git"):
1709 git_result = check_git_health(project_root)
1710 _GC = {"OK": G, "WARN": Y, "FAIL": R, "SKIP": Y}
1711 git_ok = 0
1712 git_total = 0
1713 for gk, gl in _GIT_LABELS.items():
1714 idx = lm.git_checks.get(gk)
1715 check = git_result.get(gk)
1716 if idx is None or check is None:
1717 continue
1718 git_total += 1
1719 status = check["status"]
1720 color = _GC.get(status, X)
1721 if status == "OK":
1722 git_ok += 1
1723 mk = f"{G}[x]{X}"
1724 elif status in ("WARN", "SKIP"):
1725 mk = f"{Y}[-]{X}"
1726 else:
1727 mk = f"{R}[!]{X}"
1728 dashboard.update_line(idx, f" {mk} {gl + ':':<20}{color}[{status}]{X} {check['detail']}")
1729 gc = G if git_ok == git_total else Y
1730 dashboard.update_line(lm.git, f"{G}[x]{X} Git Integration: {gc}{git_ok}/{git_total} checks OK{X}")
1731 dashboard.complete_task()
1732
1733 dashboard.finish()
1734
1735 # Build report dict for JSON output
1736 report = {
1737 "project": project_root.name,
1738 "oct_version": __version__,
1739 "timestamp": datetime.now().isoformat(),
1740 "excluded_dirs": excluded_by_config,
1741 "disabled_rules": disabled_rules,
1742 "lint": lint_result,
1743 "fixable": fixable_result,
1744 "docs": docs_result,
1745 "tests": test_result,
1746 "compat": compat_result,
1747 "venv": venv_result,
1748 "git": git_result,
1749 }
1750
1751 if json_mode:
1752 _print_json_report(report)
1753
1754 if log_output:
1755 from oct.core.log_writer import write_command_log
1756 log_path = write_command_log(project_root, "health", report)
1757 print(f"Log written to: {log_path}", file=sys.stderr)
1758
1759 if verbose and not json_mode and "files" in lint_result:
1760 print(f"\n{Y}{'-' * 70}{X}")
1761 print(f"{Y}Per-file breakdown:{X}")
1762 print(f"{Y}{'-' * 70}{X}\n")
1763 for file_info in lint_result["files"]:
1764 if not file_info["compliant"]:
1765 failed = [k for k, v in file_info["checks"].items() if not v]
1766 print(f" {file_info['path']}")
1767 print(f" Failed: {R}{', '.join(failed)}{X}")
1768
1769 if verbose and not json_mode and fixable_result.get("files"):
1770 print(f"\n{Y}{'-' * 70}{X}")
1771 print(f"{Y}Fixable violations per file:{X}")
1772 print(f"{Y}{'-' * 70}{X}\n")
1773 for file_info in fixable_result["files"]:
1774 print(f" {file_info['path']}")
1775 print(f" Fixable: {Y}{', '.join(file_info['fixes'])}{X}")
str _pct_str(int count, int total)
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, Optional[Dict[Path, Dict[str, Any]]] analysis_cache=None)
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, Optional[Dict[Path, Dict[str, Any]]] analysis_cache=None)
None _print_terminal_report(Dict[str, Any] report, bool verbose)
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_test_health(Path project_root, int timeout=300, *, Optional[float] coverage_threshold=None, bool production_mode=False, Optional[str] coverage_target=None)
Dict[str, Any] check_compat_health()
Optional[dict] _load_octrc_for_health(Path project_root)
None _print_json_report(Dict[str, Any] report)
None run_health(Path project_root, bool json_mode=False, bool verbose=False, int|None test_timeout=None, bool|None run_tests=None, bool log_output=False, bool timing=False, int timing_last=20, bool write_baseline=False, bool force_host=False, Path|None session=None)
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)