@pagopa/dx-savemoney 0.2.5 → 0.3.0

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 (103) hide show
  1. package/README.md +33 -27
  2. package/dist/azure/analyzer.d.ts +49 -21
  3. package/dist/azure/analyzer.d.ts.map +1 -1
  4. package/dist/azure/analyzer.js +369 -93
  5. package/dist/azure/analyzer.js.map +1 -1
  6. package/dist/azure/analyzers/advisor.d.ts +68 -0
  7. package/dist/azure/analyzers/advisor.d.ts.map +1 -0
  8. package/dist/azure/analyzers/advisor.js +234 -0
  9. package/dist/azure/analyzers/advisor.js.map +1 -0
  10. package/dist/azure/analyzers/index.d.ts +8 -0
  11. package/dist/azure/analyzers/index.d.ts.map +1 -0
  12. package/dist/azure/analyzers/index.js +6 -0
  13. package/dist/azure/analyzers/index.js.map +1 -0
  14. package/dist/azure/analyzers/registry.d.ts +29 -0
  15. package/dist/azure/analyzers/registry.d.ts.map +1 -0
  16. package/dist/azure/analyzers/registry.js +79 -0
  17. package/dist/azure/analyzers/registry.js.map +1 -0
  18. package/dist/azure/analyzers/subscription.d.ts +53 -0
  19. package/dist/azure/analyzers/subscription.d.ts.map +1 -0
  20. package/dist/azure/analyzers/subscription.js +18 -0
  21. package/dist/azure/analyzers/subscription.js.map +1 -0
  22. package/dist/azure/analyzers/types.d.ts +62 -0
  23. package/dist/azure/analyzers/types.d.ts.map +1 -0
  24. package/dist/azure/analyzers/types.js +15 -0
  25. package/dist/azure/analyzers/types.js.map +1 -0
  26. package/dist/azure/config.d.ts.map +1 -1
  27. package/dist/azure/config.js +2 -0
  28. package/dist/azure/config.js.map +1 -1
  29. package/dist/azure/index.d.ts +1 -0
  30. package/dist/azure/index.d.ts.map +1 -1
  31. package/dist/azure/index.js +1 -0
  32. package/dist/azure/index.js.map +1 -1
  33. package/dist/azure/report.d.ts.map +1 -1
  34. package/dist/azure/report.js +178 -29
  35. package/dist/azure/report.js.map +1 -1
  36. package/dist/azure/resources/app-service.d.ts +2 -1
  37. package/dist/azure/resources/app-service.d.ts.map +1 -1
  38. package/dist/azure/resources/app-service.js +3 -3
  39. package/dist/azure/resources/app-service.js.map +1 -1
  40. package/dist/azure/resources/container-app.d.ts +2 -1
  41. package/dist/azure/resources/container-app.d.ts.map +1 -1
  42. package/dist/azure/resources/container-app.js +9 -9
  43. package/dist/azure/resources/container-app.js.map +1 -1
  44. package/dist/azure/resources/public-ip.d.ts +2 -1
  45. package/dist/azure/resources/public-ip.d.ts.map +1 -1
  46. package/dist/azure/resources/public-ip.js +2 -2
  47. package/dist/azure/resources/public-ip.js.map +1 -1
  48. package/dist/azure/resources/static-web-app.d.ts +2 -1
  49. package/dist/azure/resources/static-web-app.d.ts.map +1 -1
  50. package/dist/azure/resources/static-web-app.js +3 -3
  51. package/dist/azure/resources/static-web-app.js.map +1 -1
  52. package/dist/azure/resources/storage.d.ts +2 -1
  53. package/dist/azure/resources/storage.d.ts.map +1 -1
  54. package/dist/azure/resources/storage.js +2 -2
  55. package/dist/azure/resources/storage.js.map +1 -1
  56. package/dist/azure/resources/vm.d.ts +2 -1
  57. package/dist/azure/resources/vm.d.ts.map +1 -1
  58. package/dist/azure/resources/vm.js +3 -3
  59. package/dist/azure/resources/vm.js.map +1 -1
  60. package/dist/azure/types.d.ts +34 -1
  61. package/dist/azure/types.d.ts.map +1 -1
  62. package/dist/azure/utils.d.ts +35 -3
  63. package/dist/azure/utils.d.ts.map +1 -1
  64. package/dist/azure/utils.js +70 -29
  65. package/dist/azure/utils.js.map +1 -1
  66. package/dist/finding.d.ts +114 -0
  67. package/dist/finding.d.ts.map +1 -0
  68. package/dist/finding.js +51 -0
  69. package/dist/finding.js.map +1 -0
  70. package/dist/index.d.ts +4 -1
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +3 -0
  73. package/dist/index.js.map +1 -1
  74. package/dist/schema.d.ts +5 -0
  75. package/dist/schema.d.ts.map +1 -1
  76. package/dist/schema.js +14 -0
  77. package/dist/schema.js.map +1 -1
  78. package/package.json +4 -1
  79. package/src/__tests__/finding.test.ts +149 -0
  80. package/src/azure/__tests__/analyzer-tags.test.ts +74 -0
  81. package/src/azure/__tests__/report.test.ts +27 -0
  82. package/src/azure/__tests__/utils.test.ts +164 -2
  83. package/src/azure/analyzer.ts +513 -182
  84. package/src/azure/analyzers/__tests__/advisor.test.ts +367 -0
  85. package/src/azure/analyzers/advisor.ts +324 -0
  86. package/src/azure/analyzers/index.ts +14 -0
  87. package/src/azure/analyzers/registry.ts +196 -0
  88. package/src/azure/analyzers/subscription.ts +56 -0
  89. package/src/azure/analyzers/types.ts +66 -0
  90. package/src/azure/config.ts +2 -0
  91. package/src/azure/index.ts +1 -0
  92. package/src/azure/report.ts +206 -35
  93. package/src/azure/resources/app-service.ts +4 -0
  94. package/src/azure/resources/container-app.ts +10 -0
  95. package/src/azure/resources/public-ip.ts +3 -0
  96. package/src/azure/resources/static-web-app.ts +4 -0
  97. package/src/azure/resources/storage.ts +3 -0
  98. package/src/azure/resources/vm.ts +4 -0
  99. package/src/azure/types.ts +35 -1
  100. package/src/azure/utils.ts +110 -39
  101. package/src/finding.ts +152 -0
  102. package/src/index.ts +19 -1
  103. package/src/schema.ts +14 -0
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Default registry of Azure analyzers.
3
+ *
4
+ * Each entry wraps an existing per-type analyzer function (kept in
5
+ * `../resources/`) and exposes it through the unified `Analyzer`
6
+ * interface. The orchestrator simply iterates the registry — no big
7
+ * `switch` statement, no risk of forgetting to wire a new analyzer
8
+ * into the orchestrator when the catalog grows.
9
+ *
10
+ * Adding a new analyzer is a single insertion here.
11
+ */
12
+
13
+ import type { SubscriptionAnalyzer } from "./subscription.js";
14
+ import type { Analyzer } from "./types.js";
15
+
16
+ import {
17
+ analyzeAppServicePlan,
18
+ analyzeContainerApp,
19
+ analyzeDisk,
20
+ analyzeNic,
21
+ analyzePrivateEndpoint,
22
+ analyzePublicIp,
23
+ analyzeStaticSite,
24
+ analyzeStorageAccount,
25
+ analyzeVM,
26
+ } from "../resources/index.js";
27
+ import { createAdvisorAnalyzer } from "./advisor.js";
28
+
29
+ /**
30
+ * Builds the default set of analyzers in the same order they were
31
+ * previously evaluated by the orchestrator's `switch` statement. The
32
+ * order is not behaviourally meaningful today (each resource is matched
33
+ * by exactly one analyzer) but is kept deterministic for predictable
34
+ * logging and to ease future debugging.
35
+ */
36
+ export function createDefaultAnalyzers(): Analyzer[] {
37
+ return [
38
+ {
39
+ analyze: ({
40
+ clients,
41
+ metricsCache,
42
+ resource,
43
+ thresholds,
44
+ timespanDays,
45
+ verbose,
46
+ }) =>
47
+ analyzeContainerApp(
48
+ resource,
49
+ clients.containerApps,
50
+ clients.monitor,
51
+ timespanDays,
52
+ thresholds,
53
+ verbose,
54
+ metricsCache,
55
+ ),
56
+ id: "azure.container-app",
57
+ supports: (r) => r.type?.toLowerCase() === "microsoft.app/containerapps",
58
+ },
59
+ {
60
+ analyze: ({ clients, resource, verbose }) =>
61
+ analyzeDisk(resource, clients.compute, verbose),
62
+ id: "azure.disk",
63
+ supports: (r) => r.type?.toLowerCase() === "microsoft.compute/disks",
64
+ },
65
+ {
66
+ analyze: ({
67
+ clients,
68
+ metricsCache,
69
+ resource,
70
+ thresholds,
71
+ timespanDays,
72
+ verbose,
73
+ }) =>
74
+ analyzeVM(
75
+ resource,
76
+ clients.monitor,
77
+ clients.compute,
78
+ timespanDays,
79
+ thresholds,
80
+ verbose,
81
+ metricsCache,
82
+ ),
83
+ id: "azure.vm",
84
+ supports: (r) =>
85
+ r.type?.toLowerCase() === "microsoft.compute/virtualmachines",
86
+ },
87
+ {
88
+ analyze: ({ clients, resource, verbose }) =>
89
+ analyzeNic(resource, clients.network, verbose),
90
+ id: "azure.nic",
91
+ supports: (r) =>
92
+ r.type?.toLowerCase() === "microsoft.network/networkinterfaces",
93
+ },
94
+ {
95
+ analyze: ({ clients, resource, verbose }) =>
96
+ analyzePrivateEndpoint(resource, clients.network, verbose),
97
+ id: "azure.private-endpoint",
98
+ supports: (r) =>
99
+ r.type?.toLowerCase() === "microsoft.network/privateendpoints",
100
+ },
101
+ {
102
+ analyze: ({
103
+ clients,
104
+ metricsCache,
105
+ resource,
106
+ thresholds,
107
+ timespanDays,
108
+ verbose,
109
+ }) =>
110
+ analyzePublicIp(
111
+ resource,
112
+ clients.network,
113
+ clients.monitor,
114
+ timespanDays,
115
+ thresholds,
116
+ verbose,
117
+ metricsCache,
118
+ ),
119
+ id: "azure.public-ip",
120
+ supports: (r) =>
121
+ r.type?.toLowerCase() === "microsoft.network/publicipaddresses",
122
+ },
123
+ {
124
+ analyze: ({
125
+ clients,
126
+ metricsCache,
127
+ resource,
128
+ thresholds,
129
+ timespanDays,
130
+ verbose,
131
+ }) =>
132
+ analyzeStorageAccount(
133
+ resource,
134
+ clients.monitor,
135
+ timespanDays,
136
+ thresholds,
137
+ verbose,
138
+ metricsCache,
139
+ ),
140
+ id: "azure.storage-account",
141
+ supports: (r) =>
142
+ r.type?.toLowerCase() === "microsoft.storage/storageaccounts",
143
+ },
144
+ {
145
+ analyze: ({
146
+ clients,
147
+ metricsCache,
148
+ resource,
149
+ thresholds,
150
+ timespanDays,
151
+ verbose,
152
+ }) =>
153
+ analyzeAppServicePlan(
154
+ resource,
155
+ clients.webSite,
156
+ clients.monitor,
157
+ timespanDays,
158
+ thresholds,
159
+ verbose,
160
+ metricsCache,
161
+ ),
162
+ id: "azure.app-service-plan",
163
+ supports: (r) => r.type?.toLowerCase() === "microsoft.web/serverfarms",
164
+ },
165
+ {
166
+ analyze: ({
167
+ clients,
168
+ metricsCache,
169
+ resource,
170
+ thresholds,
171
+ timespanDays,
172
+ verbose,
173
+ }) =>
174
+ analyzeStaticSite(
175
+ resource,
176
+ clients.monitor,
177
+ timespanDays,
178
+ thresholds,
179
+ verbose,
180
+ metricsCache,
181
+ ),
182
+ id: "azure.static-web-app",
183
+ supports: (r) => r.type?.toLowerCase() === "microsoft.web/staticsites",
184
+ },
185
+ ];
186
+ }
187
+
188
+ /**
189
+ * Builds the default set of subscription-level analyzers.
190
+ *
191
+ * Today this is just Azure Advisor; Phase 4 will add a quota / usages
192
+ * analyzer here. Adding new sources is a single insertion.
193
+ */
194
+ export function createDefaultSubscriptionAnalyzers(): SubscriptionAnalyzer[] {
195
+ return [createAdvisorAnalyzer()];
196
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Subscription-level analyzer plugin contract.
3
+ *
4
+ * Some data sources are not naturally per-resource. Azure Advisor, for
5
+ * example, returns a flat list of recommendations for an entire
6
+ * subscription in a single call; future quota / `Microsoft.Capacity/usages`
7
+ * analyzers will follow the same shape.
8
+ *
9
+ * A `SubscriptionAnalyzer` is invoked once per subscription. It returns a
10
+ * flat list of `Finding`s carrying the `resourceId` they refer to; the
11
+ * orchestrator merges those findings into the per-resource reports.
12
+ *
13
+ * This interface is intentionally additive: per-resource `Analyzer`s
14
+ * (see `./types.ts`) keep working untouched. Sources written against the
15
+ * two interfaces can coexist in the same run.
16
+ */
17
+
18
+ import type { TokenCredential } from "@azure/identity";
19
+
20
+ import type { Finding } from "../../finding.js";
21
+
22
+ /**
23
+ * Contract every subscription-level analyzer must satisfy.
24
+ */
25
+ export type SubscriptionAnalyzer = {
26
+ /**
27
+ * Runs the analyzer for the given subscription. Implementations should
28
+ * be resilient: a failure here must not abort the whole run, the
29
+ * orchestrator logs and continues with the remaining analyzers.
30
+ */
31
+ analyze(ctx: SubscriptionContext): Promise<Finding[]>;
32
+ /**
33
+ * Stable identifier of the analyzer (e.g. `azure.advisor`,
34
+ * `azure.quota`). Used for logging and future deduplication logic.
35
+ */
36
+ readonly id: string;
37
+ };
38
+
39
+ /**
40
+ * Context passed to every subscription-level analyzer.
41
+ */
42
+ export type SubscriptionContext = {
43
+ /**
44
+ * Azure credential to instantiate management clients. The orchestrator
45
+ * builds it once and reuses it across analyzers.
46
+ */
47
+ credential: TokenCredential;
48
+ /**
49
+ * Subscription ID to analyze.
50
+ */
51
+ subscriptionId: string;
52
+ /**
53
+ * Whether verbose logging is enabled.
54
+ */
55
+ verbose: boolean;
56
+ };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Plugin architecture for Azure resource analyzers.
3
+ *
4
+ * Each `Analyzer` is a self-contained unit that:
5
+ * 1. declares the resource types it can handle (`supports`)
6
+ * 2. produces an `AnalysisResult` for a given resource (`analyze`)
7
+ *
8
+ * The orchestrator in `analyzer.ts` walks the registered analyzers for
9
+ * every resource it encounters. New sources (Azure Advisor, custom
10
+ * checks, pricing-enriched analyzers, …) can be added by implementing
11
+ * the interface and registering them in `registry.ts` without touching
12
+ * the orchestrator.
13
+ */
14
+
15
+ import type { ContainerAppsAPIClient } from "@azure/arm-appcontainers";
16
+ import type { WebSiteManagementClient } from "@azure/arm-appservice";
17
+ import type { ComputeManagementClient } from "@azure/arm-compute";
18
+ import type { MonitorClient } from "@azure/arm-monitor";
19
+ import type { NetworkManagementClient } from "@azure/arm-network";
20
+ import type * as armResources from "@azure/arm-resources";
21
+
22
+ import type { AnalysisResult, Thresholds } from "../../types.js";
23
+ import type { MetricsCache } from "../utils.js";
24
+
25
+ /**
26
+ * Contract every analyzer must satisfy.
27
+ */
28
+ export type Analyzer = {
29
+ analyze(ctx: AnalyzerContext): Promise<AnalysisResult>;
30
+ /**
31
+ * Stable identifier of the analyzer (e.g. `azure.vm`, `azure.advisor`).
32
+ * Used for logging, telemetry and future deduplication logic.
33
+ */
34
+ readonly id: string;
35
+ supports(resource: armResources.GenericResource): boolean;
36
+ };
37
+
38
+ /**
39
+ * Per-resource analysis context handed to every analyzer.
40
+ */
41
+ export type AnalyzerContext = {
42
+ clients: AzureClients;
43
+ /**
44
+ * Run-scoped metrics cache. Pass through to `getMetric` calls so that
45
+ * concurrent `analyzeAzureResources` invocations stay isolated.
46
+ */
47
+ metricsCache: MetricsCache;
48
+ preferredLocation: string;
49
+ resource: armResources.GenericResource;
50
+ thresholds: Thresholds;
51
+ timespanDays: number;
52
+ verbose: boolean;
53
+ };
54
+
55
+ /**
56
+ * Bundle of Azure SDK clients an analyzer might need. The orchestrator
57
+ * builds them once per subscription and passes the same instances to
58
+ * every analyzer.
59
+ */
60
+ export type AzureClients = {
61
+ compute: ComputeManagementClient;
62
+ containerApps: ContainerAppsAPIClient;
63
+ monitor: MonitorClient;
64
+ network: NetworkManagementClient;
65
+ webSite: WebSiteManagementClient;
66
+ };
@@ -42,7 +42,9 @@ export async function loadAzureConfig(
42
42
  const rawYaml = yaml.load(raw);
43
43
  const parsed = ConfigSchema.parse(rawYaml);
44
44
  return {
45
+ concurrency: parsed.azure.concurrency,
45
46
  preferredLocation: parsed.azure.preferredLocation,
47
+ sources: parsed.azure.sources,
46
48
  subscriptionIds: parsed.azure.subscriptionIds,
47
49
  thresholds: parsed.azure.thresholds,
48
50
  timespanDays: parsed.azure.timespanDays,
@@ -6,4 +6,5 @@
6
6
 
7
7
  export * from "./analyzer.js";
8
8
  export * from "./config.js";
9
+ export * from "./report.js";
9
10
  export * from "./types.js";
@@ -2,16 +2,25 @@
2
2
  * Azure report generation
3
3
  */
4
4
 
5
+ import Table from "cli-table3";
6
+
7
+ import type { Money } from "../finding.js";
5
8
  import type {
6
9
  AzureDetailedResourceReport,
7
10
  AzureResourceReport,
8
11
  } from "./types.js";
9
12
 
13
+ // Fixed column widths — keeps the Reason column within a readable width
14
+ // while allowing cli-table3 to word-wrap long content across multiple lines.
15
+ const COL_WIDTHS = [30, 35, 25, 8, 55] as const;
16
+
10
17
  // ANSI color codes — only applied when stdout is a TTY to avoid cluttering redirected output
11
18
  const isTTY = process.stdout.isTTY ?? false;
12
19
  const RED = isTTY ? "\x1b[31m" : "";
13
20
  const YELLOW = isTTY ? "\x1b[33m" : "";
14
21
  const BLUE = isTTY ? "\x1b[34m" : "";
22
+ const GREEN = isTTY ? "\x1b[32m" : "";
23
+ const CYAN = isTTY ? "\x1b[36m" : "";
15
24
  const BOLD = isTTY ? "\x1b[1m" : "";
16
25
  const RESET = isTTY ? "\x1b[0m" : "";
17
26
  const DIM = isTTY ? "\x1b[2m" : "";
@@ -28,6 +37,12 @@ const RISK_COLOR = {
28
37
  medium: YELLOW,
29
38
  } as const;
30
39
 
40
+ type Summary = {
41
+ counts: { high: number; low: number; medium: number };
42
+ savingsByCurrency: Map<string, number>;
43
+ sourceCounts: Record<string, number>;
44
+ };
45
+
31
46
  /**
32
47
  * Generates a report from Azure resource analysis in the specified format.
33
48
  *
@@ -43,77 +58,233 @@ export async function generateReport(
43
58
  return;
44
59
  }
45
60
 
46
- // For other formats, we extract the summary data.
47
- const summaryReport: AzureResourceReport[] = report.map((r) => ({
48
- costRisk: r.analysis.costRisk,
49
- location: r.resource.location ?? "",
50
- name: r.resource.name ?? "unknown",
51
- reason: r.analysis.reason,
52
- resourceGroup: r.resource.id?.split("/")[4],
53
- subscriptionId: r.resource.id?.split("/")[2] ?? "unknown",
54
- suspectedUnused: r.analysis.suspectedUnused,
55
- type: r.resource.type ?? "unknown",
56
- }));
57
-
58
61
  if (format === "json") {
62
+ const summaryReport: AzureResourceReport[] = report.map((r) => ({
63
+ costRisk: r.analysis.costRisk,
64
+ location: r.resource.location ?? "",
65
+ name: r.resource.name ?? "unknown",
66
+ reason: r.analysis.reason,
67
+ resourceGroup: r.resource.id?.split("/")[4],
68
+ subscriptionId: r.resource.id?.split("/")[2] ?? "unknown",
69
+ suspectedUnused: r.analysis.suspectedUnused,
70
+ type: r.resource.type ?? "unknown",
71
+ }));
59
72
  console.log(JSON.stringify(summaryReport, null, 2));
60
73
  } else if (format === "lint") {
61
74
  generateLintReport(report);
62
75
  } else {
63
- console.table(
64
- summaryReport.map((r) => ({
65
- Name: r.name,
66
- Reason: r.reason,
67
- "Resource Group": r.resourceGroup || "N/A",
68
- Risk: r.costRisk,
69
- Type: r.type,
70
- })),
71
- ["Name", "Type", "Resource Group", "Risk", "Reason"],
72
- );
76
+ const summaryReport: AzureResourceReport[] = report.map((r) => ({
77
+ costRisk: r.analysis.costRisk,
78
+ location: r.resource.location ?? "",
79
+ name: r.resource.name ?? "unknown",
80
+ reason: r.analysis.reason,
81
+ resourceGroup: r.resource.id?.split("/")[4],
82
+ subscriptionId: r.resource.id?.split("/")[2] ?? "unknown",
83
+ suspectedUnused: r.analysis.suspectedUnused,
84
+ type: r.resource.type ?? "unknown",
85
+ }));
86
+ const table = new Table({
87
+ colWidths: [...COL_WIDTHS],
88
+ head: ["Name", "Type", "Resource Group", "Risk", "Reason"],
89
+ wordWrap: true,
90
+ });
91
+ for (const r of summaryReport) {
92
+ table.push([
93
+ r.name,
94
+ r.type,
95
+ r.resourceGroup || "N/A",
96
+ r.costRisk,
97
+ r.reason,
98
+ ]);
99
+ }
100
+ console.log(table.toString());
101
+ // Same summary block the lint format prints, so the table view also
102
+ // surfaces totals, source breakdown and estimated savings at a glance.
103
+ printSummary(computeSummary(report));
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Walks the report once to compute totals, source breakdown and savings
109
+ * per currency without mutating it. Used by the table format, which has
110
+ * no per-line walk of its own.
111
+ */
112
+ function computeSummary(report: AzureDetailedResourceReport[]): Summary {
113
+ const summary: Summary = {
114
+ counts: { high: 0, low: 0, medium: 0 },
115
+ savingsByCurrency: new Map(),
116
+ sourceCounts: {},
117
+ };
118
+ for (const entry of report) {
119
+ const lines = entry.findings?.length
120
+ ? entry.findings.map((f) => ({
121
+ savings: f.estimatedMonthlySavings,
122
+ severity: f.severity,
123
+ source: f.source,
124
+ }))
125
+ : splitReasons(entry.analysis.reason).map(() => ({
126
+ savings: undefined,
127
+ severity: entry.analysis.costRisk,
128
+ source: "custom" as const,
129
+ }));
130
+ for (const line of lines) {
131
+ summary.counts[line.severity]++;
132
+ summary.sourceCounts[line.source] =
133
+ (summary.sourceCounts[line.source] ?? 0) + 1;
134
+ if (line.savings) {
135
+ summary.savingsByCurrency.set(
136
+ line.savings.currency,
137
+ (summary.savingsByCurrency.get(line.savings.currency) ?? 0) +
138
+ line.savings.amount,
139
+ );
140
+ }
141
+ }
142
+ }
143
+ return summary;
144
+ }
145
+
146
+ /**
147
+ * Formats a monetary amount with an ISO 4217 currency code, falling back
148
+ * to the raw code prefix when the runtime locale data lacks the currency
149
+ * symbol.
150
+ */
151
+ function formatAmount(amount: number, currency: string): string {
152
+ try {
153
+ return new Intl.NumberFormat("en-US", {
154
+ currency,
155
+ maximumFractionDigits: 2,
156
+ minimumFractionDigits: 2,
157
+ style: "currency",
158
+ }).format(amount);
159
+ } catch {
160
+ return `${currency} ${amount.toFixed(2)}`;
73
161
  }
74
162
  }
75
163
 
164
+ /**
165
+ * Renders an estimated monthly savings value as the short "(€ 12.50/mo)"
166
+ * label that ships next to each lint line. Returns an empty string when
167
+ * the analyzer didn't provide an estimate.
168
+ */
169
+ function formatSavings(savings: Money | undefined): string {
170
+ if (!savings) return "";
171
+ return `(${formatAmount(savings.amount, savings.currency)}/mo)`;
172
+ }
173
+
76
174
  /**
77
175
  * Renders a linter-style report to stdout, grouping findings by resource.
78
176
  *
177
+ * When `Finding[]` is attached to a report (Phase 1+), each finding is
178
+ * rendered with its `source` badge (e.g. `[advisor]`) and the estimated
179
+ * monthly savings, when known. For older payloads without `findings` we
180
+ * fall back to splitting `analysis.reason` like before so the format stays
181
+ * backward compatible.
182
+ *
79
183
  * Example output:
80
184
  *
81
185
  * /subscriptions/.../virtualMachines/my-vm
82
- * ✖ HIGH VM is deallocated.
83
- * ✖ HIGH No tags found.
186
+ * ✖ HIGH [advisor] Right-size your VM. (€ 12.50/mo)
187
+ * ✖ HIGH [custom] No tags found.
84
188
  *
85
189
  * Summary: 3 issues found (2 high, 0 medium, 1 low)
190
+ * Estimated monthly savings: € 12.50
86
191
  */
87
192
  function generateLintReport(report: AzureDetailedResourceReport[]): void {
88
- const counts = { high: 0, low: 0, medium: 0 };
193
+ const summary = {
194
+ counts: { high: 0, low: 0, medium: 0 },
195
+ savingsByCurrency: new Map<string, number>(),
196
+ sourceCounts: {} as Record<string, number>,
197
+ };
89
198
 
90
199
  for (const entry of report) {
91
200
  const resourceId =
92
201
  entry.resource.id ?? `unknown/${entry.resource.name ?? "unknown"}`;
93
- const risk = entry.analysis.costRisk;
94
- const findings = splitReasons(entry.analysis.reason);
95
-
96
202
  console.log(`${BOLD}${resourceId}${RESET}`);
97
203
 
98
- for (const finding of findings) {
99
- const icon = RISK_ICON[risk];
100
- const color = RISK_COLOR[risk];
101
- const label = `${color}${risk.toUpperCase().padEnd(6)}${RESET}`;
102
- console.log(` ${icon} ${label} ${DIM}${finding}${RESET}`);
103
- counts[risk]++;
204
+ const lines = entry.findings?.length
205
+ ? entry.findings.map((f) => ({
206
+ extra: formatSavings(f.estimatedMonthlySavings),
207
+ severity: f.severity,
208
+ source: f.source,
209
+ text: f.reason,
210
+ }))
211
+ : splitReasons(entry.analysis.reason).map((text) => ({
212
+ extra: "",
213
+ severity: entry.analysis.costRisk,
214
+ source: "custom" as const,
215
+ text,
216
+ }));
217
+
218
+ for (const line of lines) {
219
+ const icon = RISK_ICON[line.severity];
220
+ const color = RISK_COLOR[line.severity];
221
+ const label = `${color}${line.severity.toUpperCase().padEnd(6)}${RESET}`;
222
+ const sourceBadge = `${CYAN}[${line.source}]${RESET}`;
223
+ const extra = line.extra ? ` ${GREEN}${line.extra}${RESET}` : "";
224
+ console.log(
225
+ ` ${icon} ${label} ${sourceBadge} ${DIM}${line.text}${RESET}${extra}`,
226
+ );
227
+ summary.counts[line.severity]++;
228
+ summary.sourceCounts[line.source] =
229
+ (summary.sourceCounts[line.source] ?? 0) + 1;
230
+ }
231
+
232
+ if (entry.findings) {
233
+ for (const f of entry.findings) {
234
+ if (f.estimatedMonthlySavings) {
235
+ const { amount, currency } = f.estimatedMonthlySavings;
236
+ summary.savingsByCurrency.set(
237
+ currency,
238
+ (summary.savingsByCurrency.get(currency) ?? 0) + amount,
239
+ );
240
+ }
241
+ }
104
242
  }
105
243
 
106
244
  console.log();
107
245
  }
108
246
 
247
+ printSummary(summary);
248
+ }
249
+
250
+ /**
251
+ * Prints the shared trailer (issues, sources, estimated savings) used by
252
+ * both the lint and the table format.
253
+ *
254
+ * Savings are kept grouped by currency intentionally: Azure Advisor
255
+ * returns each recommendation in the subscription's native billing
256
+ * currency, so the same report can carry EUR and USD figures at the
257
+ * same time when subscriptions are billed in different regions. We do
258
+ * NOT convert across currencies — the rates would be both volatile and
259
+ * out of scope for this tool.
260
+ */
261
+ function printSummary(summary: Summary): void {
262
+ const { counts, savingsByCurrency, sourceCounts } = summary;
109
263
  const total = counts.high + counts.medium + counts.low;
110
264
  const summaryLine =
111
265
  `${BOLD}Summary:${RESET} ${total} issue${total !== 1 ? "s" : ""} found` +
112
266
  ` ${RED}(${counts.high} high${RESET}` +
113
267
  `, ${YELLOW}${counts.medium} medium${RESET}` +
114
268
  `, ${BLUE}${counts.low} low${RESET})`;
115
-
116
269
  console.log(summaryLine);
270
+
271
+ const sourceBreakdown = Object.entries(sourceCounts)
272
+ .sort(([a], [b]) => a.localeCompare(b))
273
+ .map(([source, n]) => `${n} ${source}`)
274
+ .join(", ");
275
+ if (sourceBreakdown) {
276
+ console.log(`${BOLD}Sources:${RESET} ${sourceBreakdown}`);
277
+ }
278
+
279
+ if (savingsByCurrency.size > 0) {
280
+ const parts = [...savingsByCurrency.entries()].map(
281
+ ([currency, amount]) =>
282
+ `${GREEN}${formatAmount(amount, currency)}${RESET}`,
283
+ );
284
+ console.log(
285
+ `${BOLD}Estimated monthly savings:${RESET} ${parts.join(", ")}`,
286
+ );
287
+ }
117
288
  }
118
289
 
119
290
  /**
@@ -13,6 +13,7 @@ import type { AnalysisResult, Thresholds } from "../../types.js";
13
13
  import { DEFAULT_THRESHOLDS } from "../../types.js";
14
14
  import {
15
15
  getMetric,
16
+ type MetricsCache,
16
17
  verboseLog,
17
18
  verboseLogAnalysisResult,
18
19
  verboseLogResourceStart,
@@ -34,6 +35,7 @@ export async function analyzeAppServicePlan(
34
35
  timespanDays: number,
35
36
  thresholds: Thresholds = DEFAULT_THRESHOLDS,
36
37
  verbose = false,
38
+ cache?: MetricsCache,
37
39
  ): Promise<AnalysisResult> {
38
40
  verboseLogResourceStart(
39
41
  verbose,
@@ -79,6 +81,7 @@ export async function analyzeAppServicePlan(
79
81
  "CpuPercentage",
80
82
  "Average",
81
83
  timespanDays,
84
+ cache,
82
85
  );
83
86
 
84
87
  const memoryPercentage = await getMetric(
@@ -87,6 +90,7 @@ export async function analyzeAppServicePlan(
87
90
  "MemoryPercentage",
88
91
  "Average",
89
92
  timespanDays,
93
+ cache,
90
94
  );
91
95
 
92
96
  if (