@pagopa/dx-savemoney 0.1.5 → 0.2.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 (76) hide show
  1. package/README.md +104 -27
  2. package/dist/azure/analyzer.d.ts +4 -4
  3. package/dist/azure/analyzer.d.ts.map +1 -1
  4. package/dist/azure/analyzer.js +16 -10
  5. package/dist/azure/analyzer.js.map +1 -1
  6. package/dist/azure/config.d.ts +15 -3
  7. package/dist/azure/config.d.ts.map +1 -1
  8. package/dist/azure/config.js +32 -16
  9. package/dist/azure/config.js.map +1 -1
  10. package/dist/azure/report.d.ts +2 -2
  11. package/dist/azure/report.d.ts.map +1 -1
  12. package/dist/azure/report.js +68 -3
  13. package/dist/azure/report.js.map +1 -1
  14. package/dist/azure/resources/app-service.d.ts +2 -2
  15. package/dist/azure/resources/app-service.d.ts.map +1 -1
  16. package/dist/azure/resources/app-service.js +8 -5
  17. package/dist/azure/resources/app-service.js.map +1 -1
  18. package/dist/azure/resources/container-app.d.ts +2 -2
  19. package/dist/azure/resources/container-app.d.ts.map +1 -1
  20. package/dist/azure/resources/container-app.js +10 -8
  21. package/dist/azure/resources/container-app.js.map +1 -1
  22. package/dist/azure/resources/public-ip.d.ts +2 -2
  23. package/dist/azure/resources/public-ip.d.ts.map +1 -1
  24. package/dist/azure/resources/public-ip.js +3 -3
  25. package/dist/azure/resources/public-ip.js.map +1 -1
  26. package/dist/azure/resources/static-web-app.d.ts +2 -2
  27. package/dist/azure/resources/static-web-app.d.ts.map +1 -1
  28. package/dist/azure/resources/static-web-app.js +4 -5
  29. package/dist/azure/resources/static-web-app.js.map +1 -1
  30. package/dist/azure/resources/storage.d.ts +2 -2
  31. package/dist/azure/resources/storage.d.ts.map +1 -1
  32. package/dist/azure/resources/storage.js +4 -3
  33. package/dist/azure/resources/storage.js.map +1 -1
  34. package/dist/azure/resources/vm.d.ts +2 -2
  35. package/dist/azure/resources/vm.d.ts.map +1 -1
  36. package/dist/azure/resources/vm.js +4 -5
  37. package/dist/azure/resources/vm.js.map +1 -1
  38. package/dist/azure/types.d.ts +10 -2
  39. package/dist/azure/types.d.ts.map +1 -1
  40. package/dist/azure/utils.d.ts +9 -0
  41. package/dist/azure/utils.d.ts.map +1 -1
  42. package/dist/azure/utils.js +14 -0
  43. package/dist/azure/utils.js.map +1 -1
  44. package/dist/index.d.ts +16 -11
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +18 -54
  47. package/dist/index.js.map +1 -1
  48. package/dist/schema.d.ts +210 -0
  49. package/dist/schema.d.ts.map +1 -0
  50. package/dist/schema.js +97 -0
  51. package/dist/schema.js.map +1 -0
  52. package/dist/types.d.ts +35 -0
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/types.js +6 -0
  55. package/dist/types.js.map +1 -1
  56. package/package.json +12 -6
  57. package/src/azure/__tests__/config.test.ts +97 -0
  58. package/src/azure/__tests__/fixtures/full-override.yaml +25 -0
  59. package/src/azure/__tests__/fixtures/partial-override.yaml +10 -0
  60. package/src/azure/__tests__/report.test.ts +138 -0
  61. package/src/azure/__tests__/utils.test.ts +124 -0
  62. package/src/azure/analyzer.ts +24 -3
  63. package/src/azure/config.ts +33 -21
  64. package/src/azure/report.ts +81 -4
  65. package/src/azure/resources/__tests__/storage.test.ts +185 -0
  66. package/src/azure/resources/app-service.ts +13 -5
  67. package/src/azure/resources/container-app.ts +13 -4
  68. package/src/azure/resources/public-ip.ts +4 -3
  69. package/src/azure/resources/static-web-app.ts +5 -5
  70. package/src/azure/resources/storage.ts +7 -3
  71. package/src/azure/resources/vm.ts +5 -5
  72. package/src/azure/types.ts +15 -2
  73. package/src/azure/utils.ts +21 -0
  74. package/src/index.ts +19 -69
  75. package/src/schema.ts +134 -0
  76. package/src/types.ts +14 -0
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Tests for generateReport() with the "lint" format.
3
+ *
4
+ * Since process.stdout.isTTY is false/undefined in test environments,
5
+ * ANSI color codes are empty strings (~no-ops), making the output
6
+ * easy to assert on plain text.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+
11
+ import type { AzureDetailedResourceReport } from "../types.js";
12
+
13
+ import { generateReport } from "../report.js";
14
+
15
+ // ── test fixtures ──────────────────────────────────────────────────────────
16
+
17
+ function makeEntry(
18
+ id: string,
19
+ costRisk: "high" | "low" | "medium",
20
+ reason: string,
21
+ suspectedUnused = true,
22
+ ): AzureDetailedResourceReport {
23
+ return {
24
+ analysis: { costRisk, reason, suspectedUnused },
25
+ resource: {
26
+ id,
27
+ name: id.split("/").pop() ?? "unknown",
28
+ type: "Microsoft.Compute/virtualMachines",
29
+ },
30
+ };
31
+ }
32
+
33
+ const HIGH_ENTRY = makeEntry(
34
+ "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm-high",
35
+ "high",
36
+ "VM is deallocated. No disk activity detected.",
37
+ );
38
+
39
+ const MEDIUM_ENTRY = makeEntry(
40
+ "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm-medium",
41
+ "medium",
42
+ "Low CPU usage.",
43
+ );
44
+
45
+ const LOW_ENTRY = makeEntry(
46
+ "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm-low",
47
+ "low",
48
+ "Possible idle resource.",
49
+ false,
50
+ );
51
+
52
+ // ── helpers ────────────────────────────────────────────────────────────────
53
+
54
+ function allLogs(spy: ReturnType<typeof vi.spyOn>) {
55
+ return spy.mock.calls.map((c) => c[0] as string).join("\n");
56
+ }
57
+
58
+ // ── tests ──────────────────────────────────────────────────────────────────
59
+
60
+ describe("generateReport — lint format", () => {
61
+ let logSpy: ReturnType<typeof vi.spyOn>;
62
+
63
+ beforeEach(() => {
64
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
65
+ });
66
+
67
+ afterEach(() => {
68
+ vi.restoreAllMocks();
69
+ });
70
+
71
+ it("prints the resource ID for each entry", async () => {
72
+ await generateReport([HIGH_ENTRY], "lint");
73
+ const output = allLogs(logSpy);
74
+ expect(output).toContain(HIGH_ENTRY.resource.id);
75
+ });
76
+
77
+ it("uses ✖ icon for high-risk resources", async () => {
78
+ await generateReport([HIGH_ENTRY], "lint");
79
+ const output = allLogs(logSpy);
80
+ expect(output).toContain("✖");
81
+ });
82
+
83
+ it("uses ⚠ icon for medium-risk resources", async () => {
84
+ await generateReport([MEDIUM_ENTRY], "lint");
85
+ const output = allLogs(logSpy);
86
+ expect(output).toContain("⚠");
87
+ });
88
+
89
+ it("uses ℹ icon for low-risk resources", async () => {
90
+ await generateReport([LOW_ENTRY], "lint");
91
+ const output = allLogs(logSpy);
92
+ expect(output).toContain("ℹ");
93
+ });
94
+
95
+ it("splits a multi-sentence reason into separate findings", async () => {
96
+ // Reason has two sentences separated by '. '
97
+ await generateReport([HIGH_ENTRY], "lint");
98
+ const calls = logSpy.mock.calls.map(
99
+ (c) => (c[0] as string | undefined) ?? "",
100
+ );
101
+ const findingLines = calls.filter((l) => l.startsWith(" "));
102
+ // "VM is deallocated." and "No disk activity detected." → 2 findings
103
+ expect(findingLines.length).toBe(2);
104
+ });
105
+
106
+ it("prints a Summary line at the end", async () => {
107
+ await generateReport([HIGH_ENTRY, MEDIUM_ENTRY, LOW_ENTRY], "lint");
108
+ const output = allLogs(logSpy);
109
+ expect(output).toContain("Summary:");
110
+ });
111
+
112
+ it("summary reports correct total issue count", async () => {
113
+ await generateReport([HIGH_ENTRY, MEDIUM_ENTRY, LOW_ENTRY], "lint");
114
+ const output = allLogs(logSpy);
115
+ // HIGH has 2 findings (split reason), MEDIUM and LOW have 1 each → total 4
116
+ expect(output).toContain("4 issues found");
117
+ });
118
+
119
+ it("summary reports correctly with a single issue (no plural)", async () => {
120
+ await generateReport([MEDIUM_ENTRY], "lint");
121
+ const output = allLogs(logSpy);
122
+ // "1 issue found" (not "1 issues found")
123
+ expect(output).toContain("1 issue found");
124
+ });
125
+
126
+ it("shows risk level label in uppercase for each finding", async () => {
127
+ await generateReport([HIGH_ENTRY], "lint");
128
+ const output = allLogs(logSpy);
129
+ expect(output).toContain("HIGH");
130
+ });
131
+
132
+ it("prints nothing but the summary for an empty report", async () => {
133
+ await generateReport([], "lint");
134
+ const calls = logSpy.mock.calls.map((c) => c[0] as string);
135
+ expect(calls).toHaveLength(1);
136
+ expect(calls[0]).toContain("0 issues found");
137
+ });
138
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Tests for matchesTags() — verifies AND-logic tag filtering:
3
+ * 1. No filter (undefined/empty) → always include the resource.
4
+ * 2. Exact key-value match → include.
5
+ * 3. Key missing on resource → exclude.
6
+ * 4. Key present but wrong value → exclude.
7
+ * 5. Multiple tags: all match → include; any mismatch → exclude.
8
+ */
9
+
10
+ import type { GenericResource } from "@azure/arm-resources";
11
+
12
+ import { describe, expect, it } from "vitest";
13
+
14
+ import { matchesTags } from "../utils.js";
15
+
16
+ function makeResource(tags?: Record<string, string>): GenericResource {
17
+ return { id: "r1", name: "res", tags };
18
+ }
19
+
20
+ describe("matchesTags", () => {
21
+ describe("when no filter is provided", () => {
22
+ it("returns true for undefined filterTags", () => {
23
+ expect(matchesTags(makeResource({ env: "prod" }), undefined)).toBe(true);
24
+ });
25
+
26
+ it("returns true for empty Map", () => {
27
+ expect(matchesTags(makeResource({ env: "prod" }), new Map())).toBe(true);
28
+ });
29
+
30
+ it("returns true even when resource has no tags", () => {
31
+ expect(matchesTags(makeResource(), undefined)).toBe(true);
32
+ });
33
+ });
34
+
35
+ describe("single tag filter", () => {
36
+ it("returns true when tag key and value match exactly", () => {
37
+ expect(
38
+ matchesTags(makeResource({ env: "prod" }), new Map([["env", "prod"]])),
39
+ ).toBe(true);
40
+ });
41
+
42
+ it("returns false when tag key is missing from resource", () => {
43
+ expect(
44
+ matchesTags(makeResource({ app: "myapp" }), new Map([["env", "prod"]])),
45
+ ).toBe(false);
46
+ });
47
+
48
+ it("returns false when tag key exists but value differs", () => {
49
+ expect(
50
+ matchesTags(makeResource({ env: "dev" }), new Map([["env", "prod"]])),
51
+ ).toBe(false);
52
+ });
53
+
54
+ it("returns false when resource has no tags at all", () => {
55
+ expect(matchesTags(makeResource(), new Map([["env", "prod"]]))).toBe(
56
+ false,
57
+ );
58
+ });
59
+
60
+ it("is case-sensitive for tag values", () => {
61
+ expect(
62
+ matchesTags(makeResource({ env: "Prod" }), new Map([["env", "prod"]])),
63
+ ).toBe(false);
64
+ });
65
+ });
66
+
67
+ describe("multiple tag filters (AND logic)", () => {
68
+ it("returns true when all filter tags match", () => {
69
+ const resource = makeResource({
70
+ env: "prod",
71
+ region: "italy",
72
+ team: "dx",
73
+ });
74
+ expect(
75
+ matchesTags(
76
+ resource,
77
+ new Map([
78
+ ["env", "prod"],
79
+ ["team", "dx"],
80
+ ]),
81
+ ),
82
+ ).toBe(true);
83
+ });
84
+
85
+ it("returns false when one of the filter tags is missing", () => {
86
+ const resource = makeResource({ env: "prod" });
87
+ expect(
88
+ matchesTags(
89
+ resource,
90
+ new Map([
91
+ ["env", "prod"],
92
+ ["team", "dx"],
93
+ ]),
94
+ ),
95
+ ).toBe(false);
96
+ });
97
+
98
+ it("returns false when one of the filter tags has the wrong value", () => {
99
+ const resource = makeResource({ env: "prod", team: "backend" });
100
+ expect(
101
+ matchesTags(
102
+ resource,
103
+ new Map([
104
+ ["env", "prod"],
105
+ ["team", "dx"],
106
+ ]),
107
+ ),
108
+ ).toBe(false);
109
+ });
110
+
111
+ it("returns false when both filter tags have wrong values", () => {
112
+ const resource = makeResource({ env: "dev", team: "backend" });
113
+ expect(
114
+ matchesTags(
115
+ resource,
116
+ new Map([
117
+ ["env", "prod"],
118
+ ["team", "dx"],
119
+ ]),
120
+ ),
121
+ ).toBe(false);
122
+ });
123
+ });
124
+ });
@@ -13,7 +13,12 @@ import { getLogger } from "@logtape/logtape";
13
13
 
14
14
  import type { AzureConfig, AzureDetailedResourceReport } from "./types.js";
15
15
 
16
- import { type AnalysisResult, mergeResults } from "../types.js";
16
+ import {
17
+ type AnalysisResult,
18
+ DEFAULT_THRESHOLDS,
19
+ mergeResults,
20
+ type Thresholds,
21
+ } from "../types.js";
17
22
  import { generateReport } from "./report.js";
18
23
  import {
19
24
  analyzeAppServicePlan,
@@ -26,16 +31,17 @@ import {
26
31
  analyzeStorageAccount,
27
32
  analyzeVM,
28
33
  } from "./resources/index.js";
34
+ import { matchesTags } from "./utils.js";
29
35
 
30
36
  /**
31
37
  * Analyzes resources in multiple Azure subscriptions and generates a report.
32
38
  *
33
39
  * @param config - Azure configuration with subscription IDs and settings
34
- * @param format - Output format (table, json, or detailed-json)
40
+ * @param format - Output format (table, json, detailed-json, or lint)
35
41
  */
36
42
  export async function analyzeAzureResources(
37
43
  config: AzureConfig,
38
- format: "detailed-json" | "json" | "table",
44
+ format: "detailed-json" | "json" | "lint" | "table",
39
45
  ) {
40
46
  const logger = getLogger(["savemoney", "azure"]);
41
47
  const credential = new DefaultAzureCredential();
@@ -66,8 +72,15 @@ export async function analyzeAzureResources(
66
72
  subscriptionId.trim(),
67
73
  );
68
74
 
75
+ const thresholds: Thresholds = config.thresholds ?? DEFAULT_THRESHOLDS;
76
+
69
77
  // Use the async iterator to avoid memory explosion for large environments
70
78
  for await (const resource of resourceClient.resources.list()) {
79
+ // Skip resources that don't match the requested tag filter
80
+ if (!matchesTags(resource, config.filterTags)) {
81
+ continue;
82
+ }
83
+
71
84
  const { costRisk, reason, suspectedUnused } = await analyzeResource(
72
85
  resource,
73
86
  monitorClient,
@@ -77,6 +90,7 @@ export async function analyzeAzureResources(
77
90
  containerAppsClient,
78
91
  config.preferredLocation,
79
92
  config.timespanDays,
93
+ thresholds,
80
94
  config.verbose || false,
81
95
  );
82
96
 
@@ -127,6 +141,7 @@ export async function analyzeResource(
127
141
  containerAppsClient: ContainerAppsAPIClient,
128
142
  preferredLocation: string,
129
143
  timespanDays: number,
144
+ thresholds: Thresholds,
130
145
  verbose = false,
131
146
  ): Promise<AnalysisResult> {
132
147
  const type = resource.type?.toLowerCase() || "";
@@ -150,6 +165,7 @@ export async function analyzeResource(
150
165
  containerAppsClient,
151
166
  monitorClient,
152
167
  timespanDays,
168
+ thresholds,
153
169
  verbose,
154
170
  );
155
171
  result = mergeResults(result, containerAppResult);
@@ -166,6 +182,7 @@ export async function analyzeResource(
166
182
  monitorClient,
167
183
  computeClient,
168
184
  timespanDays,
185
+ thresholds,
169
186
  verbose,
170
187
  );
171
188
  result = mergeResults(result, vmResult);
@@ -191,6 +208,7 @@ export async function analyzeResource(
191
208
  networkClient,
192
209
  monitorClient,
193
210
  timespanDays,
211
+ thresholds,
194
212
  verbose,
195
213
  );
196
214
  result = mergeResults(result, pipResult);
@@ -201,6 +219,7 @@ export async function analyzeResource(
201
219
  resource,
202
220
  monitorClient,
203
221
  timespanDays,
222
+ thresholds,
204
223
  verbose,
205
224
  );
206
225
  result = mergeResults(result, storageResult);
@@ -212,6 +231,7 @@ export async function analyzeResource(
212
231
  webSiteClient,
213
232
  monitorClient,
214
233
  timespanDays,
234
+ thresholds,
215
235
  verbose,
216
236
  );
217
237
  result = mergeResults(result, aspResult);
@@ -222,6 +242,7 @@ export async function analyzeResource(
222
242
  resource,
223
243
  monitorClient,
224
244
  timespanDays,
245
+ thresholds,
225
246
  verbose,
226
247
  );
227
248
  result = mergeResults(result, staticSiteResult);
@@ -4,38 +4,53 @@
4
4
 
5
5
  import { getLogger } from "@logtape/logtape";
6
6
  import * as fs from "fs";
7
+ import * as yaml from "js-yaml";
7
8
  import * as readline from "readline";
9
+ import { z } from "zod";
8
10
 
9
11
  import type { AzureConfig } from "./types.js";
10
12
 
13
+ import { ConfigSchema } from "../schema.js";
14
+
11
15
  /**
12
- * Loads Azure configuration from file, environment variables, or interactive prompts.
16
+ * Loads Azure configuration from a YAML file, environment variables, or interactive prompts.
17
+ *
18
+ * The YAML file must have an `azure` top-level key:
19
+ * ```yaml
20
+ * azure:
21
+ * subscriptionIds:
22
+ * - xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
23
+ * preferredLocation: italynorth
24
+ * timespanDays: 30
25
+ * thresholds:
26
+ * vm:
27
+ * cpuPercent: 5
28
+ * ```
13
29
  *
14
- * @param configPath - Optional path to JSON configuration file
15
- * @returns Azure configuration object with subscription IDs and settings
30
+ * @param configPath - Optional path to a YAML configuration file
31
+ * @returns Azure configuration object with subscription IDs, settings and thresholds
16
32
  */
17
33
  export async function loadAzureConfig(
18
34
  configPath?: string,
19
35
  ): Promise<AzureConfig> {
20
- if (configPath && fs.existsSync(configPath)) {
36
+ if (configPath) {
37
+ if (!fs.existsSync(configPath)) {
38
+ throw new Error(`Config file not found: ${configPath}`);
39
+ }
21
40
  try {
22
- const configContent = fs.readFileSync(configPath, "utf-8");
23
- const config = JSON.parse(configContent) as Partial<AzureConfig>;
24
-
25
- if (!config.tenantId || !config.subscriptionIds) {
26
- throw new Error(
27
- "Config file must contain 'tenantId' and 'subscriptionIds'",
28
- );
29
- }
30
-
41
+ const raw = fs.readFileSync(configPath, "utf-8");
42
+ const rawYaml = yaml.load(raw);
43
+ const parsed = ConfigSchema.parse(rawYaml);
31
44
  return {
32
- ...config,
33
- preferredLocation: config.preferredLocation || "italynorth",
34
- subscriptionIds: config.subscriptionIds,
35
- tenantId: config.tenantId,
36
- timespanDays: config.timespanDays || 30,
45
+ preferredLocation: parsed.azure.preferredLocation,
46
+ subscriptionIds: parsed.azure.subscriptionIds,
47
+ thresholds: parsed.azure.thresholds,
48
+ timespanDays: parsed.azure.timespanDays,
37
49
  };
38
50
  } catch (error) {
51
+ if (error instanceof z.ZodError) {
52
+ throw new Error(`Invalid config file:\n${z.prettifyError(error)}`);
53
+ }
39
54
  throw new Error(
40
55
  `Failed to load config file: ${error instanceof Error ? error.message : error}`,
41
56
  );
@@ -47,8 +62,6 @@ export async function loadAzureConfig(
47
62
  "Configuration file not found. Checking environment variables...",
48
63
  );
49
64
 
50
- const tenantId =
51
- process.env.ARM_TENANT_ID || (await prompt("Enter Tenant ID: "));
52
65
  const subscriptionIds = process.env.ARM_SUBSCRIPTION_ID
53
66
  ? process.env.ARM_SUBSCRIPTION_ID.split(",")
54
67
  : (await prompt("Enter Subscription IDs (comma-separated): ")).split(",");
@@ -56,7 +69,6 @@ export async function loadAzureConfig(
56
69
  return {
57
70
  preferredLocation: "italynorth",
58
71
  subscriptionIds,
59
- tenantId,
60
72
  timespanDays: 30,
61
73
  };
62
74
  }
@@ -7,15 +7,36 @@ import type {
7
7
  AzureResourceReport,
8
8
  } from "./types.js";
9
9
 
10
+ // ANSI color codes — only applied when stdout is a TTY to avoid cluttering redirected output
11
+ const isTTY = process.stdout.isTTY ?? false;
12
+ const RED = isTTY ? "\x1b[31m" : "";
13
+ const YELLOW = isTTY ? "\x1b[33m" : "";
14
+ const BLUE = isTTY ? "\x1b[34m" : "";
15
+ const BOLD = isTTY ? "\x1b[1m" : "";
16
+ const RESET = isTTY ? "\x1b[0m" : "";
17
+ const DIM = isTTY ? "\x1b[2m" : "";
18
+
19
+ const RISK_ICON = {
20
+ high: `${RED}✖${RESET}`,
21
+ low: `${BLUE}ℹ${RESET}`,
22
+ medium: `${YELLOW}⚠${RESET}`,
23
+ } as const;
24
+
25
+ const RISK_COLOR = {
26
+ high: RED,
27
+ low: BLUE,
28
+ medium: YELLOW,
29
+ } as const;
30
+
10
31
  /**
11
32
  * Generates a report from Azure resource analysis in the specified format.
12
33
  *
13
34
  * @param report - Array of detailed resource reports
14
- * @param format - Output format (table, json, or detailed-json)
35
+ * @param format - Output format (table, json, detailed-json, or lint)
15
36
  */
16
37
  export async function generateReport(
17
38
  report: AzureDetailedResourceReport[],
18
- format: "detailed-json" | "json" | "table",
39
+ format: "detailed-json" | "json" | "lint" | "table",
19
40
  ) {
20
41
  if (format === "detailed-json") {
21
42
  console.log(JSON.stringify(report, null, 2));
@@ -36,6 +57,8 @@ export async function generateReport(
36
57
 
37
58
  if (format === "json") {
38
59
  console.log(JSON.stringify(summaryReport, null, 2));
60
+ } else if (format === "lint") {
61
+ generateLintReport(report);
39
62
  } else {
40
63
  console.table(
41
64
  summaryReport.map((r) => ({
@@ -44,9 +67,63 @@ export async function generateReport(
44
67
  "Resource Group": r.resourceGroup || "N/A",
45
68
  Risk: r.costRisk,
46
69
  Type: r.type,
47
- Unused: r.suspectedUnused ? "Yes" : "No",
48
70
  })),
49
- ["Name", "Type", "Resource Group", "Risk", "Unused", "Reason"],
71
+ ["Name", "Type", "Resource Group", "Risk", "Reason"],
50
72
  );
51
73
  }
52
74
  }
75
+
76
+ /**
77
+ * Renders a linter-style report to stdout, grouping findings by resource.
78
+ *
79
+ * Example output:
80
+ *
81
+ * /subscriptions/.../virtualMachines/my-vm
82
+ * ✖ HIGH VM is deallocated.
83
+ * ✖ HIGH No tags found.
84
+ *
85
+ * Summary: 3 issues found (2 high, 0 medium, 1 low)
86
+ */
87
+ function generateLintReport(report: AzureDetailedResourceReport[]): void {
88
+ const counts = { high: 0, low: 0, medium: 0 };
89
+
90
+ for (const entry of report) {
91
+ const resourceId =
92
+ entry.resource.id ?? `unknown/${entry.resource.name ?? "unknown"}`;
93
+ const risk = entry.analysis.costRisk;
94
+ const findings = splitReasons(entry.analysis.reason);
95
+
96
+ console.log(`${BOLD}${resourceId}${RESET}`);
97
+
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]++;
104
+ }
105
+
106
+ console.log();
107
+ }
108
+
109
+ const total = counts.high + counts.medium + counts.low;
110
+ const summaryLine =
111
+ `${BOLD}Summary:${RESET} ${total} issue${total !== 1 ? "s" : ""} found` +
112
+ ` ${RED}(${counts.high} high${RESET}` +
113
+ `, ${YELLOW}${counts.medium} medium${RESET}` +
114
+ `, ${BLUE}${counts.low} low${RESET})`;
115
+
116
+ console.log(summaryLine);
117
+ }
118
+
119
+ /**
120
+ * Splits a concatenated reason string (sentences separated by ". ") into
121
+ * individual finding strings, stripping trailing whitespace.
122
+ */
123
+ function splitReasons(reason: string): string[] {
124
+ return reason
125
+ .split(/\.\s+/)
126
+ .map((s) => s.trim())
127
+ .filter((s) => s.length > 0)
128
+ .map((s) => (s.endsWith(".") ? s : `${s}.`));
129
+ }