@pagopa/dx-savemoney 0.6.4 → 0.6.5

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,127 @@
1
+ /**
2
+ * Stable per-sentence identity for custom (live-scan) analyzer findings.
3
+ *
4
+ * `Finding.recommendationId` powers cross-source dedup, but custom analyzer
5
+ * sentences were deliberately excluded from it: every sentence produced by
6
+ * `findingsFromAnalysisResult` shared the generic `code: "custom.unknown"`
7
+ * and had no stable identifier, so two different sentences could never be
8
+ * told apart safely.
9
+ *
10
+ * This module closes that gap for the sentences whose wording is known ahead
11
+ * of time: each per-resource analyzer in `azure/resources/` builds its
12
+ * `reason` string from a fixed set of templates (e.g. "Disk is unattached.",
13
+ * "VM is deallocated."). This module recognises those templates by a
14
+ * case-insensitive prefix match on the (already sentence-split) `reason` and
15
+ * assigns both `code` and `recommendationId` to a stable `custom.<id>` value,
16
+ * following the same convention as `advisor.<recommendationTypeId>` /
17
+ * `azqr.<recommendationId>`. That is what lets `foldDuplicateFinding`
18
+ * (`finding-dedup.ts`) — whose matching is already identity-based and
19
+ * source-agnostic — collapse two custom findings that describe the same
20
+ * problem on the same resource.
21
+ *
22
+ * A handful of sentences are worded identically by more than one resource
23
+ * type (e.g. App Service Plan and Container App both emit "Very low CPU usage
24
+ * (…)."). Those intentionally share one generic code: dedup only ever
25
+ * compares findings within the same `resourceId`, and a resource can't be two
26
+ * types at once, so the shared code cannot cause a false merge across
27
+ * unrelated resources.
28
+ *
29
+ * Sentences that don't match any known template are left untouched — they
30
+ * keep `code: "custom.unknown"` and no `recommendationId`, exactly like
31
+ * before, so genuinely unrecognised text is never merged on a guess.
32
+ */
33
+ import { DETAIL_LOOKUP_FAILURE_PREFIX, MISSING_TAGS_REASON, NO_ANALYZER_REASON, UNPREFERRED_LOCATION_REASON, } from "./finding-category.js";
34
+ /** Shared prefix for every stable custom finding id, mirroring `advisor.<id>` / `azqr.<id>`. */
35
+ const CODE_PREFIX = "custom.";
36
+ /**
37
+ * Ordered `(normalized reason prefix, code suffix)` pairs. Evaluated
38
+ * top-to-bottom; the first match wins. Prefixes are matched against the
39
+ * trimmed, lower-cased `reason`, so the dynamic suffixes analyzers append
40
+ * (percentages, byte counts, durations, …) never break the match.
41
+ */
42
+ const REASON_CODES = [
43
+ // Generic — emitted by (almost) every per-resource analyzer.
44
+ ["resource id is missing.", "missing-resource-id"],
45
+ [DETAIL_LOOKUP_FAILURE_PREFIX, "lookup-failed"],
46
+ [MISSING_TAGS_REASON.toLowerCase(), "missing-tags"],
47
+ [NO_ANALYZER_REASON.toLowerCase(), "no-analyzer"],
48
+ [UNPREFERRED_LOCATION_REASON.toLowerCase(), "unpreferred-location"],
49
+ // Managed Disk (azure/resources/disk.ts)
50
+ ["disk is unattached.", "disk.unattached"],
51
+ // Network Interface (azure/resources/nic.ts)
52
+ ["nic not attached to any vm or private endpoint.", "nic.unattached"],
53
+ ["no public ip assigned.", "nic.no-public-ip"],
54
+ // Private Endpoint (azure/resources/private-endpoint.ts)
55
+ [
56
+ "private endpoint has no private link service connections configured.",
57
+ "private-endpoint.no-connections",
58
+ ],
59
+ [
60
+ "private endpoint has rejected or disconnected connections.",
61
+ "private-endpoint.rejected-connection",
62
+ ],
63
+ [
64
+ "private endpoint has no network interfaces attached.",
65
+ "private-endpoint.no-nics",
66
+ ],
67
+ [
68
+ "private endpoint is not associated with a subnet.",
69
+ "private-endpoint.no-subnet",
70
+ ],
71
+ // Container App (azure/resources/container-app.ts)
72
+ ["container app is not running.", "container-app.not-running"],
73
+ ["container app has 0 replicas configured.", "container-app.zero-replicas"],
74
+ // App Service Plan (azure/resources/app-service.ts)
75
+ ["app service plan has no apps deployed.", "app-service.no-apps"],
76
+ [
77
+ "premium tier with low resource utilization.",
78
+ "app-service.premium-underutilized",
79
+ ],
80
+ // Public IP (azure/resources/public-ip.ts)
81
+ ["public ip not associated with any resource.", "public-ip.unassociated"],
82
+ ["static ip not in use.", "public-ip.static-unused"],
83
+ // Storage Account (azure/resources/storage.ts)
84
+ ["very low transaction count (", "storage.low-transactions"],
85
+ // Virtual Machine (azure/resources/vm.ts) — worded distinctly ("Low ...",
86
+ // no "Very") from the shared "Very low ..." family below.
87
+ ["vm is deallocated.", "vm.deallocated"],
88
+ ["vm is stopped.", "vm.stopped"],
89
+ ["low cpu usage (avg", "vm.low-cpu-usage"],
90
+ ["low network traffic (", "vm.low-network-traffic"],
91
+ // Static Web App (azure/resources/static-web-app.ts)
92
+ ["no traffic data available in", "static-site.no-traffic-data"],
93
+ ["very low site traffic (", "static-site.low-traffic"],
94
+ ["very low data transfer (", "static-site.low-data-transfer"],
95
+ // Shared across resource types whose analyzers phrase the same signal with
96
+ // identical text — see module doc. App Service Plan + Container App for
97
+ // CPU/memory; Container App + Public IP for network traffic.
98
+ ["very low cpu usage (", "low-cpu-usage"],
99
+ ["very low memory usage (", "low-memory-usage"],
100
+ ["very low network traffic (", "low-network-traffic"],
101
+ ];
102
+ /**
103
+ * Assigns a stable `code`/`recommendationId` to every custom finding whose
104
+ * `reason` matches a known sentence template, so equivalent sentences about
105
+ * the same resource collapse via `foldDuplicateFinding` instead of appearing
106
+ * as separate rows.
107
+ *
108
+ * Leaves untouched: non-`"custom"` findings, and findings that already carry
109
+ * a `recommendationId` (nothing to add) or whose `reason` matches no known
110
+ * template (stays `"custom.unknown"`, ungrouped — the same fallback behaviour
111
+ * as before this module existed).
112
+ */
113
+ export function assignCustomFindingCodes(findings) {
114
+ return findings.map((finding) => {
115
+ if (finding.source !== "custom" || finding.recommendationId) {
116
+ return finding;
117
+ }
118
+ const code = stableCodeFor(finding.reason);
119
+ return code ? { ...finding, code, recommendationId: code } : finding;
120
+ });
121
+ }
122
+ function stableCodeFor(reason) {
123
+ const normalized = reason.trim().toLowerCase();
124
+ const match = REASON_CODES.find(([prefix]) => normalized.startsWith(prefix));
125
+ return match ? `${CODE_PREFIX}${match[1]}` : undefined;
126
+ }
127
+ //# sourceMappingURL=finding-code.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"finding-code.js","sourceRoot":"","sources":["../../src/azure/finding-code.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAIH,OAAO,EACL,4BAA4B,EAC5B,mBAAmB,EACnB,kBAAkB,EAClB,2BAA2B,GAC5B,MAAM,uBAAuB,CAAC;AAE/B,gGAAgG;AAChG,MAAM,WAAW,GAAG,SAAS,CAAC;AAE9B;;;;;GAKG;AACH,MAAM,YAAY,GAA2C;IAC3D,6DAA6D;IAC7D,CAAC,yBAAyB,EAAE,qBAAqB,CAAC;IAClD,CAAC,4BAA4B,EAAE,eAAe,CAAC;IAC/C,CAAC,mBAAmB,CAAC,WAAW,EAAE,EAAE,cAAc,CAAC;IACnD,CAAC,kBAAkB,CAAC,WAAW,EAAE,EAAE,aAAa,CAAC;IACjD,CAAC,2BAA2B,CAAC,WAAW,EAAE,EAAE,sBAAsB,CAAC;IAEnE,yCAAyC;IACzC,CAAC,qBAAqB,EAAE,iBAAiB,CAAC;IAE1C,6CAA6C;IAC7C,CAAC,iDAAiD,EAAE,gBAAgB,CAAC;IACrE,CAAC,wBAAwB,EAAE,kBAAkB,CAAC;IAE9C,yDAAyD;IACzD;QACE,sEAAsE;QACtE,iCAAiC;KAClC;IACD;QACE,4DAA4D;QAC5D,sCAAsC;KACvC;IACD;QACE,sDAAsD;QACtD,0BAA0B;KAC3B;IACD;QACE,mDAAmD;QACnD,4BAA4B;KAC7B;IAED,mDAAmD;IACnD,CAAC,+BAA+B,EAAE,2BAA2B,CAAC;IAC9D,CAAC,0CAA0C,EAAE,6BAA6B,CAAC;IAE3E,oDAAoD;IACpD,CAAC,wCAAwC,EAAE,qBAAqB,CAAC;IACjE;QACE,6CAA6C;QAC7C,mCAAmC;KACpC;IAED,2CAA2C;IAC3C,CAAC,6CAA6C,EAAE,wBAAwB,CAAC;IACzE,CAAC,uBAAuB,EAAE,yBAAyB,CAAC;IAEpD,+CAA+C;IAC/C,CAAC,8BAA8B,EAAE,0BAA0B,CAAC;IAE5D,0EAA0E;IAC1E,0DAA0D;IAC1D,CAAC,oBAAoB,EAAE,gBAAgB,CAAC;IACxC,CAAC,gBAAgB,EAAE,YAAY,CAAC;IAChC,CAAC,oBAAoB,EAAE,kBAAkB,CAAC;IAC1C,CAAC,uBAAuB,EAAE,wBAAwB,CAAC;IAEnD,qDAAqD;IACrD,CAAC,8BAA8B,EAAE,6BAA6B,CAAC;IAC/D,CAAC,yBAAyB,EAAE,yBAAyB,CAAC;IACtD,CAAC,0BAA0B,EAAE,+BAA+B,CAAC;IAE7D,2EAA2E;IAC3E,wEAAwE;IACxE,6DAA6D;IAC7D,CAAC,sBAAsB,EAAE,eAAe,CAAC;IACzC,CAAC,yBAAyB,EAAE,kBAAkB,CAAC;IAC/C,CAAC,4BAA4B,EAAE,qBAAqB,CAAC;CACtD,CAAC;AAEF;;;;;;;;;;GAUG;AACH,MAAM,UAAU,wBAAwB,CAAC,QAAmB;IAC1D,OAAO,QAAQ,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE;QAC9B,IAAI,OAAO,CAAC,MAAM,KAAK,QAAQ,IAAI,OAAO,CAAC,gBAAgB,EAAE,CAAC;YAC5D,OAAO,OAAO,CAAC;QACjB,CAAC;QACD,MAAM,IAAI,GAAG,aAAa,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QAC3C,OAAO,IAAI,CAAC,CAAC,CAAC,EAAE,GAAG,OAAO,EAAE,IAAI,EAAE,gBAAgB,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC;IACvE,CAAC,CAAC,CAAC;AACL,CAAC;AAED,SAAS,aAAa,CAAC,MAAc;IACnC,MAAM,UAAU,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IAC/C,MAAM,KAAK,GAAG,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC;IAC7E,OAAO,KAAK,CAAC,CAAC,CAAC,GAAG,WAAW,GAAG,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;AACzD,CAAC"}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Cross-source deduplication of findings.
3
+ *
4
+ * SaveMoney observes the same subscription through three lenses: the custom
5
+ * live-scan analyzers, Azure Advisor, and an optional AZQR report. They
6
+ * overlap — an unattached disk is waste no matter who reports it — so the
7
+ * same resource could otherwise appear several times in the output, once per
8
+ * source. This also folds duplicate sentences emitted by the custom analyzers
9
+ * themselves (e.g. the same resource triggering the same "Very low CPU usage"
10
+ * template twice), since the matching below is source-agnostic.
11
+ *
12
+ * Findings are unified on the `resourceId + recommendationId` key. Sources
13
+ * normalise `recommendationId` onto a shared vocabulary whenever one exists:
14
+ * orphaned resources use {@link orphanRecommendationId} regardless of who
15
+ * detected them, which is what lets an AZQR `AOR` row collapse onto the
16
+ * Advisor or custom finding for the same resource. Custom analyzer sentences
17
+ * with a known template get a stable `custom.<id>` identity from
18
+ * `finding-code.ts`; sentences with no known template keep
19
+ * `code: "custom.unknown"` and no `recommendationId`, so they are never
20
+ * collapsed on guesswork. The surviving finding is the one carrying the
21
+ * monetary estimate (only Advisor and the pricing-enriched custom analyzers
22
+ * have one — AZQR reports no amounts at all), annotated with the
23
+ * corroborating source so the extra provenance is not lost.
24
+ */
25
+ import type { Finding } from "../finding.js";
26
+ import type { AzureDetailedResourceReport } from "./types.js";
27
+ /**
28
+ * Folds `incoming` into an existing finding of `report` when both describe the
29
+ * same problem, so the resource is reported once instead of once per source.
30
+ *
31
+ * The surviving finding keeps the highest severity and the only available
32
+ * monetary estimate, and records the corroborating source both in its own
33
+ * reason and in the legacy per-resource `analysis` summary, so the `lint`,
34
+ * `json` and `table` formats all keep the provenance.
35
+ *
36
+ * @returns `true` when the finding was folded and must not be appended.
37
+ */
38
+ export declare function foldDuplicateFinding(incoming: Finding, report: AzureDetailedResourceReport): boolean;
39
+ /**
40
+ * Builds the canonical identity of an orphaned resource of the given type,
41
+ * e.g. `orphan.microsoft.compute/disks`.
42
+ */
43
+ export declare function orphanRecommendationId(resourceType: string): string;
44
+ //# sourceMappingURL=finding-dedup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"finding-dedup.d.ts","sourceRoot":"","sources":["../../src/azure/finding-dedup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAiB,MAAM,eAAe,CAAC;AAC5D,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,YAAY,CAAC;AAa9D;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,OAAO,EACjB,MAAM,EAAE,2BAA2B,GAClC,OAAO,CA0BT;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAEnE"}
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Cross-source deduplication of findings.
3
+ *
4
+ * SaveMoney observes the same subscription through three lenses: the custom
5
+ * live-scan analyzers, Azure Advisor, and an optional AZQR report. They
6
+ * overlap — an unattached disk is waste no matter who reports it — so the
7
+ * same resource could otherwise appear several times in the output, once per
8
+ * source. This also folds duplicate sentences emitted by the custom analyzers
9
+ * themselves (e.g. the same resource triggering the same "Very low CPU usage"
10
+ * template twice), since the matching below is source-agnostic.
11
+ *
12
+ * Findings are unified on the `resourceId + recommendationId` key. Sources
13
+ * normalise `recommendationId` onto a shared vocabulary whenever one exists:
14
+ * orphaned resources use {@link orphanRecommendationId} regardless of who
15
+ * detected them, which is what lets an AZQR `AOR` row collapse onto the
16
+ * Advisor or custom finding for the same resource. Custom analyzer sentences
17
+ * with a known template get a stable `custom.<id>` identity from
18
+ * `finding-code.ts`; sentences with no known template keep
19
+ * `code: "custom.unknown"` and no `recommendationId`, so they are never
20
+ * collapsed on guesswork. The surviving finding is the one carrying the
21
+ * monetary estimate (only Advisor and the pricing-enriched custom analyzers
22
+ * have one — AZQR reports no amounts at all), annotated with the
23
+ * corroborating source so the extra provenance is not lost.
24
+ */
25
+ import { maxCostRisk } from "../types.js";
26
+ /** Sources that observe the live subscription, as opposed to a static report. */
27
+ const LIVE_SOURCES = new Set(["advisor", "custom"]);
28
+ /**
29
+ * Canonical identity prefix for "this resource is provisioned but unused".
30
+ * Shared by every source so orphan findings deduplicate across them.
31
+ */
32
+ const ORPHAN_RECOMMENDATION_PREFIX = "orphan.";
33
+ /**
34
+ * Folds `incoming` into an existing finding of `report` when both describe the
35
+ * same problem, so the resource is reported once instead of once per source.
36
+ *
37
+ * The surviving finding keeps the highest severity and the only available
38
+ * monetary estimate, and records the corroborating source both in its own
39
+ * reason and in the legacy per-resource `analysis` summary, so the `lint`,
40
+ * `json` and `table` formats all keep the provenance.
41
+ *
42
+ * @returns `true` when the finding was folded and must not be appended.
43
+ */
44
+ export function foldDuplicateFinding(incoming, report) {
45
+ const findings = report.findings;
46
+ if (!findings) {
47
+ return false;
48
+ }
49
+ const surviving = findDuplicateFinding(incoming, findings);
50
+ if (!surviving) {
51
+ return false;
52
+ }
53
+ const note = corroborationNote(surviving, incoming);
54
+ findings[findings.indexOf(surviving)] = {
55
+ ...surviving,
56
+ estimatedMonthlySavings: surviving.estimatedMonthlySavings ?? incoming.estimatedMonthlySavings,
57
+ reason: note ? `${surviving.reason.trimEnd()} ${note}` : surviving.reason,
58
+ recommendationId: surviving.recommendationId ?? incoming.recommendationId,
59
+ severity: maxCostRisk(surviving.severity, incoming.severity),
60
+ };
61
+ report.analysis = {
62
+ ...report.analysis,
63
+ costRisk: maxCostRisk(report.analysis.costRisk, incoming.severity),
64
+ reason: note
65
+ ? `${report.analysis.reason.trimEnd()} ${note}`.trim()
66
+ : report.analysis.reason,
67
+ };
68
+ return true;
69
+ }
70
+ /**
71
+ * Builds the canonical identity of an orphaned resource of the given type,
72
+ * e.g. `orphan.microsoft.compute/disks`.
73
+ */
74
+ export function orphanRecommendationId(resourceType) {
75
+ return `${ORPHAN_RECOMMENDATION_PREFIX}${resourceType.toLowerCase()}`;
76
+ }
77
+ /**
78
+ * Short sentence appended to the surviving finding so the reader still sees
79
+ * that a second source flagged the same resource. Omitted for same-source
80
+ * duplicates, where there is no extra provenance to record, and when the same
81
+ * note is already there — repeated rows must corroborate, not accumulate.
82
+ */
83
+ function corroborationNote(surviving, incoming) {
84
+ if (surviving.source === incoming.source) {
85
+ return undefined;
86
+ }
87
+ const note = isOrphanRecommendation(incoming.recommendationId)
88
+ ? `Also flagged by ${incoming.source} as an orphaned resource.`
89
+ : `Also flagged by ${incoming.source}.`;
90
+ return surviving.reason.includes(note) ? undefined : note;
91
+ }
92
+ /**
93
+ * Returns the already-collected finding that covers the same problem as
94
+ * `incoming`, or `undefined` when the incoming finding is genuinely new.
95
+ *
96
+ * Two findings describe the same problem when either:
97
+ *
98
+ * 1. they share the exact canonical identity (`recommendationId`) on the same
99
+ * resource — a true duplicate, e.g. the same AZQR row ingested twice; or
100
+ * 2. `incoming` reports the resource as orphaned and a live source already
101
+ * reported billable waste on it with no stronger identity of its own (e.g.
102
+ * a sentence-level custom finding with no `recommendationId`). An orphaned
103
+ * resource has a single cost problem — it exists — so the live finding,
104
+ * which can carry a monetary estimate, supersedes the static one. A live
105
+ * finding that already carries its own distinct `recommendationId` (e.g.
106
+ * an Advisor right-sizing recommendation) is a different problem and is
107
+ * never absorbed this way.
108
+ *
109
+ * Findings without a `recommendationId` never match: sentences whose text
110
+ * matches no known template (still `code: "custom.unknown"`, see
111
+ * `finding-code.ts`) must not be collapsed on guesswork.
112
+ */
113
+ function findDuplicateFinding(incoming, existing) {
114
+ const identity = incoming.recommendationId?.toLowerCase();
115
+ if (!identity) {
116
+ return undefined;
117
+ }
118
+ const sameIdentity = existing.find((candidate) => candidate.recommendationId?.toLowerCase() === identity);
119
+ if (sameIdentity) {
120
+ return sameIdentity;
121
+ }
122
+ if (!isOrphanRecommendation(incoming.recommendationId)) {
123
+ return undefined;
124
+ }
125
+ return existing.find((candidate) => candidate.category === "cost" &&
126
+ LIVE_SOURCES.has(candidate.source) &&
127
+ !candidate.recommendationId);
128
+ }
129
+ function isOrphanRecommendation(recommendationId) {
130
+ return (recommendationId ?? "").startsWith(ORPHAN_RECOMMENDATION_PREFIX);
131
+ }
132
+ //# sourceMappingURL=finding-dedup.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"finding-dedup.js","sourceRoot":"","sources":["../../src/azure/finding-dedup.ts"],"names":[],"mappings":"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"}
package/dist/finding.d.ts CHANGED
@@ -14,7 +14,8 @@ import type { CostRisk } from "./types.js";
14
14
  * A single, atomic observation about a resource.
