@iceinvein/agent-skills 0.1.28 → 0.1.30

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.
@@ -0,0 +1,150 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+
5
+ export type PathFilterConfig = {
6
+ exclude: string[]
7
+ include: string[]
8
+ useDefaults: boolean
9
+ }
10
+
11
+ export const DEFAULT_EXCLUDES: readonly string[] = [
12
+ // Lockfiles
13
+ '**/package-lock.json',
14
+ '**/yarn.lock',
15
+ '**/pnpm-lock.yaml',
16
+ '**/bun.lock',
17
+ '**/bun.lockb',
18
+ '**/Cargo.lock',
19
+ '**/Pipfile.lock',
20
+ '**/poetry.lock',
21
+ '**/composer.lock',
22
+ '**/Gemfile.lock',
23
+ '**/uv.lock',
24
+ // Build / vendored / dependency dirs
25
+ 'dist/**',
26
+ 'build/**',
27
+ 'out/**',
28
+ '.next/**',
29
+ '.nuxt/**',
30
+ '.svelte-kit/**',
31
+ '.turbo/**',
32
+ '.cache/**',
33
+ 'coverage/**',
34
+ 'node_modules/**',
35
+ 'vendor/**',
36
+ // Generated source
37
+ '**/*.generated.*',
38
+ '**/*.gen.*',
39
+ '**/*.pb.go',
40
+ '**/*.pb.ts',
41
+ '**/*.pb.js',
42
+ '**/*_pb2.py',
43
+ '**/*.min.js',
44
+ '**/*.min.css',
45
+ '**/*.map',
46
+ // Snapshot fixtures
47
+ '**/*.snap',
48
+ '**/__snapshots__/**',
49
+ '**/.snapshots/**',
50
+ ]
51
+
52
+ export function globToRegex(pattern: string): RegExp {
53
+ let re = ''
54
+ let i = 0
55
+ while (i < pattern.length) {
56
+ const ch = pattern[i]
57
+ if (ch === '*') {
58
+ if (pattern[i + 1] === '*') {
59
+ re += '.*'
60
+ i += 2
61
+ if (pattern[i] === '/') i += 1
62
+ } else {
63
+ re += '[^/]*'
64
+ i += 1
65
+ }
66
+ } else if (ch === '?') {
67
+ re += '[^/]'
68
+ i += 1
69
+ } else if (ch && /[.+^$()|[\]\\{}]/.test(ch)) {
70
+ re += `\\${ch}`
71
+ i += 1
72
+ } else {
73
+ re += ch ?? ''
74
+ i += 1
75
+ }
76
+ }
77
+ return new RegExp(`^${re}$`)
78
+ }
79
+
80
+ export function matchesAny(path: string, patterns: readonly string[]): string | null {
81
+ for (const pat of patterns) {
82
+ if (globToRegex(pat).test(path)) return pat
83
+ }
84
+ return null
85
+ }
86
+
87
+ export async function loadPathFilterConfig(cwd: string): Promise<PathFilterConfig> {
88
+ const cfgPath = join(cwd, '.magpie.json')
89
+ if (!existsSync(cfgPath)) {
90
+ return { exclude: [...DEFAULT_EXCLUDES], include: [], useDefaults: true }
91
+ }
92
+ let parsed: unknown
93
+ try {
94
+ parsed = JSON.parse(await readFile(cfgPath, 'utf8'))
95
+ } catch {
96
+ return { exclude: [...DEFAULT_EXCLUDES], include: [], useDefaults: true }
97
+ }
98
+ const obj = (parsed ?? {}) as Record<string, unknown>
99
+ const userExclude = Array.isArray(obj.exclude)
100
+ ? obj.exclude.filter((s): s is string => typeof s === 'string')
101
+ : []
102
+ const userInclude = Array.isArray(obj.include)
103
+ ? obj.include.filter((s): s is string => typeof s === 'string')
104
+ : []
105
+ const useDefaults = obj.useDefaults !== false
106
+ return {
107
+ exclude: useDefaults ? [...DEFAULT_EXCLUDES, ...userExclude] : userExclude,
108
+ include: userInclude,
109
+ useDefaults,
110
+ }
111
+ }
112
+
113
+ export type ExcludedFile = { path: string; pattern: string }
114
+ export type FilterDiffResult = { filtered: string; excluded: ExcludedFile[] }
115
+
116
+ const FILE_HEADER = /^diff --git a\/(.+?) b\/(.+?)$/m
117
+
118
+ export function filterDiff(rawDiff: string, config: PathFilterConfig): FilterDiffResult {
119
+ if (!rawDiff) return { filtered: '', excluded: [] }
120
+ const chunks = rawDiff.split(/^(?=diff --git )/m)
121
+ const kept: string[] = []
122
+ const excluded: ExcludedFile[] = []
123
+ for (const chunk of chunks) {
124
+ if (!chunk.startsWith('diff --git ')) {
125
+ if (chunk.trim()) kept.push(chunk)
126
+ continue
127
+ }
128
+ const m = chunk.match(FILE_HEADER)
129
+ const path = m?.[2] ?? m?.[1]
130
+ if (!path) {
131
+ kept.push(chunk)
132
+ continue
133
+ }
134
+ if (config.include.length > 0) {
135
+ if (matchesAny(path, config.include)) {
136
+ kept.push(chunk)
137
+ continue
138
+ }
139
+ excluded.push({ path, pattern: 'not-in-include' })
140
+ continue
141
+ }
142
+ const hit = matchesAny(path, config.exclude)
143
+ if (hit) {
144
+ excluded.push({ path, pattern: hit })
145
+ continue
146
+ }
147
+ kept.push(chunk)
148
+ }
149
+ return { filtered: kept.join(''), excluded }
150
+ }
@@ -3,7 +3,13 @@ import { appendFile, readFile, writeFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
4
  import { parseUnifiedDiffToHunks, splitDiffByFile } from './diff-utils.ts'
5
5
  import { formatFindingDescriptionMarkdown } from './finding-description.ts'
6
- import { type FocusId, parseFinding, type ReviewFinding, type Severity } from './types.ts'
6
+ import {
7
+ type FocusId,
8
+ type PrFileEntry,
9
+ parseFinding,
10
+ type ReviewFinding,
11
+ type Severity,
12
+ } from './types.ts'
7
13
 
8
14
  /**
9
15
  * Build a per-file set of RIGHT-side line numbers that GitHub's PR Reviews API
@@ -37,9 +43,9 @@ export type PostInput = {
37
43
  /**
38
44
  * Whether to also post a top-level review-summary comment before the
39
45
  * per-finding posts.
40
- * - 'auto' (default): post when this batch will newly post 2+ findings and
41
- * no summary has been posted before.
42
- * - 'always': post even for single-finding batches.
46
+ * - 'auto' (default): post when this batch has at least one finding and no
47
+ * summary has been posted before. Always-on UX.
48
+ * - 'always': post even on empty batches (clean-review comment).
43
49
  * - 'never': skip the summary entirely.
44
50
  */
45
51
  includeSummary?: 'auto' | 'always' | 'never'
@@ -87,6 +93,7 @@ const FOCUS_LABEL: Record<FocusId, string> = {
87
93
  performance: 'Performance',
88
94
  'code-smells': 'Code Smells',
89
95
  architecture: 'Architecture',
96
+ tests: 'Tests',
90
97
  }
91
98
 
92
99
  function formatFocus(f: ReviewFinding): string | null {
@@ -213,6 +220,81 @@ function formatLocation(f: ReviewFinding): string {
213
220
  export type ReviewSummaryOptions = {
214
221
  /** Optional commit SHA. When present, surfaces a "Reviewed at <sha>" trailer. */
215
222
  commitId?: string
223
+ /** PR files changed (from pr.json). Used for the walkthrough table and effort score. */
224
+ files?: PrFileEntry[]
225
+ /** Incremental review context. When present, surfaces "since <prev-sha>" trailer. */
226
+ incremental?: { previousSha: string; sameSha: boolean }
227
+ }
228
+
229
+ /** Effort score 1 (trivial) to 5 (substantial), derived from changed files and lines. */
230
+ export function computeEffortScore(files: PrFileEntry[]): number {
231
+ if (files.length === 0) return 1
232
+ const lines = files.reduce((acc, f) => acc + f.additions + f.deletions, 0)
233
+ if (files.length <= 2 && lines <= 50) return 1
234
+ if (files.length <= 5 && lines <= 200) return 2
235
+ if (files.length <= 10 && lines <= 500) return 3
236
+ if (files.length <= 20 && lines <= 1000) return 4
237
+ return 5
238
+ }
239
+
240
+ function renderEffortBar(score: number): string {
241
+ const filled = '●'.repeat(score)
242
+ const empty = '○'.repeat(Math.max(0, 5 - score))
243
+ return `${filled}${empty}`
244
+ }
245
+
246
+ function findingsByFile(findings: ReviewFinding[]): Map<string, ReviewFinding[]> {
247
+ const map = new Map<string, ReviewFinding[]>()
248
+ for (const f of findings) {
249
+ if (!f.file) continue
250
+ const list = map.get(f.file)
251
+ if (list) list.push(f)
252
+ else map.set(f.file, [f])
253
+ }
254
+ return map
255
+ }
256
+
257
+ function renderWalkthroughTable(
258
+ files: PrFileEntry[],
259
+ byFile: Map<string, ReviewFinding[]>,
260
+ ): string[] {
261
+ if (files.length === 0) return []
262
+ const sorted = [...files].sort((a, b) => b.additions + b.deletions - (a.additions + a.deletions))
263
+ const top = sorted.slice(0, 20)
264
+ const lines = [
265
+ '',
266
+ `<details>`,
267
+ `<summary><b>Files changed</b> (${plural(files.length, 'file')}${files.length > top.length ? `, showing top ${top.length}` : ''})</summary>`,
268
+ '',
269
+ '| File | +/- | Findings |',
270
+ '|---|---|---|',
271
+ ]
272
+ for (const f of top) {
273
+ const finds = byFile.get(f.path)?.length ?? 0
274
+ const stats = `+${f.additions} / -${f.deletions}`
275
+ lines.push(`| \`${f.path}\` | ${stats} | ${finds || ''} |`)
276
+ }
277
+ lines.push('', '</details>')
278
+ return lines
279
+ }
280
+
281
+ function renderRiskHotspots(byFile: Map<string, ReviewFinding[]>): string[] {
282
+ const entries = [...byFile.entries()]
283
+ .map(([file, finds]) => ({ file, finds }))
284
+ .filter((e) => e.finds.length >= 2)
285
+ .sort((a, b) => b.finds.length - a.finds.length)
286
+ .slice(0, 3)
287
+ if (entries.length === 0) return []
288
+ const lines = ['', '### Risk hotspots', '']
289
+ for (const e of entries) {
290
+ const counts: Record<Severity, number> = { blocker: 0, high: 0, medium: 0, low: 0 }
291
+ for (const f of e.finds) counts[f.severity] += 1
292
+ const tally = SEVERITY_ORDER.filter((s) => counts[s] > 0)
293
+ .map((s) => `${SEVERITY_ICON[s]} ${counts[s]}`)
294
+ .join(' · ')
295
+ lines.push(`- \`${e.file}\` · ${plural(e.finds.length, 'finding')} · ${tally}`)
296
+ }
297
+ return lines
216
298
  }
217
299
 
218
300
  /**
@@ -233,6 +315,9 @@ export function formatReviewSummaryBody(
233
315
 
234
316
  const fileCount = new Set(inlineFindings.map((f) => f.file)).size
235
317
  const shortSha = options.commitId ? options.commitId.slice(0, 7) : ''
318
+ const files = options.files ?? []
319
+ const effort = computeEffortScore(files)
320
+ const byFile = findingsByFile(findings)
236
321
 
237
322
  let verdict: string
238
323
  if (counts.blocker > 0) {
@@ -251,6 +336,18 @@ export function formatReviewSummaryBody(
251
336
 
252
337
  const lines: string[] = [header]
253
338
 
339
+ if (files.length > 0) {
340
+ lines.push('', `<sub>Review effort · ${renderEffortBar(effort)} (${effort}/5)</sub>`)
341
+ }
342
+
343
+ if (options.incremental) {
344
+ const prevShort = options.incremental.previousSha.slice(0, 7)
345
+ const msg = options.incremental.sameSha
346
+ ? `Re-review (no new commits since \`${prevShort}\`)`
347
+ : `Incremental review since \`${prevShort}\``
348
+ lines.push('', `<sub>${msg}</sub>`)
349
+ }
350
+
254
351
  if (findings.length > 0) {
255
352
  const inlineText =
256
353
  inlineFindings.length > 0
@@ -282,6 +379,9 @@ export function formatReviewSummaryBody(
282
379
  }
283
380
  }
284
381
 
382
+ lines.push(...renderRiskHotspots(byFile))
383
+ lines.push(...renderWalkthroughTable(files, byFile))
384
+
285
385
  if (findings.length > 0) {
286
386
  lines.push(
287
387
  '',
@@ -680,20 +780,44 @@ export async function runPost(input: PostInput): Promise<PostOutcome> {
680
780
 
681
781
  // Decide whether to post the review summary up front. It is one extra
682
782
  // top-level PR comment that gives the conversation a coherent overview
683
- // before the per-finding threads land.
783
+ // before the per-finding threads land. Default 'auto' fires for any
784
+ // non-empty batch; 'always' fires even on empty batches (clean-review).
684
785
  const summaryMode = input.includeSummary ?? 'auto'
685
786
  const selectedFindings = input.findingIds
686
787
  .map((id) => byId.get(id))
687
788
  .filter((f): f is ReviewFinding => Boolean(f))
688
- const pendingCount = selectedFindings.filter((f) => status[f.id] !== 'posted').length
689
789
  const summaryAlreadyPosted = status.__summary__ === 'posted'
690
790
  const shouldPostSummary =
691
791
  !summaryAlreadyPosted &&
692
- selectedFindings.length > 0 &&
693
- (summaryMode === 'always' || (summaryMode === 'auto' && pendingCount >= 2))
792
+ summaryMode !== 'never' &&
793
+ (summaryMode === 'always' || selectedFindings.length >= 1)
794
+
795
+ const prFiles = Array.isArray(prJson.files)
796
+ ? (prJson.files as PrFileEntry[]).filter(
797
+ (f): f is PrFileEntry =>
798
+ typeof f?.path === 'string' &&
799
+ typeof f?.additions === 'number' &&
800
+ typeof f?.deletions === 'number',
801
+ )
802
+ : []
803
+
804
+ let incremental: ReviewSummaryOptions['incremental']
805
+ try {
806
+ const raw = await readFile(join(input.runDir, 'incremental.json'), 'utf8')
807
+ const parsed = JSON.parse(raw) as { previousSha?: string; sameSha?: boolean }
808
+ if (typeof parsed.previousSha === 'string') {
809
+ incremental = { previousSha: parsed.previousSha, sameSha: Boolean(parsed.sameSha) }
810
+ }
811
+ } catch {
812
+ // No incremental sidecar; first run for this PR.
813
+ }
694
814
 
695
815
  if (shouldPostSummary) {
696
- const summaryBody = formatReviewSummaryBody(selectedFindings, { commitId: headSha })
816
+ const summaryBody = formatReviewSummaryBody(selectedFindings, {
817
+ commitId: headSha,
818
+ files: prFiles,
819
+ ...(incremental ? { incremental } : {}),
820
+ })
697
821
  const summaryCmd = buildPrCommentCommand(repo, prNumber, summaryBody)
698
822
  if (input.dryRun) {
699
823
  results.push({ id: '__summary__', status: 'posted', command: summaryCmd })
@@ -0,0 +1,39 @@
1
+ import type { Action, Confidence, Impact, Likelihood, Risk } from './types.ts'
2
+
3
+ const IMPACT_WEIGHT: Record<Impact, number> = {
4
+ critical: 10,
5
+ high: 7,
6
+ medium: 4,
7
+ low: 1,
8
+ }
9
+
10
+ const LIKELIHOOD_WEIGHT: Record<Likelihood, number> = {
11
+ likely: 10,
12
+ possible: 6,
13
+ 'edge-case': 3,
14
+ unknown: 4,
15
+ }
16
+
17
+ const CONFIDENCE_WEIGHT: Record<Confidence, number> = {
18
+ high: 10,
19
+ medium: 5,
20
+ low: 2,
21
+ }
22
+
23
+ const ACTION_WEIGHT: Record<Action, number> = {
24
+ 'must-fix': 10,
25
+ 'should-fix': 7,
26
+ consider: 4,
27
+ optional: 1,
28
+ }
29
+
30
+ export const DEFAULT_THRESHOLD = 3
31
+
32
+ export function scoreRisk(risk: Risk): number {
33
+ const raw =
34
+ IMPACT_WEIGHT[risk.impact] * 0.4 +
35
+ LIKELIHOOD_WEIGHT[risk.likelihood] * 0.25 +
36
+ CONFIDENCE_WEIGHT[risk.confidence] * 0.2 +
37
+ ACTION_WEIGHT[risk.action] * 0.15
38
+ return Math.round(raw * 10) / 10
39
+ }
@@ -1,7 +1,10 @@
1
- import { appendFile, mkdir, rm } from 'node:fs/promises'
1
+ import { appendFile, mkdir, rm, writeFile } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
  import { fetchPr } from './gh.ts'
4
+ import { buildIncrementalContext, findPreviousRun } from './incremental.ts'
5
+ import { filterDiff, loadPathFilterConfig } from './path-filter.ts'
4
6
  import { type Deps, defaultDeps, preflight, renderInstallHint } from './preflight.ts'
7
+ import { detectMissingTests } from './tests-check.ts'
5
8
  import { createWorktree } from './worktree.ts'
6
9
 
7
10
  export type RunSetupInput = {
@@ -45,6 +48,12 @@ export async function runSetup(input: RunSetupInput): Promise<number> {
45
48
  }
46
49
  await logLine(input.runDir, { stage: 'fetch-pr', status: 'done' })
47
50
 
51
+ await applyPathFilter(input.runDir, input.repoPath)
52
+
53
+ await runTestsCheck(input.runDir)
54
+
55
+ await detectIncrementalReview(input.runDir, input.prNumber)
56
+
48
57
  const prJson = (await Bun.file(join(input.runDir, 'pr.json')).json()) as { headRefName: string }
49
58
  const branch = prJson.headRefName
50
59
 
@@ -65,7 +74,69 @@ export async function runSetup(input: RunSetupInput): Promise<number> {
65
74
  }
66
75
 
67
76
  async function cleanup(runDir: string): Promise<void> {
68
- for (const name of ['worktree', 'pr.json', 'diff.patch', 'findings', 'screen', 'state']) {
77
+ for (const name of [
78
+ 'worktree',
79
+ 'pr.json',
80
+ 'diff.patch',
81
+ 'diff.full.patch',
82
+ 'excluded-files.json',
83
+ 'incremental.json',
84
+ 'findings',
85
+ 'screen',
86
+ 'state',
87
+ ]) {
69
88
  await rm(join(runDir, name), { recursive: true, force: true }).catch(() => {})
70
89
  }
71
90
  }
91
+
92
+ async function runTestsCheck(runDir: string): Promise<void> {
93
+ const diff = await Bun.file(join(runDir, 'diff.patch')).text()
94
+ const findings = detectMissingTests(diff)
95
+ if (findings.length === 0) {
96
+ await logLine(runDir, { stage: 'tests-check', status: 'done', findings: 0 })
97
+ return
98
+ }
99
+ await writeFile(join(runDir, 'findings', 'tests.json'), `${JSON.stringify(findings, null, 2)}\n`)
100
+ await logLine(runDir, { stage: 'tests-check', status: 'done', findings: findings.length })
101
+ }
102
+
103
+ async function detectIncrementalReview(runDir: string, prNumber: number): Promise<void> {
104
+ const previous = await findPreviousRun(prNumber, runDir)
105
+ if (!previous) {
106
+ await logLine(runDir, { stage: 'incremental', status: 'first-run' })
107
+ return
108
+ }
109
+ const currentPr = (await Bun.file(join(runDir, 'pr.json')).json()) as Record<string, unknown>
110
+ const context = buildIncrementalContext(currentPr, previous)
111
+ if (!context) {
112
+ await logLine(runDir, { stage: 'incremental', status: 'missing-sha' })
113
+ return
114
+ }
115
+ await writeFile(join(runDir, 'incremental.json'), `${JSON.stringify(context, null, 2)}\n`)
116
+ await logLine(runDir, {
117
+ stage: 'incremental',
118
+ status: 'done',
119
+ previousRunId: context.previousRunId,
120
+ sameSha: context.sameSha,
121
+ })
122
+ }
123
+
124
+ async function applyPathFilter(runDir: string, repoPath: string): Promise<void> {
125
+ const diffPath = join(runDir, 'diff.patch')
126
+ const rawDiff = await Bun.file(diffPath).text()
127
+ const config = await loadPathFilterConfig(repoPath)
128
+ const { filtered, excluded } = filterDiff(rawDiff, config)
129
+ if (excluded.length === 0) {
130
+ await logLine(runDir, { stage: 'filter', status: 'done', excluded: 0 })
131
+ return
132
+ }
133
+ await writeFile(join(runDir, 'diff.full.patch'), rawDiff)
134
+ await writeFile(diffPath, filtered)
135
+ await writeFile(join(runDir, 'excluded-files.json'), `${JSON.stringify(excluded, null, 2)}\n`)
136
+ await logLine(runDir, {
137
+ stage: 'filter',
138
+ status: 'done',
139
+ excluded: excluded.length,
140
+ patterns: Array.from(new Set(excluded.map((e) => e.pattern))),
141
+ })
142
+ }
@@ -0,0 +1,118 @@
1
+ import { splitDiffByFile } from './diff-utils.ts'
2
+ import type { ReviewFinding } from './types.ts'
3
+
4
+ const TEST_FILE_PATTERNS: RegExp[] = [
5
+ /\.test\.[jt]sx?$/, // foo.test.ts
6
+ /\.spec\.[jt]sx?$/, // foo.spec.ts
7
+ /(^|\/)__tests__\//, // __tests__/foo.ts
8
+ /(^|\/)tests?\//, // tests/foo.ts, test/foo.ts
9
+ /(^|\/)test_[^/]+\.py$/, // test_foo.py
10
+ /_test\.(go|py|rb)$/, // foo_test.go
11
+ /(^|\/)spec\//, // spec/foo_spec.rb
12
+ /Tests?\.(java|kt|cs)$/, // FooTest.java
13
+ ]
14
+
15
+ const NON_CODE_EXTENSIONS = new Set([
16
+ '.md',
17
+ '.txt',
18
+ '.json',
19
+ '.yaml',
20
+ '.yml',
21
+ '.toml',
22
+ '.lock',
23
+ '.svg',
24
+ '.png',
25
+ '.jpg',
26
+ '.jpeg',
27
+ '.gif',
28
+ '.ico',
29
+ '.css',
30
+ '.scss',
31
+ '.html',
32
+ ])
33
+
34
+ export function isTestFile(path: string): boolean {
35
+ return TEST_FILE_PATTERNS.some((p) => p.test(path))
36
+ }
37
+
38
+ function fileExt(path: string): string {
39
+ const dot = path.lastIndexOf('.')
40
+ return dot === -1 ? '' : path.slice(dot)
41
+ }
42
+
43
+ function looksLikeSourceCode(path: string): boolean {
44
+ return !NON_CODE_EXTENSIONS.has(fileExt(path).toLowerCase())
45
+ }
46
+
47
+ const COMMENT_PREFIXES = ['//', '#', '*', '/*', '"""', "'''"]
48
+
49
+ function isLikelyCodeLine(content: string): boolean {
50
+ const trimmed = content.trim()
51
+ if (trimmed.length === 0) return false
52
+ if (COMMENT_PREFIXES.some((p) => trimmed.startsWith(p))) return false
53
+ // Strip leading import statements; they're common churn that doesn't warrant tests.
54
+ if (/^(import|from|require\()/.test(trimmed)) return false
55
+ return /[A-Za-z]/.test(trimmed)
56
+ }
57
+
58
+ function countAddedCodeLines(chunk: string): number {
59
+ let n = 0
60
+ for (const line of chunk.split('\n')) {
61
+ if (!line.startsWith('+')) continue
62
+ if (line.startsWith('+++')) continue
63
+ if (isLikelyCodeLine(line.slice(1))) n += 1
64
+ }
65
+ return n
66
+ }
67
+
68
+ export type DetectMissingTestsOptions = {
69
+ /** Minimum added code lines to trigger a missing-tests finding. Default 10. */
70
+ minAddedLines?: number
71
+ }
72
+
73
+ /**
74
+ * Walk the unified diff. If the diff contains zero test files anywhere, emit
75
+ * one finding per non-test source file that added >= minAddedLines of code-ish
76
+ * lines. Returns an empty array when test files are present (we assume the
77
+ * author has accounted for coverage).
78
+ */
79
+ export function detectMissingTests(
80
+ diff: string,
81
+ options: DetectMissingTestsOptions = {},
82
+ ): ReviewFinding[] {
83
+ if (!diff) return []
84
+ const minAdded = options.minAddedLines ?? 10
85
+ const byFile = splitDiffByFile(diff)
86
+ const filePaths = [...byFile.keys()]
87
+ const hasAnyTestFile = filePaths.some((p) => isTestFile(p))
88
+ if (hasAnyTestFile) return []
89
+
90
+ const findings: ReviewFinding[] = []
91
+ let id = 1
92
+ for (const [path, chunk] of byFile) {
93
+ if (isTestFile(path)) continue
94
+ if (!looksLikeSourceCode(path)) continue
95
+ const added = countAddedCodeLines(chunk)
96
+ if (added < minAdded) continue
97
+ findings.push({
98
+ id: `tests-${id++}`,
99
+ file: path,
100
+ line: null,
101
+ severity: 'medium',
102
+ risk: {
103
+ impact: 'medium',
104
+ likelihood: 'possible',
105
+ confidence: 'medium',
106
+ action: 'should-fix',
107
+ },
108
+ title: `No tests added for changes in ${path}`,
109
+ description: [
110
+ `Observation: This PR adds ${added} new code-ish line${added === 1 ? '' : 's'} in \`${path}\` and the diff contains no test or spec file anywhere.`,
111
+ `Why it matters: Behaviour changes without test coverage are easy to regress and harder to review with confidence.`,
112
+ `Suggested direction: Add or extend a test that exercises the new behaviour, or note explicitly why a test is not warranted (config-only change, type-only change, trivial refactor).`,
113
+ ].join('\n\n'),
114
+ domain: 'tests',
115
+ })
116
+ }
117
+ return findings
118
+ }
@@ -1,4 +1,11 @@
1
- export const FOCUS_IDS = ['security', 'bugs', 'performance', 'code-smells', 'architecture'] as const
1
+ export const FOCUS_IDS = [
2
+ 'security',
3
+ 'bugs',
4
+ 'performance',
5
+ 'code-smells',
6
+ 'architecture',
7
+ 'tests',
8
+ ] as const
2
9
  export type FocusId = (typeof FOCUS_IDS)[number]
3
10
 
4
11
  export const SEVERITIES = ['blocker', 'high', 'medium', 'low'] as const
@@ -45,6 +52,8 @@ export type ReviewFinding = {
45
52
  suggestion?: Suggestion
46
53
  domain: FocusId | string | null
47
54
  mergedFrom?: MergedFromEntry[]
55
+ /** Derived 0-10 importance score from risk fields. Populated during dedupe. */
56
+ score?: number
48
57
  }
49
58
 
50
59
  function assertOneOf<T extends string>(
@@ -102,6 +111,7 @@ export function parseFinding(raw: unknown): ReviewFinding {
102
111
  suggestion,
103
112
  domain: (r.domain as ReviewFinding['domain']) ?? null,
104
113
  mergedFrom: Array.isArray(r.mergedFrom) ? (r.mergedFrom as MergedFromEntry[]) : undefined,
114
+ ...(typeof r.score === 'number' && Number.isFinite(r.score) ? { score: r.score } : {}),
105
115
  }
106
116
  }
107
117
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Interactive PR review pipeline. Runs five parallel specialist subagents (security, bugs, performance, code-smells, architecture), dedupes findings, applies a critic rubric, peer-reviews via codex exec, and serves an interactive HTML report for selecting findings to post via gh. Bundles a Bun CLI installed onto PATH via the skill's postinstall step. Use when the user asks to review a GitHub pull request.",
5
5
  "author": "iceinvein",
6
6
  "type": "prompt",