@kabran-tecnologia/kabran-config 1.5.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.
@@ -10,6 +10,64 @@ set -euo pipefail
10
10
  RUNNER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
11
  CORE_SCRIPT="$RUNNER_DIR/ci-core.sh"
12
12
 
13
+ # ==============================================================================
14
+ # Command Line Arguments
15
+ # ==============================================================================
16
+
17
+ show_help() {
18
+ cat << EOF
19
+ Usage: $(basename "$0") [options]
20
+
21
+ Kabran CI Runner - Execute project CI pipelines
22
+
23
+ Options:
24
+ --list-scopes List available scopes from ci-config.sh and exit
25
+ --scope <name> Run only the specified scope (component)
26
+ --help Show this help message and exit
27
+
28
+ Environment Variables:
29
+ CI_SCOPE Set the scope filter (default: all)
30
+ CI_VERBOSE Enable verbose output (default: false)
31
+ CI_OUTPUT_FILE Output file for legacy v1 format
32
+ CI_OUTPUT_FILE_V2 Output file for v2 format (default: docs/quality/ci-result.json)
33
+ CI_CONFIG_FILE Path to project ci-config.sh
34
+
35
+ Examples:
36
+ # Run all steps
37
+ $(basename "$0")
38
+
39
+ # Run only specific component
40
+ $(basename "$0") --scope app
41
+
42
+ # List available scopes
43
+ $(basename "$0") --list-scopes
44
+ EOF
45
+ }
46
+
47
+ LIST_SCOPES=false
48
+
49
+ while [[ $# -gt 0 ]]; do
50
+ case $1 in
51
+ --list-scopes)
52
+ LIST_SCOPES=true
53
+ shift
54
+ ;;
55
+ --scope)
56
+ CI_SCOPE="$2"
57
+ shift 2
58
+ ;;
59
+ --help|-h)
60
+ show_help
61
+ exit 0
62
+ ;;
63
+ *)
64
+ echo "Unknown option: $1" >&2
65
+ show_help
66
+ exit 1
67
+ ;;
68
+ esac
69
+ done
70
+
13
71
  # Load core functions
14
72
  if [ ! -f "$CORE_SCRIPT" ]; then
15
73
  echo "ERROR: ci-core.sh not found at $CORE_SCRIPT" >&2
@@ -27,6 +85,7 @@ OUTPUT_FILE_V2="${CI_OUTPUT_FILE_V2:-docs/quality/ci-result.json}"
27
85
  VERBOSE="${CI_VERBOSE:-false}"
28
86
  PROJECT_ROOT="${PROJECT_ROOT:-$(pwd)}"
29
87
  USE_V2="${CI_USE_V2:-true}"
88
+ CI_SCOPE="${CI_SCOPE:-all}" # Scope filter: "all" or component name (e.g., "app", "website")
30
89
 
31
90
  # Track errors (reinitialize to avoid issues with sourced scripts)
32
91
  ERRORS=()
@@ -57,6 +116,33 @@ fi
57
116
  log_info "Loading project configuration: $CONFIG_FILE"
58
117
  source "$CONFIG_FILE"
59
118
 
119
+ # ==============================================================================
120
+ # List Scopes (if requested)
121
+ # ==============================================================================
122
+
123
+ if [ "$LIST_SCOPES" = "true" ]; then
124
+ echo "Available scopes in $PROJECT_NAME:"
125
+ echo ""
126
+
127
+ # Check if CI_COMPONENTS is defined (common pattern for monorepos)
128
+ if [ -n "${CI_COMPONENTS:-}" ]; then
129
+ echo "Components:"
130
+ for component in $CI_COMPONENTS; do
131
+ echo " - $component"
132
+ done
133
+ elif declare -f list_scopes >/dev/null; then
134
+ # Project can define custom list_scopes function
135
+ list_scopes
136
+ else
137
+ echo " all (single project - no components defined)"
138
+ fi
139
+
140
+ echo ""
141
+ echo "Usage: CI_SCOPE=<scope> $(basename "$0")"
142
+ echo " or: $(basename "$0") --scope <scope>"
143
+ exit 0
144
+ fi
145
+
60
146
  # ==============================================================================
61
147
  # Validate Configuration
62
148
  # ==============================================================================
@@ -80,6 +166,9 @@ fi
80
166
  log_info "Starting Kabran CI - $PROJECT_NAME"
81
167
  log_info "Working directory: $PROJECT_ROOT"
82
168
  log_info "CI Core Version: $CI_CORE_VERSION"
169
+ if [ "$CI_SCOPE" != "all" ]; then
170
+ log_info "Scope: $CI_SCOPE (filtered)"
171
+ fi
83
172
  echo ""
84
173
 
85
174
  # Start timing
@@ -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
+ }