@iceinvein/agent-skills 0.22.0 → 0.23.0
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/README.md +1 -1
- package/package.json +1 -1
- package/skills/bug-hunter/SKILL.md +5 -0
- package/skills/bug-hunter/evals/README.md +6 -1
- package/skills/bug-hunter/evals/callers-tree-unchanged/graders/output-ignores-itself.md +11 -0
- package/skills/bug-hunter/evals/rules.json +8 -0
- package/skills/bug-hunter/skill.json +1 -1
- package/skills/index.json +4 -4
- package/skills/magpie/README.md +5 -3
- package/skills/magpie/SKILL.md +40 -15
- package/skills/magpie/bin/magpie.ts +83 -7
- package/skills/magpie/evals/README.md +7 -4
- package/skills/magpie/evals/local-mode-hands-back/case.yaml +4 -0
- package/skills/magpie/evals/local-mode-hands-back/fixture.sh +300 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/branch-was-reachable.md +5 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/cleanup-after-handoff.md +6 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/handoff-was-run.md +6 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/nothing-served-or-posted.md +7 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/reply-lists-both-findings.md +10 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/skill-fired.md +5 -0
- package/skills/magpie/evals/local-mode-hands-back/prompt.md +11 -0
- package/skills/magpie/evals/quality/README.md +73 -2
- package/skills/magpie/evals/quality/__tests__/recall.test.ts +234 -0
- package/skills/magpie/evals/quality/__tests__/replay-full.test.ts +164 -0
- package/skills/magpie/evals/quality/corpus.ts +4 -1
- package/skills/magpie/evals/quality/recall.ts +249 -0
- package/skills/magpie/evals/quality/replay-critic.ts +3 -0
- package/skills/magpie/evals/quality/replay-full.ts +168 -6
- package/skills/magpie/evals/rules.json +9 -1
- package/skills/magpie/references/checks.md +78 -0
- package/skills/magpie/references/peer-review.md +7 -0
- package/skills/magpie/references/scout.md +12 -5
- package/skills/magpie/references/specialists.md +10 -8
- package/skills/magpie/scripts/__tests__/cleanup-cmd.test.ts +94 -0
- package/skills/magpie/scripts/__tests__/critic-cmd.test.ts +18 -0
- package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +80 -1
- package/skills/magpie/scripts/__tests__/handoff-cmd.test.ts +319 -0
- package/skills/magpie/scripts/__tests__/housekeeping-cmd.test.ts +14 -0
- package/skills/magpie/scripts/__tests__/local-diff.test.ts +275 -0
- package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +1 -0
- package/skills/magpie/scripts/__tests__/resolve-run.test.ts +28 -0
- package/skills/magpie/scripts/__tests__/server.test.ts +30 -1
- package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +324 -1
- package/skills/magpie/scripts/__tests__/skill-lint.test.ts +8 -5
- package/skills/magpie/scripts/__tests__/status-cmd.test.ts +44 -0
- package/skills/magpie/scripts/__tests__/target.test.ts +84 -0
- package/skills/magpie/scripts/__tests__/types.test.ts +2 -1
- package/skills/magpie/scripts/cleanup-cmd.ts +5 -1
- package/skills/magpie/scripts/critic-cmd.ts +2 -1
- package/skills/magpie/scripts/dedupe-cmd.ts +15 -5
- package/skills/magpie/scripts/handoff-cmd.ts +132 -0
- package/skills/magpie/scripts/housekeeping-cmd.ts +7 -5
- package/skills/magpie/scripts/local-diff.ts +228 -0
- package/skills/magpie/scripts/post-cmd.ts +1 -0
- package/skills/magpie/scripts/render-annotation.ts +1 -0
- package/skills/magpie/scripts/resolve-run.ts +17 -3
- package/skills/magpie/scripts/server.ts +9 -1
- package/skills/magpie/scripts/setup-cmd.ts +241 -49
- package/skills/magpie/scripts/status-cmd.ts +22 -6
- package/skills/magpie/scripts/target.ts +56 -0
- package/skills/magpie/scripts/types.ts +1 -0
- package/skills/magpie/skill.json +2 -2
- package/skills/sluice/SKILL.md +3 -1
- package/skills/sluice/evals/README.md +10 -2
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/case.yaml +4 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/fixture.sh +108 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/deploy-names-its-steps.md +9 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/kept-status-by-changing-code.md +12 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/sluice-fired.md +5 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/status-test-unchanged.md +6 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/prompt.md +11 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/case.yaml +4 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/fixture.sh +73 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/proved-after-the-last-edit.md +18 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/sluice-fired.md +5 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/test-expects-the-new-line.md +5 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/updated-without-asking.md +11 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/prompt.md +11 -0
- package/skills/sluice/evals/rules.json +11 -0
- package/skills/sluice/references/review.md +6 -4
- package/skills/sluice/references/test-first.md +24 -4
- package/skills/sluice/scripts/content-digest.sh +1 -1
- package/skills/sluice/scripts/run-stats.sh +14 -0
- package/skills/sluice/scripts/session-start.sh +44 -2
- package/skills/sluice/scripts/status.sh +293 -0
- package/skills/sluice/scripts/stop-guard.sh +80 -6
- package/skills/sluice/scripts/test-changes.sh +135 -0
- package/skills/sluice/skill.json +1 -1
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// the labelled ones, and scores them. Spends real money: the whole review runs
|
|
5
5
|
// again, capped by --max-cost-usd.
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { existsSync } from 'node:fs'
|
|
8
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
8
9
|
import { tmpdir } from 'node:os'
|
|
9
10
|
import { join } from 'node:path'
|
|
10
11
|
import { detectMissingTests } from '../../scripts/tests-check.ts'
|
|
@@ -15,6 +16,14 @@ import {
|
|
|
15
16
|
readFindings,
|
|
16
17
|
resolveCorpusRoot,
|
|
17
18
|
} from './corpus.ts'
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_JUDGE_MODEL,
|
|
21
|
+
judgeRecall,
|
|
22
|
+
loadMisses,
|
|
23
|
+
type RecallScore,
|
|
24
|
+
type RecallVerdict,
|
|
25
|
+
scoreRecall,
|
|
26
|
+
} from './recall.ts'
|
|
18
27
|
import { claudeFailureMessage, worktreeRemoveCommand } from './replay-critic.ts'
|
|
19
28
|
import {
|
|
20
29
|
formatRatio,
|
|
@@ -29,6 +38,117 @@ const SKILL_DIR = new URL('../..', import.meta.url).pathname.replace(/\/$/, '')
|
|
|
29
38
|
const MAGPIE_BIN = join(SKILL_DIR, 'bin', 'magpie.ts')
|
|
30
39
|
const DEFAULT_MAX_COST_USD = 10
|
|
31
40
|
|
|
41
|
+
export type ReplayDiagnostics = {
|
|
42
|
+
log: Array<Record<string, unknown>>
|
|
43
|
+
shardManifest: unknown | null
|
|
44
|
+
findingsFiles: string[]
|
|
45
|
+
dedupedCount: number | null
|
|
46
|
+
criticDropped: unknown | null
|
|
47
|
+
evidenceDropped: unknown | null
|
|
48
|
+
thresholdDropped: unknown | null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function readJsonIfPresent(path: string): Promise<unknown | null> {
|
|
52
|
+
if (!existsSync(path)) return null
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(await readFile(path, 'utf8'))
|
|
55
|
+
} catch (err) {
|
|
56
|
+
throw new Error(`${path} is not valid JSON: ${err instanceof Error ? err.message : err}`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What the scratch run dir says about the session, read before the dir is deleted:
|
|
61
|
+
* without it a thin result cannot be told apart as missed shards or critic drops.
|
|
62
|
+
* The model writes most log lines by hand, so a line that is not a JSON object is
|
|
63
|
+
* kept raw in its place rather than failing a replay that has already been paid for. */
|
|
64
|
+
export async function replayDiagnostics(runDir: string): Promise<ReplayDiagnostics> {
|
|
65
|
+
const log: Array<Record<string, unknown>> = []
|
|
66
|
+
for (const [i, line] of (await readFile(join(runDir, 'log.jsonl'), 'utf8')).split('\n').entries()) {
|
|
67
|
+
if (line.trim() === '') continue
|
|
68
|
+
let entry: unknown
|
|
69
|
+
try {
|
|
70
|
+
entry = JSON.parse(line)
|
|
71
|
+
} catch {
|
|
72
|
+
entry = null
|
|
73
|
+
}
|
|
74
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
75
|
+
log.push({ unparsed: line, line: i + 1 })
|
|
76
|
+
} else {
|
|
77
|
+
log.push(entry as Record<string, unknown>)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const dedupedPath = join(runDir, 'findings.deduped.json')
|
|
82
|
+
const deduped = await readJsonIfPresent(dedupedPath)
|
|
83
|
+
if (deduped !== null && !Array.isArray(deduped)) {
|
|
84
|
+
throw new Error(`${dedupedPath} is not a JSON array`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
log,
|
|
89
|
+
shardManifest: await readJsonIfPresent(join(runDir, 'shards', 'manifest.json')),
|
|
90
|
+
findingsFiles: (await readdir(join(runDir, 'findings'))).sort(),
|
|
91
|
+
dedupedCount: deduped === null ? null : deduped.length,
|
|
92
|
+
criticDropped: await readJsonIfPresent(join(runDir, 'critic-dropped.json')),
|
|
93
|
+
evidenceDropped: await readJsonIfPresent(join(runDir, 'evidence-dropped.json')),
|
|
94
|
+
thresholdDropped: await readJsonIfPresent(join(runDir, 'threshold-dropped.json')),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Shards some specialist wrote a findings file for, out of the manifest's shards.
|
|
99
|
+
* A single-shard run takes the unsharded path, so its files are `<focus>.json`;
|
|
100
|
+
* `tests.json` is the deterministic setup finding and proves no specialist ran. */
|
|
101
|
+
export function shardCoverageCount(
|
|
102
|
+
manifest: unknown | null,
|
|
103
|
+
findingsFiles: string[],
|
|
104
|
+
): { reviewed: number; total: number } {
|
|
105
|
+
if (manifest === null) return { reviewed: 0, total: 0 }
|
|
106
|
+
const shards =
|
|
107
|
+
typeof manifest === 'object' && 'shards' in manifest ? manifest.shards : undefined
|
|
108
|
+
if (!Array.isArray(shards)) throw new Error('shard manifest has no shards array')
|
|
109
|
+
if (shards.length <= 1) {
|
|
110
|
+
const reviewed = findingsFiles.some((f) => /^[^.]+\.json$/.test(f) && f !== 'tests.json')
|
|
111
|
+
return { reviewed: reviewed && shards.length === 1 ? 1 : 0, total: shards.length }
|
|
112
|
+
}
|
|
113
|
+
const ids = new Set(
|
|
114
|
+
shards.map((s, i) => {
|
|
115
|
+
const id = typeof s === 'object' && s !== null && 'id' in s ? s.id : undefined
|
|
116
|
+
if (typeof id !== 'number') throw new Error(`shard manifest shards[${i}] has no numeric id`)
|
|
117
|
+
return id
|
|
118
|
+
}),
|
|
119
|
+
)
|
|
120
|
+
const reviewed = new Set<number>()
|
|
121
|
+
for (const f of findingsFiles) {
|
|
122
|
+
const m = /^[^.]+\.shard-(\d+)\.json$/.exec(f)
|
|
123
|
+
if (m && ids.has(Number(m[1]))) reviewed.add(Number(m[1]))
|
|
124
|
+
}
|
|
125
|
+
return { reviewed: reviewed.size, total: shards.length }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function replaySummary(input: {
|
|
129
|
+
runId: string
|
|
130
|
+
kept: number
|
|
131
|
+
unlabelled: number
|
|
132
|
+
precision: number | null
|
|
133
|
+
recall: number | null
|
|
134
|
+
costUsd: number | null
|
|
135
|
+
missedRecall: { caught: number; misses: number } | null
|
|
136
|
+
coverage: { reviewed: number; total: number }
|
|
137
|
+
outPath: string
|
|
138
|
+
}): string {
|
|
139
|
+
const missed =
|
|
140
|
+
input.missedRecall === null
|
|
141
|
+
? ''
|
|
142
|
+
: `, missed-recall ${input.missedRecall.caught}/${input.missedRecall.misses}`
|
|
143
|
+
return (
|
|
144
|
+
`${input.runId}: kept ${input.kept} (${input.unlabelled} unlabelled), ` +
|
|
145
|
+
`precision ${formatRatio(input.precision)}, recall ${formatRatio(input.recall)}, ` +
|
|
146
|
+
`cost ${input.costUsd === null ? 'unreported' : `$${input.costUsd.toFixed(2)}`}${missed}, ` +
|
|
147
|
+
`shards ${input.coverage.reviewed}/${input.coverage.total}\n` +
|
|
148
|
+
`wrote ${input.outPath}\n`
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
32
152
|
async function exec(
|
|
33
153
|
cmd: string[],
|
|
34
154
|
options: { cwd?: string; stdinText?: string; claudeLabel?: string } = {},
|
|
@@ -72,13 +192,15 @@ This is an unattended evaluation run:
|
|
|
72
192
|
}
|
|
73
193
|
|
|
74
194
|
async function main(argv: string[]): Promise<number> {
|
|
75
|
-
const flags = parseFlags(argv, ['corpus', 'repo', 'out', 'max-cost-usd'])
|
|
195
|
+
const flags = parseFlags(argv, ['corpus', 'repo', 'out', 'max-cost-usd', 'judge-model'])
|
|
76
196
|
if (!flags.corpus || !flags.repo) {
|
|
77
197
|
throw new Error(
|
|
78
|
-
'usage: replay-full.ts --corpus <runId> --repo <path> [--max-cost-usd N]
|
|
198
|
+
'usage: replay-full.ts --corpus <runId> --repo <path> [--max-cost-usd N] ' +
|
|
199
|
+
'[--judge-model M] [--out <corpus-dir>]',
|
|
79
200
|
)
|
|
80
201
|
}
|
|
81
202
|
const maxCostUsd = positiveNumberFlag(flags, 'max-cost-usd') ?? DEFAULT_MAX_COST_USD
|
|
203
|
+
const judgeModel = flags['judge-model'] ?? DEFAULT_JUDGE_MODEL
|
|
82
204
|
const corpusRoot = resolveCorpusRoot(flags)
|
|
83
205
|
const run = await loadCorpusRun(join(corpusRoot, flags.corpus))
|
|
84
206
|
const sha = headRefOid(run)
|
|
@@ -143,6 +265,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
143
265
|
newKept,
|
|
144
266
|
})
|
|
145
267
|
|
|
268
|
+
const diagnostics = await replayDiagnostics(scratch)
|
|
269
|
+
const shardCoverage = shardCoverageCount(diagnostics.shardManifest, diagnostics.findingsFiles)
|
|
270
|
+
|
|
146
271
|
const resultsDir = join(corpusRoot, 'results')
|
|
147
272
|
await mkdir(resultsDir, { recursive: true })
|
|
148
273
|
const outPath = join(resultsDir, `${resultTimestamp(new Date())}-full-${run.runId}.json`)
|
|
@@ -156,12 +281,49 @@ async function main(argv: string[]): Promise<number> {
|
|
|
156
281
|
matches: Object.fromEntries(matches),
|
|
157
282
|
newKept,
|
|
158
283
|
score,
|
|
284
|
+
recall: null as RecallScore | null,
|
|
285
|
+
recallVerdicts: null as RecallVerdict[] | null,
|
|
286
|
+
judgeModel: null as string | null,
|
|
287
|
+
judgeCostUsd: null as number | null,
|
|
288
|
+
...diagnostics,
|
|
289
|
+
shardCoverage,
|
|
159
290
|
}
|
|
291
|
+
// Written before the judge runs so a judge failure cannot lose a paid replay.
|
|
160
292
|
await writeFile(outPath, `${JSON.stringify(result, null, 2)}\n`)
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
const misses = await loadMisses(join(corpusRoot, 'misses', `${run.runId}.json`))
|
|
296
|
+
if (misses !== null) {
|
|
297
|
+
const judged = await judgeRecall({
|
|
298
|
+
misses,
|
|
299
|
+
findings: newKept,
|
|
300
|
+
model: judgeModel,
|
|
301
|
+
cwd: scratch,
|
|
302
|
+
})
|
|
303
|
+
result.recall = scoreRecall(misses, judged.verdicts)
|
|
304
|
+
result.recallVerdicts = judged.verdicts
|
|
305
|
+
result.judgeModel = judgeModel
|
|
306
|
+
result.judgeCostUsd = judged.costUsd
|
|
307
|
+
await writeFile(outPath, `${JSON.stringify(result, null, 2)}\n`)
|
|
308
|
+
}
|
|
309
|
+
} catch (err) {
|
|
310
|
+
throw new Error(
|
|
311
|
+
`${err instanceof Error ? err.message : err} (replay result already written to ${outPath})`,
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
|
|
161
315
|
process.stdout.write(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
316
|
+
replaySummary({
|
|
317
|
+
runId: run.runId,
|
|
318
|
+
kept: score.kept,
|
|
319
|
+
unlabelled,
|
|
320
|
+
precision: score.precision,
|
|
321
|
+
recall: score.recall,
|
|
322
|
+
costUsd,
|
|
323
|
+
missedRecall: result.recall,
|
|
324
|
+
coverage: shardCoverage,
|
|
325
|
+
outPath,
|
|
326
|
+
}),
|
|
165
327
|
)
|
|
166
328
|
return 0
|
|
167
329
|
} finally {
|
|
@@ -147,7 +147,15 @@
|
|
|
147
147
|
"states": "SKILL.md: 10. Cleanup",
|
|
148
148
|
"origin": "commit:e49534b",
|
|
149
149
|
"cases": [],
|
|
150
|
-
"unpinned": "
|
|
150
|
+
"unpinned": "Only local-mode-hands-back runs cleanup, and local cleanup prints no view-later line by design; no PR case reaches stage 10, and the PR fakes have no cleanup subcommand."
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"id": "local-mode-hands-back-via-handoff",
|
|
154
|
+
"states": "SKILL.md: Local mode",
|
|
155
|
+
"origin": "commit:9353023",
|
|
156
|
+
"cases": [
|
|
157
|
+
"local-mode-hands-back"
|
|
158
|
+
]
|
|
151
159
|
},
|
|
152
160
|
{
|
|
153
161
|
"id": "resumable-means-log-not-server-info",
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Checks prompt
|
|
2
|
+
|
|
3
|
+
Stage 3 of the walkthrough dispatches one subagent from this file, alongside the scout,
|
|
4
|
+
once per run and never per shard. Build its prompt from two parts, in this order, and
|
|
5
|
+
send it as the agent's entire task:
|
|
6
|
+
|
|
7
|
+
1. The fenced `magpie-checks` block below, with `<<RUN_DIR>>`, `<<WORKTREE>>` and
|
|
8
|
+
`<<TARGET>>` replaced by the real values, as `references/scout.md` describes them.
|
|
9
|
+
2. The `## Output Contract` section of `references/specialists.md`, verbatim, with every
|
|
10
|
+
`<RUN_DIR>` replaced. Its `<focus>` is `checks`, which the block tells the agent, so
|
|
11
|
+
the findings land in `<RUN_DIR>/findings/checks.json`.
|
|
12
|
+
|
|
13
|
+
The subagent has no shell variables from your session, so an unexpanded path means it
|
|
14
|
+
writes its findings where nothing will read them.
|
|
15
|
+
|
|
16
|
+
```magpie-checks
|
|
17
|
+
You are a senior engineer reviewing the checks in <<TARGET>>: the tests, scenario
|
|
18
|
+
and golden-file harnesses, runner scripts that score or gate results, baselines and blessed
|
|
19
|
+
fixtures, positive and negative controls, guards in test helpers, and CI steps that the
|
|
20
|
+
diff adds or changes. Your focus id is `checks`.
|
|
21
|
+
|
|
22
|
+
Working directory: <<WORKTREE>>
|
|
23
|
+
Diff: <<RUN_DIR>>/diff.patch
|
|
24
|
+
|
|
25
|
+
The specialists reviewing this PR read the product code. You read the checks, and ask of
|
|
26
|
+
each one: what is it for, and could it pass without proving that?
|
|
27
|
+
|
|
28
|
+
## How to work
|
|
29
|
+
|
|
30
|
+
For each check the diff adds or changes, and for each runner or helper the diff changes
|
|
31
|
+
that existing checks rely on:
|
|
32
|
+
|
|
33
|
+
1. Work out what behaviour it exists to prove, from the code and from any test name,
|
|
34
|
+
comment, doc or plan in the repo that says so. Cite the file:line you learned it from.
|
|
35
|
+
2. Find what it depends on that it does not control: the real clock under a frozen or
|
|
36
|
+
faked one, the user's real data directory or database, process-wide state shared with
|
|
37
|
+
tests that run in parallel, network, timing on a loaded machine, another check's output.
|
|
38
|
+
3. Find how it could pass while proving nothing:
|
|
39
|
+
- an assertion that cannot fail;
|
|
40
|
+
- an expected value produced by running the code under test;
|
|
41
|
+
- a test double that ignores the input the check is about (a query filter, an id);
|
|
42
|
+
- a guard missing for the very case the check was added for;
|
|
43
|
+
- an empty, skipped or timed-out run that scores as a pass;
|
|
44
|
+
- a control that cannot tell "worked" from "did not run";
|
|
45
|
+
- a baseline re-blessed with no stated cause;
|
|
46
|
+
- a suite that CI never runs, or that skips itself silently when its environment is absent.
|
|
47
|
+
4. Confirm each condition by reading the code before you report it. A condition you
|
|
48
|
+
could not confirm is not a finding.
|
|
49
|
+
|
|
50
|
+
Report a check only when it is weaker than its purpose in a way that would let a real
|
|
51
|
+
regression through, or when it can fail for a reason unrelated to its purpose (the clock,
|
|
52
|
+
shared state, load), which teaches people to ignore it. A check that is merely thin, or
|
|
53
|
+
tests a constant, is not a finding unless something depends on it catching more.
|
|
54
|
+
|
|
55
|
+
## Findings
|
|
56
|
+
|
|
57
|
+
Each finding names the check, what it is for, and the concrete way it passes without
|
|
58
|
+
proving it.
|
|
59
|
+
- `Observation:` the condition, with the file:line that shows it.
|
|
60
|
+
- `Why it matters:` the regression it would let through.
|
|
61
|
+
- `Suggested direction:` the smallest change that makes the check able to fail.
|
|
62
|
+
|
|
63
|
+
Anchor a finding on the line of the assertion, guard, double or runner branch at fault,
|
|
64
|
+
with its `evidence`. Use `line: null` only when the defect is an absence, such as a
|
|
65
|
+
missing guard entry or a suite CI never runs, and name the file where it belongs.
|
|
66
|
+
|
|
67
|
+
Risk guide (`risk.impact`):
|
|
68
|
+
- high: a check the PR relies on to prove a fix or a security property cannot fail
|
|
69
|
+
- medium: a check that passes on a realistic broken path, or depends on uncontrolled state
|
|
70
|
+
- low: a weak check whose purpose is covered elsewhere
|
|
71
|
+
|
|
72
|
+
Boundary: report the check's weakness. A defect in the product code the check should have
|
|
73
|
+
caught belongs to the specialists; mention it only as what the weak check lets through.
|
|
74
|
+
|
|
75
|
+
Report at most 8 findings, the ones whose false pass would mislead the most.
|
|
76
|
+
|
|
77
|
+
Use the JSON schema defined in the `## Output Contract` section that follows; do not invent fields.
|
|
78
|
+
```
|
|
@@ -27,6 +27,13 @@ Replace each `<<NAME>>` placeholder in the block below:
|
|
|
27
27
|
`severity` is left out on purpose: it is derived from `risk.impact`, so a reviewer who wants to change how bad a finding is changes `risk`.
|
|
28
28
|
- `<<DIFF_EXCERPT>>` — the diff hunks containing the kept findings. For small PRs, the full `diff.patch` is fine. For larger PRs, narrow to the files referenced by `findings.kept.json`.
|
|
29
29
|
|
|
30
|
+
**Local runs.** A local run has no `pr.json`. Take these four from `local.json` instead; the rest are unchanged:
|
|
31
|
+
|
|
32
|
+
- `<<PR_TITLE>>`: `Local branch <branch>`, with `<branch>` from `jq -r .branch < $RUN_DIR/local.json`, or `Local detached HEAD` when that is null
|
|
33
|
+
- `<<PR_AUTHOR>>`: `jq -r .author < $RUN_DIR/local.json`
|
|
34
|
+
- `<<PR_HEAD_BRANCH>>`: `jq -r '.branch // "detached HEAD"' < $RUN_DIR/local.json`
|
|
35
|
+
- `<<PR_BASE_BRANCH>>`: `jq -r .baseRef < $RUN_DIR/local.json`
|
|
36
|
+
|
|
30
37
|
````magpie-peer-review
|
|
31
38
|
You are the second-opinion reviewer for a PR review. <<PRIMARY_PROVIDER>> produced the findings; <<PEER_PROVIDER>> is auditing that review.
|
|
32
39
|
|
|
@@ -2,16 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
Stage 3 of the walkthrough dispatches one subagent from this file, before the five
|
|
4
4
|
specialists. Send the fenced `magpie-scout` block below as the agent's entire task,
|
|
5
|
-
with `<<RUN_DIR>>` and `<<
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
with `<<RUN_DIR>>`, `<<WORKTREE>>` and `<<TARGET>>` replaced by the real values first:
|
|
6
|
+
`<<WORKTREE>>` is what `magpie target "$RUN_DIR"` prints, and `<<TARGET>>` is `PR #<n>`
|
|
7
|
+
or, on a local run, `local branch <branch> against <baseRef>` (`detached HEAD
|
|
8
|
+
against <baseRef>` when HEAD has no branch). The subagent has no shell variables from
|
|
9
|
+
your session, so an unexpanded path means it writes the brief where nothing will read it.
|
|
10
|
+
|
|
11
|
+
On a local run there is no `pr.json`, so drop the `PR metadata:` line from the block.
|
|
12
|
+
The intent evidence is `<<RUN_DIR>>/local.json` instead: its `commits[].headline` and
|
|
13
|
+
`commits[].body`, plus the file named by its `intentFile` when that is not null. Tell
|
|
14
|
+
the scout to read those in place of `title`, `body` and the linked issues.
|
|
8
15
|
|
|
9
16
|
```magpie-scout
|
|
10
17
|
You are a senior engineer building the orienting brief that five specialist
|
|
11
|
-
reviewers will read before they review
|
|
18
|
+
reviewers will read before they review <<TARGET>>. You are not reviewing the
|
|
12
19
|
code. You are answering "what is this PR for, and what does it actually do".
|
|
13
20
|
|
|
14
|
-
Working directory: <<
|
|
21
|
+
Working directory: <<WORKTREE>>
|
|
15
22
|
PR metadata: <<RUN_DIR>>/pr.json
|
|
16
23
|
Diff: <<RUN_DIR>>/diff.patch
|
|
17
24
|
|
|
@@ -9,16 +9,16 @@ them is instruction to you, not text for the specialist.
|
|
|
9
9
|
2. The run header, with the placeholders filled in. Unsharded:
|
|
10
10
|
|
|
11
11
|
```
|
|
12
|
-
You are reviewing
|
|
13
|
-
Working directory: <
|
|
12
|
+
You are reviewing <TARGET>.
|
|
13
|
+
Working directory: <WORKTREE>
|
|
14
14
|
Diff: <RUN_DIR>/diff.patch
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
When stage 4 is fanning this focus across shards, use instead:
|
|
18
18
|
|
|
19
19
|
```
|
|
20
|
-
You are reviewing
|
|
21
|
-
Working directory: <
|
|
20
|
+
You are reviewing <TARGET>.
|
|
21
|
+
Working directory: <WORKTREE>
|
|
22
22
|
Shard: <n> of <N>
|
|
23
23
|
Diff: <RUN_DIR>/shards/shard-<n>.patch
|
|
24
24
|
|
|
@@ -68,10 +68,12 @@ review rule is a convention the maintainers wrote down: a change that breaks one
|
|
|
68
68
|
finding in your domain when the rule bears on it, citing the rule's source.
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
Replace every `<RUN_DIR>` and `<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
Replace every `<RUN_DIR>`, `<WORKTREE>` and `<TARGET>` with the real values before
|
|
72
|
+
sending. `<WORKTREE>` is what `magpie target "$RUN_DIR"` prints. `<TARGET>` is `PR #<n>`,
|
|
73
|
+
or on a local run `local branch <branch> against <baseRef>` (`detached HEAD
|
|
74
|
+
against <baseRef>` when HEAD has no branch). The subagent has no shell variables from
|
|
75
|
+
your session, so an unexpanded path means it writes its findings where nothing will
|
|
76
|
+
read them. Leave `<focus>` as written; the contract tells the subagent to substitute it.
|
|
75
77
|
|
|
76
78
|
Parts 1, 2, and 3 go every time. The contract is what makes the output parseable by
|
|
77
79
|
`magpie dedupe`, and the focus blocks are what keep the five reviews from collapsing
|
|
@@ -3,6 +3,9 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promis
|
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { basename, dirname, join } from 'node:path'
|
|
5
5
|
import { killServer, runCleanup } from '../cleanup-cmd.ts'
|
|
6
|
+
import { runSetup } from '../setup-cmd.ts'
|
|
7
|
+
|
|
8
|
+
const FAKE_GH = new URL('../../fixtures/fake-gh.sh', import.meta.url).pathname
|
|
6
9
|
|
|
7
10
|
let repo: string
|
|
8
11
|
let runDir: string
|
|
@@ -13,6 +16,14 @@ async function sh(cwd: string, ...cmd: string[]): Promise<void> {
|
|
|
13
16
|
if (exit !== 0) throw new Error(`${cmd.join(' ')} exit ${exit}`)
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
async function gitOut(cwd: string, ...args: string[]): Promise<string> {
|
|
20
|
+
const proc = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
21
|
+
const out = await new Response(proc.stdout).text()
|
|
22
|
+
const exit = await proc.exited
|
|
23
|
+
if (exit !== 0) throw new Error(`git ${args.join(' ')} exit ${exit}`)
|
|
24
|
+
return out
|
|
25
|
+
}
|
|
26
|
+
|
|
16
27
|
beforeEach(async () => {
|
|
17
28
|
repo = await mkdtemp(join(tmpdir(), 'magpie-cleanup-repo-'))
|
|
18
29
|
runDir = await mkdtemp(join(tmpdir(), 'magpie-cleanup-run-'))
|
|
@@ -26,6 +37,8 @@ beforeEach(async () => {
|
|
|
26
37
|
await sh(repo, 'git', 'commit', '-q', '-m', 'init')
|
|
27
38
|
await sh(repo, 'git', 'worktree', 'add', '--detach', join(runDir, 'worktree'), 'HEAD')
|
|
28
39
|
await mkdir(join(runDir, 'state'), { recursive: true })
|
|
40
|
+
// setup writes log.jsonl first in every real run.
|
|
41
|
+
await writeFile(join(runDir, 'log.jsonl'), '')
|
|
29
42
|
})
|
|
30
43
|
|
|
31
44
|
afterEach(async () => {
|
|
@@ -62,6 +75,35 @@ test('cleanup prints a view-later hint line with the archived id', async () => {
|
|
|
62
75
|
expect(text).toMatch(/magpie open \S+\.archived-\d+/)
|
|
63
76
|
})
|
|
64
77
|
|
|
78
|
+
test('cleanup of a local run prints the archive path but no view-later hint', async () => {
|
|
79
|
+
await sh(repo, 'git', 'worktree', 'remove', '--force', join(runDir, 'worktree'))
|
|
80
|
+
await writeFile(join(runDir, 'target.json'), JSON.stringify({ mode: 'local', root: repo }))
|
|
81
|
+
const out: string[] = []
|
|
82
|
+
const origWrite = process.stdout.write.bind(process.stdout)
|
|
83
|
+
process.stdout.write = ((s: string) => {
|
|
84
|
+
out.push(s)
|
|
85
|
+
return true
|
|
86
|
+
}) as typeof process.stdout.write
|
|
87
|
+
try {
|
|
88
|
+
await runCleanup({ runDir, repoPath: repo, gitBin: 'git' })
|
|
89
|
+
} finally {
|
|
90
|
+
process.stdout.write = origWrite
|
|
91
|
+
}
|
|
92
|
+
const text = out.join('')
|
|
93
|
+
expect(text).toContain('archived to ')
|
|
94
|
+
expect(text).not.toContain('view later')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('cleanup with a malformed target.json rejects before archiving or writing labels', async () => {
|
|
98
|
+
await writeFile(join(runDir, 'log.jsonl'), '')
|
|
99
|
+
await writeFile(join(runDir, 'findings.final.json'), '[]')
|
|
100
|
+
await writeFile(join(runDir, 'target.json'), '{not json')
|
|
101
|
+
await expect(runCleanup({ runDir, repoPath: repo, gitBin: 'git' })).rejects.toThrow('target.json')
|
|
102
|
+
const entries = await readdir(dirname(runDir))
|
|
103
|
+
expect(entries.some((e) => e.startsWith(`${basename(runDir)}.archived-`))).toBe(false)
|
|
104
|
+
expect(await Bun.file(join(runDir, 'labels.json')).exists()).toBe(false)
|
|
105
|
+
})
|
|
106
|
+
|
|
65
107
|
test('killServer reports no-server-info when state/server-info missing', async () => {
|
|
66
108
|
const result = await killServer(runDir, 100)
|
|
67
109
|
expect(result.outcome).toBe('no-server-info')
|
|
@@ -151,3 +193,55 @@ test('runCleanup logs kill outcome to log.jsonl', async () => {
|
|
|
151
193
|
expect(log).toContain('"stage":"cleanup"')
|
|
152
194
|
expect(log).toContain('"outcome":"no-server-info"')
|
|
153
195
|
})
|
|
196
|
+
|
|
197
|
+
test('cleanup after a local setup archives the run and leaves the repo as it was', async () => {
|
|
198
|
+
// The shared fixture adds a worktree; a local run has none, so it gets its own repo and run dir.
|
|
199
|
+
const localRepo = await mkdtemp(join(tmpdir(), 'magpie-cleanup-local-repo-'))
|
|
200
|
+
const localRun = await mkdtemp(join(tmpdir(), 'magpie-cleanup-local-run-'))
|
|
201
|
+
try {
|
|
202
|
+
await sh(localRepo, 'git', 'init', '-q', '-b', 'main')
|
|
203
|
+
await sh(localRepo, 'git', 'config', 'user.email', 't@t.t')
|
|
204
|
+
await sh(localRepo, 'git', 'config', 'user.name', 't')
|
|
205
|
+
await sh(localRepo, 'git', 'config', 'commit.gpgsign', 'false')
|
|
206
|
+
await writeFile(join(localRepo, 'a.txt'), 'a\n')
|
|
207
|
+
await sh(localRepo, 'git', 'add', '.')
|
|
208
|
+
await sh(localRepo, 'git', 'commit', '-q', '-m', 'init')
|
|
209
|
+
await sh(localRepo, 'git', 'checkout', '-q', '-b', 'feature-x')
|
|
210
|
+
await writeFile(join(localRepo, 'b.txt'), 'b\n')
|
|
211
|
+
await sh(localRepo, 'git', 'add', 'b.txt')
|
|
212
|
+
await sh(localRepo, 'git', 'commit', '-q', '-m', 'add b')
|
|
213
|
+
await writeFile(join(localRepo, 'a.txt'), 'a\nedited\n')
|
|
214
|
+
await writeFile(join(localRepo, 'new.txt'), 'untracked\n')
|
|
215
|
+
|
|
216
|
+
const setupExit = await runSetup({
|
|
217
|
+
mode: 'local',
|
|
218
|
+
runDir: localRun,
|
|
219
|
+
repoPath: localRepo,
|
|
220
|
+
baseRef: 'main',
|
|
221
|
+
deps: { bun: 'bun', gh: FAKE_GH, codex: 'echo', git: 'git' },
|
|
222
|
+
})
|
|
223
|
+
expect(setupExit).toBe(0)
|
|
224
|
+
|
|
225
|
+
const exit = await runCleanup({ runDir: localRun, repoPath: localRepo, gitBin: 'git' })
|
|
226
|
+
expect(exit).toBe(0)
|
|
227
|
+
|
|
228
|
+
const parent = dirname(localRun)
|
|
229
|
+
const archived = (await readdir(parent)).find((e) =>
|
|
230
|
+
e.startsWith(`${basename(localRun)}.archived-`),
|
|
231
|
+
)
|
|
232
|
+
if (!archived) throw new Error('expected an archived run dir')
|
|
233
|
+
const target = JSON.parse(await readFile(join(parent, archived, 'target.json'), 'utf8'))
|
|
234
|
+
await rm(join(parent, archived), { recursive: true, force: true })
|
|
235
|
+
expect(target).toEqual({ mode: 'local', root: localRepo })
|
|
236
|
+
|
|
237
|
+
expect(await readFile(join(localRepo, 'a.txt'), 'utf8')).toBe('a\nedited\n')
|
|
238
|
+
expect(await readFile(join(localRepo, 'new.txt'), 'utf8')).toBe('untracked\n')
|
|
239
|
+
const status = await gitOut(localRepo, 'status', '--porcelain')
|
|
240
|
+
expect(status).toBe(' M a.txt\n?? new.txt\n')
|
|
241
|
+
const worktrees = await gitOut(localRepo, 'worktree', 'list')
|
|
242
|
+
expect(worktrees.trim().split('\n')).toHaveLength(1)
|
|
243
|
+
} finally {
|
|
244
|
+
await rm(localRepo, { recursive: true, force: true })
|
|
245
|
+
await rm(localRun, { recursive: true, force: true })
|
|
246
|
+
}
|
|
247
|
+
})
|
|
@@ -12,6 +12,8 @@ let stderr: string
|
|
|
12
12
|
|
|
13
13
|
beforeEach(async () => {
|
|
14
14
|
runDir = await mkdtemp(join(tmpdir(), 'magpie-critic-'))
|
|
15
|
+
// setup's first write; without it the dir does not read as a run.
|
|
16
|
+
await writeFile(join(runDir, 'log.jsonl'), '')
|
|
15
17
|
stdout = ''
|
|
16
18
|
stderr = ''
|
|
17
19
|
spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => {
|
|
@@ -88,6 +90,22 @@ test('critic-prompt writes the prompt file from the magpie-critic block and prin
|
|
|
88
90
|
expect(prompt).toContain(`Write to: ${outputPath}`)
|
|
89
91
|
})
|
|
90
92
|
|
|
93
|
+
test('critic-prompt points <<WORKTREE>> at the review root named in target.json', async () => {
|
|
94
|
+
const templatePath = join(runDir, 'critic.md')
|
|
95
|
+
await writeFile(templatePath, TEMPLATE_DOC)
|
|
96
|
+
await writeDeduped([finding('a', 'bugs')])
|
|
97
|
+
await writeFile(
|
|
98
|
+
join(runDir, 'target.json'),
|
|
99
|
+
`${JSON.stringify({ mode: 'local', root: '/repos/caller' }, null, 2)}\n`,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
const exit = await runCriticPrompt(runDir, { templatePath })
|
|
103
|
+
|
|
104
|
+
expect(exit).toBe(0)
|
|
105
|
+
const prompt = await readFile(join(runDir, 'critic-prompt.md'), 'utf8')
|
|
106
|
+
expect(prompt).toContain('Worktree: /repos/caller\n')
|
|
107
|
+
})
|
|
108
|
+
|
|
91
109
|
test('critic-prompt writes one numbered prompt per batch', async () => {
|
|
92
110
|
const templatePath = join(runDir, 'critic.md')
|
|
93
111
|
await writeFile(templatePath, TEMPLATE_DOC)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
2
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
@@ -10,6 +10,8 @@ let runDir: string
|
|
|
10
10
|
beforeEach(async () => {
|
|
11
11
|
runDir = await mkdtemp(join(tmpdir(), 'magpie-dedupe-'))
|
|
12
12
|
await mkdir(join(runDir, 'findings'), { recursive: true })
|
|
13
|
+
// setup's first write; without it the dir does not read as a run.
|
|
14
|
+
await writeFile(join(runDir, 'log.jsonl'), '')
|
|
13
15
|
})
|
|
14
16
|
|
|
15
17
|
afterEach(async () => {
|
|
@@ -385,6 +387,36 @@ test('a malformed shards/manifest.json is treated as no manifest', async () => {
|
|
|
385
387
|
expect(done?.coverage).toBeUndefined()
|
|
386
388
|
})
|
|
387
389
|
|
|
390
|
+
test('a checks.json written once per run reaches the deduped findings', async () => {
|
|
391
|
+
await writeWorktreeSource()
|
|
392
|
+
await writeFile(
|
|
393
|
+
join(runDir, 'findings', 'checks.json'),
|
|
394
|
+
JSON.stringify([
|
|
395
|
+
{
|
|
396
|
+
...f('checks-1', 'a.ts', 0, 'runner scores an empty report as a passing control', 'checks'),
|
|
397
|
+
line: null,
|
|
398
|
+
},
|
|
399
|
+
]),
|
|
400
|
+
)
|
|
401
|
+
expect(await runDedupe(runDir, { threshold: 0 })).toBe(0)
|
|
402
|
+
const out = JSON.parse(await readFile(join(runDir, 'findings.deduped.json'), 'utf8'))
|
|
403
|
+
expect(out.map((x: ReviewFinding) => [x.id, x.domain])).toEqual([['checks-1', 'checks']])
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
test('a sharded run never expects a checks file per shard', async () => {
|
|
407
|
+
await writeManifest(2)
|
|
408
|
+
for (const focus of LLM_FOCUSES) {
|
|
409
|
+
for (const shard of [1, 2]) {
|
|
410
|
+
await writeFile(join(runDir, 'findings', `${focus}.shard-${shard}.json`), JSON.stringify([]))
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
await writeFile(join(runDir, 'findings', 'checks.json'), JSON.stringify([]))
|
|
414
|
+
const { exit } = await dedupeCapturingStdout()
|
|
415
|
+
expect(exit).toBe(0)
|
|
416
|
+
const done = await dedupeLogEntry('done')
|
|
417
|
+
expect(done?.coverage).toEqual({ expected: 10, missing: [] })
|
|
418
|
+
})
|
|
419
|
+
|
|
388
420
|
test('runDedupe still skips files it cannot map to a focus', async () => {
|
|
389
421
|
await writeFile(join(runDir, 'findings', 'nonsense.json'), JSON.stringify([]))
|
|
390
422
|
await writeFile(join(runDir, 'findings', 'security.shard-x.json'), JSON.stringify([]))
|
|
@@ -398,3 +430,50 @@ test('runDedupe still skips files it cannot map to a focus', async () => {
|
|
|
398
430
|
.filter((e) => e.status === 'skip' && e.reason === 'unknown-focus')
|
|
399
431
|
expect(skips.map((s) => s.file).sort()).toEqual(['nonsense.json', 'security.shard-x.json'])
|
|
400
432
|
})
|
|
433
|
+
|
|
434
|
+
describe('with target.json naming a review root outside the run dir', () => {
|
|
435
|
+
let root: string
|
|
436
|
+
|
|
437
|
+
beforeEach(async () => {
|
|
438
|
+
root = await mkdtemp(join(tmpdir(), 'magpie-dedupe-root-'))
|
|
439
|
+
await writeFile(
|
|
440
|
+
join(runDir, 'target.json'),
|
|
441
|
+
`${JSON.stringify({ mode: 'local', root }, null, 2)}\n`,
|
|
442
|
+
)
|
|
443
|
+
await writeFile(
|
|
444
|
+
join(runDir, 'findings', 'bugs.json'),
|
|
445
|
+
JSON.stringify([
|
|
446
|
+
{
|
|
447
|
+
...f('bugs-1', 'a.ts', 2, 'secret read without a fallback', 'bugs'),
|
|
448
|
+
evidence: 'const secret = process.env.KEY',
|
|
449
|
+
},
|
|
450
|
+
]),
|
|
451
|
+
)
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
afterEach(async () => {
|
|
455
|
+
await rm(root, { recursive: true, force: true })
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
test('keeps a finding whose file and evidence are in that root', async () => {
|
|
459
|
+
await writeFile(join(root, 'a.ts'), '// line 1\nconst secret = process.env.KEY\n')
|
|
460
|
+
expect(await runDedupe(runDir, { threshold: 0 })).toBe(0)
|
|
461
|
+
const kept = JSON.parse(await readFile(join(runDir, 'findings.deduped.json'), 'utf8'))
|
|
462
|
+
expect(kept.map((x: ReviewFinding) => x.id)).toEqual(['bugs-1'])
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
test('fails without writing findings when a local root does not exist', async () => {
|
|
466
|
+
await rm(root, { recursive: true, force: true })
|
|
467
|
+
const exit = await runDedupe(runDir, { threshold: 0 })
|
|
468
|
+
expect(exit).not.toBe(0)
|
|
469
|
+
expect(await Bun.file(join(runDir, 'findings.deduped.json')).exists()).toBe(false)
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
test('drops the finding as hallucinated-file when that root lacks the file', async () => {
|
|
473
|
+
expect(await runDedupe(runDir, { threshold: 0 })).toBe(0)
|
|
474
|
+
const evidence = JSON.parse(await readFile(join(runDir, 'evidence-dropped.json'), 'utf8'))
|
|
475
|
+
expect(evidence.dropped).toEqual([
|
|
476
|
+
{ id: 'bugs-1', reason: 'hallucinated-file', file: 'a.ts', line: 2 },
|
|
477
|
+
])
|
|
478
|
+
})
|
|
479
|
+
})
|