@iceinvein/agent-skills 0.23.9 → 0.23.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/package.json +1 -1
  2. package/skills/index.json +2 -2
  3. package/skills/magpie/SKILL.md +21 -20
  4. package/skills/magpie/bin/magpie.ts +63 -2
  5. package/skills/magpie/evals/quality/README.md +74 -1
  6. package/skills/magpie/evals/quality/__tests__/probe-specialist.test.ts +498 -0
  7. package/skills/magpie/evals/quality/__tests__/replay-full.test.ts +9 -0
  8. package/skills/magpie/evals/quality/probe-specialist.ts +672 -0
  9. package/skills/magpie/evals/quality/replay-full.ts +6 -2
  10. package/skills/magpie/fixtures/fake-gh-with-lockfile.sh +37 -2
  11. package/skills/magpie/fixtures/fake-gh.sh +49 -2
  12. package/skills/magpie/references/critic.md +20 -4
  13. package/skills/magpie/references/proof.md +102 -0
  14. package/skills/magpie/references/scout.md +6 -1
  15. package/skills/magpie/references/specialists.md +15 -9
  16. package/skills/magpie/scripts/__tests__/cleanup-cmd.test.ts +103 -7
  17. package/skills/magpie/scripts/__tests__/cli.test.ts +69 -0
  18. package/skills/magpie/scripts/__tests__/critic-cmd.test.ts +46 -0
  19. package/skills/magpie/scripts/__tests__/critic.test.ts +11 -1
  20. package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +20 -1
  21. package/skills/magpie/scripts/__tests__/git-diff.test.ts +23 -4
  22. package/skills/magpie/scripts/__tests__/handoff-cmd.test.ts +68 -0
  23. package/skills/magpie/scripts/__tests__/history.test.ts +232 -0
  24. package/skills/magpie/scripts/__tests__/impact-cmd.test.ts +477 -0
  25. package/skills/magpie/scripts/__tests__/impact-render.test.ts +365 -0
  26. package/skills/magpie/scripts/__tests__/impact.test.ts +915 -0
  27. package/skills/magpie/scripts/__tests__/incremental.test.ts +59 -8
  28. package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +1 -1
  29. package/skills/magpie/scripts/__tests__/post-cmd.test.ts +58 -0
  30. package/skills/magpie/scripts/__tests__/pr-activity.test.ts +281 -0
  31. package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +4 -4
  32. package/skills/magpie/scripts/__tests__/previous-findings.test.ts +267 -0
  33. package/skills/magpie/scripts/__tests__/proof-cmd.test.ts +260 -0
  34. package/skills/magpie/scripts/__tests__/proof.test.ts +226 -0
  35. package/skills/magpie/scripts/__tests__/refresh.test.ts +71 -0
  36. package/skills/magpie/scripts/__tests__/render-annotation.test.ts +34 -0
  37. package/skills/magpie/scripts/__tests__/render-findings.test.ts +75 -0
  38. package/skills/magpie/scripts/__tests__/render-progress.test.ts +9 -0
  39. package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +376 -1
  40. package/skills/magpie/scripts/__tests__/shard-coverage.test.ts +79 -0
  41. package/skills/magpie/scripts/__tests__/skill-lint.test.ts +48 -2
  42. package/skills/magpie/scripts/__tests__/status-cmd.test.ts +20 -8
  43. package/skills/magpie/scripts/__tests__/types.test.ts +46 -0
  44. package/skills/magpie/scripts/cleanup-cmd.ts +88 -0
  45. package/skills/magpie/scripts/critic-cmd.ts +2 -0
  46. package/skills/magpie/scripts/critic.ts +4 -0
  47. package/skills/magpie/scripts/dedupe-cmd.ts +4 -0
  48. package/skills/magpie/scripts/evidence-filter.ts +10 -0
  49. package/skills/magpie/scripts/git-diff.ts +50 -19
  50. package/skills/magpie/scripts/handoff-cmd.ts +42 -3
  51. package/skills/magpie/scripts/history.ts +143 -0
  52. package/skills/magpie/scripts/impact-cmd.ts +202 -0
  53. package/skills/magpie/scripts/impact-render.ts +158 -0
  54. package/skills/magpie/scripts/impact.ts +622 -0
  55. package/skills/magpie/scripts/incremental.ts +44 -5
  56. package/skills/magpie/scripts/labels-cmd.ts +7 -3
  57. package/skills/magpie/scripts/post-cmd.ts +46 -0
  58. package/skills/magpie/scripts/pr-activity.ts +243 -0
  59. package/skills/magpie/scripts/preview-cmd.ts +3 -0
  60. package/skills/magpie/scripts/previous-findings.ts +181 -0
  61. package/skills/magpie/scripts/proof-cmd.ts +226 -0
  62. package/skills/magpie/scripts/proof.ts +150 -0
  63. package/skills/magpie/scripts/refresh.ts +11 -0
  64. package/skills/magpie/scripts/render-annotation.ts +17 -0
  65. package/skills/magpie/scripts/render-cmd.ts +12 -0
  66. package/skills/magpie/scripts/render-findings.ts +37 -1
  67. package/skills/magpie/scripts/render-progress.ts +3 -0
  68. package/skills/magpie/scripts/setup-cmd.ts +142 -11
  69. package/skills/magpie/scripts/shard-coverage.ts +53 -0
  70. package/skills/magpie/scripts/status-cmd.ts +2 -0
  71. package/skills/magpie/scripts/types.ts +39 -0
  72. package/skills/magpie/skill.json +1 -1
  73. package/skills/magpie/templates/styles.css +30 -0
  74. package/skills/sluice/scripts/status.sh +32 -13
  75. package/skills/sluice/skill.json +1 -1
