@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.
@@ -305,6 +305,125 @@ export function extractComponents(steps) {
305
305
  return Array.from(components).sort()
306
306
  }
307
307
 
308
+ /**
309
+ * Aggregate coverage data from multiple test steps
310
+ *
311
+ * @param {Array} steps - Array of step results with coverage data
312
+ * @returns {Object|null} Aggregated coverage or null if no coverage data
313
+ */
314
+ export function aggregateCoverage(steps) {
315
+ const coverageData = []
316
+
317
+ for (const step of steps) {
318
+ if (step.output?.coverage) {
319
+ coverageData.push({
320
+ component: step.component || 'default',
321
+ coverage: step.output.coverage,
322
+ tests: {
323
+ passed: step.output.passed || 0,
324
+ failed: step.output.failed || 0,
325
+ skipped: step.output.skipped || 0,
326
+ },
327
+ })
328
+ }
329
+ }
330
+
331
+ if (coverageData.length === 0) {
332
+ return null
333
+ }
334
+
335
+ // Calculate weighted average based on test count
336
+ let totalTests = 0
337
+ let weightedLines = 0
338
+ let weightedBranches = 0
339
+ let weightedFunctions = 0
340
+ let weightedStatements = 0
341
+
342
+ const byComponent = {}
343
+
344
+ for (const data of coverageData) {
345
+ const testCount = data.tests.passed + data.tests.failed
346
+ totalTests += testCount
347
+
348
+ const cov = data.coverage
349
+ if (cov.lines !== undefined) weightedLines += cov.lines * testCount
350
+ if (cov.branches !== undefined) weightedBranches += cov.branches * testCount
351
+ if (cov.functions !== undefined) weightedFunctions += cov.functions * testCount
352
+ if (cov.statements !== undefined) weightedStatements += cov.statements * testCount
353
+
354
+ byComponent[data.component] = {
355
+ ...data.coverage,
356
+ tests: data.tests,
357
+ }
358
+ }
359
+
360
+ // Calculate averages
361
+ const avgLines = totalTests > 0 ? Math.round((weightedLines / totalTests) * 10) / 10 : 0
362
+ const avgBranches = totalTests > 0 ? Math.round((weightedBranches / totalTests) * 10) / 10 : 0
363
+ const avgFunctions = totalTests > 0 ? Math.round((weightedFunctions / totalTests) * 10) / 10 : 0
364
+ const avgStatements = totalTests > 0 ? Math.round((weightedStatements / totalTests) * 10) / 10 : 0
365
+
366
+ return {
367
+ lines: avgLines,
368
+ branches: avgBranches,
369
+ functions: avgFunctions,
370
+ statements: avgStatements,
371
+ by_component: byComponent,
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Compare two CI results and calculate diff
377
+ *
378
+ * @param {Object} current - Current CI result
379
+ * @param {Object} baseline - Baseline CI result (e.g., from main branch)
380
+ * @returns {Object} Comparison result with diffs
381
+ */
382
+ export function compareCiResults(current, baseline) {
383
+ const scoreDiff = current.summary.score - baseline.summary.score
384
+ const issuesDiff = current.summary.total_issues - baseline.summary.total_issues
385
+ const blockingDiff = current.summary.blocking - baseline.summary.blocking
386
+
387
+ // Determine trend
388
+ let trend = 'stable'
389
+ if (scoreDiff > 5) trend = 'improving'
390
+ else if (scoreDiff < -5) trend = 'degrading'
391
+
392
+ // Compare coverage if available
393
+ let coverageDiff = null
394
+ if (current.checks?.test?.coverage && baseline.checks?.test?.coverage) {
395
+ coverageDiff = {
396
+ lines: (current.checks.test.coverage.lines || 0) - (baseline.checks.test.coverage.lines || 0),
397
+ branches: (current.checks.test.coverage.branches || 0) - (baseline.checks.test.coverage.branches || 0),
398
+ functions: (current.checks.test.coverage.functions || 0) - (baseline.checks.test.coverage.functions || 0),
399
+ }
400
+ }
401
+
402
+ return {
403
+ trend,
404
+ score: {
405
+ current: current.summary.score,
406
+ baseline: baseline.summary.score,
407
+ diff: scoreDiff,
408
+ },
409
+ issues: {
410
+ current: current.summary.total_issues,
411
+ baseline: baseline.summary.total_issues,
412
+ diff: issuesDiff,
413
+ },
414
+ blocking: {
415
+ current: current.summary.blocking,
416
+ baseline: baseline.summary.blocking,
417
+ diff: blockingDiff,
418
+ },
419
+ coverage: coverageDiff,
420
+ status: {
421
+ current: current.summary.status,
422
+ baseline: baseline.summary.status,
423
+ },
424
+ }
425
+ }
426
+
308
427
  /**
309
428
  * Create a minimal valid CI result object
310
429
  *
@@ -363,3 +482,107 @@ export function createMinimalResult({ projectName, passed = true }) {
363
482
  extensions: {},
364
483
  }
365
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
+ }
@@ -34,8 +34,14 @@ import {
34
34
  calculateExecutionStats,
35
35
  countIssues,
36
36
  extractComponents,
37
+ getTraceId,
38
+ buildTelemetryExtension,
37
39
  } from './ci-result-utils.mjs'
38
40
 
41
+ // History and trends imports
42
+ import { loadHistory, addToHistory, saveHistory } from './ci-result-history.mjs'
43
+ import { calculateTrends } from './ci-result-trends.mjs'
44
+
39
45
  // Validator imports
40
46
  import { getLicenseCheckResult } from './license-check.mjs'
41
47
  import { getReadmeCheckResult } from './readme-validator.mjs'
@@ -132,6 +138,7 @@ async function runValidators(projectRoot, options = {}) {
132
138
  * @param {Object} input.project - Project information
133
139
  * @param {Object} input.metadata - Additional metadata
134
140
  * @param {Object} input.validators - Validator results (from runValidators)
141
+ * @param {Object} input.trends - Pre-calculated trends (optional)
135
142
  * @returns {Object} CI result object
136
143
  */
137
144
  export function generateCiResult(input) {
@@ -143,6 +150,7 @@ export function generateCiResult(input) {
143
150
  metadata = {},
144
151
  issues = [],
145
152
  validators = {},
153
+ trends = null,
146
154
  } = input
147
155
 
148
156
  const now = new Date().toISOString()
@@ -169,19 +177,36 @@ export function generateCiResult(input) {
169
177
  // Determine exit code
170
178
  const exitCode = executionStats.steps_failed > 0 ? 1 : 0
171
179
 
180
+ // Get trace ID if available
181
+ const traceId = getTraceId()
182
+
183
+ // Build meta object
184
+ const meta = {
185
+ generated_at: now,
186
+ generator: getGeneratorVersion(),
187
+ run_id: generateRunId(),
188
+ trigger: detectTrigger(),
189
+ branch: getGitBranch(),
190
+ commit: getGitCommit(),
191
+ }
192
+
193
+ // Add trace_id if available
194
+ if (traceId) {
195
+ meta.trace_id = traceId
196
+ }
197
+
198
+ // Build extensions with telemetry if trace_id exists
199
+ const extensions = { ...(metadata.extensions || {}) }
200
+ if (traceId) {
201
+ extensions.telemetry = buildTelemetryExtension(traceId)
202
+ }
203
+
172
204
  // Build result object
173
205
  const result = {
174
206
  $schema: 'https://kabran.dev/schemas/ci-result.v2.json',
175
207
  version: '1.0.0',
176
208
 
177
- meta: {
178
- generated_at: now,
179
- generator: getGeneratorVersion(),
180
- run_id: generateRunId(),
181
- trigger: detectTrigger(),
182
- branch: getGitBranch(),
183
- commit: getGitCommit(),
184
- },
209
+ meta,
185
210
 
186
211
  project: {
187
212
  name: project.name || 'unknown',
@@ -216,7 +241,12 @@ export function generateCiResult(input) {
216
241
  checks,
217
242
  issues,
218
243
  errors,
219
- extensions: metadata.extensions || {},
244
+ extensions,
245
+ }
246
+
247
+ // Add trends if provided
248
+ if (trends) {
249
+ result.trends = trends
220
250
  }
221
251
 
222
252
  return result
@@ -236,6 +266,10 @@ function parseArgs(args) {
236
266
  skipReadme: false,
237
267
  skipEnv: false,
238
268
  skipQualityStandard: false,
269
+ trackHistory: false,
270
+ historyFile: null,
271
+ maxHistoryEntries: 30,
272
+ calculateTrends: false,
239
273
  }
240
274
 
241
275
  for (let i = 0; i < args.length; i++) {
@@ -259,6 +293,16 @@ function parseArgs(args) {
259
293
  options.skipEnv = true
260
294
  } else if (arg === '--skip-quality-standard') {
261
295
  options.skipQualityStandard = true
296
+ } else if (arg === '--track-history') {
297
+ options.trackHistory = true
298
+ } else if (arg === '--history-file') {
299
+ options.historyFile = args[++i]
300
+ options.trackHistory = true
301
+ } else if (arg === '--max-history') {
302
+ options.maxHistoryEntries = parseInt(args[++i], 10) || 30
303
+ } else if (arg === '--calculate-trends') {
304
+ options.calculateTrends = true
305
+ options.trackHistory = true
262
306
  } else if (arg === '--help' || arg === '-h') {
263
307
  console.log(`
264
308
  Usage: generate-ci-result.mjs [options]
@@ -273,6 +317,10 @@ Options:
273
317
  --skip-readme Skip README check when running validators
274
318
  --skip-env Skip env check when running validators
275
319
  --skip-quality-standard Skip quality-standard check when running validators
320
+ --track-history Track result in history file (max 30 entries)
321
+ --history-file <file> Custom history file path (default: docs/quality/ci-result-history.json)
322
+ --max-history <n> Maximum history entries to keep (default: 30)
323
+ --calculate-trends Calculate and include trends from history
276
324
  -h, --help Show this help message
277
325
 
278
326
  Input format:
@@ -362,15 +410,35 @@ async function main() {
362
410
  })
363
411
  }
364
412
 
413
+ // Determine paths
414
+ const outputPath = options.output || resolve(options.projectRoot, 'docs/quality/ci-result.json')
415
+ const historyPath = options.historyFile || resolve(options.projectRoot, 'docs/quality/ci-result-history.json')
416
+
417
+ // Load history and calculate trends if requested
418
+ let history = null
419
+ if (options.trackHistory || options.calculateTrends) {
420
+ history = loadHistory(historyPath)
421
+
422
+ // Calculate trends if requested
423
+ if (options.calculateTrends && history.entries.length > 0) {
424
+ input.trends = calculateTrends(history.entries)
425
+ }
426
+ }
427
+
365
428
  // Generate result
366
429
  const result = generateCiResult(input)
367
430
 
431
+ // Update history with new result
432
+ if (options.trackHistory && history) {
433
+ addToHistory(result, history)
434
+ saveHistory(history, historyPath, options.maxHistoryEntries)
435
+ console.log(`History updated: ${historyPath} (${history.entries.length} entries)`)
436
+ }
437
+
368
438
  // Output
369
439
  if (options.stdout) {
370
440
  console.log(JSON.stringify(result, null, 2))
371
441
  } else {
372
- const outputPath = options.output || resolve(options.projectRoot, 'docs/quality/ci-result.json')
373
-
374
442
  // Ensure directory exists
375
443
  mkdirSync(dirname(outputPath), { recursive: true })
376
444