@mergesignal/shared 0.2.2 → 0.2.4

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 (72) hide show
  1. package/README.md +37 -0
  2. package/dist/cardObservationCatalog.d.ts +55 -0
  3. package/dist/cardObservationCatalog.d.ts.map +1 -0
  4. package/dist/cardObservationCatalog.js +320 -0
  5. package/dist/cardSummaryCopy.d.ts +12 -0
  6. package/dist/cardSummaryCopy.d.ts.map +1 -0
  7. package/dist/cardSummaryCopy.js +36 -0
  8. package/dist/deriveCardDisplaySummary.d.ts +16 -0
  9. package/dist/deriveCardDisplaySummary.d.ts.map +1 -0
  10. package/dist/deriveCardDisplaySummary.js +56 -0
  11. package/dist/deriveCardDisplaySummary.test.d.ts +2 -0
  12. package/dist/deriveCardDisplaySummary.test.d.ts.map +1 -0
  13. package/dist/deriveCardDisplaySummary.test.js +89 -0
  14. package/dist/deriveCardOperationalObservations.d.ts +19 -0
  15. package/dist/deriveCardOperationalObservations.d.ts.map +1 -0
  16. package/dist/deriveCardOperationalObservations.js +180 -0
  17. package/dist/deriveCardOperationalObservations.test.d.ts +2 -0
  18. package/dist/deriveCardOperationalObservations.test.d.ts.map +1 -0
  19. package/dist/deriveCardOperationalObservations.test.js +268 -0
  20. package/dist/deriveRiskScoreSummary.d.ts +33 -0
  21. package/dist/deriveRiskScoreSummary.d.ts.map +1 -0
  22. package/dist/deriveRiskScoreSummary.js +90 -0
  23. package/dist/deriveRiskScoreSummary.test.d.ts +2 -0
  24. package/dist/deriveRiskScoreSummary.test.d.ts.map +1 -0
  25. package/dist/deriveRiskScoreSummary.test.js +47 -0
  26. package/dist/deriveScanDetailRecommendations.d.ts +63 -0
  27. package/dist/deriveScanDetailRecommendations.d.ts.map +1 -0
  28. package/dist/deriveScanDetailRecommendations.js +725 -0
  29. package/dist/deriveScanDetailRecommendations.test.d.ts +2 -0
  30. package/dist/deriveScanDetailRecommendations.test.d.ts.map +1 -0
  31. package/dist/deriveScanDetailRecommendations.test.js +181 -0
  32. package/dist/formatCardAreaLabels.d.ts +8 -0
  33. package/dist/formatCardAreaLabels.d.ts.map +1 -0
  34. package/dist/formatCardAreaLabels.js +65 -0
  35. package/dist/formatCardEvidenceCounts.d.ts +7 -0
  36. package/dist/formatCardEvidenceCounts.d.ts.map +1 -0
  37. package/dist/formatCardEvidenceCounts.js +19 -0
  38. package/dist/formatCardExposureDisplay.d.ts +24 -0
  39. package/dist/formatCardExposureDisplay.d.ts.map +1 -0
  40. package/dist/formatCardExposureDisplay.js +71 -0
  41. package/dist/formatCardExposureDisplay.test.d.ts +2 -0
  42. package/dist/formatCardExposureDisplay.test.d.ts.map +1 -0
  43. package/dist/formatCardExposureDisplay.test.js +36 -0
  44. package/dist/index.d.ts +12 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +12 -0
  47. package/dist/productMessaging.d.ts +85 -0
  48. package/dist/productMessaging.d.ts.map +1 -0
  49. package/dist/productMessaging.js +109 -0
  50. package/dist/productMessaging.test.d.ts +2 -0
  51. package/dist/productMessaging.test.d.ts.map +1 -0
  52. package/dist/productMessaging.test.js +97 -0
  53. package/dist/riskVocabulary.d.ts +8 -2
  54. package/dist/riskVocabulary.d.ts.map +1 -1
  55. package/dist/riskVocabulary.js +26 -9
  56. package/dist/scanCardSummary.d.ts +9 -0
  57. package/dist/scanCardSummary.d.ts.map +1 -1
  58. package/dist/scanCardSummary.js +19 -29
  59. package/dist/scanCardSummary.test.js +45 -8
  60. package/dist/scanDetailViewModel.d.ts +108 -0
  61. package/dist/scanDetailViewModel.d.ts.map +1 -0
  62. package/dist/scanDetailViewModel.js +420 -0
  63. package/dist/scanDetailViewModel.test.d.ts +2 -0
  64. package/dist/scanDetailViewModel.test.d.ts.map +1 -0
  65. package/dist/scanDetailViewModel.test.js +503 -0
  66. package/dist/scanSurfaceCopy.d.ts +187 -0
  67. package/dist/scanSurfaceCopy.d.ts.map +1 -1
  68. package/dist/scanSurfaceCopy.js +190 -0
  69. package/dist/truncateCardSummary.d.ts +5 -0
  70. package/dist/truncateCardSummary.d.ts.map +1 -0
  71. package/dist/truncateCardSummary.js +23 -0
  72. package/package.json +1 -1
