@mergesignal/shared 0.2.0

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 (61) hide show
  1. package/dist/actionsStepSummary.d.ts +29 -0
  2. package/dist/actionsStepSummary.d.ts.map +1 -0
  3. package/dist/actionsStepSummary.js +757 -0
  4. package/dist/actionsStepSummary.test.d.ts +2 -0
  5. package/dist/actionsStepSummary.test.d.ts.map +1 -0
  6. package/dist/actionsStepSummary.test.js +348 -0
  7. package/dist/deriveScanSummaryText.d.ts +7 -0
  8. package/dist/deriveScanSummaryText.d.ts.map +1 -0
  9. package/dist/deriveScanSummaryText.js +24 -0
  10. package/dist/deriveScanSummaryText.test.d.ts +2 -0
  11. package/dist/deriveScanSummaryText.test.d.ts.map +1 -0
  12. package/dist/deriveScanSummaryText.test.js +77 -0
  13. package/dist/formatInsight.d.ts +10 -0
  14. package/dist/formatInsight.d.ts.map +1 -0
  15. package/dist/formatInsight.js +49 -0
  16. package/dist/formatInsight.test.d.ts +2 -0
  17. package/dist/formatInsight.test.d.ts.map +1 -0
  18. package/dist/formatInsight.test.js +102 -0
  19. package/dist/index.d.ts +11 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +10 -0
  22. package/dist/prCheckRunPresentation.d.ts +67 -0
  23. package/dist/prCheckRunPresentation.d.ts.map +1 -0
  24. package/dist/prCheckRunPresentation.js +314 -0
  25. package/dist/prCheckRunPresentation.test.d.ts +2 -0
  26. package/dist/prCheckRunPresentation.test.d.ts.map +1 -0
  27. package/dist/prCheckRunPresentation.test.js +277 -0
  28. package/dist/riskVocabulary.d.ts +31 -0
  29. package/dist/riskVocabulary.d.ts.map +1 -0
  30. package/dist/riskVocabulary.js +62 -0
  31. package/dist/riskVocabulary.test.d.ts +2 -0
  32. package/dist/riskVocabulary.test.d.ts.map +1 -0
  33. package/dist/riskVocabulary.test.js +14 -0
  34. package/dist/scanResultSchema.d.ts +116 -0
  35. package/dist/scanResultSchema.d.ts.map +1 -0
  36. package/dist/scanResultSchema.js +100 -0
  37. package/dist/scanResultSchema.test.d.ts +2 -0
  38. package/dist/scanResultSchema.test.d.ts.map +1 -0
  39. package/dist/scanResultSchema.test.js +105 -0
  40. package/dist/scanSurfaceCopy.d.ts +90 -0
  41. package/dist/scanSurfaceCopy.d.ts.map +1 -0
  42. package/dist/scanSurfaceCopy.js +104 -0
  43. package/dist/scanSurfaceCopy.test.d.ts +2 -0
  44. package/dist/scanSurfaceCopy.test.d.ts.map +1 -0
  45. package/dist/scanSurfaceCopy.test.js +14 -0
  46. package/dist/selectTopAffectedAreas.d.ts +17 -0
  47. package/dist/selectTopAffectedAreas.d.ts.map +1 -0
  48. package/dist/selectTopAffectedAreas.js +80 -0
  49. package/dist/selectTopAffectedAreas.test.d.ts +2 -0
  50. package/dist/selectTopAffectedAreas.test.d.ts.map +1 -0
  51. package/dist/selectTopAffectedAreas.test.js +179 -0
  52. package/dist/trustedScanGuards.d.ts +39 -0
  53. package/dist/trustedScanGuards.d.ts.map +1 -0
  54. package/dist/trustedScanGuards.js +88 -0
  55. package/dist/trustedScanGuards.test.d.ts +2 -0
  56. package/dist/trustedScanGuards.test.d.ts.map +1 -0
  57. package/dist/trustedScanGuards.test.js +143 -0
  58. package/dist/types.d.ts +362 -0
  59. package/dist/types.d.ts.map +1 -0
  60. package/dist/types.js +12 -0
  61. package/package.json +49 -0
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=scanSurfaceCopy.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scanSurfaceCopy.test.d.ts","sourceRoot":"","sources":["../src/scanSurfaceCopy.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,14 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
5
+ import { scanSurfaceCopyFlat } from "./scanSurfaceCopy.js";
6
+ describe("scanSurfaceCopy", () => {
7
+ it("matches generated CI JSON (no drift)", () => {
8
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
9
+ const jsonPath = join(root, "scripts/ci/scan-surface-copy.generated.json");
10
+ const onDisk = JSON.parse(readFileSync(jsonPath, "utf8"));
11
+ const fromTs = scanSurfaceCopyFlat();
12
+ expect(fromTs).toEqual(onDisk);
13
+ });
14
+ });
@@ -0,0 +1,17 @@
1
+ import type { ScanResult } from "./types.js";
2
+ export type TopAreasOptions = {
3
+ max?: number;
4
+ maxLabelLen?: number;
5
+ };
6
+ /**
7
+ * Select up to `max` (default 3) compact human-readable area labels from a
8
+ * ScanResult, ordered by signal strength. Returns [] when nothing meaningful
9
+ * can be surfaced — callers should omit the UI block entirely in that case.
10
+ *
11
+ * Priority order (first-match-wins per slot, deduped):
12
+ * 1. explain.reasons sorted by |scoreImpact| (most impactful first)
13
+ * 2. insights[].message (dedupe by first sentence)
14
+ * 3. Layer names from layerScores (only when slots 1+2 yielded nothing)
15
+ */
16
+ export declare function selectTopAffectedAreas(result: ScanResult | null | undefined, opts?: TopAreasOptions): string[];
17
+ //# sourceMappingURL=selectTopAffectedAreas.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"selectTopAffectedAreas.d.ts","sourceRoot":"","sources":["../src/selectTopAffectedAreas.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,MAAM,MAAM,eAAe,GAAG;IAC5B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,UAAU,GAAG,IAAI,GAAG,SAAS,EACrC,IAAI,GAAE,eAAoB,GACzB,MAAM,EAAE,CAuEV"}
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Select up to `max` (default 3) compact human-readable area labels from a
3
+ * ScanResult, ordered by signal strength. Returns [] when nothing meaningful
4
+ * can be surfaced — callers should omit the UI block entirely in that case.
5
+ *
6
+ * Priority order (first-match-wins per slot, deduped):
7
+ * 1. explain.reasons sorted by |scoreImpact| (most impactful first)
8
+ * 2. insights[].message (dedupe by first sentence)
9
+ * 3. Layer names from layerScores (only when slots 1+2 yielded nothing)
10
+ */
11
+ export function selectTopAffectedAreas(result, opts = {}) {
12
+ if (!result)
13
+ return [];
14
+ const max = opts.max ?? 3;
15
+ const maxLabelLen = opts.maxLabelLen ?? 38;
16
+ function trimLabel(raw) {
17
+ const clean = raw.replace(/^(Finding:\s*|Area:\s*)/i, "").trim();
18
+ return clean.length > maxLabelLen
19
+ ? clean.slice(0, maxLabelLen - 1) + "…"
20
+ : clean;
21
+ }
22
+ const seen = new Set();
23
+ const areas = [];
24
+ function add(label) {
25
+ if (areas.length >= max)
26
+ return false;
27
+ const t = trimLabel(label);
28
+ const key = t.toLowerCase();
29
+ if (!t || seen.has(key))
30
+ return false;
31
+ seen.add(key);
32
+ areas.push(t);
33
+ return areas.length < max;
34
+ }
35
+ // Priority 1: explain.reasons sorted by absolute scoreImpact
36
+ if (Array.isArray(result.explain?.reasons)) {
37
+ const sorted = [...result.explain.reasons]
38
+ .filter((r) => r.title)
39
+ .sort((a, b) => Math.abs(b.scoreImpact ?? 0) - Math.abs(a.scoreImpact ?? 0));
40
+ for (const r of sorted) {
41
+ if (!add(r.title))
42
+ break;
43
+ }
44
+ }
45
+ if (areas.length >= max)
46
+ return areas;
47
+ // Priority 2: PR insights — first sentence of message
48
+ if (Array.isArray(result.insights)) {
49
+ for (const ins of result.insights) {
50
+ if (!ins.message)
51
+ continue;
52
+ const firstSentence = ins.message.split(".")[0] ?? ins.message;
53
+ if (!add(firstSentence))
54
+ break;
55
+ }
56
+ }
57
+ if (areas.length >= max)
58
+ return areas;
59
+ // Priority 3: layer names only when nothing else surfaced
60
+ if (areas.length === 0) {
61
+ const LAYER_LABELS = {
62
+ security: "Security",
63
+ maintainability: "Maintainability",
64
+ ecosystem: "Ecosystem",
65
+ upgradeImpact: "Upgrade impact",
66
+ };
67
+ const layerScores = result.layerScores;
68
+ if (!layerScores || typeof layerScores !== "object")
69
+ return areas;
70
+ const sorted = Object.entries(layerScores)
71
+ .filter(([, v]) => typeof v === "number")
72
+ // highest score = most impactful layer (inverted: higher score = worse)
73
+ .sort(([, a], [, b]) => b - a);
74
+ for (const [layer] of sorted) {
75
+ if (!add(LAYER_LABELS[layer] ?? layer))
76
+ break;
77
+ }
78
+ }
79
+ return areas;
80
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=selectTopAffectedAreas.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"selectTopAffectedAreas.test.d.ts","sourceRoot":"","sources":["../src/selectTopAffectedAreas.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,179 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { selectTopAffectedAreas } from "./selectTopAffectedAreas.js";
3
+ function makeScanResult(overrides = {}) {
4
+ return {
5
+ totalScore: 60,
6
+ layerScores: {
7
+ security: 70,
8
+ maintainability: 50,
9
+ ecosystem: 40,
10
+ upgradeImpact: 30,
11
+ },
12
+ findings: [],
13
+ generatedAt: "2026-01-01T00:00:00Z",
14
+ ...overrides,
15
+ };
16
+ }
17
+ describe("selectTopAffectedAreas", () => {
18
+ it("returns [] for null input", () => {
19
+ expect(selectTopAffectedAreas(null)).toEqual([]);
20
+ });
21
+ it("returns [] for undefined input", () => {
22
+ expect(selectTopAffectedAreas(undefined)).toEqual([]);
23
+ });
24
+ it("returns at most 3 results by default", () => {
25
+ const result = makeScanResult({
26
+ explain: {
27
+ reasons: [
28
+ { id: "r1", layer: "security", title: "Auth flow", scoreImpact: -20 },
29
+ {
30
+ id: "r2",
31
+ layer: "security",
32
+ title: "State sync",
33
+ scoreImpact: -15,
34
+ },
35
+ {
36
+ id: "r3",
37
+ layer: "ecosystem",
38
+ title: "Middleware",
39
+ scoreImpact: -10,
40
+ },
41
+ {
42
+ id: "r4",
43
+ layer: "maintainability",
44
+ title: "Extra area",
45
+ scoreImpact: -5,
46
+ },
47
+ ],
48
+ },
49
+ });
50
+ const areas = selectTopAffectedAreas(result);
51
+ expect(areas.length).toBe(3);
52
+ expect(areas).toEqual(["Auth flow", "State sync", "Middleware"]);
53
+ });
54
+ it("respects custom max", () => {
55
+ const result = makeScanResult({
56
+ explain: {
57
+ reasons: [
58
+ { id: "r1", layer: "security", title: "Area A", scoreImpact: -20 },
59
+ { id: "r2", layer: "security", title: "Area B", scoreImpact: -15 },
60
+ ],
61
+ },
62
+ });
63
+ expect(selectTopAffectedAreas(result, { max: 1 })).toEqual(["Area A"]);
64
+ });
65
+ it("sorts explain reasons by absolute scoreImpact descending", () => {
66
+ const result = makeScanResult({
67
+ explain: {
68
+ reasons: [
69
+ { id: "r1", layer: "security", title: "Minor", scoreImpact: -2 },
70
+ { id: "r2", layer: "security", title: "Critical", scoreImpact: -30 },
71
+ { id: "r3", layer: "ecosystem", title: "Medium", scoreImpact: -15 },
72
+ ],
73
+ },
74
+ });
75
+ const areas = selectTopAffectedAreas(result);
76
+ expect(areas[0]).toBe("Critical");
77
+ expect(areas[1]).toBe("Medium");
78
+ });
79
+ it("falls back to insights when explain is empty", () => {
80
+ const result = makeScanResult({
81
+ insights: [
82
+ {
83
+ type: "behavioral_change",
84
+ priority: "high",
85
+ confidence: "confirmed",
86
+ scope: "changed",
87
+ message: "Auth middleware changed. Check tokens",
88
+ context: "src/auth.ts",
89
+ remediation: "Review carefully",
90
+ },
91
+ ],
92
+ });
93
+ const areas = selectTopAffectedAreas(result);
94
+ expect(areas).toEqual(["Auth middleware changed"]);
95
+ });
96
+ it("falls back to layer names when explain and insights are empty", () => {
97
+ const result = makeScanResult();
98
+ const areas = selectTopAffectedAreas(result);
99
+ expect(areas.length).toBeGreaterThan(0);
100
+ expect(areas.length).toBeLessThanOrEqual(3);
101
+ expect(areas).toContain("Security");
102
+ });
103
+ it("deduplicates normalized labels", () => {
104
+ const result = makeScanResult({
105
+ explain: {
106
+ reasons: [
107
+ { id: "r1", layer: "security", title: "Auth flow", scoreImpact: -20 },
108
+ { id: "r2", layer: "security", title: "auth flow", scoreImpact: -15 },
109
+ {
110
+ id: "r3",
111
+ layer: "ecosystem",
112
+ title: "Auth flow",
113
+ scoreImpact: -10,
114
+ },
115
+ ],
116
+ },
117
+ });
118
+ const areas = selectTopAffectedAreas(result);
119
+ const unique = new Set(areas.map((a) => a.toLowerCase()));
120
+ expect(unique.size).toBe(areas.length);
121
+ expect(areas).toContain("Auth flow");
122
+ expect(areas.filter((a) => a.toLowerCase() === "auth flow").length).toBe(1);
123
+ });
124
+ it("trims the 'Finding: ' prefix from labels", () => {
125
+ const result = makeScanResult({
126
+ explain: {
127
+ reasons: [
128
+ {
129
+ id: "r1",
130
+ layer: "security",
131
+ title: "Finding: Outdated lodash",
132
+ scoreImpact: -20,
133
+ },
134
+ ],
135
+ },
136
+ });
137
+ const areas = selectTopAffectedAreas(result);
138
+ expect(areas[0]).toBe("Outdated lodash");
139
+ });
140
+ it("truncates labels longer than maxLabelLen with ellipsis", () => {
141
+ const long = "A".repeat(50);
142
+ const result = makeScanResult({
143
+ explain: {
144
+ reasons: [
145
+ { id: "r1", layer: "security", title: long, scoreImpact: -10 },
146
+ ],
147
+ },
148
+ });
149
+ const areas = selectTopAffectedAreas(result, { maxLabelLen: 10 });
150
+ expect(areas[0].length).toBeLessThanOrEqual(10);
151
+ expect(areas[0].endsWith("…")).toBe(true);
152
+ });
153
+ it("returns empty array when no useful signal at all", () => {
154
+ // Completely empty scan result with empty layerScores
155
+ const result = {
156
+ totalScore: 0,
157
+ layerScores: {
158
+ security: 0,
159
+ maintainability: 0,
160
+ ecosystem: 0,
161
+ upgradeImpact: 0,
162
+ },
163
+ findings: [],
164
+ generatedAt: "2026-01-01T00:00:00Z",
165
+ };
166
+ // With layer fallback this would return layer names; only truly empty when
167
+ // caller passes a result without layerScores entirely — validate max guard
168
+ const areas = selectTopAffectedAreas(result, { max: 3 });
169
+ expect(areas.length).toBeLessThanOrEqual(3);
170
+ });
171
+ it("returns [] when layerScores is missing and no explain/insights", () => {
172
+ const partial = {
173
+ totalScore: 10,
174
+ findings: [],
175
+ generatedAt: "2026-01-01T00:00:00Z",
176
+ };
177
+ expect(selectTopAffectedAreas(partial)).toEqual([]);
178
+ });
179
+ });
@@ -0,0 +1,39 @@
1
+ import type { EngineEmittedScanResult, ScanResult } from "./types.js";
2
+ /** True when methodology is tagged as the OSS stub engine. */
3
+ export declare function isStubMethodologyVersion(methodologyVersion: string | undefined): boolean;
4
+ /**
5
+ * Substrings that must not appear in a trusted GitHub Actions step summary.
6
+ * Keep literals short and tied to product copy — no dependency-name heuristics.
7
+ */
8
+ export declare function trustedActionsSummaryDenylistPhrases(): readonly string[];
9
+ /**
10
+ * Refuse to render a trusted Actions summary when methodology is stub-tagged.
11
+ * Mirrors legacy render script behavior; use from CLI and CI renderer.
12
+ */
13
+ export declare function assertTrustedActionsSummaryAllowed(profile: string, methodologyVersion: string | undefined): void;
14
+ /**
15
+ * Validates scan JSON after a trusted analysis run (CLI / worker boundary).
16
+ */
17
+ export declare function assertTrustedScanResult(result: ScanResult): void;
18
+ /**
19
+ * Strict structural validation for fresh engine JSON + trusted policy.
20
+ * Do not pass historical `scans.result` rows (may omit methodology).
21
+ */
22
+ export declare function validateTrustedEngineScanResult(raw: unknown): EngineEmittedScanResult;
23
+ export type TrustedActionsAuditResult = {
24
+ ok: true;
25
+ } | {
26
+ ok: false;
27
+ errors: readonly string[];
28
+ };
29
+ /**
30
+ * Post-render audit for trusted Actions: methodology + explicit wording only.
31
+ */
32
+ export declare function auditTrustedActionsOutput(opts: {
33
+ summaryText: string;
34
+ /** Raw scan JSON (e.g. from disk); validated with strict engine-output schema + trusted policy. */
35
+ scanResult: unknown;
36
+ /** When true, require trusted-analysis mode in process.env for this audit. */
37
+ requireTrustedEnv?: boolean;
38
+ }): TrustedActionsAuditResult;
39
+ //# sourceMappingURL=trustedScanGuards.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trustedScanGuards.d.ts","sourceRoot":"","sources":["../src/trustedScanGuards.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,uBAAuB,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAKtE,8DAA8D;AAC9D,wBAAgB,wBAAwB,CACtC,kBAAkB,EAAE,MAAM,GAAG,SAAS,GACrC,OAAO,CAKT;AAED;;;GAGG;AACH,wBAAgB,oCAAoC,IAAI,SAAS,MAAM,EAAE,CAOxE;AAED;;;GAGG;AACH,wBAAgB,kCAAkC,CAChD,OAAO,EAAE,MAAM,EACf,kBAAkB,EAAE,MAAM,GAAG,SAAS,GACrC,IAAI,CAMN;AAED;;GAEG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI,CAchE;AAED;;;GAGG;AACH,wBAAgB,+BAA+B,CAC7C,GAAG,EAAE,OAAO,GACX,uBAAuB,CAIzB;AAED,MAAM,MAAM,yBAAyB,GACjC;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GACZ;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,CAAC;AAE7C;;GAEG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE;IAC9C,WAAW,EAAE,MAAM,CAAC;IACpB,mGAAmG;IACnG,UAAU,EAAE,OAAO,CAAC;IACpB,8EAA8E;IAC9E,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B,GAAG,yBAAyB,CAgC5B"}
@@ -0,0 +1,88 @@
1
+ import { parseEngineOutputScanResultOrThrow } from "./scanResultSchema.js";
2
+ import { scanSurfaceCopy } from "./scanSurfaceCopy.js";
3
+ const STUB_METHODOLOGY = /^engine-stub\//i;
4
+ /** True when methodology is tagged as the OSS stub engine. */
5
+ export function isStubMethodologyVersion(methodologyVersion) {
6
+ return (methodologyVersion != null &&
7
+ STUB_METHODOLOGY.test(String(methodologyVersion).trim()));
8
+ }
9
+ /**
10
+ * Substrings that must not appear in a trusted GitHub Actions step summary.
11
+ * Keep literals short and tied to product copy — no dependency-name heuristics.
12
+ */
13
+ export function trustedActionsSummaryDenylistPhrases() {
14
+ return [
15
+ scanSurfaceCopy.actions.demoSummaryTitle,
16
+ scanSurfaceCopy.actions.demoSummaryBanner,
17
+ "engine-stub/",
18
+ "engine-stub/v2",
19
+ ];
20
+ }
21
+ /**
22
+ * Refuse to render a trusted Actions summary when methodology is stub-tagged.
23
+ * Mirrors legacy render script behavior; use from CLI and CI renderer.
24
+ */
25
+ export function assertTrustedActionsSummaryAllowed(profile, methodologyVersion) {
26
+ const p = String(profile ?? "").trim();
27
+ if (p !== "trusted")
28
+ return;
29
+ if (isStubMethodologyVersion(methodologyVersion)) {
30
+ throw new Error(scanSurfaceCopy.actions.trustedSummaryStubBlocked);
31
+ }
32
+ }
33
+ /**
34
+ * Validates scan JSON after a trusted analysis run (CLI / worker boundary).
35
+ */
36
+ export function assertTrustedScanResult(result) {
37
+ if (isStubMethodologyVersion(result.methodologyVersion)) {
38
+ throw new Error(scanSurfaceCopy.actions.trustedSummaryStubBlocked);
39
+ }
40
+ const mv = result.methodologyVersion?.trim();
41
+ if (!mv) {
42
+ throw new Error(scanSurfaceCopy.actions.trustedMethodologyMissing);
43
+ }
44
+ const prefix = String(process.env.MERGESIGNAL_TRUSTED_METHODOLOGY_PREFIX ?? "").trim();
45
+ if (prefix && !mv.startsWith(prefix)) {
46
+ throw new Error(scanSurfaceCopy.actions.trustedMethodologyPolicyMismatch);
47
+ }
48
+ }
49
+ /**
50
+ * Strict structural validation for fresh engine JSON + trusted policy.
51
+ * Do not pass historical `scans.result` rows (may omit methodology).
52
+ */
53
+ export function validateTrustedEngineScanResult(raw) {
54
+ const result = parseEngineOutputScanResultOrThrow(raw);
55
+ assertTrustedScanResult(result);
56
+ return result;
57
+ }
58
+ /**
59
+ * Post-render audit for trusted Actions: methodology + explicit wording only.
60
+ */
61
+ export function auditTrustedActionsOutput(opts) {
62
+ const errors = [];
63
+ if (opts.requireTrustedEnv &&
64
+ process.env.MERGESIGNAL_TRUSTED_ANALYSIS !== "1") {
65
+ errors.push(scanSurfaceCopy.actions.trustedAuditEnvInvalid);
66
+ }
67
+ let scanResult;
68
+ try {
69
+ scanResult = validateTrustedEngineScanResult(opts.scanResult);
70
+ }
71
+ catch (e) {
72
+ errors.push(e instanceof Error ? e.message : String(e));
73
+ }
74
+ if (scanResult) {
75
+ const text = opts.summaryText;
76
+ let denylistHit = false;
77
+ for (const phrase of trustedActionsSummaryDenylistPhrases()) {
78
+ if (phrase && text.includes(phrase)) {
79
+ denylistHit = true;
80
+ break;
81
+ }
82
+ }
83
+ if (denylistHit) {
84
+ errors.push(scanSurfaceCopy.actions.trustedSummaryVerificationFailed);
85
+ }
86
+ }
87
+ return errors.length ? { ok: false, errors } : { ok: true };
88
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=trustedScanGuards.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trustedScanGuards.test.d.ts","sourceRoot":"","sources":["../src/trustedScanGuards.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { assertTrustedActionsSummaryAllowed, assertTrustedScanResult, auditTrustedActionsOutput, isStubMethodologyVersion, trustedActionsSummaryDenylistPhrases, validateTrustedEngineScanResult, } from "./trustedScanGuards.js";
3
+ import { scanSurfaceCopy } from "./scanSurfaceCopy.js";
4
+ const baseResult = {
5
+ totalScore: 10,
6
+ layerScores: {
7
+ security: 10,
8
+ maintainability: 10,
9
+ ecosystem: 10,
10
+ upgradeImpact: 10,
11
+ },
12
+ findings: [],
13
+ recommendations: [],
14
+ generatedAt: "2026-01-01T00:00:00.000Z",
15
+ methodologyVersion: "engine-stub/v2",
16
+ };
17
+ describe("trustedScanGuards", () => {
18
+ it("detects stub methodology", () => {
19
+ expect(isStubMethodologyVersion("engine-stub/v2")).toBe(true);
20
+ expect(isStubMethodologyVersion("Engine-Stub/v1")).toBe(true);
21
+ expect(isStubMethodologyVersion("acme-engine/v1")).toBe(false);
22
+ expect(isStubMethodologyVersion(undefined)).toBe(false);
23
+ });
24
+ it("denylist includes demo copy", () => {
25
+ const d = trustedActionsSummaryDenylistPhrases();
26
+ expect(d).toContain(scanSurfaceCopy.actions.demoSummaryTitle);
27
+ expect(d).toContain(scanSurfaceCopy.actions.demoSummaryBanner);
28
+ });
29
+ it("assertTrustedActionsSummaryAllowed throws for trusted + stub", () => {
30
+ expect(() => assertTrustedActionsSummaryAllowed("trusted", "engine-stub/v2")).toThrow(/verification requirements/);
31
+ });
32
+ it("assertTrustedActionsSummaryAllowed allows trusted + real methodology", () => {
33
+ expect(() => assertTrustedActionsSummaryAllowed("trusted", "acme-prod/v3")).not.toThrow();
34
+ });
35
+ it("assertTrustedActionsSummaryAllowed ignores non-trusted profile", () => {
36
+ expect(() => assertTrustedActionsSummaryAllowed("development", "engine-stub/v2")).not.toThrow();
37
+ });
38
+ describe("assertTrustedScanResult", () => {
39
+ const originalEnv = process.env;
40
+ beforeEach(() => {
41
+ process.env = { ...originalEnv };
42
+ delete process.env.MERGESIGNAL_TRUSTED_METHODOLOGY_PREFIX;
43
+ });
44
+ afterEach(() => {
45
+ process.env = originalEnv;
46
+ });
47
+ it("rejects stub methodology", () => {
48
+ expect(() => assertTrustedScanResult(baseResult)).toThrow(/verification requirements/);
49
+ });
50
+ it("accepts non-stub methodology", () => {
51
+ expect(() => assertTrustedScanResult({
52
+ ...baseResult,
53
+ methodologyVersion: "engine-test-fixture/v1",
54
+ })).not.toThrow();
55
+ });
56
+ it("enforces methodology prefix when env set", () => {
57
+ process.env.MERGESIGNAL_TRUSTED_METHODOLOGY_PREFIX =
58
+ "engine-test-fixture/";
59
+ expect(() => assertTrustedScanResult({
60
+ ...baseResult,
61
+ methodologyVersion: "engine-test-fixture/v1",
62
+ })).not.toThrow();
63
+ expect(() => assertTrustedScanResult({
64
+ ...baseResult,
65
+ methodologyVersion: "other/v1",
66
+ })).toThrow(/methodology output did not match/);
67
+ });
68
+ });
69
+ describe("validateTrustedEngineScanResult", () => {
70
+ const originalEnv = process.env;
71
+ beforeEach(() => {
72
+ process.env = { ...originalEnv };
73
+ delete process.env.MERGESIGNAL_TRUSTED_METHODOLOGY_PREFIX;
74
+ });
75
+ afterEach(() => {
76
+ process.env = originalEnv;
77
+ });
78
+ it("rejects structurally valid relaxed payload without methodologyVersion", () => {
79
+ expect(() => validateTrustedEngineScanResult({
80
+ totalScore: 10,
81
+ layerScores: {
82
+ security: 10,
83
+ maintainability: 10,
84
+ ecosystem: 10,
85
+ upgradeImpact: 10,
86
+ },
87
+ findings: [],
88
+ generatedAt: "2026-01-01T00:00:00.000Z",
89
+ })).toThrow(/^validation:/);
90
+ });
91
+ it("returns result when strict schema and trusted policy pass", () => {
92
+ const r = validateTrustedEngineScanResult({
93
+ ...baseResult,
94
+ methodologyVersion: "acme-prod/v2",
95
+ });
96
+ expect(r.methodologyVersion).toBe("acme-prod/v2");
97
+ });
98
+ });
99
+ describe("auditTrustedActionsOutput", () => {
100
+ it("fails when summary contains demo title", () => {
101
+ const r = auditTrustedActionsOutput({
102
+ summaryText: `Hello ${scanSurfaceCopy.actions.demoSummaryTitle} world`,
103
+ scanResult: {
104
+ ...baseResult,
105
+ methodologyVersion: "acme/v1",
106
+ },
107
+ });
108
+ expect(r.ok).toBe(false);
109
+ if (!r.ok)
110
+ expect(r.errors.length).toBeGreaterThan(0);
111
+ });
112
+ it("passes for clean summary and valid methodology", () => {
113
+ const r = auditTrustedActionsOutput({
114
+ summaryText: "# MergeSignal\n\nLow risk posture.",
115
+ scanResult: {
116
+ ...baseResult,
117
+ methodologyVersion: "acme-prod/v2",
118
+ },
119
+ });
120
+ expect(r).toEqual({ ok: true });
121
+ });
122
+ it("fails when scan JSON omits methodology (strict engine contract)", () => {
123
+ const r = auditTrustedActionsOutput({
124
+ summaryText: "# MergeSignal\n\nLow risk posture.",
125
+ scanResult: {
126
+ totalScore: 10,
127
+ layerScores: {
128
+ security: 10,
129
+ maintainability: 10,
130
+ ecosystem: 10,
131
+ upgradeImpact: 10,
132
+ },
133
+ findings: [],
134
+ generatedAt: "2026-01-01T00:00:00.000Z",
135
+ },
136
+ });
137
+ expect(r.ok).toBe(false);
138
+ if (!r.ok) {
139
+ expect(r.errors.some((e) => e.startsWith("validation:"))).toBe(true);
140
+ }
141
+ });
142
+ });
143
+ });