@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.
- package/README.md +37 -0
- package/dist/cardObservationCatalog.d.ts +55 -0
- package/dist/cardObservationCatalog.d.ts.map +1 -0
- package/dist/cardObservationCatalog.js +320 -0
- package/dist/cardSummaryCopy.d.ts +12 -0
- package/dist/cardSummaryCopy.d.ts.map +1 -0
- package/dist/cardSummaryCopy.js +36 -0
- package/dist/deriveCardDisplaySummary.d.ts +16 -0
- package/dist/deriveCardDisplaySummary.d.ts.map +1 -0
- package/dist/deriveCardDisplaySummary.js +56 -0
- package/dist/deriveCardDisplaySummary.test.d.ts +2 -0
- package/dist/deriveCardDisplaySummary.test.d.ts.map +1 -0
- package/dist/deriveCardDisplaySummary.test.js +89 -0
- package/dist/deriveCardOperationalObservations.d.ts +19 -0
- package/dist/deriveCardOperationalObservations.d.ts.map +1 -0
- package/dist/deriveCardOperationalObservations.js +180 -0
- package/dist/deriveCardOperationalObservations.test.d.ts +2 -0
- package/dist/deriveCardOperationalObservations.test.d.ts.map +1 -0
- package/dist/deriveCardOperationalObservations.test.js +268 -0
- package/dist/deriveRiskScoreSummary.d.ts +33 -0
- package/dist/deriveRiskScoreSummary.d.ts.map +1 -0
- package/dist/deriveRiskScoreSummary.js +90 -0
- package/dist/deriveRiskScoreSummary.test.d.ts +2 -0
- package/dist/deriveRiskScoreSummary.test.d.ts.map +1 -0
- package/dist/deriveRiskScoreSummary.test.js +47 -0
- package/dist/deriveScanDetailRecommendations.d.ts +63 -0
- package/dist/deriveScanDetailRecommendations.d.ts.map +1 -0
- package/dist/deriveScanDetailRecommendations.js +725 -0
- package/dist/deriveScanDetailRecommendations.test.d.ts +2 -0
- package/dist/deriveScanDetailRecommendations.test.d.ts.map +1 -0
- package/dist/deriveScanDetailRecommendations.test.js +181 -0
- package/dist/formatCardAreaLabels.d.ts +8 -0
- package/dist/formatCardAreaLabels.d.ts.map +1 -0
- package/dist/formatCardAreaLabels.js +65 -0
- package/dist/formatCardEvidenceCounts.d.ts +7 -0
- package/dist/formatCardEvidenceCounts.d.ts.map +1 -0
- package/dist/formatCardEvidenceCounts.js +19 -0
- package/dist/formatCardExposureDisplay.d.ts +24 -0
- package/dist/formatCardExposureDisplay.d.ts.map +1 -0
- package/dist/formatCardExposureDisplay.js +71 -0
- package/dist/formatCardExposureDisplay.test.d.ts +2 -0
- package/dist/formatCardExposureDisplay.test.d.ts.map +1 -0
- package/dist/formatCardExposureDisplay.test.js +36 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/dist/productMessaging.d.ts +85 -0
- package/dist/productMessaging.d.ts.map +1 -0
- package/dist/productMessaging.js +109 -0
- package/dist/productMessaging.test.d.ts +2 -0
- package/dist/productMessaging.test.d.ts.map +1 -0
- package/dist/productMessaging.test.js +97 -0
- package/dist/riskVocabulary.d.ts +8 -2
- package/dist/riskVocabulary.d.ts.map +1 -1
- package/dist/riskVocabulary.js +26 -9
- package/dist/scanCardSummary.d.ts +9 -0
- package/dist/scanCardSummary.d.ts.map +1 -1
- package/dist/scanCardSummary.js +19 -29
- package/dist/scanCardSummary.test.js +45 -8
- package/dist/scanDetailViewModel.d.ts +108 -0
- package/dist/scanDetailViewModel.d.ts.map +1 -0
- package/dist/scanDetailViewModel.js +420 -0
- package/dist/scanDetailViewModel.test.d.ts +2 -0
- package/dist/scanDetailViewModel.test.d.ts.map +1 -0
- package/dist/scanDetailViewModel.test.js +503 -0
- package/dist/scanSurfaceCopy.d.ts +187 -0
- package/dist/scanSurfaceCopy.d.ts.map +1 -1
- package/dist/scanSurfaceCopy.js +190 -0
- package/dist/truncateCardSummary.d.ts +5 -0
- package/dist/truncateCardSummary.d.ts.map +1 -0
- package/dist/truncateCardSummary.js +23 -0
- package/package.json +1 -1
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { sortRecommendationsForDisplay } from "./actionsStepSummary.js";
|
|
2
|
+
import { mapContributionToCatalogPhrase, mapExplainReasonToCatalogPhrase, mapFindingToCatalogPhrase, mapGraphInsightsToCatalogPhrases, mapRecommendationToCatalogPhrase, mapTextToCatalogPhrase, } from "./cardObservationCatalog.js";
|
|
3
|
+
import { formatCardAreaLabels } from "./formatCardAreaLabels.js";
|
|
4
|
+
import { selectTopAffectedAreas } from "./selectTopAffectedAreas.js";
|
|
5
|
+
const SOURCE_WEIGHT = {
|
|
6
|
+
recommendation: 100,
|
|
7
|
+
explain: 80,
|
|
8
|
+
finding: 60,
|
|
9
|
+
graph: 50,
|
|
10
|
+
contribution: 45,
|
|
11
|
+
summary: 20,
|
|
12
|
+
area: 15,
|
|
13
|
+
};
|
|
14
|
+
let rankCounter = 0;
|
|
15
|
+
function nextRank() {
|
|
16
|
+
rankCounter += 1;
|
|
17
|
+
return rankCounter;
|
|
18
|
+
}
|
|
19
|
+
function addCandidate(bucket, candidate) {
|
|
20
|
+
bucket.push({ ...candidate, rank: nextRank() });
|
|
21
|
+
}
|
|
22
|
+
function collectCandidates(result) {
|
|
23
|
+
const candidates = [];
|
|
24
|
+
rankCounter = 0;
|
|
25
|
+
const recs = sortRecommendationsForDisplay(Array.isArray(result.recommendations) ? result.recommendations : []);
|
|
26
|
+
for (const rec of recs) {
|
|
27
|
+
const mapped = mapRecommendationToCatalogPhrase(rec);
|
|
28
|
+
if (mapped) {
|
|
29
|
+
addCandidate(candidates, {
|
|
30
|
+
...mapped,
|
|
31
|
+
weight: SOURCE_WEIGHT.recommendation,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (Array.isArray(result.explain?.reasons)) {
|
|
36
|
+
const sorted = [...result.explain.reasons]
|
|
37
|
+
.filter((r) => r.title)
|
|
38
|
+
.sort((a, b) => Math.abs(b.scoreImpact ?? 0) - Math.abs(a.scoreImpact ?? 0));
|
|
39
|
+
for (const reason of sorted) {
|
|
40
|
+
const mapped = mapExplainReasonToCatalogPhrase(reason);
|
|
41
|
+
if (mapped) {
|
|
42
|
+
addCandidate(candidates, {
|
|
43
|
+
...mapped,
|
|
44
|
+
weight: SOURCE_WEIGHT.explain,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (Array.isArray(result.findings)) {
|
|
50
|
+
const severityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
51
|
+
const sortedFindings = [...result.findings].sort((a, b) => (severityOrder[b.severity] ?? 0) - (severityOrder[a.severity] ?? 0));
|
|
52
|
+
for (const finding of sortedFindings) {
|
|
53
|
+
const mapped = mapFindingToCatalogPhrase(finding);
|
|
54
|
+
if (mapped) {
|
|
55
|
+
addCandidate(candidates, {
|
|
56
|
+
...mapped,
|
|
57
|
+
weight: SOURCE_WEIGHT.finding,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const mapped of mapGraphInsightsToCatalogPhrases(result.graphInsights)) {
|
|
63
|
+
addCandidate(candidates, {
|
|
64
|
+
...mapped,
|
|
65
|
+
weight: SOURCE_WEIGHT.graph,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (Array.isArray(result.contributions)) {
|
|
69
|
+
for (const c of result.contributions) {
|
|
70
|
+
const mapped = mapContributionToCatalogPhrase(c.id);
|
|
71
|
+
if (mapped) {
|
|
72
|
+
addCandidate(candidates, {
|
|
73
|
+
...mapped,
|
|
74
|
+
weight: SOURCE_WEIGHT.contribution,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const reasoning = result.decision?.reasoning;
|
|
80
|
+
if (Array.isArray(reasoning)) {
|
|
81
|
+
for (const line of reasoning) {
|
|
82
|
+
const mapped = mapTextToCatalogPhrase(line);
|
|
83
|
+
if (mapped) {
|
|
84
|
+
addCandidate(candidates, {
|
|
85
|
+
...mapped,
|
|
86
|
+
weight: SOURCE_WEIGHT.summary,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (Array.isArray(result.insights)) {
|
|
92
|
+
for (const ins of result.insights) {
|
|
93
|
+
const mapped = mapTextToCatalogPhrase(ins.message);
|
|
94
|
+
if (mapped) {
|
|
95
|
+
addCandidate(candidates, {
|
|
96
|
+
...mapped,
|
|
97
|
+
weight: SOURCE_WEIGHT.summary,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return candidates;
|
|
103
|
+
}
|
|
104
|
+
function selectObservations(candidates, max) {
|
|
105
|
+
const byFamily = new Map();
|
|
106
|
+
for (const c of candidates) {
|
|
107
|
+
const existing = byFamily.get(c.family);
|
|
108
|
+
if (!existing || c.weight > existing.weight) {
|
|
109
|
+
byFamily.set(c.family, c);
|
|
110
|
+
}
|
|
111
|
+
else if (c.weight === existing.weight && c.rank < existing.rank) {
|
|
112
|
+
byFamily.set(c.family, c);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const distinct = [...byFamily.values()].sort((a, b) => {
|
|
116
|
+
if (b.weight !== a.weight)
|
|
117
|
+
return b.weight - a.weight;
|
|
118
|
+
return a.rank - b.rank;
|
|
119
|
+
});
|
|
120
|
+
if (distinct.length === 0)
|
|
121
|
+
return [];
|
|
122
|
+
const topWeight = distinct[0].weight;
|
|
123
|
+
const strongFamilies = distinct.filter((c) => c.weight >= topWeight - 15 || c.weight >= SOURCE_WEIGHT.finding);
|
|
124
|
+
let take = distinct;
|
|
125
|
+
if (strongFamilies.length <= 2 && distinct.length > 2) {
|
|
126
|
+
take = distinct.slice(0, 2);
|
|
127
|
+
}
|
|
128
|
+
else if (distinct.length > max) {
|
|
129
|
+
take = distinct.slice(0, max);
|
|
130
|
+
}
|
|
131
|
+
return take.map((c) => c.phrase);
|
|
132
|
+
}
|
|
133
|
+
function deriveSupportingLine(result, observations, candidates) {
|
|
134
|
+
if (observations.length !== 1)
|
|
135
|
+
return null;
|
|
136
|
+
const usedPhrase = observations[0];
|
|
137
|
+
const usedFamily = candidates.find((c) => c.phrase === usedPhrase)?.family;
|
|
138
|
+
const secondCatalog = candidates.find((c) => c.phrase !== usedPhrase && c.family !== usedFamily);
|
|
139
|
+
if (secondCatalog)
|
|
140
|
+
return secondCatalog.phrase;
|
|
141
|
+
const areas = formatCardAreaLabels(selectTopAffectedAreas(result, { max: 2 }));
|
|
142
|
+
const area = areas[0];
|
|
143
|
+
if (area && area.length <= 48 && !/\d/.test(area)) {
|
|
144
|
+
return area;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
function denormalizedFallback(mergePosture) {
|
|
149
|
+
if (mergePosture === "safe") {
|
|
150
|
+
return { operationalObservations: [], supportingLine: null };
|
|
151
|
+
}
|
|
152
|
+
if (mergePosture === "needs_review") {
|
|
153
|
+
return { operationalObservations: [], supportingLine: null };
|
|
154
|
+
}
|
|
155
|
+
return { operationalObservations: [], supportingLine: null };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Derive 0–3 catalog-mapped operational observations for PR dashboard cards.
|
|
159
|
+
* Primary path: silence when no mappable signals (G2).
|
|
160
|
+
*/
|
|
161
|
+
export function deriveCardOperationalObservations(result, options) {
|
|
162
|
+
const max = options.max ?? 3;
|
|
163
|
+
const hasFullResult = options.hasFullResult !== false;
|
|
164
|
+
if (!result) {
|
|
165
|
+
if (hasFullResult) {
|
|
166
|
+
return { operationalObservations: [], supportingLine: null };
|
|
167
|
+
}
|
|
168
|
+
return denormalizedFallback(options.mergePosture);
|
|
169
|
+
}
|
|
170
|
+
const candidates = collectCandidates(result);
|
|
171
|
+
const operationalObservations = selectObservations(candidates, max);
|
|
172
|
+
if (operationalObservations.length === 0) {
|
|
173
|
+
if (hasFullResult) {
|
|
174
|
+
return { operationalObservations: [], supportingLine: null };
|
|
175
|
+
}
|
|
176
|
+
return denormalizedFallback(options.mergePosture);
|
|
177
|
+
}
|
|
178
|
+
const supportingLine = deriveSupportingLine(result, operationalObservations, candidates);
|
|
179
|
+
return { operationalObservations, supportingLine };
|
|
180
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deriveCardOperationalObservations.test.d.ts","sourceRoot":"","sources":["../src/deriveCardOperationalObservations.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { CARD_OBSERVATION_CATALOG, containsImperativeOrActionLanguage, containsTelemetryOrDigits, isCatalogPhrase, isGenericObservation, mapRecommendationToCatalogPhrase, mapTextToCatalogPhrase, validateCatalogIntegrity, } from "./cardObservationCatalog.js";
|
|
3
|
+
import { deriveCardOperationalObservations } from "./deriveCardOperationalObservations.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
|
+
describe("cardObservationCatalog", () => {
|
|
16
|
+
it("validates catalog integrity", () => {
|
|
17
|
+
expect(() => validateCatalogIntegrity()).not.toThrow();
|
|
18
|
+
});
|
|
19
|
+
it("keeps all catalog phrases within length and guardrails", () => {
|
|
20
|
+
for (const phrase of CARD_OBSERVATION_CATALOG) {
|
|
21
|
+
expect(isCatalogPhrase(phrase)).toBe(true);
|
|
22
|
+
expect(containsTelemetryOrDigits(phrase)).toBe(false);
|
|
23
|
+
expect(containsImperativeOrActionLanguage(phrase)).toBe(false);
|
|
24
|
+
expect(isGenericObservation(phrase)).toBe(false);
|
|
25
|
+
expect(phrase.length).toBeLessThanOrEqual(48);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
it("maps recommendation imperatives to detection phrases", () => {
|
|
29
|
+
const mapped = mapRecommendationToCatalogPhrase({
|
|
30
|
+
id: "rec-1",
|
|
31
|
+
title: "Reduce duplicate dependency versions",
|
|
32
|
+
rationale: "Overlapping semver ranges on runtime boundary",
|
|
33
|
+
impact: "high",
|
|
34
|
+
});
|
|
35
|
+
expect(mapped?.phrase).toBe("Duplicate dependency versions detected");
|
|
36
|
+
});
|
|
37
|
+
it("maps graph.duplicates tokens to duplicate versions phrase", () => {
|
|
38
|
+
const mapped = mapTextToCatalogPhrase("graph.duplicates on runtime boundary");
|
|
39
|
+
expect(mapped?.phrase).toBe("Duplicate dependency versions detected");
|
|
40
|
+
});
|
|
41
|
+
it("rejects generic narration", () => {
|
|
42
|
+
expect(isGenericObservation("No high-confidence merge risks")).toBe(true);
|
|
43
|
+
expect(isGenericObservation("dependency concerns detected")).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe("deriveCardOperationalObservations", () => {
|
|
47
|
+
it("returns silence on primary path when no mappable signals", () => {
|
|
48
|
+
const result = deriveCardOperationalObservations({
|
|
49
|
+
...baseResult,
|
|
50
|
+
decision: {
|
|
51
|
+
recommendation: "safe",
|
|
52
|
+
confidence: "high",
|
|
53
|
+
reasoning: [
|
|
54
|
+
"No high-confidence merge risks from this PR dependency change",
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
}, { mergePosture: "safe", hasFullResult: true });
|
|
58
|
+
expect(result.operationalObservations).toEqual([]);
|
|
59
|
+
expect(result.supportingLine).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
it("surfaces catalog phrase from explain.reasons instead of generic reasoning", () => {
|
|
62
|
+
const result = deriveCardOperationalObservations({
|
|
63
|
+
...baseResult,
|
|
64
|
+
totalScore: 55,
|
|
65
|
+
decision: {
|
|
66
|
+
recommendation: "needs_review",
|
|
67
|
+
confidence: "medium",
|
|
68
|
+
reasoning: ["Potential runtime impact detected"],
|
|
69
|
+
},
|
|
70
|
+
explain: {
|
|
71
|
+
reasons: [
|
|
72
|
+
{
|
|
73
|
+
id: "graph.transitive.1",
|
|
74
|
+
layer: "ecosystem",
|
|
75
|
+
title: "graph.transitive volume cluster",
|
|
76
|
+
scoreImpact: 18,
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
}, { mergePosture: "needs_review", hasFullResult: true });
|
|
81
|
+
expect(result.operationalObservations).toContain("High transitive dependency volume");
|
|
82
|
+
expect(result.operationalObservations.some((o) => /merge risk|runtime impact/i.test(o))).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
it("maps duplicate signal from recommendation without leaking imperative title", () => {
|
|
85
|
+
const result = deriveCardOperationalObservations({
|
|
86
|
+
...baseResult,
|
|
87
|
+
recommendations: [
|
|
88
|
+
{
|
|
89
|
+
id: "rec-dup",
|
|
90
|
+
title: "Reduce duplicate dependency versions",
|
|
91
|
+
rationale: "Multiple semver majors on shared packages",
|
|
92
|
+
impact: "high",
|
|
93
|
+
priorityScore: 90,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
}, { mergePosture: "needs_review", hasFullResult: true });
|
|
97
|
+
expect(result.operationalObservations).toEqual([
|
|
98
|
+
"Duplicate dependency versions detected",
|
|
99
|
+
]);
|
|
100
|
+
expect(result.operationalObservations.some((o) => /^reduce/i.test(o))).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
it("caps at three distinct catalog phrases on fat scans", () => {
|
|
103
|
+
const fat = {
|
|
104
|
+
...baseResult,
|
|
105
|
+
totalScore: 72,
|
|
106
|
+
recommendations: [
|
|
107
|
+
{
|
|
108
|
+
id: "r1",
|
|
109
|
+
title: "Reduce duplicate dependency versions",
|
|
110
|
+
rationale: "semver overlap",
|
|
111
|
+
impact: "high",
|
|
112
|
+
priorityScore: 95,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: "r2",
|
|
116
|
+
title: "Review transitive dependency surface",
|
|
117
|
+
rationale: "graph.transitive volume",
|
|
118
|
+
impact: "high",
|
|
119
|
+
priorityScore: 90,
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
explain: {
|
|
123
|
+
reasons: [
|
|
124
|
+
{
|
|
125
|
+
id: "g1",
|
|
126
|
+
layer: "security",
|
|
127
|
+
title: "graph.vulnerable packages",
|
|
128
|
+
scoreImpact: 20,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: "g2",
|
|
132
|
+
layer: "maintainability",
|
|
133
|
+
title: "Stale releases on multiple direct dependencies",
|
|
134
|
+
scoreImpact: 15,
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
graphInsights: {
|
|
139
|
+
maxDepth: 9,
|
|
140
|
+
nodes: 1500,
|
|
141
|
+
edges: 4000,
|
|
142
|
+
vulnerable: [
|
|
143
|
+
{
|
|
144
|
+
kind: "vulnerable",
|
|
145
|
+
packageName: "lodash",
|
|
146
|
+
direct: false,
|
|
147
|
+
depth: 3,
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
hotspots: Array.from({ length: 5 }, (_, i) => ({
|
|
151
|
+
kind: "hotspot",
|
|
152
|
+
packageName: `pkg-${i}`,
|
|
153
|
+
direct: true,
|
|
154
|
+
depth: 1,
|
|
155
|
+
})),
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
const result = deriveCardOperationalObservations(fat, {
|
|
159
|
+
mergePosture: "risky",
|
|
160
|
+
hasFullResult: true,
|
|
161
|
+
max: 3,
|
|
162
|
+
});
|
|
163
|
+
expect(result.operationalObservations.length).toBeLessThanOrEqual(3);
|
|
164
|
+
for (const phrase of result.operationalObservations) {
|
|
165
|
+
expect(isCatalogPhrase(phrase)).toBe(true);
|
|
166
|
+
expect(containsTelemetryOrDigits(phrase)).toBe(false);
|
|
167
|
+
expect(containsImperativeOrActionLanguage(phrase)).toBe(false);
|
|
168
|
+
}
|
|
169
|
+
expect(result.supportingLine).toBeNull();
|
|
170
|
+
});
|
|
171
|
+
it("prefers one to two observations when one family dominates", () => {
|
|
172
|
+
const result = deriveCardOperationalObservations({
|
|
173
|
+
...baseResult,
|
|
174
|
+
explain: {
|
|
175
|
+
reasons: [
|
|
176
|
+
{
|
|
177
|
+
id: "t1",
|
|
178
|
+
layer: "ecosystem",
|
|
179
|
+
title: "graph.transitive volume cluster 1",
|
|
180
|
+
scoreImpact: 30,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: "t2",
|
|
184
|
+
layer: "ecosystem",
|
|
185
|
+
title: "graph.transitive volume cluster 2",
|
|
186
|
+
scoreImpact: 25,
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
id: "t3",
|
|
190
|
+
layer: "ecosystem",
|
|
191
|
+
title: "graph.transitive volume cluster 3",
|
|
192
|
+
scoreImpact: 20,
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
}, { mergePosture: "needs_review", hasFullResult: true });
|
|
197
|
+
expect(result.operationalObservations).toEqual([
|
|
198
|
+
"High transitive dependency volume",
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
it("omits supporting line when three observations are shown", () => {
|
|
202
|
+
const result = deriveCardOperationalObservations({
|
|
203
|
+
...baseResult,
|
|
204
|
+
recommendations: [
|
|
205
|
+
{
|
|
206
|
+
id: "r1",
|
|
207
|
+
title: "Reduce duplicate dependency versions",
|
|
208
|
+
rationale: "dup",
|
|
209
|
+
impact: "high",
|
|
210
|
+
priorityScore: 95,
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
explain: {
|
|
214
|
+
reasons: [
|
|
215
|
+
{
|
|
216
|
+
id: "e1",
|
|
217
|
+
layer: "security",
|
|
218
|
+
title: "graph.vulnerable",
|
|
219
|
+
scoreImpact: 20,
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
id: "e2",
|
|
223
|
+
layer: "upgradeImpact",
|
|
224
|
+
title: "Large upgrade blast radius",
|
|
225
|
+
scoreImpact: 18,
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
}, { mergePosture: "risky", hasFullResult: true });
|
|
230
|
+
if (result.operationalObservations.length === 3) {
|
|
231
|
+
expect(result.supportingLine).toBeNull();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
it("allows supporting line only with exactly one observation", () => {
|
|
235
|
+
const result = deriveCardOperationalObservations({
|
|
236
|
+
...baseResult,
|
|
237
|
+
recommendations: [
|
|
238
|
+
{
|
|
239
|
+
id: "r1",
|
|
240
|
+
title: "Reduce duplicate dependency versions",
|
|
241
|
+
rationale: "dup",
|
|
242
|
+
impact: "high",
|
|
243
|
+
priorityScore: 95,
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
explain: {
|
|
247
|
+
reasons: [
|
|
248
|
+
{
|
|
249
|
+
id: "e1",
|
|
250
|
+
layer: "security",
|
|
251
|
+
title: "graph.vulnerable packages in tree",
|
|
252
|
+
scoreImpact: 10,
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
},
|
|
256
|
+
}, { mergePosture: "needs_review", hasFullResult: true });
|
|
257
|
+
if (result.operationalObservations.length === 1) {
|
|
258
|
+
expect(result.supportingLine).toBe("Vulnerable transitive packages detected");
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
it("returns silence on denormalized path without signals", () => {
|
|
262
|
+
const result = deriveCardOperationalObservations(null, {
|
|
263
|
+
mergePosture: "safe",
|
|
264
|
+
hasFullResult: false,
|
|
265
|
+
});
|
|
266
|
+
expect(result.operationalObservations).toEqual([]);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ScanResult, ScoreLayer } from "./types.js";
|
|
2
|
+
export declare const SCAN_DETAIL_RISK_BAND_MODERATE_MIN = 40;
|
|
3
|
+
export declare const SCAN_DETAIL_RISK_BAND_HIGH_MIN = 80;
|
|
4
|
+
export type ScanDetailOverallRiskBand = "low" | "moderate" | "high";
|
|
5
|
+
export type ScanDetailLayerConcernLevel = "low" | "medium" | "high";
|
|
6
|
+
export type ScanDetailSignalLayer = {
|
|
7
|
+
layer: ScoreLayer;
|
|
8
|
+
label: string;
|
|
9
|
+
score: number;
|
|
10
|
+
concernLevel: ScanDetailLayerConcernLevel;
|
|
11
|
+
concernLabel: string;
|
|
12
|
+
};
|
|
13
|
+
/** @deprecated Use ScanDetailSignalLayer */
|
|
14
|
+
export type ScanDetailRiskLayer = ScanDetailSignalLayer;
|
|
15
|
+
export type RiskScoreGaugeModel = {
|
|
16
|
+
fillPercent: number;
|
|
17
|
+
band: ScanDetailOverallRiskBand;
|
|
18
|
+
ariaLabel: string;
|
|
19
|
+
};
|
|
20
|
+
export type ScanDetailSignalSummary = {
|
|
21
|
+
score: number;
|
|
22
|
+
overallBand: ScanDetailOverallRiskBand;
|
|
23
|
+
overallLabel: string;
|
|
24
|
+
gauge: RiskScoreGaugeModel;
|
|
25
|
+
layers: ScanDetailSignalLayer[];
|
|
26
|
+
};
|
|
27
|
+
/** @deprecated Use ScanDetailSignalSummary */
|
|
28
|
+
export type ScanDetailRiskSummary = ScanDetailSignalSummary;
|
|
29
|
+
export declare function deriveOverallRiskBand(score: number | null | undefined): ScanDetailOverallRiskBand;
|
|
30
|
+
export declare function overallSignalBandLabel(band: ScanDetailOverallRiskBand): string;
|
|
31
|
+
export declare function deriveRiskScoreGauge(score: number, band: ScanDetailOverallRiskBand): RiskScoreGaugeModel;
|
|
32
|
+
export declare function deriveSignalSummary(result: ScanResult): ScanDetailSignalSummary | null;
|
|
33
|
+
//# sourceMappingURL=deriveRiskScoreSummary.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deriveRiskScoreSummary.d.ts","sourceRoot":"","sources":["../src/deriveRiskScoreSummary.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAEzD,eAAO,MAAM,kCAAkC,KAAK,CAAC;AACrD,eAAO,MAAM,8BAA8B,KAAK,CAAC;AAgBjD,MAAM,MAAM,yBAAyB,GAAG,KAAK,GAAG,UAAU,GAAG,MAAM,CAAC;AACpE,MAAM,MAAM,2BAA2B,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;AAEpE,MAAM,MAAM,qBAAqB,GAAG;IAClC,KAAK,EAAE,UAAU,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,2BAA2B,CAAC;IAC1C,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,4CAA4C;AAC5C,MAAM,MAAM,mBAAmB,GAAG,qBAAqB,CAAC;AAExD,MAAM,MAAM,mBAAmB,GAAG;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,yBAAyB,CAAC;IAChC,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,yBAAyB,CAAC;IACvC,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,mBAAmB,CAAC;IAC3B,MAAM,EAAE,qBAAqB,EAAE,CAAC;CACjC,CAAC;AAEF,8CAA8C;AAC9C,MAAM,MAAM,qBAAqB,GAAG,uBAAuB,CAAC;AAO5D,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAC/B,yBAAyB,CAM3B;AAED,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,yBAAyB,GAC9B,MAAM,CAKR;AAgBD,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,yBAAyB,GAC9B,mBAAmB,CASrB;AAED,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,UAAU,GACjB,uBAAuB,GAAG,IAAI,CAoChC"}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { layerRiskBandLabel } from "./actionsStepSummary.js";
|
|
2
|
+
import { scanSurfaceCopy } from "./scanSurfaceCopy.js";
|
|
3
|
+
export const SCAN_DETAIL_RISK_BAND_MODERATE_MIN = 40;
|
|
4
|
+
export const SCAN_DETAIL_RISK_BAND_HIGH_MIN = 80;
|
|
5
|
+
const LAYER_ORDER = [
|
|
6
|
+
"security",
|
|
7
|
+
"maintainability",
|
|
8
|
+
"ecosystem",
|
|
9
|
+
"upgradeImpact",
|
|
10
|
+
];
|
|
11
|
+
const LAYER_LABEL = {
|
|
12
|
+
security: "Security",
|
|
13
|
+
maintainability: "Maintainability",
|
|
14
|
+
ecosystem: "Ecosystem",
|
|
15
|
+
upgradeImpact: "Upgrade Impact",
|
|
16
|
+
};
|
|
17
|
+
function clampScore(score) {
|
|
18
|
+
if (!Number.isFinite(score))
|
|
19
|
+
return 0;
|
|
20
|
+
return Math.max(0, Math.min(100, Math.round(score)));
|
|
21
|
+
}
|
|
22
|
+
export function deriveOverallRiskBand(score) {
|
|
23
|
+
if (score == null || !Number.isFinite(score))
|
|
24
|
+
return "low";
|
|
25
|
+
const value = clampScore(score);
|
|
26
|
+
if (value >= SCAN_DETAIL_RISK_BAND_HIGH_MIN)
|
|
27
|
+
return "high";
|
|
28
|
+
if (value >= SCAN_DETAIL_RISK_BAND_MODERATE_MIN)
|
|
29
|
+
return "moderate";
|
|
30
|
+
return "low";
|
|
31
|
+
}
|
|
32
|
+
export function overallSignalBandLabel(band) {
|
|
33
|
+
const copy = scanSurfaceCopy.scanDetail.signalSummary.bandLabel;
|
|
34
|
+
if (band === "high")
|
|
35
|
+
return copy.high;
|
|
36
|
+
if (band === "moderate")
|
|
37
|
+
return copy.moderate;
|
|
38
|
+
return copy.low;
|
|
39
|
+
}
|
|
40
|
+
function layerBandToSignal(band) {
|
|
41
|
+
const copy = scanSurfaceCopy.scanDetail.signalSummary.signalLabel;
|
|
42
|
+
if (band === "High") {
|
|
43
|
+
return { level: "high", label: copy.high };
|
|
44
|
+
}
|
|
45
|
+
if (band === "Moderate") {
|
|
46
|
+
return { level: "medium", label: copy.medium };
|
|
47
|
+
}
|
|
48
|
+
return { level: "low", label: copy.low };
|
|
49
|
+
}
|
|
50
|
+
export function deriveRiskScoreGauge(score, band) {
|
|
51
|
+
const clamped = clampScore(score);
|
|
52
|
+
return {
|
|
53
|
+
fillPercent: clamped,
|
|
54
|
+
band,
|
|
55
|
+
ariaLabel: scanSurfaceCopy.scanDetail.signalSummary.gaugeAriaLabel
|
|
56
|
+
.replace("{score}", String(clamped))
|
|
57
|
+
.replace("{band}", overallSignalBandLabel(band)),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function deriveSignalSummary(result) {
|
|
61
|
+
if (typeof result.totalScore !== "number" ||
|
|
62
|
+
!Number.isFinite(result.totalScore)) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const score = clampScore(result.totalScore);
|
|
66
|
+
const overallBand = deriveOverallRiskBand(score);
|
|
67
|
+
const overallLabel = overallSignalBandLabel(overallBand);
|
|
68
|
+
const gauge = deriveRiskScoreGauge(score, overallBand);
|
|
69
|
+
const layers = LAYER_ORDER.map((layer) => {
|
|
70
|
+
const layerScore = typeof result.layerScores?.[layer] === "number"
|
|
71
|
+
? clampScore(result.layerScores[layer])
|
|
72
|
+
: 0;
|
|
73
|
+
const band = layerRiskBandLabel(layerScore);
|
|
74
|
+
const signal = layerBandToSignal(band);
|
|
75
|
+
return {
|
|
76
|
+
layer,
|
|
77
|
+
label: LAYER_LABEL[layer],
|
|
78
|
+
score: layerScore,
|
|
79
|
+
concernLevel: signal.level,
|
|
80
|
+
concernLabel: signal.label,
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
score,
|
|
85
|
+
overallBand,
|
|
86
|
+
overallLabel,
|
|
87
|
+
gauge,
|
|
88
|
+
layers,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deriveRiskScoreSummary.test.d.ts","sourceRoot":"","sources":["../src/deriveRiskScoreSummary.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { deriveOverallRiskBand, deriveSignalSummary, SCAN_DETAIL_RISK_BAND_HIGH_MIN, SCAN_DETAIL_RISK_BAND_MODERATE_MIN, } from "./deriveRiskScoreSummary.js";
|
|
3
|
+
const baseResult = {
|
|
4
|
+
totalScore: 71,
|
|
5
|
+
layerScores: {
|
|
6
|
+
security: 80,
|
|
7
|
+
maintainability: 65,
|
|
8
|
+
ecosystem: 55,
|
|
9
|
+
upgradeImpact: 70,
|
|
10
|
+
},
|
|
11
|
+
findings: [],
|
|
12
|
+
generatedAt: "2026-01-01T00:00:00.000Z",
|
|
13
|
+
};
|
|
14
|
+
describe("deriveOverallRiskBand", () => {
|
|
15
|
+
it("uses 40/80 thresholds", () => {
|
|
16
|
+
expect(SCAN_DETAIL_RISK_BAND_MODERATE_MIN).toBe(40);
|
|
17
|
+
expect(SCAN_DETAIL_RISK_BAND_HIGH_MIN).toBe(80);
|
|
18
|
+
expect(deriveOverallRiskBand(39)).toBe("low");
|
|
19
|
+
expect(deriveOverallRiskBand(40)).toBe("moderate");
|
|
20
|
+
expect(deriveOverallRiskBand(71)).toBe("moderate");
|
|
21
|
+
expect(deriveOverallRiskBand(79)).toBe("moderate");
|
|
22
|
+
expect(deriveOverallRiskBand(80)).toBe("high");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe("deriveSignalSummary", () => {
|
|
26
|
+
it("returns moderate band for score 71 on safe posture", () => {
|
|
27
|
+
const summary = deriveSignalSummary({
|
|
28
|
+
...baseResult,
|
|
29
|
+
decision: { recommendation: "safe", confidence: "high", reasoning: [] },
|
|
30
|
+
});
|
|
31
|
+
expect(summary?.score).toBe(71);
|
|
32
|
+
expect(summary?.overallBand).toBe("moderate");
|
|
33
|
+
expect(summary?.overallLabel).toBe("Moderate");
|
|
34
|
+
expect(summary?.gauge.fillPercent).toBe(71);
|
|
35
|
+
});
|
|
36
|
+
it("returns null when totalScore is missing", () => {
|
|
37
|
+
expect(deriveSignalSummary({
|
|
38
|
+
...baseResult,
|
|
39
|
+
totalScore: Number.NaN,
|
|
40
|
+
})).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
it("uses short signal labels on layer rows", () => {
|
|
43
|
+
const summary = deriveSignalSummary(baseResult);
|
|
44
|
+
expect(summary?.layers).toHaveLength(4);
|
|
45
|
+
expect(summary?.layers[0]?.concernLabel).toBe("Elevated");
|
|
46
|
+
});
|
|
47
|
+
});
|