costguard-cli 2.0.16__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {costguard_cli-2.0.16 → costguard_cli-2.2.0}/PKG-INFO +2 -1
  2. {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli/__init__.py +1 -1
  3. {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli/formatters/html_report.py +39 -5
  4. {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli/formatters/markdown.py +109 -12
  5. {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli/validate.py +115 -11
  6. {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli.egg-info/PKG-INFO +2 -1
  7. {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli.egg-info/SOURCES.txt +3 -1
  8. {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli.egg-info/requires.txt +1 -0
  9. {costguard_cli-2.0.16 → costguard_cli-2.2.0}/pyproject.toml +2 -1
  10. costguard_cli-2.2.0/tests/test_formatters_cost_diff.py +157 -0
  11. costguard_cli-2.2.0/tests/test_resource_table_collapse.py +54 -0
  12. {costguard_cli-2.0.16 → costguard_cli-2.2.0}/LICENSE +0 -0
  13. {costguard_cli-2.0.16 → costguard_cli-2.2.0}/README.md +0 -0
  14. {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli/__main__.py +0 -0
  15. {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli/formatters/__init__.py +0 -0
  16. {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli/formatters/json_report.py +0 -0
  17. {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli/formatters/terminal.py +0 -0
  18. {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli/platforms.py +0 -0
  19. {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli/py.typed +0 -0
  20. {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli.egg-info/dependency_links.txt +0 -0
  21. {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli.egg-info/entry_points.txt +0 -0
  22. {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli.egg-info/top_level.txt +0 -0
  23. {costguard_cli-2.0.16 → costguard_cli-2.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: costguard-cli
3
- Version: 2.0.16
3
+ Version: 2.2.0
4
4
  Summary: CostGuard CI/CD validation CLI — shift-left cost governance for cloud infrastructure
5
5
  Author-email: SKYXOPS <engineering@skyxops.com>
6
6
  License-Expression: LicenseRef-Proprietary
@@ -24,6 +24,7 @@ Description-Content-Type: text/markdown
24
24
  License-File: LICENSE
25
25
  Requires-Dist: requests>=2.28.0
26
26
  Requires-Dist: jinja2>=3.1.0
27
+ Requires-Dist: pyyaml>=6.0
27
28
  Provides-Extra: dev
28
29
  Requires-Dist: pytest>=7.0; extra == "dev"
29
30
  Requires-Dist: ruff>=0.1.0; extra == "dev"
@@ -1,3 +1,3 @@
1
1
  """CostGuard CLI — shift-left cost governance for CI/CD pipelines."""
2
2
 
3
- __version__ = "2.0.15"
3
+ __version__ = "2.2.0"
@@ -1271,26 +1271,60 @@ class HtmlFormatter:
1271
1271
 
1272
1272
  decision = (endpoint_data.get("decision") or "ERROR").upper()
1273
1273
 
1274
- # Cost values — support both flat summary and legacy
1274
+ # Cost values — support both flat summary and legacy. AI-CG-11:
1275
+ # when past/planned/delta are present, the headline becomes the
1276
+ # signed delta so DELETE / DOWNSIZE PRs render the savings instead
1277
+ # of the misleading "$0.00" the legacy field reports.
1275
1278
  summary = results.get("summary") or {}
1276
- total_monthly = summary.get("total_monthly_cost") or results.get("total_monthly_usd")
1277
1279
  currency = summary.get("currency") or results.get("currency", "USD")
1278
- total_cost_str = f"${total_monthly:,.2f}" if total_monthly is not None else "N/A"
1279
1280
  resources_raw = results.get("resources") or []
1280
1281
  resource_count = summary.get("total_resources") or len(resources_raw)
1281
1282
 
1282
- # Prepare resource rows
1283
+ past_total = results.get("past_monthly_cost")
1284
+ planned_total = results.get("planned_monthly_cost")
1285
+ delta_total = results.get("delta_monthly_cost")
1286
+ legacy_total = summary.get("total_monthly_cost") or results.get("total_monthly_usd")
1287
+ # Keep `total_monthly` defined for legacy code paths below (violations
1288
+ # block etc. expects this variable to exist).
1289
+ total_monthly = legacy_total
1290
+
1291
+ if delta_total is not None and (past_total is not None or planned_total is not None):
1292
+ sign = "+" if delta_total >= 0 else "-"
1293
+ kind = "increase" if delta_total > 0 else ("savings" if delta_total < 0 else "no change")
1294
+ total_cost_str = f"{sign}${abs(delta_total):,.2f} ({kind})"
1295
+ else:
1296
+ total_cost_str = f"${legacy_total:,.2f}" if legacy_total is not None else "N/A"
1297
+
1298
+ # Prepare resource rows — for DELETE the customer-visible cost
1299
+ # column reflects the SAVINGS (signed negative), not the legacy
1300
+ # future-state $0.
1301
+ def _fmt_signed(v):
1302
+ return f"{'+' if v >= 0 else '-'}${abs(v):,.2f}"
1303
+
1283
1304
  resources = []
1284
1305
  for r in resources_raw:
1285
1306
  mc = r.get("monthly_cost")
1286
1307
  hc = r.get("hourly_cost")
1308
+ ext = r.get("extensions") or {}
1309
+ action = ext.get("action")
1310
+ delta = ext.get("delta_monthly_cost")
1311
+
1312
+ if delta is not None and action in ("delete", "update", "create"):
1313
+ # New-shape: render signed delta as the headline cost
1314
+ monthly_str = _fmt_signed(delta)
1315
+ else:
1316
+ monthly_str = f"${mc:,.2f}" if mc is not None else "N/A"
1317
+
1287
1318
  resources.append({
1288
1319
  "resource_name": r.get("resource_name") or r.get("resource_id", "unknown"),
1289
1320
  "resource_type": r.get("resource_type", "unknown"),
1290
1321
  "provider": (r.get("provider") or "-").upper(),
1291
1322
  "region": r.get("region", "-"),
1292
1323
  "hourly_str": f"${hc:,.4f}" if hc is not None else "-",
1293
- "monthly_str": f"${mc:,.2f}" if mc is not None else "N/A",
1324
+ "monthly_str": monthly_str,
1325
+ "action": (action or "").capitalize(),
1326
+ "past_str": f"${ext.get('past_monthly_cost', 0):,.2f}" if ext.get('past_monthly_cost') is not None else "—",
1327
+ "planned_str": f"${ext.get('planned_monthly_cost', 0):,.2f}" if ext.get('planned_monthly_cost') is not None else "—",
1294
1328
  })
1295
1329
 
1296
1330
  # Violations
@@ -17,6 +17,11 @@ class MarkdownFormatter:
17
17
  "low": "🔵",
18
18
  }
19
19
 
20
+ # Wrap per-resource table in <details> when row count exceeds this.
21
+ # Big plans (50-100+ resources) otherwise bury budget/violations/AI
22
+ # narrative below an unreadable wall of rows.
23
+ RESOURCE_COLLAPSE_THRESHOLD = 10
24
+
20
25
  def format(self, response: dict, tag: str | None = None, **kwargs) -> str:
21
26
  """Format the full CostGuard API response as Markdown."""
22
27
  sections: list[str] = []
@@ -84,12 +89,39 @@ class MarkdownFormatter:
84
89
  )
85
90
 
86
91
  def _cost_summary(self, results: dict) -> str:
92
+ """Render plan-level summary.
93
+
94
+ AI-CG-11: when the response carries past/planned/delta (server added
95
+ these in feature/ai-cg-11-cost-diff), surface the signed delta as
96
+ the headline so DELETE / DOWNSIZE PRs show their savings instead of
97
+ the misleading "Estimated Monthly Cost: $0" the legacy field reports.
98
+ """
87
99
  summary = results.get("summary") or {}
88
- total = summary.get("total_monthly_cost") or results.get("total_monthly_usd")
89
100
  currency = summary.get("currency") or results.get("currency", "USD")
90
101
  resource_count = summary.get("total_resources") or len(results.get("resources") or [])
91
102
 
92
- total_str = f"${total:,.2f}" if total is not None else "N/A"
103
+ # New cost-diff fields take precedence when present.
104
+ past = results.get("past_monthly_cost")
105
+ planned = results.get("planned_monthly_cost")
106
+ delta = results.get("delta_monthly_cost")
107
+ legacy_total = summary.get("total_monthly_cost") or results.get("total_monthly_usd")
108
+
109
+ if delta is not None and (past is not None or planned is not None):
110
+ sign = "+" if delta >= 0 else "-"
111
+ kind = "increase" if delta > 0 else ("savings" if delta < 0 else "no change")
112
+ past_str = f"${past:,.2f}" if past is not None else "$0.00"
113
+ planned_str = f"${planned:,.2f}" if planned is not None else "$0.00"
114
+ return (
115
+ f"### Cost Summary\n\n"
116
+ f"| Metric | Value |\n"
117
+ f"|--------|-------|\n"
118
+ f"| **Monthly cost change** | **{sign}${abs(delta):,.2f} {currency} ({kind})** |\n"
119
+ f"| Past monthly cost | {past_str} |\n"
120
+ f"| Planned monthly cost | {planned_str} |\n"
121
+ f"| Resources Analyzed | {resource_count} |"
122
+ )
123
+
124
+ total_str = f"${legacy_total:,.2f}" if legacy_total is not None else "N/A"
93
125
  return (
94
126
  f"### Cost Summary\n\n"
95
127
  f"| Metric | Value |\n"
@@ -99,24 +131,89 @@ class MarkdownFormatter:
99
131
  )
100
132
 
101
133
  def _resource_table(self, resources: list[dict]) -> str:
102
- lines = [
103
- "### Per-Resource Costs\n",
104
- "| Resource | Type | Region | Monthly Cost |",
105
- "|----------|------|--------|-------------:|",
106
- ]
134
+ """Render per-resource table.
135
+
136
+ AI-CG-11: adaptive columns based on the actions present in the plan:
137
+ pure CREATE → Resource · Type · Region · Cost (3-col cost area)
138
+ pure DELETE → Resource · Type · Region · Savings (signed negative)
139
+ UPDATE / MIX → Resource · Type · Region · Action · Past · Planned · Δ
140
+
141
+ DELETE rows previously rendered "$0.00" because monthly_cost is the
142
+ future-state cost. Now they render the signed delta (the saving).
143
+ """
144
+ actions = {
145
+ (r.get("extensions") or {}).get("action")
146
+ for r in resources
147
+ }
148
+ actions.discard(None)
149
+ # Default to all-columns shape when actions are mixed or absent
150
+ pure_create = actions == {"create"}
151
+ pure_delete = actions == {"delete"}
152
+
153
+ if pure_create:
154
+ header = "| Resource | Type | Region | Cost |"
155
+ divider = "|----------|------|--------|-----:|"
156
+ elif pure_delete:
157
+ header = "| Resource | Type | Region | Savings |"
158
+ divider = "|----------|------|--------|--------:|"
159
+ else:
160
+ header = "| Resource | Type | Region | Action | Past | Planned | Δ |"
161
+ divider = "|----------|------|--------|--------|-----:|--------:|---:|"
162
+
163
+ rows = [header, divider]
107
164
 
108
165
  for r in resources:
109
166
  name = r.get("resource_name") or r.get("resource_id", "unknown")
110
167
  rtype = r.get("resource_type", "unknown")
111
168
  region = r.get("region", "-")
112
- mc = r.get("monthly_cost")
113
- cost_str = f"${mc:,.2f}" if mc is not None else "N/A"
169
+ ext = r.get("extensions") or {}
170
+ action = ext.get("action")
171
+ past = ext.get("past_monthly_cost")
172
+ planned = ext.get("planned_monthly_cost")
173
+ delta = ext.get("delta_monthly_cost")
174
+ mc = r.get("monthly_cost")
114
175
  success = r.get("success", True)
115
176
  status = "" if success else " (failed)"
116
177
 
117
- lines.append(f"| `{name}` | {rtype} | {region} | {cost_str}{status} |")
118
-
119
- return "\n".join(lines)
178
+ def fmt(v):
179
+ return f"${v:,.2f}" if v is not None else "—"
180
+ def fmt_signed(v):
181
+ if v is None:
182
+ return "—"
183
+ return f"{'+' if v >= 0 else '-'}${abs(v):,.2f}"
184
+
185
+ if pure_create:
186
+ cost_str = fmt(planned if planned is not None else mc)
187
+ rows.append(f"| `{name}` | {rtype} | {region} | {cost_str}{status} |")
188
+ elif pure_delete:
189
+ savings_str = fmt_signed(delta) if delta is not None else fmt_signed(-(past or 0))
190
+ rows.append(f"| `{name}` | {rtype} | {region} | {savings_str}{status} |")
191
+ else:
192
+ action_str = (action or "-").capitalize() if action else "-"
193
+ past_str = "—" if action == "create" else fmt(past)
194
+ planned_str = "—" if action == "delete" else fmt(planned)
195
+ delta_str = fmt_signed(delta) if delta is not None else fmt(mc)
196
+ rows.append(
197
+ f"| `{name}` | {rtype} | {region} | {action_str} | "
198
+ f"{past_str} | {planned_str} | {delta_str}{status} |"
199
+ )
200
+
201
+ table = "\n".join(rows)
202
+ count = len(resources)
203
+
204
+ # Long resource lists make PR/MR comments huge and dominate the review
205
+ # surface. Wrap the table in <details> when it crosses the threshold so
206
+ # reviewers see the summary by default and can expand on demand.
207
+ # GitLab and GitHub both render <details>/<summary> as native folds.
208
+ if count > self.RESOURCE_COLLAPSE_THRESHOLD:
209
+ return (
210
+ "### Per-Resource Costs\n\n"
211
+ f"<details>\n"
212
+ f"<summary>Show {count} resources</summary>\n\n"
213
+ f"{table}\n\n"
214
+ f"</details>"
215
+ )
216
+ return f"### Per-Resource Costs\n\n{table}"
120
217
 
121
218
  def _budget_section(self, budget: dict) -> str:
122
219
  budget_name = budget.get("budget_name", "Unknown")
@@ -185,8 +185,37 @@ def build_parser() -> argparse.ArgumentParser:
185
185
  # ---------------------------------------------------------------------------
186
186
  # Plan file reading
187
187
  # ---------------------------------------------------------------------------
188
+ def _parse_plan_text(raw: str, source: str) -> dict:
189
+ """Parse plan text as JSON, falling back to YAML for CloudFormation
190
+ templates which are commonly authored in YAML. `source` is used only
191
+ for error messages ('stdin' or a file path)."""
192
+ try:
193
+ return json.loads(raw)
194
+ except json.JSONDecodeError as json_exc:
195
+ try:
196
+ import yaml # PyYAML is a runtime dep — see pyproject.toml
197
+ except ImportError as imp_exc:
198
+ raise ValueError(
199
+ f"Invalid JSON in {source} ({json_exc}); install PyYAML to also "
200
+ "accept CloudFormation YAML templates."
201
+ ) from imp_exc
202
+ try:
203
+ loaded = yaml.safe_load(raw)
204
+ except yaml.YAMLError as yaml_exc:
205
+ raise ValueError(
206
+ f"{source} is neither valid JSON ({json_exc}) nor valid YAML ({yaml_exc})."
207
+ ) from yaml_exc
208
+ if not isinstance(loaded, dict):
209
+ raise ValueError(
210
+ f"{source} parsed as YAML to {type(loaded).__name__}, expected a dict "
211
+ "(top-level mapping like a CloudFormation template)."
212
+ )
213
+ return loaded
214
+
215
+
188
216
  def read_plan_file(plan_path: str) -> dict:
189
- """Read and parse the IaC plan JSON file, or stdin when plan_path is '-'."""
217
+ """Read and parse the IaC plan JSON (or CloudFormation YAML) file, or
218
+ stdin when plan_path is '-'."""
190
219
  if plan_path == "-":
191
220
  try:
192
221
  raw = sys.stdin.read()
@@ -194,10 +223,7 @@ def read_plan_file(plan_path: str) -> dict:
194
223
  raise ValueError("Stdin read interrupted")
195
224
  if not raw.strip():
196
225
  raise ValueError("No data received on stdin (empty pipe)")
197
- try:
198
- return json.loads(raw)
199
- except json.JSONDecodeError as exc:
200
- raise ValueError(f"Invalid JSON on stdin: {exc}") from exc
226
+ return _parse_plan_text(raw, source="stdin")
201
227
 
202
228
  path = Path(plan_path)
203
229
  if not path.exists():
@@ -205,11 +231,9 @@ def read_plan_file(plan_path: str) -> dict:
205
231
  if not path.is_file():
206
232
  raise ValueError(f"Plan path is not a file: {plan_path}")
207
233
 
208
- try:
209
- with open(path, "r", encoding="utf-8") as fh:
210
- return json.load(fh)
211
- except json.JSONDecodeError as exc:
212
- raise ValueError(f"Invalid JSON in plan file: {exc}") from exc
234
+ with open(path, "r", encoding="utf-8") as fh:
235
+ raw = fh.read()
236
+ return _parse_plan_text(raw, source=str(path))
213
237
 
214
238
 
215
239
  # ---------------------------------------------------------------------------
@@ -235,6 +259,69 @@ def detect_iac_type(plan_content: dict) -> str:
235
259
  return "terraform"
236
260
 
237
261
 
262
+ def _changeset_missing_properties(plan_content: dict) -> bool:
263
+ """Detect CloudFormation describe-change-set output that lacks resource
264
+ Properties — AWS strips Properties unless `describe-change-set` was
265
+ invoked with `--include-property-values`. CostGuard's CFN parser cannot
266
+ price these and would return 'Invalid CloudFormation format' or zero
267
+ monthly cost. Catching it locally saves an API round-trip and gives a
268
+ clearer error message.
269
+
270
+ Returns True only when:
271
+ - the plan looks like a changeset (has Changes[])
272
+ - at least one Add/Modify change is present
273
+ - NONE of those changes carry property values (AfterContext.Properties
274
+ or BeforeContext.Properties or Details[].Target.Name)
275
+ """
276
+ changes = plan_content.get("Changes")
277
+ if not isinstance(changes, list) or not changes:
278
+ return False # Not a changeset shape, let the API/parser handle it.
279
+
280
+ saw_priceable_change = False
281
+ for change in changes:
282
+ if not isinstance(change, dict):
283
+ continue
284
+ rc = change.get("ResourceChange") or {}
285
+ action = (rc.get("Action") or "").lower()
286
+ if action in ("remove", "delete"):
287
+ continue # Deletes have no cost impact, no Properties expected.
288
+ saw_priceable_change = True
289
+
290
+ # Properties may be present in any of these shapes.
291
+ for ctx_key in ("AfterContext", "BeforeContext"):
292
+ ctx = rc.get(ctx_key)
293
+ if isinstance(ctx, str):
294
+ # AWS sometimes serializes as a JSON-encoded string.
295
+ try:
296
+ ctx = json.loads(ctx)
297
+ except (json.JSONDecodeError, TypeError):
298
+ ctx = None
299
+ if isinstance(ctx, dict) and ctx.get("Properties"):
300
+ return False
301
+ details = rc.get("Details") or []
302
+ if any(
303
+ isinstance(d, dict) and (d.get("Target") or {}).get("Name")
304
+ for d in details
305
+ ):
306
+ return False
307
+
308
+ return saw_priceable_change
309
+
310
+
311
+ def _normalize_api_url(api_url: str) -> str:
312
+ """Accept either a base URL ('https://host') or a full endpoint URL
313
+ ('https://host/v1/costguard/analyze') so users can store whichever they
314
+ like in COSTGUARD_API_URL without thinking about which one this CLI
315
+ expects. Strips the well-known analyze suffix if present so the caller
316
+ can safely re-append it.
317
+ """
318
+ base = api_url.rstrip("/")
319
+ suffix = "/v1/costguard/analyze"
320
+ if base.endswith(suffix):
321
+ base = base[: -len(suffix)]
322
+ return base
323
+
324
+
238
325
  def call_costguard_api(
239
326
  api_url: str,
240
327
  api_key: str,
@@ -249,7 +336,7 @@ def call_costguard_api(
249
336
  errors (429, 502, 503, 504, timeouts, connection resets).
250
337
  Client errors (400, 401, 403, 404) fail immediately.
251
338
  """
252
- url = f"{api_url.rstrip('/')}/v1/costguard/analyze"
339
+ url = f"{_normalize_api_url(api_url)}/v1/costguard/analyze"
253
340
 
254
341
  headers = {
255
342
  "x-api-key": api_key,
@@ -424,6 +511,23 @@ def main() -> None:
424
511
  # Determine IaC type
425
512
  iac_type = args.iac_type or detect_iac_type(plan_content)
426
513
 
514
+ # Pre-flight: CloudFormation changesets must include resource
515
+ # property values; otherwise the API has nothing to price against
516
+ # and would return "Invalid CloudFormation format". Catching this
517
+ # locally avoids a wasted API call and points the user at the AWS
518
+ # CLI flag they actually need.
519
+ if iac_type == "cloudformation" and _changeset_missing_properties(plan_content):
520
+ print(
521
+ "[CostGuard] ERROR: CloudFormation changeset contains no resource "
522
+ "Properties.\n"
523
+ "[CostGuard] Hint: regenerate the changeset with --include-property-values:\n"
524
+ "[CostGuard] aws cloudformation describe-change-set \\\n"
525
+ "[CostGuard] --stack-name <stack> --change-set-name <name> \\\n"
526
+ "[CostGuard] --include-property-values > changeset.json",
527
+ file=sys.stderr,
528
+ )
529
+ sys.exit(EXIT_ERROR)
530
+
427
531
  # Warn if no budget code and no skip — API will reject
428
532
  if not budget_code and not args.skip_budget:
429
533
  print(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: costguard-cli
3
- Version: 2.0.16
3
+ Version: 2.2.0
4
4
  Summary: CostGuard CI/CD validation CLI — shift-left cost governance for cloud infrastructure
5
5
  Author-email: SKYXOPS <engineering@skyxops.com>
6
6
  License-Expression: LicenseRef-Proprietary
@@ -24,6 +24,7 @@ Description-Content-Type: text/markdown
24
24
  License-File: LICENSE
25
25
  Requires-Dist: requests>=2.28.0
26
26
  Requires-Dist: jinja2>=3.1.0
27
+ Requires-Dist: pyyaml>=6.0
27
28
  Provides-Extra: dev
28
29
  Requires-Dist: pytest>=7.0; extra == "dev"
29
30
  Requires-Dist: ruff>=0.1.0; extra == "dev"
@@ -16,4 +16,6 @@ costguard_cli/formatters/__init__.py
16
16
  costguard_cli/formatters/html_report.py
17
17
  costguard_cli/formatters/json_report.py
18
18
  costguard_cli/formatters/markdown.py
19
- costguard_cli/formatters/terminal.py
19
+ costguard_cli/formatters/terminal.py
20
+ tests/test_formatters_cost_diff.py
21
+ tests/test_resource_table_collapse.py
@@ -1,5 +1,6 @@
1
1
  requests>=2.28.0
2
2
  jinja2>=3.1.0
3
+ pyyaml>=6.0
3
4
 
4
5
  [dev]
5
6
  pytest>=7.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "costguard-cli"
7
- version = "2.0.16"
7
+ version = "2.2.0"
8
8
  description = "CostGuard CI/CD validation CLI — shift-left cost governance for cloud infrastructure"
9
9
  requires-python = ">=3.9"
10
10
  license = "LicenseRef-Proprietary"
@@ -36,6 +36,7 @@ classifiers = [
36
36
  dependencies = [
37
37
  "requests>=2.28.0",
38
38
  "jinja2>=3.1.0",
39
+ "pyyaml>=6.0",
39
40
  ]
40
41
 
41
42
  [project.scripts]
@@ -0,0 +1,157 @@
1
+ # =============================================================================
2
+ # Copyright (c) 2026 SKYXOPS Corp. All rights reserved.
3
+ # =============================================================================
4
+ """AI-CG-11 follow-up: markdown + HTML formatters render signed cost-diff
5
+ when the costguard API response carries past/planned/delta fields.
6
+
7
+ Regression-guard for the original bug: DELETE resources render as "$0.00"
8
+ in the legacy `monthly_cost` field, hiding the savings from the reviewer.
9
+ """
10
+
11
+ import pytest
12
+
13
+ from costguard_cli.formatters.markdown import MarkdownFormatter
14
+ from costguard_cli.formatters.html_report import HtmlFormatter
15
+
16
+
17
+ @pytest.fixture
18
+ def markdown():
19
+ return MarkdownFormatter()
20
+
21
+
22
+ @pytest.fixture
23
+ def html():
24
+ return HtmlFormatter()
25
+
26
+
27
+ def _mixed_response():
28
+ """Minimal response shape with all 4 actions exercised. Mirrors the
29
+ real costguard API: top-level `endpoint_data` (decision/violations/budget)
30
+ + top-level `results` (cost numbers + resources)."""
31
+ return {
32
+ "request_id": "01J5K8R7-TEST",
33
+ "endpoint_data": {"decision": "ALLOW"},
34
+ "results": {
35
+ "narrative": "Test narrative.",
36
+ "currency": "USD",
37
+ "past_monthly_cost": 270.60,
38
+ "planned_monthly_cost": 247.90,
39
+ "delta_monthly_cost": -22.70,
40
+ "total_monthly_usd": 247.90,
41
+ "resources": [
42
+ {
43
+ "resource_name": "aws_instance.d1",
44
+ "resource_type": "aws_instance",
45
+ "provider": "aws",
46
+ "region": "us-east-1",
47
+ "monthly_cost": 0.0,
48
+ "extensions": {
49
+ "action": "delete",
50
+ "past_monthly_cost": 140.00,
51
+ "planned_monthly_cost": 0.00,
52
+ "delta_monthly_cost": -140.00,
53
+ },
54
+ },
55
+ {
56
+ "resource_name": "aws_instance.u1",
57
+ "resource_type": "aws_instance",
58
+ "provider": "aws",
59
+ "region": "us-east-1",
60
+ "monthly_cost": 3.80,
61
+ "extensions": {
62
+ "action": "update",
63
+ "past_monthly_cost": 30.30,
64
+ "planned_monthly_cost": 3.80,
65
+ "delta_monthly_cost": -26.50,
66
+ },
67
+ },
68
+ {
69
+ "resource_name": "aws_instance.c1",
70
+ "resource_type": "aws_instance",
71
+ "provider": "aws",
72
+ "region": "us-east-1",
73
+ "monthly_cost": 70.00,
74
+ "extensions": {
75
+ "action": "create",
76
+ "past_monthly_cost": 0.00,
77
+ "planned_monthly_cost": 70.00,
78
+ "delta_monthly_cost": +70.00,
79
+ },
80
+ },
81
+ ],
82
+ },
83
+ }
84
+
85
+
86
+ def _pure_delete_response():
87
+ return {
88
+ "request_id": "01J5K8R7-DEL",
89
+ "endpoint_data": {"decision": "ALLOW"},
90
+ "results": {
91
+ "narrative": "Decommission.",
92
+ "currency": "USD",
93
+ "past_monthly_cost": 430.50,
94
+ "planned_monthly_cost": 0.00,
95
+ "delta_monthly_cost": -430.50,
96
+ "total_monthly_usd": 0.00,
97
+ "resources": [
98
+ {
99
+ "resource_name": "aws_db_instance.legacy",
100
+ "resource_type": "aws_db_instance",
101
+ "provider": "aws", "region": "us-east-1",
102
+ "monthly_cost": 0.0,
103
+ "extensions": {
104
+ "action": "delete",
105
+ "past_monthly_cost": 290.20,
106
+ "planned_monthly_cost": 0.00,
107
+ "delta_monthly_cost": -290.20,
108
+ },
109
+ },
110
+ ],
111
+ },
112
+ }
113
+
114
+
115
+ # ----- Markdown -----
116
+
117
+ def test_markdown_mixed_shows_signed_delta_headline(markdown):
118
+ out = markdown.format(_mixed_response())
119
+ assert "Monthly cost change" in out
120
+ assert "-$22.70" in out
121
+ assert "(savings)" in out
122
+
123
+
124
+ def test_markdown_delete_row_shows_negative_savings_not_zero(markdown):
125
+ out = markdown.format(_mixed_response())
126
+ # The DELETE row's Δ column must show -$140.00, NOT $0.00
127
+ assert "-$140.00" in out
128
+ # Sanity: the DELETE row's Δ column contains the signed savings
129
+ # (note: aws_instance.d1 also appears in the hourly-cost collapsible,
130
+ # so just verify the resource is rendered at all).
131
+ assert "aws_instance.d1" in out
132
+
133
+
134
+ def test_markdown_pure_delete_uses_savings_column(markdown):
135
+ out = markdown.format(_pure_delete_response())
136
+ assert "Savings" in out
137
+ assert "-$290.20" in out
138
+
139
+
140
+ # ----- HTML -----
141
+
142
+ def test_html_mixed_shows_signed_delta_headline(html):
143
+ out = html.format(_mixed_response())
144
+ assert "-$22.70" in out
145
+ assert "(savings)" in out
146
+
147
+
148
+ def test_html_delete_row_shows_signed_savings(html):
149
+ out = html.format(_mixed_response())
150
+ assert "-$140.00" in out
151
+ assert "aws_instance.d1" in out
152
+
153
+
154
+ def test_html_pure_delete_headline_shows_signed_delta(html):
155
+ out = html.format(_pure_delete_response())
156
+ assert "-$430.50" in out
157
+ assert "aws_db_instance.legacy" in out
@@ -0,0 +1,54 @@
1
+ """AI-XMOD-202: collapse per-resource cost table when > RESOURCE_COLLAPSE_THRESHOLD."""
2
+
3
+ from costguard_cli.formatters.markdown import MarkdownFormatter
4
+
5
+
6
+ def _response(n_resources: int) -> dict:
7
+ return {
8
+ "endpoint_data": {"decision": "ALLOW", "budget": {}, "violations": []},
9
+ "results": {
10
+ "total_monthly_usd": n_resources * 10,
11
+ "resources": [
12
+ {
13
+ "resource_name": f"aws_instance.r{i}",
14
+ "resource_type": "aws_instance",
15
+ "region": "us-east-1",
16
+ "monthly_cost": 10.0,
17
+ "extensions": {"action": "create", "planned_monthly_cost": 10.0},
18
+ }
19
+ for i in range(n_resources)
20
+ ],
21
+ },
22
+ "request_id": "test",
23
+ "timestamp": "2026-05-24T00:00:00",
24
+ "processing_time_ms": 1,
25
+ }
26
+
27
+
28
+ def test_short_table_renders_flat():
29
+ out = MarkdownFormatter().format(_response(5))
30
+ assert "### Per-Resource Costs" in out
31
+ assert "<summary>Show 5 resources" not in out
32
+ assert "aws_instance.r0" in out
33
+ assert "aws_instance.r4" in out
34
+
35
+
36
+ def test_long_table_wrapped_in_details():
37
+ out = MarkdownFormatter().format(_response(15))
38
+ assert "### Per-Resource Costs" in out
39
+ assert "<details>" in out
40
+ assert "<summary>Show 15 resources</summary>" in out
41
+ assert "</details>" in out
42
+ assert "aws_instance.r0" in out
43
+ assert "aws_instance.r14" in out
44
+
45
+
46
+ def test_threshold_boundary_exact_match_not_collapsed():
47
+ # At exactly the threshold, stay flat (strictly greater than collapses).
48
+ out = MarkdownFormatter().format(_response(MarkdownFormatter.RESOURCE_COLLAPSE_THRESHOLD))
49
+ assert "<summary>Show" not in out
50
+
51
+
52
+ def test_threshold_boundary_one_over_collapses():
53
+ out = MarkdownFormatter().format(_response(MarkdownFormatter.RESOURCE_COLLAPSE_THRESHOLD + 1))
54
+ assert "<summary>Show" in out
File without changes
File without changes
File without changes