diff --git a/scripts/check_doc_gate.py b/scripts/check_doc_gate.py index fed729d40..627ef3537 100644 --- a/scripts/check_doc_gate.py +++ b/scripts/check_doc_gate.py @@ -37,6 +37,13 @@ DEFAULT_CONFIG = REPO_ROOT / "docs" / "doc-gate.toml" DEFAULT_TRAILER = "Docs-Reviewed:" +# Exit codes: 0 clean, 1 a doc-gate violation, 2 a config/usage error +# (broken, missing, or unparseable config). Distinct so a misconfigured gate +# is never mistaken for a real documentation-drift violation. +EXIT_OK = 0 +EXIT_VIOLATION = 1 +EXIT_CONFIG_ERROR = 2 + # A path-like token: one of the four known repo prefixes followed by a run of # non-whitespace / non-quoting characters. The negative lookbehind stops us # matching a prefix that is actually embedded inside a larger path (e.g. the @@ -254,10 +261,10 @@ def get_trailer(config: dict) -> str: def _report(failures: list[str]) -> int: if not failures: print("doc-gate: clean") - return 0 + return EXIT_OK for failure in failures: print(f"DOC-GATE FAIL: {failure}") - return 1 + return EXIT_VIOLATION def main(argv: list[str] | None = None) -> int: @@ -273,7 +280,11 @@ def main(argv: list[str] | None = None) -> int: group.add_argument("--base", help="Compare ...HEAD (CI / commit-msg)") args = parser.parse_args(argv) - config = load_config(args.config) + try: + config = load_config(args.config) + except (tomllib.TOMLDecodeError, OSError) as e: + print(f"doc-gate: config error: {args.config}: {e}", file=sys.stderr) + return EXIT_CONFIG_ERROR if args.command == "invariants": files_to_scan = config.get("invariants", {}).get("referenced_paths_scan", []) diff --git a/tests/test_doc_gate.py b/tests/test_doc_gate.py index 1525d16eb..54669aea1 100644 --- a/tests/test_doc_gate.py +++ b/tests/test_doc_gate.py @@ -295,3 +295,34 @@ def test_desktop_shell_test_file_does_not_trigger(self): ("A", "desktop/src/stores/__tests__/theme-store.test.ts"), ] assert dg.evaluate_rules(changed, [], CHANGELOG_RULE_CONFIG) == [] + + +class TestConfigErrorExitCode: + """A broken/unparseable config must exit distinctly from a real violation. + + Regression: previously an unparseable config raised an unhandled traceback + that exited 1 -- identical to a genuine doc-gate violation -- so a typo in + docs/doc-gate.toml looked just like a missing changelog.""" + + def test_unparseable_config_returns_config_error_code(self, tmp_path, capsys): + bad = tmp_path / "bad.toml" + bad.write_text('key = "unterminated string\n') + rc = dg.main(["--config", str(bad), "print-trailer"]) + captured = capsys.readouterr() + assert rc == dg.EXIT_CONFIG_ERROR + assert rc != dg.EXIT_VIOLATION + assert "config error" in captured.err + + def test_missing_config_file_returns_config_error_code(self, tmp_path): + missing = tmp_path / "does_not_exist.toml" + rc = dg.main(["--config", str(missing), "print-trailer"]) + assert rc == dg.EXIT_CONFIG_ERROR + assert rc != dg.EXIT_VIOLATION + + def test_real_violation_returns_violation_not_config_error(self): + """Exit code 1 (violation) must remain distinct from exit code 2 + (config error).""" + changed = [("A", "tinyagentos/routes/themes.py")] + failures = dg.evaluate_rules(changed, [], APPS_RULE_CONFIG) + assert dg._report(failures) == dg.EXIT_VIOLATION +