@eduardbar/drift 1.1.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 (66) 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 +153 -0
  4. package/AGENTS.md +6 -0
  5. package/README.md +192 -4
  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 +509 -23
  12. package/dist/diff.js +74 -10
  13. package/dist/git.js +12 -0
  14. package/dist/index.d.ts +5 -1
  15. package/dist/index.js +3 -0
  16. package/dist/map.d.ts +3 -2
  17. package/dist/map.js +98 -10
  18. package/dist/plugins.d.ts +2 -1
  19. package/dist/plugins.js +177 -28
  20. package/dist/printer.js +4 -0
  21. package/dist/review.js +2 -2
  22. package/dist/rules/comments.js +2 -2
  23. package/dist/rules/complexity.js +2 -7
  24. package/dist/rules/nesting.js +3 -13
  25. package/dist/rules/phase0-basic.js +10 -10
  26. package/dist/rules/shared.d.ts +2 -0
  27. package/dist/rules/shared.js +27 -3
  28. package/dist/saas.d.ts +219 -0
  29. package/dist/saas.js +762 -0
  30. package/dist/trust-kpi.d.ts +9 -0
  31. package/dist/trust-kpi.js +445 -0
  32. package/dist/trust.d.ts +65 -0
  33. package/dist/trust.js +571 -0
  34. package/dist/types.d.ts +160 -0
  35. package/docs/PRD.md +199 -172
  36. package/docs/plugin-contract.md +61 -0
  37. package/docs/trust-core-release-checklist.md +55 -0
  38. package/package.json +5 -3
  39. package/packages/vscode-drift/src/code-actions.ts +53 -0
  40. package/packages/vscode-drift/src/extension.ts +11 -0
  41. package/src/analyzer.ts +484 -155
  42. package/src/benchmark.ts +244 -0
  43. package/src/cli.ts +628 -36
  44. package/src/diff.ts +75 -10
  45. package/src/git.ts +16 -0
  46. package/src/index.ts +63 -0
  47. package/src/map.ts +112 -10
  48. package/src/plugins.ts +354 -26
  49. package/src/printer.ts +4 -0
  50. package/src/review.ts +2 -2
  51. package/src/rules/comments.ts +2 -2
  52. package/src/rules/complexity.ts +2 -7
  53. package/src/rules/nesting.ts +3 -13
  54. package/src/rules/phase0-basic.ts +11 -12
  55. package/src/rules/shared.ts +31 -3
  56. package/src/saas.ts +1031 -0
  57. package/src/trust-kpi.ts +518 -0
  58. package/src/trust.ts +774 -0
  59. package/src/types.ts +177 -0
  60. package/tests/diff.test.ts +124 -0
  61. package/tests/new-features.test.ts +98 -0
  62. package/tests/plugins.test.ts +219 -0
  63. package/tests/rules.test.ts +23 -1
  64. package/tests/saas-foundation.test.ts +464 -0
  65. package/tests/trust-kpi.test.ts +120 -0
  66. package/tests/trust.test.ts +584 -0
