@mergesignal/shared 0.2.2 → 0.2.3
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/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/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 +20 -0
- package/dist/formatCardExposureDisplay.d.ts.map +1 -0
- package/dist/formatCardExposureDisplay.js +56 -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 +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -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/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,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filter and rank area labels for dashboard cards (max 2).
|
|
3
|
+
* Longer specific labels win; generic taxonomy is dropped.
|
|
4
|
+
*/
|
|
5
|
+
export declare function formatCardAreaLabels(areas: string[] | null | undefined, max?: number): string[];
|
|
6
|
+
/** Join formatted areas for evidence row display. */
|
|
7
|
+
export declare function joinCardAreaLabels(areas: string[]): string | null;
|
|
8
|
+
//# sourceMappingURL=formatCardAreaLabels.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"formatCardAreaLabels.d.ts","sourceRoot":"","sources":["../src/formatCardAreaLabels.ts"],"names":[],"mappings":"AAmCA;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,GAAG,SAAS,EAClC,GAAG,SAAI,GACN,MAAM,EAAE,CAmBV;AAED,qDAAqD;AACrD,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,GAAG,IAAI,CAIjE"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { GENERIC_CARD_AREA_PHRASES } from "./cardSummaryCopy.js";
|
|
2
|
+
const PATH_LIKE = /[/\\]|^packages\/|\.tsx?$|\.jsx?$|^src\//i;
|
|
3
|
+
const KEBAB_SNAKE = /^[a-z0-9]+[-_][a-z0-9_-]+$/;
|
|
4
|
+
function normalizeKey(label) {
|
|
5
|
+
return label.trim().toLowerCase();
|
|
6
|
+
}
|
|
7
|
+
function isTechnicalLabel(label) {
|
|
8
|
+
const t = label.trim();
|
|
9
|
+
if (!t)
|
|
10
|
+
return true;
|
|
11
|
+
if (PATH_LIKE.test(t))
|
|
12
|
+
return true;
|
|
13
|
+
if (KEBAB_SNAKE.test(t) && !/\s/.test(t))
|
|
14
|
+
return true;
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
function isGenericArea(label) {
|
|
18
|
+
const key = normalizeKey(label);
|
|
19
|
+
return GENERIC_CARD_AREA_PHRASES.some((g) => key === g || key.startsWith(`${g} `) || key.endsWith(` ${g}`));
|
|
20
|
+
}
|
|
21
|
+
function humanizeLabel(label) {
|
|
22
|
+
const clean = label.replace(/^(Finding:\s*|Area:\s*)/i, "").trim();
|
|
23
|
+
if (!clean)
|
|
24
|
+
return "";
|
|
25
|
+
if (/\s/.test(clean) || /^[A-Z]/.test(clean))
|
|
26
|
+
return clean;
|
|
27
|
+
return clean
|
|
28
|
+
.split(/[-_]/)
|
|
29
|
+
.filter(Boolean)
|
|
30
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
|
31
|
+
.join(" ");
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Filter and rank area labels for dashboard cards (max 2).
|
|
35
|
+
* Longer specific labels win; generic taxonomy is dropped.
|
|
36
|
+
*/
|
|
37
|
+
export function formatCardAreaLabels(areas, max = 2) {
|
|
38
|
+
if (!Array.isArray(areas) || areas.length === 0)
|
|
39
|
+
return [];
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
const candidates = [];
|
|
42
|
+
for (const raw of areas) {
|
|
43
|
+
if (isTechnicalLabel(raw))
|
|
44
|
+
continue;
|
|
45
|
+
const label = humanizeLabel(raw);
|
|
46
|
+
if (!label || label.length < 4)
|
|
47
|
+
continue;
|
|
48
|
+
if (isGenericArea(label))
|
|
49
|
+
continue;
|
|
50
|
+
const key = normalizeKey(label);
|
|
51
|
+
if (seen.has(key))
|
|
52
|
+
continue;
|
|
53
|
+
seen.add(key);
|
|
54
|
+
candidates.push(label);
|
|
55
|
+
}
|
|
56
|
+
candidates.sort((a, b) => b.length - a.length);
|
|
57
|
+
return candidates.slice(0, max);
|
|
58
|
+
}
|
|
59
|
+
/** Join formatted areas for evidence row display. */
|
|
60
|
+
export function joinCardAreaLabels(areas) {
|
|
61
|
+
const formatted = formatCardAreaLabels(areas);
|
|
62
|
+
if (formatted.length === 0)
|
|
63
|
+
return null;
|
|
64
|
+
return formatted.join(" · ");
|
|
65
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { FindingCountSummary } from "./scanCardSummary.js";
|
|
2
|
+
import type { MergePosture } from "./riskVocabulary.js";
|
|
3
|
+
/**
|
|
4
|
+
* One soft evidence phrase for dashboard cards (not scanner-style metrics).
|
|
5
|
+
*/
|
|
6
|
+
export declare function formatCardEvidenceCounts(counts: FindingCountSummary | null | undefined, posture: MergePosture | null): string | null;
|
|
7
|
+
//# sourceMappingURL=formatCardEvidenceCounts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"formatCardEvidenceCounts.d.ts","sourceRoot":"","sources":["../src/formatCardEvidenceCounts.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAChE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAExD;;GAEG;AACH,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,mBAAmB,GAAG,IAAI,GAAG,SAAS,EAC9C,OAAO,EAAE,YAAY,GAAG,IAAI,GAC3B,MAAM,GAAG,IAAI,CAaf"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One soft evidence phrase for dashboard cards (not scanner-style metrics).
|
|
3
|
+
*/
|
|
4
|
+
export function formatCardEvidenceCounts(counts, posture) {
|
|
5
|
+
if (!counts || !posture || posture === "safe")
|
|
6
|
+
return null;
|
|
7
|
+
const { critical, high } = counts;
|
|
8
|
+
if (critical > 0 && posture === "risky") {
|
|
9
|
+
if (critical === 1)
|
|
10
|
+
return "1 critical finding";
|
|
11
|
+
return "Critical findings present";
|
|
12
|
+
}
|
|
13
|
+
if (high > 0 && (posture === "risky" || posture === "needs_review")) {
|
|
14
|
+
if (high === 1)
|
|
15
|
+
return "1 high-severity finding";
|
|
16
|
+
return `${high} high-severity findings`;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard card exposure semantics — display-only interpretation of totalScore.
|
|
3
|
+
* Not persisted; not an engine output; complements merge posture (Safe / Review / Risky).
|
|
4
|
+
*/
|
|
5
|
+
export type CardExposureCategory = "minimal" | "limited" | "moderate" | "elevated" | "broad";
|
|
6
|
+
export type CardExposureDisplay = {
|
|
7
|
+
category: CardExposureCategory;
|
|
8
|
+
/** User-facing label, e.g. "Moderate exposure". */
|
|
9
|
+
label: string;
|
|
10
|
+
value: number;
|
|
11
|
+
};
|
|
12
|
+
/** User-facing exposure labels for docs and UI copy (low → high). */
|
|
13
|
+
export declare const CARD_EXPOSURE_CATEGORY_LABELS: readonly string[];
|
|
14
|
+
/** Map numeric totalScore to a fixed exposure category (card UX only). */
|
|
15
|
+
export declare function deriveCardExposureDisplay(score: number | null | undefined): CardExposureDisplay | null;
|
|
16
|
+
/** Compact card line: exposure category label only. */
|
|
17
|
+
export declare function formatCardExposureLine(score: number | null | undefined): string | null;
|
|
18
|
+
/** Fragment for composite aria labels on PR cards. */
|
|
19
|
+
export declare function exposureAriaFragment(score: number | null | undefined): string | null;
|
|
20
|
+
//# sourceMappingURL=formatCardExposureDisplay.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"formatCardExposureDisplay.d.ts","sourceRoot":"","sources":["../src/formatCardExposureDisplay.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,MAAM,oBAAoB,GAC5B,SAAS,GACT,SAAS,GACT,UAAU,GACV,UAAU,GACV,OAAO,CAAC;AAEZ,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,mDAAmD;IACnD,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAUF,qEAAqE;AACrE,eAAO,MAAM,6BAA6B,EAAE,SAAS,MAAM,EAM1D,CAAC;AAkBF,0EAA0E;AAC1E,wBAAgB,yBAAyB,CACvC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAC/B,mBAAmB,GAAG,IAAI,CAM5B;AAED,uDAAuD;AACvD,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAC/B,MAAM,GAAG,IAAI,CAIf;AAED,sDAAsD;AACtD,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAC/B,MAAM,GAAG,IAAI,CAIf"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard card exposure semantics — display-only interpretation of totalScore.
|
|
3
|
+
* Not persisted; not an engine output; complements merge posture (Safe / Review / Risky).
|
|
4
|
+
*/
|
|
5
|
+
const EXPOSURE_LABEL = {
|
|
6
|
+
minimal: "Minimal exposure",
|
|
7
|
+
limited: "Limited exposure",
|
|
8
|
+
moderate: "Moderate exposure",
|
|
9
|
+
elevated: "Elevated exposure",
|
|
10
|
+
broad: "Broad exposure",
|
|
11
|
+
};
|
|
12
|
+
/** User-facing exposure labels for docs and UI copy (low → high). */
|
|
13
|
+
export const CARD_EXPOSURE_CATEGORY_LABELS = [
|
|
14
|
+
EXPOSURE_LABEL.minimal,
|
|
15
|
+
EXPOSURE_LABEL.limited,
|
|
16
|
+
EXPOSURE_LABEL.moderate,
|
|
17
|
+
EXPOSURE_LABEL.elevated,
|
|
18
|
+
EXPOSURE_LABEL.broad,
|
|
19
|
+
];
|
|
20
|
+
/** Inclusive upper bounds (0–100). Five stable buckets only. */
|
|
21
|
+
const EXPOSURE_UPPER_BOUND = [24, 44, 74, 89, 100];
|
|
22
|
+
const EXPOSURE_ORDER = [
|
|
23
|
+
"minimal",
|
|
24
|
+
"limited",
|
|
25
|
+
"moderate",
|
|
26
|
+
"elevated",
|
|
27
|
+
"broad",
|
|
28
|
+
];
|
|
29
|
+
function clampScore(score) {
|
|
30
|
+
if (!Number.isFinite(score))
|
|
31
|
+
return 0;
|
|
32
|
+
return Math.min(100, Math.max(0, Math.round(score)));
|
|
33
|
+
}
|
|
34
|
+
/** Map numeric totalScore to a fixed exposure category (card UX only). */
|
|
35
|
+
export function deriveCardExposureDisplay(score) {
|
|
36
|
+
if (score == null || !Number.isFinite(score))
|
|
37
|
+
return null;
|
|
38
|
+
const value = clampScore(score);
|
|
39
|
+
const idx = EXPOSURE_UPPER_BOUND.findIndex((bound) => value <= bound);
|
|
40
|
+
const category = EXPOSURE_ORDER[idx >= 0 ? idx : EXPOSURE_ORDER.length - 1];
|
|
41
|
+
return { category, label: EXPOSURE_LABEL[category], value };
|
|
42
|
+
}
|
|
43
|
+
/** Compact card line: exposure category label only. */
|
|
44
|
+
export function formatCardExposureLine(score) {
|
|
45
|
+
const display = deriveCardExposureDisplay(score);
|
|
46
|
+
if (!display)
|
|
47
|
+
return null;
|
|
48
|
+
return display.label;
|
|
49
|
+
}
|
|
50
|
+
/** Fragment for composite aria labels on PR cards. */
|
|
51
|
+
export function exposureAriaFragment(score) {
|
|
52
|
+
const display = deriveCardExposureDisplay(score);
|
|
53
|
+
if (!display)
|
|
54
|
+
return null;
|
|
55
|
+
return display.label;
|
|
56
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"formatCardExposureDisplay.test.d.ts","sourceRoot":"","sources":["../src/formatCardExposureDisplay.test.ts"],"names":[],"mappings":""}
|