@eduardbar/drift 1.2.0 → 1.3.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.
Files changed (61) hide show
  1. package/.github/workflows/publish-vscode.yml +3 -3
  2. package/.github/workflows/publish.yml +3 -3
  3. package/.github/workflows/review-pr.yml +98 -6
  4. package/AGENTS.md +6 -0
  5. package/README.md +160 -10
  6. package/ROADMAP.md +6 -5
  7. package/dist/analyzer.d.ts +2 -2
  8. package/dist/analyzer.js +420 -159
  9. package/dist/benchmark.d.ts +2 -0
  10. package/dist/benchmark.js +185 -0
  11. package/dist/cli.js +453 -62
  12. package/dist/diff.js +74 -10
  13. package/dist/git.js +12 -0
  14. package/dist/index.d.ts +5 -3
  15. package/dist/index.js +3 -1
  16. package/dist/plugins.d.ts +2 -1
  17. package/dist/plugins.js +177 -28
  18. package/dist/printer.js +4 -0
  19. package/dist/review.js +2 -2
  20. package/dist/rules/comments.js +2 -2
  21. package/dist/rules/complexity.js +2 -7
  22. package/dist/rules/nesting.js +3 -13
  23. package/dist/rules/phase0-basic.js +10 -10
  24. package/dist/rules/shared.d.ts +2 -0
  25. package/dist/rules/shared.js +27 -3
  26. package/dist/saas.d.ts +143 -7
  27. package/dist/saas.js +478 -37
  28. package/dist/trust-kpi.d.ts +9 -0
  29. package/dist/trust-kpi.js +445 -0
  30. package/dist/trust.d.ts +65 -0
  31. package/dist/trust.js +571 -0
  32. package/dist/types.d.ts +154 -0
  33. package/docs/PRD.md +187 -109
  34. package/docs/plugin-contract.md +61 -0
  35. package/docs/trust-core-release-checklist.md +55 -0
  36. package/package.json +5 -3
  37. package/src/analyzer.ts +484 -155
  38. package/src/benchmark.ts +244 -0
  39. package/src/cli.ts +562 -79
  40. package/src/diff.ts +75 -10
  41. package/src/git.ts +16 -0
  42. package/src/index.ts +48 -0
  43. package/src/plugins.ts +354 -26
  44. package/src/printer.ts +4 -0
  45. package/src/review.ts +2 -2
  46. package/src/rules/comments.ts +2 -2
  47. package/src/rules/complexity.ts +2 -7
  48. package/src/rules/nesting.ts +3 -13
  49. package/src/rules/phase0-basic.ts +11 -12
  50. package/src/rules/shared.ts +31 -3
  51. package/src/saas.ts +641 -43
  52. package/src/trust-kpi.ts +518 -0
  53. package/src/trust.ts +774 -0
  54. package/src/types.ts +171 -0
  55. package/tests/diff.test.ts +124 -0
  56. package/tests/new-features.test.ts +71 -0
  57. package/tests/plugins.test.ts +219 -0
  58. package/tests/rules.test.ts +23 -1
  59. package/tests/saas-foundation.test.ts +358 -1
  60. package/tests/trust-kpi.test.ts +120 -0
  61. package/tests/trust.test.ts +584 -0
