@pagopa/dx-savemoney 0.6.4 → 0.6.6

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 (43) hide show
  1. package/README.md +38 -0
  2. package/dist/azure/analyzer.d.ts.map +1 -1
  3. package/dist/azure/analyzer.js +42 -16
  4. package/dist/azure/analyzer.js.map +1 -1
  5. package/dist/azure/analyzers/advisor.js +11 -0
  6. package/dist/azure/analyzers/advisor.js.map +1 -1
  7. package/dist/azure/azqr.d.ts +3 -3
  8. package/dist/azure/azqr.d.ts.map +1 -1
  9. package/dist/azure/azqr.js +26 -4
  10. package/dist/azure/azqr.js.map +1 -1
  11. package/dist/azure/finding-category.d.ts +45 -0
  12. package/dist/azure/finding-category.d.ts.map +1 -0
  13. package/dist/azure/finding-category.js +65 -0
  14. package/dist/azure/finding-category.js.map +1 -0
  15. package/dist/azure/finding-code.d.ts +46 -0
  16. package/dist/azure/finding-code.d.ts.map +1 -0
  17. package/dist/azure/finding-code.js +127 -0
  18. package/dist/azure/finding-code.js.map +1 -0
  19. package/dist/azure/finding-dedup.d.ts +44 -0
  20. package/dist/azure/finding-dedup.d.ts.map +1 -0
  21. package/dist/azure/finding-dedup.js +132 -0
  22. package/dist/azure/finding-dedup.js.map +1 -0
  23. package/dist/finding.d.ts +19 -7
  24. package/dist/finding.d.ts.map +1 -1
  25. package/dist/finding.js.map +1 -1
  26. package/dist/types.d.ts +8 -0
  27. package/dist/types.d.ts.map +1 -1
  28. package/dist/types.js +14 -0
  29. package/dist/types.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/azure/__tests__/azqr.test.ts +45 -1
  32. package/src/azure/__tests__/finding-category.test.ts +68 -0
  33. package/src/azure/__tests__/finding-code.test.ts +128 -0
  34. package/src/azure/__tests__/finding-dedup.test.ts +269 -0
  35. package/src/azure/analyzer.ts +54 -18
  36. package/src/azure/analyzers/__tests__/advisor.test.ts +60 -0
  37. package/src/azure/analyzers/advisor.ts +14 -0
  38. package/src/azure/azqr.ts +28 -4
  39. package/src/azure/finding-category.ts +75 -0
  40. package/src/azure/finding-code.ts +148 -0
  41. package/src/azure/finding-dedup.ts +157 -0
  42. package/src/finding.ts +19 -7
  43. package/src/types.ts +16 -0
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Unit tests for stable per-sentence custom finding codes.
3
+ *
4
+ * These codes are what lets `foldDuplicateFinding` collapse two custom
5
+ * findings that describe the same problem on the same resource, so the tests
6
+ * focus on: known sentences get a stable identity, identical wording shared
7
+ * across resource types collapses onto one code, and anything the table
8
+ * doesn't recognise (or that already has an identity, or isn't "custom") is
9
+ * left untouched.
10
+ */
11
+
12
+ import { describe, expect, it } from "vitest";
13
+
14
+ import type { Finding } from "../../finding.js";
15
+
16
+ import { assignCustomFindingCodes } from "../finding-code.js";
17
+
18
+ const RESOURCE_ID =
19
+ "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/disks/disk1";
20
+
21
+ function customFinding(
22
+ reason: string,
23
+ overrides: Partial<Finding> = {},
24
+ ): Finding {
25
+ return {
26
+ category: "cost",
27
+ code: "custom.unknown",
28
+ reason,
29
+ resourceId: RESOURCE_ID,
30
+ severity: "low",
31
+ source: "custom",
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ describe("assignCustomFindingCodes", () => {
37
+ it.each([
38
+ ["Disk is unattached.", "custom.disk.unattached"],
39
+ [
40
+ "NIC not attached to any VM or private endpoint.",
41
+ "custom.nic.unattached",
42
+ ],
43
+ ["No public IP assigned.", "custom.nic.no-public-ip"],
44
+ [
45
+ "Private Endpoint has no private link service connections configured.",
46
+ "custom.private-endpoint.no-connections",
47
+ ],
48
+ ["Container App is not running.", "custom.container-app.not-running"],
49
+ ["App Service Plan has no apps deployed.", "custom.app-service.no-apps"],
50
+ [
51
+ "Public IP not associated with any resource.",
52
+ "custom.public-ip.unassociated",
53
+ ],
54
+ ["Static IP not in use.", "custom.public-ip.static-unused"],
55
+ [
56
+ "Very low transaction count (2.00 avg/day).",
57
+ "custom.storage.low-transactions",
58
+ ],
59
+ ["VM is deallocated.", "custom.vm.deallocated"],
60
+ ["VM is stopped.", "custom.vm.stopped"],
61
+ ["Low CPU usage (avg 1.23%).", "custom.vm.low-cpu-usage"],
62
+ ["Low network traffic (0.50 MB/day avg).", "custom.vm.low-network-traffic"],
63
+ [
64
+ "No traffic data available in 30 days.",
65
+ "custom.static-site.no-traffic-data",
66
+ ],
67
+ [
68
+ "Very low site traffic (3 requests in 30 days).",
69
+ "custom.static-site.low-traffic",
70
+ ],
71
+ [
72
+ "Very low data transfer (0.01 MB in 30 days).",
73
+ "custom.static-site.low-data-transfer",
74
+ ],
75
+ ["Resource ID is missing.", "custom.missing-resource-id"],
76
+ ["Could not retrieve detailed NIC information.", "custom.lookup-failed"],
77
+ ["No tags found.", "custom.missing-tags"],
78
+ ])("assigns %j the stable code %j", (reason, expectedCode) => {
79
+ const [finding] = assignCustomFindingCodes([customFinding(reason)]);
80
+ expect(finding.code).toBe(expectedCode);
81
+ expect(finding.recommendationId).toBe(expectedCode);
82
+ });
83
+
84
+ it.each([
85
+ ["Very low CPU usage (12.34%).", "App Service Plan"],
86
+ ["Very low CPU usage (0.5000 cores).", "Container App"],
87
+ ])(
88
+ "shares one code across resource types for identical wording: %j (%s)",
89
+ (reason) => {
90
+ const [finding] = assignCustomFindingCodes([customFinding(reason)]);
91
+ expect(finding.recommendationId).toBe("custom.low-cpu-usage");
92
+ },
93
+ );
94
+
95
+ it("gives two different resources' identical sentences the same recommendationId, enabling a later fold within a resource but not across resources", () => {
96
+ const [a, b] = assignCustomFindingCodes([
97
+ customFinding("Very low network traffic (1.00 MB/day avg)."),
98
+ customFinding("Very low network traffic (2.00 MB/day avg)."),
99
+ ]);
100
+
101
+ expect(a.recommendationId).toBe("custom.low-network-traffic");
102
+ expect(b.recommendationId).toBe("custom.low-network-traffic");
103
+ });
104
+
105
+ it("leaves unrecognised sentences unchanged", () => {
106
+ const finding = customFinding("Something entirely unexpected happened.");
107
+
108
+ expect(assignCustomFindingCodes([finding])).toEqual([finding]);
109
+ });
110
+
111
+ it("leaves non-custom findings unchanged", () => {
112
+ const finding = customFinding("Disk is unattached.", {
113
+ code: "advisor.some-id",
114
+ recommendationId: "advisor.some-id",
115
+ source: "advisor",
116
+ });
117
+
118
+ expect(assignCustomFindingCodes([finding])).toEqual([finding]);
119
+ });
120
+
121
+ it("does not overwrite a finding that already has a recommendationId", () => {
122
+ const finding = customFinding("Disk is unattached.", {
123
+ recommendationId: "custom.already-set",
124
+ });
125
+
126
+ expect(assignCustomFindingCodes([finding])).toEqual([finding]);
127
+ });
128
+ });
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Unit tests for cross-source finding deduplication.
3
+ *
4
+ * The same waste can be reported by AZQR, Azure Advisor and the custom
5
+ * analyzers at once. These tests pin down when the incoming finding collapses
6
+ * onto an existing one, and what the surviving finding is expected to carry.
7
+ */
8
+
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import type { Finding } from "../../finding.js";
12
+ import type { AzureDetailedResourceReport } from "../types.js";
13
+
14
+ import {
15
+ foldDuplicateFinding,
16
+ orphanRecommendationId,
17
+ } from "../finding-dedup.js";
18
+
19
+ const RESOURCE_ID =
20
+ "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/disks/disk1";
21
+
22
+ const ORPHAN_DISK = orphanRecommendationId("Microsoft.Compute/disks");
23
+
24
+ function mkFinding(overrides: Partial<Finding> = {}): Finding {
25
+ return {
26
+ category: "cost",
27
+ code: "custom.unknown",
28
+ reason: "Disk is unattached.",
29
+ resourceId: RESOURCE_ID,
30
+ severity: "low",
31
+ source: "custom",
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ function mkReport(findings: Finding[]): AzureDetailedResourceReport {
37
+ return {
38
+ analysis: {
39
+ costRisk: "low",
40
+ reason: findings.map((finding) => finding.reason).join(" "),
41
+ suspectedUnused: true,
42
+ },
43
+ findings,
44
+ resource: {
45
+ id: RESOURCE_ID,
46
+ name: "disk1",
47
+ type: "Microsoft.Compute/disks",
48
+ },
49
+ };
50
+ }
51
+
52
+ describe("orphanRecommendationId", () => {
53
+ it("normalises the resource type so sources share one identity", () => {
54
+ expect(ORPHAN_DISK).toBe("orphan.microsoft.compute/disks");
55
+ });
56
+ });
57
+
58
+ describe("foldDuplicateFinding", () => {
59
+ it("keeps findings without a recommendation id separate", () => {
60
+ const report = mkReport([mkFinding({ reason: "No tags found." })]);
61
+
62
+ expect(foldDuplicateFinding(mkFinding(), report)).toBe(false);
63
+ expect(report.findings).toHaveLength(1);
64
+ });
65
+
66
+ it("folds a finding sharing the same recommendation id", () => {
67
+ const report = mkReport([
68
+ mkFinding({ recommendationId: ORPHAN_DISK, source: "advisor" }),
69
+ ]);
70
+
71
+ const folded = foldDuplicateFinding(
72
+ mkFinding({
73
+ recommendationId: ORPHAN_DISK.toUpperCase(),
74
+ source: "azqr",
75
+ }),
76
+ report,
77
+ );
78
+
79
+ expect(folded).toBe(true);
80
+ expect(report.findings).toHaveLength(1);
81
+ });
82
+
83
+ it("folds an AZQR orphan row onto the live cost finding for the same resource", () => {
84
+ const report = mkReport([mkFinding()]);
85
+
86
+ const folded = foldDuplicateFinding(
87
+ mkFinding({
88
+ recommendationId: ORPHAN_DISK,
89
+ severity: "high",
90
+ source: "azqr",
91
+ }),
92
+ report,
93
+ );
94
+
95
+ expect(folded).toBe(true);
96
+ expect(report.findings?.[0]).toMatchObject({
97
+ reason:
98
+ "Disk is unattached. Also flagged by azqr as an orphaned resource.",
99
+ severity: "high",
100
+ source: "custom",
101
+ });
102
+ });
103
+
104
+ it("adopts the canonical identity of the finding it absorbs", () => {
105
+ const report = mkReport([mkFinding()]);
106
+
107
+ foldDuplicateFinding(
108
+ mkFinding({ recommendationId: ORPHAN_DISK, source: "azqr" }),
109
+ report,
110
+ );
111
+
112
+ expect(report.findings?.[0].recommendationId).toBe(ORPHAN_DISK);
113
+ });
114
+
115
+ it("records the corroborating source in the per-resource summary too", () => {
116
+ const report = mkReport([mkFinding()]);
117
+
118
+ foldDuplicateFinding(
119
+ mkFinding({
120
+ recommendationId: ORPHAN_DISK,
121
+ severity: "high",
122
+ source: "azqr",
123
+ }),
124
+ report,
125
+ );
126
+
127
+ expect(report.analysis).toMatchObject({
128
+ costRisk: "high",
129
+ reason:
130
+ "Disk is unattached. Also flagged by azqr as an orphaned resource.",
131
+ });
132
+ });
133
+
134
+ it("keeps the monetary estimate of whichever finding carries one", () => {
135
+ const report = mkReport([mkFinding({ recommendationId: ORPHAN_DISK })]);
136
+
137
+ foldDuplicateFinding(
138
+ mkFinding({
139
+ estimatedMonthlySavings: { amount: 12.5, currency: "EUR" },
140
+ recommendationId: ORPHAN_DISK,
141
+ source: "azqr",
142
+ }),
143
+ report,
144
+ );
145
+
146
+ expect(report.findings?.[0].estimatedMonthlySavings).toEqual({
147
+ amount: 12.5,
148
+ currency: "EUR",
149
+ });
150
+ });
151
+
152
+ it("does not fold an orphan row onto a governance-only finding", () => {
153
+ const report = mkReport([
154
+ mkFinding({
155
+ category: "operationalExcellence",
156
+ reason: "No tags found.",
157
+ }),
158
+ ]);
159
+
160
+ const folded = foldDuplicateFinding(
161
+ mkFinding({ recommendationId: ORPHAN_DISK, source: "azqr" }),
162
+ report,
163
+ );
164
+
165
+ expect(folded).toBe(false);
166
+ expect(report.analysis.reason).toBe("No tags found.");
167
+ });
168
+
169
+ it("does not fold an orphan row onto another static-report finding", () => {
170
+ const report = mkReport([
171
+ mkFinding({ recommendationId: "azqr.other", source: "azqr" }),
172
+ ]);
173
+
174
+ expect(
175
+ foldDuplicateFinding(
176
+ mkFinding({ recommendationId: ORPHAN_DISK, source: "azqr" }),
177
+ report,
178
+ ),
179
+ ).toBe(false);
180
+ });
181
+
182
+ it("does not fold an orphan row onto a live finding with its own distinct identity", () => {
183
+ const report = mkReport([
184
+ mkFinding({
185
+ recommendationId: "advisor.right-size-vm",
186
+ source: "advisor",
187
+ }),
188
+ ]);
189
+
190
+ expect(
191
+ foldDuplicateFinding(
192
+ mkFinding({ recommendationId: ORPHAN_DISK, source: "azqr" }),
193
+ report,
194
+ ),
195
+ ).toBe(false);
196
+ expect(report.findings).toHaveLength(1);
197
+ });
198
+ });
199
+
200
+ describe("foldDuplicateFinding - repeated and idempotent folds", () => {
201
+ it("does not annotate same-source duplicates", () => {
202
+ const report = mkReport([
203
+ mkFinding({ recommendationId: ORPHAN_DISK, source: "azqr" }),
204
+ ]);
205
+
206
+ foldDuplicateFinding(
207
+ mkFinding({ recommendationId: ORPHAN_DISK, source: "azqr" }),
208
+ report,
209
+ );
210
+
211
+ expect(report.findings?.[0].reason).toBe("Disk is unattached.");
212
+ expect(report.analysis.reason).toBe("Disk is unattached.");
213
+ });
214
+
215
+ it("does not annotate the same corroboration twice", () => {
216
+ const report = mkReport([mkFinding()]);
217
+ const azqrRow = () =>
218
+ mkFinding({ recommendationId: ORPHAN_DISK, source: "azqr" });
219
+
220
+ foldDuplicateFinding(azqrRow(), report);
221
+ const foldedAgain = foldDuplicateFinding(azqrRow(), report);
222
+
223
+ expect(foldedAgain).toBe(true);
224
+ expect(report.findings).toHaveLength(1);
225
+ expect(report.findings?.[0].reason).toBe(
226
+ "Disk is unattached. Also flagged by azqr as an orphaned resource.",
227
+ );
228
+ expect(report.analysis.reason).toBe(
229
+ "Disk is unattached. Also flagged by azqr as an orphaned resource.",
230
+ );
231
+ });
232
+
233
+ it("leaves reports without collected findings untouched", () => {
234
+ const report = mkReport([]);
235
+ delete report.findings;
236
+
237
+ expect(
238
+ foldDuplicateFinding(
239
+ mkFinding({ recommendationId: ORPHAN_DISK, source: "azqr" }),
240
+ report,
241
+ ),
242
+ ).toBe(false);
243
+ });
244
+
245
+ // Custom analyzer sentences with a known template carry a stable
246
+ // recommendationId too (see finding-code.ts), so two custom findings for
247
+ // the same problem must collapse just like a custom/advisor pair does.
248
+ it("folds two custom findings sharing a stable recommendation id", () => {
249
+ const report = mkReport([
250
+ mkFinding({
251
+ code: "custom.disk.unattached",
252
+ recommendationId: "custom.disk.unattached",
253
+ }),
254
+ ]);
255
+
256
+ const folded = foldDuplicateFinding(
257
+ mkFinding({
258
+ code: "custom.disk.unattached",
259
+ recommendationId: "custom.disk.unattached",
260
+ }),
261
+ report,
262
+ );
263
+
264
+ expect(folded).toBe(true);
265
+ expect(report.findings).toHaveLength(1);
266
+ // Same-source fold: no corroboration note is appended.
267
+ expect(report.findings?.[0].reason).toBe("Disk is unattached.");
268
+ });
269
+ });
@@ -37,8 +37,9 @@ import type { AzureConfig, AzureDetailedResourceReport } from "./types.js";
37
37
  import { findingsFromAnalysisResult } from "../finding.js";
38
38
  import {
39
39
  type AnalysisResult,
40
- type CostRisk,
40
+ COST_RISK_ORDER,
41
41
  DEFAULT_THRESHOLDS,
42
+ maxCostRisk,
42
43
  mergeResults,
43
44
  type Thresholds,
44
45
  } from "../types.js";
@@ -55,13 +56,19 @@ import {
55
56
  isAzqrReportMasked,
56
57
  loadAzqrReport,
57
58
  } from "./azqr.js";
59
+ import {
60
+ categorizeCustomFindings,
61
+ MISSING_TAGS_REASON,
62
+ NO_ANALYZER_REASON,
63
+ unpreferredLocationReason,
64
+ } from "./finding-category.js";
65
+ import { assignCustomFindingCodes } from "./finding-code.js";
66
+ import { foldDuplicateFinding } from "./finding-dedup.js";
58
67
  import { PricingClient, PricingService } from "./pricing/index.js";
59
68
  import { matchesTags, type MetricsCache } from "./utils.js";
60
69
 
61
70
  const DEFAULT_CONCURRENCY = 8;
62
71
 
63
- const RISK_ORDER: Record<CostRisk, number> = { high: 0, low: 2, medium: 1 };
64
-
65
72
  /**
66
73
  * Analyzes resources in every configured Azure subscription and returns
67
74
  * the structured report.
@@ -195,7 +202,10 @@ export async function analyzeAzureResources(
195
202
  if (aSub !== bSub) return aSub ? 1 : -1;
196
203
  if (a.analysis.costRisk === b.analysis.costRisk)
197
204
  return (a.resource.name ?? "").localeCompare(b.resource.name ?? "");
198
- return RISK_ORDER[a.analysis.costRisk] - RISK_ORDER[b.analysis.costRisk];
205
+ return (
206
+ COST_RISK_ORDER[a.analysis.costRisk] -
207
+ COST_RISK_ORDER[b.analysis.costRisk]
208
+ );
199
209
  });
200
210
 
201
211
  return allReports;
@@ -236,7 +246,7 @@ export async function analyzeResource(
236
246
  // Generic check: lack of tags is a common sign of unmanaged resources.
237
247
  if (!resource.tags || Object.keys(resource.tags).length === 0) {
238
248
  result.suspectedUnused = true;
239
- result.reason += "No tags found. ";
249
+ result.reason += `${MISSING_TAGS_REASON} `;
240
250
  }
241
251
 
242
252
  const ctx: AnalyzerContext = {
@@ -261,7 +271,7 @@ export async function analyzeResource(
261
271
  }
262
272
 
263
273
  if (!matched) {
264
- result.reason += "No specific analysis for this resource type. ";
274
+ result.reason += `${NO_ANALYZER_REASON} `;
265
275
  }
266
276
 
267
277
  // Generic check for location
@@ -269,7 +279,7 @@ export async function analyzeResource(
269
279
  resource.location &&
270
280
  !resource.location.toLowerCase().includes(preferredLocation.toLowerCase())
271
281
  ) {
272
- result.reason += `Resource not in preferred location (${preferredLocation}). `;
282
+ result.reason += `${unpreferredLocationReason(preferredLocation)} `;
273
283
  }
274
284
 
275
285
  return { ...result, reason: result.reason.trim() };
@@ -294,10 +304,7 @@ export function mergeFinding(
294
304
  // don't produce "Sentence one.Sentence two." when the existing reason is
295
305
  // already trimmed (i.e. has no trailing separator space).
296
306
  existing.analysis = {
297
- costRisk:
298
- RISK_ORDER[existing.analysis.costRisk] <= RISK_ORDER[added.costRisk]
299
- ? existing.analysis.costRisk
300
- : added.costRisk,
307
+ costRisk: maxCostRisk(existing.analysis.costRisk, added.costRisk),
301
308
  estimatedMonthlySavings: existing.analysis.estimatedMonthlySavings,
302
309
  reason:
303
310
  existing.analysis.reason && added.reason
@@ -451,6 +458,11 @@ function hasTagFilter(filterTags: Map<string, string> | undefined): boolean {
451
458
  * report collected during the live scan so AZQR findings attach to their
452
459
  * resource when present or create a stub otherwise.
453
460
  *
461
+ * Findings that duplicate what a live source already reported for the same
462
+ * resource are folded into the existing finding instead of being appended, so
463
+ * an orphaned resource seen by both AZQR and Advisor/custom analyzers shows up
464
+ * once — on the entry that carries the monetary estimate.
465
+ *
454
466
  * When the report is still masked, findings cannot match live resource IDs, so
455
467
  * a warning advises re-running AZQR with `--mask=false`.
456
468
  */
@@ -472,11 +484,22 @@ async function ingestAzqrReport(
472
484
  const reportsById = new Map<string, AzureDetailedResourceReport>(
473
485
  reports.map((r) => [(r.resource.id ?? "").toLowerCase(), r]),
474
486
  );
487
+ let merged = 0;
488
+ let deduplicated = 0;
475
489
  for (const finding of findings) {
490
+ const existing = reportsById.get(finding.resourceId.toLowerCase());
491
+ if (existing && foldDuplicateFinding(finding, existing)) {
492
+ deduplicated++;
493
+ continue;
494
+ }
476
495
  mergeFinding(finding, reports, reportsById);
496
+ merged++;
477
497
  }
478
498
  logger.info(
479
- `AZQR: merged ${findings.length} finding(s) from ${azqrReportPath}`,
499
+ `AZQR: merged ${merged} finding(s) from ${azqrReportPath}` +
500
+ (deduplicated > 0
501
+ ? `, ${deduplicated} deduplicated onto existing findings`
502
+ : ""),
480
503
  );
481
504
  }
482
505
 
@@ -569,16 +592,29 @@ async function runPerResourceAnalysis(args: {
569
592
 
570
593
  if (analysis.suspectedUnused) {
571
594
  const reason = analysis.reason || "No specific findings.";
595
+ const findings = assignCustomFindingCodes(
596
+ categorizeCustomFindings(
597
+ findingsFromAnalysisResult({
598
+ reason,
599
+ resourceId: resource.id ?? "",
600
+ severity: analysis.costRisk,
601
+ source: "custom",
602
+ }),
603
+ ),
604
+ );
572
605
  const report: AzureDetailedResourceReport = {
573
606
  analysis: { ...analysis, reason },
574
- findings: findingsFromAnalysisResult({
575
- reason,
576
- resourceId: resource.id ?? "",
577
- severity: analysis.costRisk,
578
- source: "custom",
579
- }),
607
+ findings: [],
580
608
  resource,
581
609
  };
610
+ // Fold sentences that share a stable recommendationId so e.g. two
611
+ // "Very low CPU usage" hits on the same resource collapse into one
612
+ // row instead of appearing as separate findings.
613
+ for (const finding of findings) {
614
+ if (!foldDuplicateFinding(finding, report)) {
615
+ report.findings?.push(finding);
616
+ }
617
+ }
582
618
  reports.push(report);
583
619
  const idKey = (resource.id ?? "").toLowerCase();
584
620
  if (idKey) reportsById.set(idKey, report);
@@ -365,3 +365,63 @@ describe("createAdvisorAnalyzer — filtering", () => {
365
365
  expect(findings[0].reason).toBe("Azure Advisor cost recommendation.");
366
366
  });
367
367
  });
368
+
369
+ describe("createAdvisorAnalyzer — cross-source identity", () => {
370
+ it("namespaces the recommendation type id of resource-level findings", async () => {
371
+ const analyzer = createAdvisorAnalyzer({
372
+ build: () =>
373
+ makeFakeClient([
374
+ {
375
+ category: "Cost",
376
+ impact: "High",
377
+ recommendationTypeId: "right-size-vm",
378
+ resourceMetadata: { resourceId: RID1 },
379
+ shortDescription: { problem: "p" },
380
+ },
381
+ ]),
382
+ });
383
+
384
+ const findings = await analyzer.analyze(makeCtx());
385
+
386
+ expect(findings[0].recommendationId).toBe("advisor.right-size-vm");
387
+ });
388
+
389
+ it("namespaces the recommendation type id of subscription-level findings", async () => {
390
+ const SUB_URI = "/subscriptions/00000000-0000-0000-0000-000000000000";
391
+ const analyzer = createAdvisorAnalyzer({
392
+ build: () =>
393
+ makeFakeClient([
394
+ {
395
+ category: "Cost",
396
+ extendedProperties: { savingsAmount: "50", savingsCurrency: "EUR" },
397
+ impact: "High",
398
+ recommendationTypeId: "vm-ri",
399
+ resourceMetadata: { resourceId: SUB_URI },
400
+ shortDescription: { problem: "Buy VM reserved instance" },
401
+ },
402
+ ]),
403
+ });
404
+
405
+ const findings = await analyzer.analyze(makeCtx());
406
+
407
+ expect(findings[0].recommendationId).toBe("advisor.vm-ri");
408
+ });
409
+
410
+ it("leaves the recommendation id unset when Advisor reports no type", async () => {
411
+ const analyzer = createAdvisorAnalyzer({
412
+ build: () =>
413
+ makeFakeClient([
414
+ {
415
+ category: "Cost",
416
+ impact: "Low",
417
+ resourceMetadata: { resourceId: RID1 },
418
+ shortDescription: { problem: "p" },
419
+ },
420
+ ]),
421
+ });
422
+
423
+ const findings = await analyzer.analyze(makeCtx());
424
+
425
+ expect(findings[0].recommendationId).toBeUndefined();
426
+ });
427
+ });
@@ -153,6 +153,18 @@ function addToSubGroups(
153
153
  }
154
154
  }
155
155
 
156
+ /**
157
+ * Namespaced canonical identity of an Advisor recommendation. Combined with the
158
+ * resource ID it is the deduplication key that lets the same problem reported
159
+ * by another source collapse onto this finding. Left unset when Advisor does
160
+ * not report a type ID, so unrelated recommendations are never merged.
161
+ */
162
+ function advisorRecommendationId(
163
+ recommendationTypeId: string | undefined,
164
+ ): string | undefined {
165
+ return recommendationTypeId ? `advisor.${recommendationTypeId}` : undefined;
166
+ }
167
+
156
168
  function buildResourceFinding(
157
169
  rec: RecommendationInfo,
158
170
  savings: undefined | { amount: number; currency: string },
@@ -168,6 +180,7 @@ function buildResourceFinding(
168
180
  code: `advisor.${rec.recommendationTypeId ?? "unknown"}`,
169
181
  estimatedMonthlySavings: savings,
170
182
  reason: enrichReason(problem, props),
183
+ recommendationId: advisorRecommendationId(rec.recommendationTypeId),
171
184
  recommendedAction: rec.shortDescription?.solution,
172
185
  resourceId: rawResourceId,
173
186
  severity: mapImpact(rec.impact),
@@ -202,6 +215,7 @@ function createSubGroup(
202
215
  category: "cost",
203
216
  code: `advisor.${typeKey}`,
204
217
  reason,
218
+ recommendationId: advisorRecommendationId(rec.recommendationTypeId),
205
219
  recommendedAction: rec.shortDescription?.solution,
206
220
  resourceId,
207
221
  severity: mapImpact(rec.impact),