From 3bb0486d66b38da909998e390c185b27af211ed8 Mon Sep 17 00:00:00 2001 From: cowork-bot Date: Fri, 21 Aug 2026 06:33:55 -0400 Subject: [PATCH 1/2] cowork-bot: scan command fails loudly when no config files load (silent-green guard) - exit 1 if zero config files loaded across all environment dirs - exit 1 if baseline env loaded no keys (empty-baseline diff would flag everything) - +5 regression tests in TestScanEmptyGuards; suite 148 passed, ruff clean --- src/configdrift/cli.py | 18 +++++++++++++ tests/test_cli.py | 58 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/configdrift/cli.py b/src/configdrift/cli.py index cb471ff..8d1343a 100644 --- a/src/configdrift/cli.py +++ b/src/configdrift/cli.py @@ -280,6 +280,7 @@ def scan( raise typer.Exit(code=1) env_configs: dict[str, dict[str, Any]] = {} + files_loaded = 0 for env_name, dir_path in dir_mapping.items(): env_configs[env_name] = {} p = Path(dir_path) @@ -294,9 +295,26 @@ def scan( try: data = load_file(str(f)) env_configs[env_name].update(data) + files_loaded += 1 except Exception as e: console.print(f"[yellow]Warning: could not load {f}: {e}[/yellow]") + # Silent-failure guard: if nothing was actually loaded, any "no drift" + # result would be a false green. Fail loudly instead. + if files_loaded == 0: + console.print( + "[red]ERROR: No config files could be loaded from any environment " + "directory. Refusing to report 'no drift' from an empty scan.[/red]" + ) + raise typer.Exit(code=1) + if not env_configs.get(baseline): + console.print( + f"[red]ERROR: Baseline environment '{baseline}' loaded no config " + "keys; comparison against an empty baseline would flag every key " + "as drift.[/red]" + ) + raise typer.Exit(code=1) + results = diff_environments(env_configs, baseline_env=baseline) if output == OutputFormat.JSON: diff --git a/tests/test_cli.py b/tests/test_cli.py index 4c4667c..f2115f0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -473,3 +473,61 @@ def test_version_output(self): assert result.exit_code == 0 assert "configdrift" in result.stdout assert "0.1.0" in result.stdout + + +class TestScanEmptyGuards: + """Regression: scan must not report a false-green 'no drift' when nothing loaded.""" + + def test_scan_all_dirs_missing_exits_1(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = runner.invoke( + app, ["scan", str(Path(tmpdir) / "nope1"), str(Path(tmpdir) / "nope2")] + ) + assert result.exit_code == 1 + # Either the baseline-not-found guard or the empty-scan guard fires. + assert ( + "not found" in result.stdout + or "No config files could be loaded" in result.stdout + ) + + def test_scan_empty_dirs_exits_1(self): + with tempfile.TemporaryDirectory() as tmpdir: + dev = Path(tmpdir) / "dev" + prod = Path(tmpdir) / "prod" + dev.mkdir() + prod.mkdir() + result = runner.invoke(app, ["scan", str(dev), str(prod)]) + assert result.exit_code == 1 + assert "No config files could be loaded" in result.stdout + + def test_scan_baseline_dir_missing_exits_1(self): + with tempfile.TemporaryDirectory() as tmpdir: + prod = Path(tmpdir) / "prod" + prod.mkdir() + (prod / "config.yaml").write_text(yaml.dump({"host": "prod.example.com"})) + result = runner.invoke(app, ["scan", str(prod), "--baseline", "dev"]) + assert result.exit_code == 1 + + def test_scan_baseline_loaded_nothing_exits_1(self): + """Baseline dir exists but only contains unparseable files -> refuse empty-baseline diff.""" + with tempfile.TemporaryDirectory() as tmpdir: + dev = Path(tmpdir) / "dev" + prod = Path(tmpdir) / "prod" + dev.mkdir() + prod.mkdir() + (dev / "broken.yaml").write_text(":::: not yaml :::\n") + (prod / "config.yaml").write_text(yaml.dump({"host": "prod.example.com"})) + result = runner.invoke(app, ["scan", str(dev), str(prod)]) + assert result.exit_code == 1 + assert "loaded no config" in result.stdout + + def test_scan_healthy_still_works(self): + with tempfile.TemporaryDirectory() as tmpdir: + dev = Path(tmpdir) / "dev" + prod = Path(tmpdir) / "prod" + dev.mkdir() + prod.mkdir() + (dev / "config.yaml").write_text(yaml.dump({"host": "localhost"})) + (prod / "config.yaml").write_text(yaml.dump({"host": "prod.example.com"})) + result = runner.invoke(app, ["scan", str(dev), str(prod)]) + assert result.exit_code == 0 From 51f31daea838157cabbd5cc95294e07700ec596c Mon Sep 17 00:00:00 2001 From: cowork-bot Date: Sun, 23 Aug 2026 18:58:00 -0400 Subject: [PATCH 2/2] =?UTF-8?q?cowork-bot:=20type-sensitive=20diff=20compa?= =?UTF-8?q?rison=20=E2=80=94=20flag=20bool/number=20cross-type=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python's == treats True == 1, so a config change like 'debug: true -> debug: 1' silently compared as no drift. _values_differ() now flags cross-type changes (bool vs number) while same-type comparisons are unchanged. +7 regression tests in TestTypeSensitiveComparison; suite 155 passed, ruff clean. --- src/configdrift/diff.py | 15 ++++++++++++++- tests/test_diff.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/configdrift/diff.py b/src/configdrift/diff.py index 8b657d8..4a62189 100644 --- a/src/configdrift/diff.py +++ b/src/configdrift/diff.py @@ -128,6 +128,19 @@ def by_severity(self, severity: Severity) -> list[Change]: return [c for c in self.changes if c.severity == severity] +def _values_differ(old: Any, new: Any) -> bool: + """Type-sensitive value comparison. + + Python's ``==`` treats ``True == 1`` and ``False == 0``, so a config change + like ``debug: true -> debug: 1`` would silently compare equal. A drift + detector must flag cross-type changes (bool vs number) even when values + compare equal. + """ + if isinstance(old, bool) != isinstance(new, bool): + return True + return old != new + + def diff_configs( base: dict[str, Any], target: dict[str, Any], @@ -162,7 +175,7 @@ def diff_configs( env=base_env, ) ) - elif old_val != new_val: + elif _values_differ(old_val, new_val): result.changes.append( Change( key=key, diff --git a/tests/test_diff.py b/tests/test_diff.py index 9307850..beb7615 100644 --- a/tests/test_diff.py +++ b/tests/test_diff.py @@ -374,3 +374,37 @@ def test_authentication_not_auth(self): def test_authz_not_auth(self): assert _infer_severity_added("authz", "x") == Severity.WARNING + + +class TestTypeSensitiveComparison: + """bool-vs-number equivalence hole: True == 1 in Python must still be drift.""" + + def test_bool_true_vs_int_one_is_change(self): + result = diff_configs({"debug": True}, {"debug": 1}) + assert result.count == 1 + change = result.changes[0] + assert change.change_type == ChangeType.CHANGED + assert change.old_value is True + assert change.new_value == 1 + + def test_bool_false_vs_int_zero_is_change(self): + result = diff_configs({"debug": False}, {"debug": 0}) + assert result.count == 1 + assert result.changes[0].change_type == ChangeType.CHANGED + + def test_same_bool_values_not_drift(self): + assert diff_configs({"debug": True}, {"debug": True}).count == 0 + + def test_same_int_values_not_drift(self): + assert diff_configs({"port": 5432}, {"port": 5432}).count == 0 + + def test_str_vs_int_still_detected(self): + assert diff_configs({"port": "5432"}, {"port": 5432}).count == 1 + + def test_bool_vs_string_detected(self): + assert diff_configs({"debug": True}, {"debug": "true"}).count == 1 + + def test_nested_flattened_key_bool_vs_number(self): + base = {"app.debug": True} + target = {"app.debug": 1} + assert diff_configs(base, target).count == 1