@iceinvein/agent-skills 0.1.31 → 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__/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/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,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',
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { createHighlighter, type Highlighter } from 'shiki'
|
|
2
|
+
|
|
3
|
+
const LANG_BY_EXT: Record<string, string> = {
|
|
4
|
+
ts: 'typescript',
|
|
5
|
+
mts: 'typescript',
|
|
6
|
+
cts: 'typescript',
|
|
7
|
+
tsx: 'tsx',
|
|
8
|
+
js: 'javascript',
|
|
9
|
+
mjs: 'javascript',
|
|
10
|
+
cjs: 'javascript',
|
|
11
|
+
jsx: 'jsx',
|
|
12
|
+
py: 'python',
|
|
13
|
+
go: 'go',
|
|
14
|
+
rs: 'rust',
|
|
15
|
+
java: 'java',
|
|
16
|
+
rb: 'ruby',
|
|
17
|
+
sh: 'bash',
|
|
18
|
+
bash: 'bash',
|
|
19
|
+
zsh: 'bash',
|
|
20
|
+
json: 'json',
|
|
21
|
+
jsonc: 'json',
|
|
22
|
+
yml: 'yaml',
|
|
23
|
+
yaml: 'yaml',
|
|
24
|
+
md: 'markdown',
|
|
25
|
+
markdown: 'markdown',
|
|
26
|
+
html: 'html',
|
|
27
|
+
htm: 'html',
|
|
28
|
+
css: 'css',
|
|
29
|
+
sql: 'sql',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const REGISTERED_LANGS: string[] = [
|
|
33
|
+
'typescript',
|
|
34
|
+
'tsx',
|
|
35
|
+
'javascript',
|
|
36
|
+
'jsx',
|
|
37
|
+
'python',
|
|
38
|
+
'go',
|
|
39
|
+
'rust',
|
|
40
|
+
'java',
|
|
41
|
+
'ruby',
|
|
42
|
+
'bash',
|
|
43
|
+
'json',
|
|
44
|
+
'yaml',
|
|
45
|
+
'markdown',
|
|
46
|
+
'html',
|
|
47
|
+
'css',
|
|
48
|
+
'sql',
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
const THEMES = { light: 'vitesse-light', dark: 'vitesse-dark' } as const
|
|
52
|
+
|
|
53
|
+
export function languageFromPath(path: string): string {
|
|
54
|
+
if (!path) return 'plaintext'
|
|
55
|
+
const dot = path.lastIndexOf('.')
|
|
56
|
+
if (dot < 0 || dot === path.length - 1) return 'plaintext'
|
|
57
|
+
const ext = path.slice(dot + 1).toLowerCase()
|
|
58
|
+
return LANG_BY_EXT[ext] ?? 'plaintext'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let cached: Promise<Highlighter> | null = null
|
|
62
|
+
|
|
63
|
+
export function getHighlighter(): Promise<Highlighter> {
|
|
64
|
+
if (!cached) {
|
|
65
|
+
cached = createHighlighter({
|
|
66
|
+
themes: [THEMES.light, THEMES.dark],
|
|
67
|
+
langs: REGISTERED_LANGS,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
return cached
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function _resetHighlighterForTests(): void {
|
|
74
|
+
cached = null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function highlightCodeBlock(highlighter: Highlighter, code: string, lang: string): string {
|
|
78
|
+
const safeLang = REGISTERED_LANGS.includes(lang) ? lang : 'plaintext'
|
|
79
|
+
return highlighter.codeToHtml(code, {
|
|
80
|
+
lang: safeLang,
|
|
81
|
+
themes: THEMES,
|
|
82
|
+
defaultColor: 'light',
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function highlightDiffSide(highlighter: Highlighter, code: string, lang: string): string[] {
|
|
87
|
+
const safeLang = REGISTERED_LANGS.includes(lang) ? lang : 'plaintext'
|
|
88
|
+
const html = highlighter.codeToHtml(code, {
|
|
89
|
+
lang: safeLang,
|
|
90
|
+
themes: THEMES,
|
|
91
|
+
defaultColor: 'light',
|
|
92
|
+
})
|
|
93
|
+
return extractShikiLines(html)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Shiki wraps each line in <span class="line">...</span>. Each such outer span
|
|
97
|
+
// sits on one text line in the rendered HTML, but the first and last lines also
|
|
98
|
+
// carry the <pre>/<code> wrappers. We use a per-text-line greedy match (no `s`
|
|
99
|
+
// flag, so `.` stops at newlines) which correctly captures all inner spans while
|
|
100
|
+
// treating the last </span> on that text line as the outer closer.
|
|
101
|
+
// Empty input produces a single empty-span line which we normalise to [].
|
|
102
|
+
export function extractShikiLines(shikiHtml: string): string[] {
|
|
103
|
+
const results: string[] = []
|
|
104
|
+
for (const textLine of shikiHtml.split('\n')) {
|
|
105
|
+
const match = textLine.match(/<span class="line"[^>]*>(.*)<\/span>/)
|
|
106
|
+
if (match) {
|
|
107
|
+
results.push(match[1] ?? '')
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Shiki always emits at least one line span even for empty input; collapse that.
|
|
111
|
+
if (results.length === 1 && results[0] === '') return []
|
|
112
|
+
return results
|
|
113
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { Highlighter } from 'shiki'
|
|
1
2
|
import { parseFindingDescription } from './finding-description.ts'
|
|
3
|
+
import { highlightCodeBlock, languageFromPath } from './highlight.ts'
|
|
2
4
|
import { isSuggestion, type ReviewFinding } from './types.ts'
|
|
3
5
|
|
|
4
6
|
const DOMAIN_LABELS: Record<string, string> = {
|
|
@@ -35,24 +37,44 @@ export type RenderAnnotationOptions = {
|
|
|
35
37
|
asCard: boolean
|
|
36
38
|
/** When posting previously failed, surface the failure on the card. */
|
|
37
39
|
failed?: { message: string }
|
|
40
|
+
highlighter: Highlighter
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function renderInline(body: string): string {
|
|
44
|
+
// Backtick-delimited spans become inline <code> elements; everything else
|
|
45
|
+
// remains escaped text. Splitting on `([^`]+)` puts plain text at even
|
|
46
|
+
// indices and backtick contents at odd indices.
|
|
47
|
+
const parts = body.split(/`([^`]+)`/g)
|
|
48
|
+
return parts
|
|
49
|
+
.map((part, i) => {
|
|
50
|
+
const escaped = esc(part)
|
|
51
|
+
return i % 2 === 1 ? `<code class="inline-code">${escaped}</code>` : escaped
|
|
52
|
+
})
|
|
53
|
+
.join('')
|
|
38
54
|
}
|
|
39
55
|
|
|
40
56
|
function renderSections(description: string): string {
|
|
41
57
|
const sections = parseFindingDescription(description)
|
|
42
58
|
if (sections.length === 0) {
|
|
43
|
-
return `<section class="section"><div class="section-label">Observation</div><div class="section-body">${
|
|
59
|
+
return `<section class="section"><div class="section-label">Observation</div><div class="section-body">${renderInline(description.trim())}</div></section>`
|
|
44
60
|
}
|
|
45
61
|
return sections
|
|
46
62
|
.map(
|
|
47
63
|
(s) =>
|
|
48
|
-
`<section class="section"><div class="section-label">${esc(s.label)}</div><div class="section-body">${
|
|
64
|
+
`<section class="section"><div class="section-label">${esc(s.label)}</div><div class="section-body">${renderInline(s.body)}</div></section>`,
|
|
49
65
|
)
|
|
50
66
|
.join('')
|
|
51
67
|
}
|
|
52
68
|
|
|
53
|
-
function renderSuggestion(f: ReviewFinding): string {
|
|
69
|
+
function renderSuggestion(f: ReviewFinding, highlighter: Highlighter): string {
|
|
54
70
|
if (!f.suggestion) return ''
|
|
55
|
-
|
|
71
|
+
const lang = languageFromPath(f.file)
|
|
72
|
+
const shiki = highlightCodeBlock(highlighter, f.suggestion.body, lang)
|
|
73
|
+
// Shiki emits `<pre class="shiki ..."><code>...</code></pre>`. Layer our
|
|
74
|
+
// existing `.suggestion` class on top so the border/padding/font-size
|
|
75
|
+
// styling continues to apply.
|
|
76
|
+
const withClass = shiki.replace('class="shiki', 'class="suggestion shiki')
|
|
77
|
+
return `<section class="section"><div class="section-label">Suggested change</div>${withClass}</section>`
|
|
56
78
|
}
|
|
57
79
|
|
|
58
80
|
function renderRisk(f: ReviewFinding): string {
|
|
@@ -87,7 +109,7 @@ export function renderAnnotation(f: ReviewFinding, opts: RenderAnnotationOptions
|
|
|
87
109
|
${statusChip}
|
|
88
110
|
</div>
|
|
89
111
|
${renderSections(f.description)}
|
|
90
|
-
${renderSuggestion(f)}
|
|
112
|
+
${renderSuggestion(f, opts.highlighter)}
|
|
91
113
|
${renderRisk(f)}
|
|
92
114
|
</div>
|
|
93
115
|
<button type="button" class="send-btn" data-action="post-one" data-finding-id="${esc(f.id)}" title="Post this finding" aria-label="post">▸</button>
|