From f55b16cef79c81ece2e221922b431b8bfe3495d0 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Mon, 24 Aug 2026 22:49:48 +0300 Subject: [PATCH 01/13] WIP: start issue 91 CLI version reporting (checkpoint) From 111346bffd0583235cb8422461d1d8fe37880db9 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Mon, 24 Aug 2026 22:51:47 +0300 Subject: [PATCH 02/13] WIP: add issue 91 version tests (checkpoint; tests failing) --- tests/test_cli_version.py | 194 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 tests/test_cli_version.py diff --git a/tests/test_cli_version.py b/tests/test_cli_version.py new file mode 100644 index 0000000..339bd16 --- /dev/null +++ b/tests/test_cli_version.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import importlib.metadata +import json +import subprocess +import sys +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from io import StringIO +from pathlib import Path +from unittest.mock import patch + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "src")) + +from agent_code_guard.code_guard import main + + +COMPATIBILITY_RUNNER = REPO_ROOT / "skills" / "code-guard" / "scripts" / "code_guard.py" +DISTRIBUTION = "agent-code-guard" + + +class CliVersionTests(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 = main() + return result, stdout.getvalue(), stderr.getvalue() + + def test_human_version_uses_distribution_metadata_exactly(self) -> None: + with patch("agent_code_guard.code_guard.distribution_version", return_value="9.8.7+local") as version: + result = self.run_main("--version") + + self.assertEqual(result, (0, "agent-code-guard 9.8.7+local\n", "")) + version.assert_called_once_with(DISTRIBUTION) + + def test_json_version_has_exact_stable_shape(self) -> None: + with patch("agent_code_guard.code_guard.distribution_version", return_value="9.8.7"): + code, stdout, stderr = self.run_main("--version", "--json") + + self.assertEqual(code, 0) + self.assertEqual(stderr, "") + self.assertEqual(json.loads(stdout), {"distribution": DISTRIBUTION, "version": "9.8.7"}) + + def test_metadata_failure_uses_deterministic_human_error(self) -> None: + error = importlib.metadata.PackageNotFoundError(DISTRIBUTION) + with patch("agent_code_guard.code_guard.distribution_version", side_effect=error): + result = self.run_main("--version") + + self.assertEqual( + result, + (3, "", "Code Guard error: installed distribution metadata is unavailable for agent-code-guard\n"), + ) + self.assertNotIn("Traceback", "".join(result[1:])) + self.assertNotIn(str(error), "".join(result[1:])) + + def test_metadata_failure_uses_deterministic_json_error(self) -> None: + error = importlib.metadata.PackageNotFoundError(DISTRIBUTION) + with patch("agent_code_guard.code_guard.distribution_version", side_effect=error): + code, stdout, stderr = self.run_main("--version", "--json") + + self.assertEqual(code, 3) + self.assertEqual(stderr, "") + self.assertEqual( + json.loads(stdout), + {"error": "installed distribution metadata is unavailable for agent-code-guard"}, + ) + self.assertNotIn("Traceback", stdout) + self.assertNotIn(str(error), stdout) + + def test_version_returns_before_analysis_configuration_scope_and_skill_work(self) -> None: + forbidden_calls = [ + "validate_configuration", + "resolve_scope", + "run_guards", + "installed_skill_path", + "export_skill", + ] + patches = [patch(f"agent_code_guard.code_guard.{name}") for name in forbidden_calls] + mocks = [item.start() for item in patches] + self.addCleanup(lambda: [item.stop() for item in reversed(patches)]) + + with patch("agent_code_guard.code_guard.distribution_version", return_value="1.2.3"): + self.assertEqual(self.run_main("--version"), (0, "agent-code-guard 1.2.3\n", "")) + + for mocked in mocks: + mocked.assert_not_called() + + def test_version_does_not_import_or_initialize_providers(self) -> None: + script = """ +import json +import sys +from unittest.mock import patch +from agent_code_guard.code_guard import main +with patch.object(sys, 'argv', ['code-guard', '--version']): + result = main() +loaded = sorted(name for name in sys.modules if name.startswith( + ('agent_code_guard.analysis', 'agent_code_guard.markdown', 'tree_sitter') +)) +print(json.dumps({'result': result, 'loaded': loaded})) +""" + result = subprocess.run([sys.executable, "-c", script], text=True, capture_output=True) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout.splitlines()[-1]), {"result": 0, "loaded": []}) + + def test_version_is_read_only(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + before = list(root.rglob("*")) + with patch("agent_code_guard.code_guard.distribution_version", return_value="1.2.3"): + with patch("pathlib.Path.cwd", return_value=root): + self.assertEqual(self.run_main("--version")[0], 0) + self.assertEqual(list(root.rglob("*")), before) + + def test_version_rejects_every_non_json_argument_category(self) -> None: + cases = { + "path": ["sample.py"], + "config": ["--config", "config.json"], + "loc_warn": ["--warn", "10"], + "loc_fail": ["--fail", "20"], + "loc_include": ["--include", ".txt"], + "loc_exclude": ["--exclude", "vendor/**"], + "scope_exclude": ["--scope-exclude", "generated/**"], + "count_blank": ["--count-blank-lines"], + "ignore_comments": ["--ignore-comment-lines"], + "changed": ["--changed-only"], + "staged": ["--staged"], + "base_ref": ["--base-ref", "main"], + "ci": ["--ci"], + "skill_path": ["--skill-path"], + "export_skill": ["--export-skill", "target"], + } + for name, arguments in cases.items(): + for json_arguments in ([], ["--json"]): + with self.subTest(category=name, json=bool(json_arguments)): + code, stdout, stderr = self.run_main("--version", *arguments, *json_arguments) + self.assertEqual(code, 3) + if json_arguments: + self.assertEqual(stderr, "") + self.assertEqual(set(json.loads(stdout)), {"error"}) + else: + self.assertEqual(stdout, "") + self.assertTrue(stderr.startswith("Code Guard error: ")) + + def test_checkout_compatibility_runner_matches_console_behavior(self) -> None: + expected_version = importlib.metadata.version(DISTRIBUTION) + for arguments in (["--version"], ["--version", "--json"]): + with self.subTest(arguments=arguments): + result = subprocess.run( + [sys.executable, "-I", str(COMPATIBILITY_RUNNER), *arguments], + text=True, + capture_output=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stderr, "") + if "--json" in arguments: + self.assertEqual( + json.loads(result.stdout), + {"distribution": DISTRIBUTION, "version": expected_version}, + ) + else: + self.assertEqual(result.stdout, f"{DISTRIBUTION} {expected_version}\n") + + def test_non_version_cli_behavior_is_preserved(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + source = root / "sample.py" + source.write_text("answer = 42\n", encoding="utf-8") + config = root / "config.json" + config.write_text( + '{"guards":{"callableSize":{"enabled":false},"nesting":{"enabled":false},' + '"cyclomaticComplexity":{"enabled":false},"markdownDocumentSize":{"enabled":false},' + '"markdownSectionSize":{"enabled":false}}}', + encoding="utf-8", + ) + result = subprocess.run( + [sys.executable, str(COMPATIBILITY_RUNNER), str(source), "--config", str(config), "--json"], + cwd=root, + text=True, + capture_output=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout)["overall"], "pass") + + +if __name__ == "__main__": + unittest.main() From b0365dfebed7c7d8c3b214d30f54dab3ed077616 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Mon, 24 Aug 2026 22:53:23 +0300 Subject: [PATCH 03/13] WIP: implement issue 91 CLI version reporting (checkpoint) --- CHANGELOG.md | 10 +++++ README.md | 13 +++++++ docs/usage.md | 27 +++++++++++++ src/agent_code_guard/code_guard.py | 61 +++++++++++++++++++++++++++--- tests/test_cli_version.py | 7 +++- 5 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9c5eeb1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +Notable changes to Agent Code Guard are recorded here. + +## Unreleased + +### Added + +- Deterministic `code-guard --version` reporting from installed distribution + metadata, with human and JSON output modes. diff --git a/README.md b/README.md index f573377..d70c16a 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,19 @@ Deliberate full audit: code-guard . ``` +Confirm the installed distribution identity without running analysis: + +```text +$ code-guard --version +agent-code-guard +``` + +Use `code-guard --version --json` for the exact JSON shape +`{"distribution": "agent-code-guard", "version": ""}`. The version +comes from installed `agent-code-guard` distribution metadata. Version mode may +be combined only with `--json`; incompatible arguments or unavailable metadata +are tool errors that exit `3` through the normal human or JSON error channel. + Changed work is not a full audit. Use Git selection during normal development; do not repeatedly scan unrelated repository history after every edit. diff --git a/docs/usage.md b/docs/usage.md index 8a16535..74c2756 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -36,6 +36,33 @@ repository and skill compatibility; it is not the primary installed command. The public command is `code-guard`. With no paths, it defaults to `.`, but normal development should select current work explicitly. +### Installed version + +Report the installed distribution identity without configuration, scope, Git, +provider, skill, or guard work: + +```text +$ code-guard --version +agent-code-guard +``` + +For machine-readable output: + +```text +$ code-guard --version --json +{ + "distribution": "agent-code-guard", + "version": "" +} +``` + +The value is read from installed metadata for the canonical +`agent-code-guard` distribution. `--version` may be combined only with +`--json`. Incompatible arguments exit `3`; human errors use standard error and +JSON errors use an `error` object on standard output. If distribution metadata +is unavailable, the error is +`installed distribution metadata is unavailable for agent-code-guard`. + ### Git changed-only ```bash diff --git a/src/agent_code_guard/code_guard.py b/src/agent_code_guard/code_guard.py index 7c9d770..2cc3d37 100644 --- a/src/agent_code_guard/code_guard.py +++ b/src/agent_code_guard/code_guard.py @@ -5,6 +5,7 @@ import argparse from importlib import import_module +from importlib.metadata import PackageNotFoundError, version as distribution_version import json import sys from pathlib import Path @@ -15,6 +16,9 @@ from .result_model import GuardResult, aggregate_state, required_policies from .skill_distribution import export_skill, skill_path as installed_skill_path +DISTRIBUTION_NAME = "agent-code-guard" +METADATA_UNAVAILABLE = f"installed distribution metadata is unavailable for {DISTRIBUTION_NAME}" + def parser() -> argparse.ArgumentParser: value = argparse.ArgumentParser(description="Run deterministic Code Guard checks.") @@ -26,6 +30,10 @@ def parser() -> argparse.ArgumentParser: value.add_argument("--warn", type=int, help="Override the global LOC warning threshold.") value.add_argument("--fail", type=int, help="Override the global LOC failure threshold.") value.add_argument("--json", action="store_true", help="Emit normalized JSON.") + value.add_argument( + "--version", action="store_true", + help="Print the installed agent-code-guard distribution version; may be combined only with --json.", + ) value.add_argument("--ci", action="store_true", help="Do not fail solely on REVIEW.") value.add_argument("--changed-only", action="store_true", help="Inspect staged, unstaged, and untracked files.") value.add_argument("--staged", action="store_true", help="Inspect index-only changes.") @@ -49,6 +57,39 @@ def parser() -> argparse.ArgumentParser: return value +def _version_mode(args: argparse.Namespace) -> int | None: + if not args.version: + return None + incompatible = ( + bool(args.paths) + or args.config is not None + or args.warn is not None + or args.fail 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("--version may be combined only with --json") + try: + installed_version = distribution_version(DISTRIBUTION_NAME) + except (PackageNotFoundError, OSError) as exc: + raise ValueError(METADATA_UNAVAILABLE) from exc + if args.json: + print(json.dumps({"distribution": DISTRIBUTION_NAME, "version": installed_version}, indent=2)) + else: + print(f"{DISTRIBUTION_NAME} {installed_version}") + return 0 + + def _management_mode(args: argparse.Namespace) -> int | None: if not args.skill_path and args.export_skill is None: args.paths = args.paths or ["."] @@ -210,9 +251,23 @@ def exit_code(overall: str, ci: bool) -> int: return 0 +def _print_tool_error(message: str, json_mode: bool) -> int: + if json_mode: + print(json.dumps({"error": message}, indent=2)) + else: + print(f"Code Guard error: {message}", file=sys.stderr) + return 3 + + def main() -> int: + raw_arguments = sys.argv[1:] + if "--version" in raw_arguments and any(value in raw_arguments for value in ("-h", "--help")): + return _print_tool_error("--version may be combined only with --json", "--json" in raw_arguments) args = parser().parse_args() try: + version_result = _version_mode(args) + if version_result is not None: + return version_result management_result = _management_mode(args) if management_result is not None: return management_result @@ -225,11 +280,7 @@ def main() -> int: print_text(data) return exit_code(data["overall"], args.ci) except Exception as exc: - if args.json: - print(json.dumps({"error": str(exc)}, indent=2)) - else: - print(f"Code Guard error: {exc}", file=sys.stderr) - return 3 + return _print_tool_error(str(exc), args.json) if __name__ == "__main__": diff --git a/tests/test_cli_version.py b/tests/test_cli_version.py index 339bd16..6793426 100644 --- a/tests/test_cli_version.py +++ b/tests/test_cli_version.py @@ -96,7 +96,9 @@ def test_version_does_not_import_or_initialize_providers(self) -> None: script = """ import json import sys +from pathlib import Path from unittest.mock import patch +sys.path.insert(0, sys.argv[1]) from agent_code_guard.code_guard import main with patch.object(sys, 'argv', ['code-guard', '--version']): result = main() @@ -105,7 +107,9 @@ def test_version_does_not_import_or_initialize_providers(self) -> None: )) print(json.dumps({'result': result, 'loaded': loaded})) """ - result = subprocess.run([sys.executable, "-c", script], text=True, capture_output=True) + result = subprocess.run( + [sys.executable, "-c", script, str(REPO_ROOT / "src")], text=True, capture_output=True, + ) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(json.loads(result.stdout.splitlines()[-1]), {"result": 0, "loaded": []}) @@ -136,6 +140,7 @@ def test_version_rejects_every_non_json_argument_category(self) -> None: "ci": ["--ci"], "skill_path": ["--skill-path"], "export_skill": ["--export-skill", "target"], + "help": ["--help"], } for name, arguments in cases.items(): for json_arguments in ([], ["--json"]): From f3035aee23dfc1265de9c80a8163bd2911684156 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Mon, 24 Aug 2026 23:01:39 +0300 Subject: [PATCH 04/13] Complete issue 91 CLI version reporting --- tests/test_cli_version.py | 69 +++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/tests/test_cli_version.py b/tests/test_cli_version.py index 6793426..5c7a434 100644 --- a/tests/test_cli_version.py +++ b/tests/test_cli_version.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib.metadata +import importlib.util import json import subprocess import sys @@ -12,9 +13,17 @@ from unittest.mock import patch REPO_ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(REPO_ROOT / "src")) -from agent_code_guard.code_guard import main +import agent_code_guard + +agent_code_guard.__path__.insert(0, str(REPO_ROOT / "src" / "agent_code_guard")) +module_spec = importlib.util.spec_from_file_location( + "agent_code_guard._checkout_code_guard", REPO_ROOT / "src" / "agent_code_guard" / "code_guard.py", +) +assert module_spec is not None and module_spec.loader is not None +checkout_code_guard = importlib.util.module_from_spec(module_spec) +module_spec.loader.exec_module(checkout_code_guard) +main = checkout_code_guard.main COMPATIBILITY_RUNNER = REPO_ROOT / "skills" / "code-guard" / "scripts" / "code_guard.py" @@ -34,14 +43,14 @@ def run_main(self, *arguments: str) -> tuple[int, str, str]: return result, stdout.getvalue(), stderr.getvalue() def test_human_version_uses_distribution_metadata_exactly(self) -> None: - with patch("agent_code_guard.code_guard.distribution_version", return_value="9.8.7+local") as version: + with patch.object(checkout_code_guard, "distribution_version", return_value="9.8.7+local") as version: result = self.run_main("--version") self.assertEqual(result, (0, "agent-code-guard 9.8.7+local\n", "")) version.assert_called_once_with(DISTRIBUTION) def test_json_version_has_exact_stable_shape(self) -> None: - with patch("agent_code_guard.code_guard.distribution_version", return_value="9.8.7"): + with patch.object(checkout_code_guard, "distribution_version", return_value="9.8.7"): code, stdout, stderr = self.run_main("--version", "--json") self.assertEqual(code, 0) @@ -49,30 +58,32 @@ def test_json_version_has_exact_stable_shape(self) -> None: self.assertEqual(json.loads(stdout), {"distribution": DISTRIBUTION, "version": "9.8.7"}) def test_metadata_failure_uses_deterministic_human_error(self) -> None: - error = importlib.metadata.PackageNotFoundError(DISTRIBUTION) - with patch("agent_code_guard.code_guard.distribution_version", side_effect=error): - result = self.run_main("--version") - - self.assertEqual( - result, - (3, "", "Code Guard error: installed distribution metadata is unavailable for agent-code-guard\n"), - ) - self.assertNotIn("Traceback", "".join(result[1:])) - self.assertNotIn(str(error), "".join(result[1:])) + for error in (importlib.metadata.PackageNotFoundError(DISTRIBUTION), OSError("private detail")): + with self.subTest(error=type(error).__name__): + with patch.object(checkout_code_guard, "distribution_version", side_effect=error): + result = self.run_main("--version") + + self.assertEqual( + result, + (3, "", "Code Guard error: installed distribution metadata is unavailable for agent-code-guard\n"), + ) + self.assertNotIn("Traceback", "".join(result[1:])) + self.assertNotIn(str(error), "".join(result[1:])) def test_metadata_failure_uses_deterministic_json_error(self) -> None: - error = importlib.metadata.PackageNotFoundError(DISTRIBUTION) - with patch("agent_code_guard.code_guard.distribution_version", side_effect=error): - code, stdout, stderr = self.run_main("--version", "--json") - - self.assertEqual(code, 3) - self.assertEqual(stderr, "") - self.assertEqual( - json.loads(stdout), - {"error": "installed distribution metadata is unavailable for agent-code-guard"}, - ) - self.assertNotIn("Traceback", stdout) - self.assertNotIn(str(error), stdout) + for error in (importlib.metadata.PackageNotFoundError(DISTRIBUTION), OSError("private detail")): + with self.subTest(error=type(error).__name__): + with patch.object(checkout_code_guard, "distribution_version", side_effect=error): + code, stdout, stderr = self.run_main("--version", "--json") + + self.assertEqual(code, 3) + self.assertEqual(stderr, "") + self.assertEqual( + json.loads(stdout), + {"error": "installed distribution metadata is unavailable for agent-code-guard"}, + ) + self.assertNotIn("Traceback", stdout) + self.assertNotIn(str(error), stdout) def test_version_returns_before_analysis_configuration_scope_and_skill_work(self) -> None: forbidden_calls = [ @@ -82,11 +93,11 @@ def test_version_returns_before_analysis_configuration_scope_and_skill_work(self "installed_skill_path", "export_skill", ] - patches = [patch(f"agent_code_guard.code_guard.{name}") for name in forbidden_calls] + patches = [patch.object(checkout_code_guard, name) for name in forbidden_calls] mocks = [item.start() for item in patches] self.addCleanup(lambda: [item.stop() for item in reversed(patches)]) - with patch("agent_code_guard.code_guard.distribution_version", return_value="1.2.3"): + with patch.object(checkout_code_guard, "distribution_version", return_value="1.2.3"): self.assertEqual(self.run_main("--version"), (0, "agent-code-guard 1.2.3\n", "")) for mocked in mocks: @@ -118,7 +129,7 @@ def test_version_is_read_only(self) -> None: with tempfile.TemporaryDirectory() as temp: root = Path(temp) before = list(root.rglob("*")) - with patch("agent_code_guard.code_guard.distribution_version", return_value="1.2.3"): + with patch.object(checkout_code_guard, "distribution_version", return_value="1.2.3"): with patch("pathlib.Path.cwd", return_value=root): self.assertEqual(self.run_main("--version")[0], 0) self.assertEqual(list(root.rglob("*")), before) From 9a155e4c96ab3416e10de284a6c613191ec7102b Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Tue, 25 Aug 2026 18:38:33 +0300 Subject: [PATCH 05/13] WIP: start issue 91 review fixes (checkpoint) From 5cdeeea91c8dbc82d5c6dff87329a756840ced3e Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Tue, 25 Aug 2026 18:39:43 +0300 Subject: [PATCH 06/13] WIP: add issue 91 metadata and isolation regressions (checkpoint; tests failing) --- tests/test_cli_version.py | 68 +++++++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/tests/test_cli_version.py b/tests/test_cli_version.py index 5c7a434..03c2cee 100644 --- a/tests/test_cli_version.py +++ b/tests/test_cli_version.py @@ -28,6 +28,7 @@ COMPATIBILITY_RUNNER = REPO_ROOT / "skills" / "code-guard" / "scripts" / "code_guard.py" DISTRIBUTION = "agent-code-guard" +METADATA_UNAVAILABLE = f"installed distribution metadata is unavailable for {DISTRIBUTION}" class CliVersionTests(unittest.TestCase): @@ -85,6 +86,41 @@ def test_metadata_failure_uses_deterministic_json_error(self) -> None: self.assertNotIn("Traceback", stdout) self.assertNotIn(str(error), stdout) + def test_unicode_decode_error_uses_deterministic_human_error(self) -> None: + error = UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte") + with patch.object(checkout_code_guard, "distribution_version", side_effect=error): + result = self.run_main("--version") + + self.assertEqual( + result, + (3, "", "Code Guard error: installed distribution metadata is unavailable for agent-code-guard\n"), + ) + self.assertNotIn(str(error), "".join(result[1:])) + + def test_unicode_decode_error_uses_deterministic_json_error(self) -> None: + error = UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte") + with patch.object(checkout_code_guard, "distribution_version", side_effect=error): + code, stdout, stderr = self.run_main("--version", "--json") + + self.assertEqual(code, 3) + self.assertEqual(stderr, "") + self.assertEqual(json.loads(stdout), {"error": METADATA_UNAVAILABLE}) + self.assertNotIn(str(error), stdout) + + def test_non_string_metadata_uses_deterministic_human_error(self) -> None: + with patch.object(checkout_code_guard, "distribution_version", return_value=None): + result = self.run_main("--version") + + self.assertEqual(result, (3, "", f"Code Guard error: {METADATA_UNAVAILABLE}\n")) + + def test_non_string_metadata_uses_deterministic_json_error(self) -> None: + with patch.object(checkout_code_guard, "distribution_version", return_value=None): + code, stdout, stderr = self.run_main("--version", "--json") + + self.assertEqual(code, 3) + self.assertEqual(stderr, "") + self.assertEqual(json.loads(stdout), {"error": METADATA_UNAVAILABLE}) + def test_version_returns_before_analysis_configuration_scope_and_skill_work(self) -> None: forbidden_calls = [ "validate_configuration", @@ -166,14 +202,31 @@ def test_version_rejects_every_non_json_argument_category(self) -> None: self.assertTrue(stderr.startswith("Code Guard error: ")) def test_checkout_compatibility_runner_matches_console_behavior(self) -> None: - expected_version = importlib.metadata.version(DISTRIBUTION) + expected = subprocess.run( + [ + sys.executable, + "-I", + "-c", + "import importlib.metadata,sys;sys.path.insert(0,sys.argv[1]);" + "print(importlib.metadata.version(sys.argv[2]))", + str(REPO_ROOT / "src"), + DISTRIBUTION, + ], + text=True, + capture_output=True, + ) + self.assertEqual(expected.returncode, 0, expected.stderr) + expected_version = expected.stdout.rstrip("\n") + parent_version = importlib.metadata.version for arguments in (["--version"], ["--version", "--json"]): with self.subTest(arguments=arguments): - result = subprocess.run( - [sys.executable, "-I", str(COMPATIBILITY_RUNNER), *arguments], - text=True, - capture_output=True, - ) + with patch.object(importlib.metadata, "version", return_value="0.0.0-parent-mismatch"): + result = subprocess.run( + [sys.executable, "-I", str(COMPATIBILITY_RUNNER), *arguments], + text=True, + capture_output=True, + ) + self.assertIs(importlib.metadata.version, parent_version) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stderr, "") if "--json" in arguments: @@ -184,6 +237,9 @@ def test_checkout_compatibility_runner_matches_console_behavior(self) -> None: else: self.assertEqual(result.stdout, f"{DISTRIBUTION} {expected_version}\n") + def test_checkout_loading_does_not_mutate_package_search_path(self) -> None: + self.assertNotIn(str(REPO_ROOT / "src" / "agent_code_guard"), agent_code_guard.__path__) + def test_non_version_cli_behavior_is_preserved(self) -> None: with tempfile.TemporaryDirectory() as temp: root = Path(temp) From 6371063693f2c586d5a2e8cf1f38fd89f6d6166a Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Tue, 25 Aug 2026 18:40:19 +0300 Subject: [PATCH 07/13] Fix version metadata errors and checkout test isolation --- src/agent_code_guard/code_guard.py | 15 ++++++++--- tests/test_cli_version.py | 41 ++++++++++++++++++++++-------- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/src/agent_code_guard/code_guard.py b/src/agent_code_guard/code_guard.py index 2cc3d37..8dfe3d3 100644 --- a/src/agent_code_guard/code_guard.py +++ b/src/agent_code_guard/code_guard.py @@ -20,6 +20,16 @@ METADATA_UNAVAILABLE = f"installed distribution metadata is unavailable for {DISTRIBUTION_NAME}" +def _installed_distribution_version() -> str: + try: + installed_version = distribution_version(DISTRIBUTION_NAME) + except (PackageNotFoundError, OSError, UnicodeError) as exc: + raise ValueError(METADATA_UNAVAILABLE) from exc + if not isinstance(installed_version, str): + raise ValueError(METADATA_UNAVAILABLE) + return installed_version + + def parser() -> argparse.ArgumentParser: value = argparse.ArgumentParser(description="Run deterministic Code Guard checks.") value.add_argument( @@ -79,10 +89,7 @@ def _version_mode(args: argparse.Namespace) -> int | None: ) if incompatible: raise ValueError("--version may be combined only with --json") - try: - installed_version = distribution_version(DISTRIBUTION_NAME) - except (PackageNotFoundError, OSError) as exc: - raise ValueError(METADATA_UNAVAILABLE) from exc + installed_version = _installed_distribution_version() if args.json: print(json.dumps({"distribution": DISTRIBUTION_NAME, "version": installed_version}, indent=2)) else: diff --git a/tests/test_cli_version.py b/tests/test_cli_version.py index 03c2cee..590022a 100644 --- a/tests/test_cli_version.py +++ b/tests/test_cli_version.py @@ -6,6 +6,7 @@ import subprocess import sys import tempfile +import types import unittest from contextlib import redirect_stderr, redirect_stdout from io import StringIO @@ -14,15 +15,30 @@ REPO_ROOT = Path(__file__).resolve().parents[1] -import agent_code_guard - -agent_code_guard.__path__.insert(0, str(REPO_ROOT / "src" / "agent_code_guard")) -module_spec = importlib.util.spec_from_file_location( - "agent_code_guard._checkout_code_guard", REPO_ROOT / "src" / "agent_code_guard" / "code_guard.py", -) -assert module_spec is not None and module_spec.loader is not None -checkout_code_guard = importlib.util.module_from_spec(module_spec) -module_spec.loader.exec_module(checkout_code_guard) +CHECKOUT_PACKAGE = "_checkout_agent_code_guard" + + +def _load_checkout_code_guard(): + package = types.ModuleType(CHECKOUT_PACKAGE) + package.__path__ = [str(REPO_ROOT / "src" / "agent_code_guard")] + module_name = f"{CHECKOUT_PACKAGE}.code_guard" + module_spec = importlib.util.spec_from_file_location( + module_name, REPO_ROOT / "src" / "agent_code_guard" / "code_guard.py", + ) + assert module_spec is not None and module_spec.loader is not None + checkout_module = importlib.util.module_from_spec(module_spec) + sys.modules[CHECKOUT_PACKAGE] = package + sys.modules[module_name] = checkout_module + try: + module_spec.loader.exec_module(checkout_module) + finally: + for loaded_name in tuple(sys.modules): + if loaded_name == CHECKOUT_PACKAGE or loaded_name.startswith(f"{CHECKOUT_PACKAGE}."): + del sys.modules[loaded_name] + return checkout_module + + +checkout_code_guard = _load_checkout_code_guard() main = checkout_code_guard.main @@ -238,7 +254,12 @@ def test_checkout_compatibility_runner_matches_console_behavior(self) -> None: self.assertEqual(result.stdout, f"{DISTRIBUTION} {expected_version}\n") def test_checkout_loading_does_not_mutate_package_search_path(self) -> None: - self.assertNotIn(str(REPO_ROOT / "src" / "agent_code_guard"), agent_code_guard.__path__) + self.assertFalse(any( + name == CHECKOUT_PACKAGE or name.startswith(f"{CHECKOUT_PACKAGE}.") for name in sys.modules + )) + host_package = sys.modules.get("agent_code_guard") + if host_package is not None: + self.assertNotIn(str(REPO_ROOT / "src" / "agent_code_guard"), host_package.__path__) def test_non_version_cli_behavior_is_preserved(self) -> None: with tempfile.TemporaryDirectory() as temp: From 364e27f738b68f148d10e148d866335ebbb14d90 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Tue, 25 Aug 2026 18:59:02 +0300 Subject: [PATCH 08/13] WIP: start issue 91 final review fixes (checkpoint) From a979273c59a26d8ddbb7b04c8e83f013a478a50e Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Tue, 25 Aug 2026 18:59:53 +0300 Subject: [PATCH 09/13] WIP: add issue 91 help and mismatch regressions (checkpoint; tests failing) --- tests/test_cli_version.py | 92 +++++++++++++++++++++++++++------------ 1 file changed, 64 insertions(+), 28 deletions(-) diff --git a/tests/test_cli_version.py b/tests/test_cli_version.py index 590022a..488af01 100644 --- a/tests/test_cli_version.py +++ b/tests/test_cli_version.py @@ -66,6 +66,36 @@ def test_human_version_uses_distribution_metadata_exactly(self) -> None: self.assertEqual(result, (0, "agent-code-guard 9.8.7+local\n", "")) version.assert_called_once_with(DISTRIBUTION) + def test_help_documents_version_invocations_outputs_and_exits(self) -> None: + help_text = checkout_code_guard.parser().format_help() + + for fragment in ( + "code-guard --version", + "code-guard --version --json", + "agent-code-guard ", + '"distribution"', + '"version"', + "exit 0", + "exit 3", + "--version may be combined only with --json", + ): + with self.subTest(fragment=fragment): + self.assertIn(fragment, help_text) + + stdout = StringIO() + stderr = StringIO() + with ( + patch.object(sys, "argv", ["code-guard", "--help"]), + redirect_stdout(stdout), + redirect_stderr(stderr), + self.assertRaises(SystemExit) as exit_context, + ): + main() + + self.assertEqual(exit_context.exception.code, 0) + self.assertEqual(stderr.getvalue(), "") + self.assertEqual(stdout.getvalue(), help_text) + def test_json_version_has_exact_stable_shape(self) -> None: with patch.object(checkout_code_guard, "distribution_version", return_value="9.8.7"): code, stdout, stderr = self.run_main("--version", "--json") @@ -218,40 +248,46 @@ def test_version_rejects_every_non_json_argument_category(self) -> None: self.assertTrue(stderr.startswith("Code Guard error: ")) def test_checkout_compatibility_runner_matches_console_behavior(self) -> None: - expected = subprocess.run( - [ - sys.executable, - "-I", - "-c", - "import importlib.metadata,sys;sys.path.insert(0,sys.argv[1]);" - "print(importlib.metadata.version(sys.argv[2]))", - str(REPO_ROOT / "src"), - DISTRIBUTION, - ], - text=True, - capture_output=True, - ) - self.assertEqual(expected.returncode, 0, expected.stderr) - expected_version = expected.stdout.rstrip("\n") + sentinel = "parent-metadata-sentinel-that-is-not-a-version" parent_version = importlib.metadata.version - for arguments in (["--version"], ["--version", "--json"]): - with self.subTest(arguments=arguments): - with patch.object(importlib.metadata, "version", return_value="0.0.0-parent-mismatch"): + with patch.object(importlib.metadata, "version", return_value=sentinel): + self.assertEqual(importlib.metadata.version(DISTRIBUTION), sentinel) + expected = subprocess.run( + [ + sys.executable, + "-I", + "-c", + "import importlib.metadata,sys;sys.path.insert(0,sys.argv[1]);" + "print(importlib.metadata.version(sys.argv[2]))", + str(REPO_ROOT / "src"), + DISTRIBUTION, + ], + text=True, + capture_output=True, + ) + self.assertEqual(expected.returncode, 0, expected.stderr) + expected_version = expected.stdout.rstrip("\n") + self.assertNotEqual(expected_version, sentinel) + + for arguments in (["--version"], ["--version", "--json"]): + with self.subTest(arguments=arguments): result = subprocess.run( [sys.executable, "-I", str(COMPATIBILITY_RUNNER), *arguments], text=True, capture_output=True, ) - self.assertIs(importlib.metadata.version, parent_version) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stderr, "") - if "--json" in arguments: - self.assertEqual( - json.loads(result.stdout), - {"distribution": DISTRIBUTION, "version": expected_version}, - ) - else: - self.assertEqual(result.stdout, f"{DISTRIBUTION} {expected_version}\n") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stderr, "") + self.assertNotIn(sentinel, result.stdout) + if "--json" in arguments: + self.assertEqual( + json.loads(result.stdout), + {"distribution": DISTRIBUTION, "version": expected_version}, + ) + else: + self.assertEqual(result.stdout, f"{DISTRIBUTION} {expected_version}\n") + + self.assertIs(importlib.metadata.version, parent_version) def test_checkout_loading_does_not_mutate_package_search_path(self) -> None: self.assertFalse(any( From e339550877f498ed6b6f76c0ec1208404698f5cb Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Tue, 25 Aug 2026 19:00:25 +0300 Subject: [PATCH 10/13] Fix version help and checkout mismatch coverage --- README.md | 5 +++-- docs/usage.md | 7 ++++--- src/agent_code_guard/code_guard.py | 13 ++++++++++++- tests/test_cli_version.py | 2 +- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d70c16a..6eb5c80 100644 --- a/README.md +++ b/README.md @@ -115,8 +115,9 @@ agent-code-guard Use `code-guard --version --json` for the exact JSON shape `{"distribution": "agent-code-guard", "version": ""}`. The version comes from installed `agent-code-guard` distribution metadata. Version mode may -be combined only with `--json`; incompatible arguments or unavailable metadata -are tool errors that exit `3` through the normal human or JSON error channel. +be combined only with `--json`. Both successful version forms exit `0`; +incompatible arguments or unavailable metadata are tool errors that exit `3` +through the normal human or JSON error channel. Changed work is not a full audit. Use Git selection during normal development; do not repeatedly scan unrelated repository history after every edit. diff --git a/docs/usage.md b/docs/usage.md index 74c2756..63357bc 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -58,9 +58,10 @@ $ code-guard --version --json The value is read from installed metadata for the canonical `agent-code-guard` distribution. `--version` may be combined only with -`--json`. Incompatible arguments exit `3`; human errors use standard error and -JSON errors use an `error` object on standard output. If distribution metadata -is unavailable, the error is +`--json`. Both successful version forms exit `0`; incompatible arguments or +unavailable metadata exit `3`. Human errors use standard error and JSON errors +use an `error` object on standard output. If distribution metadata is +unavailable, the error is `installed distribution metadata is unavailable for agent-code-guard`. ### Git changed-only diff --git a/src/agent_code_guard/code_guard.py b/src/agent_code_guard/code_guard.py index 8dfe3d3..105bb22 100644 --- a/src/agent_code_guard/code_guard.py +++ b/src/agent_code_guard/code_guard.py @@ -31,7 +31,18 @@ def _installed_distribution_version() -> str: def parser() -> argparse.ArgumentParser: - value = argparse.ArgumentParser(description="Run deterministic Code Guard checks.") + value = argparse.ArgumentParser( + description="Run deterministic Code Guard checks.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""Version reporting: + code-guard --version + Output: agent-code-guard + code-guard --version --json + Output: {"distribution": "agent-code-guard", "version": ""} + +Successful version reporting exits 0. Incompatible arguments or unavailable +metadata exit 3. --version may be combined only with --json.""", + ) value.add_argument( "paths", nargs="*", default=[], help="Files or directories to inspect; bounds files selected by a Git selection mode.", diff --git a/tests/test_cli_version.py b/tests/test_cli_version.py index 488af01..f422d8a 100644 --- a/tests/test_cli_version.py +++ b/tests/test_cli_version.py @@ -75,7 +75,7 @@ def test_help_documents_version_invocations_outputs_and_exits(self) -> None: "agent-code-guard ", '"distribution"', '"version"', - "exit 0", + "exits 0", "exit 3", "--version may be combined only with --json", ): From b8d67f1f35deee06266d100a4f846e36c87e2602 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Tue, 25 Aug 2026 19:26:23 +0300 Subject: [PATCH 11/13] WIP: start issue 91 CI portability fix (checkpoint) From f64b75b43f96fb223ffd01c2189b3c4408cf5ef4 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Tue, 25 Aug 2026 19:27:35 +0300 Subject: [PATCH 12/13] Fix deterministic CLI help program name --- src/agent_code_guard/code_guard.py | 1 + tests/test_cli_version.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/agent_code_guard/code_guard.py b/src/agent_code_guard/code_guard.py index 105bb22..7975fb9 100644 --- a/src/agent_code_guard/code_guard.py +++ b/src/agent_code_guard/code_guard.py @@ -32,6 +32,7 @@ def _installed_distribution_version() -> str: def parser() -> argparse.ArgumentParser: value = argparse.ArgumentParser( + prog="code-guard", description="Run deterministic Code Guard checks.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""Version reporting: diff --git a/tests/test_cli_version.py b/tests/test_cli_version.py index f422d8a..4d00bb3 100644 --- a/tests/test_cli_version.py +++ b/tests/test_cli_version.py @@ -67,7 +67,10 @@ def test_human_version_uses_distribution_metadata_exactly(self) -> None: version.assert_called_once_with(DISTRIBUTION) def test_help_documents_version_invocations_outputs_and_exits(self) -> None: - help_text = checkout_code_guard.parser().format_help() + with patch.object(sys, "argv", ["python.exe", "-m", "unittest"]): + help_text = checkout_code_guard.parser().format_help() + + self.assertTrue(help_text.startswith("usage: code-guard")) for fragment in ( "code-guard --version", From c76b75b8bad0a2167fde0e8765a49a409ddcf31e Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Tue, 25 Aug 2026 19:43:45 +0300 Subject: [PATCH 13/13] Remove invalid checkout-loader isolation test --- tests/test_cli_version.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/test_cli_version.py b/tests/test_cli_version.py index 4d00bb3..d0f2aa3 100644 --- a/tests/test_cli_version.py +++ b/tests/test_cli_version.py @@ -292,14 +292,6 @@ def test_checkout_compatibility_runner_matches_console_behavior(self) -> None: self.assertIs(importlib.metadata.version, parent_version) - def test_checkout_loading_does_not_mutate_package_search_path(self) -> None: - self.assertFalse(any( - name == CHECKOUT_PACKAGE or name.startswith(f"{CHECKOUT_PACKAGE}.") for name in sys.modules - )) - host_package = sys.modules.get("agent_code_guard") - if host_package is not None: - self.assertNotIn(str(REPO_ROOT / "src" / "agent_code_guard"), host_package.__path__) - def test_non_version_cli_behavior_is_preserved(self) -> None: with tempfile.TemporaryDirectory() as temp: root = Path(temp)