@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
package/src/azure/azqr.ts CHANGED
@@ -23,9 +23,9 @@
23
23
  * resources — {@link isAzqrReportMasked} lets the orchestrator warn and advise
24
24
  * re-running with `azqr scan --mask=false`.
25
25
  *
26
- * Scope note (CES-2192 / Fase 1): only `impacted` rows are ingested, enriched
27
- * with `inventory` metadata when available. AZQR `advisor` rows are ignored to
28
- * avoid duplicating SaveMoney's own Azure Advisor cost query.
26
+ * Scope note: only `impacted` rows are ingested, enriched with `inventory`
27
+ * metadata when available. AZQR `advisor` rows are ignored to avoid
28
+ * duplicating SaveMoney's own Azure Advisor cost query.
29
29
  */
30
30
 
31
31
  import { readFile } from "node:fs/promises";
@@ -34,6 +34,7 @@ import { z } from "zod";
34
34
  import type { CostRisk } from "../types.js";
35
35
 
36
36
  import { type Finding } from "../finding.js";
37
+ import { orphanRecommendationId } from "./finding-dedup.js";
37
38
 
38
39
  /** A single `impacted` row from an AZQR JSON report (fields we consume). */
39
40
  const azqrImpactedRowSchema = z.object({
@@ -140,6 +141,7 @@ export function azqrImpactedToFindings(report: AzqrReport): Finding[] {
140
141
  ? `azqr.${row.recommendationId}`
141
142
  : "azqr.impacted",
142
143
  reason: buildReason(row, inventory),
144
+ recommendationId: canonicalRecommendationId(row),
143
145
  recommendedAction: row.learn
144
146
  ? `${remediation} Learn more: ${row.learn}`
145
147
  : remediation,
@@ -172,7 +174,7 @@ export function classifyAzqrRow(
172
174
  if ((row.category ?? "").toLowerCase().includes("cost")) {
173
175
  return "cost";
174
176
  }
175
- if ((row.source ?? "").toLowerCase() !== ORPHAN_CHECK_SOURCE) {
177
+ if (!isOrphanRow(row)) {
176
178
  return null;
177
179
  }
178
180
  const resourceType = (row.resourceType ?? "").toLowerCase();
@@ -254,6 +256,28 @@ function buildReason(
254
256
  return context.length > 0 ? `${sentence} (${context.join(", ")})` : sentence;
255
257
  }
256
258
 
259
+ /**
260
+ * Canonical identity of the problem a row describes, used to deduplicate it
261
+ * against findings coming from other sources.
262
+ *
263
+ * Orphaned resources (AZQR's `AOR` check) map onto the shared
264
+ * `orphan.<resourceType>` vocabulary, so the same waste reported by Azure
265
+ * Advisor or by a custom analyzer collapses onto a single finding. Every other
266
+ * row keeps AZQR's own recommendation ID, namespaced so it can only ever match
267
+ * another AZQR row.
268
+ */
269
+ function canonicalRecommendationId(row: AzqrImpactedRow): string | undefined {
270
+ if (isOrphanRow(row) && row.resourceType) {
271
+ return orphanRecommendationId(row.resourceType);
272
+ }
273
+ return row.recommendationId ? `azqr.${row.recommendationId}` : undefined;
274
+ }
275
+
276
+ /** Whether the row comes from AZQR's Azure Orphan Resources check. */
277
+ function isOrphanRow(row: AzqrImpactedRow): boolean {
278
+ return (row.source ?? "").toLowerCase() === ORPHAN_CHECK_SOURCE;
279
+ }
280
+
257
281
  /**
258
282
  * Maps AZQR/APRL `impact` (`High` | `Medium` | `Low`) onto the SaveMoney
259
283
  * `CostRisk` scale, defaulting to `low` for unknown values.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Categorisation of the reasons emitted by the custom (live-scan) Azure
3
+ * analyzers.
4
+ *
5
+ * The per-resource analyzers describe everything they observe in a single
6
+ * concatenated `reason` string, which `findingsFromAnalysisResult` then splits
7
+ * into one `Finding` per sentence. Historically every sentence was labelled
8
+ * `category: "cost"`, including governance and diagnostic observations such as
9
+ * missing tags or a failed detail lookup — statements that carry no billable
10
+ * waste and would suggest a "saving" that does not exist.
11
+ *
12
+ * This module separates the two: governance / diagnostic sentences are
13
+ * reported as `operationalExcellence`, leaving `cost` to mean "actual billable
14
+ * waste". The distinction is also what makes cross-source deduplication safe
15
+ * (see `finding-dedup.ts`): a resource flagged only for missing tags is not
16
+ * evidence that it is wasted, so it must not absorb the corresponding AZQR
17
+ * orphan row.
18
+ *
19
+ * The generic reason strings are defined here and consumed by `analyzer.ts`, so
20
+ * the classifier cannot drift from the text it has to recognise. `finding-code.ts`
21
+ * reuses the same constants to assign a stable per-sentence `recommendationId`,
22
+ * so both classifiers stay in sync with the actual analyzer output.
23
+ */
24
+
25
+ import type { Finding, FindingCategory } from "../finding.js";
26
+
27
+ /** Reason emitted when a resource carries no tags at all. */
28
+ export const MISSING_TAGS_REASON = "No tags found.";
29
+
30
+ /** Reason emitted when no analyzer plugin supports the resource type. */
31
+ export const NO_ANALYZER_REASON =
32
+ "No specific analysis for this resource type.";
33
+
34
+ /**
35
+ * Prefix of the diagnostic sentences emitted by the per-resource analyzers when
36
+ * an Azure detail lookup fails (e.g. "Could not retrieve detailed NIC
37
+ * information."). They report a gap in the scan, not a cost opportunity.
38
+ */
39
+ export const DETAIL_LOOKUP_FAILURE_PREFIX = "could not retrieve detailed";
40
+
41
+ /** Prefix of the reason emitted when a resource sits outside the target region. */
42
+ export const UNPREFERRED_LOCATION_REASON = "Resource not in preferred location";
43
+
44
+ /**
45
+ * Re-categorises the findings produced from a custom analyzer's `reason`
46
+ * string, so governance and diagnostic sentences stop being reported as cost
47
+ * opportunities.
48
+ */
49
+ export function categorizeCustomFindings(findings: Finding[]): Finding[] {
50
+ return findings.map((finding) => ({
51
+ ...finding,
52
+ category: categorizeCustomReason(finding.reason),
53
+ }));
54
+ }
55
+
56
+ /** Builds the reason emitted when a resource sits outside the target region. */
57
+ export function unpreferredLocationReason(preferredLocation: string): string {
58
+ return `${UNPREFERRED_LOCATION_REASON} (${preferredLocation}).`;
59
+ }
60
+
61
+ /**
62
+ * Classifies a single custom analyzer sentence.
63
+ *
64
+ * @returns `"operationalExcellence"` for governance / diagnostic statements,
65
+ * `"cost"` for everything else (the analyzers' actual waste signals).
66
+ */
67
+ function categorizeCustomReason(reason: string): FindingCategory {
68
+ const normalized = reason.trim().toLowerCase();
69
+ const isNonCost =
70
+ normalized.startsWith(MISSING_TAGS_REASON.toLowerCase()) ||
71
+ normalized.startsWith(NO_ANALYZER_REASON.toLowerCase()) ||
72
+ normalized.startsWith(UNPREFERRED_LOCATION_REASON.toLowerCase()) ||
73
+ normalized.startsWith(DETAIL_LOOKUP_FAILURE_PREFIX);
74
+ return isNonCost ? "operationalExcellence" : "cost";
75
+ }
@@ -0,0 +1,148 @@
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
+
34
+ import type { Finding } from "../finding.js";
35
+
36
+ import {
37
+ DETAIL_LOOKUP_FAILURE_PREFIX,
38
+ MISSING_TAGS_REASON,
39
+ NO_ANALYZER_REASON,
40
+ UNPREFERRED_LOCATION_REASON,
41
+ } from "./finding-category.js";
42
+
43
+ /** Shared prefix for every stable custom finding id, mirroring `advisor.<id>` / `azqr.<id>`. */
44
+ const CODE_PREFIX = "custom.";
45
+
46
+ /**
47
+ * Ordered `(normalized reason prefix, code suffix)` pairs. Evaluated
48
+ * top-to-bottom; the first match wins. Prefixes are matched against the
49
+ * trimmed, lower-cased `reason`, so the dynamic suffixes analyzers append
50
+ * (percentages, byte counts, durations, …) never break the match.
51
+ */
52
+ const REASON_CODES: readonly (readonly [string, string])[] = [
53
+ // Generic — emitted by (almost) every per-resource analyzer.
54
+ ["resource id is missing.", "missing-resource-id"],
55
+ [DETAIL_LOOKUP_FAILURE_PREFIX, "lookup-failed"],
56
+ [MISSING_TAGS_REASON.toLowerCase(), "missing-tags"],
57
+ [NO_ANALYZER_REASON.toLowerCase(), "no-analyzer"],
58
+ [UNPREFERRED_LOCATION_REASON.toLowerCase(), "unpreferred-location"],
59
+
60
+ // Managed Disk (azure/resources/disk.ts)
61
+ ["disk is unattached.", "disk.unattached"],
62
+
63
+ // Network Interface (azure/resources/nic.ts)
64
+ ["nic not attached to any vm or private endpoint.", "nic.unattached"],
65
+ ["no public ip assigned.", "nic.no-public-ip"],
66
+
67
+ // Private Endpoint (azure/resources/private-endpoint.ts)
68
+ [
69
+ "private endpoint has no private link service connections configured.",
70
+ "private-endpoint.no-connections",
71
+ ],
72
+ [
73
+ "private endpoint has rejected or disconnected connections.",
74
+ "private-endpoint.rejected-connection",
75
+ ],
76
+ [
77
+ "private endpoint has no network interfaces attached.",
78
+ "private-endpoint.no-nics",
79
+ ],
80
+ [
81
+ "private endpoint is not associated with a subnet.",
82
+ "private-endpoint.no-subnet",
83
+ ],
84
+
85
+ // Container App (azure/resources/container-app.ts)
86
+ ["container app is not running.", "container-app.not-running"],
87
+ ["container app has 0 replicas configured.", "container-app.zero-replicas"],
88
+
89
+ // App Service Plan (azure/resources/app-service.ts)
90
+ ["app service plan has no apps deployed.", "app-service.no-apps"],
91
+ [
92
+ "premium tier with low resource utilization.",
93
+ "app-service.premium-underutilized",
94
+ ],
95
+
96
+ // Public IP (azure/resources/public-ip.ts)
97
+ ["public ip not associated with any resource.", "public-ip.unassociated"],
98
+ ["static ip not in use.", "public-ip.static-unused"],
99
+
100
+ // Storage Account (azure/resources/storage.ts)
101
+ ["very low transaction count (", "storage.low-transactions"],
102
+
103
+ // Virtual Machine (azure/resources/vm.ts) — worded distinctly ("Low ...",
104
+ // no "Very") from the shared "Very low ..." family below.
105
+ ["vm is deallocated.", "vm.deallocated"],
106
+ ["vm is stopped.", "vm.stopped"],
107
+ ["low cpu usage (avg", "vm.low-cpu-usage"],
108
+ ["low network traffic (", "vm.low-network-traffic"],
109
+
110
+ // Static Web App (azure/resources/static-web-app.ts)
111
+ ["no traffic data available in", "static-site.no-traffic-data"],
112
+ ["very low site traffic (", "static-site.low-traffic"],
113
+ ["very low data transfer (", "static-site.low-data-transfer"],
114
+
115
+ // Shared across resource types whose analyzers phrase the same signal with
116
+ // identical text — see module doc. App Service Plan + Container App for
117
+ // CPU/memory; Container App + Public IP for network traffic.
118
+ ["very low cpu usage (", "low-cpu-usage"],
119
+ ["very low memory usage (", "low-memory-usage"],
120
+ ["very low network traffic (", "low-network-traffic"],
121
+ ];
122
+
123
+ /**
124
+ * Assigns a stable `code`/`recommendationId` to every custom finding whose
125
+ * `reason` matches a known sentence template, so equivalent sentences about
126
+ * the same resource collapse via `foldDuplicateFinding` instead of appearing
127
+ * as separate rows.
128
+ *
129
+ * Leaves untouched: non-`"custom"` findings, and findings that already carry
130
+ * a `recommendationId` (nothing to add) or whose `reason` matches no known
131
+ * template (stays `"custom.unknown"`, ungrouped — the same fallback behaviour
132
+ * as before this module existed).
133
+ */
134
+ export function assignCustomFindingCodes(findings: Finding[]): Finding[] {
135
+ return findings.map((finding) => {
136
+ if (finding.source !== "custom" || finding.recommendationId) {
137
+ return finding;
138
+ }
139
+ const code = stableCodeFor(finding.reason);
140
+ return code ? { ...finding, code, recommendationId: code } : finding;
141
+ });
142
+ }
143
+
144
+ function stableCodeFor(reason: string): string | undefined {
145
+ const normalized = reason.trim().toLowerCase();
146
+ const match = REASON_CODES.find(([prefix]) => normalized.startsWith(prefix));
147
+ return match ? `${CODE_PREFIX}${match[1]}` : undefined;
148
+ }
@@ -0,0 +1,157 @@
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
+
26
+ import type { Finding, FindingSource } from "../finding.js";
27
+ import type { AzureDetailedResourceReport } from "./types.js";
28
+
29
+ import { maxCostRisk } from "../types.js";
30
+
31
+ /** Sources that observe the live subscription, as opposed to a static report. */
32
+ const LIVE_SOURCES: ReadonlySet<FindingSource> = new Set(["advisor", "custom"]);
33
+
34
+ /**
35
+ * Canonical identity prefix for "this resource is provisioned but unused".
36
+ * Shared by every source so orphan findings deduplicate across them.
37
+ */
38
+ const ORPHAN_RECOMMENDATION_PREFIX = "orphan.";
39
+
40
+ /**
41
+ * Folds `incoming` into an existing finding of `report` when both describe the
42
+ * same problem, so the resource is reported once instead of once per source.
43
+ *
44
+ * The surviving finding keeps the highest severity and the only available
45
+ * monetary estimate, and records the corroborating source both in its own
46
+ * reason and in the legacy per-resource `analysis` summary, so the `lint`,
47
+ * `json` and `table` formats all keep the provenance.
48
+ *
49
+ * @returns `true` when the finding was folded and must not be appended.
50
+ */
51
+ export function foldDuplicateFinding(
52
+ incoming: Finding,
53
+ report: AzureDetailedResourceReport,
54
+ ): boolean {
55
+ const findings = report.findings;
56
+ if (!findings) {
57
+ return false;
58
+ }
59
+ const surviving = findDuplicateFinding(incoming, findings);
60
+ if (!surviving) {
61
+ return false;
62
+ }
63
+ const note = corroborationNote(surviving, incoming);
64
+ findings[findings.indexOf(surviving)] = {
65
+ ...surviving,
66
+ estimatedMonthlySavings:
67
+ surviving.estimatedMonthlySavings ?? incoming.estimatedMonthlySavings,
68
+ reason: note ? `${surviving.reason.trimEnd()} ${note}` : surviving.reason,
69
+ recommendationId: surviving.recommendationId ?? incoming.recommendationId,
70
+ severity: maxCostRisk(surviving.severity, incoming.severity),
71
+ };
72
+ report.analysis = {
73
+ ...report.analysis,
74
+ costRisk: maxCostRisk(report.analysis.costRisk, incoming.severity),
75
+ reason: note
76
+ ? `${report.analysis.reason.trimEnd()} ${note}`.trim()
77
+ : report.analysis.reason,
78
+ };
79
+ return true;
80
+ }
81
+
82
+ /**
83
+ * Builds the canonical identity of an orphaned resource of the given type,
84
+ * e.g. `orphan.microsoft.compute/disks`.
85
+ */
86
+ export function orphanRecommendationId(resourceType: string): string {
87
+ return `${ORPHAN_RECOMMENDATION_PREFIX}${resourceType.toLowerCase()}`;
88
+ }
89
+
90
+ /**
91
+ * Short sentence appended to the surviving finding so the reader still sees
92
+ * that a second source flagged the same resource. Omitted for same-source
93
+ * duplicates, where there is no extra provenance to record, and when the same
94
+ * note is already there — repeated rows must corroborate, not accumulate.
95
+ */
96
+ function corroborationNote(
97
+ surviving: Finding,
98
+ incoming: Finding,
99
+ ): string | undefined {
100
+ if (surviving.source === incoming.source) {
101
+ return undefined;
102
+ }
103
+ const note = isOrphanRecommendation(incoming.recommendationId)
104
+ ? `Also flagged by ${incoming.source} as an orphaned resource.`
105
+ : `Also flagged by ${incoming.source}.`;
106
+ return surviving.reason.includes(note) ? undefined : note;
107
+ }
108
+
109
+ /**
110
+ * Returns the already-collected finding that covers the same problem as
111
+ * `incoming`, or `undefined` when the incoming finding is genuinely new.
112
+ *
113
+ * Two findings describe the same problem when either:
114
+ *
115
+ * 1. they share the exact canonical identity (`recommendationId`) on the same
116
+ * resource — a true duplicate, e.g. the same AZQR row ingested twice; or
117
+ * 2. `incoming` reports the resource as orphaned and a live source already
118
+ * reported billable waste on it with no stronger identity of its own (e.g.
119
+ * a sentence-level custom finding with no `recommendationId`). An orphaned
120
+ * resource has a single cost problem — it exists — so the live finding,
121
+ * which can carry a monetary estimate, supersedes the static one. A live
122
+ * finding that already carries its own distinct `recommendationId` (e.g.
123
+ * an Advisor right-sizing recommendation) is a different problem and is
124
+ * never absorbed this way.
125
+ *
126
+ * Findings without a `recommendationId` never match: sentences whose text
127
+ * matches no known template (still `code: "custom.unknown"`, see
128
+ * `finding-code.ts`) must not be collapsed on guesswork.
129
+ */
130
+ function findDuplicateFinding(
131
+ incoming: Finding,
132
+ existing: readonly Finding[],
133
+ ): Finding | undefined {
134
+ const identity = incoming.recommendationId?.toLowerCase();
135
+ if (!identity) {
136
+ return undefined;
137
+ }
138
+ const sameIdentity = existing.find(
139
+ (candidate) => candidate.recommendationId?.toLowerCase() === identity,
140
+ );
141
+ if (sameIdentity) {
142
+ return sameIdentity;
143
+ }
144
+ if (!isOrphanRecommendation(incoming.recommendationId)) {
145
+ return undefined;
146
+ }
147
+ return existing.find(
148
+ (candidate) =>
149
+ candidate.category === "cost" &&
150
+ LIVE_SOURCES.has(candidate.source) &&
151
+ !candidate.recommendationId,
152
+ );
153
+ }
154
+
155
+ function isOrphanRecommendation(recommendationId: string | undefined): boolean {
156
+ return (recommendationId ?? "").startsWith(ORPHAN_RECOMMENDATION_PREFIX);
157
+ }
package/src/finding.ts CHANGED
@@ -16,7 +16,8 @@ import type { CostRisk } from "./types.js";
16
16
  * A single, atomic observation about a resource.
17
17
  *
18
18
  * One resource can produce multiple findings (e.g. "no tags" + "low CPU").
19
- * Findings are designed to be deduplicated by `(resourceId, source, code)`.
19
+ * Findings are deduplicated on `(resourceId, recommendationId)` — see
20
+ * {@link Finding.recommendationId}.
20
21
  */
21
22
  export type Finding = {
22
23
  /**
@@ -27,8 +28,8 @@ export type Finding = {
27
28
  /**
28
29
  * Stable machine-readable identifier for the kind of finding, e.g.
29
30
  * `vm.deallocated`, `disk.unattached`, `advisor.right-size-vm`.
30
- * Used for deduplication and grouping. Free-form for now to keep the
31
- * adapter from existing analyzers cheap; can be tightened later.
31
+ * Free-form and source-specific: use `recommendationId` for
32
+ * deduplication across sources.
32
33
  */
33
34
  code: string;
34
35
  /**
@@ -42,6 +43,19 @@ export type Finding = {
42
43
  * legacy `AnalysisResult.reason` field.
43
44
  */
44
45
  reason: string;
46
+ /**
47
+ * Canonical, source-independent identity of the underlying problem.
48
+ * Together with `resourceId` it forms the deduplication key, so the same
49
+ * waste reported by different sources collapses into a single finding
50
+ * (see `azure/finding-dedup.ts`).
51
+ *
52
+ * Producers normalise it whenever a shared vocabulary exists — orphaned
53
+ * resources use `orphan.<resourceType>` regardless of the source that
54
+ * detected them — and fall back to their native recommendation ID
55
+ * otherwise. Left unset when the source has no stable identifier: such
56
+ * findings are never deduplicated.
57
+ */
58
+ recommendationId?: string;
45
59
  /**
46
60
  * Optional, machine-friendly hint about how to remediate. For Advisor
47
61
  * this typically maps to `shortDescription.solution`.
@@ -91,10 +105,8 @@ export type Money = {
91
105
  /**
92
106
  * Aggregate view: one resource with the list of findings emitted for it.
93
107
  *
94
- * This is the type future report generators should consume. The current
95
- * report layer still works on `AzureDetailedResourceReport`; a helper
96
- * (`legacyReportFromResourceReport`) bridges the two until the report
97
- * layer is migrated.
108
+ * This is the type future report generators should consume; the current
109
+ * report layer still works on `AzureDetailedResourceReport`.
98
110
  */
99
111
  export type ResourceReport<TResource = unknown> = {
100
112
  findings: Finding[];
package/src/types.ts CHANGED
@@ -29,6 +29,13 @@ export type BaseConfig = {
29
29
 
30
30
  export type CostRisk = "high" | "low" | "medium";
31
31
 
32
+ /** Cost risk ordering, from the most to the least severe. */
33
+ export const COST_RISK_ORDER: Record<CostRisk, number> = {
34
+ high: 0,
35
+ low: 2,
36
+ medium: 1,
37
+ };
38
+
32
39
  /**
33
40
  * Configurable thresholds used during resource analysis.
34
41
  * Derived from `ThresholdsSchema` — the schema is the single source of truth.
@@ -41,6 +48,15 @@ export type { Thresholds } from "./schema.js";
41
48
  */
42
49
  export const DEFAULT_THRESHOLDS = ThresholdsSchema.parse({});
43
50
 
51
+ /**
52
+ * Returns the more severe of two cost risks. Used whenever two observations
53
+ * about the same resource (or the same problem seen by two sources) are
54
+ * combined, so severity never degrades silently.
55
+ */
56
+ export function maxCostRisk(a: CostRisk, b: CostRisk): CostRisk {
57
+ return COST_RISK_ORDER[a] <= COST_RISK_ORDER[b] ? a : b;
58
+ }
59
+
44
60
  /**
45
61
  * Merges analysis results, preserving existing reasons and combining suspectedUnused flags.
46
62
  */