@kabran-tecnologia/kabran-config 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,245 @@
1
+ /**
2
+ * CI Result History Module
3
+ *
4
+ * Manages historical CI run data for trend analysis.
5
+ * Stores up to 30 recent runs in docs/quality/ci-result-history.json.
6
+ *
7
+ * @module ci-result-history
8
+ */
9
+
10
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
11
+ import { dirname } from 'node:path'
12
+
13
+ /**
14
+ * Default maximum number of history entries to keep
15
+ */
16
+ export const DEFAULT_MAX_ENTRIES = 30
17
+
18
+ /**
19
+ * Extract a minimal history entry from a full CI result
20
+ *
21
+ * @param {Object} result - Full CI result object
22
+ * @returns {Object} Minimal history entry
23
+ */
24
+ export function extractHistoryEntry(result) {
25
+ return {
26
+ run_id: result.meta?.run_id || 'unknown',
27
+ generated_at: result.meta?.generated_at || new Date().toISOString(),
28
+ branch: result.meta?.branch || 'unknown',
29
+ commit: result.meta?.commit || 'unknown',
30
+ score: result.summary?.score ?? 0,
31
+ status: result.summary?.status || 'unknown',
32
+ timing: {
33
+ total_ms: result.timing?.total_ms || 0,
34
+ total_human: result.timing?.total_human || '0ms',
35
+ },
36
+ summary: {
37
+ exit_code: result.summary?.exit_code ?? 0,
38
+ total_issues: result.summary?.total_issues ?? 0,
39
+ blocking: result.summary?.blocking ?? 0,
40
+ warnings: result.summary?.warnings ?? 0,
41
+ },
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Load history from file
47
+ *
48
+ * @param {string} filePath - Path to history file
49
+ * @returns {Object} History object with entries array and metadata
50
+ */
51
+ export function loadHistory(filePath) {
52
+ if (!existsSync(filePath)) {
53
+ return {
54
+ $schema: 'https://kabran.dev/schemas/ci-result-history.json',
55
+ version: '1.0.0',
56
+ meta: {
57
+ created_at: new Date().toISOString(),
58
+ updated_at: new Date().toISOString(),
59
+ max_entries: DEFAULT_MAX_ENTRIES,
60
+ },
61
+ entries: [],
62
+ }
63
+ }
64
+
65
+ try {
66
+ const content = readFileSync(filePath, 'utf8')
67
+ const history = JSON.parse(content)
68
+
69
+ // Ensure entries is an array
70
+ if (!Array.isArray(history.entries)) {
71
+ history.entries = []
72
+ }
73
+
74
+ return history
75
+ } catch (error) {
76
+ // If file is corrupted, start fresh
77
+ return {
78
+ $schema: 'https://kabran.dev/schemas/ci-result-history.json',
79
+ version: '1.0.0',
80
+ meta: {
81
+ created_at: new Date().toISOString(),
82
+ updated_at: new Date().toISOString(),
83
+ max_entries: DEFAULT_MAX_ENTRIES,
84
+ error: `Failed to load: ${error.message}`,
85
+ },
86
+ entries: [],
87
+ }
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Prune history to maximum number of entries
93
+ *
94
+ * @param {Array} entries - History entries
95
+ * @param {number} maxEntries - Maximum entries to keep
96
+ * @returns {Array} Pruned entries (most recent first)
97
+ */
98
+ export function pruneHistory(entries, maxEntries = DEFAULT_MAX_ENTRIES) {
99
+ if (!Array.isArray(entries)) {
100
+ return []
101
+ }
102
+
103
+ // Sort by generated_at descending (most recent first)
104
+ const sorted = [...entries].sort((a, b) => {
105
+ const dateA = new Date(a.generated_at || 0)
106
+ const dateB = new Date(b.generated_at || 0)
107
+ return dateB - dateA
108
+ })
109
+
110
+ // Keep only the most recent entries
111
+ return sorted.slice(0, maxEntries)
112
+ }
113
+
114
+ /**
115
+ * Add a CI result to history
116
+ *
117
+ * @param {Object} result - Full CI result object
118
+ * @param {Object} history - Existing history object
119
+ * @returns {Object} Updated history object
120
+ */
121
+ export function addToHistory(result, history) {
122
+ const entry = extractHistoryEntry(result)
123
+
124
+ // Check for duplicate run_id
125
+ const existingIndex = history.entries.findIndex((e) => e.run_id === entry.run_id)
126
+
127
+ if (existingIndex >= 0) {
128
+ // Update existing entry
129
+ history.entries[existingIndex] = entry
130
+ } else {
131
+ // Add new entry at the beginning
132
+ history.entries.unshift(entry)
133
+ }
134
+
135
+ // Update metadata
136
+ history.meta = {
137
+ ...history.meta,
138
+ updated_at: new Date().toISOString(),
139
+ }
140
+
141
+ return history
142
+ }
143
+
144
+ /**
145
+ * Save history to file
146
+ *
147
+ * @param {Object} history - History object
148
+ * @param {string} filePath - Path to save
149
+ * @param {number} maxEntries - Maximum entries to keep
150
+ */
151
+ export function saveHistory(history, filePath, maxEntries = DEFAULT_MAX_ENTRIES) {
152
+ // Prune before saving
153
+ history.entries = pruneHistory(history.entries, maxEntries)
154
+ history.meta.max_entries = maxEntries
155
+ history.meta.entry_count = history.entries.length
156
+
157
+ // Ensure directory exists
158
+ const dir = dirname(filePath)
159
+ if (!existsSync(dir)) {
160
+ mkdirSync(dir, { recursive: true })
161
+ }
162
+
163
+ // Write file
164
+ writeFileSync(filePath, JSON.stringify(history, null, 2) + '\n')
165
+ }
166
+
167
+ /**
168
+ * Get entries for a specific branch
169
+ *
170
+ * @param {Array} entries - History entries
171
+ * @param {string} branch - Branch name to filter
172
+ * @returns {Array} Filtered entries
173
+ */
174
+ export function filterByBranch(entries, branch) {
175
+ if (!branch) return entries
176
+ return entries.filter((e) => e.branch === branch)
177
+ }
178
+
179
+ /**
180
+ * Get entries within a date range
181
+ *
182
+ * @param {Array} entries - History entries
183
+ * @param {number} days - Number of days to include
184
+ * @returns {Array} Filtered entries
185
+ */
186
+ export function filterByDays(entries, days) {
187
+ if (!days || days <= 0) return entries
188
+
189
+ const cutoff = new Date()
190
+ cutoff.setDate(cutoff.getDate() - days)
191
+
192
+ return entries.filter((e) => {
193
+ const date = new Date(e.generated_at)
194
+ return date >= cutoff
195
+ })
196
+ }
197
+
198
+ /**
199
+ * Get statistics from history
200
+ *
201
+ * @param {Array} entries - History entries
202
+ * @returns {Object} Statistics object
203
+ */
204
+ export function getHistoryStats(entries) {
205
+ if (!entries || entries.length === 0) {
206
+ return {
207
+ total_runs: 0,
208
+ oldest_run: null,
209
+ newest_run: null,
210
+ unique_branches: 0,
211
+ branches: [],
212
+ }
213
+ }
214
+
215
+ const sorted = [...entries].sort((a, b) => {
216
+ return new Date(a.generated_at) - new Date(b.generated_at)
217
+ })
218
+
219
+ const branches = [...new Set(entries.map((e) => e.branch).filter(Boolean))]
220
+
221
+ return {
222
+ total_runs: entries.length,
223
+ oldest_run: sorted[0]?.generated_at || null,
224
+ newest_run: sorted[sorted.length - 1]?.generated_at || null,
225
+ unique_branches: branches.length,
226
+ branches,
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Process a CI result and update history
232
+ *
233
+ * Convenience function that loads, updates, and saves history.
234
+ *
235
+ * @param {Object} result - Full CI result object
236
+ * @param {string} historyPath - Path to history file
237
+ * @param {number} maxEntries - Maximum entries to keep
238
+ * @returns {Object} Updated history object
239
+ */
240
+ export function processHistory(result, historyPath, maxEntries = DEFAULT_MAX_ENTRIES) {
241
+ const history = loadHistory(historyPath)
242
+ addToHistory(result, history)
243
+ saveHistory(history, historyPath, maxEntries)
244
+ return history
245
+ }
@@ -0,0 +1,296 @@
1
+ /**
2
+ * CI Result Trends Module
3
+ *
4
+ * Calculates trends from historical CI run data.
5
+ * Analyzes score, performance, and issues over time.
6
+ *
7
+ * @module ci-result-trends
8
+ */
9
+
10
+ import { filterByDays, filterByBranch } from './ci-result-history.mjs'
11
+
12
+ /**
13
+ * Direction indicators for trends
14
+ */
15
+ export const DIRECTION = {
16
+ IMPROVING: 'improving',
17
+ DEGRADING: 'degrading',
18
+ STABLE: 'stable',
19
+ }
20
+
21
+ /**
22
+ * Threshold for determining significant change (percentage)
23
+ */
24
+ export const SIGNIFICANCE_THRESHOLD = 5
25
+
26
+ /**
27
+ * Calculate average of numeric values
28
+ *
29
+ * @param {number[]} values - Array of numbers
30
+ * @returns {number} Average value
31
+ */
32
+ export function calculateAverage(values) {
33
+ if (!values || values.length === 0) return 0
34
+ const sum = values.reduce((acc, val) => acc + (val || 0), 0)
35
+ return sum / values.length
36
+ }
37
+
38
+ /**
39
+ * Calculate percentage change between two values
40
+ *
41
+ * @param {number} oldValue - Previous value
42
+ * @param {number} newValue - Current value
43
+ * @returns {number} Percentage change (null if cannot calculate)
44
+ */
45
+ export function calculatePercentageChange(oldValue, newValue) {
46
+ if (oldValue === 0) {
47
+ if (newValue === 0) return 0
48
+ return newValue > 0 ? 100 : -100
49
+ }
50
+ return ((newValue - oldValue) / Math.abs(oldValue)) * 100
51
+ }
52
+
53
+ /**
54
+ * Calculate absolute change between two values
55
+ *
56
+ * @param {number} oldValue - Previous value
57
+ * @param {number} newValue - Current value
58
+ * @returns {number} Absolute change
59
+ */
60
+ export function calculateAbsoluteChange(oldValue, newValue) {
61
+ return newValue - oldValue
62
+ }
63
+
64
+ /**
65
+ * Determine direction of trend
66
+ *
67
+ * @param {number} changePercent - Percentage change
68
+ * @param {number} threshold - Significance threshold
69
+ * @param {boolean} higherIsBetter - Whether higher values are better
70
+ * @returns {string} Direction: 'improving', 'degrading', or 'stable'
71
+ */
72
+ export function determineDirection(changePercent, threshold = SIGNIFICANCE_THRESHOLD, higherIsBetter = true) {
73
+ if (Math.abs(changePercent) < threshold) {
74
+ return DIRECTION.STABLE
75
+ }
76
+
77
+ const isPositive = changePercent > 0
78
+
79
+ if (higherIsBetter) {
80
+ return isPositive ? DIRECTION.IMPROVING : DIRECTION.DEGRADING
81
+ } else {
82
+ return isPositive ? DIRECTION.DEGRADING : DIRECTION.IMPROVING
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Extract field values from history entries
88
+ *
89
+ * @param {Object[]} entries - History entries
90
+ * @param {string} field - Field path (dot notation supported)
91
+ * @returns {number[]} Array of values
92
+ */
93
+ export function extractValues(entries, field) {
94
+ return entries.map((entry) => {
95
+ const parts = field.split('.')
96
+ let value = entry
97
+ for (const part of parts) {
98
+ value = value?.[part]
99
+ }
100
+ return typeof value === 'number' ? value : 0
101
+ })
102
+ }
103
+
104
+ /**
105
+ * Calculate trend for a specific metric
106
+ *
107
+ * @param {Object[]} entries - History entries (newest first)
108
+ * @param {string} field - Field path to analyze
109
+ * @param {boolean} higherIsBetter - Whether higher values are better
110
+ * @returns {Object} Trend object with direction and changes
111
+ */
112
+ export function calculateMetricTrend(entries, field, higherIsBetter = true) {
113
+ if (!entries || entries.length === 0) {
114
+ return {
115
+ direction: DIRECTION.STABLE,
116
+ current: null,
117
+ change_7d: null,
118
+ change_30d: null,
119
+ avg_7d: null,
120
+ avg_30d: null,
121
+ data_points: 0,
122
+ }
123
+ }
124
+
125
+ const values = extractValues(entries, field)
126
+ const current = values[0] || 0
127
+
128
+ // Get entries by time period
129
+ const entries7d = filterByDays(entries, 7)
130
+ const entries30d = filterByDays(entries, 30)
131
+
132
+ const values7d = extractValues(entries7d, field)
133
+ const values30d = extractValues(entries30d, field)
134
+
135
+ const avg7d = calculateAverage(values7d)
136
+ const avg30d = calculateAverage(values30d)
137
+
138
+ // Calculate changes
139
+ const change7d = values7d.length > 1 ? calculateAbsoluteChange(values7d[values7d.length - 1], current) : null
140
+
141
+ const change30d = values30d.length > 1 ? calculateAbsoluteChange(values30d[values30d.length - 1], current) : null
142
+
143
+ // Determine direction based on 7d vs 30d averages
144
+ let direction = DIRECTION.STABLE
145
+ if (avg30d !== 0 && values7d.length >= 2 && values30d.length >= 2) {
146
+ const percentChange = calculatePercentageChange(avg30d, avg7d)
147
+ direction = determineDirection(percentChange, SIGNIFICANCE_THRESHOLD, higherIsBetter)
148
+ }
149
+
150
+ return {
151
+ direction,
152
+ current: Math.round(current * 100) / 100,
153
+ change_7d: change7d !== null ? Math.round(change7d * 100) / 100 : null,
154
+ change_30d: change30d !== null ? Math.round(change30d * 100) / 100 : null,
155
+ avg_7d: Math.round(avg7d * 100) / 100,
156
+ avg_30d: Math.round(avg30d * 100) / 100,
157
+ data_points: entries.length,
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Calculate all trends from history entries
163
+ *
164
+ * @param {Object[]} entries - History entries (newest first)
165
+ * @param {string} [branch] - Optional branch filter
166
+ * @returns {Object} Complete trends object
167
+ */
168
+ export function calculateTrends(entries, branch = null) {
169
+ // Filter by branch if specified
170
+ const filteredEntries = branch ? filterByBranch(entries, branch) : entries
171
+
172
+ if (!filteredEntries || filteredEntries.length === 0) {
173
+ return {
174
+ score: createEmptyTrend(),
175
+ performance: createEmptyTrend(),
176
+ issues: createEmptyTrend(),
177
+ data: {
178
+ total_runs: 0,
179
+ branch_filter: branch,
180
+ calculated_at: new Date().toISOString(),
181
+ },
182
+ }
183
+ }
184
+
185
+ return {
186
+ score: calculateMetricTrend(filteredEntries, 'score', true),
187
+ performance: calculateMetricTrend(filteredEntries, 'timing.total_ms', false),
188
+ issues: calculateMetricTrend(filteredEntries, 'summary.total_issues', false),
189
+ data: {
190
+ total_runs: filteredEntries.length,
191
+ branch_filter: branch,
192
+ calculated_at: new Date().toISOString(),
193
+ date_range: {
194
+ from: filteredEntries[filteredEntries.length - 1]?.generated_at,
195
+ to: filteredEntries[0]?.generated_at,
196
+ },
197
+ },
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Create an empty trend object
203
+ *
204
+ * @returns {Object} Empty trend structure
205
+ */
206
+ function createEmptyTrend() {
207
+ return {
208
+ direction: DIRECTION.STABLE,
209
+ current: null,
210
+ change_7d: null,
211
+ change_30d: null,
212
+ avg_7d: null,
213
+ avg_30d: null,
214
+ data_points: 0,
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Calculate trends grouped by branch
220
+ *
221
+ * @param {Object[]} entries - History entries
222
+ * @returns {Object} Trends by branch
223
+ */
224
+ export function calculateTrendsByBranch(entries) {
225
+ if (!entries || entries.length === 0) {
226
+ return {}
227
+ }
228
+
229
+ const branches = [...new Set(entries.map((e) => e.branch).filter(Boolean))]
230
+ const result = {}
231
+
232
+ for (const branch of branches) {
233
+ result[branch] = calculateTrends(entries, branch)
234
+ }
235
+
236
+ return result
237
+ }
238
+
239
+ /**
240
+ * Generate time series data for visualization
241
+ *
242
+ * @param {Object[]} entries - History entries
243
+ * @param {string} field - Field to extract
244
+ * @param {number} [limit] - Maximum data points
245
+ * @returns {Object[]} Time series array
246
+ */
247
+ export function generateTimeSeries(entries, field, limit = 30) {
248
+ if (!entries || entries.length === 0) return []
249
+
250
+ const values = extractValues(entries, field)
251
+
252
+ return entries.slice(0, limit).map((entry, index) => ({
253
+ date: entry.generated_at,
254
+ value: values[index],
255
+ run_id: entry.run_id,
256
+ branch: entry.branch,
257
+ }))
258
+ }
259
+
260
+ /**
261
+ * Calculate summary statistics for trend display
262
+ *
263
+ * @param {Object} trends - Trends object from calculateTrends
264
+ * @returns {Object} Summary for display
265
+ */
266
+ export function getTrendSummary(trends) {
267
+ const summaryLines = []
268
+
269
+ if (trends.score.direction !== DIRECTION.STABLE) {
270
+ const emoji = trends.score.direction === DIRECTION.IMPROVING ? '+' : '-'
271
+ const change = trends.score.change_7d !== null ? Math.abs(trends.score.change_7d) : 0
272
+ summaryLines.push(`Score ${trends.score.direction} (${emoji}${change} over 7d)`)
273
+ }
274
+
275
+ if (trends.performance.direction !== DIRECTION.STABLE) {
276
+ const emoji = trends.performance.direction === DIRECTION.IMPROVING ? '-' : '+'
277
+ const change = trends.performance.change_7d !== null ? Math.abs(trends.performance.change_7d / 1000) : 0
278
+ summaryLines.push(`Performance ${trends.performance.direction} (${emoji}${change.toFixed(1)}s over 7d)`)
279
+ }
280
+
281
+ if (trends.issues.direction !== DIRECTION.STABLE) {
282
+ const emoji = trends.issues.direction === DIRECTION.IMPROVING ? '-' : '+'
283
+ const change = trends.issues.change_7d !== null ? Math.abs(trends.issues.change_7d) : 0
284
+ summaryLines.push(`Issues ${trends.issues.direction} (${emoji}${change} over 7d)`)
285
+ }
286
+
287
+ return {
288
+ lines: summaryLines,
289
+ overall:
290
+ summaryLines.length === 0
291
+ ? 'stable'
292
+ : summaryLines.some((l) => l.includes('degrading'))
293
+ ? 'degrading'
294
+ : 'improving',
295
+ }
296
+ }
@@ -482,3 +482,107 @@ export function createMinimalResult({ projectName, passed = true }) {
482
482
  extensions: {},
483
483
  }
484
484
  }
485
+
486
+ /**
487
+ * Get trace ID from environment variables
488
+ *
489
+ * Checks common OpenTelemetry and CI environment variables for trace IDs.
490
+ *
491
+ * @returns {string|null} Trace ID or null if not available
492
+ */
493
+ export function getTraceId() {
494
+ // Direct trace ID from environment
495
+ if (process.env.OTEL_TRACE_ID) {
496
+ return process.env.OTEL_TRACE_ID
497
+ }
498
+
499
+ // W3C traceparent header format: 00-{trace_id}-{span_id}-{flags}
500
+ if (process.env.TRACEPARENT) {
501
+ const parts = process.env.TRACEPARENT.split('-')
502
+ if (parts.length >= 2) {
503
+ return parts[1]
504
+ }
505
+ }
506
+
507
+ // GitHub Actions run ID as fallback trace correlation
508
+ if (process.env.GITHUB_RUN_ID) {
509
+ return `gh-${process.env.GITHUB_RUN_ID}`
510
+ }
511
+
512
+ return null
513
+ }
514
+
515
+ /**
516
+ * Build trace URL from trace ID and endpoint
517
+ *
518
+ * @param {string} traceId - The trace ID
519
+ * @param {string} [endpoint] - OTel endpoint (default: from env)
520
+ * @param {string} [template] - URL template with {trace_id} placeholder
521
+ * @returns {string|null} Trace URL or null
522
+ */
523
+ export function buildTraceUrl(traceId, endpoint = null, template = null) {
524
+ if (!traceId) return null
525
+
526
+ const otelEndpoint = endpoint || process.env.OTEL_EXPORTER_OTLP_ENDPOINT || process.env.OTEL_ENDPOINT
527
+
528
+ // Use custom template if provided
529
+ if (template) {
530
+ return template.replace('{trace_id}', traceId).replace('{endpoint}', otelEndpoint || '')
531
+ }
532
+
533
+ // Use env template if available
534
+ if (process.env.OTEL_TRACE_URL_TEMPLATE) {
535
+ return process.env.OTEL_TRACE_URL_TEMPLATE.replace('{trace_id}', traceId).replace(
536
+ '{endpoint}',
537
+ otelEndpoint || ''
538
+ )
539
+ }
540
+
541
+ // Default Jaeger-style URL if endpoint is known
542
+ if (otelEndpoint) {
543
+ // Extract base URL from endpoint (remove /v1/traces if present)
544
+ const baseUrl = otelEndpoint.replace(/\/v1\/traces\/?$/, '').replace(/\/$/, '')
545
+ return `${baseUrl}/trace/${traceId}`
546
+ }
547
+
548
+ return null
549
+ }
550
+
551
+ /**
552
+ * Build telemetry extension object for ci-result.json
553
+ *
554
+ * @param {string} traceId - The trace ID
555
+ * @param {Object} [options] - Additional options
556
+ * @param {number} [options.spansExported] - Number of spans exported
557
+ * @param {number} [options.errorsRecorded] - Number of errors recorded
558
+ * @param {string} [options.endpoint] - OTel endpoint
559
+ * @returns {Object} Telemetry extension object
560
+ */
561
+ export function buildTelemetryExtension(traceId, options = {}) {
562
+ const { spansExported = 0, errorsRecorded = 0, endpoint = null } = options
563
+
564
+ const extension = {
565
+ trace_id: traceId,
566
+ enabled: true,
567
+ }
568
+
569
+ // Add trace URL if we can build one
570
+ const traceUrl = buildTraceUrl(traceId, endpoint)
571
+ if (traceUrl) {
572
+ extension.trace_url = traceUrl
573
+ }
574
+
575
+ // Add metrics if provided
576
+ if (spansExported > 0 || errorsRecorded > 0) {
577
+ extension.spans_exported = spansExported
578
+ extension.errors_recorded = errorsRecorded
579
+ }
580
+
581
+ // Add endpoint info
582
+ const otelEndpoint = endpoint || process.env.OTEL_EXPORTER_OTLP_ENDPOINT || process.env.OTEL_ENDPOINT
583
+ if (otelEndpoint) {
584
+ extension.collector_endpoint = otelEndpoint
585
+ }
586
+
587
+ return extension
588
+ }