From cc888c896c8600a7d806c2da5814814b2be1eb5f Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 15 Sep 2026 12:05:15 +0000 Subject: [PATCH 1/9] [AISOS-2510] Extend version subcommand argument parser in src/forge/cli.py to support --json Detailed description: - Extended the 'version' subcommand argument parser with the optional '--json' flag. - Updated 'cmd_version' function to serialize the package version to compact JSON format when the flag is specified. - Configured 'setup_logging' to direct basic logging output explicitly to sys.stderr, isolating output payload streams from diagnostic logs. - Added comprehensive unit tests validating option parsing, compact JSON formatting, and stream isolation. Closes: AISOS-2510 --- src/forge/cli.py | 17 ++++++-- tests/unit/test_cli_version.py | 72 ++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/forge/cli.py b/src/forge/cli.py index 0318d8e83..b7d4ba3cf 100644 --- a/src/forge/cli.py +++ b/src/forge/cli.py @@ -17,6 +17,7 @@ def setup_logging(verbose: bool = False) -> None: logging.basicConfig( level=level, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + stream=sys.stderr, ) @@ -1423,11 +1424,16 @@ async def cmd_smoke_test(_args: argparse.Namespace) -> int: return await run_smoke_test(settings) -async def cmd_version(_args: argparse.Namespace) -> int: +async def cmd_version(args: argparse.Namespace) -> int: """Print the installed Forge package version.""" from forge import __version__ - print(f"Forge v{__version__}") + if getattr(args, "json", False): + import json + + print(json.dumps({"version": __version__})) + else: + print(f"Forge v{__version__}") return 0 @@ -1553,10 +1559,15 @@ def main(argv: list[str] | None = None) -> int: ) # version command - subparsers.add_parser( + version_parser = subparsers.add_parser( "version", help="Print the installed Forge package version", ) + version_parser.add_argument( + "--json", + action="store_true", + help="Print version information as a JSON object", + ) # test-skill subparser group test_skill_parser = subparsers.add_parser( diff --git a/tests/unit/test_cli_version.py b/tests/unit/test_cli_version.py index f37974593..7886e63a1 100644 --- a/tests/unit/test_cli_version.py +++ b/tests/unit/test_cli_version.py @@ -31,3 +31,75 @@ async def test_cmd_version_execution(self, capsys): assert code == 0 captured = capsys.readouterr() assert f"Forge v{__version__}" in captured.out + + @patch("forge.cli.cmd_version", new_callable=AsyncMock) + @patch("forge.cli.setup_logging") + def test_routing_version_json(self, _mock_setup_logging, mock_cmd): + """Calling main(['version', '--json']) routes to cmd_version with args.json=True.""" + mock_cmd.return_value = 0 + code = main(["version", "--json"]) + assert code == 0 + mock_cmd.assert_called_once() + args = mock_cmd.call_args[0][0] + assert args.command == "version" + assert args.json is True + + @pytest.mark.asyncio + async def test_cmd_version_json_execution(self, capsys): + """cmd_version with json=True prints compact JSON and exits with 0.""" + import json + + args = argparse.Namespace(json=True) + code = await cmd_version(args) + assert code == 0 + captured = capsys.readouterr() + + # Verify it has exactly one trailing newline and is valid JSON + assert captured.out.endswith("\n") + assert captured.out.count("\n") == 1 + + data = json.loads(captured.out.strip()) + assert data == {"version": __version__} + assert captured.err == "" + + @pytest.mark.asyncio + async def test_cmd_version_logging_isolation(self, capsys): + """When verbose logging is set, log output is routed to stderr, and only JSON goes to stdout.""" + import json + import logging + + from forge.cli import setup_logging + + # Clean up existing handlers to start fresh + root_logger = logging.getLogger() + old_handlers = list(root_logger.handlers) + old_level = root_logger.level + root_logger.handlers.clear() + + try: + setup_logging(verbose=True) + logger = logging.getLogger("test_cli_version") + logger.info("This is an info log message") + logger.debug("This is a debug log message") + + args = argparse.Namespace(json=True) + code = await cmd_version(args) + assert code == 0 + + captured = capsys.readouterr() + + # Assert stdout has ONLY the json payload + stdout_lines = captured.out.strip().split("\n") + assert len(stdout_lines) == 1 + data = json.loads(stdout_lines[0]) + assert data == {"version": __version__} + + # Assert stderr has the log messages + assert "This is an info log message" in captured.err + assert "This is a debug log message" in captured.err + finally: + # Restore handlers and level + root_logger.handlers.clear() + for h in old_handlers: + root_logger.addHandler(h) + root_logger.setLevel(old_level) From 9940ec6d4ed5a946af3d2b75b8420b289825583a Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 15 Sep 2026 12:18:33 +0000 Subject: [PATCH 2/9] [AISOS-2512] Expand and implement unit tests for --json option and parser routing Detailed description: - Added comprehensive unit and integration tests to tests/unit/test_cli_version.py. - Verified parser routing behavior and JSON payload outputs. - Covered edge cases where 'json' attribute is absent or explicitly False on argparse Namespace. - Added end-to-end stream isolation tests using main() to guarantee stdout/stderr isolation. Closes: AISOS-2512 --- tests/unit/test_cli_version.py | 50 ++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/unit/test_cli_version.py b/tests/unit/test_cli_version.py index 7886e63a1..9d058e1af 100644 --- a/tests/unit/test_cli_version.py +++ b/tests/unit/test_cli_version.py @@ -103,3 +103,53 @@ async def test_cmd_version_logging_isolation(self, capsys): for h in old_handlers: root_logger.addHandler(h) root_logger.setLevel(old_level) + + @pytest.mark.asyncio + async def test_cmd_version_no_json_attribute_defaults_to_text(self, capsys): + """When args does not contain a 'json' attribute, cmd_version defaults to plain text.""" + args = argparse.Namespace() + code = await cmd_version(args) + assert code == 0 + captured = capsys.readouterr() + assert captured.out == f"Forge v{__version__}\n" + + @pytest.mark.asyncio + async def test_cmd_version_json_explicit_false(self, capsys): + """When args has 'json' explicitly set to False, cmd_version prints plain text.""" + args = argparse.Namespace(json=False) + code = await cmd_version(args) + assert code == 0 + captured = capsys.readouterr() + assert captured.out == f"Forge v{__version__}\n" + + @patch("forge.cli.setup_logging") + def test_main_version_json_isolated(self, mock_setup_logging, capsys): + """Calling main(['-v', 'version', '--json']) prints the correct compact json to stdout and exits 0.""" + import json + + code = main(["-v", "version", "--json"]) + assert code == 0 + mock_setup_logging.assert_called_once_with(True) + captured = capsys.readouterr() + + # Verify it has exactly one trailing newline and is valid JSON + assert captured.out.endswith("\n") + assert captured.out.count("\n") == 1 + + data = json.loads(captured.out.strip()) + assert data == {"version": __version__} + assert captured.err == "" + + def test_main_version_plain_text(self, capsys): + """Calling main(['version']) prints the correct plain text to stdout and exits 0.""" + code = main(["version"]) + assert code == 0 + captured = capsys.readouterr() + assert captured.out == f"Forge v{__version__}\n" + + def test_main_version_verbose_plain_text(self, capsys): + """Calling main(['-v', 'version']) prints the correct plain text to stdout and exits 0.""" + code = main(["-v", "version"]) + assert code == 0 + captured = capsys.readouterr() + assert captured.out == f"Forge v{__version__}\n" From 0fd766ef7c07986d25bd6cd38c216e90267ce3bc Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 15 Sep 2026 12:28:47 +0000 Subject: [PATCH 3/9] [AISOS-2513] Explicitly configure root and console loggers to route exclusively to sys.stderr in src/forge/cli.py Detailed description: - Updated setup_logging in src/forge/cli.py to clear root logger's handlers first to prevent stdout logging pollution. - Configured a StreamHandler(sys.stderr) with custom level and the standard formatter format on the root logger. - Added comprehensive unit tests test_setup_logging_clears_handlers_and_routes_to_stderr in tests/unit/test_cli_version.py to verify clearing, routing, leveling, and formatting. Closes: AISOS-2513 --- src/forge/cli.py | 20 ++++++++++++---- tests/unit/test_cli_version.py | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/forge/cli.py b/src/forge/cli.py index b7d4ba3cf..cb6e57869 100644 --- a/src/forge/cli.py +++ b/src/forge/cli.py @@ -13,12 +13,22 @@ def setup_logging(verbose: bool = False) -> None: """Configure logging for CLI usage.""" + root_logger = logging.getLogger() + # Clear any pre-existing logging handlers registered on the root logger + for handler in list(root_logger.handlers): + root_logger.removeHandler(handler) + level = logging.DEBUG if verbose else logging.INFO - logging.basicConfig( - level=level, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - stream=sys.stderr, - ) + root_logger.setLevel(level) + + # Instantiate and attach a new logging.StreamHandler(sys.stderr) + handler = logging.StreamHandler(sys.stderr) + handler.setLevel(level) + + formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + handler.setFormatter(formatter) + + root_logger.addHandler(handler) async def _get_compiled_workflow_for_ticket(ticket_key: str): diff --git a/tests/unit/test_cli_version.py b/tests/unit/test_cli_version.py index 9d058e1af..4e0c48b66 100644 --- a/tests/unit/test_cli_version.py +++ b/tests/unit/test_cli_version.py @@ -104,6 +104,50 @@ async def test_cmd_version_logging_isolation(self, capsys): root_logger.addHandler(h) root_logger.setLevel(old_level) + def test_setup_logging_clears_handlers_and_routes_to_stderr(self): + """setup_logging clears existing handlers and attaches a StreamHandler(sys.stderr) with correct level and formatter.""" + import logging + import sys + + from forge.cli import setup_logging + + root_logger = logging.getLogger() + old_handlers = list(root_logger.handlers) + old_level = root_logger.level + root_logger.handlers.clear() + + # Add a dummy handler to verify it gets cleared + dummy_handler = logging.NullHandler() + root_logger.addHandler(dummy_handler) + assert dummy_handler in root_logger.handlers + + try: + # Test non-verbose logging setup + setup_logging(verbose=False) + assert dummy_handler not in root_logger.handlers + assert len(root_logger.handlers) == 1 + handler = root_logger.handlers[0] + assert isinstance(handler, logging.StreamHandler) + assert handler.stream is sys.stderr + assert root_logger.level == logging.INFO + assert handler.level == logging.INFO + assert handler.formatter is not None + assert handler.formatter._fmt == "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + + # Test verbose logging setup + setup_logging(verbose=True) + assert len(root_logger.handlers) == 1 + handler = root_logger.handlers[0] + assert isinstance(handler, logging.StreamHandler) + assert handler.stream is sys.stderr + assert root_logger.level == logging.DEBUG + assert handler.level == logging.DEBUG + finally: + root_logger.handlers.clear() + for h in old_handlers: + root_logger.addHandler(h) + root_logger.setLevel(old_level) + @pytest.mark.asyncio async def test_cmd_version_no_json_attribute_defaults_to_text(self, capsys): """When args does not contain a 'json' attribute, cmd_version defaults to plain text.""" From d9a4826e6485d2ffbcacc8fa5fdcdb67ed59fdfc Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 15 Sep 2026 12:37:48 +0000 Subject: [PATCH 4/9] [AISOS-2513] Explicitly configure root and console loggers to route exclusively to sys.stderr Detailed description: - Updated setup_logging in src/forge/cli.py to route all CLI logs to sys.stderr and prevent stdout logging pollution. - Cleared pre-existing logging handlers registered on the root logger, and attached a new logging.StreamHandler(sys.stderr). - Added logic to explicitly redirect any standard console StreamHandlers pointing to sys.stdout in auxiliary loggers to sys.stderr, or remove them if propagate is True. - Expanded tests in tests/unit/test_cli_version.py to verify that auxiliary loggers and stdout stream handlers are correctly rerouted to stderr. Closes: AISOS-2513 --- src/forge/cli.py | 12 +++++++ tests/unit/test_cli_version.py | 60 ++++++++++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/forge/cli.py b/src/forge/cli.py index cb6e57869..0aa7d9eeb 100644 --- a/src/forge/cli.py +++ b/src/forge/cli.py @@ -30,6 +30,18 @@ def setup_logging(verbose: bool = False) -> None: root_logger.addHandler(handler) + # Ensure all auxiliary loggers and standard console handlers default strictly to stderr + for logger_obj in list(logging.root.manager.loggerDict.values()): + if isinstance(logger_obj, logging.Logger): + for h in list(logger_obj.handlers): + if isinstance(h, logging.StreamHandler) and ( + h.stream is sys.stdout or h.stream == sys.stdout + ): + if logger_obj.propagate: + logger_obj.removeHandler(h) + else: + h.stream = sys.stderr + async def _get_compiled_workflow_for_ticket(ticket_key: str): """Helper to get compiled workflow for a ticket (used by CLI commands). diff --git a/tests/unit/test_cli_version.py b/tests/unit/test_cli_version.py index 4e0c48b66..786c1a36e 100644 --- a/tests/unit/test_cli_version.py +++ b/tests/unit/test_cli_version.py @@ -148,6 +148,60 @@ def test_setup_logging_clears_handlers_and_routes_to_stderr(self): root_logger.addHandler(h) root_logger.setLevel(old_level) + def test_setup_logging_configures_auxiliary_loggers(self): + """setup_logging ensures auxiliary loggers with StreamHandler(sys.stdout) are rerouted to sys.stderr.""" + import logging + import sys + + from forge.cli import setup_logging + + root_logger = logging.getLogger() + old_handlers = list(root_logger.handlers) + old_level = root_logger.level + root_logger.handlers.clear() + + # Set up an auxiliary logger with a stdout handler + aux_logger = logging.getLogger("test_auxiliary_logger") + aux_handler = logging.StreamHandler(sys.stdout) + aux_logger.addHandler(aux_handler) + + # Keep track of old state of the auxiliary logger + old_aux_handlers = list(aux_logger.handlers) + old_aux_propagate = aux_logger.propagate + + try: + # First, check behavior when propagate is True + aux_logger.propagate = True + setup_logging(verbose=False) + + # The stdout handler should have been removed because propagate is True + assert aux_handler not in aux_logger.handlers + + # Re-add and set propagate to False + aux_logger.addHandler(aux_handler) + aux_logger.propagate = False + + setup_logging(verbose=False) + + # The stdout handler stream should have been redirected to sys.stderr + assert aux_handler in aux_logger.handlers + assert aux_handler.stream is sys.stderr + + finally: + # Restore + aux_logger.handlers.clear() + for h in old_aux_handlers: + # Make sure to reset stream if we mutated it + if isinstance(h, logging.StreamHandler): + h.stream = sys.stdout + aux_logger.addHandler(h) + aux_logger.propagate = old_aux_propagate + + root_logger.handlers.clear() + for h in old_handlers: + root_logger.addHandler(h) + root_logger.setLevel(old_level) + @pytest.mark.asyncio async def test_cmd_version_no_json_attribute_defaults_to_text(self, capsys): """When args does not contain a 'json' attribute, cmd_version defaults to plain text.""" @@ -184,14 +238,16 @@ def test_main_version_json_isolated(self, mock_setup_logging, capsys): assert data == {"version": __version__} assert captured.err == "" - def test_main_version_plain_text(self, capsys): + @patch("forge.cli.setup_logging") + def test_main_version_plain_text(self, _mock_setup_logging, capsys): """Calling main(['version']) prints the correct plain text to stdout and exits 0.""" code = main(["version"]) assert code == 0 captured = capsys.readouterr() assert captured.out == f"Forge v{__version__}\n" - def test_main_version_verbose_plain_text(self, capsys): + @patch("forge.cli.setup_logging") + def test_main_version_verbose_plain_text(self, _mock_setup_logging, capsys): """Calling main(['-v', 'version']) prints the correct plain text to stdout and exits 0.""" code = main(["-v", "version"]) assert code == 0 From 1a1d028b139ae9f5e68415f6c2842670ccf136ff Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 15 Sep 2026 12:44:43 +0000 Subject: [PATCH 5/9] [AISOS-2514] Add unit-level capsys stream-isolation tests in tests/unit/test_cli_version.py Detailed description: - Added test_default_version_stream_isolation to execute main(['version']) and verify stdout contains exactly 'Forge v' while stderr remains empty. - Added test_verbose_version_stream_isolation to execute main(['-v', 'version']) and assert that stdout has only the version payload, while stderr captures verbose logging messages. - Added test_verbose_json_version_stream_isolation to verify that main(['-v', 'version', '--json']) prints a clean, parseable JSON payload on stdout and all auxiliary logs on stderr. - Ensured correct setup/teardown of root logger handlers and log levels within each test to avoid test contamination or environment pollution. Closes: AISOS-2514 --- tests/unit/test_cli_version.py | 72 ++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/unit/test_cli_version.py b/tests/unit/test_cli_version.py index 786c1a36e..43a74dbe9 100644 --- a/tests/unit/test_cli_version.py +++ b/tests/unit/test_cli_version.py @@ -253,3 +253,75 @@ def test_main_version_verbose_plain_text(self, _mock_setup_logging, capsys): assert code == 0 captured = capsys.readouterr() assert captured.out == f"Forge v{__version__}\n" + + def test_default_version_stream_isolation(self, capsys): + """Execute main(['version']) and verify stdout contains exactly 'Forge v' while stderr remains empty.""" + import logging + + root_logger = logging.getLogger() + old_handlers = list(root_logger.handlers) + old_level = root_logger.level + root_logger.handlers.clear() + + try: + code = main(["version"]) + assert code == 0 + captured = capsys.readouterr() + assert captured.out == f"Forge v{__version__}\n" + assert captured.err == "" + finally: + root_logger.handlers.clear() + for h in old_handlers: + root_logger.addHandler(h) + root_logger.setLevel(old_level) + + def test_verbose_version_stream_isolation(self, capsys): + """Execute main(['-v', 'version']) and assert that stdout has only the version payload, while stderr captures verbose logging messages.""" + import logging + + root_logger = logging.getLogger() + old_handlers = list(root_logger.handlers) + old_level = root_logger.level + root_logger.handlers.clear() + + try: + code = main(["-v", "version"]) + assert code == 0 + captured = capsys.readouterr() + assert captured.out == f"Forge v{__version__}\n" + # Since verbose is enabled, some debug/verbose logs must be captured on stderr + assert captured.err != "" + finally: + root_logger.handlers.clear() + for h in old_handlers: + root_logger.addHandler(h) + root_logger.setLevel(old_level) + + def test_verbose_json_version_stream_isolation(self, capsys): + """Verify that main(['-v', 'version', '--json']) prints a clean, parseable JSON payload on stdout and all auxiliary logs on stderr.""" + import json + import logging + + root_logger = logging.getLogger() + old_handlers = list(root_logger.handlers) + old_level = root_logger.level + root_logger.handlers.clear() + + try: + code = main(["-v", "version", "--json"]) + assert code == 0 + captured = capsys.readouterr() + + # Verify stdout contains exactly the clean JSON payload with a single trailing newline + assert captured.out.endswith("\n") + assert captured.out.count("\n") == 1 + data = json.loads(captured.out.strip()) + assert data == {"version": __version__} + + # Verify stderr captures verbose logging messages + assert captured.err != "" + finally: + root_logger.handlers.clear() + for h in old_handlers: + root_logger.addHandler(h) + root_logger.setLevel(old_level) From f35acc9fc3c4931b4d1c3ef42f4ad43a93c4c022 Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 15 Sep 2026 12:55:28 +0000 Subject: [PATCH 6/9] [AISOS-2514] Add type annotations and ensure robust capsys stream-isolation tests Detailed description: - Added comprehensive type annotations to all test functions in tests/unit/test_cli_version.py to ensure complete compliance with mypy strict type checking. - Verified and optimized tests verifying stdout/stderr separation when executing standard version and verbose/json version command options. - Ran formatting, linting, and tests to guarantee everything passes perfectly. Closes: AISOS-2514 --- tests/unit/test_cli_version.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/tests/unit/test_cli_version.py b/tests/unit/test_cli_version.py index 43a74dbe9..3151d9ac6 100644 --- a/tests/unit/test_cli_version.py +++ b/tests/unit/test_cli_version.py @@ -1,6 +1,7 @@ """Unit tests for the forge version CLI command.""" import argparse +from typing import Any from unittest.mock import AsyncMock, patch import pytest @@ -14,7 +15,7 @@ class TestCLIVersionParserAndRouting: @patch("forge.cli.cmd_version", new_callable=AsyncMock) @patch("forge.cli.setup_logging") - def test_routing_version(self, _mock_setup_logging, mock_cmd): + def test_routing_version(self, _mock_setup_logging: Any, mock_cmd: Any) -> None: """Calling main(['version']) routes to cmd_version.""" mock_cmd.return_value = 0 code = main(["version"]) @@ -24,7 +25,7 @@ def test_routing_version(self, _mock_setup_logging, mock_cmd): assert args.command == "version" @pytest.mark.asyncio - async def test_cmd_version_execution(self, capsys): + async def test_cmd_version_execution(self, capsys: Any) -> None: """cmd_version prints the correct version string and exits with 0.""" args = argparse.Namespace() code = await cmd_version(args) @@ -34,7 +35,7 @@ async def test_cmd_version_execution(self, capsys): @patch("forge.cli.cmd_version", new_callable=AsyncMock) @patch("forge.cli.setup_logging") - def test_routing_version_json(self, _mock_setup_logging, mock_cmd): + def test_routing_version_json(self, _mock_setup_logging: Any, mock_cmd: Any) -> None: """Calling main(['version', '--json']) routes to cmd_version with args.json=True.""" mock_cmd.return_value = 0 code = main(["version", "--json"]) @@ -45,7 +46,7 @@ def test_routing_version_json(self, _mock_setup_logging, mock_cmd): assert args.json is True @pytest.mark.asyncio - async def test_cmd_version_json_execution(self, capsys): + async def test_cmd_version_json_execution(self, capsys: Any) -> None: """cmd_version with json=True prints compact JSON and exits with 0.""" import json @@ -63,7 +64,7 @@ async def test_cmd_version_json_execution(self, capsys): assert captured.err == "" @pytest.mark.asyncio - async def test_cmd_version_logging_isolation(self, capsys): + async def test_cmd_version_logging_isolation(self, capsys: Any) -> None: """When verbose logging is set, log output is routed to stderr, and only JSON goes to stdout.""" import json import logging @@ -104,7 +105,7 @@ async def test_cmd_version_logging_isolation(self, capsys): root_logger.addHandler(h) root_logger.setLevel(old_level) - def test_setup_logging_clears_handlers_and_routes_to_stderr(self): + def test_setup_logging_clears_handlers_and_routes_to_stderr(self) -> None: """setup_logging clears existing handlers and attaches a StreamHandler(sys.stderr) with correct level and formatter.""" import logging import sys @@ -148,7 +149,7 @@ def test_setup_logging_clears_handlers_and_routes_to_stderr(self): root_logger.addHandler(h) root_logger.setLevel(old_level) - def test_setup_logging_configures_auxiliary_loggers(self): + def test_setup_logging_configures_auxiliary_loggers(self) -> None: """setup_logging ensures auxiliary loggers with StreamHandler(sys.stdout) are rerouted to sys.stderr.""" import logging import sys @@ -203,7 +204,7 @@ def test_setup_logging_configures_auxiliary_loggers(self): root_logger.setLevel(old_level) @pytest.mark.asyncio - async def test_cmd_version_no_json_attribute_defaults_to_text(self, capsys): + async def test_cmd_version_no_json_attribute_defaults_to_text(self, capsys: Any) -> None: """When args does not contain a 'json' attribute, cmd_version defaults to plain text.""" args = argparse.Namespace() code = await cmd_version(args) @@ -212,7 +213,7 @@ async def test_cmd_version_no_json_attribute_defaults_to_text(self, capsys): assert captured.out == f"Forge v{__version__}\n" @pytest.mark.asyncio - async def test_cmd_version_json_explicit_false(self, capsys): + async def test_cmd_version_json_explicit_false(self, capsys: Any) -> None: """When args has 'json' explicitly set to False, cmd_version prints plain text.""" args = argparse.Namespace(json=False) code = await cmd_version(args) @@ -221,7 +222,7 @@ async def test_cmd_version_json_explicit_false(self, capsys): assert captured.out == f"Forge v{__version__}\n" @patch("forge.cli.setup_logging") - def test_main_version_json_isolated(self, mock_setup_logging, capsys): + def test_main_version_json_isolated(self, mock_setup_logging: Any, capsys: Any) -> None: """Calling main(['-v', 'version', '--json']) prints the correct compact json to stdout and exits 0.""" import json @@ -239,7 +240,7 @@ def test_main_version_json_isolated(self, mock_setup_logging, capsys): assert captured.err == "" @patch("forge.cli.setup_logging") - def test_main_version_plain_text(self, _mock_setup_logging, capsys): + def test_main_version_plain_text(self, _mock_setup_logging: Any, capsys: Any) -> None: """Calling main(['version']) prints the correct plain text to stdout and exits 0.""" code = main(["version"]) assert code == 0 @@ -247,14 +248,14 @@ def test_main_version_plain_text(self, _mock_setup_logging, capsys): assert captured.out == f"Forge v{__version__}\n" @patch("forge.cli.setup_logging") - def test_main_version_verbose_plain_text(self, _mock_setup_logging, capsys): + def test_main_version_verbose_plain_text(self, _mock_setup_logging: Any, capsys: Any) -> None: """Calling main(['-v', 'version']) prints the correct plain text to stdout and exits 0.""" code = main(["-v", "version"]) assert code == 0 captured = capsys.readouterr() assert captured.out == f"Forge v{__version__}\n" - def test_default_version_stream_isolation(self, capsys): + def test_default_version_stream_isolation(self, capsys: Any) -> None: """Execute main(['version']) and verify stdout contains exactly 'Forge v' while stderr remains empty.""" import logging @@ -275,7 +276,7 @@ def test_default_version_stream_isolation(self, capsys): root_logger.addHandler(h) root_logger.setLevel(old_level) - def test_verbose_version_stream_isolation(self, capsys): + def test_verbose_version_stream_isolation(self, capsys: Any) -> None: """Execute main(['-v', 'version']) and assert that stdout has only the version payload, while stderr captures verbose logging messages.""" import logging @@ -297,7 +298,7 @@ def test_verbose_version_stream_isolation(self, capsys): root_logger.addHandler(h) root_logger.setLevel(old_level) - def test_verbose_json_version_stream_isolation(self, capsys): + def test_verbose_json_version_stream_isolation(self, capsys: Any) -> None: """Verify that main(['-v', 'version', '--json']) prints a clean, parseable JSON payload on stdout and all auxiliary logs on stderr.""" import json import logging From bfaba825eadbdec4b2a7aed449f8fe86912e784d Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 15 Sep 2026 13:04:54 +0000 Subject: [PATCH 7/9] [AISOS-2515] Implement CLI integration and subprocess-level stream routing tests Detailed description: - Created src/forge/__main__.py as the module entry point, which invokes the CLI main entrypoint, allowing python -m forge commands to be fully functional. - Added test_subprocess_version_plain_text, test_subprocess_version_verbose_plain_text, and test_subprocess_version_verbose_json to tests/unit/test_cli_version.py to execute the CLI package via python -m forge in an actual subprocess, capturing stdout/stderr and asserting stream isolation. Closes: AISOS-2515 --- src/forge/__main__.py | 8 ++++ tests/unit/test_cli_version.py | 79 ++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 src/forge/__main__.py diff --git a/src/forge/__main__.py b/src/forge/__main__.py new file mode 100644 index 000000000..9a8e25127 --- /dev/null +++ b/src/forge/__main__.py @@ -0,0 +1,8 @@ +"""Main entry point for forge when run as a module.""" + +import sys + +from forge.cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/unit/test_cli_version.py b/tests/unit/test_cli_version.py index 3151d9ac6..a287b1f30 100644 --- a/tests/unit/test_cli_version.py +++ b/tests/unit/test_cli_version.py @@ -326,3 +326,82 @@ def test_verbose_json_version_stream_isolation(self, capsys: Any) -> None: for h in old_handlers: root_logger.addHandler(h) root_logger.setLevel(old_level) + + def test_subprocess_version_plain_text(self) -> None: + """Verify stream separation for 'python -m forge version' under actual subprocess execution.""" + import os + import subprocess + import sys + + env = os.environ.copy() + # Add src/ to PYTHONPATH to be absolutely sure the module can be imported + if "PYTHONPATH" in env: + env["PYTHONPATH"] = f"src{os.pathsep}{env['PYTHONPATH']}" + else: + env["PYTHONPATH"] = "src" + + res = subprocess.run( + [sys.executable, "-m", "forge", "version"], + capture_output=True, + text=True, + env=env, + ) + + assert res.returncode == 0 + assert res.stdout == f"Forge v{__version__}\n" + assert res.stderr == "" + + def test_subprocess_version_verbose_plain_text(self) -> None: + """Verify stream separation for 'python -m forge -v version' under actual subprocess execution.""" + import os + import subprocess + import sys + + env = os.environ.copy() + if "PYTHONPATH" in env: + env["PYTHONPATH"] = f"src{os.pathsep}{env['PYTHONPATH']}" + else: + env["PYTHONPATH"] = "src" + + res = subprocess.run( + [sys.executable, "-m", "forge", "-v", "version"], + capture_output=True, + text=True, + env=env, + ) + + assert res.returncode == 0 + assert res.stdout == f"Forge v{__version__}\n" + # Since verbose is enabled, some debug/verbose logs must be captured on stderr, but stdout remains clean + assert res.stderr != "" + + def test_subprocess_version_verbose_json(self) -> None: + """Verify stream separation for 'python -m forge -v version --json' under actual subprocess execution.""" + import json + import os + import subprocess + import sys + + env = os.environ.copy() + if "PYTHONPATH" in env: + env["PYTHONPATH"] = f"src{os.pathsep}{env['PYTHONPATH']}" + else: + env["PYTHONPATH"] = "src" + + res = subprocess.run( + [sys.executable, "-m", "forge", "-v", "version", "--json"], + capture_output=True, + text=True, + env=env, + ) + + assert res.returncode == 0 + + # Verify stdout contains exactly the clean JSON payload with a single trailing newline + assert res.stdout.endswith("\n") + assert res.stdout.count("\n") == 1 + data = json.loads(res.stdout.strip()) + assert data == {"version": __version__} + + # Verify stderr captures verbose logging messages, but stdout remains free of logs + assert res.stderr != "" From d36d63e14e71a7d91823150b1eadcd2ed55fe76a Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 15 Sep 2026 13:18:27 +0000 Subject: [PATCH 8/9] [AISOS-2507-review] Fix breaking test in test_implement_work.py by mocking ContainerRunner Detailed description: - Mocked ContainerRunner in test_implements_resolved_task_and_marks_normalized_work_complete. This prevents podman-not-available errors when running unit tests in environments without a full podman installation. - Added type annotations to local test helpers and mock definitions to pass strict PEP-484 mypy static type analysis. Closes: AISOS-2507-review --- tests/unit/workflow/nodes/test_implement_work.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/unit/workflow/nodes/test_implement_work.py b/tests/unit/workflow/nodes/test_implement_work.py index 1b752d065..f4c65ffc4 100644 --- a/tests/unit/workflow/nodes/test_implement_work.py +++ b/tests/unit/workflow/nodes/test_implement_work.py @@ -1,6 +1,7 @@ """Tests for the generic task-first implementation node.""" from types import SimpleNamespace +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -9,7 +10,7 @@ from forge.workflow.stations.implementation_input import NoPendingImplementationWork -def resolved_task(): +def resolved_task() -> SimpleNamespace: artifact = { "id": "jira:TASK-1:task", "kind": "task", @@ -41,7 +42,7 @@ async def test_implements_resolved_task_and_marks_normalized_work_complete() -> jira.close = AsyncMock() git = MagicMock() - async def execute(state, *_args, **_kwargs): + async def execute(state: dict[str, Any], *_args: Any, **_kwargs: Any) -> dict[str, Any]: return {**state, "last_error": None, "commit_info": {"committed": True}} with ( @@ -71,6 +72,10 @@ async def execute(state, *_args, **_kwargs): AsyncMock(side_effect=lambda _state, _jira, prompt: prompt), ), patch("forge.workflow.nodes.implement_work.post_status_comment", AsyncMock()), + patch( + "forge.workflow.nodes.implement_work.ContainerRunner", + return_value=MagicMock(), + ), patch( "forge.workflow.nodes.implement_work.run_and_persist_execution", AsyncMock(side_effect=execute), From 84c5d64447fbaa4e4a34ac129f69431d5f4a41fb Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 15 Sep 2026 13:21:54 +0000 Subject: [PATCH 9/9] [AISOS-2507-docs] Update stale documentation for CLI and logging changes Detailed description: - Updated the Forge Version Command section in docs/developer-guide.md to detail plain-text output, --json formatted output, and module-level invocation. - Corrected the Worker logs section in docs/developer-guide.md to indicate that logs route strictly to stderr to prevent stdout payload contamination. - Added version JSON and module execution commands to CLAUDE.md. Closes: AISOS-2507-docs --- CLAUDE.md | 4 ++++ docs/developer-guide.md | 20 +++++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e976693f6..30742bbce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,10 @@ uv run forge worker # Print Forge version uv run forge version +uv run forge version --json + +# Run Forge as a module +python -m forge version # Build container podman build -t forge-dev:latest containers/ diff --git a/docs/developer-guide.md b/docs/developer-guide.md index e05d0ab73..b4b8ac138 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -723,17 +723,31 @@ For workflows paused at `review_response_gate` (due to contested comments): ### Forge Version Command -To print the currently installed Forge package version, run: +To print the currently installed Forge package version in plain text, run: ```bash uv run forge version ``` -This will print the package version in the format `Forge v` (e.g., `Forge v1.0.0`) and exit with a success status code. +This will print the package version in the format `Forge v` (e.g., `Forge v2.0.0`) and exit with a success status code. + +To print the version information as a JSON object, run: + +```bash +uv run forge version --json +``` + +This will print the version metadata in compact JSON format (e.g., `{"version": "2.0.0"}`) directly to standard output. + +Alternatively, you can run Forge as a module using the `python -m` option: + +```bash +python -m forge version +``` ### Worker logs -The worker logs to stdout. Useful log entries to grep for: +The worker logs strictly to stderr to prevent log messages from polluting standard output. Useful log entries to grep for: ```bash # Watch for a specific ticket