@@ -0,0 +1,503 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { primaryPrExample } from "./productMessaging.js";
3
+ import { ACT2_MAX_THEMES, deriveAct2Themes, deriveOperationalImpact, deriveFollowUpBridgeNote, deriveScanDetailViewModel, deriveVerdictLine, sanitizeAct2Theme, tier1PassesRecallTest, } from "./scanDetailViewModel.js";
4
+ const baseResult = {
5
+ totalScore: 10,
6
+ layerScores: {
7
+ security: 1,
8
+ maintainability: 2,
9
+ ecosystem: 3,
10
+ upgradeImpact: 4,
11
+ },
12
+ findings: [],
13
+ generatedAt: "2026-01-01T00:00:00.000Z",
14
+ };
15
+ function minimalResult(overrides = {}) {
16
+ return {
17
+ totalScore: 42,
18
+ layerScores: {
19
+ security: 10,
20
+ maintainability: 20,
21
+ ecosystem: 30,
22
+ upgradeImpact: 15,
23
+ },
24
+ findings: [],
25
+ generatedAt: new Date().toISOString(),
26
+ decision: {
27
+ recommendation: "needs_review",
28
+ confidence: "medium",
29
+ reasoning: ["Lockfile adds transitive depth on shared utilities."],
30
+ },
31
+ ...overrides,
32
+ };
33
+ }
34
+ describe("reach vocabulary on fixtures", () => {
35
+ it("quiet safe scan — Safe · Focused reach", () => {
36
+ const vm = deriveScanDetailViewModel({
37
+ ...baseResult,
38
+ decision: {
39
+ recommendation: "safe",
40
+ confidence: "high",
41
+ reasoning: ["No merge blockers"],
42
+ },
43
+ }, { scanId: "s1", status: "done" });
44
+ expect(vm?.verdict.posture).toBe("safe");
45
+ expect(vm?.verdict.scopeChip).toBe("Focused reach");
46
+ expect(vm?.verdict.verdictLine).toContain("No merge blockers");
47
+ expect(vm?.verdict.verdictLine).toContain("narrow footprint");
48
+ });
49
+ it("safe + wide reach — score 85", () => {
50
+ const vm = deriveScanDetailViewModel({
51
+ ...baseResult,
52
+ totalScore: 85,
53
+ decision: {
54
+ recommendation: "safe",
55
+ confidence: "high",
56
+ reasoning: [],
57
+ },
58
+ changedPackages: Array.from({ length: 14 }, (_, i) => `pkg-${i}`),
59
+ }, { scanId: "s2", status: "done" });
60
+ expect(vm?.verdict.scopeChip).toBe("Wide reach");
61
+ expect(vm?.verdict.verdictLine).toBe("No merge blockers · wide upgrade footprint");
62
+ });
63
+ it("needs review + limited reach — minimalResult score 42", () => {
64
+ const vm = deriveScanDetailViewModel(minimalResult(), {
65
+ scanId: "s3",
66
+ status: "done",
67
+ });
68
+ expect(vm?.verdict.posture).toBe("needs_review");
69
+ expect(vm?.verdict.scopeChip).toBe("Limited reach");
70
+ expect(vm?.verdict.verdictLine).toContain("Review before merge");
71
+ });
72
+ it("risky + focused reach — low score, risky posture", () => {
73
+ const vm = deriveScanDetailViewModel({
74
+ ...baseResult,
75
+ totalScore: 18,
76
+ decision: {
77
+ recommendation: "risky",
78
+ confidence: "high",
79
+ reasoning: ["Critical issue in direct dependency"],
80
+ },
81
+ findings: [
82
+ {
83
+ id: "f1",
84
+ title: "Critical CVE",
85
+ description: "Direct dependency vulnerability",
86
+ severity: "critical",
87
+ packageName: "minimist",
88
+ },
89
+ ],
90
+ }, { scanId: "s4", status: "done" });
91
+ expect(vm?.verdict.scopeChip).toBe("Focused reach");
92
+ expect(vm?.verdict.posture).toBe("risky");
93
+ expect(vm?.verdict.verdictLine).toContain("Resolve blockers");
94
+ });
95
+ it("risky + moderate reach — fat scan score 72", () => {
96
+ const vm = deriveScanDetailViewModel({
97
+ ...baseResult,
98
+ totalScore: 72,
99
+ decision: {
100
+ recommendation: "risky",
101
+ confidence: "high",
102
+ reasoning: [],
103
+ },
104
+ graphInsights: {
105
+ maxDepth: 9,
106
+ nodes: 1500,
107
+ edges: 4000,
108
+ vulnerable: [
109
+ {
110
+ kind: "vulnerable",
111
+ packageName: "lodash",
112
+ direct: false,
113
+ depth: 3,
114
+ },
115
+ ],
116
+ },
117
+ }, { scanId: "s5", status: "done" });
118
+ expect(vm?.verdict.scopeChip).toBe("Moderate reach");
119
+ expect(vm?.verdict.verdictLine).toContain("moderate dependency footprint");
120
+ });
121
+ });
122
+ describe("deriveVerdictLine", () => {
123
+ it("maps posture and reach bands", () => {
124
+ expect(deriveVerdictLine("safe", 10)).toContain("narrow footprint");
125
+ expect(deriveVerdictLine("safe", 85)).toContain("wide upgrade footprint");
126
+ expect(deriveVerdictLine("needs_review", 42)).toContain("Review before merge");
127
+ expect(deriveVerdictLine("risky", 18)).toContain("focused footprint");
128
+ });
129
+ });
130
+ describe("Tier 1 success criterion", () => {
131
+ it("insight-rich scan passes recall test", () => {
132
+ const vm = deriveScanDetailViewModel({
133
+ ...minimalResult(),
134
+ insights: [
135
+ {
136
+ type: "behavioral_change",
137
+ priority: "high",
138
+ confidence: "confirmed",
139
+ scope: "changed",
140
+ message: primaryPrExample.message,
141
+ context: primaryPrExample.where[0],
142
+ remediation: "Confirm auth guards still run before protected handlers",
143
+ affectedFiles: [...primaryPrExample.where],
144
+ },
145
+ ],
146
+ }, { scanId: "rich", status: "done" });
147
+ expect(vm?.operationalImpact.status).toBe("rich");
148
+ expect(vm?.operationalImpact.items[0]?.message).toContain("middleware");
149
+ expect(tier1PassesRecallTest(vm.operationalImpact)).toBe(true);
150
+ });
151
+ it("graph-only lightweight scan uses F3 fallback", () => {
152
+ const impact = deriveOperationalImpact({
153
+ ...minimalResult(),
154
+ insights: [],
155
+ reportPresentation: { mode: "lightweight_pr_graph_baseline" },
156
+ });
157
+ expect(impact.status).toBe("fallback");
158
+ expect(impact.fallbackMessage).toContain("limited code-change signal");
159
+ expect(tier1PassesRecallTest(impact)).toBe(true);
160
+ });
161
+ it("quiet safe uses Q fallback", () => {
162
+ const impact = deriveOperationalImpact({
163
+ ...baseResult,
164
+ decision: {
165
+ recommendation: "safe",
166
+ confidence: "high",
167
+ reasoning: [],
168
+ },
169
+ });
170
+ expect(impact.fallbackMessage).toContain("No application-level impact");
171
+ });
172
+ });
173
+ describe("Act 2 guardrails", () => {
174
+ it("sanitizes forbidden content", () => {
175
+ expect(sanitizeAct2Theme("lodash 4.17.20 CVE-2021-23337")).toBeNull();
176
+ expect(sanitizeAct2Theme("@scope/pkg@1.2.3")).toBeNull();
177
+ expect(sanitizeAct2Theme("Known vulnerabilities in the transitive tree")).toBe("Known vulnerabilities in the transitive tree");
178
+ });
179
+ it("caps themes at ACT2_MAX_THEMES", () => {
180
+ const fat = {
181
+ ...baseResult,
182
+ totalScore: 72,
183
+ recommendations: [
184
+ {
185
+ id: "r1",
186
+ title: "Reduce duplicate dependency versions",
187
+ rationale: "semver overlap",
188
+ impact: "high",
189
+ },
190
+ {
191
+ id: "r2",
192
+ title: "Review transitive dependency surface",
193
+ rationale: "graph.transitive volume",
194
+ impact: "high",
195
+ },
196
+ ],
197
+ explain: {
198
+ reasons: [
199
+ {
200
+ id: "g1",
201
+ layer: "security",
202
+ title: "graph.vulnerable packages",
203
+ scoreImpact: 20,
204
+ },
205
+ {
206
+ id: "g2",
207
+ layer: "maintainability",
208
+ title: "Stale releases on multiple direct dependencies",
209
+ scoreImpact: 15,
210
+ },
211
+ {
212
+ id: "g3",
213
+ layer: "ecosystem",
214
+ title: "graph.transitive volume cluster",
215
+ scoreImpact: 12,
216
+ },
217
+ ],
218
+ },
219
+ graphInsights: {
220
+ maxDepth: 9,
221
+ nodes: 1500,
222
+ edges: 4000,
223
+ vulnerable: [
224
+ {
225
+ kind: "vulnerable",
226
+ packageName: "lodash",
227
+ direct: false,
228
+ depth: 3,
229
+ },
230
+ ],
231
+ hotspots: Array.from({ length: 5 }, (_, i) => ({
232
+ kind: "hotspot",
233
+ packageName: `pkg-${i}`,
234
+ direct: true,
235
+ depth: 1,
236
+ })),
237
+ },
238
+ decision: {
239
+ recommendation: "risky",
240
+ confidence: "high",
241
+ reasoning: ["Multiple structural concerns"],
242
+ },
243
+ };
244
+ const themes = deriveAct2Themes(fat);
245
+ expect(themes.length).toBeLessThanOrEqual(ACT2_MAX_THEMES);
246
+ for (const theme of themes) {
247
+ expect(theme.length).toBeLessThanOrEqual(80);
248
+ expect(sanitizeAct2Theme(theme)).toBe(theme);
249
+ }
250
+ });
251
+ it("Act 2 themes never contain package names from evidence", () => {
252
+ const vm = deriveScanDetailViewModel({
253
+ ...baseResult,
254
+ totalScore: 72,
255
+ graphInsights: {
256
+ maxDepth: 9,
257
+ nodes: 1500,
258
+ edges: 4000,
259
+ vulnerable: [
260
+ {
261
+ kind: "vulnerable",
262
+ packageName: "lodash",
263
+ direct: false,
264
+ depth: 3,
265
+ },
266
+ ],
267
+ },
268
+ decision: {
269
+ recommendation: "risky",
270
+ confidence: "high",
271
+ reasoning: [],
272
+ },
273
+ }, { scanId: "act2", status: "done" });
274
+ for (const theme of vm?.because?.themes ?? []) {
275
+ expect(theme.toLowerCase()).not.toContain("lodash");
276
+ }
277
+ expect(vm?.evidence?.attentionAreas[0]?.packages[0]?.name).toBe("lodash");
278
+ });
279
+ });
280
+ describe("scenario matrix", () => {
281
+ function assertScenario(name, vm, checks) {
282
+ expect(vm, name).not.toBeNull();
283
+ if (!vm)
284
+ return;
285
+ if (checks.tier0MaxWords != null) {
286
+ const words = vm.verdict.verdictLine.split(/\s+/).length;
287
+ expect(words, `${name} tier0`).toBeLessThanOrEqual(checks.tier0MaxWords);
288
+ }
289
+ if (checks.tier1Meaningful) {
290
+ expect(tier1PassesRecallTest(vm.operationalImpact), `${name} tier1`).toBe(true);
291
+ }
292
+ if (checks.act2MaxBullets != null && vm.because) {
293
+ expect(vm.because.themes.length, `${name} act2`).toBeLessThanOrEqual(checks.act2MaxBullets);
294
+ }
295
+ if (checks.act3Navigable && vm.evidence) {
296
+ expect(vm.evidence.findings.length + vm.evidence.attentionAreas.length, `${name} act3`).toBeGreaterThan(0);
297
+ }
298
+ if (checks.recommendationsActionable) {
299
+ expect(vm.recommendedActions.items.length, `${name} recs`).toBeGreaterThan(0);
300
+ expect(vm.recommendedActions.items[0]?.detail.why.length, `${name} rec detail`).toBeGreaterThan(10);
301
+ }
302
+ }
303
+ it("quiet safe scan", () => {
304
+ assertScenario("quiet safe", deriveScanDetailViewModel({
305
+ ...baseResult,
306
+ decision: {
307
+ recommendation: "safe",
308
+ confidence: "high",
309
+ reasoning: [],
310
+ },
311
+ }, { scanId: "q", status: "done" }), { tier0MaxWords: 12, tier1Meaningful: true, act2MaxBullets: 0 });
312
+ });
313
+ it("safe + wide reach", () => {
314
+ assertScenario("safe wide", deriveScanDetailViewModel({
315
+ ...baseResult,
316
+ totalScore: 88,
317
+ decision: {
318
+ recommendation: "safe",
319
+ confidence: "high",
320
+ reasoning: [],
321
+ },
322
+ graphInsights: {
323
+ maxDepth: 8,
324
+ nodes: 900,
325
+ edges: 1200,
326
+ deepest: [],
327
+ },
328
+ }, { scanId: "sw", status: "done" }), { tier0MaxWords: 12, tier1Meaningful: true });
329
+ });
330
+ it("risky vulnerability-heavy", () => {
331
+ assertScenario("risky vuln", deriveScanDetailViewModel({
332
+ ...baseResult,
333
+ totalScore: 72,
334
+ findings: [
335
+ {
336
+ id: "f1",
337
+ title: "CVE in lodash",
338
+ description: "Remote code execution",
339
+ severity: "critical",
340
+ packageName: "lodash",
341
+ },
342
+ ],
343
+ graphInsights: {
344
+ maxDepth: 5,
345
+ nodes: 400,
346
+ edges: 800,
347
+ vulnerable: [
348
+ {
349
+ kind: "vulnerable",
350
+ packageName: "lodash",
351
+ direct: false,
352
+ depth: 2,
353
+ },
354
+ ],
355
+ },
356
+ recommendations: [
357
+ {
358
+ id: "r1",
359
+ title: "Upgrade lodash to patched version",
360
+ rationale: "Resolves critical CVE",
361
+ impact: "high",
362
+ },
363
+ ],
364
+ decision: {
365
+ recommendation: "risky",
366
+ confidence: "high",
367
+ reasoning: [],
368
+ },
369
+ }, { scanId: "rv", status: "done" }), {
370
+ tier0MaxWords: 12,
371
+ tier1Meaningful: true,
372
+ act2MaxBullets: 4,
373
+ act3Navigable: true,
374
+ recommendationsActionable: true,
375
+ });
376
+ });
377
+ it("graph-only scan", () => {
378
+ assertScenario("graph only", deriveScanDetailViewModel({
379
+ ...minimalResult(),
380
+ insights: [],
381
+ reportPresentation: { mode: "lightweight_pr_graph_baseline" },
382
+ }, { scanId: "go", status: "done" }), { tier0MaxWords: 12, tier1Meaningful: true, act2MaxBullets: 4 });
383
+ });
384
+ it("insight-rich scan", () => {
385
+ assertScenario("insight rich", deriveScanDetailViewModel({
386
+ ...minimalResult({ totalScore: 55 }),
387
+ insights: [
388
+ {
389
+ type: "behavioral_change",
390
+ priority: "high",
391
+ confidence: "confirmed",
392
+ scope: "changed",
393
+ message: primaryPrExample.message,
394
+ context: "apps/api/src/middleware/auth.ts",
395
+ remediation: "Run auth integration tests",
396
+ },
397
+ ],
398
+ recommendations: [
399
+ {
400
+ id: "r1",
401
+ title: "Review auth middleware ordering",
402
+ rationale: "Middleware order affects auth guards",
403
+ impact: "high",
404
+ },
405
+ ],
406
+ }, { scanId: "ir", status: "done" }), {
407
+ tier0MaxWords: 12,
408
+ tier1Meaningful: true,
409
+ act2MaxBullets: 4,
410
+ recommendationsActionable: true,
411
+ });
412
+ });
413
+ it("large enterprise scan", () => {
414
+ const fat = {
415
+ ...minimalResult({ totalScore: 72 }),
416
+ graphInsights: {
417
+ maxDepth: 14,
418
+ nodes: 2400,
419
+ edges: 9000,
420
+ deepest: Array.from({ length: 20 }, (_, i) => ({
421
+ kind: "deep",
422
+ packageName: `pkg-${i}`,
423
+ depth: 10 + i,
424
+ direct: false,
425
+ via: ["a", "b"],
426
+ })),
427
+ hotspots: Array.from({ length: 12 }, (_, i) => ({
428
+ kind: "hotspot",
429
+ packageName: `hot-${i}`,
430
+ depth: 3,
431
+ direct: false,
432
+ })),
433
+ vulnerable: Array.from({ length: 8 }, (_, i) => ({
434
+ kind: "vulnerable",
435
+ packageName: `vuln-${i}`,
436
+ depth: 2,
437
+ direct: false,
438
+ })),
439
+ },
440
+ insights: Array.from({ length: 30 }, (_, i) => ({
441
+ type: "behavioral_change",
442
+ priority: "high",
443
+ confidence: "confirmed",
444
+ scope: "changed",
445
+ message: `Insight message ${i} about runtime behavior`,
446
+ context: `src/module-${i}/index.ts`,
447
+ remediation: `Verify step ${i}`,
448
+ })),
449
+ recommendations: Array.from({ length: 25 }, (_, i) => ({
450
+ id: `rec-${i}`,
451
+ title: `Recommendation title ${i}`,
452
+ rationale: `Rationale ${i}`,
453
+ impact: "high",
454
+ })),
455
+ findings: Array.from({ length: 40 }, (_, i) => ({
456
+ id: `find-${i}`,
457
+ title: `Finding ${i}`,
458
+ description: `Description ${i}`,
459
+ severity: "high",
460
+ packageName: `pkg-${i}`,
461
+ })),
462
+ decision: {
463
+ recommendation: "risky",
464
+ confidence: "high",
465
+ reasoning: [],
466
+ },
467
+ };
468
+ const vm = deriveScanDetailViewModel(fat, {
469
+ scanId: "fat",
470
+ status: "done",
471
+ });
472
+ expect(vm?.operationalImpact.items.length).toBe(30);
473
+ expect(vm?.evidence?.findings.length).toBe(40);
474
+ expect(vm?.recommendedActions.items.length).toBeLessThanOrEqual(3);
475
+ expect(vm?.recommendedActions.items[0]?.detail.whyNow.length).toBeGreaterThan(0);
476
+ expect(vm?.evidence?.findingsOverflowCount).toBe(30);
477
+ });
478
+ it("includes signalSummary on done scans", () => {
479
+ const vm = deriveScanDetailViewModel({ ...minimalResult(), totalScore: 71 }, { scanId: "risk", status: "done" });
480
+ expect(vm?.signalSummary?.overallBand).toBe("moderate");
481
+ expect(vm?.signalSummary?.score).toBe(71);
482
+ expect(vm?.signalSummary?.overallLabel).toBe("Moderate");
483
+ });
484
+ it("derives follow-up bridge note from recommended action count", () => {
485
+ expect(deriveFollowUpBridgeNote(0)).toBeNull();
486
+ expect(deriveFollowUpBridgeNote(1)).toBe("1 follow-up improvement was identified");
487
+ expect(deriveFollowUpBridgeNote(3)).toBe("3 follow-up improvements were identified");
488
+ const vm = deriveScanDetailViewModel(minimalResult(), {
489
+ scanId: "bridge",
490
+ status: "done",
491
+ });
492
+ expect(vm?.followUpBridgeNote).toMatch(/follow-up improvement/);
493
+ });
494
+ });
495
+ describe("repo context", () => {
496
+ it("defaults to hidden", () => {
497
+ const vm = deriveScanDetailViewModel(minimalResult(), {
498
+ scanId: "x",
499
+ status: "done",
500
+ });
501
+ expect(vm?.repoContext).toEqual({ status: "hidden" });
502
+ });
503
+ });