15
15
  *
16
16
  * One resource can produce multiple findings (e.g. "no tags" + "low CPU").
17
- * Findings are designed to be deduplicated by `(resourceId, source, code)`.
17
+ * Findings are deduplicated on `(resourceId, recommendationId)` — see
18
+ * {@link Finding.recommendationId}.
18
19
  */
19
20
  export type Finding = {
20
21
  /**
@@ -25,8 +26,8 @@ export type Finding = {
25
26
  /**
26
27
  * Stable machine-readable identifier for the kind of finding, e.g.
27
28
  * `vm.deallocated`, `disk.unattached`, `advisor.right-size-vm`.
28
- * Used for deduplication and grouping. Free-form for now to keep the
29
- * adapter from existing analyzers cheap; can be tightened later.
29
+ * Free-form and source-specific: use `recommendationId` for
30
+ * deduplication across sources.
30
31
  */
31
32
  code: string;
32
33
  /**
@@ -40,6 +41,19 @@ export type Finding = {
40
41
  * legacy `AnalysisResult.reason` field.
41
42
  */
42
43
  reason: string;
44
+ /**
45
+ * Canonical, source-independent identity of the underlying problem.
46
+ * Together with `resourceId` it forms the deduplication key, so the same
47
+ * waste reported by different sources collapses into a single finding
48
+ * (see `azure/finding-dedup.ts`).
49
+ *
50
+ * Producers normalise it whenever a shared vocabulary exists — orphaned
51
+ * resources use `orphan.<resourceType>` regardless of the source that
52
+ * detected them — and fall back to their native recommendation ID
53
+ * otherwise. Left unset when the source has no stable identifier: such
54
+ * findings are never deduplicated.
55
+ */
56
+ recommendationId?: string;
43
57
  /**
44
58
  * Optional, machine-friendly hint about how to remediate. For Advisor
45
59
  * this typically maps to `shortDescription.solution`.
@@ -84,10 +98,8 @@ export type Money = {
84
98
  /**
85
99
  * Aggregate view: one resource with the list of findings emitted for it.
86
100
  *
87
- * This is the type future report generators should consume. The current
88
- * report layer still works on `AzureDetailedResourceReport`; a helper
89
- * (`legacyReportFromResourceReport`) bridges the two until the report
90
- * layer is migrated.
101
+ * This is the type future report generators should consume; the current
102
+ * report layer still works on `AzureDetailedResourceReport`.
91
103
  */
92
104
  export type ResourceReport<TResource = unknown> = {
93
105
  findings: Finding[];
@@ -1 +1 @@
1
- {"version":3,"file":"finding.d.ts","sourceRoot":"","sources":["../src/finding.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C;;;;;GAKG;AACH,MAAM,MAAM,OAAO,GAAG;IACpB;;;OAGG;IACH,QAAQ,EAAE,eAAe,CAAC;IAC1B;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,uBAAuB,CAAC,EAAE,KAAK,CAAC;IAChC;;;OAGG;IACH,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;OAEG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,QAAQ,EAAE,QAAQ,CAAC;IACnB;;OAEG;IACH,MAAM,EAAE,aAAa,CAAC;CACvB,CAAC;AAEF;;;GAGG;AACH,MAAM,MAAM,eAAe,GACzB,MAAM,GAAG,uBAAuB,GAAG,aAAa,GAAG,aAAa,GAAG,UAAU,CAAC;AAEhF;;;;;;;GAOG;AACH,MAAM,MAAM,aAAa,GAAG,SAAS,GAAG,KAAK,GAAG,MAAM,GAAG,QAAQ,CAAC;AAElE;;;GAGG;AACH,MAAM,MAAM,KAAK,GAAG;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,MAAM,cAAc,CAAC,SAAS,GAAG,OAAO,IAAI;IAChD,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,QAAQ,EAAE,SAAS,CAAC;CACrB,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE;IAC/C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,QAAQ,CAAC;IACnB,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB,GAAG,OAAO,EAAE,CAqBZ"}
1
+ {"version":3,"file":"finding.d.ts","sourceRoot":"","sources":["../src/finding.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C;;;;;;GAMG;AACH,MAAM,MAAM,OAAO,GAAG;IACpB;;;OAGG;IACH,QAAQ,EAAE,eAAe,CAAC;IAC1B;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,uBAAuB,CAAC,EAAE,KAAK,CAAC;IAChC;;;OAGG;IACH,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;;;;;;OAWG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;OAEG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,QAAQ,EAAE,QAAQ,CAAC;IACnB;;OAEG;IACH,MAAM,EAAE,aAAa,CAAC;CACvB,CAAC;AAEF;;;GAGG;AACH,MAAM,MAAM,eAAe,GACzB,MAAM,GAAG,uBAAuB,GAAG,aAAa,GAAG,aAAa,GAAG,UAAU,CAAC;AAEhF;;;;;;;GAOG;AACH,MAAM,MAAM,aAAa,GAAG,SAAS,GAAG,KAAK,GAAG,MAAM,GAAG,QAAQ,CAAC;AAElE;;;GAGG;AACH,MAAM,MAAM,KAAK,GAAG;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF;;;;;GAKG;AACH,MAAM,MAAM,cAAc,CAAC,SAAS,GAAG,OAAO,IAAI;IAChD,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,QAAQ,EAAE,SAAS,CAAC;CACrB,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE;IAC/C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,QAAQ,CAAC;IACnB,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB,GAAG,OAAO,EAAE,CAqBZ"}
@@ -1 +1 @@
1
- {"version":3,"file":"finding.js","sourceRoot":"","sources":["../src/finding.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AA6FH;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,0BAA0B,CAAC,IAM1C;IACC,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,GAAG,QAAQ,EAAE,GAAG,IAAI,CAAC;IACvE,MAAM,SAAS,GAAG,wBAAwB,CAAC,MAAM,CAAC,CAAC;IACnD,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC3B,OAAO,EAAE,CAAC;IACZ,CAAC;IACD,sEAAsE;IACtE,qEAAqE;IACrE,oEAAoE;IACpE,oEAAoE;IACpE,kEAAkE;IAClE,8DAA8D;IAC9D,qBAAqB;IACrB,OAAO,SAAS,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;QAClC,QAAQ,EAAE,MAAe;QACzB,IAAI,EAAE,IAAI,IAAI,gBAAgB;QAC9B,MAAM,EAAE,QAAQ;QAChB,UAAU;QACV,QAAQ;QACR,MAAM;KACP,CAAC,CAAC,CAAC;AACN,CAAC;AAED;;;;GAIG;AACH,SAAS,wBAAwB,CAAC,MAAc;IAC9C,OAAO,MAAM;SACV,KAAK,CAAC,WAAW,CAAC;SAClB,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;SACpB,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC;SAC3B,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;AACjD,CAAC"}
1
+ {"version":3,"file":"finding.js","sourceRoot":"","sources":["../src/finding.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAyGH;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,0BAA0B,CAAC,IAM1C;IACC,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,GAAG,QAAQ,EAAE,GAAG,IAAI,CAAC;IACvE,MAAM,SAAS,GAAG,wBAAwB,CAAC,MAAM,CAAC,CAAC;IACnD,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC3B,OAAO,EAAE,CAAC;IACZ,CAAC;IACD,sEAAsE;IACtE,qEAAqE;IACrE,oEAAoE;IACpE,oEAAoE;IACpE,kEAAkE;IAClE,8DAA8D;IAC9D,qBAAqB;IACrB,OAAO,SAAS,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;QAClC,QAAQ,EAAE,MAAe;QACzB,IAAI,EAAE,IAAI,IAAI,gBAAgB;QAC9B,MAAM,EAAE,QAAQ;QAChB,UAAU;QACV,QAAQ;QACR,MAAM;KACP,CAAC,CAAC,CAAC;AACN,CAAC;AAED;;;;GAIG;AACH,SAAS,wBAAwB,CAAC,MAAc;IAC9C,OAAO,MAAM;SACV,KAAK,CAAC,WAAW,CAAC;SAClB,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;SACpB,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC;SAC3B,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;AACjD,CAAC"}
package/dist/types.d.ts CHANGED
@@ -22,6 +22,8 @@ export type BaseConfig = {
22
22
  timespanDays: number;
23
23
  };
24
24
  export type CostRisk = "high" | "low" | "medium";
25
+ /** Cost risk ordering, from the most to the least severe. */
26
+ export declare const COST_RISK_ORDER: Record<CostRisk, number>;
25
27
  /**
26
28
  * Configurable thresholds used during resource analysis.
27
29
  * Derived from `ThresholdsSchema` — the schema is the single source of truth.
@@ -57,6 +59,12 @@ export declare const DEFAULT_THRESHOLDS: {
57
59
  networkInBytesPerDay: number;
58
60
  };
59
61
  };
62
+ /**
63
+ * Returns the more severe of two cost risks. Used whenever two observations
64
+ * about the same resource (or the same problem seen by two sources) are
65
+ * combined, so severity never degrades silently.
66
+ */
67
+ export declare function maxCostRisk(a: CostRisk, b: CostRisk): CostRisk;
60
68
  /**
61
69
  * Merges analysis results, preserving existing reasons and combining suspectedUnused flags.
62
70
  */
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AAI1C,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,EAAE,QAAQ,CAAC;IACnB;;;;;OAKG;IACH,uBAAuB,CAAC,EAAE,KAAK,CAAC;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,eAAe,EAAE,OAAO,CAAC;CAC1B,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,UAAU,GAAG;IACvB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;AAEjD;;;GAGG;AACH,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;CAA6B,CAAC;AAE7D;;GAEG;AACH,wBAAgB,YAAY,CAC1B,UAAU,EAAE,cAAc,EAC1B,cAAc,EAAE,cAAc,GAC7B,cAAc,CAUhB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AAI1C,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,EAAE,QAAQ,CAAC;IACnB;;;;;OAKG;IACH,uBAAuB,CAAC,EAAE,KAAK,CAAC;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,eAAe,EAAE,OAAO,CAAC;CAC1B,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,UAAU,GAAG;IACvB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;AAEjD,6DAA6D;AAC7D,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,CAIpD,CAAC;AAEF;;;GAGG;AACH,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;CAA6B,CAAC;AAE7D;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAE9D;AAED;;GAEG;AACH,wBAAgB,YAAY,CAC1B,UAAU,EAAE,cAAc,EAC1B,cAAc,EAAE,cAAc,GAC7B,cAAc,CAUhB"}
package/dist/types.js CHANGED
@@ -2,11 +2,25 @@
2
2
  * Common types shared across all cloud providers
3
3
  */
4
4
  import { ThresholdsSchema } from "./schema.js";
5
+ /** Cost risk ordering, from the most to the least severe. */
6
+ export const COST_RISK_ORDER = {
7
+ high: 0,
8
+ low: 2,
9
+ medium: 1,
10
+ };
5
11
  /**
6
12
  * Default threshold values — produced by parsing an empty object through
7
13
  * `ThresholdsSchema`, which applies all schema-defined defaults.
8
14
  */
9
15
  export const DEFAULT_THRESHOLDS = ThresholdsSchema.parse({});
16
+ /**
17
+ * Returns the more severe of two cost risks. Used whenever two observations
18
+ * about the same resource (or the same problem seen by two sources) are
19
+ * combined, so severity never degrades silently.
20
+ */
21
+ export function maxCostRisk(a, b) {
22
+ return COST_RISK_ORDER[a] <= COST_RISK_ORDER[b] ? a : b;
23
+ }
10
24
  /**
11
25
  * Merges analysis results, preserving existing reasons and combining suspectedUnused flags.
12
26
  */
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AA+B/C;;;GAGG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,gBAAgB,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;AAE7D;;GAEG;AACH,MAAM,UAAU,YAAY,CAC1B,UAA0B,EAC1B,cAA8B;IAE9B,OAAO;QACL,QAAQ,EAAE,cAAc,CAAC,QAAQ;QACjC,uBAAuB,EACrB,cAAc,CAAC,uBAAuB;YACtC,UAAU,CAAC,uBAAuB;QACpC,MAAM,EAAE,UAAU,CAAC,MAAM,GAAG,cAAc,CAAC,MAAM;QACjD,eAAe,EACb,UAAU,CAAC,eAAe,IAAI,cAAc,CAAC,eAAe;KAC/D,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAyB/C,6DAA6D;AAC7D,MAAM,CAAC,MAAM,eAAe,GAA6B;IACvD,IAAI,EAAE,CAAC;IACP,GAAG,EAAE,CAAC;IACN,MAAM,EAAE,CAAC;CACV,CAAC;AAQF;;;GAGG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,gBAAgB,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;AAE7D;;;;GAIG;AACH,MAAM,UAAU,WAAW,CAAC,CAAW,EAAE,CAAW;IAClD,OAAO,eAAe,CAAC,CAAC,CAAC,IAAI,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AAC1D,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,YAAY,CAC1B,UAA0B,EAC1B,cAA8B;IAE9B,OAAO;QACL,QAAQ,EAAE,cAAc,CAAC,QAAQ;QACjC,uBAAuB,EACrB,cAAc,CAAC,uBAAuB;YACtC,UAAU,CAAC,uBAAuB;QACpC,MAAM,EAAE,UAAU,CAAC,MAAM,GAAG,cAAc,CAAC,MAAM;QACjD,eAAe,EACb,UAAU,CAAC,eAAe,IAAI,cAAc,CAAC,eAAe;KAC/D,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pagopa/dx-savemoney",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "type": "module",
5
5
  "description": "Azure resource analyzer for finding unused or cost-inefficient resources.",
6
6
  "repository": {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Tests for the AZQR ingestion module.
3
3
  *
4
- * Cover the behaviours CES-2192 requires:
4
+ * Cover:
5
5
  * 1. `parseAzqrReport` validates the JSON shape (accept valid, reject invalid).
6
6
  * 2. `isAzqrReportMasked` detects AZQR's default subscription-ID masking.
7
7
  * 3. `classifyAzqrRow` / `azqrImpactedToFindings` promote billable waste as
@@ -235,4 +235,48 @@ describe("azqrImpactedToFindings", () => {
235
235
  });
236
236
  expect(azqrImpactedToFindings(report)[0].severity).toBe("low");
237
237
  });
238
+
239
+ it("gives orphaned resources the shared cross-source recommendation id", () => {
240
+ const report = parseAzqrReport({
241
+ impacted: [
242
+ orphanRow("Microsoft.Compute/disks", {
243
+ recommendation: "Managed Disks not attached",
244
+ recommendationId: "orphan-disk",
245
+ }),
246
+ ],
247
+ });
248
+
249
+ expect(azqrImpactedToFindings(report)[0].recommendationId).toBe(
250
+ "orphan.microsoft.compute/disks",
251
+ );
252
+ });
253
+
254
+ it("namespaces the native recommendation id of non-orphan rows", () => {
255
+ const report = parseAzqrReport({
256
+ impacted: [
257
+ impactedRow({
258
+ category: "Cost",
259
+ recommendationId: "aprl-1234",
260
+ resourceType: "microsoft.compute/disks",
261
+ }),
262
+ ],
263
+ });
264
+
265
+ expect(azqrImpactedToFindings(report)[0].recommendationId).toBe(
266
+ "azqr.aprl-1234",
267
+ );
268
+ });
269
+
270
+ it("leaves the recommendation id unset when the row carries none", () => {
271
+ const report = parseAzqrReport({
272
+ impacted: [
273
+ impactedRow({
274
+ category: "Cost",
275
+ resourceType: "microsoft.compute/disks",
276
+ }),
277
+ ],
278
+ });
279
+
280
+ expect(azqrImpactedToFindings(report)[0].recommendationId).toBeUndefined();
281
+ });
238
282
  });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Unit tests for the categorisation of custom analyzer reasons.
3
+ *
4
+ * The per-resource analyzers describe governance observations and billable
5
+ * waste in the same `reason` string, so the classifier is what keeps the
6
+ * `cost` category meaning "money is being wasted".
7
+ */
8
+
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import type { Finding } from "../../finding.js";
12
+
13
+ import {
14
+ categorizeCustomFindings,
15
+ MISSING_TAGS_REASON,
16
+ NO_ANALYZER_REASON,
17
+ unpreferredLocationReason,
18
+ } from "../finding-category.js";
19
+
20
+ const RESOURCE_ID =
21
+ "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/disks/disk1";
22
+
23
+ function categorize(reason: string): Finding["category"] {
24
+ const [finding] = categorizeCustomFindings([customFinding(reason)]);
25
+ return finding.category;
26
+ }
27
+
28
+ function customFinding(reason: string): Finding {
29
+ return {
30
+ category: "cost",
31
+ code: "custom.unknown",
32
+ reason,
33
+ resourceId: RESOURCE_ID,
34
+ severity: "low",
35
+ source: "custom",
36
+ };
37
+ }
38
+
39
+ describe("categorizeCustomFindings", () => {
40
+ it.each([
41
+ MISSING_TAGS_REASON,
42
+ NO_ANALYZER_REASON,
43
+ unpreferredLocationReason("italynorth"),
44
+ "Could not retrieve detailed NIC information.",
45
+ ])("reports %j as operational excellence, not cost", (reason) => {
46
+ expect(categorize(reason)).toBe("operationalExcellence");
47
+ });
48
+
49
+ it.each([
50
+ "Disk is unattached.",
51
+ "VM is deallocated.",
52
+ "Public IP is not associated with any resource.",
53
+ ])("keeps %j as a cost finding", (reason) => {
54
+ expect(categorize(reason)).toBe("cost");
55
+ });
56
+
57
+ it("preserves every other field of the finding", () => {
58
+ const finding: Finding = {
59
+ ...customFinding(MISSING_TAGS_REASON),
60
+ recommendedAction: "Add the required tags.",
61
+ severity: "medium",
62
+ };
63
+
64
+ expect(categorizeCustomFindings([finding])).toEqual([
65
+ { ...finding, category: "operationalExcellence" },
66
+ ]);
67
+ });
68
+ });