@pagopa/dx-savemoney 0.2.5 → 0.2.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 (80) hide show
  1. package/dist/azure/analyzer.d.ts +31 -16
  2. package/dist/azure/analyzer.d.ts.map +1 -1
  3. package/dist/azure/analyzer.js +110 -81
  4. package/dist/azure/analyzer.js.map +1 -1
  5. package/dist/azure/analyzers/index.d.ts +6 -0
  6. package/dist/azure/analyzers/index.d.ts.map +1 -0
  7. package/dist/azure/analyzers/index.js +5 -0
  8. package/dist/azure/analyzers/index.js.map +1 -0
  9. package/dist/azure/analyzers/registry.d.ts +21 -0
  10. package/dist/azure/analyzers/registry.d.ts.map +1 -0
  11. package/dist/azure/analyzers/registry.js +69 -0
  12. package/dist/azure/analyzers/registry.js.map +1 -0
  13. package/dist/azure/analyzers/types.d.ts +62 -0
  14. package/dist/azure/analyzers/types.d.ts.map +1 -0
  15. package/dist/azure/analyzers/types.js +15 -0
  16. package/dist/azure/analyzers/types.js.map +1 -0
  17. package/dist/azure/config.d.ts.map +1 -1
  18. package/dist/azure/config.js +1 -0
  19. package/dist/azure/config.js.map +1 -1
  20. package/dist/azure/resources/app-service.d.ts +2 -1
  21. package/dist/azure/resources/app-service.d.ts.map +1 -1
  22. package/dist/azure/resources/app-service.js +3 -3
  23. package/dist/azure/resources/app-service.js.map +1 -1
  24. package/dist/azure/resources/container-app.d.ts +2 -1
  25. package/dist/azure/resources/container-app.d.ts.map +1 -1
  26. package/dist/azure/resources/container-app.js +9 -9
  27. package/dist/azure/resources/container-app.js.map +1 -1
  28. package/dist/azure/resources/public-ip.d.ts +2 -1
  29. package/dist/azure/resources/public-ip.d.ts.map +1 -1
  30. package/dist/azure/resources/public-ip.js +2 -2
  31. package/dist/azure/resources/public-ip.js.map +1 -1
  32. package/dist/azure/resources/static-web-app.d.ts +2 -1
  33. package/dist/azure/resources/static-web-app.d.ts.map +1 -1
  34. package/dist/azure/resources/static-web-app.js +3 -3
  35. package/dist/azure/resources/static-web-app.js.map +1 -1
  36. package/dist/azure/resources/storage.d.ts +2 -1
  37. package/dist/azure/resources/storage.d.ts.map +1 -1
  38. package/dist/azure/resources/storage.js +2 -2
  39. package/dist/azure/resources/storage.js.map +1 -1
  40. package/dist/azure/resources/vm.d.ts +2 -1
  41. package/dist/azure/resources/vm.d.ts.map +1 -1
  42. package/dist/azure/resources/vm.js +3 -3
  43. package/dist/azure/resources/vm.js.map +1 -1
  44. package/dist/azure/types.d.ts +6 -0
  45. package/dist/azure/types.d.ts.map +1 -1
  46. package/dist/azure/utils.d.ts +35 -3
  47. package/dist/azure/utils.d.ts.map +1 -1
  48. package/dist/azure/utils.js +70 -29
  49. package/dist/azure/utils.js.map +1 -1
  50. package/dist/finding.d.ts +114 -0
  51. package/dist/finding.d.ts.map +1 -0
  52. package/dist/finding.js +51 -0
  53. package/dist/finding.js.map +1 -0
  54. package/dist/index.d.ts +3 -0
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +3 -0
  57. package/dist/index.js.map +1 -1
  58. package/dist/schema.d.ts +1 -0
  59. package/dist/schema.d.ts.map +1 -1
  60. package/dist/schema.js +5 -0
  61. package/dist/schema.js.map +1 -1
  62. package/package.json +2 -1
  63. package/src/__tests__/finding.test.ts +149 -0
  64. package/src/azure/__tests__/utils.test.ts +164 -2
  65. package/src/azure/analyzer.ts +140 -165
  66. package/src/azure/analyzers/index.ts +6 -0
  67. package/src/azure/analyzers/registry.ts +184 -0
  68. package/src/azure/analyzers/types.ts +66 -0
  69. package/src/azure/config.ts +1 -0
  70. package/src/azure/resources/app-service.ts +4 -0
  71. package/src/azure/resources/container-app.ts +10 -0
  72. package/src/azure/resources/public-ip.ts +3 -0
  73. package/src/azure/resources/static-web-app.ts +4 -0
  74. package/src/azure/resources/storage.ts +3 -0
  75. package/src/azure/resources/vm.ts +4 -0
  76. package/src/azure/types.ts +6 -0
  77. package/src/azure/utils.ts +110 -39
  78. package/src/finding.ts +152 -0
  79. package/src/index.ts +18 -0
  80. package/src/schema.ts +5 -0