@@ -0,0 +1,584 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ buildTrustReport,
4
+ detectBranchName,
5
+ evaluateTrustGate,
6
+ formatTrustGatePolicyExplanation,
7
+ explainTrustGatePolicy,
8
+ formatTrustJson,
9
+ formatTrustMarkdown,
10
+ normalizeMergeRiskLevel,
11
+ renderTrustOutput,
12
+ resolveTrustGatePolicy,
13
+ shouldFailByMaxRisk,
14
+ shouldFailTrustGate,
15
+ } from '../src/trust.js'
16
+ import type { DriftConfig, DriftDiff, DriftReport } from '../src/types.js'
17
+
18
+ function createBaseReport(overrides?: Partial<DriftReport>): DriftReport {
19
+ return {
20
+ scannedAt: new Date().toISOString(),
21
+ targetPath: '/tmp/repo',
22
+ files: [],
23
+ totalIssues: 0,
24
+ totalScore: 0,
25
+ totalFiles: 0,
26
+ summary: {
27
+ errors: 0,
28
+ warnings: 0,
29
+ infos: 0,
30
+ byRule: {},
31
+ },
32
+ quality: {
33
+ overall: 100,
34
+ dimensions: {
35
+ architecture: 100,
36
+ complexity: 100,
37
+ 'ai-patterns': 100,
38
+ testing: 100,
39
+ },
40
+ },
41
+ maintenanceRisk: {
42
+ score: 0,
43
+ level: 'low',
44
+ hotspots: [],
45
+ signals: {
46
+ highComplexityFiles: 0,
47
+ filesWithoutNearbyTests: 0,
48
+ frequentChangeFiles: 0,
49
+ },
50
+ },
51
+ ...overrides,
52
+ }
53
+ }
54
+
55
+ describe('drift trust baseline', () => {
56
+ it('builds trust report contract with required fields', () => {
57
+ const report = createBaseReport({
58
+ totalIssues: 8,
59
+ totalScore: 52,
60
+ summary: {
61
+ errors: 2,
62
+ warnings: 5,
63
+ infos: 1,
64
+ byRule: {
65
+ 'high-complexity': 2,
66
+ 'debug-leftover': 3,
67
+ 'layer-violation': 1,
68
+ },
69
+ },
70
+ maintenanceRisk: {
71
+ score: 70,
72
+ level: 'high',
73
+ hotspots: [{
74
+ file: '/tmp/repo/src/api/user.ts',
75
+ driftScore: 65,
76
+ complexityIssues: 2,
77
+ hasNearbyTests: false,
78
+ changeFrequency: 9,
79
+ risk: 82,
80
+ reasons: ['high complexity signals'],
81
+ }],
82
+ signals: {
83
+ highComplexityFiles: 1,
84
+ filesWithoutNearbyTests: 1,
85
+ frequentChangeFiles: 1,
86
+ },
87
+ },
88
+ })
89
+
90
+ const trust = buildTrustReport(report)
91
+ expect(trust.trust_score).toBeGreaterThanOrEqual(0)
92
+ expect(trust.trust_score).toBeLessThanOrEqual(100)
93
+ expect(['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']).toContain(trust.merge_risk)
94
+ expect(trust.top_reasons.length).toBeGreaterThan(0)
95
+ expect(trust.fix_priorities.length).toBeGreaterThan(0)
96
+ expect(trust.fix_priorities[0]?.rank).toBe(1)
97
+ })
98
+
99
+ it('includes architecture signal in top reasons when violations exist', () => {
100
+ const report = createBaseReport({
101
+ totalScore: 25,
102
+ summary: {
103
+ errors: 1,
104
+ warnings: 1,
105
+ infos: 0,
106
+ byRule: {
107
+ 'layer-violation': 1,
108
+ },
109
+ },
110
+ })
111
+
112
+ const trust = buildTrustReport(report)
113
+ const reasonLabels = trust.top_reasons.map((reason) => reason.label)
114
+ expect(reasonLabels).toContain('Architecture signals')
115
+ })
116
+
117
+ it('compares merge risk thresholds for CI gating', () => {
118
+ expect(shouldFailByMaxRisk('CRITICAL', 'HIGH')).toBe(true)
119
+ expect(shouldFailByMaxRisk('HIGH', 'HIGH')).toBe(false)
120
+ expect(shouldFailByMaxRisk('LOW', 'MEDIUM')).toBe(false)
121
+ })
122
+
123
+ it('evaluates trust gate using combined thresholds', () => {
124
+ const trust = buildTrustReport(createBaseReport({ totalScore: 30 }))
125
+ const mediumTrust = { ...trust, trust_score: 65, merge_risk: 'HIGH' as const }
126
+
127
+ expect(shouldFailTrustGate(mediumTrust, { minTrust: 70 })).toBe(true)
128
+ expect(shouldFailTrustGate(mediumTrust, { minTrust: 60 })).toBe(false)
129
+ expect(shouldFailTrustGate(mediumTrust, { maxRisk: 'MEDIUM' })).toBe(true)
130
+ expect(shouldFailTrustGate(mediumTrust, { maxRisk: 'HIGH' })).toBe(false)
131
+ })
132
+
133
+ it('treats disabled trust gate policy as pass-through', () => {
134
+ const trust = buildTrustReport(createBaseReport({ totalScore: 30 }))
135
+ const mediumTrust = { ...trust, trust_score: 65, merge_risk: 'HIGH' as const }
136
+
137
+ expect(shouldFailTrustGate(mediumTrust, { enabled: false, minTrust: 90, maxRisk: 'LOW' })).toBe(false)
138
+
139
+ const evaluation = evaluateTrustGate(mediumTrust, { enabled: false, minTrust: 90, maxRisk: 'LOW' })
140
+ expect(evaluation.shouldFail).toBe(false)
141
+ expect(evaluation.checks.gateDisabled).toBe(true)
142
+ expect(evaluation.reasons).toContain('trust gate disabled by policy')
143
+ })
144
+
145
+ it('normalizes merge risk level inputs', () => {
146
+ expect(normalizeMergeRiskLevel('low')).toBe('LOW')
147
+ expect(normalizeMergeRiskLevel('MEDIUM')).toBe('MEDIUM')
148
+ expect(normalizeMergeRiskLevel('nope')).toBeUndefined()
149
+ })
150
+
151
+ it('formats markdown output for PR comments', () => {
152
+ const report = createBaseReport({
153
+ targetPath: '/tmp/repo',
154
+ totalScore: 22,
155
+ summary: {
156
+ errors: 1,
157
+ warnings: 1,
158
+ infos: 0,
159
+ byRule: {
160
+ 'high-complexity': 1,
161
+ 'layer-violation': 1,
162
+ },
163
+ },
164
+ })
165
+
166
+ const diff: DriftDiff = {
167
+ baseRef: 'origin/main',
168
+ projectPath: '/tmp/repo',
169
+ scannedAt: new Date().toISOString(),
170
+ files: [
171
+ {
172
+ path: '/tmp/repo/src/a.ts',
173
+ scoreBefore: 10,
174
+ scoreAfter: 16,
175
+ scoreDelta: 6,
176
+ newIssues: [],
177
+ resolvedIssues: [],
178
+ },
179
+ ],
180
+ totalScoreBefore: 18,
181
+ totalScoreAfter: 24,
182
+ totalDelta: 6,
183
+ newIssuesCount: 2,
184
+ resolvedIssuesCount: 1,
185
+ }
186
+
187
+ const trust = buildTrustReport(report, { diff })
188
+ const markdown = formatTrustMarkdown(trust)
189
+
190
+ expect(markdown).toContain('## drift trust')
191
+ expect(markdown).toContain('Base ref: `origin/main`')
192
+ expect(markdown).toContain('### Top reasons')
193
+ })
194
+
195
+ it('renders selected trust output format deterministically', () => {
196
+ const report = createBaseReport()
197
+ const trust = buildTrustReport(report)
198
+
199
+ expect(renderTrustOutput(trust, { json: true })).toBe(formatTrustJson(trust))
200
+ expect(renderTrustOutput(trust, { markdown: true })).toBe(formatTrustMarkdown(trust))
201
+ expect(renderTrustOutput(trust, { json: true, markdown: true })).toBe(formatTrustJson(trust))
202
+ })
203
+
204
+ it('keeps baseline trust contract unchanged when advanced mode is disabled', () => {
205
+ const report = createBaseReport({
206
+ totalScore: 28,
207
+ summary: {
208
+ errors: 1,
209
+ warnings: 2,
210
+ infos: 0,
211
+ byRule: {
212
+ 'debug-leftover': 3,
213
+ 'high-complexity': 1,
214
+ },
215
+ },
216
+ })
217
+
218
+ const trust = buildTrustReport(report)
219
+ expect(trust.advanced_context).toBeUndefined()
220
+ expect(trust.fix_priorities[0]).not.toHaveProperty('confidence')
221
+ expect(trust.fix_priorities[0]).not.toHaveProperty('explanation')
222
+ expect(trust.fix_priorities[0]).not.toHaveProperty('systemic')
223
+ })
224
+
225
+ it('enriches trust report with advanced comparison and metadata from previous trust JSON', () => {
226
+ const report = createBaseReport({
227
+ totalScore: 22,
228
+ summary: {
229
+ errors: 1,
230
+ warnings: 1,
231
+ infos: 0,
232
+ byRule: {
233
+ 'layer-violation': 1,
234
+ 'debug-leftover': 1,
235
+ },
236
+ },
237
+ })
238
+
239
+ const trust = buildTrustReport(report, {
240
+ advanced: {
241
+ enabled: true,
242
+ previousTrust: {
243
+ trust_score: 60,
244
+ merge_risk: 'HIGH',
245
+ },
246
+ },
247
+ })
248
+
249
+ expect(trust.advanced_context?.comparison?.source).toBe('previous-trust-json')
250
+ expect(typeof trust.advanced_context?.comparison?.trust_delta).toBe('number')
251
+ expect(trust.advanced_context?.team_guidance.length).toBeGreaterThan(0)
252
+ expect(trust.fix_priorities[0]).toHaveProperty('confidence')
253
+ expect(trust.fix_priorities[0]).toHaveProperty('explanation')
254
+ expect(trust.fix_priorities[0]).toHaveProperty('systemic')
255
+ })
256
+
257
+ it('uses snapshot history as historical fallback in advanced mode', () => {
258
+ const report = createBaseReport({ totalScore: 20 })
259
+ const trust = buildTrustReport(report, {
260
+ advanced: {
261
+ enabled: true,
262
+ snapshots: [
263
+ {
264
+ timestamp: '2026-01-01T00:00:00.000Z',
265
+ label: 'baseline',
266
+ score: 25,
267
+ grade: 'MODERATE',
268
+ totalIssues: 6,
269
+ files: 4,
270
+ byRule: {
271
+ 'debug-leftover': 2,
272
+ },
273
+ },
274
+ ],
275
+ },
276
+ })
277
+
278
+ expect(trust.advanced_context?.comparison?.source).toBe('snapshot-history')
279
+ expect(trust.advanced_context?.comparison?.snapshot_score_delta).toBe(-5)
280
+ })
281
+
282
+ it('prioritizes systemic rules first in advanced mode', () => {
283
+ const report = createBaseReport({
284
+ totalScore: 30,
285
+ summary: {
286
+ errors: 1,
287
+ warnings: 2,
288
+ infos: 0,
289
+ byRule: {
290
+ 'debug-leftover': 6,
291
+ 'layer-violation': 2,
292
+ },
293
+ },
294
+ })
295
+
296
+ const baseline = buildTrustReport(report)
297
+ const advanced = buildTrustReport(report, { advanced: { enabled: true } })
298
+
299
+ expect(baseline.fix_priorities[0]?.rule).toBe('debug-leftover')
300
+ expect(advanced.fix_priorities[0]?.rule).toBe('layer-violation')
301
+ expect(advanced.fix_priorities[0]?.systemic).toBe(true)
302
+ })
303
+ })
304
+
305
+ describe('drift trust branch policy', () => {
306
+ const config: DriftConfig = {
307
+ trustGate: {
308
+ enabled: true,
309
+ minTrust: 45,
310
+ maxRisk: 'HIGH',
311
+ policyPacks: {
312
+ strict: { enabled: true, minTrust: 90, maxRisk: 'LOW' },
313
+ balanced: { minTrust: 60, maxRisk: 'MEDIUM' },
314
+ lenient: { minTrust: 30, maxRisk: 'CRITICAL' },
315
+ },
316
+ presets: [
317
+ { branch: '*', minTrust: 40, maxRisk: 'CRITICAL' },
318
+ { branch: 'main', minTrust: 70, maxRisk: 'MEDIUM' },
319
+ { branch: 'release/*', minTrust: 80, maxRisk: 'LOW' },
320
+ { branch: 'release/legacy', enabled: false },
321
+ ],
322
+ },
323
+ }
324
+
325
+ it('falls back to base policy when branch does not match', () => {
326
+ const policy = resolveTrustGatePolicy(config, 'feature/new-api')
327
+ expect(policy).toMatchObject({ enabled: true, minTrust: 40, maxRisk: 'CRITICAL' })
328
+ })
329
+
330
+ it('prefers exact branch preset over wildcard preset', () => {
331
+ const policy = resolveTrustGatePolicy(config, 'main')
332
+ expect(policy).toMatchObject({ enabled: true, minTrust: 70, maxRisk: 'MEDIUM' })
333
+ })
334
+
335
+ it('prefers more specific wildcard when multiple patterns match', () => {
336
+ const policy = resolveTrustGatePolicy(config, 'release/v1.2.3')
337
+ expect(policy).toMatchObject({ enabled: true, minTrust: 80, maxRisk: 'LOW' })
338
+ })
339
+
340
+ it('applies enabled override from matching branch preset', () => {
341
+ const policy = resolveTrustGatePolicy(config, 'release/legacy')
342
+ expect(policy).toMatchObject({ enabled: false, minTrust: 80, maxRisk: 'LOW' })
343
+ })
344
+
345
+ it('returns empty policy when trust gate config is missing', () => {
346
+ expect(resolveTrustGatePolicy(undefined, 'main')).toEqual({})
347
+ })
348
+
349
+ it('resolves precedence in deterministic order base -> pack -> branch -> overrides', () => {
350
+ const policy = resolveTrustGatePolicy(config, {
351
+ branchName: 'main',
352
+ policyPack: 'strict',
353
+ overrides: { maxRisk: 'CRITICAL' },
354
+ })
355
+
356
+ expect(policy).toMatchObject({ enabled: true, minTrust: 70, maxRisk: 'CRITICAL' })
357
+ })
358
+
359
+ it('reports invalid policy pack and preserves legacy branch behavior', () => {
360
+ const legacy = resolveTrustGatePolicy(config, 'main')
361
+ const explained = explainTrustGatePolicy(config, {
362
+ branchName: 'main',
363
+ policyPack: 'unknown-pack',
364
+ })
365
+
366
+ expect(explained.invalidPolicyPack).toBe('unknown-pack')
367
+ expect(explained.effectivePolicy).toMatchObject(legacy)
368
+ })
369
+
370
+ it('keeps compatibility between branch-only and options signatures', () => {
371
+ const branchOnly = resolveTrustGatePolicy(config, 'release/v1.2.3')
372
+ const optionsBased = resolveTrustGatePolicy(config, { branchName: 'release/v1.2.3' })
373
+
374
+ expect(optionsBased).toEqual(branchOnly)
375
+ })
376
+
377
+ it('explains resolution steps in application order', () => {
378
+ const explained = explainTrustGatePolicy(config, {
379
+ branchName: 'main',
380
+ policyPack: 'balanced',
381
+ overrides: { minTrust: 75 },
382
+ })
383
+
384
+ expect(explained.steps.map((step) => step.source)).toEqual([
385
+ 'base',
386
+ 'policy-pack',
387
+ 'branch-preset',
388
+ 'branch-preset',
389
+ 'overrides',
390
+ ])
391
+ expect(explained.effectivePolicy).toMatchObject({ enabled: true, minTrust: 75, maxRisk: 'MEDIUM' })
392
+ })
393
+
394
+ it('formats policy explanation with layer summary for CLI debug mode', () => {
395
+ const explained = explainTrustGatePolicy(config, {
396
+ branchName: 'main',
397
+ policyPack: 'strict',
398
+ overrides: { maxRisk: 'CRITICAL' },
399
+ })
400
+
401
+ const formatted = formatTrustGatePolicyExplanation(explained)
402
+ expect(formatted).toContain('Trust gate policy resolution:')
403
+ expect(formatted).toContain('base (trustGate)')
404
+ expect(formatted).toContain('policy-pack (strict)')
405
+ expect(formatted).toContain('branch-preset (main)')
406
+ expect(formatted).toContain('overrides (cli)')
407
+ expect(formatted).toContain('effective: enabled=true minTrust=70 maxRisk=CRITICAL')
408
+ })
409
+
410
+ it('detects branch names from CI environment candidates', () => {
411
+ expect(detectBranchName({ GITHUB_HEAD_REF: 'feature/payment', GITHUB_REF_NAME: 'main' })).toBe('feature/payment')
412
+ expect(detectBranchName({ GITHUB_REF_NAME: 'main' })).toBe('main')
413
+ expect(detectBranchName({})).toBeUndefined()
414
+ })
415
+ })
416
+
417
+ describe('drift trust calibration (golden)', () => {
418
+ const scenarios: Array<{
419
+ name: string
420
+ report: DriftReport
421
+ expected: { trust: number; risk: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' }
422
+ }> = [
423
+ {
424
+ name: 'LOW - clean repo baseline',
425
+ report: createBaseReport(),
426
+ expected: { trust: 100, risk: 'LOW' },
427
+ },
428
+ {
429
+ name: 'MEDIUM - moderate pressure with one error',
430
+ report: createBaseReport({
431
+ totalScore: 20,
432
+ summary: {
433
+ errors: 1,
434
+ warnings: 2,
435
+ infos: 0,
436
+ byRule: {
437
+ 'high-complexity': 1,
438
+ 'debug-leftover': 2,
439
+ },
440
+ },
441
+ maintenanceRisk: {
442
+ score: 30,
443
+ level: 'medium',
444
+ hotspots: [],
445
+ signals: {
446
+ highComplexityFiles: 1,
447
+ filesWithoutNearbyTests: 0,
448
+ frequentChangeFiles: 0,
449
+ },
450
+ },
451
+ }),
452
+ expected: { trust: 77, risk: 'MEDIUM' },
453
+ },
454
+ {
455
+ name: 'HIGH - multiple risk vectors without collapse',
456
+ report: createBaseReport({
457
+ totalScore: 30,
458
+ summary: {
459
+ errors: 2,
460
+ warnings: 3,
461
+ infos: 0,
462
+ byRule: {
463
+ 'high-complexity': 2,
464
+ 'layer-violation': 1,
465
+ },
466
+ },
467
+ maintenanceRisk: {
468
+ score: 45,
469
+ level: 'medium',
470
+ hotspots: [
471
+ {
472
+ file: '/tmp/repo/src/risk.ts',
473
+ driftScore: 46,
474
+ complexityIssues: 2,
475
+ hasNearbyTests: false,
476
+ changeFrequency: 5,
477
+ risk: 50,
478
+ reasons: ['complexity and no tests'],
479
+ },
480
+ ],
481
+ signals: {
482
+ highComplexityFiles: 1,
483
+ filesWithoutNearbyTests: 1,
484
+ frequentChangeFiles: 1,
485
+ },
486
+ },
487
+ }),
488
+ expected: { trust: 56, risk: 'HIGH' },
489
+ },
490
+ {
491
+ name: 'CRITICAL - broad architecture and hotspot failure',
492
+ report: createBaseReport({
493
+ totalScore: 70,
494
+ summary: {
495
+ errors: 5,
496
+ warnings: 8,
497
+ infos: 2,
498
+ byRule: {
499
+ 'high-complexity': 4,
500
+ 'layer-violation': 2,
501
+ 'circular-dependency': 1,
502
+ 'debug-leftover': 3,
503
+ },
504
+ },
505
+ maintenanceRisk: {
506
+ score: 80,
507
+ level: 'critical',
508
+ hotspots: [
509
+ {
510
+ file: '/tmp/repo/src/critical.ts',
511
+ driftScore: 84,
512
+ complexityIssues: 6,
513
+ hasNearbyTests: false,
514
+ changeFrequency: 11,
515
+ risk: 90,
516
+ reasons: ['architecture collapse'],
517
+ },
518
+ ],
519
+ signals: {
520
+ highComplexityFiles: 3,
521
+ filesWithoutNearbyTests: 2,
522
+ frequentChangeFiles: 3,
523
+ },
524
+ },
525
+ }),
526
+ expected: { trust: 3, risk: 'CRITICAL' },
527
+ },
528
+ ]
529
+
530
+ it.each(scenarios)('$name', ({ report, expected }) => {
531
+ const trust = buildTrustReport(report)
532
+ expect(trust.trust_score).toBe(expected.trust)
533
+ expect(trust.merge_risk).toBe(expected.risk)
534
+ })
535
+
536
+ it('applies deterministic diff penalty when PR regresses', () => {
537
+ const report = createBaseReport({ totalScore: 20 })
538
+ const diff: DriftDiff = {
539
+ baseRef: 'origin/main',
540
+ projectPath: '/tmp/repo',
541
+ scannedAt: new Date().toISOString(),
542
+ files: [],
543
+ totalScoreBefore: 20,
544
+ totalScoreAfter: 30,
545
+ totalDelta: 10,
546
+ newIssuesCount: 3,
547
+ resolvedIssuesCount: 0,
548
+ }
549
+
550
+ const trust = buildTrustReport(report, { diff })
551
+ expect(trust.diff_context).toMatchObject({
552
+ baseRef: 'origin/main',
553
+ status: 'regressed',
554
+ penalty: 29,
555
+ bonus: 0,
556
+ netImpact: 29,
557
+ })
558
+ })
559
+
560
+ it('applies deterministic diff bonus when PR improves', () => {
561
+ const report = createBaseReport({ totalScore: 20 })
562
+ const diff: DriftDiff = {
563
+ baseRef: 'origin/main',
564
+ projectPath: '/tmp/repo',
565
+ scannedAt: new Date().toISOString(),
566
+ files: [],
567
+ totalScoreBefore: 35,
568
+ totalScoreAfter: 20,
569
+ totalDelta: -15,
570
+ newIssuesCount: 0,
571
+ resolvedIssuesCount: 4,
572
+ }
573
+
574
+ const trust = buildTrustReport(report, { diff })
575
+ expect(trust.diff_context).toMatchObject({
576
+ baseRef: 'origin/main',
577
+ status: 'improved',
578
+ penalty: 0,
579
+ bonus: 20,
580
+ netImpact: -20,
581
+ })
582
+ expect(trust.trust_score).toBe(100)
583
+ })
584
+ })