@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,109 @@
1
+ import { CARD_EXPOSURE_CATEGORY_LABELS } from "./formatCardExposureDisplay.js";
2
+ import { MERGE_POSTURE_LABEL } from "./riskVocabulary.js";
3
+ /**
4
+ * Canonical marketing / category copy. Operational scan strings stay in scanSurfaceCopy.
5
+ */
6
+ export const productMessaging = {
7
+ formalDefinition: "MergeSignal analyzes dependency upgrades, identifies affected application flows, and provides reviewer guidance before merge.",
8
+ hero: {
9
+ kicker: "Merge-decision intelligence for dependency upgrades",
10
+ h1: "See what a dependency upgrade can break.",
11
+ lead: "Dependency upgrade analysis that shows where runtime behavior may affect critical application flows.",
12
+ },
13
+ seo: {
14
+ title: "MergeSignal - know what an upgrade may break before merge",
15
+ description: "Know what a dependency upgrade may affect in your app before merge. Affected flows, reviewer checks, and merge guidance for this PR.",
16
+ },
17
+ finalCta: {
18
+ headline: "Try it on your next upgrade PR",
19
+ lead: "Run a local scan or add GitHub Actions to your workflow.",
20
+ button: "Get started for free",
21
+ },
22
+ homepageSections: {
23
+ pain: {
24
+ title: "Why dependency upgrades are hard to review",
25
+ subhead: "Upgrading is easy. Knowing what it affects is not.",
26
+ rows: [
27
+ {
28
+ title: "Changelogs don't show real impact",
29
+ body: "Release notes describe package changes, not how they affect your code paths and critical application flows.",
30
+ },
31
+ {
32
+ title: "Too much noise, not enough clarity",
33
+ body: "Security alerts rarely show what actually matters for this specific upgrade PR.",
34
+ },
35
+ {
36
+ title: "Runtime impact is hard to reason about",
37
+ body: "Understanding how a dependency change affects critical application flows requires deeper application context.",
38
+ },
39
+ ],
40
+ },
41
+ focus: {
42
+ title: "What MergeSignal focuses on",
43
+ subhead: "Clearer upgrade decisions with fewer merge surprises",
44
+ rows: [
45
+ {
46
+ title: "What this upgrade affects",
47
+ body: "Potential failure modes based on how your code uses the updated dependencies.",
48
+ },
49
+ {
50
+ title: "Where it shows up",
51
+ body: "The application flows, routes, and entrypoints affected by this upgrade.",
52
+ },
53
+ {
54
+ title: "What to verify before merge",
55
+ body: "Concrete reviewer checks for this specific upgrade.",
56
+ },
57
+ ],
58
+ },
59
+ value: {
60
+ title: "What changes when you use MergeSignal",
61
+ subhead: "More focused reviews and targeted validation.",
62
+ rows: [
63
+ {
64
+ title: "Reviews move faster",
65
+ body: "Start with affected flows instead of reconstructing impact from changelogs.",
66
+ },
67
+ {
68
+ title: "Testing stays targeted",
69
+ body: "Focus validation on the flows this upgrade affects.",
70
+ },
71
+ {
72
+ title: "Fewer merge surprises",
73
+ body: "Catch upgrade impact while the PR is still small.",
74
+ },
75
+ {
76
+ title: "High-signal only",
77
+ body: "MergeSignal only interrupts you when an upgrade deserves attention.",
78
+ },
79
+ ],
80
+ },
81
+ },
82
+ /** Repository dashboard PR cards — reviewer-oriented semantics (not engine/scoring docs). */
83
+ dashboardPrCard: {
84
+ intro: "The repository dashboard is a runtime-aware review surface for open upgrade pull requests: triage by merge recommendation, then read operational context on each card.",
85
+ posture: `Posture indicates the merge recommendation (${MERGE_POSTURE_LABEL.safe}, ${MERGE_POSTURE_LABEL.needs_review}, ${MERGE_POSTURE_LABEL.risky}).`,
86
+ exposure: "Exposure indicates how broadly the upgrade touches runtime-relevant application areas.",
87
+ exposureCategoriesLead: "On each scanned card, exposure reads as one of:",
88
+ exposureCategories: CARD_EXPOSURE_CATEGORY_LABELS,
89
+ cardBody: "Below the verdict, scanned cards surface up to three compact operational observations (transitive volume, blast radius, duplicate versions, and related topology signals) when the engine finds mappable structure.",
90
+ },
91
+ };
92
+ /** Illustrative scan-card example for homepage proof band and getting-started. */
93
+ export const primaryPrExample = {
94
+ package: "express",
95
+ message: "This upgrade affects middleware ordering used in auth routes.",
96
+ where: [
97
+ "apps/api/src/middleware/auth.ts - auth guard depends on middleware order",
98
+ "apps/api/src/routes/account.ts - protected handlers assume validation ran first",
99
+ ],
100
+ };
101
+ /** All homepage section row bodies for word-count validation. */
102
+ export function homepageSectionRowBodies() {
103
+ const sections = productMessaging.homepageSections;
104
+ return [
105
+ ...sections.pain.rows,
106
+ ...sections.value.rows,
107
+ ...sections.focus.rows,
108
+ ].map((row) => row.body);
109
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=productMessaging.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"productMessaging.test.d.ts","sourceRoot":"","sources":["../src/productMessaging.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { homepageSectionRowBodies, primaryPrExample, productMessaging, } from "./productMessaging.js";
3
+ const LOCKED_H1 = "See what a dependency upgrade can break.";
4
+ const FORBIDDEN_MARKETING_TERMS = [
5
+ "runtime-aware",
6
+ "AI-powered",
7
+ "framework-aware",
8
+ "observability",
9
+ "exhaustive indexing",
10
+ "repo analytics",
11
+ "risk platform",
12
+ "risk intelligence platform",
13
+ "security analysis platform",
14
+ "scanner platform",
15
+ ];
16
+ describe("productMessaging", () => {
17
+ it("locks hero H1 exactly including trailing period", () => {
18
+ expect(productMessaging.hero.h1).toBe(LOCKED_H1);
19
+ expect(productMessaging.hero.h1.endsWith(".")).toBe(true);
20
+ });
21
+ it("keeps lead separate from H1", () => {
22
+ expect(productMessaging.hero.lead).not.toBe(productMessaging.hero.h1);
23
+ });
24
+ it("uses express middleware example for marketing card", () => {
25
+ expect(primaryPrExample.package).toBe("express");
26
+ expect(primaryPrExample.message).toContain("middleware ordering");
27
+ expect(primaryPrExample.where).toHaveLength(2);
28
+ });
29
+ it("uses engineering-native example domains", () => {
30
+ const text = [primaryPrExample.message, ...primaryPrExample.where].join(" ");
31
+ expect(text.toLowerCase()).toMatch(/auth|middleware|routes/);
32
+ });
33
+ it("keeps section row bodies under 20 words", () => {
34
+ for (const body of homepageSectionRowBodies()) {
35
+ const wordCount = body.trim().split(/\s+/).length;
36
+ expect(wordCount).toBeLessThanOrEqual(20);
37
+ }
38
+ });
39
+ it("avoids em dashes in marketing copy", () => {
40
+ const strings = collectMarketingStrings();
41
+ for (const s of strings) {
42
+ expect(s).not.toMatch(/[\u2013\u2014]/);
43
+ }
44
+ });
45
+ it("avoids forbidden marketing terms in hero and seo", () => {
46
+ const heroSeo = [
47
+ productMessaging.hero.kicker,
48
+ productMessaging.hero.h1,
49
+ productMessaging.hero.lead,
50
+ productMessaging.seo.title,
51
+ productMessaging.seo.description,
52
+ productMessaging.formalDefinition,
53
+ ].join(" ");
54
+ for (const term of FORBIDDEN_MARKETING_TERMS) {
55
+ expect(heroSeo.toLowerCase()).not.toContain(term.toLowerCase());
56
+ }
57
+ });
58
+ it("does not expose comparison hooks in hero exports", () => {
59
+ expect(productMessaging.hero.kicker).not.toContain("Dependabot");
60
+ expect(productMessaging.hero.h1).not.toContain("Dependabot");
61
+ expect(productMessaging.hero.lead).not.toContain("Dependabot");
62
+ });
63
+ it("describes dashboard posture and exposure without scoring jargon", () => {
64
+ const d = productMessaging.dashboardPrCard;
65
+ const text = [
66
+ d.intro,
67
+ d.posture,
68
+ d.exposure,
69
+ d.exposureCategoriesLead,
70
+ ...d.exposureCategories,
71
+ d.cardBody,
72
+ ].join(" ");
73
+ expect(text).toContain("merge recommendation");
74
+ expect(text).toContain("runtime-relevant");
75
+ expect(text).not.toMatch(/risk index|total score|0-100|formula|weight/i);
76
+ expect(d.exposureCategories).toHaveLength(5);
77
+ expect(d.exposureCategories).toContain("Elevated exposure");
78
+ });
79
+ });
80
+ function collectMarketingStrings() {
81
+ const out = [
82
+ productMessaging.formalDefinition,
83
+ productMessaging.hero.kicker,
84
+ productMessaging.hero.h1,
85
+ productMessaging.hero.lead,
86
+ productMessaging.seo.title,
87
+ productMessaging.seo.description,
88
+ primaryPrExample.message,
89
+ ...primaryPrExample.where,
90
+ ];
91
+ for (const body of homepageSectionRowBodies()) {
92
+ out.push(body);
93
+ }
94
+ const d = productMessaging.dashboardPrCard;
95
+ out.push(d.intro, d.posture, d.exposure, d.exposureCategoriesLead, d.cardBody, ...d.exposureCategories);
96
+ return out;
97
+ }
@@ -2,6 +2,12 @@ import type { PRDecisionRecommendation } from "./types.js";
2
2
  export type MergePosture = PRDecisionRecommendation;
3
3
  /** Canonical display strings for merge posture values. */
4
4
  export declare const MERGE_POSTURE_LABEL: Record<MergePosture, string>;
5
+ /**
6
+ * Dashboard card badge labels. Defaults to full canonical names for triage clarity.
7
+ * Wire values remain `safe | needs_review | risky`.
8
+ */
9
+ export declare const CARD_POSTURE_DISPLAY_LABEL: Record<MergePosture, string>;
10
+ export declare function cardPostureDisplayLabel(posture: MergePosture): string;
5
11
  /**
6
12
  * Sorting weights: higher number = listed first (riskiest first).
7
13
  * Use as: arr.sort((a, b) => MERGE_POSTURE_SORT_ORDER[b] - MERGE_POSTURE_SORT_ORDER[a])
@@ -16,8 +22,8 @@ export declare function mergePostureLabel(decision: string | null | undefined, f
16
22
  * e.g. "Risky, risk score 72" or "Safe"
17
23
  */
18
24
  export declare function ariaLabelForPosture(decision: string | null | undefined, score: number | null | undefined): string;
19
- /** Accessible label for unified PR card risk block. */
20
- export declare function ariaLabelForCardSummary(headline: string, riskIndex: number | null | undefined, summaryLine: string | null | undefined): string;
25
+ /** Accessible label for unified PR card findings block. */
26
+ export declare function ariaLabelForCardSummary(postureLabel: string, riskIndex: number | null | undefined, observations: string[] | null | undefined, supportingLine?: string | null): string;
21
27
  export type SignalSeverity = "low" | "medium" | "high";
22
28
  /**
23
29
  * Format a signal severity for prose use — always returns a qualified string
@@ -1 +1 @@
1
- {"version":3,"file":"riskVocabulary.d.ts","sourceRoot":"","sources":["../src/riskVocabulary.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AAkB3D,MAAM,MAAM,YAAY,GAAG,wBAAwB,CAAC;AAEpD,0DAA0D;AAC1D,eAAO,MAAM,mBAAmB,EAAE,MAAM,CAAC,YAAY,EAAE,MAAM,CAI5D,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,wBAAwB,EAAE,MAAM,CAAC,YAAY,EAAE,MAAM,CAIjE,CAAC;AAEF,0EAA0E;AAC1E,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAClC,YAAY,GAAG,IAAI,CASrB;AAED,sEAAsE;AACtE,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACnC,QAAQ,SAAM,GACb,MAAM,CAGR;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACnC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAC/B,MAAM,CAIR;AAED,uDAAuD;AACvD,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACpC,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GACrC,MAAM,CAKR;AAMD,MAAM,MAAM,cAAc,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;AAQvD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAI7D;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAG3E"}
1
+ {"version":3,"file":"riskVocabulary.d.ts","sourceRoot":"","sources":["../src/riskVocabulary.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AAkB3D,MAAM,MAAM,YAAY,GAAG,wBAAwB,CAAC;AAEpD,0DAA0D;AAC1D,eAAO,MAAM,mBAAmB,EAAE,MAAM,CAAC,YAAY,EAAE,MAAM,CAI5D,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,0BAA0B,EAAE,MAAM,CAAC,YAAY,EAAE,MAAM,CAEnE,CAAC;AAEF,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,CAErE;AAED;;;GAGG;AACH,eAAO,MAAM,wBAAwB,EAAE,MAAM,CAAC,YAAY,EAAE,MAAM,CAIjE,CAAC;AAEF,0EAA0E;AAC1E,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAClC,YAAY,GAAG,IAAI,CASrB;AAED,sEAAsE;AACtE,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACnC,QAAQ,SAAM,GACb,MAAM,CAGR;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACnC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAC/B,MAAM,CAKR;AAED,2DAA2D;AAC3D,wBAAgB,uBAAuB,CACrC,YAAY,EAAE,MAAM,EACpB,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACpC,YAAY,EAAE,MAAM,EAAE,GAAG,IAAI,GAAG,SAAS,EACzC,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,GAC7B,MAAM,CASR;AAMD,MAAM,MAAM,cAAc,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;AAQvD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAI7D;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAG3E"}
@@ -1,9 +1,20 @@
1
+ import { exposureAriaFragment } from "./formatCardExposureDisplay.js";
1
2
  /** Canonical display strings for merge posture values. */
2
3
  export const MERGE_POSTURE_LABEL = {
3
4
  safe: "Safe",
4
5
  needs_review: "Needs review",
5
6
  risky: "Risky",
6
7
  };
8
+ /**
9
+ * Dashboard card badge labels. Defaults to full canonical names for triage clarity.
10
+ * Wire values remain `safe | needs_review | risky`.
11
+ */
12
+ export const CARD_POSTURE_DISPLAY_LABEL = {
13
+ ...MERGE_POSTURE_LABEL,
14
+ };
15
+ export function cardPostureDisplayLabel(posture) {
16
+ return CARD_POSTURE_DISPLAY_LABEL[posture];
17
+ }
7
18
  /**
8
19
  * Sorting weights: higher number = listed first (riskiest first).
9
20
  * Use as: arr.sort((a, b) => MERGE_POSTURE_SORT_ORDER[b] - MERGE_POSTURE_SORT_ORDER[a])
@@ -33,17 +44,23 @@ export function mergePostureLabel(decision, fallback = "—") {
33
44
  */
34
45
  export function ariaLabelForPosture(decision, score) {
35
46
  const label = mergePostureLabel(decision);
36
- if (score != null)
37
- return `${label}, risk score ${Math.round(score)}`;
47
+ const exposure = exposureAriaFragment(score);
48
+ if (exposure)
49
+ return `${label}, ${exposure}`;
38
50
  return label;
39
51
  }
40
- /** Accessible label for unified PR card risk block. */
41
- export function ariaLabelForCardSummary(headline, riskIndex, summaryLine) {
42
- const parts = [headline];
43
- if (riskIndex != null)
44
- parts.push(`risk index ${Math.round(riskIndex)}`);
45
- if (summaryLine?.trim())
46
- parts.push(summaryLine.trim());
52
+ /** Accessible label for unified PR card findings block. */
53
+ export function ariaLabelForCardSummary(postureLabel, riskIndex, observations, supportingLine) {
54
+ const parts = [postureLabel];
55
+ const exposure = exposureAriaFragment(riskIndex);
56
+ if (exposure)
57
+ parts.push(exposure);
58
+ for (const obs of observations ?? []) {
59
+ if (obs?.trim())
60
+ parts.push(obs.trim());
61
+ }
62
+ if (supportingLine?.trim())
63
+ parts.push(supportingLine.trim());
47
64
  return parts.join(". ");
48
65
  }
49
66
  const SIGNAL_SEVERITY_LABEL = {
@@ -1,3 +1,4 @@
1
+ import type { CatalogPhrase } from "./cardObservationCatalog.js";
1
2
  import { type MergePosture } from "./riskVocabulary.js";
2
3
  import type { Finding, ScanResult } from "./types.js";
3
4
  export type ScanPipelineStatus = "queued" | "running" | "done" | "failed";
@@ -14,9 +15,15 @@ export type ScanCardSummary = {
14
15
  riskIndex: number | null;
15
16
  riskIndexBand: RiskIndexBand | null;
16
17
  headline: string;
18
+ /** @deprecated Use operationalObservations */
17
19
  summaryLine: string | null;
18
20
  findingCounts: FindingCountSummary | null;
21
+ /** @deprecated Use operationalObservations */
19
22
  topAffectedAreas: string[];
23
+ /** Catalog-mapped topology observations (0–3). */
24
+ operationalObservations: CatalogPhrase[];
25
+ /** Optional quiet subline when exactly one observation is shown. */
26
+ supportingLine: string | null;
20
27
  };
21
28
  export declare const SCAN_CARD_SCANNING_SUMMARY = "Waiting for results\u2026";
22
29
  export type PipelineCompletionEvidence = {
@@ -42,6 +49,8 @@ export declare function aggregateFindingCounts(findings: Finding[] | null | unde
42
49
  /**
43
50
  * Server-side card summary for PR dashboard list views.
44
51
  * Pipeline lifecycle copy is included when `pipelineStatus !== "done"`.
52
+ *
53
+ * Quiet safe cards with no mappable signals show posture + exposure only.
45
54
  */
46
55
  export declare function deriveScanCardSummary(result: ScanResult | null | undefined, pipelineStatus: ScanPipelineStatus): ScanCardSummary;
47
56
  /** Subline appended when scan results are stale (head SHA mismatch). */
@@ -1 +1 @@
1
- {"version":3,"file":"scanCardSummary.d.ts","sourceRoot":"","sources":["../src/scanCardSummary.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"scanCardSummary.d.ts","sourceRoot":"","sources":["../src/scanCardSummary.ts"],"names":[],"mappings":"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"}
@@ -1,8 +1,7 @@
1
- import { deriveScanSummaryText } from "./deriveScanSummaryText.js";
1
+ import { deriveCardOperationalObservations } from "./deriveCardOperationalObservations.js";
2
2
  import { mergePostureFromDecision, MERGE_POSTURE_LABEL, } from "./riskVocabulary.js";
3
3
  import { scanSurfaceCopy } from "./scanSurfaceCopy.js";
4
4
  import { selectTopAffectedAreas } from "./selectTopAffectedAreas.js";
5
- const SAFE_SUMMARY = "No merge blockers detected";
6
5
  const STALE_SUBLINE = "Based on earlier commit";
7
6
  export const SCAN_CARD_SCANNING_SUMMARY = "Waiting for results…";
8
7
  function normalizePipelineStatus(status) {
@@ -94,18 +93,11 @@ export function aggregateFindingCounts(findings) {
94
93
  }
95
94
  return counts;
96
95
  }
97
- function hasActionableFindings(counts) {
98
- return counts.critical + counts.high + counts.medium + counts.low > 0;
99
- }
100
- function deriveSafeSummaryLine(posture, counts, fallback) {
101
- if (posture === "safe" && !hasActionableFindings(counts)) {
102
- return SAFE_SUMMARY;
103
- }
104
- return fallback;
105
- }
106
96
  /**
107
97
  * Server-side card summary for PR dashboard list views.
108
98
  * Pipeline lifecycle copy is included when `pipelineStatus !== "done"`.
99
+ *
100
+ * Quiet safe cards with no mappable signals show posture + exposure only.
109
101
  */
110
102
  export function deriveScanCardSummary(result, pipelineStatus) {
111
103
  if (pipelineStatus === "queued" || pipelineStatus === "running") {
@@ -117,6 +109,8 @@ export function deriveScanCardSummary(result, pipelineStatus) {
117
109
  summaryLine: SCAN_CARD_SCANNING_SUMMARY,
118
110
  findingCounts: null,
119
111
  topAffectedAreas: [],
112
+ operationalObservations: [],
113
+ supportingLine: null,
120
114
  };
121
115
  }
122
116
  if (pipelineStatus === "failed") {
@@ -128,6 +122,8 @@ export function deriveScanCardSummary(result, pipelineStatus) {
128
122
  summaryLine: null,
129
123
  findingCounts: null,
130
124
  topAffectedAreas: [],
125
+ operationalObservations: [],
126
+ supportingLine: null,
131
127
  };
132
128
  }
133
129
  if (!result) {
@@ -139,27 +135,33 @@ export function deriveScanCardSummary(result, pipelineStatus) {
139
135
  summaryLine: null,
140
136
  findingCounts: null,
141
137
  topAffectedAreas: [],
138
+ operationalObservations: [],
139
+ supportingLine: null,
142
140
  };
143
141
  }
144
142
  const posture = mergePostureFromDecision(result.decision?.recommendation) ?? null;
145
143
  const riskIndex = typeof result.totalScore === "number" ? result.totalScore : null;
146
144
  const riskIndexBand = deriveRiskIndexBand(riskIndex);
147
145
  const findingCounts = aggregateFindingCounts(result.findings);
148
- const summaryFromText = deriveScanSummaryText(result);
149
146
  const headline = posture
150
147
  ? MERGE_POSTURE_LABEL[posture]
151
148
  : scanSurfaceCopy.checkRun.mergePostureUnavailable;
152
- const summaryLine = posture
153
- ? deriveSafeSummaryLine(posture, findingCounts, summaryFromText)
154
- : summaryFromText;
149
+ const topAffectedAreas = selectTopAffectedAreas(result, { max: 2 });
150
+ const { operationalObservations, supportingLine } = deriveCardOperationalObservations(result, {
151
+ mergePosture: posture,
152
+ hasFullResult: true,
153
+ max: 3,
154
+ });
155
155
  return {
156
156
  mergePosture: posture,
157
157
  riskIndex,
158
158
  riskIndexBand,
159
159
  headline,
160
- summaryLine,
160
+ summaryLine: null,
161
161
  findingCounts,
162
- topAffectedAreas: selectTopAffectedAreas(result, { max: 2 }),
162
+ topAffectedAreas,
163
+ operationalObservations,
164
+ supportingLine,
163
165
  };
164
166
  }
165
167
  /** Subline appended when scan results are stale (head SHA mismatch). */
@@ -187,18 +189,6 @@ export function deriveScanCardSummaryFromDenormalized(decision, totalScore, summ
187
189
  : undefined,
188
190
  };
189
191
  const summary = deriveScanCardSummary(minimalResult, "done");
190
- const sanitized = isPipelinePlaceholderCopy(summaryText)
191
- ? null
192
- : (summaryText?.trim() ?? null);
193
- if (sanitized && !summary.summaryLine) {
194
- return { ...summary, summaryLine: sanitized };
195
- }
196
- if (sanitized &&
197
- posture &&
198
- summary.summaryLine === SAFE_SUMMARY &&
199
- sanitized !== SAFE_SUMMARY) {
200
- return { ...summary, summaryLine: sanitized };
201
- }
202
192
  return summary;
203
193
  }
204
194
  /**
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
+ import { isCatalogPhrase } from "./cardObservationCatalog.js";
2
3
  import { aggregateFindingCounts, deriveRiskIndexBand, deriveScanCardSummary, deriveScanCardSummaryFromDenormalized, isPipelineCardSummary, isPipelinePlaceholderCopy, resolvePipelineStatus, resolvePrScanCardSummary, staleScanSubline, } from "./scanCardSummary.js";
3
4
  const baseResult = {
4
5
  totalScore: 10,
@@ -71,15 +72,26 @@ describe("deriveScanCardSummary", () => {
71
72
  confidence: "high",
72
73
  reasoning: ["Auth boundary change"],
73
74
  },
75
+ explain: {
76
+ reasons: [
77
+ {
78
+ id: "upgrade.1",
79
+ layer: "upgradeImpact",
80
+ title: "Large upgrade blast radius",
81
+ scoreImpact: 20,
82
+ },
83
+ ],
84
+ },
74
85
  };
75
86
  const summary = deriveScanCardSummary(r, "done");
76
87
  expect(summary.mergePosture).toBe("risky");
77
88
  expect(summary.headline).toBe("Risky");
78
89
  expect(summary.riskIndex).toBe(72);
79
90
  expect(summary.riskIndexBand).toBe("high");
80
- expect(summary.summaryLine).toBe("Auth boundary change");
91
+ expect(summary.summaryLine).toBeNull();
92
+ expect(summary.operationalObservations).toContain("Large upgrade blast radius");
81
93
  });
82
- it("uses safe copy when posture is safe and no findings", () => {
94
+ it("keeps quiet safe cards posture-only without observations", () => {
83
95
  const r = {
84
96
  ...baseResult,
85
97
  totalScore: 12,
@@ -90,9 +102,10 @@ describe("deriveScanCardSummary", () => {
90
102
  },
91
103
  };
92
104
  const summary = deriveScanCardSummary(r, "done");
93
- expect(summary.summaryLine).toBe("No merge blockers detected");
105
+ expect(summary.summaryLine).toBeNull();
106
+ expect(summary.operationalObservations).toEqual([]);
94
107
  });
95
- it("caps top affected areas at 2", () => {
108
+ it("surfaces catalog observations from explain signals", () => {
96
109
  const r = {
97
110
  ...baseResult,
98
111
  decision: {
@@ -102,14 +115,28 @@ describe("deriveScanCardSummary", () => {
102
115
  },
103
116
  explain: {
104
117
  reasons: [
105
- { id: "1", layer: "security", title: "A", scoreImpact: -10 },
106
- { id: "2", layer: "security", title: "B", scoreImpact: -9 },
118
+ {
119
+ id: "1",
120
+ layer: "ecosystem",
121
+ title: "graph.transitive volume",
122
+ scoreImpact: 18,
123
+ },
124
+ {
125
+ id: "2",
126
+ layer: "ecosystem",
127
+ title: "graph.duplicates",
128
+ scoreImpact: 12,
129
+ },
107
130
  { id: "3", layer: "security", title: "C", scoreImpact: -8 },
108
131
  ],
109
132
  },
110
133
  };
111
134
  const summary = deriveScanCardSummary(r, "done");
112
135
  expect(summary.topAffectedAreas).toHaveLength(2);
136
+ expect(summary.operationalObservations.length).toBeGreaterThan(0);
137
+ for (const phrase of summary.operationalObservations) {
138
+ expect(isCatalogPhrase(phrase)).toBe(true);
139
+ }
113
140
  });
114
141
  it("exports stale subline constant", () => {
115
142
  expect(staleScanSubline()).toBe("Based on earlier commit");
@@ -149,6 +176,8 @@ describe("isPipelineCardSummary", () => {
149
176
  summaryLine: "Waiting for results…",
150
177
  findingCounts: null,
151
178
  topAffectedAreas: [],
179
+ operationalObservations: [],
180
+ supportingLine: null,
152
181
  })).toBe(true);
153
182
  });
154
183
  it("detects hybrid completed posture with pipeline summary line", () => {
@@ -160,6 +189,8 @@ describe("isPipelineCardSummary", () => {
160
189
  summaryLine: "Waiting for results…",
161
190
  findingCounts: null,
162
191
  topAffectedAreas: [],
192
+ operationalObservations: [],
193
+ supportingLine: null,
163
194
  })).toBe(true);
164
195
  });
165
196
  });
@@ -170,18 +201,24 @@ describe("isPipelinePlaceholderCopy", () => {
170
201
  });
171
202
  });
172
203
  describe("deriveScanCardSummaryFromDenormalized", () => {
173
- it("builds risky summary from denormalized columns", () => {
204
+ it("builds risky summary from denormalized columns without generic narration", () => {
174
205
  const summary = deriveScanCardSummaryFromDenormalized("risky", 72, "Auth boundary change", "done");
175
206
  expect(summary.mergePosture).toBe("risky");
176
207
  expect(summary.headline).toBe("Risky");
177
208
  expect(summary.riskIndex).toBe(72);
178
- expect(summary.summaryLine).toBe("Auth boundary change");
209
+ expect(summary.summaryLine).toBeNull();
210
+ expect(summary.operationalObservations).toEqual([]);
179
211
  });
180
212
  it("does not inject pipeline placeholder summaryText", () => {
181
213
  const summary = deriveScanCardSummaryFromDenormalized("risky", 72, "Waiting for results…", "done");
182
214
  expect(summary.mergePosture).toBe("risky");
183
215
  expect(summary.summaryLine).not.toBe("Waiting for results…");
184
216
  });
217
+ it("does not inject raw generic summaryText on quiet safe cards", () => {
218
+ const summary = deriveScanCardSummaryFromDenormalized("safe", 12, "No high-confidence merge risks from this PR dependency change", "done");
219
+ expect(summary.mergePosture).toBe("safe");
220
+ expect(summary.summaryLine).toBeNull();
221
+ });
185
222
  });
186
223
  describe("resolvePrScanCardSummary", () => {
187
224
  it("returns risk summary when wire status is running but decision exists", () => {