@@ -13,6 +13,7 @@ import type { AnalysisResult, Thresholds } from "../../types.js";
13
13
  import { DEFAULT_THRESHOLDS } from "../../types.js";
14
14
  import {
15
15
  getMetric,
16
+ type MetricsCache,
16
17
  verboseLog,
17
18
  verboseLogAnalysisResult,
18
19
  verboseLogResourceStart,
@@ -35,6 +36,7 @@ export async function analyzeContainerApp(
35
36
  timespanDays: number,
36
37
  thresholds: Thresholds = DEFAULT_THRESHOLDS,
37
38
  verbose = false,
39
+ cache?: MetricsCache,
38
40
  ): Promise<AnalysisResult> {
39
41
  verboseLogResourceStart(
40
42
  verbose,
@@ -84,6 +86,7 @@ export async function analyzeContainerApp(
84
86
  thresholds,
85
87
  reason,
86
88
  verbose,
89
+ cache,
87
90
  );
88
91
  reason = await checkNetworkMetrics(
89
92
  resource,
@@ -92,6 +95,7 @@ export async function analyzeContainerApp(
92
95
  thresholds,
93
96
  reason,
94
97
  verbose,
98
+ cache,
95
99
  );
96
100
  } catch (error) {
97
101
  const logger = getLogger(["savemoney", "azure"]);
@@ -117,6 +121,7 @@ async function checkNetworkMetrics(
117
121
  thresholds: Thresholds,
118
122
  reason: string,
119
123
  verbose: boolean,
124
+ cache?: MetricsCache,
120
125
  ): Promise<string> {
121
126
  let newReason = reason;
122
127
 
@@ -132,6 +137,7 @@ async function checkNetworkMetrics(
132
137
  "RxBytes",
133
138
  "Average",
134
139
  timespanDays,
140
+ cache,
135
141
  );
136
142
 
137
143
  const networkOut = await getMetric(
@@ -140,6 +146,7 @@ async function checkNetworkMetrics(
140
146
  "TxBytes",
141
147
  "Average",
142
148
  timespanDays,
149
+ cache,
143
150
  );
144
151
 
145
152
  verboseLog(
@@ -172,6 +179,7 @@ async function checkResourceMetrics(
172
179
  thresholds: Thresholds,
173
180
  reason: string,
174
181
  verbose: boolean,
182
+ cache?: MetricsCache,
175
183
  ): Promise<string> {
176
184
  let newReason = reason;
177
185
 
@@ -187,6 +195,7 @@ async function checkResourceMetrics(
187
195
  "UsageNanoCores",
188
196
  "Average",
189
197
  timespanDays,
198
+ cache,
190
199
  );
191
200
 
192
201
  const memoryUsage = await getMetric(
@@ -195,6 +204,7 @@ async function checkResourceMetrics(
195
204
  "WorkingSetBytes",
196
205
  "Average",
197
206
  timespanDays,
207
+ cache,
198
208
  );
199
209
 
200
210
  verboseLog(
@@ -13,6 +13,7 @@ import type { AnalysisResult, Thresholds } from "../../types.js";
13
13
  import { DEFAULT_THRESHOLDS } from "../../types.js";
14
14
  import {
15
15
  getMetric,
16
+ type MetricsCache,
16
17
  verboseLog,
17
18
  verboseLogAnalysisResult,
18
19
  verboseLogResourceStart,
@@ -34,6 +35,7 @@ export async function analyzePublicIp(
34
35
  timespanDays: number,
35
36
  thresholds: Thresholds = DEFAULT_THRESHOLDS,
36
37
  verbose = false,
38
+ cache?: MetricsCache,
37
39
  ): Promise<AnalysisResult> {
38
40
  verboseLogResourceStart(
39
41
  verbose,
@@ -87,6 +89,7 @@ export async function analyzePublicIp(
87
89
  "BytesInDDoS",
88
90
  "Average",
89
91
  timespanDays,
92
+ cache,
90
93
  );
91
94
 
92
95
  if (bytesInDDoS !== null && bytesInDDoS < thresholds.publicIp.bytesInDDoS) {
@@ -12,6 +12,7 @@ import type { AnalysisResult, Thresholds } from "../../types.js";
12
12
  import { DEFAULT_THRESHOLDS } from "../../types.js";
13
13
  import {
14
14
  getMetric,
15
+ type MetricsCache,
15
16
  verboseLog,
16
17
  verboseLogAnalysisResult,
17
18
  verboseLogResourceStart,
@@ -32,6 +33,7 @@ export async function analyzeStaticSite(
32
33
  timespanDays: number,
33
34
  thresholds: Thresholds = DEFAULT_THRESHOLDS,
34
35
  verbose = false,
36
+ cache?: MetricsCache,
35
37
  ): Promise<AnalysisResult> {
36
38
  verboseLogResourceStart(
37
39
  verbose,
@@ -63,6 +65,7 @@ export async function analyzeStaticSite(
63
65
  "SiteHits",
64
66
  "Total",
65
67
  timespanDays,
68
+ cache,
66
69
  );
67
70
 
68
71
  const bytesSent = await getMetric(
@@ -71,6 +74,7 @@ export async function analyzeStaticSite(
71
74
  "BytesSent",
72
75
  "Total",
73
76
  timespanDays,
77
+ cache,
74
78
  );
75
79
 
76
80
  verboseLog(
@@ -11,6 +11,7 @@ import type { AnalysisResult, Thresholds } from "../../types.js";
11
11
  import { DEFAULT_THRESHOLDS } from "../../types.js";
12
12
  import {
13
13
  getMetric,
14
+ type MetricsCache,
14
15
  verboseLog,
15
16
  verboseLogAnalysisResult,
16
17
  verboseLogResourceStart,
@@ -30,6 +31,7 @@ export async function analyzeStorageAccount(
30
31
  timespanDays: number,
31
32
  thresholds: Thresholds = DEFAULT_THRESHOLDS,
32
33
  verbose = false,
34
+ cache?: MetricsCache,
33
35
  ): Promise<AnalysisResult> {
34
36
  verboseLogResourceStart(
35
37
  verbose,
@@ -52,6 +54,7 @@ export async function analyzeStorageAccount(
52
54
  "Transactions",
53
55
  "Average",
54
56
  timespanDays,
57
+ cache,
55
58
  );
56
59
  if (
57
60
  transactions !== null &&
@@ -13,6 +13,7 @@ import type { AnalysisResult, Thresholds } from "../../types.js";
13
13
  import { DEFAULT_THRESHOLDS } from "../../types.js";
14
14
  import {
15
15
  getMetric,
16
+ type MetricsCache,
16
17
  verboseLog,
17
18
  verboseLogAnalysisResult,
18
19
  verboseLogResourceStart,
@@ -35,6 +36,7 @@ export async function analyzeVM(
35
36
  timespanDays: number,
36
37
  thresholds: Thresholds = DEFAULT_THRESHOLDS,
37
38
  verbose = false,
39
+ cache?: MetricsCache,
38
40
  ): Promise<AnalysisResult> {
39
41
  verboseLogResourceStart(
40
42
  verbose,
@@ -107,6 +109,7 @@ export async function analyzeVM(
107
109
  "Percentage CPU",
108
110
  "Average",
109
111
  timespanDays,
112
+ cache,
110
113
  );
111
114
  const networkIn = await getMetric(
112
115
  monitorClient,
@@ -114,6 +117,7 @@ export async function analyzeVM(
114
117
  "Network In Total",
115
118
  "Average",
116
119
  timespanDays,
120
+ cache,
117
121
  );
118
122
 
119
123
  if (cpuUsage !== null && cpuUsage < thresholds.vm.cpuPercent) {
@@ -15,6 +15,12 @@ import type {
15
15
  * Azure configuration extending base config
16
16
  */
17
17
  export type AzureConfig = BaseConfig & {
18
+ /**
19
+ * Maximum number of resources analyzed in parallel within a single
20
+ * subscription. Defaults to 8 when not provided. Set to 1 for a fully
21
+ * sequential run (useful for debugging or to be gentler on quotas).
22
+ */
23
+ concurrency?: number;
18
24
  /**
19
25
  * Only analyze resources that match ALL the given tag key-value pairs.
20
26
  * If omitted, all resources are analyzed.
@@ -9,6 +9,19 @@ import { getLogger } from "@logtape/logtape";
9
9
 
10
10
  import type { AnalysisResult } from "../types.js";
11
11
 
12
+ /** Per-run in-memory cache for Azure Monitor metric responses. */
13
+ export type MetricsCache = Map<string, Promise<null | number>>;
14
+
15
+ /**
16
+ * Minimal interface required by `getMetric` — only the `metrics.list` shape.
17
+ * Using a structural type instead of the full `MonitorClient` keeps tests
18
+ * strongly typed without unsafe casts and lets non-Azure callers supply a
19
+ * compatible mock.
20
+ */
21
+ export type MonitorClientLike = {
22
+ metrics: Pick<MonitorClient["metrics"], "list">;
23
+ };
24
+
12
25
  type MetricDataPoint = {
13
26
  average?: number;
14
27
  count?: number;
@@ -104,59 +117,61 @@ export function extractAggregatedValue(
104
117
  }
105
118
 
106
119
  /**
107
- * Fetches a specific metric for a resource from Azure Monitor.
120
+ * Module-scoped fallback cache. Used when callers of `getMetric` do not
121
+ * supply a run-scoped cache. Prefer passing an explicit `MetricsCache`
122
+ * instance through `AnalyzerContext` so concurrent runs stay isolated.
123
+ */
124
+ const metricsCache: MetricsCache = new Map();
125
+
126
+ /**
127
+ * @internal — exposed for tests only.
128
+ */
129
+ export function _metricsCacheSize(): number {
130
+ return metricsCache.size;
131
+ }
132
+
133
+ /**
134
+ * Fetches a specific metric for a resource from Azure Monitor, with an
135
+ * in-memory cache to deduplicate concurrent and repeated lookups within
136
+ * the same run.
137
+ *
138
+ * Concurrent callers for the same `(resourceId, metricName, aggregation,
139
+ * timespanDays)` tuple share the same underlying request.
108
140
  *
109
- * @param monitorClient - The Azure Monitor client instance
141
+ * Pass an explicit `cache` (created per run in the orchestrator) to keep
142
+ * concurrent analysis runs isolated from each other. When omitted, the
143
+ * module-scoped fallback cache is used — safe for sequential runs.
144
+ *
145
+ * @param monitorClient - Azure Monitor client (or compatible mock)
110
146
  * @param resourceId - The Azure resource ID
111
147
  * @param metricName - The name of the metric to fetch (e.g., "Percentage CPU")
112
148
  * @param aggregation - The aggregation type (e.g., "Average", "Total")
113
149
  * @param timespanDays - Number of days to look back for metrics
150
+ * @param cache - Optional run-scoped cache; falls back to the module-scoped one
114
151
  * @returns The metric value or null if unavailable
115
152
  */
116
153
  export async function getMetric(
117
- monitorClient: MonitorClient,
154
+ monitorClient: MonitorClientLike,
118
155
  resourceId: string,
119
156
  metricName: string,
120
157
  aggregation: string,
121
158
  timespanDays: number,
159
+ cache: MetricsCache = metricsCache,
122
160
  ): Promise<null | number> {
123
- try {
124
- const timespan = `P${timespanDays}D`;
125
- const result = await monitorClient.metrics.list(resourceId, {
126
- aggregation,
127
- metricnames: metricName,
128
- timespan,
129
- });
130
-
131
- if (result.value.length === 0) {
132
- return null;
133
- }
134
-
135
- const metric = result.value[0];
136
-
137
- if (!metric.timeseries || metric.timeseries.length === 0) {
138
- return null;
139
- }
140
-
141
- const timeserie = metric.timeseries[0];
142
-
143
- if (!timeserie.data || timeserie.data.length === 0) {
144
- return null;
145
- }
146
-
147
- const aggregatedValue = aggregateDataPoints(
148
- timeserie.data as MetricDataPoint[],
149
- aggregation,
150
- );
151
-
152
- return aggregatedValue;
153
- } catch (error) {
154
- const logger = getLogger(["savemoney", "azure", "metrics"]);
155
- logger.error(
156
- `Failed to fetch metric ${metricName} for resource ${resourceId}: ${error instanceof Error ? error.message : String(error)}`,
157
- );
158
- return null;
161
+ const key = `${resourceId}|${metricName}|${aggregation}|${timespanDays}`;
162
+ const cached = cache.get(key);
163
+ if (cached !== undefined) {
164
+ return cached;
159
165
  }
166
+ const promise = fetchMetric(
167
+ monitorClient,
168
+ resourceId,
169
+ metricName,
170
+ aggregation,
171
+ timespanDays,
172
+ );
173
+ cache.set(key, promise);
174
+ return promise;
160
175
  }
161
176
 
162
177
  /**
@@ -179,6 +194,16 @@ export function matchesTags(
179
194
  );
180
195
  }
181
196
 
197
+ /**
198
+ * Clears the module-scoped fallback metrics cache.
199
+ *
200
+ * Only needed when `getMetric` is called without an explicit `cache`
201
+ * argument. Prefer passing a run-scoped `MetricsCache` instead.
202
+ */
203
+ export function resetMetricsCache(): void {
204
+ metricsCache.clear();
205
+ }
206
+
182
207
  /**
183
208
  * Logs a verbose message, optionally with an object.
184
209
  *
@@ -243,3 +268,49 @@ export function verboseLogResourceStart(
243
268
  logger.debug("=".repeat(80));
244
269
  }
245
270
  }
271
+
272
+ async function fetchMetric(
273
+ monitorClient: MonitorClientLike,
274
+ resourceId: string,
275
+ metricName: string,
276
+ aggregation: string,
277
+ timespanDays: number,
278
+ ): Promise<null | number> {
279
+ try {
280
+ const timespan = `P${timespanDays}D`;
281
+ const result = await monitorClient.metrics.list(resourceId, {
282
+ aggregation,
283
+ metricnames: metricName,
284
+ timespan,
285
+ });
286
+
287
+ if (result.value.length === 0) {
288
+ return null;
289
+ }
290
+
291
+ const metric = result.value[0];
292
+
293
+ if (!metric.timeseries || metric.timeseries.length === 0) {
294
+ return null;
295
+ }
296
+
297
+ const timeserie = metric.timeseries[0];
298
+
299
+ if (!timeserie.data || timeserie.data.length === 0) {
300
+ return null;
301
+ }
302
+
303
+ const aggregatedValue = aggregateDataPoints(
304
+ timeserie.data as MetricDataPoint[],
305
+ aggregation,
306
+ );
307
+
308
+ return aggregatedValue;
309
+ } catch (error) {
310
+ const logger = getLogger(["savemoney", "azure", "metrics"]);
311
+ logger.error(
312
+ `Failed to fetch metric ${metricName} for resource ${resourceId}: ${error instanceof Error ? error.message : String(error)}`,
313
+ );
314
+ return null;
315
+ }
316
+ }
package/src/finding.ts ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Unified Finding model — the single representation of a cost-related
3
+ * observation, regardless of its source (custom analyzers, Azure Advisor,
4
+ * future AWS Trusted Advisor, …).
5
+ *
6
+ * Introduced in Phase 0 of the savemoney evolution roadmap. Existing
7
+ * `AnalysisResult`-based analyzers keep working untouched: an adapter
8
+ * (`findingsFromAnalysisResult`) splits the concatenated `reason` string
9
+ * into one `Finding` per sentence, so downstream consumers can already
10
+ * reason in terms of `Finding[]`.
11
+ */
12
+
13
+ import type { CostRisk } from "./types.js";
14
+
15
+ /**
16
+ * A single, atomic observation about a resource.
17
+ *
18
+ * One resource can produce multiple findings (e.g. "no tags" + "low CPU").
19
+ * Findings are designed to be deduplicated by `(resourceId, source, code)`.
20
+ */
21
+ export type Finding = {
22
+ /**
23
+ * Cloud category. Today every custom finding is "cost"; Advisor may
24
+ * surface other categories that we currently ignore.
25
+ */
26
+ category: FindingCategory;
27
+ /**
28
+ * Stable machine-readable identifier for the kind of finding, e.g.
29
+ * `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.
32
+ */
33
+ code: string;
34
+ /**
35
+ * Estimated monthly cost that could be recovered by acting on this
36
+ * finding. Populated by Advisor and (in later phases) by the Retail
37
+ * Prices integration. Absent when the analyzer cannot estimate it.
38
+ */
39
+ estimatedMonthlySavings?: Money;
40
+ /**
41
+ * Free-text, human-readable description. Backward compatible with the
42
+ * legacy `AnalysisResult.reason` field.
43
+ */
44
+ reason: string;
45
+ /**
46
+ * Optional, machine-friendly hint about how to remediate. For Advisor
47
+ * this typically maps to `shortDescription.solution`.
48
+ */
49
+ recommendedAction?: string;
50
+ /**
51
+ * Fully qualified Azure / cloud resource ID this finding refers to.
52
+ */
53
+ resourceId: string;
54
+ /**
55
+ * Cost risk classification. Maps Advisor's `impact` (High/Medium/Low)
56
+ * 1:1 onto the savemoney scale.
57
+ */
58
+ severity: CostRisk;
59
+ /**
60
+ * Provenance of the finding.
61
+ */
62
+ source: FindingSource;
63
+ };
64
+
65
+ /**
66
+ * Cloud category a finding belongs to. For now we focus on cost, but the
67
+ * model is open to future Advisor categories.
68
+ */
69
+ export type FindingCategory =
70
+ | "cost"
71
+ | "operationalExcellence"
72
+ | "performance"
73
+ | "reliability"
74
+ | "security";
75
+
76
+ /**
77
+ * Where the finding originated from.
78
+ *
79
+ * - `custom` → emitted by a savemoney analyzer plugin
80
+ * - `advisor` → fetched from Azure Advisor recommendations
81
+ * - `aws` → reserved for future AWS Trusted Advisor / Compute Optimizer
82
+ */
83
+ export type FindingSource = "advisor" | "aws" | "custom";
84
+
85
+ /**
86
+ * Monetary value associated with a finding, when known.
87
+ * Amounts use ISO 4217 currency codes (e.g. "EUR", "USD").
88
+ */
89
+ export type Money = {
90
+ amount: number;
91
+ currency: string;
92
+ };
93
+
94
+ /**
95
+ * Aggregate view: one resource with the list of findings emitted for it.
96
+ *
97
+ * This is the type future report generators should consume. The current
98
+ * report layer still works on `AzureDetailedResourceReport`; a helper
99
+ * (`legacyReportFromResourceReport`) bridges the two until the report
100
+ * layer is migrated.
101
+ */
102
+ export type ResourceReport<TResource = unknown> = {
103
+ findings: Finding[];
104
+ resource: TResource;
105
+ };
106
+
107
+ /**
108
+ * Adapter: converts a legacy `AnalysisResult` (single concatenated
109
+ * reason) into a list of `Finding`s, one per sentence.
110
+ *
111
+ * @param resourceId Fully qualified resource ID
112
+ * @param severity Cost risk classification produced by the analyzer
113
+ * @param reason Concatenated reason string (sentences joined by ". ")
114
+ * @param source Provenance (default: "custom")
115
+ * @param code Optional stable identifier for the finding kind
116
+ * (e.g. `"vm.deallocated"`). Defaults to
117
+ * `"custom.unknown"` when omitted.
118
+ */
119
+ export function findingsFromAnalysisResult(args: {
120
+ code?: string;
121
+ reason: string;
122
+ resourceId: string;
123
+ severity: CostRisk;
124
+ source?: FindingSource;
125
+ }): Finding[] {
126
+ const { code, reason, resourceId, severity, source = "custom" } = args;
127
+ const sentences = splitReasonIntoSentences(reason);
128
+ if (sentences.length === 0) {
129
+ return [];
130
+ }
131
+ return sentences.map((sentence) => ({
132
+ category: "cost" as const,
133
+ code: code ?? "custom.unknown",
134
+ reason: sentence,
135
+ resourceId,
136
+ severity,
137
+ source,
138
+ }));
139
+ }
140
+
141
+ /**
142
+ * Splits a concatenated reason string (sentences joined by ". ") into
143
+ * individual non-empty sentences. Mirrors the logic already used by the
144
+ * lint reporter so behaviour stays consistent.
145
+ */
146
+ function splitReasonIntoSentences(reason: string): string[] {
147
+ return reason
148
+ .split(/\.\s+|\.$/)
149
+ .map((s) => s.trim())
150
+ .filter((s) => s.length > 0)
151
+ .map((s) => (s.endsWith(".") ? s : `${s}.`));
152
+ }
package/src/index.ts CHANGED
@@ -13,6 +13,13 @@
13
13
  * This tool does NOT modify, tag, or delete any resources.
14
14
  */
15
15
 
16
+ export {
17
+ type Analyzer,
18
+ type AnalyzerContext,
19
+ type AzureClients,
20
+ createDefaultAnalyzers,
21
+ } from "./azure/analyzers/index.js";
22
+
16
23
  // Export common types
17
24
  export type { AzureConfig } from "./azure/types.js";
18
25
 
@@ -20,6 +27,17 @@ export type { AzureConfig } from "./azure/types.js";
20
27
  import * as azureModule from "./azure/index.js";
21
28
  export const azure = azureModule;
22
29
 
30
+ export { type MetricsCache, type MonitorClientLike } from "./azure/utils.js";
31
+
32
+ // Phase 0: unified Finding model and analyzer plugin layer
33
+ export {
34
+ type Finding,
35
+ type FindingCategory,
36
+ findingsFromAnalysisResult,
37
+ type FindingSource,
38
+ type Money,
39
+ type ResourceReport,
40
+ } from "./finding.js";
23
41
  export * from "./types.js";
24
42
 
25
43
  import type { AzureConfig } from "./azure/types.js";
package/src/schema.ts CHANGED
@@ -105,6 +105,11 @@ export const ThresholdsSchema = z
105
105
 
106
106
  const AzureSectionSchema = z
107
107
  .object({
108
+ /**
109
+ * Maximum number of resources analyzed in parallel within a single
110
+ * subscription. Defaults to 8 when not provided.
111
+ */
112
+ concurrency: z.number().int().positive().optional(),
108
113
  preferredLocation: z.string().default("italynorth"),
109
114
  subscriptionIds: z
110
115
  .array(z.string())