From 49774cdf3662c135bad2eb1c47b9f8408b960cf1 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Tue, 25 Aug 2026 22:20:12 +0300 Subject: [PATCH 1/6] WIP: start issue 95 doctor command (checkpoint; tests failing) --- tests/test_doctor.py | 122 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 tests/test_doctor.py diff --git a/tests/test_doctor.py b/tests/test_doctor.py new file mode 100644 index 0000000..02929ae --- /dev/null +++ b/tests/test_doctor.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from io import StringIO +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from agent_code_guard import code_guard, doctor + + +LANGUAGES = [ + "python", "go", "kotlin", "csharp", "java", "javascript", "typescript", + "tsx", "cpp", "rust", "php", "swift", "dart", "vue", +] + + +class DoctorTests(unittest.TestCase): + def run_main(self, *arguments: str) -> tuple[int, str, str]: + stdout = StringIO() + stderr = StringIO() + with ( + patch.object(sys, "argv", ["code-guard", *arguments]), + redirect_stdout(stdout), + redirect_stderr(stderr), + ): + result = code_guard.main() + return result, stdout.getvalue(), stderr.getvalue() + + def healthy_report(self, root: Path) -> dict[str, object]: + launcher = root / "code-guard" + launcher.touch() + skill = root / "skill" + skill.mkdir() + distribution = SimpleNamespace( + version="1.2.3", + entry_points=[SimpleNamespace(group="console_scripts", name="code-guard", value="agent_code_guard.code_guard:main")], + ) + provider = Mock() + provider.parse.return_value = object() + with ( + patch.object(sys, "argv", [str(launcher), "doctor"]), + patch.object(doctor.metadata, "distribution", return_value=distribution), + patch.object(doctor.metadata, "version", side_effect=["0.26.0", "1.14.3"]), + patch.object(doctor, "installed_skill_path", return_value=skill), + patch.object(doctor, "TreeSitterProvider", return_value=provider), + patch.object(doctor.shutil, "which", return_value=str(root / "git.exe")) as which, + patch.object(doctor.subprocess, "run", return_value=SimpleNamespace(returncode=0, stdout=str(root), stderr="")), + patch.object(Path, "cwd", return_value=root), + ): + report = doctor.gather_report() + which.assert_called_once_with("git") + self.assertEqual([call.args for call in provider.parse.call_args_list], [(language, b"") for language in LANGUAGES]) + return report + + def test_healthy_human_and_json_are_exact_ordered_stdout_only_reports(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + report = self.healthy_report(root) + self.assertEqual( + list(report), + ["schemaVersion", "status", "distribution", "python", "entryPoint", "skill", "configuration", "git", "providers"], + ) + self.assertEqual(report["status"], "healthy") + self.assertEqual([item["name"] for item in report["providers"]["languages"]], LANGUAGES) + with patch.object(code_guard, "gather_doctor_report", return_value=report): + human = self.run_main("doctor") + machine = self.run_main("doctor", "--json") + self.assertEqual(human[0], 0) + self.assertEqual(human[2], "") + self.assertEqual(human[1], doctor.format_human(report) + "\n") + self.assertEqual(machine[0], 0) + self.assertEqual(machine[2], "") + self.assertEqual(json.loads(machine[1]), report) + + def test_partial_provider_failure_is_safe_unhealthy_and_does_not_stop_checks(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + provider = Mock() + provider.parse.side_effect = [RuntimeError("private token=secret"), *([object()] * 13)] + with ( + patch.object(sys, "argv", [str(root / "missing-launcher"), "doctor"]), + patch.object(doctor.metadata, "distribution", side_effect=OSError("private metadata")), + patch.object(doctor.metadata, "version", side_effect=["0.26.0", "1.14.3"]), + patch.object(doctor, "installed_skill_path", side_effect=ValueError("private skill")), + patch.object(doctor, "TreeSitterProvider", return_value=provider), + patch.object(doctor.shutil, "which", return_value=str(root / "git")), + patch.object(doctor.subprocess, "run", return_value=SimpleNamespace(returncode=1, stdout="", stderr="fatal")) as git, + patch.object(Path, "cwd", return_value=root), + ): + report = doctor.gather_report() + self.assertEqual(report["status"], "unhealthy") + self.assertEqual(report["providers"]["languages"][0]["status"], "unavailable") + self.assertEqual(report["providers"]["languages"][-1]["status"], "ok") + self.assertNotIn("secret", json.dumps(report)) + self.assertEqual(provider.parse.call_count, 14) + git.assert_called_once() + with patch.object(code_guard, "gather_doctor_report", return_value=report): + self.assertEqual(self.run_main("doctor", "--json")[0], 1) + + def test_reservation_rejection_and_early_dispatch_preserve_analysis_paths(self) -> None: + healthy = {"status": "healthy"} + forbidden = ["validate_configuration", "resolve_scope", "run_analysis", "installed_skill_path", "export_skill"] + mocks = [patch.object(code_guard, name).start() for name in forbidden] + self.addCleanup(lambda: [patch.stopall()]) + with patch.object(code_guard, "gather_doctor_report", return_value=healthy): + self.assertEqual(self.run_main("doctor")[0], 0) + for mocked in mocks: + mocked.assert_not_called() + + with patch.object(code_guard, "resolve_scope", side_effect=RuntimeError("analysis entered")): + self.assertEqual(self.run_main("./doctor")[0], 3) + self.assertEqual(self.run_main("doctor", "extra.py")[0], 3) + self.assertEqual(self.run_main("doctor", "--config", "config.json")[0], 3) + + +if __name__ == "__main__": + unittest.main() From 182e0e1fa8b39fe4056b287a9144086d6268a320 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Tue, 25 Aug 2026 22:23:28 +0300 Subject: [PATCH 2/6] WIP: implement issue 95 doctor diagnostics (checkpoint) --- CHANGELOG.md | 2 + docs/skill-distribution.md | 6 + docs/usage.md | 26 +++ src/agent_code_guard/code_guard.py | 55 ++++- src/agent_code_guard/doctor.py | 309 +++++++++++++++++++++++++++++ tests/test_doctor.py | 11 +- 6 files changed, 406 insertions(+), 3 deletions(-) create mode 100644 src/agent_code_guard/doctor.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 244ebc0..d6030da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Notable changes to Agent Code Guard are recorded here. ### Added +- Read-only `code-guard doctor` human and JSON diagnostics for the active + installation, bundled skill, configuration, Git context, and parser providers. - Compact and explicit debug completed-analysis JSON serialization modes while preserving bare `--json` compatibility. - Deterministic `code-guard --version` reporting from installed distribution diff --git a/docs/skill-distribution.md b/docs/skill-distribution.md index c9bdf2d..ec37b26 100644 --- a/docs/skill-distribution.md +++ b/docs/skill-distribution.md @@ -31,6 +31,12 @@ copies only `SKILL.md`, `LICENSE.txt`, `agents/openai.yaml`, and the policy file under `references/`; the checkout-only `scripts/code_guard.py` compatibility runner is deliberately excluded. +For read-only troubleshooting, `code-guard doctor` (or `code-guard doctor +--json`) validates the canonical bundled payload alongside the active runtime +and providers without exporting or updating it. Healthy diagnostics exit `0` +and completed unhealthy diagnostics exit `1`. Reports contain resolved paths +and environment details that may be sensitive when shared. + Direct `--skill-path` use is intrinsically version-coupled to the installed Python distribution. An export is a snapshot and contains a generated `.agent-code-guard-version` marker with the producing distribution version. diff --git a/docs/usage.md b/docs/usage.md index f19ae36..e10c8d2 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -64,6 +64,32 @@ use an `error` object on standard output. If distribution metadata is unavailable, the error is `installed distribution metadata is unavailable for agent-code-guard`. +### Installation diagnostics + +Inspect the active installation and its immediate local capabilities without +running project analysis: + +```bash +code-guard doctor +code-guard doctor --json +``` + +The human and JSON reports cover the active distribution, Python process, +invoked entry point, bundled skill, current-directory configuration and Git +context, and all supported parser providers. A healthy report exits `0`; a +completed unhealthy report exits `1`; invocation or internal failures that +prevent a report exit `3`. Completed reports use standard output only. + +`doctor` is reserved only as the exact first token. Analyze a file or directory +with that name through a qualified spelling such as `./doctor`, `.\doctor`, or +an absolute path. Doctor is read-only: it does not analyze source, repair or +install dependencies, export skills, modify configuration or Git state, access +the network, or persist diagnostics. + +Diagnostic output includes resolved launcher, interpreter, skill, Git, and +configuration paths. Treat those paths and other environment details as +potentially sensitive before sharing a report. + ### Git changed-only ```bash diff --git a/src/agent_code_guard/code_guard.py b/src/agent_code_guard/code_guard.py index bea5614..8e178aa 100644 --- a/src/agent_code_guard/code_guard.py +++ b/src/agent_code_guard/code_guard.py @@ -36,7 +36,13 @@ def parser() -> argparse.ArgumentParser: prog="code-guard", description="Run deterministic Code Guard checks.", formatter_class=argparse.RawDescriptionHelpFormatter, - epilog="""Version reporting: + epilog="""Diagnostics: + code-guard doctor + code-guard doctor --json + Inspect the active installation and current directory without running analysis. + Healthy reports exit 0; unhealthy reports exit 1; invocation/internal errors exit 3. + +Version reporting: code-guard --version Output: agent-code-guard code-guard --version --json @@ -47,7 +53,7 @@ def parser() -> argparse.ArgumentParser: ) value.add_argument( "paths", nargs="*", default=[], - help="Files or directories to inspect; bounds files selected by a Git selection mode.", + help="Files or directories to inspect; exact first token 'doctor' selects diagnostics mode.", ) value.add_argument("--config", help="Path to code-guard.config.json.") value.add_argument("--warn", type=int, help="Override the global LOC warning threshold.") @@ -84,6 +90,38 @@ def parser() -> argparse.ArgumentParser: return value +def _doctor_mode(args: argparse.Namespace, raw_arguments: list[str]) -> int | None: + if not raw_arguments or raw_arguments[0] != "doctor": + return None + incompatible = ( + args.paths != ["doctor"] + or args.config is not None + or args.warn is not None + or args.fail is not None + or args.version + or args.json_mode is not None + or args.ci + or args.changed_only + or args.staged + or args.base_ref is not None + or bool(args.include) + or bool(args.exclude) + or bool(args.scope_exclude) + or args.count_blank_lines + or args.ignore_comment_lines + or args.skill_path + or args.export_skill is not None + ) + if incompatible: + raise ValueError("doctor may be combined only with --json") + report = gather_doctor_report() + if args.json: + print(json.dumps(report, indent=2)) + else: + print(format_doctor_report(report)) + return 0 if report["status"] == "healthy" else 1 + + def _version_mode(args: argparse.Namespace) -> int | None: if not args.version: return None @@ -347,6 +385,9 @@ def main() -> int: try: if args.json_mode is not None and not args.json: raise ValueError("--json-mode requires --json") + doctor_result = _doctor_mode(args, raw_arguments) + if doctor_result is not None: + return doctor_result version_result = _version_mode(args) if version_result is not None: return version_result @@ -365,5 +406,15 @@ def main() -> int: return _print_tool_error(str(exc), args.json) +def gather_doctor_report() -> dict[str, object]: + from .doctor import gather_report + return gather_report() + + +def format_doctor_report(report: dict[str, object]) -> str: + from .doctor import format_human + return format_human(report) + + if __name__ == "__main__": sys.exit(main()) diff --git a/src/agent_code_guard/doctor.py b/src/agent_code_guard/doctor.py new file mode 100644 index 0000000..b391cc4 --- /dev/null +++ b/src/agent_code_guard/doctor.py @@ -0,0 +1,309 @@ +"""Read-only diagnostics for the active Code Guard process and working directory.""" + +from __future__ import annotations + +import argparse +from importlib import metadata +import platform +import shutil +import subprocess +import sys +from pathlib import Path + +from .analysis.provider import TreeSitterProvider +from .config_validation import validate_configuration +from .guards import callable_size, complexity, loc, markdown_document_size, markdown_section_size, nesting +from .skill_distribution import skill_path as installed_skill_path + +DISTRIBUTION_NAME = "agent-code-guard" +ENTRY_POINT_TARGET = "agent_code_guard.code_guard:main" +PROVIDER_DISTRIBUTIONS = ("tree-sitter", "tree-sitter-language-pack") +PROVIDER_LANGUAGES = ( + "python", "go", "kotlin", "csharp", "java", "javascript", "typescript", + "tsx", "cpp", "rust", "php", "swift", "dart", "vue", +) + + +def _failure(message: str, status: str = "unavailable") -> dict[str, object]: + return {"status": status, "message": message} + + +def _distribution() -> tuple[dict[str, object], object | None]: + try: + package = metadata.distribution(DISTRIBUTION_NAME) + installed_version = package.version + if not isinstance(installed_version, str): + raise TypeError + return { + "name": DISTRIBUTION_NAME, "version": installed_version, + "status": "ok", "message": None, + }, package + except Exception: + return { + "name": DISTRIBUTION_NAME, "version": None, + **_failure(f"installed distribution metadata is unavailable for {DISTRIBUTION_NAME}"), + }, None + + +def _python() -> dict[str, object]: + try: + return { + "implementation": platform.python_implementation(), + "version": platform.python_version(), + "executable": str(Path(sys.executable).resolve()), + "status": "ok", "message": None, + } + except Exception: + return { + "implementation": platform.python_implementation(), + "version": platform.python_version(), + "executable": str(sys.executable), + **_failure("running Python executable could not be resolved"), + } + + +def _entry_point(package: object | None) -> dict[str, object]: + invoked = sys.argv[0] + resolved = None + try: + candidate = Path(invoked) + if candidate.exists(): + resolved = candidate.resolve() + except (OSError, RuntimeError): + pass + + kind = "other" + if resolved is not None: + normalized = resolved.as_posix().lower() + if normalized.endswith("/skills/code-guard/scripts/code_guard.py"): + kind = "checkout-compatibility-runner" + elif resolved.stem.lower() in {"code-guard", "code_guard"}: + kind = "console-script" + + owns_entry_point = False + if package is not None: + try: + owns_entry_point = any( + point.group == "console_scripts" + and point.name == "code-guard" + and point.value == ENTRY_POINT_TARGET + for point in package.entry_points + ) + except Exception: + owns_entry_point = False + + status = "ok" + message = None + if not owns_entry_point: + status = "unavailable" + message = "active distribution does not declare the expected code-guard entry point" + elif resolved is None: + status = "unavailable" + message = "invoked launcher could not be resolved directly" + elif kind == "other": + status = "unavailable" + message = "invoked launcher is not a recognized Code Guard launcher" + return { + "name": "code-guard", "target": ENTRY_POINT_TARGET, "invoked": invoked, + "resolvedPath": str(resolved) if resolved is not None else None, + "kind": kind, "status": status, "message": message, + } + + +def _skill() -> dict[str, object]: + try: + path = installed_skill_path() + return {"available": True, "path": str(path.resolve()), "status": "ok", "message": None} + except Exception: + return { + "available": False, "path": None, + **_failure("bundled Code Guard skill payload is unavailable or invalid"), + } + + +def _configuration(cwd: Path) -> dict[str, object]: + path = cwd / ".agent-tools" / "code-guard.config.json" + try: + exists = path.exists() + except (OSError, RuntimeError): + return { + "mode": "file", "path": str(path.absolute()), "valid": False, + **_failure("configuration file could not be inspected"), + } + if not exists: + return {"mode": "defaults", "path": None, "valid": True, "status": "ok", "message": None} + resolved = path.resolve() + args = argparse.Namespace( + config=str(resolved), warn=None, fail=None, include=[], exclude=[], + count_blank_lines=False, ignore_comment_lines=False, + ) + try: + validate_configuration(str(resolved), cwd) + for loader in ( + loc.load_config, callable_size.load_config, nesting.load_config, + complexity.load_config, markdown_document_size.load_config, + markdown_section_size.load_config, + ): + loader(args) + return {"mode": "file", "path": str(resolved), "valid": True, "status": "ok", "message": None} + except (OSError, UnicodeError): + return { + "mode": "file", "path": str(resolved), "valid": False, + **_failure("configuration file could not be read"), + } + except Exception: + return { + "mode": "file", "path": str(resolved), "valid": False, + **_failure("configuration file is malformed or unsupported", "invalid"), + } + + +def _git(cwd: Path) -> dict[str, object]: + executable = shutil.which("git") + if executable is None: + return { + "executableAvailable": False, "executable": None, + "repositoryAvailable": False, "root": None, + **_failure("Git executable is unavailable"), + } + resolved_executable = str(Path(executable).resolve()) + try: + result = subprocess.run( + [executable, "rev-parse", "--show-toplevel"], cwd=cwd, + text=True, capture_output=True, check=False, + ) + except Exception: + return { + "executableAvailable": True, "executable": resolved_executable, + "repositoryAvailable": False, "root": None, + **_failure("Git repository detection failed"), + } + if result.returncode != 0: + return { + "executableAvailable": True, "executable": resolved_executable, + "repositoryAvailable": False, "root": None, + **_failure("current directory is not in a Git repository"), + } + try: + root = str(Path(result.stdout.strip()).resolve()) + except Exception: + return { + "executableAvailable": True, "executable": resolved_executable, + "repositoryAvailable": False, "root": None, + **_failure("Git repository root could not be resolved"), + } + return { + "executableAvailable": True, "executable": resolved_executable, + "repositoryAvailable": True, "root": root, "status": "ok", "message": None, + } + + +def _providers() -> dict[str, object]: + distributions = [] + failed_distributions = [] + for name in PROVIDER_DISTRIBUTIONS: + try: + installed_version = metadata.version(name) + if not isinstance(installed_version, str): + raise TypeError + distributions.append({"name": name, "version": installed_version, "status": "ok", "message": None}) + except Exception: + failed_distributions.append(name) + distributions.append({ + "name": name, "version": None, + **_failure(f"installed distribution metadata is unavailable for {name}"), + }) + + languages = [] + failed_languages = [] + try: + provider = TreeSitterProvider() + except Exception: + provider = None + for language in PROVIDER_LANGUAGES: + try: + if provider is None: + raise RuntimeError + provider.parse(language, b"") + languages.append({"name": language, "status": "ok", "message": None}) + except Exception: + failed_languages.append(language) + languages.append({ + "name": language, + **_failure(f"syntax provider is unavailable for {language}"), + }) + failed = [*failed_distributions, *failed_languages] + return { + "status": "ok" if not failed else "unavailable", + "message": None if not failed else f"provider checks unavailable: {', '.join(failed)}", + "distributions": distributions, + "languages": languages, + } + + +def gather_report() -> dict[str, object]: + """Gather all independent diagnostics without invoking ordinary analysis.""" + cwd = Path.cwd() + distribution, package = _distribution() + python = _python() + entry_point = _entry_point(package) + skill = _skill() + configuration = _configuration(cwd) + git = _git(cwd) + providers = _providers() + required = (distribution, python, entry_point, skill, configuration, providers) + status = "healthy" if all(item["status"] == "ok" for item in required) else "unhealthy" + return { + "schemaVersion": 1, + "status": status, + "distribution": distribution, + "python": python, + "entryPoint": entry_point, + "skill": skill, + "configuration": configuration, + "git": git, + "providers": providers, + } + + +def _label(item: dict[str, object]) -> str: + return str(item["status"]).upper() + + +def _suffix(item: dict[str, object]) -> str: + return "" if item["status"] == "ok" else f" - {item['message']}" + + +def format_human(report: dict[str, object]) -> str: + distribution = report["distribution"] + python = report["python"] + entry = report["entryPoint"] + skill = report["skill"] + configuration = report["configuration"] + git = report["git"] + providers = report["providers"] + config_fact = ( + "defaults (no configuration file)" + if configuration["mode"] == "defaults" + else f"file {configuration['path']}" + ) + git_fact = ( + f"{git['executable']}; repository {git['root']}" + if git["repositoryAvailable"] + else f"{git['executable'] or 'no Git executable'}; no repository" + ) + provider_versions = "; ".join( + f"{item['name']} {item['version'] if item['version'] is not None else 'unavailable'}" + for item in providers["distributions"] + ) + available_languages = sum(item["status"] == "ok" for item in providers["languages"]) + return "\n".join(( + f"Code Guard doctor: {str(report['status']).upper()}", + f"Distribution: {_label(distribution)} {distribution['name']} {distribution['version'] if distribution['version'] is not None else 'unavailable'}{_suffix(distribution)}", + f"Python: {_label(python)} {python['implementation']} {python['version']} ({python['executable']}){_suffix(python)}", + f"Entry point: {_label(entry)} {entry['name']} -> {entry['target']} ({entry['kind']}; {entry['resolvedPath'] or 'unresolved'}){_suffix(entry)}", + f"Skill: {_label(skill)} {skill['path'] or 'unavailable'}{_suffix(skill)}", + f"Configuration: {_label(configuration)} {config_fact}{_suffix(configuration)}", + f"Git: {_label(git)} {git_fact}{_suffix(git)}", + f"Providers: {_label(providers)} {provider_versions}; {available_languages}/{len(providers['languages'])} languages available{_suffix(providers)}", + )) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 02929ae..9538324 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -10,6 +10,9 @@ from types import SimpleNamespace from unittest.mock import Mock, patch +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "src")) + from agent_code_guard import code_guard, doctor @@ -58,6 +61,12 @@ def healthy_report(self, root: Path) -> dict[str, object]: return report def test_healthy_human_and_json_are_exact_ordered_stdout_only_reports(self) -> None: + help_text = code_guard.parser().format_help() + for fragment in ( + "code-guard doctor", "code-guard doctor --json", "Healthy reports exit 0", + "unhealthy reports exit 1", "errors exit 3", + ): + self.assertIn(fragment, help_text) with tempfile.TemporaryDirectory() as temp: root = Path(temp) report = self.healthy_report(root) @@ -108,7 +117,7 @@ def test_reservation_rejection_and_early_dispatch_preserve_analysis_paths(self) mocks = [patch.object(code_guard, name).start() for name in forbidden] self.addCleanup(lambda: [patch.stopall()]) with patch.object(code_guard, "gather_doctor_report", return_value=healthy): - self.assertEqual(self.run_main("doctor")[0], 0) + self.assertEqual(self.run_main("doctor", "--json")[0], 0) for mocked in mocks: mocked.assert_not_called() From d81474dda648ed20e236d4e11c49fff26e368525 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Tue, 25 Aug 2026 22:29:11 +0300 Subject: [PATCH 3/6] WIP: harden independent doctor checks (checkpoint) --- src/agent_code_guard/doctor.py | 66 +++++++++++++++++++++++++----- tests/test_doctor.py | 73 +++++++++++++++++++++++++++++----- 2 files changed, 119 insertions(+), 20 deletions(-) diff --git a/src/agent_code_guard/doctor.py b/src/agent_code_guard/doctor.py index b391cc4..84c4149 100644 --- a/src/agent_code_guard/doctor.py +++ b/src/agent_code_guard/doctor.py @@ -62,6 +62,18 @@ def _python() -> dict[str, object]: } +def _distribution_owns_launcher(package: object, resolved: Path) -> bool: + try: + for item in package.files or (): + if Path(str(item)).name.lower() not in {"code-guard", "code-guard.exe", "code-guard-script.py"}: + continue + if Path(package.locate_file(item)).resolve() == resolved: + return True + except Exception: + return False + return False + + def _entry_point(package: object | None) -> dict[str, object]: invoked = sys.argv[0] resolved = None @@ -77,7 +89,7 @@ def _entry_point(package: object | None) -> dict[str, object]: normalized = resolved.as_posix().lower() if normalized.endswith("/skills/code-guard/scripts/code_guard.py"): kind = "checkout-compatibility-runner" - elif resolved.stem.lower() in {"code-guard", "code_guard"}: + elif package is not None and _distribution_owns_launcher(package, resolved): kind = "console-script" owns_entry_point = False @@ -243,14 +255,43 @@ def _providers() -> dict[str, object]: def gather_report() -> dict[str, object]: """Gather all independent diagnostics without invoking ordinary analysis.""" - cwd = Path.cwd() - distribution, package = _distribution() - python = _python() - entry_point = _entry_point(package) - skill = _skill() - configuration = _configuration(cwd) - git = _git(cwd) - providers = _providers() + try: + cwd = Path.cwd() + except Exception as exc: + raise RuntimeError("current working directory is unavailable") from exc + distribution, package = _safe_check( + _distribution, + ({"name": DISTRIBUTION_NAME, "version": None, **_failure("distribution check failed")}, None), + ) + python = _safe_check(_python, { + "implementation": "unavailable", "version": "unavailable", "executable": str(sys.executable), + **_failure("running Python details are unavailable"), + }) + entry_point = _safe_check(lambda: _entry_point(package), { + "name": "code-guard", "target": ENTRY_POINT_TARGET, "invoked": sys.argv[0], + "resolvedPath": None, "kind": "other", **_failure("entry point check failed"), + }) + skill = _safe_check(_skill, { + "available": False, "path": None, **_failure("bundled Code Guard skill check failed"), + }) + configuration = _safe_check(lambda: _configuration(cwd), { + "mode": "file", "path": None, "valid": False, **_failure("configuration check failed"), + }) + git = _safe_check(lambda: _git(cwd), { + "executableAvailable": False, "executable": None, + "repositoryAvailable": False, "root": None, **_failure("Git check failed"), + }) + providers = _safe_check(_providers, { + "status": "unavailable", "message": "provider checks failed", + "distributions": [ + {"name": name, "version": None, **_failure(f"provider distribution check failed for {name}")} + for name in PROVIDER_DISTRIBUTIONS + ], + "languages": [ + {"name": name, **_failure(f"syntax provider check failed for {name}")} + for name in PROVIDER_LANGUAGES + ], + }) required = (distribution, python, entry_point, skill, configuration, providers) status = "healthy" if all(item["status"] == "ok" for item in required) else "unhealthy" return { @@ -266,6 +307,13 @@ def gather_report() -> dict[str, object]: } +def _safe_check(check, fallback): + try: + return check() + except Exception: + return fallback + + def _label(item: dict[str, object]) -> str: return str(item["status"]).upper() diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 9538324..5f7326f 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -1,19 +1,24 @@ from __future__ import annotations import json +import importlib import sys import tempfile +import types import unittest -from contextlib import redirect_stderr, redirect_stdout +from contextlib import ExitStack, redirect_stderr, redirect_stdout from io import StringIO from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock, patch REPO_ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(REPO_ROOT / "src")) - -from agent_code_guard import code_guard, doctor +CHECKOUT_PACKAGE = "_doctor_checkout_agent_code_guard" +package = types.ModuleType(CHECKOUT_PACKAGE) +package.__path__ = [str(REPO_ROOT / "src" / "agent_code_guard")] +sys.modules[CHECKOUT_PACKAGE] = package +code_guard = importlib.import_module(f"{CHECKOUT_PACKAGE}.code_guard") +doctor = importlib.import_module(f"{CHECKOUT_PACKAGE}.doctor") LANGUAGES = [ @@ -42,6 +47,8 @@ def healthy_report(self, root: Path) -> dict[str, object]: distribution = SimpleNamespace( version="1.2.3", entry_points=[SimpleNamespace(group="console_scripts", name="code-guard", value="agent_code_guard.code_guard:main")], + files=[Path("../Scripts/code-guard")], + locate_file=lambda item: launcher, ) provider = Mock() provider.parse.return_value = object() @@ -74,6 +81,19 @@ def test_healthy_human_and_json_are_exact_ordered_stdout_only_reports(self) -> N list(report), ["schemaVersion", "status", "distribution", "python", "entryPoint", "skill", "configuration", "git", "providers"], ) + self.assertEqual(list(report["distribution"]), ["name", "version", "status", "message"]) + self.assertEqual(list(report["python"]), ["implementation", "version", "executable", "status", "message"]) + self.assertEqual( + list(report["entryPoint"]), + ["name", "target", "invoked", "resolvedPath", "kind", "status", "message"], + ) + self.assertEqual(list(report["skill"]), ["available", "path", "status", "message"]) + self.assertEqual(list(report["configuration"]), ["mode", "path", "valid", "status", "message"]) + self.assertEqual( + list(report["git"]), + ["executableAvailable", "executable", "repositoryAvailable", "root", "status", "message"], + ) + self.assertEqual(list(report["providers"]), ["status", "message", "distributions", "languages"]) self.assertEqual(report["status"], "healthy") self.assertEqual([item["name"] for item in report["providers"]["languages"]], LANGUAGES) with patch.object(code_guard, "gather_doctor_report", return_value=report): @@ -81,7 +101,17 @@ def test_healthy_human_and_json_are_exact_ordered_stdout_only_reports(self) -> N machine = self.run_main("doctor", "--json") self.assertEqual(human[0], 0) self.assertEqual(human[2], "") - self.assertEqual(human[1], doctor.format_human(report) + "\n") + self.assertEqual(human[1], "\n".join(( + "Code Guard doctor: HEALTHY", + "Distribution: OK agent-code-guard 1.2.3", + f"Python: OK {report['python']['implementation']} {report['python']['version']} ({report['python']['executable']})", + f"Entry point: OK code-guard -> agent_code_guard.code_guard:main (console-script; {report['entryPoint']['resolvedPath']})", + f"Skill: OK {report['skill']['path']}", + "Configuration: OK defaults (no configuration file)", + f"Git: OK {report['git']['executable']}; repository {report['git']['root']}", + "Providers: OK tree-sitter 0.26.0; tree-sitter-language-pack 1.14.3; 14/14 languages available", + "", + ))) self.assertEqual(machine[0], 0) self.assertEqual(machine[2], "") self.assertEqual(json.loads(machine[1]), report) @@ -111,18 +141,39 @@ def test_partial_provider_failure_is_safe_unhealthy_and_does_not_stop_checks(sel with patch.object(code_guard, "gather_doctor_report", return_value=report): self.assertEqual(self.run_main("doctor", "--json")[0], 1) + with ( + patch.object(doctor, "_git", side_effect=RuntimeError("private failure")), + patch.object(doctor, "_providers", return_value=report["providers"]) as providers, + ): + continued = doctor.gather_report() + self.assertEqual(continued["git"]["message"], "Git check failed") + providers.assert_called_once() + def test_reservation_rejection_and_early_dispatch_preserve_analysis_paths(self) -> None: + with tempfile.TemporaryDirectory() as temp: + unrelated = Path(temp) / "code_guard.py" + unrelated.touch() + distribution = SimpleNamespace( + entry_points=[SimpleNamespace(group="console_scripts", name="code-guard", value="agent_code_guard.code_guard:main")], + files=[], + ) + with patch.object(sys, "argv", [str(unrelated), "doctor"]): + entry = doctor._entry_point(distribution) + self.assertEqual(entry["kind"], "other") + self.assertEqual(entry["status"], "unavailable") + healthy = {"status": "healthy"} forbidden = ["validate_configuration", "resolve_scope", "run_analysis", "installed_skill_path", "export_skill"] - mocks = [patch.object(code_guard, name).start() for name in forbidden] - self.addCleanup(lambda: [patch.stopall()]) - with patch.object(code_guard, "gather_doctor_report", return_value=healthy): + with ExitStack() as stack: + mocks = [stack.enter_context(patch.object(code_guard, name)) for name in forbidden] + stack.enter_context(patch.object(code_guard, "gather_doctor_report", return_value=healthy)) self.assertEqual(self.run_main("doctor", "--json")[0], 0) - for mocked in mocks: - mocked.assert_not_called() + for mocked in mocks: + mocked.assert_not_called() - with patch.object(code_guard, "resolve_scope", side_effect=RuntimeError("analysis entered")): + with patch.object(code_guard, "resolve_scope", side_effect=RuntimeError("qualified path analyzed")) as resolve: self.assertEqual(self.run_main("./doctor")[0], 3) + resolve.assert_called_once() self.assertEqual(self.run_main("doctor", "extra.py")[0], 3) self.assertEqual(self.run_main("doctor", "--config", "config.json")[0], 3) From 5bf86519631be1d0af925198accb53ace835cef0 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Tue, 25 Aug 2026 22:30:51 +0300 Subject: [PATCH 4/6] Add read-only doctor diagnostics From a8f2d00ea130e96a443ec924c26c6814b24a48e5 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Tue, 25 Aug 2026 22:40:43 +0300 Subject: [PATCH 5/6] WIP: start issue 95 review fixes (checkpoint; tests failing) --- tests/test_doctor.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 5f7326f..e9e382c 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -19,6 +19,7 @@ sys.modules[CHECKOUT_PACKAGE] = package code_guard = importlib.import_module(f"{CHECKOUT_PACKAGE}.code_guard") doctor = importlib.import_module(f"{CHECKOUT_PACKAGE}.doctor") +regions = importlib.import_module(f"{CHECKOUT_PACKAGE}.analysis.regions") LANGUAGES = [ @@ -40,20 +41,21 @@ def run_main(self, *arguments: str) -> tuple[int, str, str]: return result, stdout.getvalue(), stderr.getvalue() def healthy_report(self, root: Path) -> dict[str, object]: - launcher = root / "code-guard" + launcher = root / "code-guard.exe" launcher.touch() skill = root / "skill" skill.mkdir() distribution = SimpleNamespace( version="1.2.3", entry_points=[SimpleNamespace(group="console_scripts", name="code-guard", value="agent_code_guard.code_guard:main")], - files=[Path("../Scripts/code-guard")], + files=[Path("../Scripts/code-guard.exe")], locate_file=lambda item: launcher, ) provider = Mock() provider.parse.return_value = object() with ( - patch.object(sys, "argv", [str(launcher), "doctor"]), + patch.object(sys, "argv", [str(root / "code-guard"), "doctor"]), + patch.object(doctor.platform, "system", return_value="Windows"), patch.object(doctor.metadata, "distribution", return_value=distribution), patch.object(doctor.metadata, "version", side_effect=["0.26.0", "1.14.3"]), patch.object(doctor, "installed_skill_path", return_value=skill), @@ -95,7 +97,11 @@ def test_healthy_human_and_json_are_exact_ordered_stdout_only_reports(self) -> N ) self.assertEqual(list(report["providers"]), ["status", "message", "distributions", "languages"]) self.assertEqual(report["status"], "healthy") + self.assertEqual(report["entryPoint"]["invoked"], str(root / "code-guard")) + self.assertEqual(report["entryPoint"]["resolvedPath"], str((root / "code-guard.exe").resolve())) + self.assertEqual(report["entryPoint"]["kind"], "console-script") self.assertEqual([item["name"] for item in report["providers"]["languages"]], LANGUAGES) + self.assertEqual(doctor.PROVIDER_LANGUAGES, regions.PROVIDER_LANGUAGES) with patch.object(code_guard, "gather_doctor_report", return_value=report): human = self.run_main("doctor") machine = self.run_main("doctor", "--json") From 5c15bd3548408bb875e9f697431021dcb863348b Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Tue, 25 Aug 2026 22:42:33 +0300 Subject: [PATCH 6/6] Fix doctor launcher and provider ownership --- src/agent_code_guard/analysis/regions.py | 1 + src/agent_code_guard/doctor.py | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/agent_code_guard/analysis/regions.py b/src/agent_code_guard/analysis/regions.py index 0af0e7a..3998597 100644 --- a/src/agent_code_guard/analysis/regions.py +++ b/src/agent_code_guard/analysis/regions.py @@ -18,6 +18,7 @@ ".hpp": "cpp", ".hh": "cpp", ".hxx": "cpp", ".rs": "rust", ".php": "php", ".swift": "swift", ".dart": "dart", } +PROVIDER_LANGUAGES = (*dict.fromkeys(LANGUAGE_BY_SUFFIX.values()), "vue") APPLICABLE_SUFFIXES = frozenset((*LANGUAGE_BY_SUFFIX, ".vue")) diff --git a/src/agent_code_guard/doctor.py b/src/agent_code_guard/doctor.py index 84c4149..5f97fa9 100644 --- a/src/agent_code_guard/doctor.py +++ b/src/agent_code_guard/doctor.py @@ -11,6 +11,7 @@ from pathlib import Path from .analysis.provider import TreeSitterProvider +from .analysis.regions import PROVIDER_LANGUAGES from .config_validation import validate_configuration from .guards import callable_size, complexity, loc, markdown_document_size, markdown_section_size, nesting from .skill_distribution import skill_path as installed_skill_path @@ -18,10 +19,6 @@ DISTRIBUTION_NAME = "agent-code-guard" ENTRY_POINT_TARGET = "agent_code_guard.code_guard:main" PROVIDER_DISTRIBUTIONS = ("tree-sitter", "tree-sitter-language-pack") -PROVIDER_LANGUAGES = ( - "python", "go", "kotlin", "csharp", "java", "javascript", "typescript", - "tsx", "cpp", "rust", "php", "swift", "dart", "vue", -) def _failure(message: str, status: str = "unavailable") -> dict[str, object]: @@ -81,6 +78,10 @@ def _entry_point(package: object | None) -> dict[str, object]: candidate = Path(invoked) if candidate.exists(): resolved = candidate.resolve() + elif platform.system() == "Windows" and not candidate.suffix: + executable_candidate = Path(f"{candidate}.exe") + if executable_candidate.exists(): + resolved = executable_candidate.resolve() except (OSError, RuntimeError): pass