@mergesignal/shared 0.2.3 → 0.2.5

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.
@@ -0,0 +1,725 @@
1
+ import { humanizeEngineSurfaceText, sortPRInsightsForDisplay, sortRecommendationsForDisplay, truncateWithEllipsis, } from "./actionsStepSummary.js";
2
+ import { mapExplainReasonToCatalogPhrase, mapFindingToCatalogPhrase, mapGraphInsightsToCatalogPhrases, mapRecommendationToCatalogPhrase, phraseForFamily, } from "./cardObservationCatalog.js";
3
+ import { deriveCardExposureDisplay, } from "./formatCardExposureDisplay.js";
4
+ import { formatInsight } from "./formatInsight.js";
5
+ import { mergePostureFromDecision, } from "./riskVocabulary.js";
6
+ import { scanSurfaceCopy } from "./scanSurfaceCopy.js";
7
+ export const RECOMMENDATION_MAX_ITEMS = 3;
8
+ export const RECOMMENDATION_MAX_TITLE_CHARS = 80;
9
+ export const RECOMMENDATION_PREFER_MAX_ON_SAFE = 2;
10
+ export const RECOMMENDATION_MAX_SIGNALS = 4;
11
+ export const RECOMMENDATION_MAX_PACKAGES = 5;
12
+ const GUIDANCE_FORBIDDEN = /CVE-\d|@\d|\d+\.\d+\.\d+|\bgraph\.|fan[\s_-]?in\b|scoreimpact|\bfan-in\b/i;
13
+ const GENERIC_RECOMMENDATION_TITLE = /\b(reduce|flatten|minimize|minimise|avoid|improve|consider|optimize|optimise|review|upgrade|decrease|increase|check|verify|ensure|audit)\b.*\b(depend|transitiv|surface|depth|chain|area|footprint|overlap|duplicat|lockfile|semver|vulnerabil|supply)/i;
14
+ export const GUIDANCE_ACTION_FOR_FAMILY = {
15
+ vulnerable_transitive: "Review vulnerable transitive packages",
16
+ duplicate_versions: "Reduce duplicate dependency versions",
17
+ transitive_hotspots: "Review dependency hotspots before merging",
18
+ stale_ecosystem: "Monitor stale dependencies in affected areas",
19
+ blast_radius: "Validate impact across changed packages",
20
+ indirect_chains: "Review deep transitive paths before merge",
21
+ transitive_volume: "Narrow dependency footprint where possible",
22
+ package_surface: "Review broad package surface before merging",
23
+ overlapping_paths: "Reduce overlapping dependency paths",
24
+ };
25
+ const GUIDANCE_WHY_FOR_FAMILY = {
26
+ duplicate_versions: "Multiple versions of the same dependency increase upgrade complexity and expand runtime surface area.",
27
+ vulnerable_transitive: "Known vulnerabilities in transitive dependencies can reach production even when direct dependencies look safe.",
28
+ transitive_hotspots: "Packages many paths depend on amplify the blast radius of any change or advisory.",
29
+ stale_ecosystem: "Stale dependencies miss security patches and make future upgrades harder to predict.",
30
+ blast_radius: "Large upgrade footprints increase the chance of unexpected runtime interactions.",
31
+ indirect_chains: "Deep transitive chains hide behavior changes and make rollbacks harder to reason about.",
32
+ transitive_volume: "High transitive volume expands the maintenance surface you inherit with this merge.",
33
+ package_surface: "A broad package surface increases review burden and the chance of conflicting versions.",
34
+ overlapping_paths: "Overlapping paths to the same package often indicate version skew or lockfile drift.",
35
+ };
36
+ const GUIDANCE_BENEFIT_FOR_FAMILY = {
37
+ duplicate_versions: "Simpler future upgrades and reduced maintenance burden.",
38
+ vulnerable_transitive: "Reduced vulnerability exposure before code reaches production.",
39
+ transitive_hotspots: "Lower blast radius when upstream packages change or publish advisories.",
40
+ stale_ecosystem: "Easier dependency management on the next upgrade cycle.",
41
+ blast_radius: "More predictable merges with fewer surprise runtime interactions.",
42
+ indirect_chains: "Clearer dependency paths and easier rollback decisions.",
43
+ transitive_volume: "Smaller runtime surface area and less transitive risk.",
44
+ package_surface: "Easier dependency management and fewer version conflicts.",
45
+ overlapping_paths: "Cleaner lockfiles and fewer semver surprises on the next upgrade.",
46
+ };
47
+ const FAMILY_PRIORITY = {
48
+ vulnerable_transitive: 95,
49
+ duplicate_versions: 75,
50
+ transitive_hotspots: 70,
51
+ stale_ecosystem: 55,
52
+ blast_radius: 65,
53
+ indirect_chains: 50,
54
+ overlapping_paths: 60,
55
+ transitive_volume: 45,
56
+ package_surface: 40,
57
+ };
58
+ function reachBand(category) {
59
+ if (!category || category === "minimal" || category === "limited") {
60
+ return "narrow";
61
+ }
62
+ if (category === "moderate")
63
+ return "moderate";
64
+ return "wide";
65
+ }
66
+ function normalizeKey(label) {
67
+ return label.toLowerCase().replace(/\s+/g, " ").trim();
68
+ }
69
+ function sanitizeDetailText(raw, maxChars = 220) {
70
+ const trimmed = raw.trim();
71
+ if (!trimmed || GUIDANCE_FORBIDDEN.test(trimmed))
72
+ return null;
73
+ if (trimmed.length < 8)
74
+ return null;
75
+ return truncateWithEllipsis(trimmed, maxChars);
76
+ }
77
+ function sanitizeGuidanceLabel(raw) {
78
+ return sanitizeDetailText(raw, RECOMMENDATION_MAX_TITLE_CHARS);
79
+ }
80
+ function sanitizeSignal(raw) {
81
+ const trimmed = raw.trim();
82
+ if (!trimmed || GUIDANCE_FORBIDDEN.test(trimmed))
83
+ return null;
84
+ return truncateWithEllipsis(trimmed, 72);
85
+ }
86
+ function formatPackageList(names, max = 3) {
87
+ const unique = [...new Set(names.map((n) => n.trim()).filter(Boolean))];
88
+ if (unique.length === 0)
89
+ return "affected packages";
90
+ if (unique.length === 1)
91
+ return unique[0];
92
+ if (unique.length === 2)
93
+ return `${unique[0]} and ${unique[1]}`;
94
+ if (unique.length <= max) {
95
+ return `${unique.slice(0, -1).join(", ")}, and ${unique[unique.length - 1]}`;
96
+ }
97
+ return `${unique.slice(0, max).join(", ")}, and others`;
98
+ }
99
+ function firstDecisionReason(result) {
100
+ const reasoning = result.decision?.reasoning;
101
+ if (!Array.isArray(reasoning) || reasoning.length === 0)
102
+ return null;
103
+ return sanitizeDetailText(humanizeEngineSurfaceText(String(reasoning[0] ?? "")));
104
+ }
105
+ function findingsForFamily(result, family) {
106
+ const findings = Array.isArray(result.findings) ? result.findings : [];
107
+ return findings.filter((f) => mapFindingToCatalogPhrase(f)?.family === family);
108
+ }
109
+ function packagesForFamily(result, family, rec) {
110
+ const names = [];
111
+ if (rec?.packages?.length) {
112
+ names.push(...rec.packages);
113
+ }
114
+ if (family) {
115
+ for (const f of findingsForFamily(result, family)) {
116
+ names.push(f.packageName);
117
+ }
118
+ }
119
+ const gi = result.graphInsights;
120
+ if (family === "vulnerable_transitive" && Array.isArray(gi?.vulnerable)) {
121
+ names.push(...gi.vulnerable.map((v) => v.packageName));
122
+ }
123
+ if (family === "transitive_hotspots" && Array.isArray(gi?.hotspots)) {
124
+ names.push(...gi.hotspots.map((h) => h.packageName));
125
+ }
126
+ if (family === "blast_radius" && Array.isArray(result.changedPackages)) {
127
+ names.push(...result.changedPackages);
128
+ }
129
+ return [...new Set(names.map((n) => n.trim()).filter(Boolean))];
130
+ }
131
+ function packageSummary(names) {
132
+ if (names.length === 0)
133
+ return undefined;
134
+ const capped = names.slice(0, RECOMMENDATION_MAX_PACKAGES);
135
+ return {
136
+ names: capped,
137
+ overflowCount: Math.max(0, names.length - capped.length),
138
+ };
139
+ }
140
+ function signalsForFamily(result, family, packages) {
141
+ const out = [];
142
+ const observation = phraseForFamily(family);
143
+ out.push(observation);
144
+ if (family === "duplicate_versions") {
145
+ const dupFindings = findingsForFamily(result, family);
146
+ if (dupFindings.length > 0) {
147
+ out.push(`${dupFindings.length} duplicate dependency version${dupFindings.length === 1 ? "" : "s"} detected`);
148
+ }
149
+ for (const pkg of packages.slice(0, 3)) {
150
+ out.push(`Multiple ${pkg} versions present`);
151
+ }
152
+ }
153
+ if (family === "vulnerable_transitive") {
154
+ const vuln = result.graphInsights?.vulnerable ?? [];
155
+ if (vuln.length > 0) {
156
+ out.push(`${vuln.length} vulnerable transitive package${vuln.length === 1 ? "" : "s"} detected`);
157
+ }
158
+ const critical = findingsForFamily(result, family).filter((f) => f.severity === "critical" || f.severity === "high");
159
+ if (critical.length > 0) {
160
+ out.push(`${critical.length} high-severity security finding${critical.length === 1 ? "" : "s"}`);
161
+ }
162
+ }
163
+ if (family === "transitive_hotspots") {
164
+ const hotspots = result.graphInsights?.hotspots ?? [];
165
+ if (hotspots.length > 0) {
166
+ out.push(`${hotspots.length} transitive dependency hotspot${hotspots.length === 1 ? "" : "s"} detected`);
167
+ }
168
+ }
169
+ if (family === "indirect_chains") {
170
+ const depth = result.graphInsights?.maxDepth;
171
+ if (typeof depth === "number" && depth >= 5) {
172
+ out.push(`Dependency paths reach depth ${depth}`);
173
+ }
174
+ }
175
+ if (family === "blast_radius") {
176
+ const changed = result.changedPackages?.length ?? 0;
177
+ if (changed > 0) {
178
+ out.push(`${changed} package${changed === 1 ? "" : "s"} changed in this upgrade`);
179
+ }
180
+ }
181
+ if (family === "transitive_volume" || family === "package_surface") {
182
+ const nodes = result.graphInsights?.nodes;
183
+ if (typeof nodes === "number" && nodes > 0) {
184
+ out.push(`${nodes} packages in the reviewed dependency tree`);
185
+ }
186
+ }
187
+ const seen = new Set();
188
+ return out
189
+ .map((s) => sanitizeSignal(s))
190
+ .filter((s) => Boolean(s))
191
+ .filter((s) => {
192
+ const key = s.toLowerCase();
193
+ if (seen.has(key))
194
+ return false;
195
+ seen.add(key);
196
+ return true;
197
+ })
198
+ .slice(0, RECOMMENDATION_MAX_SIGNALS);
199
+ }
200
+ function whyNowForFamily(result, family, packages) {
201
+ if (family === "duplicate_versions") {
202
+ if (packages.length >= 2) {
203
+ return `This scan detected multiple versions of ${formatPackageList(packages)} in the affected dependency tree.`;
204
+ }
205
+ return "This PR introduces additional duplicate versions of existing dependencies.";
206
+ }
207
+ if (family === "vulnerable_transitive") {
208
+ if (packages.length > 0) {
209
+ return `Known vulnerabilities are present in ${formatPackageList(packages)} reachable from this upgrade.`;
210
+ }
211
+ return "Known vulnerabilities are present in packages touched by this upgrade.";
212
+ }
213
+ if (family === "transitive_hotspots") {
214
+ return "Dependency concentration increased in areas many runtime paths share.";
215
+ }
216
+ if (family === "blast_radius") {
217
+ const changed = result.changedPackages?.length ?? 0;
218
+ if (changed >= 8) {
219
+ return `This upgrade changes ${changed} packages, expanding the merge footprint.`;
220
+ }
221
+ return "This upgrade touches enough packages to warrant validating downstream impact.";
222
+ }
223
+ if (family === "indirect_chains") {
224
+ const depth = result.graphInsights?.maxDepth;
225
+ if (typeof depth === "number" && depth >= 5) {
226
+ return `Transitive paths in this scan reach depth ${depth}, increasing hidden coupling.`;
227
+ }
228
+ return "Deep indirect dependency chains were detected in this scan.";
229
+ }
230
+ const reason = firstDecisionReason(result);
231
+ if (reason)
232
+ return reason;
233
+ return scanSurfaceCopy.scanDetail.recommendationDetail.defaultWhyNow;
234
+ }
235
+ function playbookDetail(key, result) {
236
+ const copy = scanSurfaceCopy.scanDetail.recommendationPlaybookDetail[key];
237
+ const reason = firstDecisionReason(result);
238
+ return {
239
+ why: copy.why,
240
+ whyNow: reason ?? copy.whyNow,
241
+ expectedBenefit: copy.expectedBenefit,
242
+ signals: [...copy.signals],
243
+ };
244
+ }
245
+ function enrichCandidate(candidate, result, rank) {
246
+ const family = candidate.family;
247
+ const packages = packagesForFamily(result, family, candidate.recommendation);
248
+ const affectedPackages = packageSummary(packages);
249
+ const proofFindingIds = candidate.finding
250
+ ? [candidate.finding.id]
251
+ : (candidate.findings?.map((f) => f.id) ??
252
+ (family ? findingsForFamily(result, family).map((f) => f.id) : []));
253
+ if (candidate.insight) {
254
+ const formatted = formatInsight(candidate.insight);
255
+ const why = sanitizeDetailText(formatted.message) ??
256
+ scanSurfaceCopy.scanDetail.recommendationDetail.defaultWhy;
257
+ const whyNow = sanitizeDetailText(`${formatted.message}${formatted.where ? ` Affected area: ${formatted.where}.` : ""}`) ?? why;
258
+ return {
259
+ id: candidate.id,
260
+ rank,
261
+ title: candidate.label,
262
+ priority: candidate.priority,
263
+ source: candidate.source,
264
+ signalFamily: family,
265
+ detail: {
266
+ why,
267
+ whyNow,
268
+ signals: [
269
+ sanitizeSignal(`Runtime insight: ${formatted.message}`),
270
+ candidate.insight.confidence === "confirmed"
271
+ ? "Confirmed code-path signal in this PR"
272
+ : candidate.insight.confidence === "likely"
273
+ ? "Likely code-path signal in this PR"
274
+ : "Directional code-path signal in this PR",
275
+ ].filter((s) => Boolean(s)),
276
+ expectedBenefit: scanSurfaceCopy.scanDetail.recommendationDetail.insightBenefit,
277
+ },
278
+ };
279
+ }
280
+ if (candidate.playbookKey) {
281
+ const playbook = playbookDetail(candidate.playbookKey, result);
282
+ return {
283
+ id: candidate.id,
284
+ rank,
285
+ title: candidate.label,
286
+ priority: candidate.priority,
287
+ source: candidate.source,
288
+ signalFamily: family,
289
+ detail: {
290
+ why: playbook.why,
291
+ whyNow: playbook.whyNow,
292
+ signals: playbook.signals
293
+ .map((s) => sanitizeSignal(s))
294
+ .filter((s) => Boolean(s)),
295
+ affectedPackages,
296
+ expectedBenefit: playbook.expectedBenefit,
297
+ },
298
+ };
299
+ }
300
+ if (candidate.finding) {
301
+ const why = sanitizeDetailText(candidate.finding.description) ??
302
+ sanitizeDetailText(candidate.finding.title) ??
303
+ scanSurfaceCopy.scanDetail.recommendationDetail.defaultWhy;
304
+ const whyNow = sanitizeDetailText(`${candidate.finding.title} affects ${candidate.finding.packageName} in this upgrade.`) ?? why;
305
+ return {
306
+ id: candidate.id,
307
+ rank,
308
+ title: candidate.label,
309
+ priority: candidate.priority,
310
+ source: candidate.source,
311
+ signalFamily: family,
312
+ proofRefs: proofFindingIds.length
313
+ ? { findingIds: proofFindingIds }
314
+ : undefined,
315
+ detail: {
316
+ why,
317
+ whyNow,
318
+ signals: [
319
+ sanitizeSignal(`${candidate.finding.severity} severity finding`),
320
+ sanitizeSignal(`Affects ${candidate.finding.packageName}`),
321
+ ].filter((s) => Boolean(s)),
322
+ affectedPackages: packageSummary([candidate.finding.packageName]),
323
+ expectedBenefit: candidate.finding.severity === "critical" ||
324
+ candidate.finding.severity === "high"
325
+ ? scanSurfaceCopy.scanDetail.recommendationDetail
326
+ .securityFindingBenefit
327
+ : scanSurfaceCopy.scanDetail.recommendationDetail.defaultBenefit,
328
+ },
329
+ };
330
+ }
331
+ if (candidate.id === "blocker:critical") {
332
+ const critical = (Array.isArray(result.findings) ? result.findings : []).filter((f) => f.severity === "critical" || f.severity === "high");
333
+ return {
334
+ id: candidate.id,
335
+ rank,
336
+ title: candidate.label,
337
+ priority: candidate.priority,
338
+ source: candidate.source,
339
+ signalFamily: "vulnerable_transitive",
340
+ proofRefs: { findingIds: critical.map((f) => f.id) },
341
+ detail: {
342
+ why: scanSurfaceCopy.scanDetail.recommendationPlaybookDetail
343
+ .resolveCriticalBeforeMerge.why,
344
+ whyNow: critical.length === 1
345
+ ? `This scan flagged a ${critical[0].severity} finding on ${critical[0].packageName}.`
346
+ : `This scan flagged ${critical.length} high-severity findings that block a safe merge.`,
347
+ signals: [
348
+ `${critical.length} high-severity finding${critical.length === 1 ? "" : "s"} detected`,
349
+ ...critical.slice(0, 2).map((f) => sanitizeSignal(f.title)),
350
+ ].filter((s) => Boolean(s)),
351
+ affectedPackages: packageSummary(critical.map((f) => f.packageName)),
352
+ expectedBenefit: scanSurfaceCopy.scanDetail.recommendationPlaybookDetail
353
+ .resolveCriticalBeforeMerge.expectedBenefit,
354
+ },
355
+ };
356
+ }
357
+ const resolvedFamily = family ??
358
+ (candidate.recommendation
359
+ ? (mapRecommendationToCatalogPhrase(candidate.recommendation)?.family ??
360
+ null)
361
+ : null);
362
+ const why = (resolvedFamily
363
+ ? GUIDANCE_WHY_FOR_FAMILY[resolvedFamily]
364
+ : candidate.recommendation
365
+ ? sanitizeDetailText(humanizeEngineSurfaceText(candidate.recommendation.rationale))
366
+ : null) ?? scanSurfaceCopy.scanDetail.recommendationDetail.defaultWhy;
367
+ const whyNow = resolvedFamily
368
+ ? whyNowForFamily(result, resolvedFamily, packages)
369
+ : (firstDecisionReason(result) ??
370
+ scanSurfaceCopy.scanDetail.recommendationDetail.defaultWhyNow);
371
+ const signals = resolvedFamily
372
+ ? signalsForFamily(result, resolvedFamily, packages)
373
+ : candidate.recommendation
374
+ ? [
375
+ sanitizeSignal(humanizeEngineSurfaceText(candidate.recommendation.rationale)),
376
+ ].filter((s) => Boolean(s))
377
+ : [];
378
+ const expectedBenefit = resolvedFamily
379
+ ? GUIDANCE_BENEFIT_FOR_FAMILY[resolvedFamily]
380
+ : scanSurfaceCopy.scanDetail.recommendationDetail.defaultBenefit;
381
+ return {
382
+ id: candidate.id,
383
+ rank,
384
+ title: candidate.label,
385
+ priority: candidate.priority,
386
+ source: candidate.source,
387
+ signalFamily: resolvedFamily,
388
+ proofRefs: proofFindingIds.length
389
+ ? { findingIds: proofFindingIds }
390
+ : undefined,
391
+ detail: {
392
+ why,
393
+ whyNow,
394
+ signals,
395
+ affectedPackages,
396
+ expectedBenefit,
397
+ },
398
+ };
399
+ }
400
+ function insightConfidenceWeight(insight) {
401
+ if (insight.confidence === "confirmed")
402
+ return 92;
403
+ if (insight.confidence === "likely")
404
+ return 88;
405
+ return 82;
406
+ }
407
+ function recommendationWeight(rec) {
408
+ let w = 78;
409
+ if (rec.impact === "high")
410
+ w += 8;
411
+ if (rec.impact === "medium")
412
+ w += 4;
413
+ if (typeof rec.priorityScore === "number" &&
414
+ Number.isFinite(rec.priorityScore)) {
415
+ w += Math.min(10, Math.floor(rec.priorityScore / 10));
416
+ }
417
+ return w;
418
+ }
419
+ function recommendationToLabel(rec) {
420
+ const title = String(rec.title ?? "").trim();
421
+ const rationale = String(rec.rationale ?? "").trim();
422
+ const mapped = mapRecommendationToCatalogPhrase(rec);
423
+ if (GENERIC_RECOMMENDATION_TITLE.test(title)) {
424
+ if (mapped) {
425
+ return sanitizeGuidanceLabel(GUIDANCE_ACTION_FOR_FAMILY[mapped.family]);
426
+ }
427
+ if (rationale) {
428
+ return sanitizeGuidanceLabel(humanizeEngineSurfaceText(rationale));
429
+ }
430
+ }
431
+ const humanized = sanitizeGuidanceLabel(humanizeEngineSurfaceText(title));
432
+ if (humanized)
433
+ return humanized;
434
+ return sanitizeGuidanceLabel(humanizeEngineSurfaceText(rationale));
435
+ }
436
+ function addCandidate(bucket, candidate) {
437
+ const label = sanitizeGuidanceLabel(candidate.label);
438
+ if (!label)
439
+ return;
440
+ bucket.push({ ...candidate, label });
441
+ }
442
+ function collectSignalFamilyCandidates(result) {
443
+ const out = [];
444
+ const posture = mergePostureFromDecision(result.decision?.recommendation);
445
+ for (const { family } of mapGraphInsightsToCatalogPhrases(result.graphInsights)) {
446
+ addCandidate(out, {
447
+ id: `signal:${family}`,
448
+ label: GUIDANCE_ACTION_FOR_FAMILY[family],
449
+ weight: FAMILY_PRIORITY[family],
450
+ family,
451
+ source: "signal",
452
+ priority: posture === "risky" ? "high" : "medium",
453
+ findings: findingsForFamily(result, family),
454
+ });
455
+ }
456
+ const findings = Array.isArray(result.findings) ? result.findings : [];
457
+ for (const finding of findings) {
458
+ const mapped = mapFindingToCatalogPhrase(finding);
459
+ if (mapped) {
460
+ addCandidate(out, {
461
+ id: `finding-family:${finding.id}:${mapped.family}`,
462
+ label: GUIDANCE_ACTION_FOR_FAMILY[mapped.family],
463
+ weight: FAMILY_PRIORITY[mapped.family] +
464
+ (finding.severity === "critical"
465
+ ? 15
466
+ : finding.severity === "high"
467
+ ? 8
468
+ : 0),
469
+ family: mapped.family,
470
+ source: "signal",
471
+ priority: finding.severity === "critical" || finding.severity === "high"
472
+ ? "high"
473
+ : "medium",
474
+ finding,
475
+ findings: findingsForFamily(result, mapped.family),
476
+ });
477
+ }
478
+ }
479
+ const reasons = result.explain?.reasons;
480
+ if (Array.isArray(reasons)) {
481
+ for (const reason of reasons) {
482
+ const mapped = mapExplainReasonToCatalogPhrase(reason);
483
+ if (!mapped)
484
+ continue;
485
+ addCandidate(out, {
486
+ id: `explain:${reason.id}:${mapped.family}`,
487
+ label: GUIDANCE_ACTION_FOR_FAMILY[mapped.family],
488
+ weight: FAMILY_PRIORITY[mapped.family] - 5,
489
+ family: mapped.family,
490
+ source: "signal",
491
+ priority: "medium",
492
+ findings: findingsForFamily(result, mapped.family),
493
+ });
494
+ }
495
+ }
496
+ for (const rec of sortRecommendationsForDisplay(Array.isArray(result.recommendations) ? result.recommendations : [])) {
497
+ const mapped = mapRecommendationToCatalogPhrase(rec);
498
+ if (mapped) {
499
+ addCandidate(out, {
500
+ id: `rec-family:${rec.id}:${mapped.family}`,
501
+ label: GUIDANCE_ACTION_FOR_FAMILY[mapped.family],
502
+ weight: FAMILY_PRIORITY[mapped.family] - 3,
503
+ family: mapped.family,
504
+ source: "signal",
505
+ priority: rec.impact === "high" ? "high" : "medium",
506
+ recommendation: rec,
507
+ findings: findingsForFamily(result, mapped.family),
508
+ });
509
+ }
510
+ }
511
+ return out;
512
+ }
513
+ function collectInsightCandidates(result) {
514
+ const out = [];
515
+ for (const insight of sortPRInsightsForDisplay(Array.isArray(result.insights) ? result.insights : [])) {
516
+ const formatted = formatInsight(insight);
517
+ const action = sanitizeGuidanceLabel(formatted.action);
518
+ if (!action)
519
+ continue;
520
+ addCandidate(out, {
521
+ id: `insight-action:${insight.type}:${action.slice(0, 24)}`,
522
+ label: action,
523
+ weight: insightConfidenceWeight(insight),
524
+ family: null,
525
+ source: "insight",
526
+ priority: insight.priority === "critical" || insight.priority === "high"
527
+ ? "high"
528
+ : "medium",
529
+ insight,
530
+ });
531
+ }
532
+ return out;
533
+ }
534
+ function collectRecommendationCandidates(result) {
535
+ const out = [];
536
+ for (const rec of sortRecommendationsForDisplay(Array.isArray(result.recommendations) ? result.recommendations : [])) {
537
+ const label = recommendationToLabel(rec);
538
+ if (!label)
539
+ continue;
540
+ const family = mapRecommendationToCatalogPhrase(rec)?.family ?? null;
541
+ addCandidate(out, {
542
+ id: `rec:${rec.id}`,
543
+ label,
544
+ weight: recommendationWeight(rec),
545
+ family,
546
+ source: "recommendation",
547
+ priority: rec.impact === "high" ? "high" : "medium",
548
+ recommendation: rec,
549
+ findings: family ? findingsForFamily(result, family) : undefined,
550
+ });
551
+ }
552
+ return out;
553
+ }
554
+ function hasCriticalFindings(result) {
555
+ return (Array.isArray(result.findings) ? result.findings : []).some((f) => f.severity === "critical" || f.severity === "high");
556
+ }
557
+ function posturePlaybookCandidates(posture, reach, lightweightGraph) {
558
+ const copy = scanSurfaceCopy.scanDetail.guidancePlaybook;
559
+ const item = (id, label, priority, playbookKey) => ({
560
+ id,
561
+ label,
562
+ weight: 40,
563
+ family: null,
564
+ source: "posture_playbook",
565
+ priority,
566
+ playbookKey,
567
+ });
568
+ if (posture === "risky") {
569
+ return [
570
+ item("playbook:risky-1", copy.riskyResolveBeforeMerge, "high", "riskyResolveBeforeMerge"),
571
+ item("playbook:risky-2", copy.riskyReviewVulnerable, "high", "riskyReviewVulnerable"),
572
+ item("playbook:risky-3", copy.riskyRerunAfterFixes, "medium", "riskyRerunAfterFixes"),
573
+ ];
574
+ }
575
+ if (posture === "needs_review") {
576
+ const items = [
577
+ item("playbook:review-1", lightweightGraph
578
+ ? copy.reviewDependencyStructure
579
+ : copy.reviewBeforeMerge, "high", lightweightGraph ? "reviewDependencyStructure" : "reviewBeforeMerge"),
580
+ item("playbook:review-3", copy.monitorTransitiveChanges, "low", "monitorTransitiveChanges"),
581
+ ];
582
+ if (reach !== "narrow") {
583
+ items.splice(1, 0, item("playbook:review-2", copy.reviewAffectedPaths, "medium", "reviewAffectedPaths"));
584
+ }
585
+ return items.slice(0, RECOMMENDATION_MAX_ITEMS);
586
+ }
587
+ if (reach === "narrow" || reach === "moderate") {
588
+ return [
589
+ item("playbook:safe-narrow-1", copy.mergeNormally, "low", "mergeNormally"),
590
+ item("playbook:safe-narrow-2", copy.scheduleCleanup, "low", "scheduleCleanup"),
591
+ item("playbook:safe-narrow-3", copy.continueMonitoring, "low", "continueMonitoring"),
592
+ ];
593
+ }
594
+ return [
595
+ item("playbook:safe-wide-1", copy.mergeAfterVerification, "medium", "mergeAfterVerification"),
596
+ item("playbook:safe-wide-2", copy.reviewAffectedPaths, "medium", "reviewAffectedPaths"),
597
+ item("playbook:safe-wide-3", copy.scheduleCleanup, "low", "scheduleCleanup"),
598
+ ];
599
+ }
600
+ function selectCandidates(candidates, posture, reach, lightweightGraph) {
601
+ const seenLabels = new Set();
602
+ const seenFamilies = new Set();
603
+ const selected = [];
604
+ const sorted = [...candidates].sort((a, b) => b.weight - a.weight);
605
+ for (const c of sorted) {
606
+ const key = normalizeKey(c.label);
607
+ if (seenLabels.has(key))
608
+ continue;
609
+ if (c.family && seenFamilies.has(c.family))
610
+ continue;
611
+ seenLabels.add(key);
612
+ if (c.family)
613
+ seenFamilies.add(c.family);
614
+ selected.push(c);
615
+ if (selected.length >= RECOMMENDATION_MAX_ITEMS)
616
+ break;
617
+ }
618
+ if (selected.length === 0) {
619
+ return posturePlaybookCandidates(posture, reach, lightweightGraph);
620
+ }
621
+ if (posture === "safe" &&
622
+ reach === "narrow" &&
623
+ selected.length > RECOMMENDATION_PREFER_MAX_ON_SAFE &&
624
+ sorted[0] &&
625
+ sorted[0].weight < 70) {
626
+ return posturePlaybookCandidates(posture, reach, lightweightGraph).slice(0, RECOMMENDATION_PREFER_MAX_ON_SAFE);
627
+ }
628
+ while (selected.length < RECOMMENDATION_MAX_ITEMS) {
629
+ for (const p of posturePlaybookCandidates(posture, reach, lightweightGraph)) {
630
+ const key = normalizeKey(p.label);
631
+ if (seenLabels.has(key))
632
+ continue;
633
+ seenLabels.add(key);
634
+ selected.push(p);
635
+ if (selected.length >= RECOMMENDATION_MAX_ITEMS)
636
+ break;
637
+ }
638
+ break;
639
+ }
640
+ if (posture === "risky" && !selected.some((i) => i.priority === "high")) {
641
+ selected[0].priority = "high";
642
+ }
643
+ return selected.slice(0, RECOMMENDATION_MAX_ITEMS);
644
+ }
645
+ function deriveScanContext(result, posture, items) {
646
+ if (items.every((i) => i.source === "posture_playbook")) {
647
+ if (posture === "safe") {
648
+ return scanSurfaceCopy.scanDetail.recommendationScanContext.quietSafe;
649
+ }
650
+ if (posture === "needs_review") {
651
+ return scanSurfaceCopy.scanDetail.recommendationScanContext.needsReview;
652
+ }
653
+ if (posture === "risky") {
654
+ return scanSurfaceCopy.scanDetail.recommendationScanContext.risky;
655
+ }
656
+ }
657
+ if (result.reportPresentation?.mode === "lightweight_pr_graph_baseline") {
658
+ return scanSurfaceCopy.scanDetail.recommendationScanContext
659
+ .lightweightGraph;
660
+ }
661
+ return undefined;
662
+ }
663
+ /** Derive the scan detail recommendation center — always returns 1–3 enriched items. */
664
+ export function deriveScanDetailRecommendations(result) {
665
+ const posture = mergePostureFromDecision(result.decision?.recommendation);
666
+ const reach = reachBand(deriveCardExposureDisplay(result.totalScore)?.category);
667
+ const lightweightGraph = result.reportPresentation?.mode === "lightweight_pr_graph_baseline";
668
+ const candidates = [];
669
+ if (posture === "risky" && hasCriticalFindings(result)) {
670
+ candidates.push({
671
+ id: "blocker:critical",
672
+ label: scanSurfaceCopy.scanDetail.guidancePlaybook.resolveCriticalBeforeMerge,
673
+ weight: 120,
674
+ family: "vulnerable_transitive",
675
+ source: "signal",
676
+ priority: "high",
677
+ findings: (Array.isArray(result.findings) ? result.findings : []).filter((f) => f.severity === "critical" || f.severity === "high"),
678
+ });
679
+ }
680
+ candidates.push(...collectInsightCandidates(result));
681
+ candidates.push(...collectRecommendationCandidates(result));
682
+ candidates.push(...collectSignalFamilyCandidates(result));
683
+ const selected = selectCandidates(candidates, posture, reach, lightweightGraph);
684
+ const items = selected.map((candidate, index) => enrichCandidate(candidate, result, index + 1));
685
+ return {
686
+ heading: scanSurfaceCopy.scanDetail.recommendedActionsHeading,
687
+ defaultSelectedId: items[0]?.id ?? "",
688
+ items,
689
+ posture,
690
+ scanContext: deriveScanContext(result, posture, items),
691
+ };
692
+ }
693
+ export function recommendationTitlesForDedupe(center) {
694
+ return new Set(center.items.map((i) => normalizeKey(i.title)));
695
+ }
696
+ export function findingIdsCoveredByRecommendations(center) {
697
+ const ids = new Set();
698
+ for (const item of center.items) {
699
+ for (const id of item.proofRefs?.findingIds ?? []) {
700
+ ids.add(id);
701
+ }
702
+ }
703
+ return ids;
704
+ }
705
+ export function verifyLabelsForDedupe(center) {
706
+ return recommendationTitlesForDedupe(center);
707
+ }
708
+ /** @deprecated Use deriveScanDetailRecommendations */
709
+ export function deriveScanDetailGuidance(result) {
710
+ const center = deriveScanDetailRecommendations(result);
711
+ return {
712
+ posture: center.posture,
713
+ items: center.items.map((item, index) => ({
714
+ id: item.id,
715
+ label: item.title,
716
+ emphasis: index === 0 ? "primary" : "secondary",
717
+ priority: item.priority,
718
+ source: item.source,
719
+ })),
720
+ };
721
+ }
722
+ /** @deprecated Use recommendationTitlesForDedupe */
723
+ export function guidanceLabelsForDedupe(whatToDo) {
724
+ return new Set(whatToDo.items.map((i) => normalizeKey(i.label)));
725
+ }