@iceinvein/agent-skills 0.1.40 → 0.3.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 +18 -2
- package/dist/cli/index.js +115 -34
- package/package.json +1 -1
- package/skills/index.json +14 -2
- package/skills/magpie/SKILL.md +118 -40
- package/skills/magpie/bin/magpie.ts +43 -0
- package/skills/magpie/fixtures/fake-gh-nodiff.sh +38 -0
- package/skills/magpie/package.json +1 -1
- package/skills/magpie/references/peer-review.md +7 -2
- package/skills/magpie/references/specialists.md +38 -7
- package/skills/magpie/scripts/__tests__/cli.test.ts +101 -1
- package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +187 -0
- package/skills/magpie/scripts/__tests__/diff-chunks.test.ts +51 -0
- package/skills/magpie/scripts/__tests__/filter-diff-preservation.test.ts +54 -0
- package/skills/magpie/scripts/__tests__/findings-files.test.ts +35 -0
- package/skills/magpie/scripts/__tests__/gh.test.ts +69 -0
- package/skills/magpie/scripts/__tests__/git-diff.test.ts +83 -0
- package/skills/magpie/scripts/__tests__/helpers/git-fixture.ts +47 -0
- package/skills/magpie/scripts/__tests__/path-filter.test.ts +27 -0
- package/skills/magpie/scripts/__tests__/render-cmd.test.ts +95 -0
- package/skills/magpie/scripts/__tests__/render-findings.test.ts +33 -0
- package/skills/magpie/scripts/__tests__/render-progress.test.ts +42 -0
- package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +83 -1
- package/skills/magpie/scripts/__tests__/shard.test.ts +165 -0
- package/skills/magpie/scripts/__tests__/skill-lint.test.ts +96 -1
- package/skills/magpie/scripts/dedupe-cmd.ts +58 -3
- package/skills/magpie/scripts/diff-chunks.ts +28 -0
- package/skills/magpie/scripts/findings-files.ts +32 -0
- package/skills/magpie/scripts/gh.ts +64 -13
- package/skills/magpie/scripts/git-diff.ts +111 -0
- package/skills/magpie/scripts/path-filter.ts +9 -5
- package/skills/magpie/scripts/refresh.ts +8 -0
- package/skills/magpie/scripts/render-cmd.ts +28 -9
- package/skills/magpie/scripts/render-findings.ts +11 -1
- package/skills/magpie/scripts/render-progress.ts +6 -1
- package/skills/magpie/scripts/setup-cmd.ts +38 -1
- package/skills/magpie/scripts/shard.ts +171 -0
- package/skills/magpie/scripts/status-cmd.ts +4 -1
- package/skills/magpie/skill.json +2 -2
- package/skills/magpie/templates/styles.css +5 -0
- package/skills/migrate/README.md +194 -0
- package/skills/migrate/SKILL.md +197 -0
- package/skills/migrate/bin/migrate +15 -0
- package/skills/migrate/bin/migrate.ts +309 -0
- package/skills/migrate/biome.json +35 -0
- package/skills/migrate/bun.lock +24 -0
- package/skills/migrate/docs/architecture.md +294 -0
- package/skills/migrate/docs/reference.md +590 -0
- package/skills/migrate/fixtures/tiny-express/GROUND-TRUTH.md +39 -0
- package/skills/migrate/fixtures/tiny-express/app.js +29 -0
- package/skills/migrate/fixtures/tiny-express/cron.js +6 -0
- package/skills/migrate/fixtures/tiny-express/reports/daily-users.json +6 -0
- package/skills/migrate/fixtures/tiny-express/schema.sql +12 -0
- package/skills/migrate/fixtures/tiny-express/settings.json +4 -0
- package/skills/migrate/fixtures/tiny-express/views/users.html +9 -0
- package/skills/migrate/fixtures/tiny-webforms/Controllers/UsersController.cs +68 -0
- package/skills/migrate/fixtures/tiny-webforms/Default.aspx +7 -0
- package/skills/migrate/fixtures/tiny-webforms/Default.aspx.cs +14 -0
- package/skills/migrate/fixtures/tiny-webforms/GROUND-TRUTH.md +50 -0
- package/skills/migrate/fixtures/tiny-webforms/Integrations/BillingClient.cs +16 -0
- package/skills/migrate/fixtures/tiny-webforms/Jobs/NightlyDigestJob.cs +33 -0
- package/skills/migrate/fixtures/tiny-webforms/Reports/DailyUsers.rdl +11 -0
- package/skills/migrate/fixtures/tiny-webforms/Schema.sql +12 -0
- package/skills/migrate/fixtures/tiny-webforms/Site.master +16 -0
- package/skills/migrate/fixtures/tiny-webforms/Users.aspx +8 -0
- package/skills/migrate/fixtures/tiny-webforms/Users.aspx.cs +14 -0
- package/skills/migrate/fixtures/tiny-webforms/web.config +10 -0
- package/skills/migrate/install.sh +68 -0
- package/skills/migrate/package.json +17 -0
- package/skills/migrate/references/phases/enumerate.md +291 -0
- package/skills/migrate/references/phases/extract.md +652 -0
- package/skills/migrate/references/phases/parity.md +275 -0
- package/skills/migrate/references/phases/probe.md +135 -0
- package/skills/migrate/references/phases/queue.md +242 -0
- package/skills/migrate/references/phases/seam.md +416 -0
- package/skills/migrate/references/recipes/README.md +116 -0
- package/skills/migrate/references/recipes/aspnet.md +287 -0
- package/skills/migrate/references/run-ops.md +280 -0
- package/skills/migrate/scripts/__tests__/census.test.ts +775 -0
- package/skills/migrate/scripts/__tests__/check.test.ts +458 -0
- package/skills/migrate/scripts/__tests__/citations.test.ts +156 -0
- package/skills/migrate/scripts/__tests__/cli.test.ts +183 -0
- package/skills/migrate/scripts/__tests__/concurrency.test.ts +164 -0
- package/skills/migrate/scripts/__tests__/config.test.ts +112 -0
- package/skills/migrate/scripts/__tests__/e2e-express.test.ts +1093 -0
- package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +1276 -0
- package/skills/migrate/scripts/__tests__/e2e.test.ts +320 -0
- package/skills/migrate/scripts/__tests__/ids.test.ts +38 -0
- package/skills/migrate/scripts/__tests__/import.test.ts +155 -0
- package/skills/migrate/scripts/__tests__/init.test.ts +192 -0
- package/skills/migrate/scripts/__tests__/leaks.test.ts +176 -0
- package/skills/migrate/scripts/__tests__/lock.test.ts +183 -0
- package/skills/migrate/scripts/__tests__/paths.test.ts +129 -0
- package/skills/migrate/scripts/__tests__/phase-cmd.test.ts +151 -0
- package/skills/migrate/scripts/__tests__/phases.test.ts +70 -0
- package/skills/migrate/scripts/__tests__/queue.test.ts +475 -0
- package/skills/migrate/scripts/__tests__/report.test.ts +150 -0
- package/skills/migrate/scripts/__tests__/run-state.test.ts +136 -0
- package/skills/migrate/scripts/__tests__/status-reset.test.ts +318 -0
- package/skills/migrate/scripts/__tests__/store.test.ts +132 -0
- package/skills/migrate/scripts/__tests__/validate.test.ts +54 -0
- package/skills/migrate/scripts/census-cmd.ts +109 -0
- package/skills/migrate/scripts/census.ts +342 -0
- package/skills/migrate/scripts/check-cmd.ts +24 -0
- package/skills/migrate/scripts/check.ts +376 -0
- package/skills/migrate/scripts/citations.ts +92 -0
- package/skills/migrate/scripts/config.ts +237 -0
- package/skills/migrate/scripts/ids.ts +31 -0
- package/skills/migrate/scripts/import-cmd.ts +141 -0
- package/skills/migrate/scripts/init-cmd.ts +118 -0
- package/skills/migrate/scripts/leaks.ts +184 -0
- package/skills/migrate/scripts/lock.ts +188 -0
- package/skills/migrate/scripts/paths.ts +103 -0
- package/skills/migrate/scripts/phase-cmd.ts +63 -0
- package/skills/migrate/scripts/phases.ts +113 -0
- package/skills/migrate/scripts/queue-cmd.ts +98 -0
- package/skills/migrate/scripts/queue.ts +258 -0
- package/skills/migrate/scripts/report-cmd.ts +47 -0
- package/skills/migrate/scripts/report.ts +131 -0
- package/skills/migrate/scripts/reset-cmd.ts +120 -0
- package/skills/migrate/scripts/status-cmd.ts +52 -0
- package/skills/migrate/scripts/store.ts +159 -0
- package/skills/migrate/scripts/types.ts +137 -0
- package/skills/migrate/scripts/validate.ts +221 -0
- package/skills/migrate/skill.json +33 -0
- package/skills/migrate/templates/config.toml +27 -0
- package/skills/migrate/templates/queue-item.md +17 -0
- package/skills/migrate/tsconfig.json +18 -0
- package/skills/migrate/uninstall.sh +31 -0
- package/skills/sluice/SKILL.md +95 -0
- package/skills/sluice/references/deep-channel.md +114 -0
- package/skills/sluice/references/finish.md +37 -0
- package/skills/sluice/references/intent.md +29 -0
- package/skills/sluice/references/meter.md +38 -0
- package/skills/sluice/references/review.md +42 -0
- package/skills/sluice/references/root-cause.md +38 -0
- package/skills/sluice/references/show-or-say.md +36 -0
- package/skills/sluice/references/test-first.md +35 -0
- package/skills/sluice/references/verify.md +26 -0
- package/skills/sluice/scripts/run-stats.sh +236 -0
- package/skills/sluice/skill.json +33 -0
|
@@ -122,3 +122,190 @@ test('runDedupe with threshold 0 keeps everything', async () => {
|
|
|
122
122
|
const kept = JSON.parse(await readFile(join(runDir, 'findings.deduped.json'), 'utf8'))
|
|
123
123
|
expect(kept).toHaveLength(1)
|
|
124
124
|
})
|
|
125
|
+
|
|
126
|
+
test('runDedupe collects sharded focus files', async () => {
|
|
127
|
+
await writeFile(
|
|
128
|
+
join(runDir, 'findings', 'security.shard-1.json'),
|
|
129
|
+
JSON.stringify([f('security-1', 'a.ts', 10, 'hardcoded token in the client', 'security')]),
|
|
130
|
+
)
|
|
131
|
+
await writeFile(
|
|
132
|
+
join(runDir, 'findings', 'security.shard-2.json'),
|
|
133
|
+
JSON.stringify([f('security-1', 'z.ts', 99, 'missing auth check on the handler', 'security')]),
|
|
134
|
+
)
|
|
135
|
+
const exit = await runDedupe(runDir, { threshold: 0 })
|
|
136
|
+
expect(exit).toBe(0)
|
|
137
|
+
const out = JSON.parse(await readFile(join(runDir, 'findings.deduped.json'), 'utf8'))
|
|
138
|
+
expect(out).toHaveLength(2)
|
|
139
|
+
const ids = out.map((x: { id: string }) => x.id).sort()
|
|
140
|
+
expect(ids).toEqual(['security-s1-1', 'security-s2-1'])
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('runDedupe leaves unsharded ids untouched', async () => {
|
|
144
|
+
await writeFile(
|
|
145
|
+
join(runDir, 'findings', 'bugs.json'),
|
|
146
|
+
JSON.stringify([f('bugs-1', 'a.ts', 10, 'off by one in the loop bound', 'bugs')]),
|
|
147
|
+
)
|
|
148
|
+
const exit = await runDedupe(runDir, { threshold: 0 })
|
|
149
|
+
expect(exit).toBe(0)
|
|
150
|
+
const out = JSON.parse(await readFile(join(runDir, 'findings.deduped.json'), 'utf8'))
|
|
151
|
+
expect(out[0]?.id).toBe('bugs-1')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const LLM_FOCUSES = ['security', 'bugs', 'performance', 'code-smells', 'architecture'] as const
|
|
155
|
+
|
|
156
|
+
/** A manifest with `count` shards, shaped like the one `shardDiff` writes. */
|
|
157
|
+
async function writeManifest(count: number): Promise<void> {
|
|
158
|
+
await mkdir(join(runDir, 'shards'), { recursive: true })
|
|
159
|
+
const shards = Array.from({ length: count }, (_, i) => ({
|
|
160
|
+
id: i + 1,
|
|
161
|
+
path: count === 1 ? 'diff.patch' : `shards/shard-${i + 1}.patch`,
|
|
162
|
+
files: [`src/f${i + 1}.ts`],
|
|
163
|
+
lines: 10,
|
|
164
|
+
}))
|
|
165
|
+
await writeFile(
|
|
166
|
+
join(runDir, 'shards', 'manifest.json'),
|
|
167
|
+
JSON.stringify({
|
|
168
|
+
budget: 6000,
|
|
169
|
+
maxFiles: 80,
|
|
170
|
+
totalFiles: count,
|
|
171
|
+
totalLines: 10 * count,
|
|
172
|
+
shards,
|
|
173
|
+
}),
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Run dedupe with stdout captured, the way a human at the terminal sees it. */
|
|
178
|
+
async function dedupeCapturingStdout(): Promise<{ exit: number; stdout: string }> {
|
|
179
|
+
const out: string[] = []
|
|
180
|
+
const origWrite = process.stdout.write.bind(process.stdout)
|
|
181
|
+
process.stdout.write = ((s: string) => {
|
|
182
|
+
out.push(s)
|
|
183
|
+
return true
|
|
184
|
+
}) as typeof process.stdout.write
|
|
185
|
+
try {
|
|
186
|
+
const exit = await runDedupe(runDir, { threshold: 0 })
|
|
187
|
+
return { exit, stdout: out.join('') }
|
|
188
|
+
} finally {
|
|
189
|
+
process.stdout.write = origWrite
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function dedupeLogEntry(status: string): Promise<Record<string, unknown> | undefined> {
|
|
194
|
+
const log = await readFile(join(runDir, 'log.jsonl'), 'utf8')
|
|
195
|
+
return log
|
|
196
|
+
.split('\n')
|
|
197
|
+
.filter(Boolean)
|
|
198
|
+
.map((l) => JSON.parse(l) as Record<string, unknown>)
|
|
199
|
+
.find((e) => e.stage === 'dedupe' && e.status === status)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
test('runDedupe reports nothing missing when every (focus, shard) file is present', async () => {
|
|
203
|
+
await writeManifest(3)
|
|
204
|
+
for (const focus of LLM_FOCUSES) {
|
|
205
|
+
for (const shard of [1, 2, 3]) {
|
|
206
|
+
await writeFile(
|
|
207
|
+
join(runDir, 'findings', `${focus}.shard-${shard}.json`),
|
|
208
|
+
JSON.stringify([f(`${focus}-1`, `s${shard}.ts`, 1, `${focus} on shard ${shard}`, focus)]),
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Setup writes tests.json once for the whole run, never per shard.
|
|
213
|
+
await writeFile(join(runDir, 'findings', 'tests.json'), JSON.stringify([]))
|
|
214
|
+
const { exit, stdout } = await dedupeCapturingStdout()
|
|
215
|
+
expect(exit).toBe(0)
|
|
216
|
+
expect(stdout).not.toContain('missing')
|
|
217
|
+
expect(stdout).toContain('15')
|
|
218
|
+
const done = await dedupeLogEntry('done')
|
|
219
|
+
expect(done?.coverage).toEqual({ expected: 15, missing: [] })
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('runDedupe names the (focus, shard) pairs that have no findings file', async () => {
|
|
223
|
+
await writeManifest(2)
|
|
224
|
+
for (const focus of LLM_FOCUSES) {
|
|
225
|
+
await writeFile(
|
|
226
|
+
join(runDir, 'findings', `${focus}.shard-1.json`),
|
|
227
|
+
JSON.stringify([f(`${focus}-1`, 'a.ts', 1, `${focus} on shard 1`, focus)]),
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
// Shard 2 lost security and bugs: two of ten agents never wrote a file.
|
|
231
|
+
for (const focus of ['performance', 'code-smells', 'architecture']) {
|
|
232
|
+
await writeFile(
|
|
233
|
+
join(runDir, 'findings', `${focus}.shard-2.json`),
|
|
234
|
+
JSON.stringify([f(`${focus}-1`, 'b.ts', 2, `${focus} on shard 2`, focus)]),
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
const { exit, stdout } = await dedupeCapturingStdout()
|
|
238
|
+
expect(exit).toBe(0)
|
|
239
|
+
expect(stdout).toContain('missing')
|
|
240
|
+
expect(stdout).toContain('security.shard-2.json')
|
|
241
|
+
expect(stdout).toContain('bugs.shard-2.json')
|
|
242
|
+
const done = await dedupeLogEntry('done')
|
|
243
|
+
expect(done?.coverage).toEqual({
|
|
244
|
+
expected: 10,
|
|
245
|
+
missing: ['security.shard-2.json', 'bugs.shard-2.json'],
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test('a single-shard manifest expects the unsharded findings filenames', async () => {
|
|
250
|
+
await writeManifest(1)
|
|
251
|
+
for (const focus of ['security', 'bugs', 'performance', 'code-smells']) {
|
|
252
|
+
await writeFile(
|
|
253
|
+
join(runDir, 'findings', `${focus}.json`),
|
|
254
|
+
JSON.stringify([f(`${focus}-1`, 'a.ts', 1, `${focus} finding here`, focus)]),
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
const { stdout } = await dedupeCapturingStdout()
|
|
258
|
+
// Stage 4 takes the unsharded path on a single-shard run, so the expected name
|
|
259
|
+
// has no shard suffix.
|
|
260
|
+
expect(stdout).toContain('architecture.json')
|
|
261
|
+
const done = await dedupeLogEntry('done')
|
|
262
|
+
expect(done?.coverage).toEqual({ expected: 5, missing: ['architecture.json'] })
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test('runDedupe without a manifest reports no coverage at all', async () => {
|
|
266
|
+
await writeFile(
|
|
267
|
+
join(runDir, 'findings', 'bugs.json'),
|
|
268
|
+
JSON.stringify([f('bugs-1', 'a.ts', 10, 'off by one in the loop bound', 'bugs')]),
|
|
269
|
+
)
|
|
270
|
+
const { exit, stdout } = await dedupeCapturingStdout()
|
|
271
|
+
expect(exit).toBe(0)
|
|
272
|
+
// A run predating the sharder has nothing to reconcile against; behaviour is
|
|
273
|
+
// exactly as before.
|
|
274
|
+
expect(stdout).toBe('')
|
|
275
|
+
const done = await dedupeLogEntry('done')
|
|
276
|
+
expect(done).toBeDefined()
|
|
277
|
+
expect(done?.coverage).toBeUndefined()
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('a zero-shard manifest expects nothing (the diff filtered down to nothing)', async () => {
|
|
281
|
+
await writeManifest(0)
|
|
282
|
+
const { exit, stdout } = await dedupeCapturingStdout()
|
|
283
|
+
expect(exit).toBe(0)
|
|
284
|
+
expect(stdout).toBe('')
|
|
285
|
+
const done = await dedupeLogEntry('done')
|
|
286
|
+
expect(done?.coverage).toBeUndefined()
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('a malformed shards/manifest.json is treated as no manifest', async () => {
|
|
290
|
+
await mkdir(join(runDir, 'shards'), { recursive: true })
|
|
291
|
+
await writeFile(join(runDir, 'shards', 'manifest.json'), '{ not json')
|
|
292
|
+
const { exit, stdout } = await dedupeCapturingStdout()
|
|
293
|
+
expect(exit).toBe(0)
|
|
294
|
+
expect(stdout).toBe('')
|
|
295
|
+
const done = await dedupeLogEntry('done')
|
|
296
|
+
expect(done?.coverage).toBeUndefined()
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
test('runDedupe still skips files it cannot map to a focus', async () => {
|
|
300
|
+
await writeFile(join(runDir, 'findings', 'nonsense.json'), JSON.stringify([]))
|
|
301
|
+
await writeFile(join(runDir, 'findings', 'security.shard-x.json'), JSON.stringify([]))
|
|
302
|
+
const exit = await runDedupe(runDir)
|
|
303
|
+
expect(exit).toBe(0)
|
|
304
|
+
const log = await readFile(join(runDir, 'log.jsonl'), 'utf8')
|
|
305
|
+
const skips = log
|
|
306
|
+
.split('\n')
|
|
307
|
+
.filter(Boolean)
|
|
308
|
+
.map((l) => JSON.parse(l))
|
|
309
|
+
.filter((e) => e.status === 'skip' && e.reason === 'unknown-focus')
|
|
310
|
+
expect(skips.map((s) => s.file).sort()).toEqual(['nonsense.json', 'security.shard-x.json'])
|
|
311
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { chunkPath, splitFileChunks, splitRawChunks } from '../diff-chunks.ts'
|
|
3
|
+
|
|
4
|
+
/** A file chunk with `bodyLines` added lines, shaped like a real patch. */
|
|
5
|
+
function chunk(path: string, bodyLines: number): string {
|
|
6
|
+
const body = Array.from({ length: bodyLines }, (_, i) => `+line ${i}`).join('\n')
|
|
7
|
+
return [
|
|
8
|
+
`diff --git a/${path} b/${path}`,
|
|
9
|
+
'index 0000000..1111111 100644',
|
|
10
|
+
`--- a/${path}`,
|
|
11
|
+
`+++ b/${path}`,
|
|
12
|
+
`@@ -0,0 +1,${bodyLines} @@`,
|
|
13
|
+
body,
|
|
14
|
+
'',
|
|
15
|
+
].join('\n')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test('splitFileChunks finds one chunk per file with its path and line count', () => {
|
|
19
|
+
const diff = chunk('src/a.ts', 3) + chunk('src/b.ts', 5)
|
|
20
|
+
const chunks = splitFileChunks(diff)
|
|
21
|
+
expect(chunks.map((c) => c.path)).toEqual(['src/a.ts', 'src/b.ts'])
|
|
22
|
+
expect(chunks[0]?.lines).toBeGreaterThan(3)
|
|
23
|
+
expect(chunks[0]?.text).toContain('diff --git a/src/a.ts')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('splitFileChunks returns nothing for an empty diff', () => {
|
|
27
|
+
expect(splitFileChunks('')).toEqual([])
|
|
28
|
+
expect(splitFileChunks('\n\n')).toEqual([])
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('splitRawChunks keeps a leading preamble as its own element', () => {
|
|
32
|
+
const diff = `some preamble text\n${chunk('src/a.ts', 2)}`
|
|
33
|
+
const raw = splitRawChunks(diff)
|
|
34
|
+
expect(raw[0]).toBe('some preamble text\n')
|
|
35
|
+
expect(raw[1]?.startsWith('diff --git ')).toBe(true)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('chunkPath prefers the b-side path when a rename changes it', () => {
|
|
39
|
+
const rename = [
|
|
40
|
+
'diff --git a/old/name.ts b/new/name.ts',
|
|
41
|
+
'similarity index 100%',
|
|
42
|
+
'rename from old/name.ts',
|
|
43
|
+
'rename to new/name.ts',
|
|
44
|
+
'',
|
|
45
|
+
].join('\n')
|
|
46
|
+
expect(chunkPath(rename)).toBe('new/name.ts')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('chunkPath returns null when the header cannot be parsed', () => {
|
|
50
|
+
expect(chunkPath('not a diff header at all')).toBeNull()
|
|
51
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { filterDiff } from '../path-filter.ts'
|
|
3
|
+
|
|
4
|
+
// filterDiff's refactor onto the shared diff-chunks module (scripts/diff-chunks.ts)
|
|
5
|
+
// must preserve two branches that scripts/__tests__/path-filter.test.ts's DIFF
|
|
6
|
+
// fixture never exercises, because that fixture always starts with a well-formed
|
|
7
|
+
// `diff --git a/... b/...` header:
|
|
8
|
+
//
|
|
9
|
+
// 1. a leading non-`diff --git` preamble chunk is kept in `filtered`
|
|
10
|
+
// 2. a chunk whose header cannot be parsed is kept in `filtered`
|
|
11
|
+
//
|
|
12
|
+
// These tests assert directly on filterDiff's output for both, so a future edit
|
|
13
|
+
// to diff-chunks.ts that breaks either preservation is caught here rather than
|
|
14
|
+
// resting on a manual code trace.
|
|
15
|
+
|
|
16
|
+
const VALID_CHUNK = [
|
|
17
|
+
'diff --git a/src/a.ts b/src/a.ts',
|
|
18
|
+
'index 1..2 100644',
|
|
19
|
+
'--- a/src/a.ts',
|
|
20
|
+
'+++ b/src/a.ts',
|
|
21
|
+
'@@ -1 +1 @@',
|
|
22
|
+
'-x',
|
|
23
|
+
'+y',
|
|
24
|
+
'',
|
|
25
|
+
].join('\n')
|
|
26
|
+
|
|
27
|
+
test('filterDiff keeps a leading preamble that appears before the first file header', () => {
|
|
28
|
+
const preamble = 'Note: this diff was truncated by the transport layer\n'
|
|
29
|
+
const diff = preamble + VALID_CHUNK
|
|
30
|
+
const { filtered } = filterDiff(diff, { exclude: [], include: [], useDefaults: false })
|
|
31
|
+
expect(filtered).toContain('Note: this diff was truncated by the transport layer')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('filterDiff keeps a chunk whose diff --git header cannot be parsed', () => {
|
|
35
|
+
// No " b/" separator after the a-side path, so FILE_HEADER cannot match and
|
|
36
|
+
// chunkPath returns null; filterDiff must keep the chunk rather than drop it.
|
|
37
|
+
const malformed = [
|
|
38
|
+
'diff --git a/foo.ts b-badformat',
|
|
39
|
+
'index 1234567..89abcde 100644',
|
|
40
|
+
'--- a/foo.ts',
|
|
41
|
+
'+++ b-badformat',
|
|
42
|
+
'@@ -1 +1 @@',
|
|
43
|
+
'-x',
|
|
44
|
+
'+y',
|
|
45
|
+
'',
|
|
46
|
+
].join('\n')
|
|
47
|
+
const { filtered, excluded } = filterDiff(malformed, {
|
|
48
|
+
exclude: ['**/*'],
|
|
49
|
+
include: [],
|
|
50
|
+
useDefaults: false,
|
|
51
|
+
})
|
|
52
|
+
expect(filtered).toContain('b-badformat')
|
|
53
|
+
expect(excluded).toEqual([])
|
|
54
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { namespaceId, parseFindingsFilename } from '../findings-files.ts'
|
|
3
|
+
|
|
4
|
+
test('parseFindingsFilename reads an unsharded focus file', () => {
|
|
5
|
+
expect(parseFindingsFilename('security.json')).toEqual({ focus: 'security', shard: null })
|
|
6
|
+
expect(parseFindingsFilename('code-smells.json')).toEqual({ focus: 'code-smells', shard: null })
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
test('parseFindingsFilename reads a sharded focus file', () => {
|
|
10
|
+
expect(parseFindingsFilename('security.shard-2.json')).toEqual({ focus: 'security', shard: 2 })
|
|
11
|
+
expect(parseFindingsFilename('architecture.shard-11.json')).toEqual({
|
|
12
|
+
focus: 'architecture',
|
|
13
|
+
shard: 11,
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('parseFindingsFilename rejects names it cannot read', () => {
|
|
18
|
+
expect(parseFindingsFilename('notes.txt')).toBeNull()
|
|
19
|
+
expect(parseFindingsFilename('security.shard-x.json')).toBeNull()
|
|
20
|
+
expect(parseFindingsFilename('security.shard-2.extra.json')).toBeNull()
|
|
21
|
+
expect(parseFindingsFilename('.json')).toBeNull()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('namespaceId leaves unsharded ids alone', () => {
|
|
25
|
+
expect(namespaceId('security-1', 'security', null)).toBe('security-1')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('namespaceId inserts the shard tag after the focus prefix', () => {
|
|
29
|
+
expect(namespaceId('security-1', 'security', 2)).toBe('security-s2-1')
|
|
30
|
+
expect(namespaceId('code-smells-7', 'code-smells', 3)).toBe('code-smells-s3-7')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('namespaceId prefixes ids that do not carry the focus', () => {
|
|
34
|
+
expect(namespaceId('finding-1', 'security', 2)).toBe('s2-finding-1')
|
|
35
|
+
})
|
|
@@ -3,10 +3,13 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { fetchPr } from '../gh.ts'
|
|
6
|
+
import { createGitFixtureRepo } from './helpers/git-fixture.ts'
|
|
6
7
|
|
|
7
8
|
const FAKE_GH = new URL('../../fixtures/fake-gh.sh', import.meta.url).pathname
|
|
9
|
+
const FAKE_GH_NODIFF = new URL('../../fixtures/fake-gh-nodiff.sh', import.meta.url).pathname
|
|
8
10
|
|
|
9
11
|
let runDir: string
|
|
12
|
+
let repo = ''
|
|
10
13
|
|
|
11
14
|
beforeEach(async () => {
|
|
12
15
|
runDir = await mkdtemp(join(tmpdir(), 'magpie-gh-'))
|
|
@@ -14,6 +17,10 @@ beforeEach(async () => {
|
|
|
14
17
|
|
|
15
18
|
afterEach(async () => {
|
|
16
19
|
await rm(runDir, { recursive: true, force: true })
|
|
20
|
+
if (repo) {
|
|
21
|
+
await rm(repo, { recursive: true, force: true })
|
|
22
|
+
repo = ''
|
|
23
|
+
}
|
|
17
24
|
})
|
|
18
25
|
|
|
19
26
|
test('fetchPr writes pr.json and diff.patch', async () => {
|
|
@@ -66,3 +73,65 @@ test('fetchPr carries commits and linked issues into pr.json', async () => {
|
|
|
66
73
|
expect(Array.isArray(pr.closingIssuesReferences)).toBe(true)
|
|
67
74
|
expect(pr.closingIssuesReferences[0]).toMatchObject({ number: 42 })
|
|
68
75
|
})
|
|
76
|
+
|
|
77
|
+
test('fetchPr falls back to local git when gh pr diff fails', async () => {
|
|
78
|
+
const { repoPath, head } = await createGitFixtureRepo()
|
|
79
|
+
repo = repoPath
|
|
80
|
+
const result = await fetchPr({
|
|
81
|
+
ghBin: FAKE_GH_NODIFF,
|
|
82
|
+
gitBin: 'git',
|
|
83
|
+
prNumber: 7,
|
|
84
|
+
runDir,
|
|
85
|
+
cwd: repo,
|
|
86
|
+
env: { MAGPIE_FAKE_HEAD_OID: head },
|
|
87
|
+
})
|
|
88
|
+
expect(result.ok).toBe(true)
|
|
89
|
+
if (!result.ok) return
|
|
90
|
+
expect(result.source).toBe('git')
|
|
91
|
+
expect(result.mergeBase).toMatch(/^[0-9a-f]{40}$/)
|
|
92
|
+
const diff = await readFile(join(runDir, 'diff.patch'), 'utf8')
|
|
93
|
+
expect(diff).toContain('export const x = 2')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('fetchPr falls back when gh returns an empty diff for a non-empty PR', async () => {
|
|
97
|
+
const { repoPath, head } = await createGitFixtureRepo()
|
|
98
|
+
repo = repoPath
|
|
99
|
+
const result = await fetchPr({
|
|
100
|
+
ghBin: FAKE_GH_NODIFF,
|
|
101
|
+
gitBin: 'git',
|
|
102
|
+
prNumber: 7,
|
|
103
|
+
runDir,
|
|
104
|
+
cwd: repo,
|
|
105
|
+
env: { MAGPIE_FAKE_HEAD_OID: head, MAGPIE_FAKE_DIFF_MODE: 'empty' },
|
|
106
|
+
})
|
|
107
|
+
expect(result.ok).toBe(true)
|
|
108
|
+
if (!result.ok) return
|
|
109
|
+
expect(result.source).toBe('git')
|
|
110
|
+
const diff = await readFile(join(runDir, 'diff.patch'), 'utf8')
|
|
111
|
+
expect(diff).toContain('export const x = 2')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('fetchPr reports source gh when gh succeeds', async () => {
|
|
115
|
+
const result = await fetchPr({ ghBin: FAKE_GH, prNumber: 1234, runDir })
|
|
116
|
+
expect(result.ok).toBe(true)
|
|
117
|
+
if (!result.ok) return
|
|
118
|
+
expect(result.source).toBe('gh')
|
|
119
|
+
expect(result.mergeBase).toBeUndefined()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('fetchPr fails when gh refuses and the head SHA is not local', async () => {
|
|
123
|
+
const { repoPath } = await createGitFixtureRepo()
|
|
124
|
+
repo = repoPath
|
|
125
|
+
const result = await fetchPr({
|
|
126
|
+
ghBin: FAKE_GH_NODIFF,
|
|
127
|
+
gitBin: 'git',
|
|
128
|
+
prNumber: 7,
|
|
129
|
+
runDir,
|
|
130
|
+
cwd: repo,
|
|
131
|
+
// No MAGPIE_FAKE_HEAD_OID, so pr.json carries a SHA this repo has never seen.
|
|
132
|
+
})
|
|
133
|
+
expect(result.ok).toBe(false)
|
|
134
|
+
if (result.ok) return
|
|
135
|
+
expect(result.error).toContain('cannot resolve PR head')
|
|
136
|
+
expect(result.error).toContain('406')
|
|
137
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { afterEach, expect, test } from 'bun:test'
|
|
2
|
+
import { rm } from 'node:fs/promises'
|
|
3
|
+
import { localPrDiff } from '../git-diff.ts'
|
|
4
|
+
import { createGitFixtureRepo, git } from './helpers/git-fixture.ts'
|
|
5
|
+
|
|
6
|
+
let repo = ''
|
|
7
|
+
|
|
8
|
+
afterEach(async () => {
|
|
9
|
+
if (repo) {
|
|
10
|
+
await rm(repo, { recursive: true, force: true })
|
|
11
|
+
repo = ''
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('localPrDiff produces the three-dot diff from the merge base', async () => {
|
|
16
|
+
const { repoPath, head } = await createGitFixtureRepo()
|
|
17
|
+
repo = repoPath
|
|
18
|
+
const result = await localPrDiff({
|
|
19
|
+
gitBin: 'git',
|
|
20
|
+
repoPath,
|
|
21
|
+
prNumber: 7,
|
|
22
|
+
baseRefName: 'main',
|
|
23
|
+
headRefOid: head,
|
|
24
|
+
})
|
|
25
|
+
expect(result.ok).toBe(true)
|
|
26
|
+
if (!result.ok) return
|
|
27
|
+
expect(result.diff).toContain('export const x = 2')
|
|
28
|
+
// b.ts landed on main after the branch point. A two-dot diff against the
|
|
29
|
+
// base tip would show it as a deletion; the three-dot diff must not.
|
|
30
|
+
expect(result.diff).not.toContain('b.ts')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('localPrDiff errors when the head SHA is not present locally', async () => {
|
|
34
|
+
const { repoPath } = await createGitFixtureRepo()
|
|
35
|
+
repo = repoPath
|
|
36
|
+
const result = await localPrDiff({
|
|
37
|
+
gitBin: 'git',
|
|
38
|
+
repoPath,
|
|
39
|
+
prNumber: 7,
|
|
40
|
+
baseRefName: 'main',
|
|
41
|
+
headRefOid: '0'.repeat(40),
|
|
42
|
+
})
|
|
43
|
+
expect(result.ok).toBe(false)
|
|
44
|
+
if (result.ok) return
|
|
45
|
+
expect(result.error).toContain('cannot resolve PR head')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('localPrDiff errors when the base branch is not present locally', async () => {
|
|
49
|
+
const { repoPath, head } = await createGitFixtureRepo()
|
|
50
|
+
repo = repoPath
|
|
51
|
+
const result = await localPrDiff({
|
|
52
|
+
gitBin: 'git',
|
|
53
|
+
repoPath,
|
|
54
|
+
prNumber: 7,
|
|
55
|
+
baseRefName: 'no-such-branch',
|
|
56
|
+
headRefOid: head,
|
|
57
|
+
})
|
|
58
|
+
expect(result.ok).toBe(false)
|
|
59
|
+
if (result.ok) return
|
|
60
|
+
expect(result.error).toContain('cannot resolve base branch')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('localPrDiff errors when a stale refs/magpie/pr-<n> ref points at an older commit', async () => {
|
|
64
|
+
const { repoPath, head } = await createGitFixtureRepo()
|
|
65
|
+
repo = repoPath
|
|
66
|
+
// Simulate a previous run's fetch having landed an older commit under the
|
|
67
|
+
// PR ref, with no origin remote configured to refresh it from. head~1 is
|
|
68
|
+
// the pre-branch "base" commit: a valid, resolvable, genuinely older commit.
|
|
69
|
+
const staleSha = await git(repoPath, 'rev-parse', `${head}~1`)
|
|
70
|
+
await git(repoPath, 'update-ref', 'refs/magpie/pr-7', staleSha)
|
|
71
|
+
const result = await localPrDiff({
|
|
72
|
+
gitBin: 'git',
|
|
73
|
+
repoPath,
|
|
74
|
+
prNumber: 7,
|
|
75
|
+
baseRefName: 'main',
|
|
76
|
+
headRefOid: head,
|
|
77
|
+
})
|
|
78
|
+
expect(result.ok).toBe(false)
|
|
79
|
+
if (result.ok) return
|
|
80
|
+
expect(result.error).toContain('stale')
|
|
81
|
+
expect(result.error).toContain(staleSha)
|
|
82
|
+
expect(result.error).toContain(head)
|
|
83
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { mkdtemp, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/** Spawn `git` in `cwd` and return trimmed stdout, throwing on a non-zero exit. */
|
|
6
|
+
export async function git(cwd: string, ...args: string[]): Promise<string> {
|
|
7
|
+
const proc = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
8
|
+
const stdout = await new Response(proc.stdout).text()
|
|
9
|
+
const exit = await proc.exited
|
|
10
|
+
if (exit !== 0) {
|
|
11
|
+
const stderr = await new Response(proc.stderr).text()
|
|
12
|
+
throw new Error(`git ${args.join(' ')} exit ${exit}: ${stderr}`)
|
|
13
|
+
}
|
|
14
|
+
return stdout.trim()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type GitFixtureRepo = {
|
|
18
|
+
repoPath: string
|
|
19
|
+
head: string
|
|
20
|
+
baseTip: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A fresh repo with `main` and a `feature-x` branch one commit ahead, plus a
|
|
25
|
+
* commit on `main` after the branch point so a two-dot diff would differ
|
|
26
|
+
* from a three-dot one. Shared by gh.test.ts and git-diff.test.ts, which both
|
|
27
|
+
* exercise the local-git PR-diff fallback against a real clone.
|
|
28
|
+
*/
|
|
29
|
+
export async function createGitFixtureRepo(): Promise<GitFixtureRepo> {
|
|
30
|
+
const repoPath = await mkdtemp(join(tmpdir(), 'magpie-git-fixture-'))
|
|
31
|
+
await git(repoPath, 'init', '-b', 'main')
|
|
32
|
+
await git(repoPath, 'config', 'user.email', 'test@example.com')
|
|
33
|
+
await git(repoPath, 'config', 'user.name', 'Test')
|
|
34
|
+
await writeFile(join(repoPath, 'a.ts'), 'export const x = 1\n')
|
|
35
|
+
await git(repoPath, 'add', '.')
|
|
36
|
+
await git(repoPath, 'commit', '-m', 'base')
|
|
37
|
+
await git(repoPath, 'checkout', '-b', 'feature-x')
|
|
38
|
+
await writeFile(join(repoPath, 'a.ts'), 'export const x = 2\n')
|
|
39
|
+
await git(repoPath, 'commit', '-am', 'change x')
|
|
40
|
+
const head = await git(repoPath, 'rev-parse', 'HEAD')
|
|
41
|
+
await git(repoPath, 'checkout', 'main')
|
|
42
|
+
await writeFile(join(repoPath, 'b.ts'), 'export const y = 9\n')
|
|
43
|
+
await git(repoPath, 'add', '.')
|
|
44
|
+
await git(repoPath, 'commit', '-m', 'unrelated base commit')
|
|
45
|
+
const baseTip = await git(repoPath, 'rev-parse', 'HEAD')
|
|
46
|
+
return { repoPath, head, baseTip }
|
|
47
|
+
}
|
|
@@ -159,3 +159,30 @@ test('filterDiff: include records not-in-include reason', () => {
|
|
|
159
159
|
})
|
|
160
160
|
expect(excluded.find((e) => e.path === 'bun.lock')?.pattern).toBe('not-in-include')
|
|
161
161
|
})
|
|
162
|
+
|
|
163
|
+
test('DEFAULT_EXCLUDES covers generated .NET sources', () => {
|
|
164
|
+
const generated = [
|
|
165
|
+
'src/Data/Migrations/20260801_AddUsers.Designer.cs',
|
|
166
|
+
'src/Data/Migrations/AppDbContextModelSnapshot.cs',
|
|
167
|
+
'src/Generated/Api.g.cs',
|
|
168
|
+
'src/Generated/Api.g.i.cs',
|
|
169
|
+
'src/Forms/MainForm.designer.vb',
|
|
170
|
+
]
|
|
171
|
+
for (const path of generated) {
|
|
172
|
+
expect(matchesAny(path, DEFAULT_EXCLUDES)).not.toBeNull()
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('DEFAULT_EXCLUDES leaves hand-written C# and docs alone', () => {
|
|
177
|
+
// The migration itself is reviewable; only its generated designer is not.
|
|
178
|
+
const reviewable = [
|
|
179
|
+
'src/Data/Migrations/20260801_AddUsers.cs',
|
|
180
|
+
'src/Services/UserService.cs',
|
|
181
|
+
'docs/architecture.md',
|
|
182
|
+
'docs/work/notes.md',
|
|
183
|
+
'README.md',
|
|
184
|
+
]
|
|
185
|
+
for (const path of reviewable) {
|
|
186
|
+
expect(matchesAny(path, DEFAULT_EXCLUDES)).toBeNull()
|
|
187
|
+
}
|
|
188
|
+
})
|
|
@@ -192,3 +192,98 @@ test('issue entries with a missing, zero, or negative number are dropped, not de
|
|
|
192
192
|
expect(html).not.toContain('https://example.test/issues/zero')
|
|
193
193
|
expect(html).not.toContain('https://example.test/issues/negative')
|
|
194
194
|
})
|
|
195
|
+
|
|
196
|
+
test('progress sums specialist counts across shards', async () => {
|
|
197
|
+
await mkdir(join(runDir, 'shards'), { recursive: true })
|
|
198
|
+
await writeFile(
|
|
199
|
+
join(runDir, 'shards', 'manifest.json'),
|
|
200
|
+
JSON.stringify({
|
|
201
|
+
budget: 6000,
|
|
202
|
+
maxFiles: 80,
|
|
203
|
+
totalFiles: 2,
|
|
204
|
+
totalLines: 20,
|
|
205
|
+
shards: [
|
|
206
|
+
{ id: 1, path: 'shards/shard-1.patch', files: ['a.ts'], lines: 10 },
|
|
207
|
+
{ id: 2, path: 'shards/shard-2.patch', files: ['b.ts'], lines: 10 },
|
|
208
|
+
],
|
|
209
|
+
}),
|
|
210
|
+
)
|
|
211
|
+
await writeFile(
|
|
212
|
+
join(runDir, 'log.jsonl'),
|
|
213
|
+
[
|
|
214
|
+
JSON.stringify({ stage: 'specialists', status: 'running' }),
|
|
215
|
+
JSON.stringify({
|
|
216
|
+
stage: 'specialist',
|
|
217
|
+
focus: 'security',
|
|
218
|
+
shard: 1,
|
|
219
|
+
status: 'done',
|
|
220
|
+
findings: 2,
|
|
221
|
+
}),
|
|
222
|
+
JSON.stringify({
|
|
223
|
+
stage: 'specialist',
|
|
224
|
+
focus: 'security',
|
|
225
|
+
shard: 2,
|
|
226
|
+
status: 'done',
|
|
227
|
+
findings: 3,
|
|
228
|
+
}),
|
|
229
|
+
JSON.stringify({ stage: 'specialist', focus: 'bugs', shard: 1, status: 'done', findings: 1 }),
|
|
230
|
+
].join('\n'),
|
|
231
|
+
)
|
|
232
|
+
const exit = await runRender(runDir, 'progress')
|
|
233
|
+
expect(exit).toBe(0)
|
|
234
|
+
const html = await readFile(join(runDir, 'screen', 'progress.html'), 'utf8')
|
|
235
|
+
expect(html).toContain('Five reviewers across 2 shards')
|
|
236
|
+
// security is 2 + 3, not 3.
|
|
237
|
+
expect(html).toContain('security <span class="count">5</span>')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
/** Log with the specialists stage in flight, which is the only state where the
|
|
241
|
+
* shard count reaches the rendered wording. */
|
|
242
|
+
async function specialistsRunning(): Promise<void> {
|
|
243
|
+
await writeFile(
|
|
244
|
+
join(runDir, 'log.jsonl'),
|
|
245
|
+
JSON.stringify({ stage: 'specialists', status: 'running' }),
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
test('a malformed shards/manifest.json degrades to the unsharded progress wording', async () => {
|
|
250
|
+
await mkdir(join(runDir, 'shards'), { recursive: true })
|
|
251
|
+
await writeFile(join(runDir, 'shards', 'manifest.json'), '{ "shards": [ truncated')
|
|
252
|
+
await specialistsRunning()
|
|
253
|
+
expect(await runRender(runDir, 'progress')).toBe(0)
|
|
254
|
+
const html = await readFile(join(runDir, 'screen', 'progress.html'), 'utf8')
|
|
255
|
+
expect(html).toContain('Five reviewers reading the diff in parallel')
|
|
256
|
+
expect(html).not.toMatch(/across \S+ shards/)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('a manifest with shards: [] renders the unsharded progress wording', async () => {
|
|
260
|
+
// A lockfile-only PR filters down to nothing, and shardDiff writes an
|
|
261
|
+
// otherwise-normal manifest with no shards. Zero must not read as "across 0
|
|
262
|
+
// shards"; stage 4 treats it as the unsharded path.
|
|
263
|
+
await mkdir(join(runDir, 'shards'), { recursive: true })
|
|
264
|
+
await writeFile(
|
|
265
|
+
join(runDir, 'shards', 'manifest.json'),
|
|
266
|
+
JSON.stringify({ budget: 6000, maxFiles: 80, totalFiles: 0, totalLines: 0, shards: [] }),
|
|
267
|
+
)
|
|
268
|
+
await specialistsRunning()
|
|
269
|
+
expect(await runRender(runDir, 'progress')).toBe(0)
|
|
270
|
+
const html = await readFile(join(runDir, 'screen', 'progress.html'), 'utf8')
|
|
271
|
+
expect(html).toContain('Five reviewers reading the diff in parallel')
|
|
272
|
+
expect(html).not.toContain('across 0 shards')
|
|
273
|
+
expect(html).not.toMatch(/across \S+ shards/)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test('progress re-dispatch of one shard replaces rather than doubles its count', async () => {
|
|
277
|
+
await writeFile(
|
|
278
|
+
join(runDir, 'log.jsonl'),
|
|
279
|
+
[
|
|
280
|
+
JSON.stringify({ stage: 'specialists', status: 'running' }),
|
|
281
|
+
JSON.stringify({ stage: 'specialist', focus: 'bugs', shard: 1, status: 'done', findings: 4 }),
|
|
282
|
+
JSON.stringify({ stage: 'specialist', focus: 'bugs', shard: 1, status: 'done', findings: 2 }),
|
|
283
|
+
].join('\n'),
|
|
284
|
+
)
|
|
285
|
+
const exit = await runRender(runDir, 'progress')
|
|
286
|
+
expect(exit).toBe(0)
|
|
287
|
+
const html = await readFile(join(runDir, 'screen', 'progress.html'), 'utf8')
|
|
288
|
+
expect(html).toContain('bugs <span class="count">2</span>')
|
|
289
|
+
})
|