@iceinvein/agent-skills 0.1.30 → 0.1.32
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/package.json +1 -1
- package/skills/index.json +1 -1
- package/skills/magpie/SKILL.md +24 -1
- package/skills/magpie/bun.lock +91 -0
- package/skills/magpie/package.json +3 -0
- package/skills/magpie/scripts/__tests__/highlight.test.ts +91 -0
- package/skills/magpie/scripts/__tests__/post-cmd.test.ts +55 -0
- package/skills/magpie/scripts/__tests__/render-annotation.test.ts +129 -10
- package/skills/magpie/scripts/__tests__/render-diff.test.ts +83 -1
- package/skills/magpie/scripts/__tests__/render-findings.test.ts +24 -8
- package/skills/magpie/scripts/__tests__/render-issues-list.test.ts +38 -6
- package/skills/magpie/scripts/__tests__/skill-lint.test.ts +3 -1
- package/skills/magpie/scripts/__tests__/types.test.ts +227 -14
- package/skills/magpie/scripts/highlight.ts +113 -0
- package/skills/magpie/scripts/post-cmd.ts +61 -6
- package/skills/magpie/scripts/render-annotation.ts +27 -5
- package/skills/magpie/scripts/render-diff.ts +82 -19
- package/skills/magpie/scripts/render-findings.ts +18 -4
- package/skills/magpie/scripts/render-issues-list.ts +4 -1
- package/skills/magpie/scripts/types.ts +219 -22
- package/skills/magpie/skill.json +1 -1
- package/skills/magpie/templates/styles.css +43 -2
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test'
|
|
1
|
+
import { beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { Highlighter } from 'shiki'
|
|
2
3
|
import { parseUnifiedDiffToHunks } from '../diff-utils.ts'
|
|
4
|
+
import { getHighlighter } from '../highlight.ts'
|
|
3
5
|
import { renderSplitDiff, renderUnifiedDiff } from '../render-diff.ts'
|
|
4
6
|
import type { ReviewFinding } from '../types.ts'
|
|
5
7
|
|
|
@@ -24,6 +26,11 @@ const finding: ReviewFinding = {
|
|
|
24
26
|
domain: 'bugs',
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
let hl: Highlighter
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
hl = await getHighlighter()
|
|
32
|
+
})
|
|
33
|
+
|
|
27
34
|
describe('renderUnifiedDiff', () => {
|
|
28
35
|
test('emits one row per diff line', () => {
|
|
29
36
|
const html = renderUnifiedDiff({
|
|
@@ -31,6 +38,8 @@ describe('renderUnifiedDiff', () => {
|
|
|
31
38
|
findings: [],
|
|
32
39
|
postStatus: {},
|
|
33
40
|
selectedIds: new Set(),
|
|
41
|
+
highlighter: hl,
|
|
42
|
+
file: 'x.ts',
|
|
34
43
|
})
|
|
35
44
|
expect((html.match(/class="diff-row/g) ?? []).length).toBe(5)
|
|
36
45
|
})
|
|
@@ -42,6 +51,8 @@ describe('renderUnifiedDiff', () => {
|
|
|
42
51
|
findings: [finding],
|
|
43
52
|
postStatus: {},
|
|
44
53
|
selectedIds: new Set(),
|
|
54
|
+
highlighter: hl,
|
|
55
|
+
file: 'x.ts',
|
|
45
56
|
})
|
|
46
57
|
const idx = html.indexOf('data-finding-id="f-1"')
|
|
47
58
|
const before = html.lastIndexOf('class="diff-row', idx)
|
|
@@ -55,6 +66,8 @@ describe('renderUnifiedDiff', () => {
|
|
|
55
66
|
findings: [],
|
|
56
67
|
postStatus: {},
|
|
57
68
|
selectedIds: new Set(),
|
|
69
|
+
highlighter: hl,
|
|
70
|
+
file: 'x.ts',
|
|
58
71
|
})
|
|
59
72
|
expect(html).toContain('No changes')
|
|
60
73
|
})
|
|
@@ -65,11 +78,57 @@ describe('renderUnifiedDiff', () => {
|
|
|
65
78
|
findings: [finding],
|
|
66
79
|
postStatus: { 'f-1': 'posted' },
|
|
67
80
|
selectedIds: new Set(),
|
|
81
|
+
highlighter: hl,
|
|
82
|
+
file: 'x.ts',
|
|
68
83
|
})
|
|
69
84
|
expect(html).toContain('data-posted="true"')
|
|
70
85
|
})
|
|
71
86
|
})
|
|
72
87
|
|
|
88
|
+
test('emits shiki-highlighted line content in unified mode', () => {
|
|
89
|
+
const diff = `--- a/x.ts
|
|
90
|
+
+++ b/x.ts
|
|
91
|
+
@@ -1,3 +1,3 @@
|
|
92
|
+
const a = 1
|
|
93
|
+
-const b = 2
|
|
94
|
+
+const b = 3
|
|
95
|
+
`
|
|
96
|
+
const html = renderUnifiedDiff({
|
|
97
|
+
hunks: parseUnifiedDiffToHunks(diff),
|
|
98
|
+
findings: [],
|
|
99
|
+
postStatus: {},
|
|
100
|
+
selectedIds: new Set(),
|
|
101
|
+
highlighter: hl,
|
|
102
|
+
file: 'x.ts',
|
|
103
|
+
})
|
|
104
|
+
expect(html).toContain('const')
|
|
105
|
+
expect(html).toContain('<span style=')
|
|
106
|
+
expect(html).toContain('class="gutter"')
|
|
107
|
+
expect(html).toContain('class="sign"')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('keeps multi-line tokens consistent across diff rows', () => {
|
|
111
|
+
const diff = `--- a/x.ts
|
|
112
|
+
+++ b/x.ts
|
|
113
|
+
@@ -1,5 +1,5 @@
|
|
114
|
+
const x = \`first
|
|
115
|
+
second
|
|
116
|
+
-third\`
|
|
117
|
+
+THIRD\`
|
|
118
|
+
const y = 1
|
|
119
|
+
`
|
|
120
|
+
const html = renderUnifiedDiff({
|
|
121
|
+
hunks: parseUnifiedDiffToHunks(diff),
|
|
122
|
+
findings: [],
|
|
123
|
+
postStatus: {},
|
|
124
|
+
selectedIds: new Set(),
|
|
125
|
+
highlighter: hl,
|
|
126
|
+
file: 'x.ts',
|
|
127
|
+
})
|
|
128
|
+
const rowCount = (html.match(/class="diff-row /g) || []).length
|
|
129
|
+
expect(rowCount).toBe(5)
|
|
130
|
+
})
|
|
131
|
+
|
|
73
132
|
describe('renderSplitDiff', () => {
|
|
74
133
|
test('emits left/right cells per row', () => {
|
|
75
134
|
const html = renderSplitDiff({
|
|
@@ -77,8 +136,31 @@ describe('renderSplitDiff', () => {
|
|
|
77
136
|
findings: [],
|
|
78
137
|
postStatus: {},
|
|
79
138
|
selectedIds: new Set(),
|
|
139
|
+
highlighter: hl,
|
|
140
|
+
file: 'x.ts',
|
|
80
141
|
})
|
|
81
142
|
expect((html.match(/diff-row split/g) ?? []).length).toBeGreaterThan(0)
|
|
82
143
|
expect(html).toContain('diff-cell')
|
|
83
144
|
})
|
|
84
145
|
})
|
|
146
|
+
|
|
147
|
+
test('emits shiki-highlighted line content in split mode', () => {
|
|
148
|
+
const diff = `--- a/x.ts
|
|
149
|
+
+++ b/x.ts
|
|
150
|
+
@@ -1,3 +1,3 @@
|
|
151
|
+
const a = 1
|
|
152
|
+
-const b = 2
|
|
153
|
+
+const b = 3
|
|
154
|
+
`
|
|
155
|
+
const html = renderSplitDiff({
|
|
156
|
+
hunks: parseUnifiedDiffToHunks(diff),
|
|
157
|
+
findings: [],
|
|
158
|
+
postStatus: {},
|
|
159
|
+
selectedIds: new Set(),
|
|
160
|
+
highlighter: hl,
|
|
161
|
+
file: 'x.ts',
|
|
162
|
+
})
|
|
163
|
+
expect(html).toContain('class="diff-row split"')
|
|
164
|
+
expect(html).toContain('<span style=')
|
|
165
|
+
expect(html).toContain('class="gutter"')
|
|
166
|
+
})
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { expect, test } from 'bun:test'
|
|
1
|
+
import { beforeAll, expect, test } from 'bun:test'
|
|
2
|
+
import type { Highlighter } from 'shiki'
|
|
3
|
+
import { getHighlighter } from '../highlight.ts'
|
|
2
4
|
import { renderFindingsHtml } from '../render-findings.ts'
|
|
3
5
|
import type { ReviewFinding } from '../types.ts'
|
|
4
6
|
|
|
@@ -44,21 +46,26 @@ const SAMPLE_DIFF = `--- a/src/a.ts
|
|
|
44
46
|
line3
|
|
45
47
|
`
|
|
46
48
|
|
|
49
|
+
let hl: Highlighter
|
|
50
|
+
beforeAll(async () => {
|
|
51
|
+
hl = await getHighlighter()
|
|
52
|
+
})
|
|
53
|
+
|
|
47
54
|
test('empty findings renders the page shell with an empty-state message', () => {
|
|
48
|
-
const html = renderFindingsHtml({ findings: [], postStatus: {} })
|
|
55
|
+
const html = renderFindingsHtml({ findings: [], postStatus: {}, highlighter: hl })
|
|
49
56
|
expect(html).toContain('class="pr-header"')
|
|
50
57
|
expect(html).toContain('No findings')
|
|
51
58
|
})
|
|
52
59
|
|
|
53
60
|
test('emits the two-tab segmented control with Files and All Issues buttons', () => {
|
|
54
|
-
const html = renderFindingsHtml({ findings: SAMPLE_FINDINGS, postStatus: {} })
|
|
61
|
+
const html = renderFindingsHtml({ findings: SAMPLE_FINDINGS, postStatus: {}, highlighter: hl })
|
|
55
62
|
expect(html).toContain('data-action="set-view"')
|
|
56
63
|
expect(html).toContain('data-view="files"')
|
|
57
64
|
expect(html).toContain('data-view="all-issues"')
|
|
58
65
|
})
|
|
59
66
|
|
|
60
67
|
test('sets body data attributes for default view, diff-mode, and suggestions visibility', () => {
|
|
61
|
-
const html = renderFindingsHtml({ findings: SAMPLE_FINDINGS, postStatus: {} })
|
|
68
|
+
const html = renderFindingsHtml({ findings: SAMPLE_FINDINGS, postStatus: {}, highlighter: hl })
|
|
62
69
|
expect(html).toContain('data-page="findings"')
|
|
63
70
|
expect(html).toContain('data-view="files"')
|
|
64
71
|
expect(html).toContain('data-diff-mode="unified"')
|
|
@@ -74,6 +81,7 @@ test('renders one file pane per PrFileEntry, hidden by default', () => {
|
|
|
74
81
|
{ path: 'src/b.ts', additions: 1, deletions: 0 },
|
|
75
82
|
],
|
|
76
83
|
diff: SAMPLE_DIFF,
|
|
84
|
+
highlighter: hl,
|
|
77
85
|
})
|
|
78
86
|
expect(html).toContain('data-file-pane="src/a.ts"')
|
|
79
87
|
expect(html).toContain('data-file-pane="src/b.ts"')
|
|
@@ -81,7 +89,7 @@ test('renders one file pane per PrFileEntry, hidden by default', () => {
|
|
|
81
89
|
})
|
|
82
90
|
|
|
83
91
|
test('renders an Overview pane mapped to data-file-pane=""', () => {
|
|
84
|
-
const html = renderFindingsHtml({ findings: SAMPLE_FINDINGS, postStatus: {} })
|
|
92
|
+
const html = renderFindingsHtml({ findings: SAMPLE_FINDINGS, postStatus: {}, highlighter: hl })
|
|
85
93
|
expect(html).toContain('data-file-pane=""')
|
|
86
94
|
})
|
|
87
95
|
|
|
@@ -91,6 +99,7 @@ test('renders a per-file toolbar with breadcrumb, deltas, finding-nav and diff-m
|
|
|
91
99
|
postStatus: {},
|
|
92
100
|
files: [{ path: 'src/a.ts', additions: 3, deletions: 1 }],
|
|
93
101
|
diff: SAMPLE_DIFF,
|
|
102
|
+
highlighter: hl,
|
|
94
103
|
})
|
|
95
104
|
expect(html).toContain('+3')
|
|
96
105
|
expect(html).toContain('-1')
|
|
@@ -107,6 +116,7 @@ test('renders both unified and split diff containers per file', () => {
|
|
|
107
116
|
postStatus: {},
|
|
108
117
|
files: [{ path: 'src/a.ts', additions: 3, deletions: 1 }],
|
|
109
118
|
diff: SAMPLE_DIFF,
|
|
119
|
+
highlighter: hl,
|
|
110
120
|
})
|
|
111
121
|
expect(html).toContain('data-diff-mode="unified"')
|
|
112
122
|
expect(html).toContain('data-diff-mode="split"')
|
|
@@ -118,18 +128,19 @@ test('places an inline annotation at the affected diff line', () => {
|
|
|
118
128
|
postStatus: {},
|
|
119
129
|
files: [{ path: 'src/a.ts', additions: 3, deletions: 1 }],
|
|
120
130
|
diff: SAMPLE_DIFF,
|
|
131
|
+
highlighter: hl,
|
|
121
132
|
})
|
|
122
133
|
expect(html).toContain('data-finding-id="a"')
|
|
123
134
|
expect(html).toContain('class="annot')
|
|
124
135
|
})
|
|
125
136
|
|
|
126
137
|
test('mounts the All Issues pane', () => {
|
|
127
|
-
const html = renderFindingsHtml({ findings: SAMPLE_FINDINGS, postStatus: {} })
|
|
138
|
+
const html = renderFindingsHtml({ findings: SAMPLE_FINDINGS, postStatus: {}, highlighter: hl })
|
|
128
139
|
expect(html).toContain('data-role="issues-list"')
|
|
129
140
|
})
|
|
130
141
|
|
|
131
142
|
test('mounts the action bar at the page bottom', () => {
|
|
132
|
-
const html = renderFindingsHtml({ findings: SAMPLE_FINDINGS, postStatus: {} })
|
|
143
|
+
const html = renderFindingsHtml({ findings: SAMPLE_FINDINGS, postStatus: {}, highlighter: hl })
|
|
133
144
|
expect(html).toContain('data-role="action-bar"')
|
|
134
145
|
expect(html).toContain('Post Recommended')
|
|
135
146
|
})
|
|
@@ -138,6 +149,7 @@ test('marks posted findings with data-posted="true"', () => {
|
|
|
138
149
|
const html = renderFindingsHtml({
|
|
139
150
|
findings: SAMPLE_FINDINGS,
|
|
140
151
|
postStatus: { a: 'posted' },
|
|
152
|
+
highlighter: hl,
|
|
141
153
|
})
|
|
142
154
|
expect(html).toContain('data-posted="true"')
|
|
143
155
|
})
|
|
@@ -152,8 +164,10 @@ test('renders the suggestion code block in the annotation', () => {
|
|
|
152
164
|
}),
|
|
153
165
|
],
|
|
154
166
|
postStatus: {},
|
|
167
|
+
highlighter: hl,
|
|
155
168
|
})
|
|
156
|
-
|
|
169
|
+
// Shiki tokenizes identifiers into separate spans; check for a known token
|
|
170
|
+
expect(html).toContain('return')
|
|
157
171
|
expect(html).toContain('Suggested change')
|
|
158
172
|
})
|
|
159
173
|
|
|
@@ -168,6 +182,7 @@ test('parses labelled paragraphs into section headers', () => {
|
|
|
168
182
|
}),
|
|
169
183
|
],
|
|
170
184
|
postStatus: {},
|
|
185
|
+
highlighter: hl,
|
|
171
186
|
})
|
|
172
187
|
expect(html).toContain('Observation')
|
|
173
188
|
expect(html).toContain('Why it matters')
|
|
@@ -179,6 +194,7 @@ test('renders pr meta when supplied', () => {
|
|
|
179
194
|
findings: SAMPLE_FINDINGS,
|
|
180
195
|
postStatus: {},
|
|
181
196
|
pr: { number: 42, branch: 'feat/x', headSha: 'deadbeefdeadbeef0000111122223333' },
|
|
197
|
+
highlighter: hl,
|
|
182
198
|
})
|
|
183
199
|
expect(html).toContain('PR #42')
|
|
184
200
|
expect(html).toContain('feat/x')
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test'
|
|
1
|
+
import { beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { Highlighter } from 'shiki'
|
|
3
|
+
import { getHighlighter } from '../highlight.ts'
|
|
2
4
|
import { renderIssuesList } from '../render-issues-list.ts'
|
|
3
5
|
import type { ReviewFinding } from '../types.ts'
|
|
4
6
|
|
|
@@ -35,31 +37,61 @@ const findings: ReviewFinding[] = [
|
|
|
35
37
|
},
|
|
36
38
|
] as ReviewFinding[]
|
|
37
39
|
|
|
40
|
+
let hl: Highlighter
|
|
41
|
+
beforeAll(async () => {
|
|
42
|
+
hl = await getHighlighter()
|
|
43
|
+
})
|
|
44
|
+
|
|
38
45
|
describe('renderIssuesList', () => {
|
|
39
46
|
test('renders an issue-card per finding', () => {
|
|
40
|
-
const html = renderIssuesList({
|
|
47
|
+
const html = renderIssuesList({
|
|
48
|
+
findings,
|
|
49
|
+
postStatus: {},
|
|
50
|
+
selectedIds: new Set(),
|
|
51
|
+
highlighter: hl,
|
|
52
|
+
})
|
|
41
53
|
expect((html.match(/class="issue-card/g) ?? []).length).toBe(3)
|
|
42
54
|
})
|
|
43
55
|
|
|
44
56
|
test('renders severity filter pills with counts', () => {
|
|
45
|
-
const html = renderIssuesList({
|
|
57
|
+
const html = renderIssuesList({
|
|
58
|
+
findings,
|
|
59
|
+
postStatus: {},
|
|
60
|
+
selectedIds: new Set(),
|
|
61
|
+
highlighter: hl,
|
|
62
|
+
})
|
|
46
63
|
expect(html).toContain('Blocker (1)')
|
|
47
64
|
expect(html).toContain('High (1)')
|
|
48
65
|
expect(html).toContain('Low (1)')
|
|
49
66
|
})
|
|
50
67
|
|
|
51
68
|
test('emits "N should review" with actionable count only', () => {
|
|
52
|
-
const html = renderIssuesList({
|
|
69
|
+
const html = renderIssuesList({
|
|
70
|
+
findings,
|
|
71
|
+
postStatus: {},
|
|
72
|
+
selectedIds: new Set(),
|
|
73
|
+
highlighter: hl,
|
|
74
|
+
})
|
|
53
75
|
expect(html).toContain('2 should review')
|
|
54
76
|
})
|
|
55
77
|
|
|
56
78
|
test('emits "Show N suggestions" toggle when suggestions present', () => {
|
|
57
|
-
const html = renderIssuesList({
|
|
79
|
+
const html = renderIssuesList({
|
|
80
|
+
findings,
|
|
81
|
+
postStatus: {},
|
|
82
|
+
selectedIds: new Set(),
|
|
83
|
+
highlighter: hl,
|
|
84
|
+
})
|
|
58
85
|
expect(html).toContain('Show 1 suggestion')
|
|
59
86
|
})
|
|
60
87
|
|
|
61
88
|
test('suggestion finding has data-suggestion="true"', () => {
|
|
62
|
-
const html = renderIssuesList({
|
|
89
|
+
const html = renderIssuesList({
|
|
90
|
+
findings,
|
|
91
|
+
postStatus: {},
|
|
92
|
+
selectedIds: new Set(),
|
|
93
|
+
highlighter: hl,
|
|
94
|
+
})
|
|
63
95
|
expect(html).toMatch(
|
|
64
96
|
/data-finding-id="3"[^>]*data-suggestion="true"|data-suggestion="true"[^>]*data-finding-id="3"/,
|
|
65
97
|
)
|
|
@@ -52,7 +52,9 @@ test('styles.css declares a prefers-color-scheme:dark block that overrides core
|
|
|
52
52
|
test('rendered HTML opts into light/dark with a color-scheme meta tag', async () => {
|
|
53
53
|
const { renderFindingsHtml } = await import('../render-findings.ts')
|
|
54
54
|
const { renderProgressHtml } = await import('../render-progress.ts')
|
|
55
|
-
const
|
|
55
|
+
const { getHighlighter } = await import('../highlight.ts')
|
|
56
|
+
const highlighter = await getHighlighter()
|
|
57
|
+
const findings = renderFindingsHtml({ findings: [], postStatus: {}, highlighter })
|
|
56
58
|
const progress = renderProgressHtml({
|
|
57
59
|
prNumber: 1,
|
|
58
60
|
headSha: 'deadbeef0011',
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
import type { FocusId, PrFileEntry, ReviewFinding } from '../types.ts'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
coerceRisk,
|
|
5
|
+
coerceSeverity,
|
|
6
|
+
FOCUS_IDS,
|
|
7
|
+
isSuggestion,
|
|
8
|
+
looksLikeProse,
|
|
9
|
+
parseFinding,
|
|
10
|
+
} from '../types.ts'
|
|
4
11
|
|
|
5
12
|
test('FOCUS_IDS contains the LLM focuses plus the deterministic tests domain', () => {
|
|
6
13
|
expect(FOCUS_IDS).toEqual([
|
|
@@ -30,19 +37,151 @@ test('parseFinding accepts a minimal finding', () => {
|
|
|
30
37
|
expect(parsed.domain).toBe('bugs')
|
|
31
38
|
})
|
|
32
39
|
|
|
33
|
-
test('parseFinding
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
test('parseFinding coerces an unknown severity to medium', () => {
|
|
41
|
+
const parsed = parseFinding({
|
|
42
|
+
id: 'f1',
|
|
43
|
+
file: 'src/x.ts',
|
|
44
|
+
line: 10,
|
|
45
|
+
severity: 'panic',
|
|
46
|
+
risk: { impact: 'high', likelihood: 'possible', confidence: 'medium', action: 'should-fix' },
|
|
47
|
+
title: 't',
|
|
48
|
+
description: 'd',
|
|
49
|
+
domain: 'bugs',
|
|
50
|
+
})
|
|
51
|
+
expect(parsed.severity).toBe('medium')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('parseFinding coerces sentence-form action to canonical enum', () => {
|
|
55
|
+
const parsed = parseFinding({
|
|
56
|
+
id: 'f1',
|
|
57
|
+
file: 'src/x.ts',
|
|
58
|
+
line: 10,
|
|
59
|
+
severity: 'high',
|
|
60
|
+
risk: {
|
|
61
|
+
impact: 'high',
|
|
62
|
+
likelihood: 'possible',
|
|
63
|
+
confidence: 'medium',
|
|
64
|
+
action: 'Should be fixed before merge',
|
|
65
|
+
},
|
|
66
|
+
title: 't',
|
|
67
|
+
description: 'd',
|
|
68
|
+
domain: 'bugs',
|
|
69
|
+
})
|
|
70
|
+
expect(parsed.risk.action).toBe('should-fix')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('parseFinding coerces wrong-axis likelihood values', () => {
|
|
74
|
+
const parsed = parseFinding({
|
|
75
|
+
id: 'f1',
|
|
76
|
+
file: 'src/x.ts',
|
|
77
|
+
line: 10,
|
|
78
|
+
severity: 'high',
|
|
79
|
+
risk: { impact: 'high', likelihood: 'high', confidence: 'medium', action: 'should-fix' },
|
|
80
|
+
title: 't',
|
|
81
|
+
description: 'd',
|
|
82
|
+
domain: 'bugs',
|
|
83
|
+
})
|
|
84
|
+
expect(parsed.risk.likelihood).toBe('likely')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('coerceSeverity', () => {
|
|
88
|
+
test('passes through canonical values', () => {
|
|
89
|
+
for (const v of ['blocker', 'high', 'medium', 'low'] as const) {
|
|
90
|
+
expect(coerceSeverity(v)).toBe(v)
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
test('maps synonyms', () => {
|
|
94
|
+
expect(coerceSeverity('critical')).toBe('blocker')
|
|
95
|
+
expect(coerceSeverity('major')).toBe('high')
|
|
96
|
+
expect(coerceSeverity('moderate')).toBe('medium')
|
|
97
|
+
expect(coerceSeverity('minor')).toBe('low')
|
|
98
|
+
})
|
|
99
|
+
test('handles casing and whitespace', () => {
|
|
100
|
+
expect(coerceSeverity(' HIGH ')).toBe('high')
|
|
101
|
+
})
|
|
102
|
+
test('falls back to medium for garbage', () => {
|
|
103
|
+
expect(coerceSeverity('panic')).toBe('medium')
|
|
104
|
+
expect(coerceSeverity('')).toBe('medium')
|
|
105
|
+
expect(coerceSeverity(null)).toBe('medium')
|
|
106
|
+
expect(coerceSeverity(undefined)).toBe('medium')
|
|
107
|
+
expect(coerceSeverity(42)).toBe('medium')
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('coerceRisk', () => {
|
|
112
|
+
test('passes through canonical risk object', () => {
|
|
113
|
+
expect(
|
|
114
|
+
coerceRisk({
|
|
115
|
+
impact: 'critical',
|
|
116
|
+
likelihood: 'likely',
|
|
117
|
+
confidence: 'high',
|
|
118
|
+
action: 'must-fix',
|
|
119
|
+
}),
|
|
120
|
+
).toEqual({
|
|
121
|
+
impact: 'critical',
|
|
122
|
+
likelihood: 'likely',
|
|
123
|
+
confidence: 'high',
|
|
124
|
+
action: 'must-fix',
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
test('maps action synonyms and underscore/space variants', () => {
|
|
128
|
+
expect(coerceRisk({ action: 'must_fix' }).action).toBe('must-fix')
|
|
129
|
+
expect(coerceRisk({ action: 'should fix' }).action).toBe('should-fix')
|
|
130
|
+
expect(coerceRisk({ action: 'blocker' }).action).toBe('must-fix')
|
|
131
|
+
expect(coerceRisk({ action: 'nit' }).action).toBe('optional')
|
|
132
|
+
expect(coerceRisk({ action: 'recommend' }).action).toBe('should-fix')
|
|
133
|
+
})
|
|
134
|
+
test('extracts action from full sentences', () => {
|
|
135
|
+
expect(coerceRisk({ action: 'This must be fixed immediately.' }).action).toBe('must-fix')
|
|
136
|
+
expect(coerceRisk({ action: 'Should fix before merging the PR' }).action).toBe('should-fix')
|
|
137
|
+
expect(coerceRisk({ action: 'Consider refactoring this' }).action).toBe('consider')
|
|
138
|
+
expect(coerceRisk({ action: 'Nice to have' }).action).toBe('optional')
|
|
139
|
+
})
|
|
140
|
+
test('maps likelihood wrong-axis values', () => {
|
|
141
|
+
expect(coerceRisk({ likelihood: 'high' }).likelihood).toBe('likely')
|
|
142
|
+
expect(coerceRisk({ likelihood: 'medium' }).likelihood).toBe('possible')
|
|
143
|
+
expect(coerceRisk({ likelihood: 'low' }).likelihood).toBe('edge-case')
|
|
144
|
+
expect(coerceRisk({ likelihood: 'edge case' }).likelihood).toBe('edge-case')
|
|
145
|
+
expect(coerceRisk({ likelihood: 'n/a' }).likelihood).toBe('unknown')
|
|
146
|
+
})
|
|
147
|
+
test('maps impact synonyms', () => {
|
|
148
|
+
expect(coerceRisk({ impact: 'blocker' }).impact).toBe('critical')
|
|
149
|
+
expect(coerceRisk({ impact: 'major' }).impact).toBe('high')
|
|
150
|
+
expect(coerceRisk({ impact: 'moderate' }).impact).toBe('medium')
|
|
151
|
+
expect(coerceRisk({ impact: 'minor' }).impact).toBe('low')
|
|
152
|
+
})
|
|
153
|
+
test('maps confidence synonyms', () => {
|
|
154
|
+
expect(coerceRisk({ confidence: 'certain' }).confidence).toBe('high')
|
|
155
|
+
expect(coerceRisk({ confidence: 'moderate' }).confidence).toBe('medium')
|
|
156
|
+
expect(coerceRisk({ confidence: 'speculative' }).confidence).toBe('low')
|
|
157
|
+
})
|
|
158
|
+
test('falls back to safe defaults for garbage / missing', () => {
|
|
159
|
+
expect(coerceRisk({})).toEqual({
|
|
160
|
+
impact: 'medium',
|
|
161
|
+
likelihood: 'unknown',
|
|
162
|
+
confidence: 'medium',
|
|
163
|
+
action: 'consider',
|
|
164
|
+
})
|
|
165
|
+
expect(coerceRisk(null)).toEqual({
|
|
166
|
+
impact: 'medium',
|
|
167
|
+
likelihood: 'unknown',
|
|
168
|
+
confidence: 'medium',
|
|
169
|
+
action: 'consider',
|
|
170
|
+
})
|
|
171
|
+
expect(
|
|
172
|
+
coerceRisk({
|
|
173
|
+
impact: 'whatever',
|
|
174
|
+
likelihood: 42,
|
|
175
|
+
confidence: '',
|
|
176
|
+
action: 'do the thing',
|
|
177
|
+
}),
|
|
178
|
+
).toEqual({
|
|
179
|
+
impact: 'medium',
|
|
180
|
+
likelihood: 'unknown',
|
|
181
|
+
confidence: 'medium',
|
|
182
|
+
action: 'consider',
|
|
183
|
+
})
|
|
184
|
+
})
|
|
46
185
|
})
|
|
47
186
|
|
|
48
187
|
test('parseFinding accepts null line', () => {
|
|
@@ -68,6 +207,80 @@ describe('PrFileEntry', () => {
|
|
|
68
207
|
})
|
|
69
208
|
})
|
|
70
209
|
|
|
210
|
+
describe('looksLikeProse', () => {
|
|
211
|
+
test('returns true for multi-sentence prose with backticks', () => {
|
|
212
|
+
const body =
|
|
213
|
+
'Strip the delimiter from user-controlled fields. For example, inside `getX` apply a sanitiser that neutralises the tokens.'
|
|
214
|
+
expect(looksLikeProse(body)).toBe(true)
|
|
215
|
+
})
|
|
216
|
+
test('returns true for single-line sentence-shaped explanation', () => {
|
|
217
|
+
const body =
|
|
218
|
+
'Reject the literal delimiter token in any field that flows into the draft before invoking Bedrock.'
|
|
219
|
+
expect(looksLikeProse(body)).toBe(true)
|
|
220
|
+
})
|
|
221
|
+
test('returns false for multi-line replacement code', () => {
|
|
222
|
+
const body =
|
|
223
|
+
'if (input.includes("<<<END>>>")) {\n throw new Error("delimiter token not allowed")\n}'
|
|
224
|
+
expect(looksLikeProse(body)).toBe(false)
|
|
225
|
+
})
|
|
226
|
+
test('returns false for short single-line code', () => {
|
|
227
|
+
expect(looksLikeProse('return null;')).toBe(false)
|
|
228
|
+
expect(looksLikeProse('const x = foo(bar);')).toBe(false)
|
|
229
|
+
expect(looksLikeProse('user?.id ?? "";')).toBe(false)
|
|
230
|
+
})
|
|
231
|
+
test('returns false for one-line code that happens to end with a period', () => {
|
|
232
|
+
// No prose connectives, just a method chain.
|
|
233
|
+
expect(looksLikeProse('foo.bar.baz();')).toBe(false)
|
|
234
|
+
})
|
|
235
|
+
test('returns false for empty body', () => {
|
|
236
|
+
expect(looksLikeProse('')).toBe(false)
|
|
237
|
+
expect(looksLikeProse(' ')).toBe(false)
|
|
238
|
+
})
|
|
239
|
+
test('returns false for fenced code block', () => {
|
|
240
|
+
const body = '```ts\nconst guarded = sanitize(input)\nreturn guarded\n```'
|
|
241
|
+
expect(looksLikeProse(body)).toBe(false)
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
describe('parseFinding suggestion validator', () => {
|
|
246
|
+
const base = {
|
|
247
|
+
id: 'f1',
|
|
248
|
+
file: 'src/x.ts',
|
|
249
|
+
line: 10,
|
|
250
|
+
severity: 'medium',
|
|
251
|
+
risk: { impact: 'medium', likelihood: 'possible', confidence: 'medium', action: 'should-fix' },
|
|
252
|
+
title: 't',
|
|
253
|
+
description: 'd',
|
|
254
|
+
domain: 'security',
|
|
255
|
+
}
|
|
256
|
+
test('drops suggestion when body is prose', () => {
|
|
257
|
+
const parsed = parseFinding({
|
|
258
|
+
...base,
|
|
259
|
+
suggestion: {
|
|
260
|
+
body: 'Strip the delimiter tokens before they enter the draft. Add a regression test to confirm.',
|
|
261
|
+
startLine: 10,
|
|
262
|
+
endLine: 12,
|
|
263
|
+
},
|
|
264
|
+
})
|
|
265
|
+
expect(parsed.suggestion).toBeUndefined()
|
|
266
|
+
})
|
|
267
|
+
test('keeps suggestion when body is real replacement code', () => {
|
|
268
|
+
const parsed = parseFinding({
|
|
269
|
+
...base,
|
|
270
|
+
suggestion: {
|
|
271
|
+
body: 'const guarded = sanitize(input)\nreturn guarded',
|
|
272
|
+
startLine: 10,
|
|
273
|
+
endLine: 11,
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
expect(parsed.suggestion).toEqual({
|
|
277
|
+
body: 'const guarded = sanitize(input)\nreturn guarded',
|
|
278
|
+
startLine: 10,
|
|
279
|
+
endLine: 11,
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
|
|
71
284
|
describe('isSuggestion', () => {
|
|
72
285
|
const base = {
|
|
73
286
|
id: '1',
|