@@ -1,9 +1,9 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
- import { foldLabels, readJsonLines } from './labels.ts'
3
+ import { type FindingLabel, foldLabels, readJsonLines } from './labels.ts'
4
4
  import type { PostStatusMap } from './types.ts'
5
5
 
6
- export async function writeLabels(runDir: string): Promise<number> {
6
+ export async function readRunLabels(runDir: string): Promise<FindingLabel[]> {
7
7
  const findingsPath = join(runDir, 'findings.final.json')
8
8
  const raw = JSON.parse(await readFile(findingsPath, 'utf8')) as unknown
9
9
  if (!Array.isArray(raw)) throw new Error(`${findingsPath} is not an array`)
@@ -21,12 +21,16 @@ export async function writeLabels(runDir: string): Promise<number> {
21
21
  if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
22
22
  }
23
23
 
24
- const labels = foldLabels({
24
+ return foldLabels({
25
25
  findingIds,
26
26
  postStatus,
27
27
  events: await readJsonLines(join(runDir, 'state', 'events')),
28
28
  log: await readJsonLines(join(runDir, 'log.jsonl')),
29
29
  })
30
+ }
31
+
32
+ export async function writeLabels(runDir: string): Promise<number> {
33
+ const labels = await readRunLabels(runDir)
30
34
  await writeFile(join(runDir, 'labels.json'), `${JSON.stringify(labels, null, 2)}\n`)
31
35
  return labels.length
32
36
  }
@@ -3,7 +3,10 @@ import { appendFile, readFile, writeFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
4
  import { buildNewSideLineIndex } from './diff-utils.ts'
5
5
  import { formatFindingDescriptionMarkdown } from './finding-description.ts'
6
+ import { readHistory } from './history.ts'
6
7
  import type { PostVia } from './labels.ts'
8
+ import { describePreviousPosted } from './previous-findings.ts'
9
+ import { describeUnreviewed, readShardCoverage } from './shard-coverage.ts'
7
10
  import {
8
11
  type FocusId,
9
12
  type PrFileEntry,
@@ -150,6 +153,31 @@ function buildSuggestion(f: ReviewFinding): SuggestionParts {
150
153
  }
151
154
  }
152
155
 
156
+ /** GitHub renders the `<summary>` line as HTML, where a raw `<` in a command would start a tag. */
157
+ function escapeHtml(s: string): string {
158
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
159
+ }
160
+
161
+ /** The repro rides folded under the finding, so the comment stays short on GitHub. */
162
+ function buildRepro(f: ReviewFinding): string | null {
163
+ if (f.proof?.status !== 'reproduced') return null
164
+ const { command, source, output } = f.proof.repro
165
+ const fence = (code: string) => '`'.repeat(Math.max(3, maxBacktickRun(code) + 1))
166
+ return [
167
+ `<details><summary>Reproduced with <code>${escapeHtml(command)}</code></summary>`,
168
+ '',
169
+ fence(source),
170
+ source,
171
+ fence(source),
172
+ '',
173
+ fence(output),
174
+ output,
175
+ fence(output),
176
+ '',
177
+ '</details>',
178
+ ].join('\n')
179
+ }
180
+
153
181
  function buildFindingMarker(f: ReviewFinding): string {
154
182
  const normalized = JSON.stringify({
155
183
  file: f.file || '',
@@ -180,6 +208,7 @@ export function formatInlineBody(f: ReviewFinding): string {
180
208
  const metaLine = buildMetaLine([...buildRiskParts(f), focus ? `Focus · ${focus}` : null])
181
209
  const { block: suggestion, preamble: sugPre, postamble: sugPost } = buildSuggestion(f)
182
210
  const sections = formatFindingDescriptionMarkdown(f.description)
211
+ const repro = buildRepro(f)
183
212
 
184
213
  return joinBlock([
185
214
  `### ${icon} ${label}: ${f.title}`,
@@ -193,6 +222,8 @@ export function formatInlineBody(f: ReviewFinding): string {
193
222
  suggestion,
194
223
  sugPost ? '' : null,
195
224
  sugPost,
225
+ repro ? '' : null,
226
+ repro,
196
227
  '',
197
228
  buildFindingMarker(f),
198
229
  ])
@@ -213,6 +244,7 @@ export function formatConversationBody(f: ReviewFinding): string {
213
244
  ...buildRiskParts(f),
214
245
  ])
215
246
  const sections = formatFindingDescriptionMarkdown(f.description)
247
+ const repro = buildRepro(f)
216
248
  const { block: suggestion, preamble: sugPre, postamble: sugPost } = buildSuggestion(f)
217
249
 
218
250
  return joinBlock([
@@ -227,6 +259,8 @@ export function formatConversationBody(f: ReviewFinding): string {
227
259
  suggestion,
228
260
  sugPost ? '' : null,
229
261
  sugPost,
262
+ repro ? '' : null,
263
+ repro,
230
264
  '',
231
265
  buildFindingMarker(f),
232
266
  ])
@@ -260,6 +294,10 @@ export type ReviewSummaryOptions = {
260
294
  files?: PrFileEntry[]
261
295
  /** Incremental review context. When present, surfaces "since <prev-sha>" trailer. */
262
296
  incremental?: { previousSha: string; sameSha: boolean }
297
+ /** `describePreviousPosted` on a re-review. */
298
+ previousRun?: string
299
+ /** `describeUnreviewed` when shards were skipped at the stage 4 gate. */
300
+ unreviewed?: string
263
301
  }
264
302
 
265
303
  /** Effort score 1 (trivial) to 5 (substantial), derived from changed files and lines. */
@@ -383,6 +421,8 @@ export function formatReviewSummaryBody(
383
421
  : `Incremental review since \`${prevShort}\``
384
422
  lines.push('', `<sub>${msg}</sub>`)
385
423
  }
424
+ if (options.previousRun) lines.push('', `<sub>${options.previousRun}</sub>`)
425
+ if (options.unreviewed) lines.push('', `<sub>${options.unreviewed}</sub>`)
386
426
 
387
427
  if (findings.length > 0) {
388
428
  const inlineText =
@@ -862,11 +902,17 @@ export async function runPost(input: PostInput): Promise<PostOutcome> {
862
902
  // No incremental sidecar; first run for this PR.
863
903
  }
864
904
 
905
+ const { previous } = await readHistory(input.runDir)
906
+ const previousRun = previous ? describePreviousPosted(previous) : null
907
+ const coverage = await readShardCoverage(input.runDir)
908
+
865
909
  if (shouldPostSummary) {
866
910
  const summaryBody = formatReviewSummaryBody(selectedFindings, {
867
911
  commitId: headSha,
868
912
  files: prFiles,
869
913
  ...(incremental ? { incremental } : {}),
914
+ ...(previousRun ? { previousRun } : {}),
915
+ ...(coverage ? { unreviewed: describeUnreviewed(coverage) } : {}),
870
916
  })
871
917
  const summaryCmd = buildPrCommentCommand(repo, prNumber, summaryBody)
872
918
  if (input.dryRun) {
@@ -0,0 +1,243 @@
1
+ import { writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ export type PrCheck = {
5
+ name: string
6
+ /** CheckRun conclusion, or its status while it has none; StatusContext state. Upper case as GitHub sends it. */
7
+ conclusion: string
8
+ url: string | null
9
+ }
10
+
11
+ export type PrThread = {
12
+ file: string
13
+ line: number | null
14
+ resolved: boolean
15
+ outdated: boolean
16
+ author: string
17
+ /** The thread's first comment. */
18
+ body: string
19
+ replies: number
20
+ /** Posted by magpie in an earlier run, recognised by its hidden marker. */
21
+ magpie: boolean
22
+ }
23
+
24
+ export type PrComment = {
25
+ author: string
26
+ body: string
27
+ magpie: boolean
28
+ }
29
+
30
+ export type PrActivity = {
31
+ checks: PrCheck[]
32
+ threads: PrThread[]
33
+ comments: PrComment[]
34
+ }
35
+
36
+ export type FetchPrActivityInput = {
37
+ ghBin: string
38
+ prNumber: number
39
+ /** pr.json's `url`, the source of owner and repo for the thread query. */
40
+ prUrl: string
41
+ runDir: string
42
+ cwd?: string
43
+ env?: Record<string, string>
44
+ }
45
+
46
+ export type FetchPrActivityResult =
47
+ | { ok: true; activity: PrActivity }
48
+ | { ok: false; error: string }
49
+
50
+ const ACTIVITY_VIEW_FIELDS = 'statusCheckRollup,comments,reviews'
51
+
52
+ const FINDING_MARKER = '<!-- magpie:finding'
53
+ const SUMMARY_MARKER = '<!-- magpie:summary -->'
54
+ const REVIEW_BODY_SEPARATOR = '\n\n---\n\n'
55
+
56
+ // gh pr view does not expose review threads, which carry the file, line and
57
+ // resolved state, so they come from GraphQL a page at a time.
58
+ const THREADS_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $after: String) {
59
+ repository(owner: $owner, name: $repo) {
60
+ pullRequest(number: $number) {
61
+ reviewThreads(first: 100, after: $after) {
62
+ pageInfo { hasNextPage endCursor }
63
+ nodes {
64
+ path line originalLine isResolved isOutdated
65
+ comments(first: 1) { totalCount nodes { author { login } body } }
66
+ }
67
+ }
68
+ }
69
+ }
70
+ }`
71
+
72
+ type GhRun = { ok: true; stdout: string } | { ok: false; error: string }
73
+
74
+ async function runGh(input: FetchPrActivityInput, args: string[]): Promise<GhRun> {
75
+ const proc = Bun.spawn([input.ghBin, ...args], {
76
+ stdout: 'pipe',
77
+ stderr: 'pipe',
78
+ ...(input.cwd ? { cwd: input.cwd } : {}),
79
+ ...(input.env ? { env: { ...process.env, ...input.env } } : {}),
80
+ })
81
+ const [stdout, stderr] = await Promise.all([
82
+ new Response(proc.stdout).text(),
83
+ new Response(proc.stderr).text(),
84
+ ])
85
+ const exit = await proc.exited
86
+ if (exit !== 0)
87
+ return { ok: false, error: `gh ${args[0]} ${args[1]} exit ${exit}: ${stderr.trim()}` }
88
+ return { ok: true, stdout }
89
+ }
90
+
91
+ function str(value: unknown): string {
92
+ return typeof value === 'string' ? value : ''
93
+ }
94
+
95
+ // GitHub returns a null author for a deleted account and shows it as "ghost".
96
+ function login(author: unknown): string {
97
+ return author === null ? 'ghost' : str((author as { login?: unknown }).login)
98
+ }
99
+
100
+ function parseChecks(rollup: unknown): PrCheck[] {
101
+ if (!Array.isArray(rollup)) return []
102
+ return rollup.map((raw) => {
103
+ const c = raw as Record<string, unknown>
104
+ if (c.__typename === 'StatusContext') {
105
+ return { name: str(c.context), conclusion: str(c.state), url: str(c.targetUrl) || null }
106
+ }
107
+ return {
108
+ name: str(c.name),
109
+ conclusion: str(c.conclusion) || str(c.status),
110
+ url: str(c.detailsUrl) || null,
111
+ }
112
+ })
113
+ }
114
+
115
+ function parseComments(view: Record<string, unknown>): PrComment[] {
116
+ const entries = [
117
+ ...(Array.isArray(view.comments) ? view.comments : []),
118
+ ...(Array.isArray(view.reviews) ? view.reviews : []),
119
+ ] as Array<Record<string, unknown>>
120
+ // The report's Post Selected joins findings with no inline anchor into one
121
+ // review body; split it so each one is its own entry rather than one cut short.
122
+ return entries
123
+ .flatMap((e) =>
124
+ str(e.body)
125
+ .split(REVIEW_BODY_SEPARATOR)
126
+ .map((body) => ({ author: login(e.author), body })),
127
+ )
128
+ .filter((c) => c.body.trim() !== '' && !c.body.includes(SUMMARY_MARKER))
129
+ .map((c) => ({ ...c, magpie: c.body.includes(FINDING_MARKER) }))
130
+ }
131
+
132
+ /** Null for a thread whose opening comment was deleted: it has nothing left to repeat. */
133
+ function parseThread(raw: unknown): PrThread | null {
134
+ const t = raw as Record<string, unknown>
135
+ const comments = (t.comments ?? {}) as { totalCount?: unknown; nodes?: unknown }
136
+ const first = (Array.isArray(comments.nodes) ? comments.nodes[0] : undefined) as
137
+ | Record<string, unknown>
138
+ | undefined
139
+ const body = str(first?.body)
140
+ if (!first || body.trim() === '') return null
141
+ const total = typeof comments.totalCount === 'number' ? comments.totalCount : 0
142
+ // An outdated thread has no current line; where it was first left is still the best pointer.
143
+ const line =
144
+ typeof t.line === 'number' ? t.line : typeof t.originalLine === 'number' ? t.originalLine : null
145
+ return {
146
+ file: str(t.path),
147
+ line,
148
+ resolved: t.isResolved === true,
149
+ outdated: t.isOutdated === true,
150
+ author: login(first.author),
151
+ body,
152
+ replies: Math.max(total - 1, 0),
153
+ magpie: body.includes(FINDING_MARKER),
154
+ }
155
+ }
156
+
157
+ async function fetchThreads(
158
+ input: FetchPrActivityInput,
159
+ owner: string,
160
+ repo: string,
161
+ ): Promise<{ ok: true; threads: PrThread[] } | { ok: false; error: string }> {
162
+ const threads: PrThread[] = []
163
+ let after: string | null = null
164
+ do {
165
+ const args = [
166
+ 'api',
167
+ 'graphql',
168
+ '-f',
169
+ `query=${THREADS_QUERY}`,
170
+ '-f',
171
+ `owner=${owner}`,
172
+ '-f',
173
+ `repo=${repo}`,
174
+ '-F',
175
+ `number=${input.prNumber}`,
176
+ ...(after ? ['-f', `after=${after}`] : []),
177
+ ]
178
+ const run = await runGh(input, args)
179
+ if (!run.ok) return run
180
+ let page: {
181
+ data?: { repository?: { pullRequest?: { reviewThreads?: unknown } } } | null
182
+ errors?: Array<{ message?: unknown }>
183
+ }
184
+ try {
185
+ page = JSON.parse(run.stdout)
186
+ } catch (err) {
187
+ return { ok: false, error: `gh api graphql returned unparseable JSON: ${String(err)}` }
188
+ }
189
+ if (page.errors && page.errors.length > 0) {
190
+ return {
191
+ ok: false,
192
+ error: `gh api graphql: ${page.errors.map((e) => str(e.message)).join('; ')}`,
193
+ }
194
+ }
195
+ const connection = page.data?.repository?.pullRequest?.reviewThreads as
196
+ | { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: unknown }
197
+ | undefined
198
+ if (!connection || !Array.isArray(connection.nodes)) {
199
+ return { ok: false, error: 'gh api graphql returned no reviewThreads for the PR' }
200
+ }
201
+ threads.push(...connection.nodes.flatMap((node) => parseThread(node) ?? []))
202
+ after =
203
+ connection.pageInfo?.hasNextPage === true ? str(connection.pageInfo.endCursor) || null : null
204
+ } while (after)
205
+ return { ok: true, threads }
206
+ }
207
+
208
+ /** `https://github.com/<owner>/<repo>/pull/<n>` to owner and repo. */
209
+ function ownerRepo(prUrl: string): { owner: string; repo: string } | null {
210
+ const m = prUrl.match(/^https?:\/\/[^/]+\/([^/]+)\/([^/]+)\/pull\/\d+/)
211
+ return m?.[1] && m[2] ? { owner: m[1], repo: m[2] } : null
212
+ }
213
+
214
+ export async function fetchPrActivity(input: FetchPrActivityInput): Promise<FetchPrActivityResult> {
215
+ const target = ownerRepo(input.prUrl)
216
+ if (!target) return { ok: false, error: `cannot read owner and repo from PR url ${input.prUrl}` }
217
+
218
+ const view = await runGh(input, [
219
+ 'pr',
220
+ 'view',
221
+ String(input.prNumber),
222
+ '--json',
223
+ ACTIVITY_VIEW_FIELDS,
224
+ ])
225
+ if (!view.ok) return view
226
+ let viewJson: Record<string, unknown>
227
+ try {
228
+ viewJson = JSON.parse(view.stdout)
229
+ } catch (err) {
230
+ return { ok: false, error: `gh pr view returned unparseable JSON: ${String(err)}` }
231
+ }
232
+
233
+ const threads = await fetchThreads(input, target.owner, target.repo)
234
+ if (!threads.ok) return threads
235
+
236
+ const activity: PrActivity = {
237
+ checks: parseChecks(viewJson.statusCheckRollup),
238
+ threads: threads.threads,
239
+ comments: parseComments(viewJson),
240
+ }
241
+ await writeFile(join(input.runDir, 'pr-activity.json'), `${JSON.stringify(activity, null, 2)}\n`)
242
+ return { ok: true, activity }
243
+ }
@@ -96,6 +96,7 @@ export const STAGE_PRESETS = {
96
96
  dedupe: 'done',
97
97
  critic: 'done',
98
98
  'peer-review': 'done',
99
+ proof: 'done',
99
100
  report: 'done',
100
101
  },
101
102
  counts: { security: 1, bugs: 2, performance: 2, 'code-smells': 2, architecture: 1 },
@@ -109,6 +110,7 @@ export const STAGE_PRESETS = {
109
110
  dedupe: 'done',
110
111
  critic: 'done',
111
112
  'peer-review': 'done',
113
+ proof: 'done',
112
114
  report: 'done',
113
115
  post: 'done',
114
116
  },
@@ -124,6 +126,7 @@ function pending(): Record<StageId, StageStatus> {
124
126
  dedupe: 'pending',
125
127
  critic: 'pending',
126
128
  'peer-review': 'pending',
129
+ proof: 'pending',
127
130
  report: 'pending',
128
131
  post: 'pending',
129
132
  }
@@ -0,0 +1,181 @@
1
+ import { readFile, writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { containsEvidence } from './evidence-filter.ts'
4
+ import { DISMISS_REASONS, type DismissReason, type FindingLabel } from './labels.ts'
5
+ import { readRunLabels } from './labels-cmd.ts'
6
+ import { parseFinding } from './types.ts'
7
+
8
+ type PreviousFindingBase = {
9
+ id: string
10
+ /** The run that produced the finding, which can be older than the previous run when carried. */
11
+ fromRunId: string
12
+ file: string
13
+ line: number | null
14
+ title: string
15
+ domain: string | null
16
+ }
17
+
18
+ /**
19
+ * `anchor` says whether the code the finding quoted is still in the file. It
20
+ * does not say the finding was fixed: the code can change and keep the defect.
21
+ */
22
+ export type PreviousFinding =
23
+ | (PreviousFindingBase & { label: 'posted'; anchor: 'unchanged' | 'changed' | 'unanchored' })
24
+ | (PreviousFindingBase & { label: 'dismissed'; reason: DismissReason })
25
+
26
+ export type PreviousFindings = {
27
+ previousRunId: string
28
+ findings: PreviousFinding[]
29
+ }
30
+
31
+ function isMissingFile(err: unknown): boolean {
32
+ return (err as NodeJS.ErrnoException)?.code === 'ENOENT'
33
+ }
34
+
35
+ async function readOptionalJson(path: string): Promise<unknown | null> {
36
+ let text: string
37
+ try {
38
+ text = await readFile(path, 'utf8')
39
+ } catch (err) {
40
+ if (isMissingFile(err)) return null
41
+ throw err
42
+ }
43
+ try {
44
+ return JSON.parse(text)
45
+ } catch (err) {
46
+ throw new Error(`${path}: ${err instanceof Error ? err.message : String(err)}`)
47
+ }
48
+ }
49
+
50
+ /** cleanup writes labels.json before archiving; an active run's labels are folded fresh. */
51
+ async function readLabels(runDir: string): Promise<FindingLabel[]> {
52
+ const path = join(runDir, 'labels.json')
53
+ const raw = await readOptionalJson(path)
54
+ if (raw === null) return readRunLabels(runDir)
55
+ if (!Array.isArray(raw)) throw new Error(`${path}: expected a JSON array`)
56
+ return raw as FindingLabel[]
57
+ }
58
+
59
+ async function anchorStatus(
60
+ worktree: string,
61
+ file: string,
62
+ line: number | null,
63
+ evidence: string | undefined,
64
+ ): Promise<'unchanged' | 'changed' | 'unanchored'> {
65
+ if (line === null || !evidence?.trim()) return 'unanchored'
66
+ let text: string
67
+ try {
68
+ text = await readFile(join(worktree, file), 'utf8')
69
+ } catch (err) {
70
+ if (isMissingFile(err)) return 'changed'
71
+ throw err
72
+ }
73
+ return containsEvidence(text, evidence) ? 'unchanged' : 'changed'
74
+ }
75
+
76
+ /**
77
+ * Dismissals only travel one run at a time: a finding the critic dropped as
78
+ * already dismissed never reaches findings.final.json, so the next run would
79
+ * forget it unless the previous run's own carried dismissals come along too.
80
+ */
81
+ function parseCarried(raw: unknown, path: string): PreviousFinding[] {
82
+ if (raw === null) return []
83
+ const findings = (raw as { findings?: unknown }).findings
84
+ if (!Array.isArray(findings)) throw new Error(`${path}: expected { findings: [...] }`)
85
+ return findings.flatMap((entry, i) => {
86
+ const f = entry as Record<string, unknown>
87
+ if (f.label !== 'dismissed') return []
88
+ const reason = DISMISS_REASONS.find((r) => r === f.reason)
89
+ if (
90
+ typeof f.id !== 'string' ||
91
+ typeof f.fromRunId !== 'string' ||
92
+ typeof f.file !== 'string' ||
93
+ typeof f.title !== 'string' ||
94
+ !reason
95
+ ) {
96
+ throw new Error(`${path}: findings[${i}] is not a dismissed finding`)
97
+ }
98
+ return [
99
+ {
100
+ id: f.id,
101
+ fromRunId: f.fromRunId,
102
+ file: f.file,
103
+ line: typeof f.line === 'number' ? f.line : null,
104
+ title: f.title,
105
+ domain: typeof f.domain === 'string' ? f.domain : null,
106
+ label: 'dismissed' as const,
107
+ reason,
108
+ },
109
+ ]
110
+ })
111
+ }
112
+
113
+ /**
114
+ * The previous run's posted and dismissed findings, for a re-review to carry
115
+ * forward. Ignored findings are left out: nobody judged them. A run aborted
116
+ * before findings.final.json still passes on what it carried. Null when it has
117
+ * neither.
118
+ */
119
+ export async function readPreviousFindings(input: {
120
+ runDir: string
121
+ runId: string
122
+ worktree: string
123
+ }): Promise<PreviousFindings | null> {
124
+ const finalPath = join(input.runDir, 'findings.final.json')
125
+ const raw = await readOptionalJson(finalPath)
126
+ const carriedPath = join(input.runDir, 'previous-findings.json')
127
+ const carriedRaw = await readOptionalJson(carriedPath)
128
+ if (raw === null && carriedRaw === null) return null
129
+ if (raw !== null && !Array.isArray(raw)) throw new Error(`${finalPath}: expected a JSON array`)
130
+ const labels =
131
+ raw === null ? new Map() : new Map((await readLabels(input.runDir)).map((l) => [l.id, l]))
132
+
133
+ const findings: PreviousFinding[] = []
134
+ for (const entry of raw ?? []) {
135
+ const f = parseFinding(entry)
136
+ const label = labels.get(f.id)
137
+ const base = {
138
+ id: f.id,
139
+ fromRunId: input.runId,
140
+ file: f.file,
141
+ line: f.line,
142
+ title: f.title,
143
+ domain: typeof f.domain === 'string' ? f.domain : null,
144
+ }
145
+ if (label?.label === 'posted') {
146
+ const anchor = await anchorStatus(input.worktree, f.file, f.line, f.evidence)
147
+ findings.push({ ...base, label: 'posted', anchor })
148
+ } else if (label?.label === 'dismissed' && label.reason) {
149
+ findings.push({ ...base, label: 'dismissed', reason: label.reason })
150
+ }
151
+ }
152
+
153
+ const carried = parseCarried(carriedRaw, carriedPath)
154
+ const seen = new Set(findings.map((f) => `${f.fromRunId}/${f.id}`))
155
+ for (const f of carried) {
156
+ if (!seen.has(`${f.fromRunId}/${f.id}`)) findings.push(f)
157
+ }
158
+ return { previousRunId: input.runId, findings }
159
+ }
160
+
161
+ export async function writePreviousFindings(
162
+ runDir: string,
163
+ previous: PreviousFindings,
164
+ ): Promise<void> {
165
+ await writeFile(join(runDir, 'previous-findings.json'), `${JSON.stringify(previous, null, 2)}\n`)
166
+ }
167
+
168
+ /** One line for the report header and the summary comment; null when nothing was posted last review. */
169
+ export function describePreviousPosted(previous: PreviousFindings): string | null {
170
+ const posted = previous.findings.flatMap((f) => (f.label === 'posted' ? [f] : []))
171
+ if (posted.length === 0) return null
172
+ const count = (anchor: string) => posted.filter((f) => f.anchor === anchor).length
173
+ const parts = [
174
+ `Last review: ${posted.length} posted`,
175
+ `${count('changed')} on changed code`,
176
+ `${count('unchanged')} on unchanged code`,
177
+ ]
178
+ const unanchored = count('unanchored')
179
+ if (unanchored > 0) parts.push(`${unanchored} not anchored to a line`)
180
+ return parts.join(' · ')
181
+ }