@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
package/src/trust.ts ADDED
@@ -0,0 +1,774 @@
1
+ import { RULE_WEIGHTS } from './analyzer.js'
2
+ import type {
3
+ DriftConfig,
4
+ DriftDiff,
5
+ DriftReport,
6
+ DriftTrustReport,
7
+ MergeRiskLevel,
8
+ TrustGatePolicyPack,
9
+ TrustGatePolicyPreset,
10
+ TrustDiffContext,
11
+ TrustFixPriority,
12
+ TrustReason,
13
+ TrustAdvancedComparison,
14
+ } from './types.js'
15
+ import type { SnapshotEntry } from './snapshot.js'
16
+
17
+ const ARCHITECTURE_RULES = new Set([
18
+ 'circular-dependency',
19
+ 'layer-violation',
20
+ 'cross-boundary-import',
21
+ 'controller-no-db',
22
+ 'service-no-http',
23
+ ])
24
+
25
+ const RULE_SUGGESTIONS: Record<string, string> = {
26
+ 'circular-dependency': 'Break cycles first to reduce hidden merge blast radius.',
27
+ 'layer-violation': 'Fix layer violations to keep architecture boundaries enforceable.',
28
+ 'high-complexity': 'Split branch-heavy functions before adding more logic.',
29
+ 'deep-nesting': 'Flatten control flow with early returns.',
30
+ 'large-file': 'Split monolithic files by responsibility before merge.',
31
+ 'large-function': 'Extract smaller functions to reduce review complexity.',
32
+ 'catch-swallow': 'Handle or rethrow swallowed errors to avoid silent failures.',
33
+ 'debug-leftover': 'Remove debug leftovers from production paths.',
34
+ 'semantic-duplication': 'Consolidate duplicated logic to prevent divergent fixes.',
35
+ 'dead-file': 'Delete or wire dead files to avoid stale merge artifacts.',
36
+ }
37
+
38
+ const SYSTEMIC_RULES = new Set([
39
+ 'circular-dependency',
40
+ 'layer-violation',
41
+ 'cross-boundary-import',
42
+ 'unused-export',
43
+ 'unused-dependency',
44
+ 'dead-file',
45
+ 'semantic-duplication',
46
+ 'controller-no-db',
47
+ 'service-no-http',
48
+ ])
49
+
50
+ interface BuildTrustOptions {
51
+ diff?: DriftDiff
52
+ advanced?: {
53
+ enabled?: boolean
54
+ previousTrust?: Partial<DriftTrustReport>
55
+ snapshots?: SnapshotEntry[]
56
+ }
57
+ }
58
+
59
+ interface TrustRenderOptions {
60
+ json?: boolean
61
+ markdown?: boolean
62
+ }
63
+
64
+ function formatTrustGatePolicyValues(values: TrustGateOptions): string {
65
+ const enabled = typeof values.enabled === 'boolean' ? String(values.enabled) : 'inherit'
66
+ const minTrust = typeof values.minTrust === 'number' ? String(values.minTrust) : 'inherit'
67
+ const maxRisk = values.maxRisk ?? 'inherit'
68
+ return `enabled=${enabled} minTrust=${minTrust} maxRisk=${maxRisk}`
69
+ }
70
+
71
+ export interface TrustGateOptions {
72
+ enabled?: boolean
73
+ minTrust?: number
74
+ maxRisk?: MergeRiskLevel
75
+ }
76
+
77
+ export interface TrustGatePolicyResolutionOptions {
78
+ branchName?: string
79
+ policyPack?: string
80
+ overrides?: TrustGateOptions
81
+ }
82
+
83
+ export interface TrustGatePolicyResolutionStep {
84
+ source: 'base' | 'policy-pack' | 'branch-preset' | 'overrides'
85
+ name: string
86
+ values: TrustGateOptions
87
+ }
88
+
89
+ export interface TrustGatePolicyExplanation {
90
+ effectivePolicy: TrustGateOptions
91
+ branchName?: string
92
+ selectedPolicyPack?: string
93
+ invalidPolicyPack?: string
94
+ steps: TrustGatePolicyResolutionStep[]
95
+ }
96
+
97
+ export interface TrustGateEvaluation {
98
+ shouldFail: boolean
99
+ reasons: string[]
100
+ checks: {
101
+ gateDisabled: boolean
102
+ belowMinTrust: boolean
103
+ aboveMaxRisk: boolean
104
+ minTrust?: number
105
+ maxRisk?: MergeRiskLevel
106
+ }
107
+ }
108
+
109
+ export const MERGE_RISK_ORDER: MergeRiskLevel[] = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']
110
+
111
+ const BRANCH_ENV_CANDIDATES = [
112
+ 'DRIFT_BRANCH',
113
+ 'GITHUB_HEAD_REF',
114
+ 'GITHUB_REF_NAME',
115
+ 'CI_COMMIT_REF_NAME',
116
+ 'BRANCH_NAME',
117
+ ] as const
118
+
119
+ export function normalizeMergeRiskLevel(value: string): MergeRiskLevel | undefined {
120
+ const normalized = value.toUpperCase()
121
+ return MERGE_RISK_ORDER.find((level) => level === normalized)
122
+ }
123
+
124
+ function branchPatternToRegExp(pattern: string): RegExp {
125
+ const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, '\\$&').replace(/\*/g, '.*')
126
+ return new RegExp(`^${escaped}$`)
127
+ }
128
+
129
+ function patternSpecificity(pattern: string): number {
130
+ const wildcardCount = (pattern.match(/\*/g) ?? []).length
131
+ const staticChars = pattern.replace(/\*/g, '').length
132
+ const exactBoost = wildcardCount === 0 ? 10_000 : 0
133
+ return exactBoost + staticChars * 10 - wildcardCount
134
+ }
135
+
136
+ function resolvePresetsForBranch(
137
+ branchName: string,
138
+ presets: TrustGatePolicyPreset[] | undefined,
139
+ ): TrustGatePolicyPreset[] {
140
+ if (!presets || presets.length === 0) return []
141
+
142
+ const matched: Array<{ preset: TrustGatePolicyPreset; specificity: number; index: number }> = []
143
+
144
+ for (let index = 0; index < presets.length; index += 1) {
145
+ const preset = presets[index]
146
+ if (!preset?.branch) continue
147
+
148
+ const regex = branchPatternToRegExp(preset.branch)
149
+ if (!regex.test(branchName)) continue
150
+
151
+ matched.push({ preset, specificity: patternSpecificity(preset.branch), index })
152
+ }
153
+
154
+ matched.sort((a, b) => a.specificity - b.specificity || a.index - b.index)
155
+ return matched.map((entry) => entry.preset)
156
+ }
157
+
158
+ function normalizeMinTrust(value: unknown): number | undefined {
159
+ return typeof value === 'number' && !Number.isNaN(value) ? value : undefined
160
+ }
161
+
162
+ function normalizeMaxRisk(value: unknown): MergeRiskLevel | undefined {
163
+ if (typeof value !== 'string') return undefined
164
+ return normalizeMergeRiskLevel(value)
165
+ }
166
+
167
+ function normalizeTrustGateOptions(
168
+ source: { enabled?: unknown; minTrust?: unknown; maxRisk?: unknown } | undefined,
169
+ ): TrustGateOptions {
170
+ if (!source) return {}
171
+
172
+ return {
173
+ enabled: typeof source.enabled === 'boolean' ? source.enabled : undefined,
174
+ minTrust: normalizeMinTrust(source.minTrust),
175
+ maxRisk: normalizeMaxRisk(source.maxRisk),
176
+ }
177
+ }
178
+
179
+ function mergeTrustGateOptions(base: TrustGateOptions, layer: TrustGateOptions): TrustGateOptions {
180
+ return {
181
+ enabled: typeof layer.enabled === 'boolean' ? layer.enabled : base.enabled,
182
+ minTrust: layer.minTrust ?? base.minTrust,
183
+ maxRisk: layer.maxRisk ?? base.maxRisk,
184
+ }
185
+ }
186
+
187
+ function normalizeResolutionOptions(
188
+ branchNameOrOptions?: string | TrustGatePolicyResolutionOptions,
189
+ explicitOverrides?: TrustGateOptions,
190
+ ): TrustGatePolicyResolutionOptions {
191
+ if (typeof branchNameOrOptions === 'string') {
192
+ return {
193
+ branchName: branchNameOrOptions,
194
+ overrides: explicitOverrides,
195
+ }
196
+ }
197
+
198
+ if (!branchNameOrOptions) {
199
+ return {
200
+ overrides: explicitOverrides,
201
+ }
202
+ }
203
+
204
+ return {
205
+ ...branchNameOrOptions,
206
+ overrides: explicitOverrides
207
+ ? mergeTrustGateOptions(normalizeTrustGateOptions(branchNameOrOptions.overrides), normalizeTrustGateOptions(explicitOverrides))
208
+ : branchNameOrOptions.overrides,
209
+ }
210
+ }
211
+
212
+ function resolvePolicyPack(
213
+ policyPacks: Record<string, TrustGatePolicyPack> | undefined,
214
+ policyPackName: string | undefined,
215
+ ): { name?: string; pack?: TrustGatePolicyPack; invalid?: string } {
216
+ const normalizedName = policyPackName?.trim()
217
+ if (!normalizedName) return {}
218
+
219
+ if (!policyPacks) {
220
+ return { name: normalizedName, invalid: normalizedName }
221
+ }
222
+
223
+ const pack = policyPacks[normalizedName]
224
+ if (!pack) {
225
+ return { name: normalizedName, invalid: normalizedName }
226
+ }
227
+
228
+ return { name: normalizedName, pack }
229
+ }
230
+
231
+ export function detectBranchName(env: NodeJS.ProcessEnv = process.env): string | undefined {
232
+ for (const key of BRANCH_ENV_CANDIDATES) {
233
+ const value = env[key]?.trim()
234
+ if (value) return value
235
+ }
236
+
237
+ return undefined
238
+ }
239
+
240
+ export function explainTrustGatePolicy(
241
+ config: DriftConfig | undefined,
242
+ branchName?: string,
243
+ overrides?: TrustGateOptions,
244
+ ): TrustGatePolicyExplanation
245
+ export function explainTrustGatePolicy(
246
+ config: DriftConfig | undefined,
247
+ options?: TrustGatePolicyResolutionOptions,
248
+ ): TrustGatePolicyExplanation
249
+ export function explainTrustGatePolicy(
250
+ config: DriftConfig | undefined,
251
+ branchNameOrOptions?: string | TrustGatePolicyResolutionOptions,
252
+ explicitOverrides?: TrustGateOptions,
253
+ ): TrustGatePolicyExplanation {
254
+ const policy = config?.trustGate
255
+ const resolution = normalizeResolutionOptions(branchNameOrOptions, explicitOverrides)
256
+ const normalizedBranch = resolution.branchName?.trim()
257
+ const packResolution = resolvePolicyPack(policy?.policyPacks, resolution.policyPack)
258
+
259
+ const steps: TrustGatePolicyResolutionStep[] = []
260
+ const base = normalizeTrustGateOptions(policy)
261
+ let effective = base
262
+ steps.push({ source: 'base', name: 'trustGate', values: base })
263
+
264
+ if (packResolution.pack) {
265
+ const packOptions = normalizeTrustGateOptions(packResolution.pack)
266
+ effective = mergeTrustGateOptions(effective, packOptions)
267
+ steps.push({ source: 'policy-pack', name: packResolution.name ?? 'unknown', values: packOptions })
268
+ }
269
+
270
+ if (normalizedBranch) {
271
+ const matchedPresets = resolvePresetsForBranch(normalizedBranch, policy?.presets)
272
+ for (const preset of matchedPresets) {
273
+ const presetOptions = normalizeTrustGateOptions(preset)
274
+ effective = mergeTrustGateOptions(effective, presetOptions)
275
+ steps.push({ source: 'branch-preset', name: preset.branch, values: presetOptions })
276
+ }
277
+ }
278
+
279
+ const overrides = normalizeTrustGateOptions(resolution.overrides)
280
+ if (Object.values(overrides).some((value) => value !== undefined)) {
281
+ effective = mergeTrustGateOptions(effective, overrides)
282
+ steps.push({ source: 'overrides', name: 'cli', values: overrides })
283
+ }
284
+
285
+ return {
286
+ effectivePolicy: effective,
287
+ branchName: normalizedBranch,
288
+ selectedPolicyPack: packResolution.name,
289
+ invalidPolicyPack: packResolution.invalid,
290
+ steps,
291
+ }
292
+ }
293
+
294
+ export function resolveTrustGatePolicy(
295
+ config: DriftConfig | undefined,
296
+ branchName?: string,
297
+ overrides?: TrustGateOptions,
298
+ ): TrustGateOptions
299
+ export function resolveTrustGatePolicy(
300
+ config: DriftConfig | undefined,
301
+ options?: TrustGatePolicyResolutionOptions,
302
+ ): TrustGateOptions
303
+ export function resolveTrustGatePolicy(
304
+ config: DriftConfig | undefined,
305
+ branchNameOrOptions?: string | TrustGatePolicyResolutionOptions,
306
+ explicitOverrides?: TrustGateOptions,
307
+ ): TrustGateOptions {
308
+ const options = normalizeResolutionOptions(branchNameOrOptions, explicitOverrides)
309
+ return explainTrustGatePolicy(config, options).effectivePolicy
310
+ }
311
+
312
+ export function formatTrustGatePolicyExplanation(explanation: TrustGatePolicyExplanation): string {
313
+ const lines = ['Trust gate policy resolution:']
314
+ lines.push(`- branch: ${explanation.branchName ?? 'not provided'}`)
315
+ lines.push(`- policy pack: ${explanation.selectedPolicyPack ?? 'not selected'}`)
316
+ if (explanation.invalidPolicyPack) {
317
+ lines.push(`- invalid policy pack: ${explanation.invalidPolicyPack}`)
318
+ }
319
+ lines.push('- steps:')
320
+
321
+ for (const [index, step] of explanation.steps.entries()) {
322
+ lines.push(` ${index + 1}. ${step.source} (${step.name}): ${formatTrustGatePolicyValues(step.values)}`)
323
+ }
324
+
325
+ lines.push(`- effective: ${formatTrustGatePolicyValues(explanation.effectivePolicy)}`)
326
+ return lines.join('\n')
327
+ }
328
+
329
+ function clamp(value: number, min: number, max: number): number {
330
+ return Math.max(min, Math.min(max, value))
331
+ }
332
+
333
+ function toMergeRisk(trustScore: number): MergeRiskLevel {
334
+ if (trustScore >= 80) return 'LOW'
335
+ if (trustScore >= 60) return 'MEDIUM'
336
+ if (trustScore >= 40) return 'HIGH'
337
+ return 'CRITICAL'
338
+ }
339
+
340
+ function computeReasons(report: DriftReport): TrustReason[] {
341
+ const architectureIssues = Object.entries(report.summary.byRule)
342
+ .filter(([rule]) => ARCHITECTURE_RULES.has(rule))
343
+ .reduce((sum, [, count]) => sum + count, 0)
344
+
345
+ const worstHotspot = report.maintenanceRisk.hotspots[0]
346
+ const reasons: TrustReason[] = [
347
+ {
348
+ label: 'Drift score pressure',
349
+ detail: `Repository drift score is ${report.totalScore}/100.`,
350
+ impact: Math.round(report.totalScore * 0.55),
351
+ },
352
+ {
353
+ label: 'Error-level issues',
354
+ detail: `${report.summary.errors} error issue(s) increase merge volatility.`,
355
+ impact: Math.min(22, report.summary.errors * 4),
356
+ },
357
+ {
358
+ label: 'Architecture signals',
359
+ detail: `${architectureIssues} architecture-related issue(s) detected.`,
360
+ impact: Math.min(24, architectureIssues * 6),
361
+ },
362
+ {
363
+ label: 'Maintenance hotspots',
364
+ detail: `Maintenance risk is ${report.maintenanceRisk.level.toUpperCase()} (${report.maintenanceRisk.score}/100).`,
365
+ impact: Math.min(25, Math.round(report.maintenanceRisk.score * 0.25)),
366
+ },
367
+ {
368
+ label: 'Highest-risk file',
369
+ detail: worstHotspot
370
+ ? `${worstHotspot.file} has hotspot risk ${worstHotspot.risk}/100.`
371
+ : 'No hotspot concentration detected.',
372
+ impact: worstHotspot ? Math.min(15, Math.round(worstHotspot.risk * 0.15)) : 0,
373
+ },
374
+ ]
375
+
376
+ return reasons
377
+ .filter((reason) => reason.impact > 0)
378
+ .sort((a, b) => b.impact - a.impact)
379
+ .slice(0, 4)
380
+ }
381
+
382
+ function effortFromWeight(weight: number): 'low' | 'medium' | 'high' {
383
+ if (weight <= 6) return 'low'
384
+ if (weight <= 12) return 'medium'
385
+ return 'high'
386
+ }
387
+
388
+ function computeDiffContext(diff: DriftDiff): TrustDiffContext {
389
+ const scoreRegressionPenalty = Math.max(0, diff.totalDelta) * 2
390
+ const newIssuePenalty = diff.newIssuesCount * 3
391
+ const churnPenalty = diff.files.length >= 15 ? 4 : 0
392
+ const penalty = clamp(scoreRegressionPenalty + newIssuePenalty + churnPenalty, 0, 30)
393
+
394
+ const scoreImprovementBonus = Math.max(0, -diff.totalDelta) * 2
395
+ const resolvedIssueBonus = diff.resolvedIssuesCount * 2
396
+ const bonus = clamp(scoreImprovementBonus + resolvedIssueBonus, 0, 20)
397
+
398
+ const netImpact = penalty - bonus
399
+ const status = netImpact > 0 ? 'regressed' : netImpact < 0 ? 'improved' : 'neutral'
400
+
401
+ return {
402
+ baseRef: diff.baseRef,
403
+ status,
404
+ scoreDelta: diff.totalDelta,
405
+ newIssues: diff.newIssuesCount,
406
+ resolvedIssues: diff.resolvedIssuesCount,
407
+ filesChanged: diff.files.length,
408
+ penalty,
409
+ bonus,
410
+ netImpact,
411
+ }
412
+ }
413
+
414
+ function confidenceFromPrioritySignals(
415
+ occurrences: number,
416
+ severity: 'error' | 'warning' | 'info',
417
+ systemic: boolean,
418
+ ): 'low' | 'medium' | 'high' {
419
+ const severityScore = severity === 'error' ? 4 : severity === 'warning' ? 2 : 1
420
+ const systemicScore = systemic ? 2 : 0
421
+ const score = occurrences * 2 + severityScore + systemicScore
422
+
423
+ if (score >= 12) return 'high'
424
+ if (score >= 7) return 'medium'
425
+ return 'low'
426
+ }
427
+
428
+ function computeFixPriorities(report: DriftReport, advancedMode = false): TrustFixPriority[] {
429
+ const ordered = Object.entries(report.summary.byRule)
430
+ .map(([rule, occurrences]) => {
431
+ const weightConfig = RULE_WEIGHTS[rule] ?? { severity: 'warning' as const, weight: 6 }
432
+ const severityBoost = weightConfig.severity === 'error' ? 25 : weightConfig.severity === 'warning' ? 12 : 4
433
+ const systemic = SYSTEMIC_RULES.has(rule)
434
+ const systemicBoost = advancedMode && systemic ? 25 : 0
435
+ const priorityScore = occurrences * weightConfig.weight + severityBoost + systemicBoost
436
+ const confidence = confidenceFromPrioritySignals(occurrences, weightConfig.severity, systemic)
437
+ const explanation = advancedMode
438
+ ? systemic
439
+ ? 'System-level rule that propagates risk across multiple teams and modules.'
440
+ : 'Local rule with contained impact; treat as team-level cleanup after systemic fixes.'
441
+ : undefined
442
+
443
+ return {
444
+ rule,
445
+ severity: weightConfig.severity,
446
+ occurrences,
447
+ systemic,
448
+ priorityScore,
449
+ estimatedTrustGain: Math.min(30, Math.max(3, Math.round(priorityScore / 4))),
450
+ effort: effortFromWeight(weightConfig.weight),
451
+ suggestion: RULE_SUGGESTIONS[rule] ?? 'Address this rule in the highest-scored files first.',
452
+ confidence,
453
+ explanation,
454
+ }
455
+ })
456
+ .sort((a, b) => b.priorityScore - a.priorityScore)
457
+ .slice(0, 5)
458
+
459
+ return ordered.map((item, index) => ({
460
+ rank: index + 1,
461
+ rule: item.rule,
462
+ severity: item.severity,
463
+ occurrences: item.occurrences,
464
+ estimated_trust_gain: item.estimatedTrustGain,
465
+ effort: item.effort,
466
+ suggestion: item.suggestion,
467
+ ...(advancedMode ? { confidence: item.confidence, explanation: item.explanation, systemic: item.systemic } : {}),
468
+ }))
469
+ }
470
+
471
+ function buildComparisonFromPreviousTrust(
472
+ trustScore: number,
473
+ previousTrust: Partial<DriftTrustReport> | undefined,
474
+ ): TrustAdvancedComparison | undefined {
475
+ if (!previousTrust || typeof previousTrust.trust_score !== 'number') return undefined
476
+
477
+ const trustDelta = trustScore - previousTrust.trust_score
478
+ const trend = trustDelta > 0 ? 'improving' : trustDelta < 0 ? 'regressing' : 'stable'
479
+
480
+ return {
481
+ source: 'previous-trust-json',
482
+ trend,
483
+ summary: `Trust moved ${trustDelta >= 0 ? '+' : ''}${trustDelta} vs provided previous trust JSON.`,
484
+ trust_delta: trustDelta,
485
+ previous_trust_score: previousTrust.trust_score,
486
+ previous_merge_risk: previousTrust.merge_risk,
487
+ }
488
+ }
489
+
490
+ function buildComparisonFromSnapshotHistory(
491
+ report: DriftReport,
492
+ snapshots: SnapshotEntry[] | undefined,
493
+ ): TrustAdvancedComparison | undefined {
494
+ const lastSnapshot = snapshots && snapshots.length > 0 ? snapshots[snapshots.length - 1] : undefined
495
+ if (!lastSnapshot) return undefined
496
+
497
+ const snapshotScoreDelta = report.totalScore - lastSnapshot.score
498
+ const trend = snapshotScoreDelta < 0 ? 'improving' : snapshotScoreDelta > 0 ? 'regressing' : 'stable'
499
+ const snapshotContext = lastSnapshot.label
500
+ ? `${lastSnapshot.timestamp} (${lastSnapshot.label})`
501
+ : lastSnapshot.timestamp
502
+
503
+ return {
504
+ source: 'snapshot-history',
505
+ trend,
506
+ summary: `Drift score moved ${snapshotScoreDelta >= 0 ? '+' : ''}${snapshotScoreDelta} vs snapshot ${snapshotContext}.`,
507
+ snapshot_score_delta: snapshotScoreDelta,
508
+ snapshot_label: lastSnapshot.label || undefined,
509
+ snapshot_timestamp: lastSnapshot.timestamp,
510
+ }
511
+ }
512
+
513
+ function buildTeamGuidance(
514
+ priorities: TrustFixPriority[],
515
+ comparison: TrustAdvancedComparison | undefined,
516
+ diffContext: TrustDiffContext | undefined,
517
+ ): string[] {
518
+ const systemicTargets = priorities
519
+ .filter((priority) => priority.systemic)
520
+ .slice(0, 2)
521
+ .map((priority) => `${priority.rule} (x${priority.occurrences})`)
522
+
523
+ const guidance: string[] = []
524
+ if (systemicTargets.length > 0) {
525
+ guidance.push(`Start with systemic rules: ${systemicTargets.join(', ')}.`)
526
+ }
527
+
528
+ if (comparison?.trend === 'regressing') {
529
+ guidance.push('Trend regressed; freeze net-new debt in CI and assign owners per systemic rule.')
530
+ }
531
+
532
+ if (diffContext && diffContext.newIssues > 0) {
533
+ guidance.push(`Block net-new issue growth first (+${diffContext.newIssues} new issue(s) in diff context).`)
534
+ }
535
+
536
+ if (guidance.length === 0) {
537
+ guidance.push('Maintain current baseline and schedule periodic systemic debt cleanup by rule ownership.')
538
+ }
539
+
540
+ return guidance.slice(0, 3)
541
+ }
542
+
543
+ export function buildTrustReport(report: DriftReport, options?: BuildTrustOptions): DriftTrustReport {
544
+ const reasons = computeReasons(report)
545
+ const advancedMode = options?.advanced?.enabled === true
546
+
547
+ const diffContext = options?.diff ? computeDiffContext(options.diff) : undefined
548
+ if (diffContext && diffContext.netImpact > 0) {
549
+ reasons.push({
550
+ label: 'Diff regression signals',
551
+ detail: `Against ${diffContext.baseRef}: score delta ${diffContext.scoreDelta >= 0 ? '+' : ''}${diffContext.scoreDelta}, +${diffContext.newIssues} new issue(s), -${diffContext.resolvedIssues} resolved.`,
552
+ impact: diffContext.netImpact,
553
+ })
554
+ }
555
+
556
+ const rankedReasons = reasons
557
+ .filter((reason) => reason.impact > 0)
558
+ .sort((a, b) => b.impact - a.impact)
559
+ .slice(0, 4)
560
+
561
+ const totalPenalty = rankedReasons.reduce((sum, reason) => sum + reason.impact, 0)
562
+ const totalBonus = diffContext && diffContext.netImpact < 0 ? Math.abs(diffContext.netImpact) : 0
563
+ const trustScore = clamp(Math.round(100 - totalPenalty + totalBonus), 0, 100)
564
+
565
+ const comparison = advancedMode
566
+ ? buildComparisonFromPreviousTrust(trustScore, options?.advanced?.previousTrust)
567
+ ?? buildComparisonFromSnapshotHistory(report, options?.advanced?.snapshots)
568
+ : undefined
569
+
570
+ const fixPriorities = computeFixPriorities(report, advancedMode)
571
+ const advancedContext = advancedMode
572
+ ? {
573
+ comparison,
574
+ team_guidance: buildTeamGuidance(fixPriorities, comparison, diffContext),
575
+ }
576
+ : undefined
577
+
578
+ return {
579
+ scannedAt: new Date().toISOString(),
580
+ targetPath: report.targetPath,
581
+ trust_score: trustScore,
582
+ merge_risk: toMergeRisk(trustScore),
583
+ top_reasons: rankedReasons,
584
+ fix_priorities: fixPriorities,
585
+ diff_context: diffContext,
586
+ ...(advancedContext ? { advanced_context: advancedContext } : {}),
587
+ }
588
+ }
589
+
590
+ export function formatTrustConsole(trust: DriftTrustReport): string {
591
+ const diffContext = trust.diff_context
592
+ const diffLines = diffContext
593
+ ? [
594
+ `- base: ${diffContext.baseRef}`,
595
+ `- status: ${diffContext.status.toUpperCase()}`,
596
+ `- score delta: ${diffContext.scoreDelta >= 0 ? '+' : ''}${diffContext.scoreDelta}`,
597
+ `- issues: +${diffContext.newIssues} new / -${diffContext.resolvedIssues} resolved`,
598
+ `- impact: +${diffContext.penalty} penalty / -${diffContext.bonus} bonus (net ${diffContext.netImpact >= 0 ? '+' : ''}${diffContext.netImpact})`,
599
+ ].join('\n')
600
+ : undefined
601
+
602
+ const reasons = trust.top_reasons.length === 0
603
+ ? '- none'
604
+ : trust.top_reasons.map((reason) => `- ${reason.label}: ${reason.detail} (impact ${reason.impact})`).join('\n')
605
+
606
+ const priorities = trust.fix_priorities.length === 0
607
+ ? '- none'
608
+ : trust.fix_priorities
609
+ .map((priority) =>
610
+ `- #${priority.rank} ${priority.rule} (${priority.severity}, x${priority.occurrences}${priority.confidence ? `, confidence ${priority.confidence}` : ''}): ${priority.suggestion}`
611
+ )
612
+ .join('\n')
613
+
614
+ const advanced = trust.advanced_context
615
+ const advancedComparison = advanced?.comparison
616
+ ? [
617
+ `- source: ${advanced.comparison.source}`,
618
+ `- trend: ${advanced.comparison.trend.toUpperCase()}`,
619
+ `- summary: ${advanced.comparison.summary}`,
620
+ ].join('\n')
621
+ : '- no historical comparison available'
622
+ const advancedGuidance = advanced?.team_guidance?.length
623
+ ? advanced.team_guidance.map((item) => `- ${item}`).join('\n')
624
+ : '- none'
625
+
626
+ const sections = [
627
+ 'drift trust',
628
+ '',
629
+ `Trust Score: ${trust.trust_score}/100`,
630
+ `Merge Risk: ${trust.merge_risk}`,
631
+ '',
632
+ 'Top Reasons:',
633
+ reasons,
634
+ '',
635
+ 'Fix Priorities:',
636
+ priorities,
637
+ ]
638
+
639
+ if (diffLines) {
640
+ sections.splice(5, 0, 'Diff Context:', diffLines, '')
641
+ }
642
+
643
+ if (advanced) {
644
+ sections.push('', 'Advanced Team Guidance:', advancedComparison, '', advancedGuidance)
645
+ }
646
+
647
+ return sections.join('\n')
648
+ }
649
+
650
+ export function formatTrustMarkdown(trust: DriftTrustReport): string {
651
+ const diffContext = trust.diff_context
652
+
653
+ const reasons = trust.top_reasons.length === 0
654
+ ? '- none'
655
+ : trust.top_reasons.map((reason) => `- **${reason.label}**: ${reason.detail} (impact ${reason.impact})`).join('\n')
656
+
657
+ const priorities = trust.fix_priorities.length === 0
658
+ ? '- none'
659
+ : trust.fix_priorities
660
+ .map((priority) =>
661
+ `- #${priority.rank} \`${priority.rule}\` (${priority.severity}, x${priority.occurrences}, effort: ${priority.effort}${priority.confidence ? `, confidence: ${priority.confidence}` : ''}) - ${priority.suggestion}${priority.explanation ? ` ${priority.explanation}` : ''}`
662
+ )
663
+ .join('\n')
664
+
665
+ const diffBlock = !diffContext
666
+ ? [
667
+ '- Base ref: not provided',
668
+ '- Diff-aware adjustment: not applied',
669
+ ].join('\n')
670
+ : [
671
+ `- Base ref: \`${diffContext.baseRef}\``,
672
+ `- Diff status: **${diffContext.status.toUpperCase()}**`,
673
+ `- Score delta: **${diffContext.scoreDelta >= 0 ? '+' : ''}${diffContext.scoreDelta}**`,
674
+ `- Issues: **+${diffContext.newIssues}** new / **-${diffContext.resolvedIssues}** resolved`,
675
+ `- Trust adjustment: **+${diffContext.penalty}** penalty / **-${diffContext.bonus}** bonus (net ${diffContext.netImpact >= 0 ? '+' : ''}${diffContext.netImpact})`,
676
+ ].join('\n')
677
+
678
+ const advancedComparison = trust.advanced_context?.comparison
679
+ ? [
680
+ `- Source: \`${trust.advanced_context.comparison.source}\``,
681
+ `- Trend: **${trust.advanced_context.comparison.trend.toUpperCase()}**`,
682
+ `- Summary: ${trust.advanced_context.comparison.summary}`,
683
+ ].join('\n')
684
+ : '- Historical comparison not available'
685
+
686
+ const advancedGuidance = trust.advanced_context?.team_guidance?.length
687
+ ? trust.advanced_context.team_guidance.map((item) => `- ${item}`).join('\n')
688
+ : '- none'
689
+
690
+ const sections = [
691
+ '## drift trust',
692
+ '',
693
+ `- Trust Score: **${trust.trust_score}/100**`,
694
+ `- Merge Risk: **${trust.merge_risk}**`,
695
+ `- Target: \`${trust.targetPath}\``,
696
+ '',
697
+ '### Diff signals',
698
+ diffBlock,
699
+ '',
700
+ '### Top reasons',
701
+ reasons,
702
+ '',
703
+ '### Fix priorities',
704
+ priorities,
705
+ ]
706
+
707
+ if (trust.advanced_context) {
708
+ sections.push('', '### Advanced comparison', advancedComparison, '', '### Team guidance', advancedGuidance)
709
+ }
710
+
711
+ return sections.join('\n')
712
+ }
713
+
714
+ export function formatTrustJson(trust: DriftTrustReport): string {
715
+ return JSON.stringify(trust, null, 2)
716
+ }
717
+
718
+ export function renderTrustOutput(trust: DriftTrustReport, options?: TrustRenderOptions): string {
719
+ if (options?.json) return formatTrustJson(trust)
720
+ if (options?.markdown) return formatTrustMarkdown(trust)
721
+ return formatTrustConsole(trust)
722
+ }
723
+
724
+ export function shouldFailByMaxRisk(actual: MergeRiskLevel, allowedMaxRisk: MergeRiskLevel): boolean {
725
+ return MERGE_RISK_ORDER.indexOf(actual) > MERGE_RISK_ORDER.indexOf(allowedMaxRisk)
726
+ }
727
+
728
+ export function evaluateTrustGate(trust: DriftTrustReport, options: TrustGateOptions): TrustGateEvaluation {
729
+ if (options.enabled === false) {
730
+ return {
731
+ shouldFail: false,
732
+ reasons: ['trust gate disabled by policy'],
733
+ checks: {
734
+ gateDisabled: true,
735
+ belowMinTrust: false,
736
+ aboveMaxRisk: false,
737
+ minTrust: options.minTrust,
738
+ maxRisk: options.maxRisk,
739
+ },
740
+ }
741
+ }
742
+
743
+ const belowMinTrust =
744
+ typeof options.minTrust === 'number' &&
745
+ !Number.isNaN(options.minTrust) &&
746
+ trust.trust_score < options.minTrust
747
+
748
+ const aboveMaxRisk = Boolean(options.maxRisk && shouldFailByMaxRisk(trust.merge_risk, options.maxRisk))
749
+ const reasons: string[] = []
750
+
751
+ if (belowMinTrust) {
752
+ reasons.push(`trust ${trust.trust_score} is below minTrust ${options.minTrust}`)
753
+ }
754
+
755
+ if (aboveMaxRisk && options.maxRisk) {
756
+ reasons.push(`merge risk ${trust.merge_risk} exceeds maxRisk ${options.maxRisk}`)
757
+ }
758
+
759
+ return {
760
+ shouldFail: belowMinTrust || aboveMaxRisk,
761
+ reasons,
762
+ checks: {
763
+ gateDisabled: false,
764
+ belowMinTrust,
765
+ aboveMaxRisk,
766
+ minTrust: options.minTrust,
767
+ maxRisk: options.maxRisk,
768
+ },
769
+ }
770
+ }
771
+
772
+ export function shouldFailTrustGate(trust: DriftTrustReport, options: TrustGateOptions): boolean {
773
+ return evaluateTrustGate(trust, options).shouldFail
774
+ }