From c7ca69b0cddde73fa1ada6476faaadec296da139 Mon Sep 17 00:00:00 2001 From: o-webdev Date: Sat, 28 Mar 2026 11:27:14 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20phase=201=20bug=20fixes=20=E2=80=94=20un?= =?UTF-8?q?supported=20instance=20visibility,=20suggestions=20provider-awa?= =?UTF-8?q?re,=20WUE=20citation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dist/index.cjs | 52 +++++++++++++++++++++++++------------ formatters/markdown.test.ts | 24 +++++++++++++++++ formatters/markdown.ts | 28 +++++++++++++------- formatters/table.test.ts | 23 ++++++++++++++++ formatters/table.ts | 19 +++++++++++--- package.json | 2 +- suggestions.ts | 17 ++++++++---- 7 files changed, 131 insertions(+), 34 deletions(-) diff --git a/dist/index.cjs b/dist/index.cjs index e57de95..488a91d 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -1968,7 +1968,7 @@ var factors_default = { // package.json var package_default = { name: "greenops-cli", - version: "0.5.0", + version: "0.5.2", description: "Carbon footprint linting for Terraform plans \u2014 AWS, Azure, and GCP. Analyses infrastructure changes for Scope 2, Scope 3, and water impact. Posts recommendations directly on GitHub PRs.", main: "dist/index.cjs", bin: { @@ -2745,8 +2745,9 @@ async function postSuggestions(result2, ctx) { for (const { input, recommendation } of resourcesWithRecs) { if (!recommendation) continue; - const isDb = input.resourceId.includes("aws_db_instance") || input.instanceType.startsWith("db."); - const attributeKey = isDb ? "instance_class" : "instance_type"; + const provider = input.provider ?? "aws"; + const isDb = provider === "aws" && (input.resourceId.includes("aws_db_instance") || input.instanceType.startsWith("db.")); + const attributeKey = provider === "azure" ? "size" : provider === "gcp" ? "machine_type" : isDb ? "instance_class" : "instance_type"; const currentValue = isDb ? `db.${input.instanceType}` : input.instanceType; const newValue = recommendation.suggestedInstanceType ? isDb ? `db.${recommendation.suggestedInstanceType}` : recommendation.suggestedInstanceType : input.instanceType; if (!recommendation.suggestedInstanceType) { @@ -2843,7 +2844,8 @@ function formatWater(litres) { } function formatMarkdown(result2, options = {}) { const METHODOLOGY_URL = options.repositoryUrl || "https://github.com/omrdev1/greenops-cli/blob/main/METHODOLOGY.md"; - const recsCount = result2.resources.filter((r) => r.recommendation).length; + const analysedForCount = result2.resources.filter((r) => r.baseline.confidence !== "LOW_ASSUMED_DEFAULT"); + const recsCount = analysedForCount.filter((r) => r.recommendation).length; let out = `## \u{1F331} GreenOps Infrastructure Impact `; @@ -2879,6 +2881,8 @@ function formatMarkdown(result2, options = {}) { `; } + const analysed = result2.resources.filter((r) => r.baseline.confidence !== "LOW_ASSUMED_DEFAULT"); + const unsupportedResources = result2.resources.filter((r) => r.baseline.confidence === "LOW_ASSUMED_DEFAULT"); out += `### Resource Breakdown `; @@ -2886,25 +2890,31 @@ function formatMarkdown(result2, options = {}) { `; out += `|---|---|---|---|---|---|---|---| `; - for (const r of result2.resources) { + for (const r of analysed) { const action = r.recommendation ? `\u{1F4A1} [View Recommendation](#recommendations)` : `\u2705 Optimal`; - out += `| \`${r.input.resourceId}\` | \`${r.input.instanceType}\` | \`${r.input.region}\` | ${formatGrams(r.baseline.totalCo2eGramsPerMonth)} | ${formatGrams(r.baseline.embodiedCo2eGramsPerMonth)} | ${formatWater(r.baseline.waterLitresPerMonth)} | $${r.baseline.totalCostUsdPerMonth.toFixed(2)} | ${action} | + out += `| \`${r.input.resourceId}\` | \`${r.input.instanceType}\` | \`${r.input.region}\` | ${formatGrams(r.baseline.totalCo2eGramsPerMonth)} | ${formatGrams(r.baseline.embodiedCo2eGramsPerMonth)} | ${formatWater(r.baseline.waterLitresPerMonth)} | ${r.baseline.totalCostUsdPerMonth.toFixed(2)} | ${action} | `; } out += ` `; - if (result2.skipped.length > 0) { - out += `
\u26A0\uFE0F ${result2.skipped.length} Skipped Resources + const totalSkipped = result2.skipped.length + unsupportedResources.length; + if (totalSkipped > 0) { + out += `
\u26A0\uFE0F ${totalSkipped} Skipped Resource${totalSkipped !== 1 ? "s" : ""} `; - out += `The following resources were excluded from analysis (typically due to runtime-resolved attributes). The actual footprint may be higher. + out += `The following resources were excluded from analysis. The actual footprint may be higher. `; - out += `| Resource | Reason | -|---|---| + out += `| Resource | Instance | Reason | +|---|---|---| `; for (const s of result2.skipped) { - out += `| \`${s.resourceId}\` | \`${s.reason}\` | + out += `| \`${s.resourceId}\` | \u2014 | \`${s.reason}\` | +`; + } + for (const r of unsupportedResources) { + const reason = r.baseline.unsupportedReason ?? "Instance type not in ledger"; + out += `| \`${r.input.resourceId}\` | \`${r.input.instanceType}\` | ${reason} | `; } out += ` @@ -2944,7 +2954,7 @@ function formatMarkdown(result2, options = {}) { `; out += `*Emissions calculated using the [Open GreenOps Methodology Ledger v${result2.ledgerVersion}](${METHODOLOGY_URL}). `; out += `Scope 2 (operational) and Scope 3 (embodied) emissions tracked. `; - out += `Water consumption estimated from AWS 2023 WUE data. `; + out += `Water consumption estimated from provider sustainability reports (AWS 2023, Microsoft 2023, Google 2023). `; out += `Math is MIT-licensed and auditable. Analysed at ${result2.analysedAt}.* `; if (options.showUpgradePrompt) { @@ -2982,7 +2992,9 @@ function formatTable(result2) { `; out += `\u251C${"\u2500".repeat(38)}\u253C${"\u2500".repeat(13)}\u253C${"\u2500".repeat(13)}\u253C${"\u2500".repeat(11)}\u253C${"\u2500".repeat(11)}\u253C${"\u2500".repeat(9)}\u253C${"\u2500".repeat(13)}\u2524 `; - for (const r of result2.resources) { + const analysed = result2.resources.filter((r) => r.baseline.confidence !== "LOW_ASSUMED_DEFAULT"); + const unsupportedResources = result2.resources.filter((r) => r.baseline.confidence === "LOW_ASSUMED_DEFAULT"); + for (const r of analysed) { const scope2 = formatGrams(r.baseline.totalCo2eGramsPerMonth); const scope3 = formatGrams(r.baseline.embodiedCo2eGramsPerMonth); const water = formatWater2(r.baseline.waterLitresPerMonth); @@ -2992,6 +3004,10 @@ function formatTable(result2) { } for (const s of result2.skipped) { out += `\u2502 \x1B[90m${truncate(s.resourceId, 36)}\x1B[0m \u2502 \x1B[90m${truncate("---", 11)}\x1B[0m \u2502 \x1B[90m${truncate("---", 11)}\x1B[0m \u2502 \x1B[90m${truncate("---", 9)}\x1B[0m \u2502 \x1B[90m${truncate("---", 9)}\x1B[0m \u2502 \x1B[90m${truncate("---", 7)}\x1B[0m \u2502 \x1B[33m${truncate("\u26A0 SKIPPED", 11)}\x1B[0m \u2502 +`; + } + for (const r of unsupportedResources) { + out += `\u2502 \x1B[90m${truncate(r.input.resourceId, 36)}\x1B[0m \u2502 \x1B[90m${truncate(r.input.instanceType, 11)}\x1B[0m \u2502 \x1B[90m${truncate(r.input.region, 11)}\x1B[0m \u2502 \x1B[90m${truncate("---", 9)}\x1B[0m \u2502 \x1B[90m${truncate("---", 9)}\x1B[0m \u2502 \x1B[90m${truncate("---", 7)}\x1B[0m \u2502 \x1B[33m${truncate("\u26A0 UNKNOWN", 11)}\x1B[0m \u2502 `; } out += `\u2514${"\u2500".repeat(38)}\u2534${"\u2500".repeat(13)}\u2534${"\u2500".repeat(13)}\u2534${"\u2500".repeat(11)}\u2534${"\u2500".repeat(11)}\u2534${"\u2500".repeat(9)}\u2534${"\u2500".repeat(13)}\u2518 @@ -3005,9 +3021,13 @@ function formatTable(result2) { out += `\x1B[32mScope 2 Savings: ${formatDelta2(-result2.totals.potentialCo2eSavingGramsPerMonth)} | ${formatCostDelta2(-result2.totals.potentialCostSavingUsdPerMonth)}\x1B[0m `; } - if (result2.skipped.length > 0) { + const totalSkipped = result2.skipped.length + unsupportedResources.length; + if (totalSkipped > 0) { + const skippedNote = result2.skipped.length > 0 ? `${result2.skipped.length} unresolvable at plan time` : ""; + const unknownNote = unsupportedResources.length > 0 ? `${unsupportedResources.length} instance type(s) not yet in ledger` : ""; + const parts = [skippedNote, unknownNote].filter(Boolean).join(", "); out += ` -\x1B[90mNote: ${result2.skipped.length} resource(s) were skipped due to runtime abstractions.\x1B[0m +\x1B[90mNote: ${parts}. Actual footprint may be higher.\x1B[0m `; } return out; diff --git a/formatters/markdown.test.ts b/formatters/markdown.test.ts index df5912b..ff94e53 100644 --- a/formatters/markdown.test.ts +++ b/formatters/markdown.test.ts @@ -148,6 +148,30 @@ describe('formatMarkdown', () => { assert.ok(md.includes('aws_ecs_service'), 'Should list the unsupported type'); }); + it('shows LOW_ASSUMED_DEFAULT resources in skipped section with unsupportedReason', () => { + const result = makeMockResult({ + resources: [ + { + input: { resourceId: 'google_compute_instance.old', instanceType: 'n1-standard-4', region: 'us-central1', provider: 'gcp' as const }, + baseline: makeMockBaseline({ + confidence: 'LOW_ASSUMED_DEFAULT' as const, + totalCo2eGramsPerMonth: 0, + embodiedCo2eGramsPerMonth: 0, + totalCostUsdPerMonth: 0, + unsupportedReason: 'Instance type "n1-standard-4" is not present in the GCP section of the Open GreenOps Methodology Ledger.', + }), + recommendation: null, + }, + ], + totals: makeMockTotals(), + }); + const md = formatMarkdown(result); + assert.ok(md.includes('Skipped Resource'), 'Should show skipped section for unsupported instances'); + assert.ok(md.includes('n1-standard-4'), 'Should show the unsupported instance type in skipped section'); + assert.ok(md.includes('not present in the GCP'), 'Should show the unsupportedReason'); + assert.ok(!md.includes('✅ Optimal'), 'Should NOT show as optimal resource'); + }); + it('does not show coverage note when only known_after_apply resources are skipped', () => { const result = makeMockResult({ skipped: [{ resourceId: 'aws_instance.unknown', reason: 'known_after_apply' }], diff --git a/formatters/markdown.ts b/formatters/markdown.ts index 852fe55..c707e8f 100644 --- a/formatters/markdown.ts +++ b/formatters/markdown.ts @@ -13,7 +13,8 @@ function formatWater(litres: number): string { export function formatMarkdown(result: PlanAnalysisResult, options: FormatterOptions = {}): string { const METHODOLOGY_URL = options.repositoryUrl || 'https://github.com/omrdev1/greenops-cli/blob/main/METHODOLOGY.md'; - const recsCount = result.resources.filter(r => r.recommendation).length; + const analysedForCount = result.resources.filter(r => r.baseline.confidence !== 'LOW_ASSUMED_DEFAULT'); + const recsCount = analysedForCount.filter(r => r.recommendation).length; let out = `## 🌱 GreenOps Infrastructure Impact\n\n`; @@ -41,21 +42,30 @@ export function formatMarkdown(result: PlanAnalysisResult, options: FormatterOpt out += `> ✅ **Already optimally configured.** No upgrades recommended.\n\n`; } + // Separate fully-analysed resources from unsupported (LOW_ASSUMED_DEFAULT) + const analysed = result.resources.filter(r => r.baseline.confidence !== 'LOW_ASSUMED_DEFAULT'); + const unsupportedResources = result.resources.filter(r => r.baseline.confidence === 'LOW_ASSUMED_DEFAULT'); + out += `### Resource Breakdown\n\n`; out += `| Resource | Type | Region | Scope 2 CO2e | Scope 3 CO2e | Water | Cost/mo | Action |\n`; out += `|---|---|---|---|---|---|---|---|\n`; - for (const r of result.resources) { + for (const r of analysed) { const action = r.recommendation ? `💡 [View Recommendation](#recommendations)` : `✅ Optimal`; - out += `| \`${r.input.resourceId}\` | \`${r.input.instanceType}\` | \`${r.input.region}\` | ${formatGrams(r.baseline.totalCo2eGramsPerMonth)} | ${formatGrams(r.baseline.embodiedCo2eGramsPerMonth)} | ${formatWater(r.baseline.waterLitresPerMonth)} | $${r.baseline.totalCostUsdPerMonth.toFixed(2)} | ${action} |\n`; + out += `| \`${r.input.resourceId}\` | \`${r.input.instanceType}\` | \`${r.input.region}\` | ${formatGrams(r.baseline.totalCo2eGramsPerMonth)} | ${formatGrams(r.baseline.embodiedCo2eGramsPerMonth)} | ${formatWater(r.baseline.waterLitresPerMonth)} | ${r.baseline.totalCostUsdPerMonth.toFixed(2)} | ${action} |\n`; } out += `\n`; - if (result.skipped.length > 0) { - out += `
⚠️ ${result.skipped.length} Skipped Resources\n\n`; - out += `The following resources were excluded from analysis (typically due to runtime-resolved attributes). The actual footprint may be higher.\n\n`; - out += `| Resource | Reason |\n|---|---|\n`; + const totalSkipped = result.skipped.length + unsupportedResources.length; + if (totalSkipped > 0) { + out += `
⚠️ ${totalSkipped} Skipped Resource${totalSkipped !== 1 ? 's' : ''}\n\n`; + out += `The following resources were excluded from analysis. The actual footprint may be higher.\n\n`; + out += `| Resource | Instance | Reason |\n|---|---|---|\n`; for (const s of result.skipped) { - out += `| \`${s.resourceId}\` | \`${s.reason}\` |\n`; + out += `| \`${s.resourceId}\` | — | \`${s.reason}\` |\n`; + } + for (const r of unsupportedResources) { + const reason = r.baseline.unsupportedReason ?? 'Instance type not in ledger'; + out += `| \`${r.input.resourceId}\` | \`${r.input.instanceType}\` | ${reason} |\n`; } out += `\n
\n\n`; } @@ -83,7 +93,7 @@ export function formatMarkdown(result: PlanAnalysisResult, options: FormatterOpt out += `---\n`; out += `*Emissions calculated using the [Open GreenOps Methodology Ledger v${result.ledgerVersion}](${METHODOLOGY_URL}). `; out += `Scope 2 (operational) and Scope 3 (embodied) emissions tracked. `; - out += `Water consumption estimated from AWS 2023 WUE data. `; + out += `Water consumption estimated from provider sustainability reports (AWS 2023, Microsoft 2023, Google 2023). `; out += `Math is MIT-licensed and auditable. Analysed at ${result.analysedAt}.*\n`; if (options.showUpgradePrompt) { diff --git a/formatters/table.test.ts b/formatters/table.test.ts index 4302de4..deded8e 100644 --- a/formatters/table.test.ts +++ b/formatters/table.test.ts @@ -81,6 +81,29 @@ describe('formatTable', () => { assert.ok(table.includes('SKIPPED'), 'Should show SKIPPED for skipped resources'); }); + it('shows UNKNOWN marker for LOW_ASSUMED_DEFAULT resources instead of OK/UPGRADE', () => { + const result = makeMockResult({ + resources: [ + { + input: { resourceId: 'azurerm_linux_virtual_machine.big', instanceType: 'Standard_M96ms_v3', region: 'eastus', provider: 'azure' as const }, + baseline: makeMockBaseline({ + confidence: 'LOW_ASSUMED_DEFAULT' as const, + totalCo2eGramsPerMonth: 0, + embodiedCo2eGramsPerMonth: 0, + totalCostUsdPerMonth: 0, + unsupportedReason: 'Instance type "Standard_M96ms_v3" is not present in the AZURE ledger.', + }), + recommendation: null, + }, + ], + totals: makeMockTotals(), + }); + const table = formatTable(result); + assert.ok(table.includes('UNKNOWN'), 'Should show UNKNOWN for unsupported instance types'); + assert.ok(!table.includes('OK'), 'Should NOT show OK for unsupported resources'); + assert.ok(table.includes('not yet in ledger'), 'Footer should mention ledger gap'); + }); + it('shows Scope 2 and Scope 3 columns', () => { const result = makeMockResult({ resources: [{ diff --git a/formatters/table.ts b/formatters/table.ts index c583441..818a19e 100644 --- a/formatters/table.ts +++ b/formatters/table.ts @@ -23,16 +23,25 @@ export function formatTable(result: PlanAnalysisResult): string { out += `│ ${truncate('Resource', 36)} │ ${truncate('Instance', 11)} │ ${truncate('Region', 11)} │ ${truncate('Scope 2', 9)} │ ${truncate('Scope 3', 9)} │ ${truncate('Water', 7)} │ ${truncate('Action', 11)} │\n`; out += `├${'─'.repeat(38)}┼${'─'.repeat(13)}┼${'─'.repeat(13)}┼${'─'.repeat(11)}┼${'─'.repeat(11)}┼${'─'.repeat(9)}┼${'─'.repeat(13)}┤\n`; - for (const r of result.resources) { + // Separate analysed resources from LOW_ASSUMED_DEFAULT (unsupported instance/region) + const analysed = result.resources.filter(r => r.baseline.confidence !== 'LOW_ASSUMED_DEFAULT'); + const unsupportedResources = result.resources.filter(r => r.baseline.confidence === 'LOW_ASSUMED_DEFAULT'); + + for (const r of analysed) { const scope2 = formatGrams(r.baseline.totalCo2eGramsPerMonth); const scope3 = formatGrams(r.baseline.embodiedCo2eGramsPerMonth); const water = formatWater(r.baseline.waterLitresPerMonth); const action = r.recommendation ? `\x1b[33mUPGRADE\x1b[0m` : `\x1b[32mOK\x1b[0m`; out += `│ ${truncate(r.input.resourceId, 36)} │ ${truncate(r.input.instanceType, 11)} │ ${truncate(r.input.region, 11)} │ ${truncate(scope2, 9)} │ ${truncate(scope3, 9)} │ ${truncate(water, 7)} │ ${truncate(action, 11)} │\n`; } + // Skipped: known_after_apply and other runtime-unresolvable resources for (const s of result.skipped) { out += `│ \x1b[90m${truncate(s.resourceId, 36)}\x1b[0m │ \x1b[90m${truncate('---', 11)}\x1b[0m │ \x1b[90m${truncate('---', 11)}\x1b[0m │ \x1b[90m${truncate('---', 9)}\x1b[0m │ \x1b[90m${truncate('---', 9)}\x1b[0m │ \x1b[90m${truncate('---', 7)}\x1b[0m │ \x1b[33m${truncate('⚠ SKIPPED', 11)}\x1b[0m │\n`; } + // Skipped: unsupported instance types not in the ledger + for (const r of unsupportedResources) { + out += `│ \x1b[90m${truncate(r.input.resourceId, 36)}\x1b[0m │ \x1b[90m${truncate(r.input.instanceType, 11)}\x1b[0m │ \x1b[90m${truncate(r.input.region, 11)}\x1b[0m │ \x1b[90m${truncate('---', 9)}\x1b[0m │ \x1b[90m${truncate('---', 9)}\x1b[0m │ \x1b[90m${truncate('---', 7)}\x1b[0m │ \x1b[33m${truncate('⚠ UNKNOWN', 11)}\x1b[0m │\n`; + } out += `└${'─'.repeat(38)}┴${'─'.repeat(13)}┴${'─'.repeat(13)}┴${'─'.repeat(11)}┴${'─'.repeat(11)}┴${'─'.repeat(9)}┴${'─'.repeat(13)}┘\n\n`; out += `Scope 2: ${formatGrams(result.totals.currentCo2eGramsPerMonth)} | Scope 3: ${formatGrams(result.totals.currentEmbodiedCo2eGramsPerMonth)} | Lifecycle: ${formatGrams(result.totals.currentLifecycleCo2eGramsPerMonth)}\n`; @@ -42,8 +51,12 @@ export function formatTable(result: PlanAnalysisResult): string { out += `\x1b[32mScope 2 Savings: ${formatDelta(-result.totals.potentialCo2eSavingGramsPerMonth)} | ${formatCostDelta(-result.totals.potentialCostSavingUsdPerMonth)}\x1b[0m\n`; } - if (result.skipped.length > 0) { - out += `\n\x1b[90mNote: ${result.skipped.length} resource(s) were skipped due to runtime abstractions.\x1b[0m\n`; + const totalSkipped = result.skipped.length + unsupportedResources.length; + if (totalSkipped > 0) { + const skippedNote = result.skipped.length > 0 ? `${result.skipped.length} unresolvable at plan time` : ''; + const unknownNote = unsupportedResources.length > 0 ? `${unsupportedResources.length} instance type(s) not yet in ledger` : ''; + const parts = [skippedNote, unknownNote].filter(Boolean).join(', '); + out += `\n\x1b[90mNote: ${parts}. Actual footprint may be higher.\x1b[0m\n`; } return out; diff --git a/package.json b/package.json index 87b78e5..f770ffd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "greenops-cli", - "version": "0.5.0", + "version": "0.5.2", "description": "Carbon footprint linting for Terraform plans — AWS, Azure, and GCP. Analyses infrastructure changes for Scope 2, Scope 3, and water impact. Posts recommendations directly on GitHub PRs.", "main": "dist/index.cjs", "bin": { diff --git a/suggestions.ts b/suggestions.ts index d8e6a47..5f6ce66 100644 --- a/suggestions.ts +++ b/suggestions.ts @@ -8,7 +8,8 @@ * Design principles: * - Zero runtime dependencies: uses Node 20's built-in fetch API exclusively. * - Precise targeting: suggestions are posted on the exact line that contains - * the attribute being changed (instance_type, instance_class). + * the attribute being changed (instance_type/instance_class for AWS, + * size for Azure, machine_type for GCP). * - Idempotent: existing GreenOps suggestion comments are updated, not duplicated. * - Fail-open: if the GitHub API is unreachable or the plan file cannot be mapped * to a source file, the CLI exits 0 with a warning. Never blocks a deployment. @@ -254,10 +255,16 @@ export async function postSuggestions( for (const { input, recommendation } of resourcesWithRecs) { if (!recommendation) continue; - // Determine which attribute and value we're targeting - const isDb = input.resourceId.includes('aws_db_instance') || - input.instanceType.startsWith('db.'); - const attributeKey = isDb ? 'instance_class' : 'instance_type'; + // Determine which attribute and value we're targeting — provider-aware + const provider = input.provider ?? 'aws'; + const isDb = provider === 'aws' && ( + input.resourceId.includes('aws_db_instance') || + input.instanceType.startsWith('db.') + ); + const attributeKey = + provider === 'azure' ? 'size' : + provider === 'gcp' ? 'machine_type' : + isDb ? 'instance_class' : 'instance_type'; const currentValue = isDb ? `db.${input.instanceType}` : input.instanceType; const newValue = recommendation.suggestedInstanceType ? (isDb ? `db.${recommendation.suggestedInstanceType}` : recommendation.suggestedInstanceType)