From e0c833b0b3d7e36c5a55450600e01d8b80097756 Mon Sep 17 00:00:00 2001 From: senior-dev-rotation-B Date: Thu, 16 Jul 2026 01:06:40 -0400 Subject: [PATCH 01/10] fix: correct click-to-mcp install advice to working git+ URL click-to-mcp is not published on PyPI (pip install click-to-mcp -> 'No matching distribution found'), so the runtime error messages that tell users to 'pip install click-to-mcp' are wrong and leave them stuck. This restores the git+ install form (previously added in a5874e0 and accidentally reverted by aa004b0) across cli.py and mcp_server.py. Reviewed and approved by council_gate_code_edit (APPROVE). --- src/deploydiff/cli.py | 2 +- src/deploydiff/mcp_server.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/deploydiff/cli.py b/src/deploydiff/cli.py index 46902be..3811321 100644 --- a/src/deploydiff/cli.py +++ b/src/deploydiff/cli.py @@ -271,7 +271,7 @@ def mcp() -> None: from .mcp_server import run_for_app except ImportError as exc: console.print( - "[red]Error: click-to-mcp is not installed.[/red]\nInstall it with: [bold]pip install click-to-mcp[/bold]" + "[red]Error: click-to-mcp is not installed.[/red]\nInstall it with: [bold]pip install git+https://github.com/Coding-Dev-Tools/click-to-mcp.git[/bold]" ) raise SystemExit(1) from exc diff --git a/src/deploydiff/mcp_server.py b/src/deploydiff/mcp_server.py index a59135e..e9c90ca 100644 --- a/src/deploydiff/mcp_server.py +++ b/src/deploydiff/mcp_server.py @@ -19,7 +19,7 @@ def run_mcp() -> None: import sys print( - "Error: click-to-mcp is not installed. Install it with: pip install click-to-mcp", + "Error: click-to-mcp is not installed. Install it with: pip install git+https://github.com/Coding-Dev-Tools/click-to-mcp.git", file=sys.stderr, ) sys.exit(1) @@ -37,7 +37,7 @@ def run_for_app(app: object) -> None: import sys print( - "Error: click-to-mcp is not installed. Install it with: pip install click-to-mcp", + "Error: click-to-mcp is not installed. Install it with: pip install git+https://github.com/Coding-Dev-Tools/click-to-mcp.git", file=sys.stderr, ) sys.exit(1) From 24a77afa7d05ee5474639b1ace0ec132ce1d72b7 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Thu, 16 Jul 2026 08:35:15 -0400 Subject: [PATCH 02/10] fix(marketing): correct deploydiff install claims (remove false PyPI badge, git+ install, honesty note) --- README.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8ae652f..802bf37 100644 --- a/README.md +++ b/README.md @@ -11,15 +11,12 @@ Preview infrastructure changes with human-readable diffs, cost impact estimation [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/Coding-Dev-Tools/deploydiff/blob/main/LICENSE) [![Open Source Alternative](https://img.shields.io/badge/Open_Source_Alternative-%E2%87%92-blue?logo=opensourceinitiative)](https://www.opensourcealternative.to/project/deploydiff) |[![LibHunt](https://img.shields.io/badge/LibHunt-%E2%87%92-blue?logo=codeigniter)](https://www.libhunt.com/r/Coding-Dev-Tools/deploydiff) -|[![PyPI](https://img.shields.io/pypi/v/deploydiff)](https://pypi.org/project/deploydiff/) +|[![PyPI](https://img.shields.io/badge/PyPI-not%20published-orange)](https://github.com/Coding-Dev-Tools/deploydiff#installation)| ## Installation -```bash -pip install deploydiff -``` +DeployDiff is not published on public PyPI (publishing is pending). Install directly from GitHub: -Or install the latest version directly from GitHub: ```bash pip install git+https://github.com/Coding-Dev-Tools/deploydiff.git ``` From a2846b92eb4c9210cc14c6db6b27734879865fd4 Mon Sep 17 00:00:00 2001 From: cowork-bot Date: Thu, 16 Jul 2026 17:07:01 -0400 Subject: [PATCH 03/10] fix(marketing): correct broken bare pip install in AGENTS.md (use self-hosted index/git+) --- AGENTS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 7c39a1e..d4cc5fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,10 @@ Compare deployment configurations across environments. Detect drift between staging and production configs. Preview infrastructure changes with human-readable diffs, cost impact estimation, and rollback commands. ## Build & Test Commands -- Install: `pip install -e .` or `pip install deploydiff` +- Install (editable, from this repo): `pip install -e .` +- Install (prebuilt wheel from the self-hosted index): `pip install --index-url https://coding-dev-tools.github.io/pypi-index/simple/ deploydiff` +- Install (from source): `pip install git+https://github.com/Coding-Dev-Tools/deploydiff.git` +- NOTE: `deploydiff` is NOT on public PyPI — use the self-hosted index or a `git+` URL above. - Test: `pytest tests/` (or `python -m pytest tests/ -v --tb=short`) - Lint: `ruff check .` - Build: `pip install build twine && python -m build && twine check dist/*` From 468b34497243ae735a7d06e1d03ab0f33ade8577 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Mon, 20 Jul 2026 23:48:24 -0400 Subject: [PATCH 04/10] fix(renderer): remove module_path doubling in rendered address + add 3 edge-case tests --- src/deploydiff/diff_renderer.py | 2 - tests/test_deploydiff.py | 85 ++++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/deploydiff/diff_renderer.py b/src/deploydiff/diff_renderer.py index c082607..6fdccc5 100644 --- a/src/deploydiff/diff_renderer.py +++ b/src/deploydiff/diff_renderer.py @@ -123,8 +123,6 @@ def _render_action_group( for change in changes: symbol = change.display_action addr = change.address - if change.module_path: - addr = f"{change.module_path}.{addr}" table.add_row( f"[{color}]{symbol}[/{color}]", f"[{color}]{addr}[/{color}]", diff --git a/tests/test_deploydiff.py b/tests/test_deploydiff.py index 9974c09..b2e5588 100644 --- a/tests/test_deploydiff.py +++ b/tests/test_deploydiff.py @@ -1182,10 +1182,93 @@ def test_missing_urn_in_step(self): assert len(plan.changes) == 1 def test_mcp_without_click_to_mcp(self): - """MCP command exits 1 when click-to-mcp is not installed.""" + "MCP command exits 1 when click-to-mcp is not installed." runner = CliRunner() result = runner.invoke(main, ["mcp"]) # Either exits 1 (ImportError caught) or 0 (if click-to-mcp is installed) assert result.exit_code in (0, 1) if result.exit_code == 1: assert "click-to-mcp" in result.output.lower() + + +class TestDiffRendererEdgeCases: + "Targeted tests for diff_renderer edge cases and module_path behavior." + + def test_render_module_path_not_doubled(self): + "Module path should NOT be doubled in the rendered address." + from io import StringIO + + from rich.console import Console + + from deploydiff.diff_renderer import _render_action_group + + change = ResourceChange( + address="module.vpc.aws_nat_gateway.main", + action=ChangeAction.REPLACE, + resource_type="aws_nat_gateway", + resource_name="main", + source=ChangeSource.TERRAFORM, + module_path="module.vpc", + ) + plan = DeployPlan(source=ChangeSource.TERRAFORM, changes=[change]) + buf = StringIO() + console = Console(file=buf, force_terminal=True) + _render_action_group(plan, ChangeAction.REPLACE, [change], console, verbose=False) + output = buf.getvalue() + # The rendered address should appear exactly once, not doubled + assert "module.vpc.aws_nat_gateway.main" in output + # The doubled form would be "module.vpc.module.vpc.aws_nat_gateway.main" + doubled = "module.vpc.module.vpc" + assert doubled not in output, f"Address doubled: {output[:500]}" + + def test_render_change_details_unchanged_value(self): + "Keys with same before/after value show without diff markers." + from io import StringIO + + from rich.console import Console + + from deploydiff.diff_renderer import _render_change_details + + change = ResourceChange( + address="aws_instance.web", + action=ChangeAction.UPDATE, + resource_type="aws_instance", + resource_name="web", + source=ChangeSource.TERRAFORM, + before={"instance_type": "t3.micro", "ami": "ami-old"}, + after={"instance_type": "t3.micro", "ami": "ami-new"}, + ) + buf = StringIO() + console = Console(file=buf, force_terminal=True) + _render_change_details(change, console) + output = buf.getvalue() + assert "instance_type" in output + # t3.micro is unchanged, should appear without +/- markers + assert "t3.micro" in output + + def test_render_change_details_missing_key(self): + "Key present in one state but not the other uses em-dash fallback." + from io import StringIO + + from rich.console import Console + + from deploydiff.diff_renderer import _render_change_details + + change = ResourceChange( + address="aws_instance.web", + action=ChangeAction.UPDATE, + resource_type="aws_instance", + resource_name="web", + source=ChangeSource.TERRAFORM, + before={"instance_type": "t3.micro", "old_key": "old_val"}, + after={"instance_type": "t3.large", "new_key": "new_val"}, + ) + buf = StringIO() + console = Console(file=buf, force_terminal=True) + _render_change_details(change, console) + output = buf.getvalue() + assert "instance_type" in output + # old_key should show "old_val" on the before side, em-dash on after + assert "old_key" in output + # Use some assertion that verifies em-dash appears (Rich renders these as Unicode) + assert "new_key" in output From 086da0bc6005a74ac90c2ef66754c1442e22135e Mon Sep 17 00:00:00 2001 From: Jaixii Date: Tue, 21 Jul 2026 13:43:24 -0400 Subject: [PATCH 05/10] fix(ci): correct checkout SHA pin to actual v4 tag (11d5960) --- .github/workflows/ci.yml | 2 +- .github/workflows/publish.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4601ee6..de7cbbe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: persist-credentials: false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f4a07bd..6266823 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,7 +21,7 @@ jobs: id-token: write steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: persist-credentials: false From 1b7dea2ee32511627774d4000f991eb1466e2fd7 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Tue, 21 Jul 2026 17:17:30 -0400 Subject: [PATCH 06/10] chore(gitignore): ignore package-lock.json (Python-only project, npm lock artifact) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 9063c18..7c0ca03 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,6 @@ local.db # Added by release-prep node_modules + +# npm lock artifact (Python-only project) +package-lock.json From 6dda04d7708f3f05b30c52c912d291c8a846fb79 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Tue, 21 Jul 2026 20:50:18 -0400 Subject: [PATCH 07/10] fix(ci): revert invalid actions/checkout SHA pin to @v4 (11d5960 is not a valid ref) --- .github/workflows/ci.yml | 2 +- .github/workflows/publish.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de7cbbe..87a6713 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - uses: actions/checkout@v4 with: persist-credentials: false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6266823..c62ad6b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,7 +21,7 @@ jobs: id-token: write steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - uses: actions/checkout@v4 with: persist-credentials: false From d4dff7ee1babd7009d8e53006e8b7e48eaba6f85 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Mon, 3 Aug 2026 06:50:34 -0400 Subject: [PATCH 08/10] cowork-bot: reject non-object plan JSON Fail clearly when Terraform, CloudFormation, or Pulumi input decodes to a non-object JSON value. --- src/deploydiff/cloudformation_parser.py | 3 +++ src/deploydiff/pulumi_parser.py | 3 +++ src/deploydiff/terraform_parser.py | 3 +++ tests/test_parse_errors.py | 13 +++++++++++++ 4 files changed, 22 insertions(+) diff --git a/src/deploydiff/cloudformation_parser.py b/src/deploydiff/cloudformation_parser.py index f082586..07c4fdb 100644 --- a/src/deploydiff/cloudformation_parser.py +++ b/src/deploydiff/cloudformation_parser.py @@ -52,6 +52,9 @@ def parse_cloudformation_changeset(changeset_json: str | dict[str, Any]) -> Depl else: data = changeset_json + if not isinstance(data, dict): + raise ValueError("Change set input must be a JSON object") + changes: list[ResourceChange] = [] changes_list = data.get("Changes", data.get("changes", [])) diff --git a/src/deploydiff/pulumi_parser.py b/src/deploydiff/pulumi_parser.py index 37fb446..2f7b8dc 100644 --- a/src/deploydiff/pulumi_parser.py +++ b/src/deploydiff/pulumi_parser.py @@ -50,6 +50,9 @@ def parse_pulumi_preview(preview_json: str | dict[str, Any]) -> DeployPlan: else: data = preview_json + if not isinstance(data, dict): + raise ValueError("Preview input must be a JSON object") + changes: list[ResourceChange] = [] # Pulumi preview JSON has a "steps" array diff --git a/src/deploydiff/terraform_parser.py b/src/deploydiff/terraform_parser.py index f1a3fd4..a6eeb6b 100644 --- a/src/deploydiff/terraform_parser.py +++ b/src/deploydiff/terraform_parser.py @@ -44,6 +44,9 @@ def parse_terraform_plan(plan_json: str | dict[str, Any]) -> DeployPlan: else: data = plan_json + if not isinstance(data, dict): + raise ValueError("Plan input must be a JSON object") + format_version = data.get("format_version", "") changes: list[ResourceChange] = [] diff --git a/tests/test_parse_errors.py b/tests/test_parse_errors.py index d44422d..93a342c 100644 --- a/tests/test_parse_errors.py +++ b/tests/test_parse_errors.py @@ -59,3 +59,16 @@ def test_cloudformation_valid_dict_still_works(self): data = {"Changes": []} plan = parse_cloudformation_changeset(data) assert len(plan.changes) == 0 + + @pytest.mark.parametrize( + ("parser", "payload"), + [ + (parse_terraform_plan, []), + (parse_cloudformation_changeset, []), + (parse_pulumi_preview, []), + ], + ) + def test_json_array_is_rejected_with_clear_error(self, parser, payload): + """A decoded JSON value must be an object before parser-specific access.""" + with pytest.raises(ValueError, match="JSON object"): + parser(payload) From 01415892c3f6719c6acc8b646dcbaa98b7c2d604 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Tue, 4 Aug 2026 21:51:01 -0400 Subject: [PATCH 09/10] cowork-bot: preserve valid workflow action references --- .github/workflows/ci.yml | 2 +- .github/workflows/publish.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de7cbbe..87a6713 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - uses: actions/checkout@v4 with: persist-credentials: false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6266823..c62ad6b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,7 +21,7 @@ jobs: id-token: write steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - uses: actions/checkout@v4 with: persist-credentials: false From 1f9b0df63b3abe7d3989cfe1a557838ba7db8c7b Mon Sep 17 00:00:00 2001 From: Jaixii Date: Fri, 7 Aug 2026 19:53:20 -0400 Subject: [PATCH 10/10] cowork-bot: harden parser error-handling for malformed inputs --- src/deploydiff/cloudformation_parser.py | 10 +++++++- src/deploydiff/pulumi_parser.py | 18 ++++++++++++-- src/deploydiff/terraform_parser.py | 16 +++++++++++- tests/test_parse_errors.py | 33 +++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/deploydiff/cloudformation_parser.py b/src/deploydiff/cloudformation_parser.py index 07c4fdb..d290a50 100644 --- a/src/deploydiff/cloudformation_parser.py +++ b/src/deploydiff/cloudformation_parser.py @@ -57,11 +57,19 @@ def parse_cloudformation_changeset(changeset_json: str | dict[str, Any]) -> Depl changes: list[ResourceChange] = [] changes_list = data.get("Changes", data.get("changes", [])) + if not isinstance(changes_list, list): + raise ValueError("CloudFormation Changes must be a JSON array") - for change_entry in changes_list: + for index, change_entry in enumerate(changes_list): + if not isinstance(change_entry, dict): + raise ValueError(f"CloudFormation Changes[{index}] must be a JSON object") resource_change_data = change_entry.get( "ResourceChange", change_entry.get("resource_change", {}) ) + if not isinstance(resource_change_data, dict): + raise ValueError( + f"CloudFormation Changes[{index}].ResourceChange must be a JSON object" + ) action_str = change_entry.get( "Action", resource_change_data.get("Action", "Modify") ) diff --git a/src/deploydiff/pulumi_parser.py b/src/deploydiff/pulumi_parser.py index 2f7b8dc..63c4f36 100644 --- a/src/deploydiff/pulumi_parser.py +++ b/src/deploydiff/pulumi_parser.py @@ -57,12 +57,16 @@ def parse_pulumi_preview(preview_json: str | dict[str, Any]) -> DeployPlan: # Pulumi preview JSON has a "steps" array steps = data.get("steps", []) + if not isinstance(steps, list): + raise ValueError("Pulumi steps must be a JSON array") # Also support the resource-oriented format resources = data.get("resourceChanges", data.get("resources", {})) # Process steps-based format - for step in steps: + for index, step in enumerate(steps): + if not isinstance(step, dict): + raise ValueError(f"Pulumi steps[{index}] must be a JSON object") urn = step.get("urn", step.get("old", {}).get("urn", "unknown")) step_type = step.get("step", step.get("op", "same")) @@ -100,9 +104,19 @@ def parse_pulumi_preview(preview_json: str | dict[str, Any]) -> DeployPlan: changes.append(resource_change) # Process resource-changes-based format (count-based) - if not steps and isinstance(resources, dict): + if not steps: + if not isinstance(resources, dict): + raise ValueError("Pulumi resourceChanges must be a JSON object") for resource_type, counts in resources.items(): + if not isinstance(counts, dict): + raise ValueError( + f"Pulumi resourceChanges[{resource_type!r}] must be a JSON object" + ) for action_str, count in counts.items(): + if not isinstance(count, int) or isinstance(count, bool) or count < 0: + raise ValueError( + f"Pulumi resourceChanges[{resource_type!r}][{action_str!r}] must be a non-negative integer" + ) action = PULUMI_STEP_MAP.get(action_str, ChangeAction.UPDATE) for i in range(count): resource_change = ResourceChange( diff --git a/src/deploydiff/terraform_parser.py b/src/deploydiff/terraform_parser.py index a6eeb6b..22b06d9 100644 --- a/src/deploydiff/terraform_parser.py +++ b/src/deploydiff/terraform_parser.py @@ -52,10 +52,24 @@ def parse_terraform_plan(plan_json: str | dict[str, Any]) -> DeployPlan: # Parse planned changes resource_changes = data.get("resource_changes", []) + if not isinstance(resource_changes, list): + raise ValueError("Terraform resource_changes must be a JSON array") - for rc in resource_changes: + for index, rc in enumerate(resource_changes): + if not isinstance(rc, dict): + raise ValueError(f"Terraform resource_changes[{index}] must be a JSON object") change = rc.get("change", {}) + if not isinstance(change, dict): + raise ValueError( + f"Terraform resource_changes[{index}].change must be a JSON object" + ) action_strs = change.get("actions", []) + if not isinstance(action_strs, list) or not all( + isinstance(action, str) for action in action_strs + ): + raise ValueError( + f"Terraform resource_changes[{index}].change.actions must be a JSON array of strings" + ) # Use the primary action primary_action = _resolve_primary_action(action_strs) diff --git a/tests/test_parse_errors.py b/tests/test_parse_errors.py index 93a342c..bd04ea6 100644 --- a/tests/test_parse_errors.py +++ b/tests/test_parse_errors.py @@ -72,3 +72,36 @@ def test_json_array_is_rejected_with_clear_error(self, parser, payload): """A decoded JSON value must be an object before parser-specific access.""" with pytest.raises(ValueError, match="JSON object"): parser(payload) + + @pytest.mark.parametrize( + ("parser", "payload", "message"), + [ + ( + parse_terraform_plan, + {"resource_changes": {}}, + "resource_changes must be a JSON array", + ), + ( + parse_cloudformation_changeset, + {"Changes": {}}, + "Changes must be a JSON array", + ), + ( + parse_pulumi_preview, + {"steps": {}}, + "steps must be a JSON array", + ), + ], + ) + def test_malformed_collections_raise_clear_error(self, parser, payload, message): + """Malformed collection fields must not be silently ignored.""" + with pytest.raises(ValueError, match=message): + parser(payload) + + def test_terraform_malformed_resource_entry_is_rejected(self): + with pytest.raises(ValueError, match=r"resource_changes\[0\].*JSON object"): + parse_terraform_plan({"resource_changes": ["not-an-object"]}) + + def test_pulumi_negative_resource_count_is_rejected(self): + with pytest.raises(ValueError, match="non-negative integer"): + parse_pulumi_preview({"resourceChanges": {"aws:s3/bucket:Bucket": {"create": -1}}})