@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.
- package/package.json +1 -1
- package/skills/index.json +2 -2
- package/skills/magpie/SKILL.md +21 -20
- package/skills/magpie/bin/magpie.ts +63 -2
- package/skills/magpie/evals/quality/README.md +74 -1
- package/skills/magpie/evals/quality/__tests__/probe-specialist.test.ts +498 -0
- package/skills/magpie/evals/quality/__tests__/replay-full.test.ts +9 -0
- package/skills/magpie/evals/quality/probe-specialist.ts +672 -0
- package/skills/magpie/evals/quality/replay-full.ts +6 -2
- package/skills/magpie/fixtures/fake-gh-with-lockfile.sh +37 -2
- package/skills/magpie/fixtures/fake-gh.sh +49 -2
- package/skills/magpie/references/critic.md +20 -4
- package/skills/magpie/references/proof.md +102 -0
- package/skills/magpie/references/scout.md +6 -1
- package/skills/magpie/references/specialists.md +15 -9
- package/skills/magpie/scripts/__tests__/cleanup-cmd.test.ts +103 -7
- package/skills/magpie/scripts/__tests__/cli.test.ts +69 -0
- package/skills/magpie/scripts/__tests__/critic-cmd.test.ts +46 -0
- package/skills/magpie/scripts/__tests__/critic.test.ts +11 -1
- package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +20 -1
- package/skills/magpie/scripts/__tests__/git-diff.test.ts +23 -4
- package/skills/magpie/scripts/__tests__/handoff-cmd.test.ts +68 -0
- package/skills/magpie/scripts/__tests__/history.test.ts +232 -0
- package/skills/magpie/scripts/__tests__/impact-cmd.test.ts +477 -0
- package/skills/magpie/scripts/__tests__/impact-render.test.ts +365 -0
- package/skills/magpie/scripts/__tests__/impact.test.ts +915 -0
- package/skills/magpie/scripts/__tests__/incremental.test.ts +59 -8
- package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +1 -1
- package/skills/magpie/scripts/__tests__/post-cmd.test.ts +58 -0
- package/skills/magpie/scripts/__tests__/pr-activity.test.ts +281 -0
- package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +4 -4
- package/skills/magpie/scripts/__tests__/previous-findings.test.ts +267 -0
- package/skills/magpie/scripts/__tests__/proof-cmd.test.ts +260 -0
- package/skills/magpie/scripts/__tests__/proof.test.ts +226 -0
- package/skills/magpie/scripts/__tests__/refresh.test.ts +71 -0
- package/skills/magpie/scripts/__tests__/render-annotation.test.ts +34 -0
- package/skills/magpie/scripts/__tests__/render-findings.test.ts +75 -0
- package/skills/magpie/scripts/__tests__/render-progress.test.ts +9 -0
- package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +376 -1
- package/skills/magpie/scripts/__tests__/shard-coverage.test.ts +79 -0
- package/skills/magpie/scripts/__tests__/skill-lint.test.ts +48 -2
- package/skills/magpie/scripts/__tests__/status-cmd.test.ts +20 -8
- package/skills/magpie/scripts/__tests__/types.test.ts +46 -0
- package/skills/magpie/scripts/cleanup-cmd.ts +88 -0
- package/skills/magpie/scripts/critic-cmd.ts +2 -0
- package/skills/magpie/scripts/critic.ts +4 -0
- package/skills/magpie/scripts/dedupe-cmd.ts +4 -0
- package/skills/magpie/scripts/evidence-filter.ts +10 -0
- package/skills/magpie/scripts/git-diff.ts +50 -19
- package/skills/magpie/scripts/handoff-cmd.ts +42 -3
- package/skills/magpie/scripts/history.ts +143 -0
- package/skills/magpie/scripts/impact-cmd.ts +202 -0
- package/skills/magpie/scripts/impact-render.ts +158 -0
- package/skills/magpie/scripts/impact.ts +622 -0
- package/skills/magpie/scripts/incremental.ts +44 -5
- package/skills/magpie/scripts/labels-cmd.ts +7 -3
- package/skills/magpie/scripts/post-cmd.ts +46 -0
- package/skills/magpie/scripts/pr-activity.ts +243 -0
- package/skills/magpie/scripts/preview-cmd.ts +3 -0
- package/skills/magpie/scripts/previous-findings.ts +181 -0
- package/skills/magpie/scripts/proof-cmd.ts +226 -0
- package/skills/magpie/scripts/proof.ts +150 -0
- package/skills/magpie/scripts/refresh.ts +11 -0
- package/skills/magpie/scripts/render-annotation.ts +17 -0
- package/skills/magpie/scripts/render-cmd.ts +12 -0
- package/skills/magpie/scripts/render-findings.ts +37 -1
- package/skills/magpie/scripts/render-progress.ts +3 -0
- package/skills/magpie/scripts/setup-cmd.ts +142 -11
- package/skills/magpie/scripts/shard-coverage.ts +53 -0
- package/skills/magpie/scripts/status-cmd.ts +2 -0
- package/skills/magpie/scripts/types.ts +39 -0
- package/skills/magpie/skill.json +1 -1
- package/skills/magpie/templates/styles.css +30 -0
- package/skills/sluice/scripts/status.sh +32 -13
- package/skills/sluice/skill.json +1 -1
|
@@ -8,9 +8,79 @@ export type RunCleanupInput = {
|
|
|
8
8
|
runDir: string
|
|
9
9
|
repoPath: string
|
|
10
10
|
gitBin: string
|
|
11
|
+
ghBin: string
|
|
11
12
|
killGraceMs?: number
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
async function run(
|
|
16
|
+
cmd: string[],
|
|
17
|
+
cwd: string,
|
|
18
|
+
): Promise<{ exit: number; stdout: string; stderr: string }> {
|
|
19
|
+
const proc = Bun.spawn(cmd, { cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
20
|
+
const [exit, stdout, stderr] = await Promise.all([
|
|
21
|
+
proc.exited,
|
|
22
|
+
new Response(proc.stdout).text(),
|
|
23
|
+
new Response(proc.stderr).text(),
|
|
24
|
+
])
|
|
25
|
+
return { exit, stdout, stderr }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Setup fetches a PR head into `refs/magpie/pr-<n>` when the commit is not local,
|
|
30
|
+
* and the ref keeps that head reachable for the next re-review's changed-files
|
|
31
|
+
* list. Once the PR is closed or merged nothing will re-review it.
|
|
32
|
+
*/
|
|
33
|
+
export type PruneResult = { deleted: string[]; failed: Array<{ ref: string; error: string }> }
|
|
34
|
+
|
|
35
|
+
/** The decision for one ref: true to delete. Throws when its PR state cannot be known. */
|
|
36
|
+
async function isClosedPr(ghBin: string, repoPath: string, n: string): Promise<boolean> {
|
|
37
|
+
const view = await run([ghBin, 'pr', 'view', n, '--json', 'state'], repoPath)
|
|
38
|
+
if (view.exit !== 0) throw new Error(`gh pr view ${n} exit ${view.exit}: ${view.stderr.trim()}`)
|
|
39
|
+
let state: unknown
|
|
40
|
+
try {
|
|
41
|
+
state = (JSON.parse(view.stdout) as { state?: unknown }).state
|
|
42
|
+
} catch (err) {
|
|
43
|
+
throw new Error(`gh pr view ${n}: ${err instanceof Error ? err.message : String(err)}`)
|
|
44
|
+
}
|
|
45
|
+
if (state === 'OPEN') return false
|
|
46
|
+
if (state === 'CLOSED' || state === 'MERGED') return true
|
|
47
|
+
throw new Error(`gh pr view ${n}: unexpected state ${JSON.stringify(state)}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Setup fetches a PR head into `refs/magpie/pr-<n>` when the commit is not local,
|
|
52
|
+
* and the ref keeps that head reachable for the next re-review's changed-files
|
|
53
|
+
* list. Once the PR is closed or merged nothing will re-review it. A ref whose
|
|
54
|
+
* PR cannot be checked is kept and reported, without holding up the others.
|
|
55
|
+
*/
|
|
56
|
+
export async function pruneClosedPrRefs(input: {
|
|
57
|
+
gitBin: string
|
|
58
|
+
ghBin: string
|
|
59
|
+
repoPath: string
|
|
60
|
+
}): Promise<PruneResult> {
|
|
61
|
+
const list = await run(
|
|
62
|
+
[input.gitBin, 'for-each-ref', '--format=%(refname)', 'refs/magpie/'],
|
|
63
|
+
input.repoPath,
|
|
64
|
+
)
|
|
65
|
+
if (list.exit !== 0) throw new Error(`git for-each-ref exit ${list.exit}: ${list.stderr.trim()}`)
|
|
66
|
+
const result: PruneResult = { deleted: [], failed: [] }
|
|
67
|
+
for (const ref of list.stdout.split('\n').filter(Boolean)) {
|
|
68
|
+
const match = ref.match(/^refs\/magpie\/pr-(\d+)$/)
|
|
69
|
+
if (!match) continue
|
|
70
|
+
try {
|
|
71
|
+
if (!(await isClosedPr(input.ghBin, input.repoPath, match[1] as string))) continue
|
|
72
|
+
const del = await run([input.gitBin, 'update-ref', '-d', ref], input.repoPath)
|
|
73
|
+
if (del.exit !== 0) {
|
|
74
|
+
throw new Error(`git update-ref -d ${ref} exit ${del.exit}: ${del.stderr.trim()}`)
|
|
75
|
+
}
|
|
76
|
+
result.deleted.push(ref)
|
|
77
|
+
} catch (err) {
|
|
78
|
+
result.failed.push({ ref, error: err instanceof Error ? err.message : String(err) })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return result
|
|
82
|
+
}
|
|
83
|
+
|
|
14
84
|
export type KillOutcome =
|
|
15
85
|
| 'no-server-info'
|
|
16
86
|
| 'no-pid'
|
|
@@ -95,6 +165,21 @@ export async function runCleanup(input: RunCleanupInput): Promise<number> {
|
|
|
95
165
|
await writeLabels(input.runDir)
|
|
96
166
|
}
|
|
97
167
|
|
|
168
|
+
// Pruning is housekeeping for the repo, so a failure warns rather than leaving the run unarchived.
|
|
169
|
+
let pruneError: string | null = null
|
|
170
|
+
if (mode === 'pr') {
|
|
171
|
+
try {
|
|
172
|
+
const pruned = await pruneClosedPrRefs(input)
|
|
173
|
+
await appendFile(
|
|
174
|
+
join(input.runDir, 'log.jsonl'),
|
|
175
|
+
`${JSON.stringify({ stage: 'cleanup', status: 'prune-refs', deleted: pruned.deleted, failed: pruned.failed.map((f) => f.ref), ts: Date.now() })}\n`,
|
|
176
|
+
)
|
|
177
|
+
if (pruned.failed.length > 0) pruneError = pruned.failed.map((f) => f.error).join('; ')
|
|
178
|
+
} catch (err) {
|
|
179
|
+
pruneError = err instanceof Error ? err.message : String(err)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
98
183
|
const target = `${input.runDir}.archived-${Date.now()}`
|
|
99
184
|
await rename(input.runDir, target)
|
|
100
185
|
process.stdout.write(`archived to ${target}\n`)
|
|
@@ -102,6 +187,9 @@ export async function runCleanup(input: RunCleanupInput): Promise<number> {
|
|
|
102
187
|
if (archivedId && mode !== 'local') {
|
|
103
188
|
process.stdout.write(`view later: magpie open ${archivedId}\n`)
|
|
104
189
|
}
|
|
190
|
+
if (pruneError !== null) {
|
|
191
|
+
process.stderr.write(`magpie: warning: could not prune refs/magpie: ${pruneError}\n`)
|
|
192
|
+
}
|
|
105
193
|
if (kill.outcome === 'failed') {
|
|
106
194
|
process.stderr.write(
|
|
107
195
|
`magpie: warning: server process pid=${kill.pid} survived cleanup; kill it manually\n`,
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
DEFAULT_BATCH_SIZE,
|
|
8
8
|
parseCriticVerdicts,
|
|
9
9
|
} from './critic.ts'
|
|
10
|
+
import { readHistory, renderAlreadyRaised } from './history.ts'
|
|
10
11
|
import { reviewRoot } from './target.ts'
|
|
11
12
|
import { parseBrief, parseFinding, type ReviewFinding, type ReviewRule } from './types.ts'
|
|
12
13
|
|
|
@@ -85,6 +86,7 @@ export async function runCriticPrompt(
|
|
|
85
86
|
candidates: await readCandidates(runDir),
|
|
86
87
|
mergeCandidates: await readMergeCandidates(runDir),
|
|
87
88
|
reviewRules: await readReviewRules(runDir),
|
|
89
|
+
alreadyRaised: renderAlreadyRaised(await readHistory(runDir)),
|
|
88
90
|
worktree: await reviewRoot(runDir),
|
|
89
91
|
diffPath: join(runDir, 'diff.patch'),
|
|
90
92
|
runDir,
|
|
@@ -19,6 +19,7 @@ export const CRITIC_PLACEHOLDERS = [
|
|
|
19
19
|
'<<CANDIDATES>>',
|
|
20
20
|
'<<MERGE_CANDIDATES>>',
|
|
21
21
|
'<<REVIEW_RULES>>',
|
|
22
|
+
'<<ALREADY_RAISED>>',
|
|
22
23
|
'<<WORKTREE>>',
|
|
23
24
|
'<<DIFF_PATH>>',
|
|
24
25
|
'<<OUTPUT_PATH>>',
|
|
@@ -237,6 +238,8 @@ export function buildCriticPrompts(input: {
|
|
|
237
238
|
candidates: ReviewFinding[]
|
|
238
239
|
mergeCandidates: string[][]
|
|
239
240
|
reviewRules: ReviewRule[]
|
|
241
|
+
/** Rendered by `renderAlreadyRaised`, `(none)` when there is nothing. */
|
|
242
|
+
alreadyRaised: string
|
|
240
243
|
worktree: string
|
|
241
244
|
diffPath: string
|
|
242
245
|
runDir: string
|
|
@@ -258,6 +261,7 @@ export function buildCriticPrompts(input: {
|
|
|
258
261
|
'<<CANDIDATES>>': renderCandidates(batch),
|
|
259
262
|
'<<MERGE_CANDIDATES>>': JSON.stringify(groups, null, 2),
|
|
260
263
|
'<<REVIEW_RULES>>': renderRules(input.reviewRules),
|
|
264
|
+
'<<ALREADY_RAISED>>': input.alreadyRaised,
|
|
261
265
|
'<<WORKTREE>>': input.worktree,
|
|
262
266
|
'<<DIFF_PATH>>': input.diffPath,
|
|
263
267
|
'<<OUTPUT_PATH>>': outputPath,
|
|
@@ -6,6 +6,7 @@ import { deduplicateFindings, findMergeCandidates } from './dedupe.ts'
|
|
|
6
6
|
import { verifyEvidence } from './evidence-filter.ts'
|
|
7
7
|
import { namespaceId, parseFindingsFilename } from './findings-files.ts'
|
|
8
8
|
import { DEFAULT_THRESHOLD, scoreRisk } from './score.ts'
|
|
9
|
+
import { readShardCoverage } from './shard-coverage.ts'
|
|
9
10
|
import { readTarget } from './target.ts'
|
|
10
11
|
import { FOCUS_IDS, parseFinding, type ReviewFinding } from './types.ts'
|
|
11
12
|
|
|
@@ -53,9 +54,12 @@ async function reconcileCoverage(runDir: string, present: Set<string>): Promise<
|
|
|
53
54
|
// A single-shard manifest means stage 4 took the unsharded path, so its
|
|
54
55
|
// specialists write `<focus>.json` with no shard suffix.
|
|
55
56
|
const sharded = shards.length > 1
|
|
57
|
+
// A shard skipped at the stage 4 gate has no findings by design, and the report says so.
|
|
58
|
+
const skipped = new Set((await readShardCoverage(runDir))?.skipped.map((s) => s.id))
|
|
56
59
|
const expected: string[] = []
|
|
57
60
|
for (const [i, shard] of shards.entries()) {
|
|
58
61
|
const id = typeof shard?.id === 'number' ? shard.id : i + 1
|
|
62
|
+
if (skipped.has(id)) continue
|
|
59
63
|
for (const focus of SPECIALIST_FOCUS_IDS) {
|
|
60
64
|
expected.push(sharded ? `${focus}.shard-${id}.json` : `${focus}.json`)
|
|
61
65
|
}
|
|
@@ -72,6 +72,16 @@ function locateEvidence(fileLines: string[], snippet: string[], line: number): n
|
|
|
72
72
|
return matches.length === 1 ? (matches[0] ?? null) : null
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** True when `evidence` appears anywhere in `fileText`, whitespace normalised as above. */
|
|
76
|
+
export function containsEvidence(fileText: string, evidence: string): boolean {
|
|
77
|
+
const lines = fileText.split('\n')
|
|
78
|
+
const snippet = evidence.trim().split('\n').map(normalise)
|
|
79
|
+
for (let start = 1; start <= lines.length; start++) {
|
|
80
|
+
if (matchesAt(lines, snippet, start)) return true
|
|
81
|
+
}
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
|
|
75
85
|
export async function verifyEvidence(
|
|
76
86
|
findings: ReviewFinding[],
|
|
77
87
|
worktreePath: string,
|
|
@@ -14,8 +14,14 @@ async function git(
|
|
|
14
14
|
gitBin: string,
|
|
15
15
|
cwd: string,
|
|
16
16
|
args: string[],
|
|
17
|
+
env?: Record<string, string>,
|
|
17
18
|
): Promise<{ exit: number; stdout: string; stderr: string }> {
|
|
18
|
-
const proc = Bun.spawn([gitBin, ...args], {
|
|
19
|
+
const proc = Bun.spawn([gitBin, ...args], {
|
|
20
|
+
cwd,
|
|
21
|
+
stdout: 'pipe',
|
|
22
|
+
stderr: 'pipe',
|
|
23
|
+
...(env ? { env: { ...process.env, ...env } } : {}),
|
|
24
|
+
})
|
|
19
25
|
const stdout = await new Response(proc.stdout).text()
|
|
20
26
|
const stderr = await new Response(proc.stderr).text()
|
|
21
27
|
const exit = await proc.exited
|
|
@@ -49,31 +55,47 @@ function fetchDiagnostic(result: { exit: number; stderr: string }): string {
|
|
|
49
55
|
* `gh pr diff`'s three-dot semantics: the PR's own changes, not every commit
|
|
50
56
|
* the base branch gained since the branch point.
|
|
51
57
|
*/
|
|
52
|
-
export
|
|
53
|
-
|
|
58
|
+
export type PrHeadInput = {
|
|
59
|
+
gitBin: string
|
|
60
|
+
repoPath: string
|
|
61
|
+
prNumber: number
|
|
62
|
+
headRefOid: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The PR's head commit in the local clone, exactly `headRefOid`. The local
|
|
67
|
+
* branch named `headRefName` can be behind, ahead with unpushed commits, or
|
|
68
|
+
* absent on a fork PR, so it is never what gets reviewed.
|
|
69
|
+
*/
|
|
70
|
+
export async function resolvePrHead(
|
|
71
|
+
input: PrHeadInput,
|
|
72
|
+
): Promise<{ ok: true; sha: string } | { ok: false; error: string }> {
|
|
73
|
+
const { gitBin, repoPath, prNumber, headRefOid } = input
|
|
54
74
|
const prRef = `refs/magpie/pr-${prNumber}`
|
|
55
75
|
|
|
76
|
+
// Present, the SHA is the PR head by definition, so there is nothing to fetch
|
|
77
|
+
// and a stale ref from an earlier run does not matter.
|
|
78
|
+
const local = await resolveCommit(gitBin, repoPath, headRefOid)
|
|
79
|
+
if (local) return { ok: true, sha: local }
|
|
80
|
+
|
|
56
81
|
// Best effort. `pull/<n>/head` is a GitHub convention and resolves fork PRs
|
|
57
|
-
// that `headRefName` cannot
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
82
|
+
// that `headRefName` cannot. The fetch's own result is kept (not discarded)
|
|
83
|
+
// so, if resolution below still fails, the error can say why the fetch didn't
|
|
84
|
+
// help. The `+` lets the ref follow a force-push; without it git keeps the head
|
|
85
|
+
// an earlier run fetched. No terminal prompt, so missing credentials fail
|
|
86
|
+
// instead of waiting on a tty nobody is watching.
|
|
87
|
+
const headFetch = await git(
|
|
88
|
+
gitBin,
|
|
89
|
+
repoPath,
|
|
90
|
+
['fetch', 'origin', `+pull/${prNumber}/head:${prRef}`],
|
|
91
|
+
{ GIT_TERMINAL_PROMPT: '0' },
|
|
92
|
+
)
|
|
67
93
|
|
|
68
|
-
|
|
69
|
-
for (const ref of [prRef, headRefOid]) {
|
|
70
|
-
head = await resolveCommit(gitBin, repoPath, ref)
|
|
71
|
-
if (head) break
|
|
72
|
-
}
|
|
94
|
+
const head = await resolveCommit(gitBin, repoPath, prRef)
|
|
73
95
|
if (!head) {
|
|
74
96
|
return {
|
|
75
97
|
ok: false,
|
|
76
|
-
error: `cannot resolve PR head ${headRefOid} locally (tried ${prRef}
|
|
98
|
+
error: `cannot resolve PR head ${headRefOid} locally (tried the SHA and ${prRef}); run \`git fetch <remote> pull/${prNumber}/head\` from the remote that holds the PR, then retry${fetchDiagnostic(headFetch)}`,
|
|
77
99
|
}
|
|
78
100
|
}
|
|
79
101
|
// Reviewing a stale local ref and reporting it as a review of the PR is worse
|
|
@@ -84,6 +106,15 @@ export async function localPrDiff(input: LocalDiffInput): Promise<LocalDiffResul
|
|
|
84
106
|
error: `local PR head ${head} does not match the PR head ${headRefOid}; the local ref is stale`,
|
|
85
107
|
}
|
|
86
108
|
}
|
|
109
|
+
return { ok: true, sha: head }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function localPrDiff(input: LocalDiffInput): Promise<LocalDiffResult> {
|
|
113
|
+
const { gitBin, repoPath, prNumber, baseRefName, headRefOid } = input
|
|
114
|
+
const resolved = await resolvePrHead({ gitBin, repoPath, prNumber, headRefOid })
|
|
115
|
+
if (!resolved.ok) return resolved
|
|
116
|
+
const head = resolved.sha
|
|
117
|
+
const baseFetch = await git(gitBin, repoPath, ['fetch', 'origin', baseRefName])
|
|
87
118
|
|
|
88
119
|
let base: string | null = null
|
|
89
120
|
for (const ref of [`origin/${baseRefName}`, baseRefName]) {
|
|
@@ -3,7 +3,10 @@ import { appendFile, readFile } from 'node:fs/promises'
|
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { formatFindingDescriptionMarkdown } from './finding-description.ts'
|
|
5
5
|
import { diffSha256, localBranchDiff } from './local-diff.ts'
|
|
6
|
+
import type { ProofDropped } from './proof.ts'
|
|
7
|
+
import { readProofDropped } from './proof-cmd.ts'
|
|
6
8
|
import { rankActionable } from './render-issues-list.ts'
|
|
9
|
+
import { describeUnreviewed, readShardCoverage, type ShardCoverage } from './shard-coverage.ts'
|
|
7
10
|
import { readTarget } from './target.ts'
|
|
8
11
|
import { isSuggestion, parseFinding, type ReviewFinding, type Suggestion } from './types.ts'
|
|
9
12
|
|
|
@@ -16,6 +19,18 @@ export type FormatHandoffInput = {
|
|
|
16
19
|
target: string
|
|
17
20
|
findings: ReviewFinding[]
|
|
18
21
|
stale: boolean
|
|
22
|
+
/** Shards skipped at the stage 4 gate; absent when every shard was reviewed. */
|
|
23
|
+
unreviewed?: ShardCoverage
|
|
24
|
+
/** Findings the proof stage removed because a passing test showed the code is right. */
|
|
25
|
+
disproved?: ProofDropped[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatDisproved(disproved: ProofDropped[]): string {
|
|
29
|
+
const lines = disproved.map((d) => {
|
|
30
|
+
const anchor = d.line === null ? d.file : `${d.file}:${d.line}`
|
|
31
|
+
return `- ${anchor}: ${d.title} (${d.reason}; \`${d.repro.command}\`)`
|
|
32
|
+
})
|
|
33
|
+
return ['Dropped because a passing test disproved them:', ...lines].join('\n')
|
|
19
34
|
}
|
|
20
35
|
|
|
21
36
|
/** A fence longer than any backtick run in the body, so the body cannot close it early. */
|
|
@@ -31,22 +46,37 @@ function formatSuggestion(s: Suggestion): string {
|
|
|
31
46
|
return `Suggested replacement for ${lines}:\n${fence}\n${s.body}\n${fence}`
|
|
32
47
|
}
|
|
33
48
|
|
|
49
|
+
function formatProof(f: ReviewFinding): string | null {
|
|
50
|
+
if (f.proof?.status === 'not-proven') return `Not proven: ${f.proof.reason}`
|
|
51
|
+
if (f.proof?.status !== 'reproduced') return null
|
|
52
|
+
const { command, source } = f.proof.repro
|
|
53
|
+
const fence = fenceFor(source)
|
|
54
|
+
return `Reproduced with \`${command}\`:\n${fence}\n${source}\n${fence}`
|
|
55
|
+
}
|
|
56
|
+
|
|
34
57
|
function formatItem(n: number, f: ReviewFinding): string {
|
|
35
58
|
const anchor = f.line === null ? f.file : `${f.file}:${f.line}`
|
|
36
59
|
const parts = [`${n}. [${f.risk.action}] ${anchor}: ${f.title}`]
|
|
37
60
|
const body = formatFindingDescriptionMarkdown(f.description)
|
|
38
61
|
if (body) parts.push(body)
|
|
39
62
|
if (f.suggestion) parts.push(formatSuggestion(f.suggestion))
|
|
63
|
+
const proof = formatProof(f)
|
|
64
|
+
if (proof) parts.push(proof)
|
|
40
65
|
return parts.join('\n\n')
|
|
41
66
|
}
|
|
42
67
|
|
|
43
68
|
export function formatHandoff(input: FormatHandoffInput): string {
|
|
44
|
-
const { target, findings, stale } = input
|
|
69
|
+
const { target, findings, stale, unreviewed, disproved = [] } = input
|
|
45
70
|
const header = [`# magpie local review: ${target}`]
|
|
46
71
|
if (stale) {
|
|
47
72
|
header.push('Warning: the tree changed since this review began; line anchors may be off.')
|
|
48
73
|
}
|
|
49
|
-
if (
|
|
74
|
+
if (unreviewed) {
|
|
75
|
+
const files = unreviewed.skipped.flatMap((s) => s.files.map((f) => `- ${f}`))
|
|
76
|
+
header.push(`Warning: ${describeUnreviewed(unreviewed)}:`, ...files)
|
|
77
|
+
}
|
|
78
|
+
const tail = disproved.length > 0 ? [formatDisproved(disproved)] : []
|
|
79
|
+
if (findings.length === 0) return `${[header.join('\n'), 'No findings.', ...tail].join('\n\n')}\n`
|
|
50
80
|
|
|
51
81
|
const mustFix = findings.filter((f) => f.risk.action === 'must-fix').length
|
|
52
82
|
const shouldFix = findings.filter((f) => f.risk.action === 'should-fix').length
|
|
@@ -60,6 +90,7 @@ export function formatHandoff(input: FormatHandoffInput): string {
|
|
|
60
90
|
`${mustFix} must-fix, ${shouldFix} should-fix, ${optional} optional`,
|
|
61
91
|
...ordered.map((f, i) => formatItem(i + 1, f)),
|
|
62
92
|
'Address every must-fix and should-fix, or say why not. The rest are optional.',
|
|
93
|
+
...tail,
|
|
63
94
|
]
|
|
64
95
|
return `${sections.join('\n\n')}\n`
|
|
65
96
|
}
|
|
@@ -124,8 +155,16 @@ export async function runHandoff(runDir: string, opts: { gitBin?: string } = {})
|
|
|
124
155
|
const stale = recorded === null || currentSha === null || currentSha !== recorded
|
|
125
156
|
|
|
126
157
|
const subject = meta.branch === null ? 'detached HEAD' : `local branch ${meta.branch}`
|
|
158
|
+
const unreviewed = await readShardCoverage(runDir)
|
|
159
|
+
const disproved = await readProofDropped(runDir)
|
|
127
160
|
process.stdout.write(
|
|
128
|
-
formatHandoff({
|
|
161
|
+
formatHandoff({
|
|
162
|
+
target: `${subject} against ${meta.baseRef}`,
|
|
163
|
+
findings,
|
|
164
|
+
stale,
|
|
165
|
+
...(unreviewed ? { unreviewed } : {}),
|
|
166
|
+
disproved,
|
|
167
|
+
}),
|
|
129
168
|
)
|
|
130
169
|
await logLine(runDir, { stage: 'handoff', status: 'done', findings: findings.length })
|
|
131
170
|
return 0
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { PrActivity } from './pr-activity.ts'
|
|
4
|
+
import type { PreviousFindings } from './previous-findings.ts'
|
|
5
|
+
|
|
6
|
+
export type HistoryIncremental = {
|
|
7
|
+
previousSha: string
|
|
8
|
+
changedSincePrevious: string[] | null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type History = {
|
|
12
|
+
activity: PrActivity | null
|
|
13
|
+
previous: PreviousFindings | null
|
|
14
|
+
incremental: HistoryIncremental | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MAX_BODY = 280
|
|
18
|
+
|
|
19
|
+
// Conclusions that mean the check found something; CANCELLED and SKIPPED say nothing about the code.
|
|
20
|
+
const FAILING = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE'])
|
|
21
|
+
|
|
22
|
+
function flatten(body: string): string {
|
|
23
|
+
const text = body
|
|
24
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
25
|
+
.replace(/\s+/g, ' ')
|
|
26
|
+
.trim()
|
|
27
|
+
return text.length > MAX_BODY ? `${text.slice(0, MAX_BODY)}…` : text
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function location(file: string, line: number | null): string {
|
|
31
|
+
return line === null ? file : `${file}:${line}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function section(heading: string, lines: string[]): string[] {
|
|
35
|
+
return lines.length === 0 ? [] : [`${heading}:`, ...lines]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function joinSections(sections: string[][]): string {
|
|
39
|
+
return sections
|
|
40
|
+
.filter((s) => s.length > 0)
|
|
41
|
+
.map((s) => s.join('\n'))
|
|
42
|
+
.join('\n\n')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The account GitHub Actions posts as: coverage tables, deploy previews and other
|
|
46
|
+
// CI output, which reaches the review as checks. Review bots post as their own
|
|
47
|
+
// apps, and what they raise still counts. gh prints the login without `[bot]`,
|
|
48
|
+
// the REST API with it.
|
|
49
|
+
const CI_AUTHORS = new Set(['github-actions', 'github-actions[bot]'])
|
|
50
|
+
|
|
51
|
+
function raisedSections(input: Pick<History, 'activity' | 'previous'>): string[][] {
|
|
52
|
+
const threads = (input.activity?.threads ?? [])
|
|
53
|
+
.filter((t) => !t.resolved)
|
|
54
|
+
.map((t) => {
|
|
55
|
+
const who = t.magpie ? 'magpie, earlier run' : `${t.author}, ${t.replies} replies`
|
|
56
|
+
const outdated = t.outdated ? ' [outdated]' : ''
|
|
57
|
+
return `- ${location(t.file, t.line)} (${who})${outdated}: ${flatten(t.body)}`
|
|
58
|
+
})
|
|
59
|
+
const comments = (input.activity?.comments ?? [])
|
|
60
|
+
.filter((c) => !CI_AUTHORS.has(c.author))
|
|
61
|
+
.map((c) => `- ${c.magpie ? 'magpie, earlier run' : c.author}: ${flatten(c.body)}`)
|
|
62
|
+
const dismissed = (input.previous?.findings ?? []).flatMap((f) =>
|
|
63
|
+
f.label === 'dismissed' ? [`- ${location(f.file, f.line)} [${f.reason}]: ${f.title}`] : [],
|
|
64
|
+
)
|
|
65
|
+
return [
|
|
66
|
+
section('Open review threads', threads),
|
|
67
|
+
section('PR comments', comments),
|
|
68
|
+
section('Dismissed by the reviewer in an earlier magpie run', dismissed),
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What the critic weighs a candidate against for the "already raised" drop. */
|
|
73
|
+
export function renderAlreadyRaised(input: Pick<History, 'activity' | 'previous'>): string {
|
|
74
|
+
return joinSections(raisedSections(input)) || '(none)'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The specialists' "Already on this PR" prompt part; empty when there is nothing to say. */
|
|
78
|
+
export function renderHistory(history: History): string {
|
|
79
|
+
const checks = (history.activity?.checks ?? [])
|
|
80
|
+
.filter((c) => FAILING.has(c.conclusion))
|
|
81
|
+
.map((c) => `- ${c.name}: ${c.conclusion}${c.url ? ` (${c.url})` : ''}`)
|
|
82
|
+
const changed = history.incremental?.changedSincePrevious ?? []
|
|
83
|
+
const sinceHeading = history.incremental
|
|
84
|
+
? `Files changed since the last review at ${history.incremental.previousSha.slice(0, 7)} (the whole diff is still your scope)`
|
|
85
|
+
: ''
|
|
86
|
+
const raised = joinSections(raisedSections(history))
|
|
87
|
+
const leads = joinSections([
|
|
88
|
+
section('Failing CI checks (a lead to investigate, not a finding in itself)', checks),
|
|
89
|
+
section(
|
|
90
|
+
sinceHeading,
|
|
91
|
+
changed.map((f) => `- ${f}`),
|
|
92
|
+
),
|
|
93
|
+
])
|
|
94
|
+
if (raised === '' && leads === '') return ''
|
|
95
|
+
const parts = ['## Already on this PR']
|
|
96
|
+
if (raised !== '') {
|
|
97
|
+
parts.push(
|
|
98
|
+
'The threads, comments and dismissals below were raised before this review. Do not report a finding that repeats one of them: the critic drops repeats. Report it again only when you have something material it lacks, and say what that is.',
|
|
99
|
+
raised,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
if (leads !== '') {
|
|
103
|
+
parts.push('Leads, not repeats: a finding that explains one of these is wanted.', leads)
|
|
104
|
+
}
|
|
105
|
+
return parts.join('\n\n')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function readSidecar<T>(runDir: string, name: string): Promise<T | null> {
|
|
109
|
+
const path = join(runDir, name)
|
|
110
|
+
let text: string
|
|
111
|
+
try {
|
|
112
|
+
text = await readFile(path, 'utf8')
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return null
|
|
115
|
+
throw err
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(text) as T
|
|
119
|
+
} catch (err) {
|
|
120
|
+
throw new Error(`${path}: ${err instanceof Error ? err.message : String(err)}`)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Each sidecar is optional: local runs, first runs and runs from older versions lack them. */
|
|
125
|
+
export async function readHistory(runDir: string): Promise<History> {
|
|
126
|
+
const incremental = await readSidecar<{
|
|
127
|
+
previousSha?: unknown
|
|
128
|
+
changedSincePrevious?: unknown
|
|
129
|
+
}>(runDir, 'incremental.json')
|
|
130
|
+
return {
|
|
131
|
+
activity: await readSidecar<PrActivity>(runDir, 'pr-activity.json'),
|
|
132
|
+
previous: await readSidecar<PreviousFindings>(runDir, 'previous-findings.json'),
|
|
133
|
+
incremental:
|
|
134
|
+
incremental && typeof incremental.previousSha === 'string'
|
|
135
|
+
? {
|
|
136
|
+
previousSha: incremental.previousSha,
|
|
137
|
+
changedSincePrevious: Array.isArray(incremental.changedSincePrevious)
|
|
138
|
+
? (incremental.changedSincePrevious as string[])
|
|
139
|
+
: null,
|
|
140
|
+
}
|
|
141
|
+
: null,
|
|
142
|
+
}
|
|
143
|
+
}
|