@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
|
@@ -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
|
+
}
|
|
@@ -116,10 +116,57 @@ function buildRiskParts(f: ReviewFinding): string[] {
|
|
|
116
116
|
]
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
function
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
119
|
+
function maxBacktickRun(s: string): number {
|
|
120
|
+
let max = 0
|
|
121
|
+
for (const m of s.matchAll(/`+/g)) {
|
|
122
|
+
if (m[0].length > max) max = m[0].length
|
|
123
|
+
}
|
|
124
|
+
return max
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Pull a single fenced code block out of a suggestion body, returning its code
|
|
129
|
+
* plus any surrounding prose. LLMs sometimes wrap the real replacement in
|
|
130
|
+
* ```lang ... ``` with explanatory text around it; if that nested fence leaks
|
|
131
|
+
* into the outer ```suggestion``` wrapper it breaks GitHub's renderer and
|
|
132
|
+
* causes downstream markdown (e.g. the magpie:finding HTML marker) to spill
|
|
133
|
+
* into a stray code block. We hoist the inner code out so the suggestion body
|
|
134
|
+
* contains only what should be committed.
|
|
135
|
+
*/
|
|
136
|
+
function splitEmbeddedFence(
|
|
137
|
+
body: string,
|
|
138
|
+
): { code: string; preamble: string; postamble: string } | null {
|
|
139
|
+
const re = /(?:^|\n)(`{3,})[^\n`]*\n([\s\S]*?)\n\1(?=\n|$)/
|
|
140
|
+
const match = body.match(re)
|
|
141
|
+
if (match?.index === undefined) return null
|
|
142
|
+
const code = match[2] ?? ''
|
|
143
|
+
const matchStart = match.index + (body[match.index] === '\n' ? 1 : 0)
|
|
144
|
+
const matchEnd = matchStart + match[0].length - (body[match.index] === '\n' ? 1 : 0)
|
|
145
|
+
return {
|
|
146
|
+
code,
|
|
147
|
+
preamble: body.slice(0, matchStart).trim(),
|
|
148
|
+
postamble: body.slice(matchEnd).trim(),
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
type SuggestionParts = {
|
|
153
|
+
block: string | null
|
|
154
|
+
preamble: string | null
|
|
155
|
+
postamble: string | null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function buildSuggestion(f: ReviewFinding): SuggestionParts {
|
|
159
|
+
const raw = f.suggestion?.body.trim()
|
|
160
|
+
if (!raw) return { block: null, preamble: null, postamble: null }
|
|
161
|
+
const split = splitEmbeddedFence(raw)
|
|
162
|
+
const code = split?.code ?? raw
|
|
163
|
+
const fenceLen = Math.max(3, maxBacktickRun(code) + 1)
|
|
164
|
+
const fence = '`'.repeat(fenceLen)
|
|
165
|
+
return {
|
|
166
|
+
block: `${fence}suggestion\n${code}\n${fence}`,
|
|
167
|
+
preamble: split?.preamble || null,
|
|
168
|
+
postamble: split?.postamble || null,
|
|
169
|
+
}
|
|
123
170
|
}
|
|
124
171
|
|
|
125
172
|
function buildFindingMarker(f: ReviewFinding): string {
|
|
@@ -150,7 +197,7 @@ export function formatInlineBody(f: ReviewFinding): string {
|
|
|
150
197
|
const label = SEVERITY_LABEL[f.severity]
|
|
151
198
|
const focus = formatFocus(f)
|
|
152
199
|
const metaLine = buildMetaLine([...buildRiskParts(f), focus ? `Focus · ${focus}` : null])
|
|
153
|
-
const suggestion =
|
|
200
|
+
const { block: suggestion, preamble: sugPre, postamble: sugPost } = buildSuggestion(f)
|
|
154
201
|
const sections = formatFindingDescriptionMarkdown(f.description)
|
|
155
202
|
|
|
156
203
|
return joinBlock([
|
|
@@ -159,8 +206,12 @@ export function formatInlineBody(f: ReviewFinding): string {
|
|
|
159
206
|
metaLine,
|
|
160
207
|
'',
|
|
161
208
|
sections,
|
|
209
|
+
sugPre ? '' : null,
|
|
210
|
+
sugPre,
|
|
162
211
|
suggestion ? '' : null,
|
|
163
212
|
suggestion,
|
|
213
|
+
sugPost ? '' : null,
|
|
214
|
+
sugPost,
|
|
164
215
|
'',
|
|
165
216
|
buildFindingMarker(f),
|
|
166
217
|
])
|
|
@@ -181,7 +232,7 @@ export function formatConversationBody(f: ReviewFinding): string {
|
|
|
181
232
|
...buildRiskParts(f),
|
|
182
233
|
])
|
|
183
234
|
const sections = formatFindingDescriptionMarkdown(f.description)
|
|
184
|
-
const suggestion =
|
|
235
|
+
const { block: suggestion, preamble: sugPre, postamble: sugPost } = buildSuggestion(f)
|
|
185
236
|
|
|
186
237
|
return joinBlock([
|
|
187
238
|
`### ${icon} ${label}: ${f.title}`,
|
|
@@ -189,8 +240,12 @@ export function formatConversationBody(f: ReviewFinding): string {
|
|
|
189
240
|
metaLine,
|
|
190
241
|
'',
|
|
191
242
|
sections,
|
|
243
|
+
sugPre ? '' : null,
|
|
244
|
+
sugPre,
|
|
192
245
|
suggestion ? '' : null,
|
|
193
246
|
suggestion,
|
|
247
|
+
sugPost ? '' : null,
|
|
248
|
+
sugPost,
|
|
194
249
|
'',
|
|
195
250
|
buildFindingMarker(f),
|
|
196
251
|
])
|
|
@@ -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>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { Highlighter } from 'shiki'
|
|
1
2
|
import { buildPairedLines, type DiffHunk, type DiffLine, type PairedLine } from './diff-utils.ts'
|
|
3
|
+
import { highlightDiffSide, languageFromPath } from './highlight.ts'
|
|
2
4
|
import { renderAnnotation } from './render-annotation.ts'
|
|
3
5
|
import type { PostStatusEntry, PostStatusMap, ReviewFinding } from './types.ts'
|
|
4
6
|
|
|
@@ -20,6 +22,9 @@ export type RenderDiffInput = {
|
|
|
20
22
|
findings: ReviewFinding[]
|
|
21
23
|
postStatus: PostStatusMap
|
|
22
24
|
selectedIds: Set<string>
|
|
25
|
+
highlighter: Highlighter
|
|
26
|
+
/** File path used to detect language for highlighting. */
|
|
27
|
+
file: string
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
function isPosted(status: PostStatusEntry | undefined): boolean {
|
|
@@ -33,11 +38,11 @@ function failedFrom(status: PostStatusEntry | undefined): { message: string } |
|
|
|
33
38
|
return undefined
|
|
34
39
|
}
|
|
35
40
|
|
|
36
|
-
function
|
|
41
|
+
function coloredLineRow(line: DiffLine, coloredContent: string): string {
|
|
37
42
|
const cls = line.type === 'added' ? 'added' : line.type === 'removed' ? 'removed' : 'context'
|
|
38
43
|
const lineNo = line.type === 'removed' ? line.oldLineNo : line.newLineNo
|
|
39
44
|
const sign = line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '
|
|
40
|
-
return `<div class="diff-row ${cls}"><span class="gutter">${lineNo ?? ''}</span><span class="sign">${sign}</span><span class="content">${
|
|
45
|
+
return `<div class="diff-row ${cls}"><span class="gutter">${lineNo ?? ''}</span><span class="sign">${sign}</span><span class="content">${coloredContent}</span></div>`
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
function findingsByLine(findings: ReviewFinding[]): Map<number, ReviewFinding[]> {
|
|
@@ -52,14 +57,43 @@ function findingsByLine(findings: ReviewFinding[]): Map<number, ReviewFinding[]>
|
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
export function renderUnifiedDiff(input: RenderDiffInput): string {
|
|
55
|
-
const { hunks, findings, postStatus, selectedIds } = input
|
|
60
|
+
const { hunks, findings, postStatus, selectedIds, highlighter, file } = input
|
|
56
61
|
if (hunks.length === 0) return `<div class="diff-empty">No changes</div>`
|
|
57
62
|
const byLine = findingsByLine(findings)
|
|
63
|
+
const lang = languageFromPath(file)
|
|
58
64
|
const parts: string[] = []
|
|
59
65
|
hunks.forEach((h, hi) => {
|
|
60
66
|
if (hi > 0) parts.push(`<div class="diff-hunk-sep">⋯</div>`)
|
|
67
|
+
|
|
68
|
+
const oldLines: string[] = []
|
|
69
|
+
const newLines: string[] = []
|
|
70
|
+
const rowSide: Array<{ side: 'old' | 'new'; idx: number }> = []
|
|
61
71
|
for (const line of h.lines) {
|
|
62
|
-
|
|
72
|
+
if (line.type === 'removed') {
|
|
73
|
+
rowSide.push({ side: 'old', idx: oldLines.length })
|
|
74
|
+
oldLines.push(line.content)
|
|
75
|
+
} else if (line.type === 'added') {
|
|
76
|
+
rowSide.push({ side: 'new', idx: newLines.length })
|
|
77
|
+
newLines.push(line.content)
|
|
78
|
+
} else {
|
|
79
|
+
rowSide.push({ side: 'new', idx: newLines.length })
|
|
80
|
+
oldLines.push(line.content)
|
|
81
|
+
newLines.push(line.content)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const oldHighlighted = oldLines.length
|
|
85
|
+
? highlightDiffSide(highlighter, oldLines.join('\n'), lang)
|
|
86
|
+
: []
|
|
87
|
+
const newHighlighted = newLines.length
|
|
88
|
+
? highlightDiffSide(highlighter, newLines.join('\n'), lang)
|
|
89
|
+
: []
|
|
90
|
+
|
|
91
|
+
h.lines.forEach((line, i) => {
|
|
92
|
+
const ref = rowSide[i]
|
|
93
|
+
const colored =
|
|
94
|
+
(ref?.side === 'old' ? oldHighlighted[ref.idx] : newHighlighted[ref?.idx ?? 0]) ??
|
|
95
|
+
esc(line.content)
|
|
96
|
+
parts.push(coloredLineRow(line, colored))
|
|
63
97
|
const targetLine = line.newLineNo ?? null
|
|
64
98
|
if (targetLine != null) {
|
|
65
99
|
const fs = byLine.get(targetLine)
|
|
@@ -67,6 +101,7 @@ export function renderUnifiedDiff(input: RenderDiffInput): string {
|
|
|
67
101
|
for (const f of fs) {
|
|
68
102
|
parts.push(
|
|
69
103
|
renderAnnotation(f, {
|
|
104
|
+
highlighter,
|
|
70
105
|
checked: selectedIds.has(f.id),
|
|
71
106
|
posted: isPosted(postStatus[f.id]),
|
|
72
107
|
failed: failedFrom(postStatus[f.id]),
|
|
@@ -76,33 +111,44 @@ export function renderUnifiedDiff(input: RenderDiffInput): string {
|
|
|
76
111
|
}
|
|
77
112
|
}
|
|
78
113
|
}
|
|
79
|
-
}
|
|
114
|
+
})
|
|
80
115
|
})
|
|
81
116
|
return parts.join('\n')
|
|
82
117
|
}
|
|
83
118
|
|
|
84
|
-
function pairedRow(pair: PairedLine): string {
|
|
85
|
-
const cellL =
|
|
86
|
-
pair.left == null
|
|
87
|
-
? `<div class="diff-cell empty"></div>`
|
|
88
|
-
: `<div class="diff-cell ${pair.left.type}"><span class="gutter">${pair.left.oldLineNo ?? pair.left.newLineNo ?? ''}</span><span class="sign">${pair.left.type === 'removed' ? '-' : ' '}</span><span class="content">${esc(pair.left.content)}</span></div>`
|
|
89
|
-
const cellR =
|
|
90
|
-
pair.right == null
|
|
91
|
-
? `<div class="diff-cell empty"></div>`
|
|
92
|
-
: `<div class="diff-cell ${pair.right.type}"><span class="gutter">${pair.right.newLineNo ?? pair.right.oldLineNo ?? ''}</span><span class="sign">${pair.right.type === 'added' ? '+' : ' '}</span><span class="content">${esc(pair.right.content)}</span></div>`
|
|
93
|
-
return `<div class="diff-row split">${cellL}${cellR}</div>`
|
|
94
|
-
}
|
|
95
|
-
|
|
96
119
|
export function renderSplitDiff(input: RenderDiffInput): string {
|
|
97
|
-
const { hunks, findings, postStatus, selectedIds } = input
|
|
120
|
+
const { hunks, findings, postStatus, selectedIds, highlighter, file } = input
|
|
98
121
|
if (hunks.length === 0) return `<div class="diff-empty">No changes</div>`
|
|
99
122
|
const byLine = findingsByLine(findings)
|
|
123
|
+
const lang = languageFromPath(file)
|
|
100
124
|
const parts: string[] = []
|
|
101
125
|
hunks.forEach((h, hi) => {
|
|
102
126
|
if (hi > 0) parts.push(`<div class="diff-hunk-sep">⋯</div>`)
|
|
127
|
+
|
|
128
|
+
const leftLines: string[] = []
|
|
129
|
+
const rightLines: string[] = []
|
|
130
|
+
for (const line of h.lines) {
|
|
131
|
+
if (line.type === 'removed') leftLines.push(line.content)
|
|
132
|
+
else if (line.type === 'added') rightLines.push(line.content)
|
|
133
|
+
else {
|
|
134
|
+
leftLines.push(line.content)
|
|
135
|
+
rightLines.push(line.content)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const leftHighlighted = leftLines.length
|
|
139
|
+
? highlightDiffSide(highlighter, leftLines.join('\n'), lang)
|
|
140
|
+
: []
|
|
141
|
+
const rightHighlighted = rightLines.length
|
|
142
|
+
? highlightDiffSide(highlighter, rightLines.join('\n'), lang)
|
|
143
|
+
: []
|
|
144
|
+
|
|
103
145
|
const pairs = buildPairedLines([h])
|
|
146
|
+
let li = 0
|
|
147
|
+
let ri = 0
|
|
104
148
|
for (const pair of pairs) {
|
|
105
|
-
|
|
149
|
+
const leftColored = pair.left ? (leftHighlighted[li++] ?? esc(pair.left.content)) : null
|
|
150
|
+
const rightColored = pair.right ? (rightHighlighted[ri++] ?? esc(pair.right.content)) : null
|
|
151
|
+
parts.push(coloredPairedRow(pair, leftColored, rightColored))
|
|
106
152
|
const target = pair.right?.newLineNo ?? null
|
|
107
153
|
if (target != null) {
|
|
108
154
|
const fs = byLine.get(target)
|
|
@@ -110,6 +156,7 @@ export function renderSplitDiff(input: RenderDiffInput): string {
|
|
|
110
156
|
for (const f of fs) {
|
|
111
157
|
parts.push(
|
|
112
158
|
renderAnnotation(f, {
|
|
159
|
+
highlighter,
|
|
113
160
|
checked: selectedIds.has(f.id),
|
|
114
161
|
posted: isPosted(postStatus[f.id]),
|
|
115
162
|
failed: failedFrom(postStatus[f.id]),
|
|
@@ -123,3 +170,19 @@ export function renderSplitDiff(input: RenderDiffInput): string {
|
|
|
123
170
|
})
|
|
124
171
|
return parts.join('\n')
|
|
125
172
|
}
|
|
173
|
+
|
|
174
|
+
function coloredPairedRow(
|
|
175
|
+
pair: PairedLine,
|
|
176
|
+
leftColored: string | null,
|
|
177
|
+
rightColored: string | null,
|
|
178
|
+
): string {
|
|
179
|
+
const cellL =
|
|
180
|
+
pair.left == null || leftColored == null
|
|
181
|
+
? `<div class="diff-cell empty"></div>`
|
|
182
|
+
: `<div class="diff-cell ${pair.left.type}"><span class="gutter">${pair.left.oldLineNo ?? pair.left.newLineNo ?? ''}</span><span class="sign">${pair.left.type === 'removed' ? '-' : ' '}</span><span class="content">${leftColored}</span></div>`
|
|
183
|
+
const cellR =
|
|
184
|
+
pair.right == null || rightColored == null
|
|
185
|
+
? `<div class="diff-cell empty"></div>`
|
|
186
|
+
: `<div class="diff-cell ${pair.right.type}"><span class="gutter">${pair.right.newLineNo ?? pair.right.oldLineNo ?? ''}</span><span class="sign">${pair.right.type === 'added' ? '+' : ' '}</span><span class="content">${rightColored}</span></div>`
|
|
187
|
+
return `<div class="diff-row split">${cellL}${cellR}</div>`
|
|
188
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
2
|
import { basename } from 'node:path'
|
|
3
|
+
import type { Highlighter } from 'shiki'
|
|
3
4
|
import { parseUnifiedDiffToHunks, splitDiffByFile } from './diff-utils.ts'
|
|
5
|
+
import { getHighlighter } from './highlight.ts'
|
|
4
6
|
import { renderActionBar } from './render-action-bar.ts'
|
|
5
7
|
import { renderSplitDiff, renderUnifiedDiff } from './render-diff.ts'
|
|
6
8
|
import { renderFileTree } from './render-file-tree.ts'
|
|
@@ -26,6 +28,8 @@ export type RenderFindingsInput = {
|
|
|
26
28
|
files?: PrFileEntry[]
|
|
27
29
|
/** Raw unified diff text for the PR. */
|
|
28
30
|
diff?: string
|
|
31
|
+
/** Shiki highlighter, prepared by the caller. */
|
|
32
|
+
highlighter: Highlighter
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
function esc(s: string): string {
|
|
@@ -78,6 +82,7 @@ function filePane(opts: {
|
|
|
78
82
|
findings: ReviewFinding[]
|
|
79
83
|
postStatus: PostStatusMap
|
|
80
84
|
selectedIds: Set<string>
|
|
85
|
+
highlighter: Highlighter
|
|
81
86
|
}): string {
|
|
82
87
|
const hunks = parseUnifiedDiffToHunks(opts.fileDiff)
|
|
83
88
|
const unified = renderUnifiedDiff({
|
|
@@ -85,12 +90,16 @@ function filePane(opts: {
|
|
|
85
90
|
findings: opts.findings,
|
|
86
91
|
postStatus: opts.postStatus,
|
|
87
92
|
selectedIds: opts.selectedIds,
|
|
93
|
+
highlighter: opts.highlighter,
|
|
94
|
+
file: opts.file.path,
|
|
88
95
|
})
|
|
89
96
|
const split = renderSplitDiff({
|
|
90
97
|
hunks,
|
|
91
98
|
findings: opts.findings,
|
|
92
99
|
postStatus: opts.postStatus,
|
|
93
100
|
selectedIds: opts.selectedIds,
|
|
101
|
+
highlighter: opts.highlighter,
|
|
102
|
+
file: opts.file.path,
|
|
94
103
|
})
|
|
95
104
|
const findingsCount = opts.findings.length
|
|
96
105
|
// Findings can't be placed inline when (a) the file isn't in the diff at all
|
|
@@ -108,7 +117,7 @@ function filePane(opts: {
|
|
|
108
117
|
<span class="unplaced-banner-label">${unplaced.length} finding${unplaced.length === 1 ? '' : 's'} not anchored to the diff</span>
|
|
109
118
|
<span class="unplaced-banner-hint">${hunks.length === 0 ? 'this file is not in the PR diff snapshot' : 'line falls outside the visible hunks'}</span>
|
|
110
119
|
</div>
|
|
111
|
-
${renderIssuesList({ findings: unplaced, postStatus: opts.postStatus, selectedIds: opts.selectedIds })}
|
|
120
|
+
${renderIssuesList({ findings: unplaced, postStatus: opts.postStatus, selectedIds: opts.selectedIds, highlighter: opts.highlighter })}
|
|
112
121
|
</div>`
|
|
113
122
|
: ''
|
|
114
123
|
return `<section class="file-pane" data-file-pane="${esc(opts.file.path)}" hidden>
|
|
@@ -135,6 +144,7 @@ function overviewPane(opts: {
|
|
|
135
144
|
findings: ReviewFinding[]
|
|
136
145
|
postStatus: PostStatusMap
|
|
137
146
|
selectedIds: Set<string>
|
|
147
|
+
highlighter: Highlighter
|
|
138
148
|
}): string {
|
|
139
149
|
const general = opts.findings.filter((f) => !f.file)
|
|
140
150
|
if (general.length === 0) {
|
|
@@ -143,7 +153,7 @@ function overviewPane(opts: {
|
|
|
143
153
|
</section>`
|
|
144
154
|
}
|
|
145
155
|
return `<section class="overview-pane" data-file-pane="">
|
|
146
|
-
${renderIssuesList({ findings: general, postStatus: opts.postStatus, selectedIds: opts.selectedIds })}
|
|
156
|
+
${renderIssuesList({ findings: general, postStatus: opts.postStatus, selectedIds: opts.selectedIds, highlighter: opts.highlighter })}
|
|
147
157
|
</section>`
|
|
148
158
|
}
|
|
149
159
|
|
|
@@ -183,6 +193,7 @@ ${prHeader(input)}
|
|
|
183
193
|
findings: findingsInFile,
|
|
184
194
|
postStatus: input.postStatus,
|
|
185
195
|
selectedIds,
|
|
196
|
+
highlighter: input.highlighter,
|
|
186
197
|
})
|
|
187
198
|
})
|
|
188
199
|
.join('\n')
|
|
@@ -191,12 +202,14 @@ ${prHeader(input)}
|
|
|
191
202
|
findings: input.findings,
|
|
192
203
|
postStatus: input.postStatus,
|
|
193
204
|
selectedIds,
|
|
205
|
+
highlighter: input.highlighter,
|
|
194
206
|
})
|
|
195
207
|
const tree = renderFileTree({ files, findings: input.findings })
|
|
196
208
|
const issues = renderIssuesList({
|
|
197
209
|
findings: input.findings,
|
|
198
210
|
postStatus: input.postStatus,
|
|
199
211
|
selectedIds,
|
|
212
|
+
highlighter: input.highlighter,
|
|
200
213
|
})
|
|
201
214
|
const actionBar = renderActionBar({ findings: input.findings })
|
|
202
215
|
|
|
@@ -229,15 +242,16 @@ ${actionBar}
|
|
|
229
242
|
}
|
|
230
243
|
|
|
231
244
|
export async function renderFindingsToDisk(
|
|
232
|
-
input: RenderFindingsInput,
|
|
245
|
+
input: Omit<RenderFindingsInput, 'highlighter'>,
|
|
233
246
|
outPath: string,
|
|
234
247
|
): Promise<void> {
|
|
235
248
|
const stylesPath = new URL('../templates/styles.css', import.meta.url).pathname
|
|
236
249
|
const helperPath = new URL('./helper.js', import.meta.url).pathname
|
|
237
250
|
const css = await readFile(stylesPath, 'utf8')
|
|
238
251
|
const helper = await readFile(helperPath, 'utf8')
|
|
252
|
+
const highlighter = await getHighlighter()
|
|
239
253
|
const derived = basename(outPath.replace(/\/screen\/.+$/, ''))
|
|
240
|
-
const html = renderFindingsHtml({ ...input, runId: input.runId ?? derived })
|
|
254
|
+
const html = renderFindingsHtml({ ...input, runId: input.runId ?? derived, highlighter })
|
|
241
255
|
.replace(
|
|
242
256
|
'data:text/css;base64,STYLES_INLINE',
|
|
243
257
|
`data:text/css;base64,${Buffer.from(css).toString('base64')}`,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Highlighter } from 'shiki'
|
|
1
2
|
import { renderAnnotation } from './render-annotation.ts'
|
|
2
3
|
import type { PostStatusMap } from './types.ts'
|
|
3
4
|
import { isSuggestion, type ReviewFinding, SEVERITIES, type Severity } from './types.ts'
|
|
@@ -26,6 +27,7 @@ export type RenderIssuesListInput = {
|
|
|
26
27
|
findings: ReviewFinding[]
|
|
27
28
|
postStatus: PostStatusMap
|
|
28
29
|
selectedIds: Set<string>
|
|
30
|
+
highlighter: Highlighter
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
function severityCounts(findings: ReviewFinding[]): Map<Severity, number> {
|
|
@@ -35,7 +37,7 @@ function severityCounts(findings: ReviewFinding[]): Map<Severity, number> {
|
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
export function renderIssuesList(input: RenderIssuesListInput): string {
|
|
38
|
-
const { findings, postStatus, selectedIds } = input
|
|
40
|
+
const { findings, postStatus, selectedIds, highlighter } = input
|
|
39
41
|
const actionable = findings.filter((f) => !isSuggestion(f))
|
|
40
42
|
const suggestions = findings.filter((f) => isSuggestion(f))
|
|
41
43
|
const counts = severityCounts(findings)
|
|
@@ -61,6 +63,7 @@ export function renderIssuesList(input: RenderIssuesListInput): string {
|
|
|
61
63
|
posted: status === 'posted',
|
|
62
64
|
failed,
|
|
63
65
|
asCard: true,
|
|
66
|
+
highlighter,
|
|
64
67
|
})
|
|
65
68
|
})
|
|
66
69
|
.join('\n')
|