@@ -0,0 +1,518 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
2
+ import { dirname, isAbsolute, resolve } from 'node:path'
3
+ import { MERGE_RISK_ORDER, normalizeMergeRiskLevel } from './trust.js'
4
+ import type { DriftTrustReport, MergeRiskLevel, TrustDiffContext, TrustDiffTrendSummary, TrustKpiDiagnostic, TrustKpiReport } from './types.js'
5
+
6
+ interface ParsedTrustArtifact {
7
+ filePath: string
8
+ trustScore: number
9
+ mergeRisk: MergeRiskLevel
10
+ diffContext?: TrustDiffContext
11
+ }
12
+
13
+ interface DiscoverResult {
14
+ files: string[]
15
+ diagnostics: TrustKpiDiagnostic[]
16
+ }
17
+
18
+ const IGNORED_DIRECTORIES = new Set(['node_modules', '.git', 'dist', '.next', 'build'])
19
+
20
+ function toPosixPath(path: string): string {
21
+ return path.replace(/\\/g, '/')
22
+ }
23
+
24
+ function round(value: number, decimals = 2): number {
25
+ return Number(value.toFixed(decimals))
26
+ }
27
+
28
+ function median(values: number[]): number | null {
29
+ if (values.length === 0) return null
30
+ const sorted = [...values].sort((a, b) => a - b)
31
+ const mid = Math.floor(sorted.length / 2)
32
+ if (sorted.length % 2 === 0) {
33
+ return round((sorted[mid - 1] + sorted[mid]) / 2)
34
+ }
35
+ return round(sorted[mid])
36
+ }
37
+
38
+ function average(values: number[]): number | null {
39
+ if (values.length === 0) return null
40
+ return round(values.reduce((sum, value) => sum + value, 0) / values.length)
41
+ }
42
+
43
+ function listFilesRecursively(root: string): string[] {
44
+ if (!existsSync(root)) return []
45
+ const out: string[] = []
46
+ const stack = [root]
47
+
48
+ while (stack.length > 0) {
49
+ const current = stack.pop()!
50
+ for (const entry of readdirSync(current)) {
51
+ const fullPath = resolve(current, entry)
52
+ const info = statSync(fullPath)
53
+ if (info.isDirectory()) {
54
+ if (IGNORED_DIRECTORIES.has(entry)) continue
55
+ stack.push(fullPath)
56
+ } else {
57
+ out.push(fullPath)
58
+ }
59
+ }
60
+ }
61
+
62
+ return out
63
+ }
64
+
65
+ function isGlobPattern(input: string): boolean {
66
+ return /[*?[\]{}]/.test(input)
67
+ }
68
+
69
+ function escapeRegex(char: string): string {
70
+ return /[\\^$+?.()|{}\[\]]/.test(char) ? `\\${char}` : char
71
+ }
72
+
73
+ function globToRegex(pattern: string): RegExp {
74
+ const normalized = toPosixPath(pattern)
75
+ let expression = '^'
76
+
77
+ for (let index = 0; index < normalized.length; index += 1) {
78
+ const char = normalized[index]
79
+ const nextChar = normalized[index + 1]
80
+ const nextNextChar = normalized[index + 2]
81
+
82
+ if (char === '*' && nextChar === '*') {
83
+ if (nextNextChar === '/') {
84
+ expression += '(?:.*/)?'
85
+ index += 2
86
+ continue
87
+ }
88
+ expression += '.*'
89
+ index += 1
90
+ continue
91
+ }
92
+
93
+ if (char === '*') {
94
+ expression += '[^/]*'
95
+ continue
96
+ }
97
+
98
+ if (char === '?') {
99
+ expression += '[^/]'
100
+ continue
101
+ }
102
+
103
+ expression += escapeRegex(char)
104
+ }
105
+
106
+ expression += '$'
107
+ return new RegExp(expression)
108
+ }
109
+
110
+ function globBaseDir(pattern: string): string {
111
+ const normalized = toPosixPath(pattern)
112
+ const wildcardIndex = normalized.search(/[*?[\]{}]/)
113
+
114
+ if (wildcardIndex < 0) return dirname(pattern)
115
+
116
+ const prefix = normalized.slice(0, wildcardIndex)
117
+ const slashIndex = prefix.lastIndexOf('/')
118
+
119
+ if (slashIndex < 0) return '.'
120
+ if (slashIndex === 0) return '/'
121
+
122
+ return prefix.slice(0, slashIndex)
123
+ }
124
+
125
+ function discoverTrustJsonFiles(input: string, cwd: string): DiscoverResult {
126
+ const diagnostics: TrustKpiDiagnostic[] = []
127
+ const source = input.trim() || '.'
128
+
129
+ if (isGlobPattern(source)) {
130
+ const absolutePattern = isAbsolute(source) ? source : resolve(cwd, source)
131
+ const regex = globToRegex(toPosixPath(absolutePattern))
132
+ const base = resolve(cwd, globBaseDir(source))
133
+
134
+ if (!existsSync(base)) {
135
+ diagnostics.push({
136
+ level: 'error',
137
+ code: 'path-not-found',
138
+ message: `Glob base path does not exist: ${base}`,
139
+ })
140
+ return { files: [], diagnostics }
141
+ }
142
+
143
+ const matched = listFilesRecursively(base)
144
+ .filter((filePath) => regex.test(toPosixPath(filePath)))
145
+ .filter((filePath) => filePath.toLowerCase().endsWith('.json'))
146
+ .sort((a, b) => a.localeCompare(b))
147
+
148
+ return { files: matched, diagnostics }
149
+ }
150
+
151
+ const absolute = isAbsolute(source) ? source : resolve(cwd, source)
152
+ if (!existsSync(absolute)) {
153
+ diagnostics.push({
154
+ level: 'error',
155
+ code: 'path-not-found',
156
+ message: `Path does not exist: ${absolute}`,
157
+ })
158
+ return { files: [], diagnostics }
159
+ }
160
+
161
+ const info = statSync(absolute)
162
+ if (info.isDirectory()) {
163
+ const files = listFilesRecursively(absolute)
164
+ .filter((filePath) => filePath.toLowerCase().endsWith('.json'))
165
+ .sort((a, b) => a.localeCompare(b))
166
+ return { files, diagnostics }
167
+ }
168
+
169
+ if (info.isFile()) {
170
+ if (!absolute.toLowerCase().endsWith('.json')) {
171
+ diagnostics.push({
172
+ level: 'warning',
173
+ code: 'path-not-supported',
174
+ file: absolute,
175
+ message: 'Input file is not JSON; attempting to parse anyway',
176
+ })
177
+ }
178
+ return { files: [absolute], diagnostics }
179
+ }
180
+
181
+ diagnostics.push({
182
+ level: 'error',
183
+ code: 'path-not-supported',
184
+ message: `Path is neither a file nor directory: ${absolute}`,
185
+ })
186
+
187
+ return { files: [], diagnostics }
188
+ }
189
+
190
+ function isObjectLike(value: unknown): value is Record<string, unknown> {
191
+ return typeof value === 'object' && value !== null
192
+ }
193
+
194
+ function normalizeDiffContext(raw: unknown): { diffContext?: TrustDiffContext; diagnostic?: TrustKpiDiagnostic } {
195
+ if (!isObjectLike(raw)) {
196
+ return {
197
+ diagnostic: {
198
+ level: 'warning',
199
+ code: 'invalid-diff-context',
200
+ message: 'diff_context is present but malformed; skipping diff trend fields for this artifact',
201
+ },
202
+ }
203
+ }
204
+
205
+ const baseRef = typeof raw.baseRef === 'string' ? raw.baseRef : 'unknown'
206
+ const status = raw.status
207
+ const scoreDelta = typeof raw.scoreDelta === 'number' && Number.isFinite(raw.scoreDelta) ? raw.scoreDelta : null
208
+ const newIssues = typeof raw.newIssues === 'number' && Number.isFinite(raw.newIssues) ? raw.newIssues : null
209
+ const resolvedIssues = typeof raw.resolvedIssues === 'number' && Number.isFinite(raw.resolvedIssues) ? raw.resolvedIssues : null
210
+ const filesChanged = typeof raw.filesChanged === 'number' && Number.isFinite(raw.filesChanged) ? raw.filesChanged : 0
211
+ const penalty = typeof raw.penalty === 'number' && Number.isFinite(raw.penalty) ? raw.penalty : 0
212
+ const bonus = typeof raw.bonus === 'number' && Number.isFinite(raw.bonus) ? raw.bonus : 0
213
+ const netImpact = typeof raw.netImpact === 'number' && Number.isFinite(raw.netImpact) ? raw.netImpact : 0
214
+
215
+ if (scoreDelta == null || newIssues == null || resolvedIssues == null) {
216
+ return {
217
+ diagnostic: {
218
+ level: 'warning',
219
+ code: 'invalid-diff-context',
220
+ message: 'diff_context is missing numeric scoreDelta/newIssues/resolvedIssues; skipping diff trend fields for this artifact',
221
+ },
222
+ }
223
+ }
224
+
225
+ const normalizedStatus = status === 'improved' || status === 'regressed' || status === 'neutral'
226
+ ? status
227
+ : scoreDelta < 0
228
+ ? 'improved'
229
+ : scoreDelta > 0
230
+ ? 'regressed'
231
+ : 'neutral'
232
+
233
+ return {
234
+ diffContext: {
235
+ baseRef,
236
+ status: normalizedStatus,
237
+ scoreDelta,
238
+ newIssues,
239
+ resolvedIssues,
240
+ filesChanged,
241
+ penalty,
242
+ bonus,
243
+ netImpact,
244
+ },
245
+ }
246
+ }
247
+
248
+ function parseTrustArtifact(filePath: string): { record?: ParsedTrustArtifact; diagnostics: TrustKpiDiagnostic[] } {
249
+ const diagnostics: TrustKpiDiagnostic[] = []
250
+
251
+ let rawContent = ''
252
+ try {
253
+ rawContent = readFileSync(filePath, 'utf8')
254
+ } catch (error) {
255
+ diagnostics.push({
256
+ level: 'error',
257
+ code: 'read-failed',
258
+ file: filePath,
259
+ message: error instanceof Error ? error.message : String(error),
260
+ })
261
+ return { diagnostics }
262
+ }
263
+
264
+ let parsed: unknown
265
+ try {
266
+ parsed = JSON.parse(rawContent)
267
+ } catch (error) {
268
+ diagnostics.push({
269
+ level: 'error',
270
+ code: 'parse-failed',
271
+ file: filePath,
272
+ message: error instanceof Error ? error.message : String(error),
273
+ })
274
+ return { diagnostics }
275
+ }
276
+
277
+ if (!isObjectLike(parsed)) {
278
+ diagnostics.push({
279
+ level: 'error',
280
+ code: 'invalid-shape',
281
+ file: filePath,
282
+ message: 'Trust artifact must be a JSON object',
283
+ })
284
+ return { diagnostics }
285
+ }
286
+
287
+ const trustScore = parsed.trust_score
288
+ if (typeof trustScore !== 'number' || !Number.isFinite(trustScore)) {
289
+ diagnostics.push({
290
+ level: 'error',
291
+ code: 'invalid-shape',
292
+ file: filePath,
293
+ message: 'Missing numeric trust_score',
294
+ })
295
+ return { diagnostics }
296
+ }
297
+
298
+ const mergeRisk = typeof parsed.merge_risk === 'string'
299
+ ? normalizeMergeRiskLevel(parsed.merge_risk)
300
+ : undefined
301
+
302
+ if (!mergeRisk) {
303
+ diagnostics.push({
304
+ level: 'error',
305
+ code: 'invalid-shape',
306
+ file: filePath,
307
+ message: `Missing/invalid merge_risk (expected one of ${MERGE_RISK_ORDER.join(', ')})`,
308
+ })
309
+ return { diagnostics }
310
+ }
311
+
312
+ let diffContext: TrustDiffContext | undefined
313
+ if (parsed.diff_context !== undefined) {
314
+ const normalized = normalizeDiffContext(parsed.diff_context)
315
+ if (normalized.diagnostic) {
316
+ diagnostics.push({ ...normalized.diagnostic, file: filePath })
317
+ } else {
318
+ diffContext = normalized.diffContext
319
+ }
320
+ }
321
+
322
+ return {
323
+ record: {
324
+ filePath,
325
+ trustScore,
326
+ mergeRisk,
327
+ diffContext,
328
+ },
329
+ diagnostics,
330
+ }
331
+ }
332
+
333
+ function buildDiffTrend(records: ParsedTrustArtifact[]): TrustDiffTrendSummary {
334
+ const withDiff = records.filter((record) => record.diffContext)
335
+
336
+ if (withDiff.length === 0) {
337
+ return {
338
+ available: false,
339
+ samples: 0,
340
+ statusDistribution: {
341
+ improved: 0,
342
+ regressed: 0,
343
+ neutral: 0,
344
+ },
345
+ scoreDelta: {
346
+ average: null,
347
+ median: null,
348
+ },
349
+ issues: {
350
+ newTotal: 0,
351
+ resolvedTotal: 0,
352
+ netNew: 0,
353
+ },
354
+ }
355
+ }
356
+
357
+ const scoreDeltas = withDiff.map((record) => record.diffContext!.scoreDelta)
358
+ const newIssues = withDiff.reduce((sum, record) => sum + record.diffContext!.newIssues, 0)
359
+ const resolvedIssues = withDiff.reduce((sum, record) => sum + record.diffContext!.resolvedIssues, 0)
360
+
361
+ const statusDistribution = {
362
+ improved: withDiff.filter((record) => record.diffContext!.status === 'improved').length,
363
+ regressed: withDiff.filter((record) => record.diffContext!.status === 'regressed').length,
364
+ neutral: withDiff.filter((record) => record.diffContext!.status === 'neutral').length,
365
+ }
366
+
367
+ return {
368
+ available: true,
369
+ samples: withDiff.length,
370
+ statusDistribution,
371
+ scoreDelta: {
372
+ average: average(scoreDeltas),
373
+ median: median(scoreDeltas),
374
+ },
375
+ issues: {
376
+ newTotal: newIssues,
377
+ resolvedTotal: resolvedIssues,
378
+ netNew: newIssues - resolvedIssues,
379
+ },
380
+ }
381
+ }
382
+
383
+ export interface TrustKpiOptions {
384
+ cwd?: string
385
+ }
386
+
387
+ export function computeTrustKpis(input: string, options?: TrustKpiOptions): TrustKpiReport {
388
+ const cwd = options?.cwd ?? process.cwd()
389
+ const discovered = discoverTrustJsonFiles(input, cwd)
390
+
391
+ const records: ParsedTrustArtifact[] = []
392
+ const diagnostics = [...discovered.diagnostics]
393
+
394
+ for (const filePath of discovered.files) {
395
+ const parsed = parseTrustArtifact(filePath)
396
+ diagnostics.push(...parsed.diagnostics)
397
+ if (parsed.record) records.push(parsed.record)
398
+ }
399
+
400
+ const trustScores = records.map((record) => record.trustScore)
401
+ const mergeRiskDistribution: Record<MergeRiskLevel, number> = {
402
+ LOW: 0,
403
+ MEDIUM: 0,
404
+ HIGH: 0,
405
+ CRITICAL: 0,
406
+ }
407
+
408
+ for (const record of records) {
409
+ mergeRiskDistribution[record.mergeRisk] += 1
410
+ }
411
+
412
+ const highRiskCount = mergeRiskDistribution.HIGH + mergeRiskDistribution.CRITICAL
413
+
414
+ return {
415
+ generatedAt: new Date().toISOString(),
416
+ input,
417
+ files: {
418
+ matched: discovered.files.length,
419
+ parsed: records.length,
420
+ malformed: discovered.files.length - records.length,
421
+ },
422
+ prsEvaluated: records.length,
423
+ mergeRiskDistribution,
424
+ trustScore: {
425
+ average: average(trustScores),
426
+ median: median(trustScores),
427
+ min: trustScores.length > 0 ? Math.min(...trustScores) : null,
428
+ max: trustScores.length > 0 ? Math.max(...trustScores) : null,
429
+ },
430
+ highRiskRatio: records.length > 0 ? round(highRiskCount / records.length, 4) : null,
431
+ diffTrend: buildDiffTrend(records),
432
+ diagnostics,
433
+ }
434
+ }
435
+
436
+ export function formatTrustKpiConsole(kpi: TrustKpiReport): string {
437
+ const parts = [
438
+ 'drift kpi',
439
+ '',
440
+ `Input: ${kpi.input}`,
441
+ `Files matched: ${kpi.files.matched} | parsed: ${kpi.files.parsed} | malformed: ${kpi.files.malformed}`,
442
+ `PRs evaluated: ${kpi.prsEvaluated}`,
443
+ `Trust score (avg/median): ${kpi.trustScore.average ?? 'n/a'} / ${kpi.trustScore.median ?? 'n/a'}`,
444
+ `High-risk ratio (HIGH+CRITICAL): ${kpi.highRiskRatio == null ? 'n/a' : `${round(kpi.highRiskRatio * 100, 2)}%`}`,
445
+ `Merge risk distribution: LOW=${kpi.mergeRiskDistribution.LOW} MEDIUM=${kpi.mergeRiskDistribution.MEDIUM} HIGH=${kpi.mergeRiskDistribution.HIGH} CRITICAL=${kpi.mergeRiskDistribution.CRITICAL}`,
446
+ ]
447
+
448
+ if (kpi.diffTrend.available) {
449
+ const avgDelta = kpi.diffTrend.scoreDelta.average
450
+ const signedDelta = avgDelta == null ? 'n/a' : `${avgDelta >= 0 ? '+' : ''}${avgDelta}`
451
+ parts.push(
452
+ `Diff trend samples: ${kpi.diffTrend.samples} | avg score delta: ${signedDelta} | new/resolved: +${kpi.diffTrend.issues.newTotal}/-${kpi.diffTrend.issues.resolvedTotal}`,
453
+ )
454
+ } else {
455
+ parts.push('Diff trend samples: 0 (no diff_context found)')
456
+ }
457
+
458
+ if (kpi.diagnostics.length > 0) {
459
+ const errorCount = kpi.diagnostics.filter((diagnostic) => diagnostic.level === 'error').length
460
+ const warningCount = kpi.diagnostics.filter((diagnostic) => diagnostic.level === 'warning').length
461
+ parts.push(`Diagnostics: ${errorCount} error(s), ${warningCount} warning(s)`)
462
+ }
463
+
464
+ return parts.join('\n')
465
+ }
466
+
467
+ export function formatTrustKpiJson(kpi: TrustKpiReport): string {
468
+ return JSON.stringify(kpi, null, 2)
469
+ }
470
+
471
+ export function computeTrustKpisFromReports(reports: DriftTrustReport[]): TrustKpiReport {
472
+ const tempRecords: ParsedTrustArtifact[] = reports.reduce<ParsedTrustArtifact[]>((acc, report, index) => {
473
+ const mergeRisk = normalizeMergeRiskLevel(report.merge_risk)
474
+ if (!mergeRisk || typeof report.trust_score !== 'number') return acc
475
+ acc.push({
476
+ filePath: `report-${index + 1}`,
477
+ trustScore: report.trust_score,
478
+ mergeRisk,
479
+ diffContext: report.diff_context,
480
+ })
481
+ return acc
482
+ }, [])
483
+
484
+ const trustScores = tempRecords.map((record) => record.trustScore)
485
+ const mergeRiskDistribution: Record<MergeRiskLevel, number> = {
486
+ LOW: 0,
487
+ MEDIUM: 0,
488
+ HIGH: 0,
489
+ CRITICAL: 0,
490
+ }
491
+
492
+ for (const record of tempRecords) {
493
+ mergeRiskDistribution[record.mergeRisk] += 1
494
+ }
495
+
496
+ const highRiskCount = mergeRiskDistribution.HIGH + mergeRiskDistribution.CRITICAL
497
+
498
+ return {
499
+ generatedAt: new Date().toISOString(),
500
+ input: 'in-memory',
501
+ files: {
502
+ matched: reports.length,
503
+ parsed: tempRecords.length,
504
+ malformed: reports.length - tempRecords.length,
505
+ },
506
+ prsEvaluated: tempRecords.length,
507
+ mergeRiskDistribution,
508
+ trustScore: {
509
+ average: average(trustScores),
510
+ median: median(trustScores),
511
+ min: trustScores.length > 0 ? Math.min(...trustScores) : null,
512
+ max: trustScores.length > 0 ? Math.max(...trustScores) : null,
513
+ },
514
+ highRiskRatio: tempRecords.length > 0 ? round(highRiskCount / tempRecords.length, 4) : null,
515
+ diffTrend: buildDiffTrend(tempRecords),
516
+ diagnostics: [],
517
+ }
518
+ }