@iceinvein/agent-skills 0.1.39 → 0.2.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 +105 -28
- package/package.json +1 -1
- package/skills/index.json +14 -2
- package/skills/magpie/README.md +2 -1
- package/skills/magpie/SKILL.md +138 -45
- package/skills/magpie/bin/magpie.ts +43 -0
- package/skills/magpie/fixtures/example-pr/brief.json +18 -0
- package/skills/magpie/fixtures/fake-gh-nodiff.sh +38 -0
- package/skills/magpie/fixtures/fake-gh.sh +14 -0
- package/skills/magpie/package.json +1 -1
- package/skills/magpie/references/peer-review.md +7 -2
- package/skills/magpie/references/scout.md +90 -0
- package/skills/magpie/references/specialists.md +117 -6
- 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 +90 -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__/preview-cmd.test.ts +16 -0
- package/skills/magpie/scripts/__tests__/refresh.test.ts +45 -0
- package/skills/magpie/scripts/__tests__/render-cmd.test.ts +173 -1
- package/skills/magpie/scripts/__tests__/render-findings.test.ts +151 -1
- 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 +207 -7
- package/skills/magpie/scripts/__tests__/types.test.ts +47 -0
- 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 +68 -14
- package/skills/magpie/scripts/git-diff.ts +111 -0
- package/skills/magpie/scripts/path-filter.ts +9 -5
- package/skills/magpie/scripts/preview-cmd.ts +11 -1
- package/skills/magpie/scripts/refresh.ts +32 -3
- package/skills/magpie/scripts/render-cmd.ts +35 -11
- package/skills/magpie/scripts/render-findings.ts +78 -2
- 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 +7 -4
- package/skills/magpie/scripts/types.ts +50 -0
- package/skills/magpie/skill.json +2 -2
- package/skills/magpie/templates/styles.css +66 -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 +82 -0
- package/skills/sluice/references/deep-channel.md +94 -0
- package/skills/sluice/references/finish.md +35 -0
- package/skills/sluice/references/intent.md +29 -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/skill.json +32 -0
|
@@ -2,7 +2,7 @@ import { beforeAll, expect, test } from 'bun:test'
|
|
|
2
2
|
import type { Highlighter } from 'shiki'
|
|
3
3
|
import { getHighlighter } from '../highlight.ts'
|
|
4
4
|
import { renderFindingsHtml } from '../render-findings.ts'
|
|
5
|
-
import type { ReviewFinding } from '../types.ts'
|
|
5
|
+
import type { PrBrief, ReviewFinding } from '../types.ts'
|
|
6
6
|
|
|
7
7
|
function f(p: Partial<ReviewFinding> & { id: string; title: string }): ReviewFinding {
|
|
8
8
|
return {
|
|
@@ -200,3 +200,153 @@ test('renders pr meta when supplied', () => {
|
|
|
200
200
|
expect(html).toContain('feat/x')
|
|
201
201
|
expect(html).toContain('deadbeefdead')
|
|
202
202
|
})
|
|
203
|
+
|
|
204
|
+
const SAMPLE_BRIEF: PrBrief = {
|
|
205
|
+
purpose:
|
|
206
|
+
'Adds bounded retries to the upload path so transient S3 failures stop surfacing to users.',
|
|
207
|
+
changes: ['Wraps the S3 put in a bounded retry', 'Adds a jittered backoff helper'],
|
|
208
|
+
subsystems: [
|
|
209
|
+
{ name: 'upload', role: 'owns the client-facing put path' },
|
|
210
|
+
{ name: 'storage-client', role: 'wraps the S3 SDK' },
|
|
211
|
+
],
|
|
212
|
+
watchItems: ['The PR body claims idempotency but no request key is sent'],
|
|
213
|
+
unclear: ['Whether the retry budget interacts with the outer request timeout'],
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
test('brief header renders purpose, changes, and subsystem chips', () => {
|
|
217
|
+
const html = renderFindingsHtml({
|
|
218
|
+
findings: SAMPLE_FINDINGS,
|
|
219
|
+
postStatus: {},
|
|
220
|
+
highlighter: hl,
|
|
221
|
+
brief: SAMPLE_BRIEF,
|
|
222
|
+
})
|
|
223
|
+
expect(html).toContain('class="pr-brief"')
|
|
224
|
+
expect(html).toContain('Adds bounded retries to the upload path')
|
|
225
|
+
expect(html).toContain('Wraps the S3 put in a bounded retry')
|
|
226
|
+
expect(html).toContain('class="brief-chip"')
|
|
227
|
+
expect(html).toContain('>upload<')
|
|
228
|
+
expect(html).toContain('>storage-client<')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('brief header omits watchItems and unclear, which are prompt-only', () => {
|
|
232
|
+
const html = renderFindingsHtml({
|
|
233
|
+
findings: SAMPLE_FINDINGS,
|
|
234
|
+
postStatus: {},
|
|
235
|
+
highlighter: hl,
|
|
236
|
+
brief: SAMPLE_BRIEF,
|
|
237
|
+
})
|
|
238
|
+
expect(html).not.toContain('no request key is sent')
|
|
239
|
+
expect(html).not.toContain('outer request timeout')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('no brief means no brief header at all', () => {
|
|
243
|
+
const html = renderFindingsHtml({ findings: SAMPLE_FINDINGS, postStatus: {}, highlighter: hl })
|
|
244
|
+
expect(html).not.toContain('class="pr-brief"')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('an empty subsystem list renders no chip row', () => {
|
|
248
|
+
const html = renderFindingsHtml({
|
|
249
|
+
findings: SAMPLE_FINDINGS,
|
|
250
|
+
postStatus: {},
|
|
251
|
+
highlighter: hl,
|
|
252
|
+
brief: { ...SAMPLE_BRIEF, subsystems: [] },
|
|
253
|
+
})
|
|
254
|
+
expect(html).toContain('class="pr-brief"')
|
|
255
|
+
expect(html).not.toContain('brief-subsystems')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('brief header renders on the empty-findings page too', () => {
|
|
259
|
+
const html = renderFindingsHtml({
|
|
260
|
+
findings: [],
|
|
261
|
+
postStatus: {},
|
|
262
|
+
highlighter: hl,
|
|
263
|
+
brief: SAMPLE_BRIEF,
|
|
264
|
+
})
|
|
265
|
+
expect(html).toContain('No findings')
|
|
266
|
+
expect(html).toContain('class="pr-brief"')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
test('linked issues render as links when supplied', () => {
|
|
270
|
+
const html = renderFindingsHtml({
|
|
271
|
+
findings: SAMPLE_FINDINGS,
|
|
272
|
+
postStatus: {},
|
|
273
|
+
highlighter: hl,
|
|
274
|
+
brief: SAMPLE_BRIEF,
|
|
275
|
+
issues: [
|
|
276
|
+
{ number: 42, title: 'Uploads fail intermittently', url: 'https://example.test/issues/42' },
|
|
277
|
+
],
|
|
278
|
+
})
|
|
279
|
+
expect(html).toContain('https://example.test/issues/42')
|
|
280
|
+
expect(html).toContain('#42')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('brief content is HTML-escaped', () => {
|
|
284
|
+
const html = renderFindingsHtml({
|
|
285
|
+
findings: SAMPLE_FINDINGS,
|
|
286
|
+
postStatus: {},
|
|
287
|
+
highlighter: hl,
|
|
288
|
+
brief: { ...SAMPLE_BRIEF, purpose: 'Fixes <script>alert(1)</script> handling' },
|
|
289
|
+
})
|
|
290
|
+
expect(html).not.toContain('<script>alert(1)</script>')
|
|
291
|
+
expect(html).toContain('<script>')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test('subsystem role and issue title escape quotes in their title="" attribute context', () => {
|
|
295
|
+
const html = renderFindingsHtml({
|
|
296
|
+
findings: SAMPLE_FINDINGS,
|
|
297
|
+
postStatus: {},
|
|
298
|
+
highlighter: hl,
|
|
299
|
+
brief: {
|
|
300
|
+
...SAMPLE_BRIEF,
|
|
301
|
+
subsystems: [{ name: 'upload', role: 'owns the put path" onmouseover="alert(1)' }],
|
|
302
|
+
},
|
|
303
|
+
issues: [
|
|
304
|
+
{
|
|
305
|
+
number: 42,
|
|
306
|
+
title: 'fails intermittently" onmouseover="alert(2)',
|
|
307
|
+
url: 'https://example.test/issues/42',
|
|
308
|
+
},
|
|
309
|
+
],
|
|
310
|
+
})
|
|
311
|
+
// Raw quote-breakout must never appear unescaped in either attribute.
|
|
312
|
+
expect(html).not.toContain('path" onmouseover="alert(1)')
|
|
313
|
+
expect(html).not.toContain('intermittently" onmouseover="alert(2)')
|
|
314
|
+
// Escaped form must appear instead.
|
|
315
|
+
expect(html).toContain('path" onmouseover="alert(1)')
|
|
316
|
+
expect(html).toContain('intermittently" onmouseover="alert(2)')
|
|
317
|
+
// purpose escaping (already covered above) must remain intact alongside this.
|
|
318
|
+
expect(html).toContain('Adds bounded retries to the upload path')
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
test('findings header notes a locally-built diff', () => {
|
|
322
|
+
const html = renderFindingsHtml({
|
|
323
|
+
findings: [],
|
|
324
|
+
postStatus: {},
|
|
325
|
+
pr: { number: 7, branch: 'feature-x', headSha: 'abc123abc123' },
|
|
326
|
+
diffSource: { source: 'git', mergeBase: 'a'.repeat(40) },
|
|
327
|
+
highlighter: hl,
|
|
328
|
+
})
|
|
329
|
+
expect(html).toContain('local clone')
|
|
330
|
+
expect(html).toContain('aaaaaaaaaaaa')
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
test('findings header stays quiet when the diff came from gh', () => {
|
|
334
|
+
const html = renderFindingsHtml({
|
|
335
|
+
findings: [],
|
|
336
|
+
postStatus: {},
|
|
337
|
+
pr: { number: 7, branch: 'feature-x', headSha: 'abc123abc123' },
|
|
338
|
+
diffSource: { source: 'gh', mergeBase: null },
|
|
339
|
+
highlighter: hl,
|
|
340
|
+
})
|
|
341
|
+
expect(html).not.toContain('local clone')
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
test('findings header stays quiet when there is no diff source at all', () => {
|
|
345
|
+
const html = renderFindingsHtml({
|
|
346
|
+
findings: [],
|
|
347
|
+
postStatus: {},
|
|
348
|
+
pr: { number: 7, branch: 'feature-x', headSha: 'abc123abc123' },
|
|
349
|
+
highlighter: hl,
|
|
350
|
+
})
|
|
351
|
+
expect(html).not.toContain('local clone')
|
|
352
|
+
})
|
|
@@ -157,3 +157,45 @@ test('progress-pane block replaces diff-pane on the progress page', () => {
|
|
|
157
157
|
expect(html).toContain('data-role="progress-pane"')
|
|
158
158
|
expect(html).toContain('Indexing repo symbols')
|
|
159
159
|
})
|
|
160
|
+
|
|
161
|
+
test('progress names the shard count while specialists run', () => {
|
|
162
|
+
const html = renderProgressHtml({
|
|
163
|
+
prNumber: 7,
|
|
164
|
+
headSha: 'abc123abc123',
|
|
165
|
+
branch: 'feature-x',
|
|
166
|
+
stages: {
|
|
167
|
+
setup: 'done',
|
|
168
|
+
context: 'done',
|
|
169
|
+
specialists: 'running',
|
|
170
|
+
dedupe: 'pending',
|
|
171
|
+
critic: 'pending',
|
|
172
|
+
'peer-review': 'pending',
|
|
173
|
+
report: 'pending',
|
|
174
|
+
post: 'pending',
|
|
175
|
+
},
|
|
176
|
+
specialistCounts: {},
|
|
177
|
+
shardCount: 6,
|
|
178
|
+
})
|
|
179
|
+
expect(html).toContain('Five reviewers across 6 shards')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('progress keeps the single-shard wording when there is one shard', () => {
|
|
183
|
+
const html = renderProgressHtml({
|
|
184
|
+
prNumber: 7,
|
|
185
|
+
headSha: 'abc123abc123',
|
|
186
|
+
branch: 'feature-x',
|
|
187
|
+
stages: {
|
|
188
|
+
setup: 'done',
|
|
189
|
+
context: 'done',
|
|
190
|
+
specialists: 'running',
|
|
191
|
+
dedupe: 'pending',
|
|
192
|
+
critic: 'pending',
|
|
193
|
+
'peer-review': 'pending',
|
|
194
|
+
report: 'pending',
|
|
195
|
+
post: 'pending',
|
|
196
|
+
},
|
|
197
|
+
specialistCounts: {},
|
|
198
|
+
shardCount: 1,
|
|
199
|
+
})
|
|
200
|
+
expect(html).toContain('Five reviewers reading the diff in parallel')
|
|
201
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
-
import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { runSetup } from '../setup-cmd.ts'
|
|
@@ -179,3 +179,85 @@ test('runSetup with failing gh removes any partial state', async () => {
|
|
|
179
179
|
expect(contents).not.toContain('worktree')
|
|
180
180
|
expect(contents).not.toContain('pr.json')
|
|
181
181
|
})
|
|
182
|
+
|
|
183
|
+
test('runSetup shards the filtered diff and logs the result', async () => {
|
|
184
|
+
const exit = await runSetup({
|
|
185
|
+
runDir,
|
|
186
|
+
prNumber: 1234,
|
|
187
|
+
repoPath: repo,
|
|
188
|
+
deps: { bun: 'bun', gh: FAKE_GH, codex: 'echo', git: 'git' },
|
|
189
|
+
})
|
|
190
|
+
expect(exit).toBe(0)
|
|
191
|
+
const manifest = JSON.parse(await readFile(join(runDir, 'shards', 'manifest.json'), 'utf8'))
|
|
192
|
+
expect(manifest.shards.length).toBeGreaterThanOrEqual(1)
|
|
193
|
+
// The fake PR is one small file, so it stays a single-shard passthrough.
|
|
194
|
+
expect(manifest.shards[0].path).toBe('diff.patch')
|
|
195
|
+
const log = await readFile(join(runDir, 'log.jsonl'), 'utf8')
|
|
196
|
+
const shardEntry = log
|
|
197
|
+
.split('\n')
|
|
198
|
+
.filter(Boolean)
|
|
199
|
+
.map((l) => JSON.parse(l))
|
|
200
|
+
.find((e) => e.stage === 'shard')
|
|
201
|
+
expect(shardEntry).toMatchObject({ status: 'done', shards: 1 })
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('runSetup cleans up a populated shards/ when worktree creation fails after sharding', async () => {
|
|
205
|
+
// `git worktree add` refuses a target path that already exists and is
|
|
206
|
+
// non-empty, which forces the worktree step to fail *after* fetchPr,
|
|
207
|
+
// applyPathFilter, and runShard have already run successfully, without
|
|
208
|
+
// needing a fake git binary.
|
|
209
|
+
await mkdir(join(runDir, 'worktree'), { recursive: true })
|
|
210
|
+
await writeFile(join(runDir, 'worktree', 'occupied'), 'x')
|
|
211
|
+
|
|
212
|
+
const exit = await runSetup({
|
|
213
|
+
runDir,
|
|
214
|
+
prNumber: 1234,
|
|
215
|
+
repoPath: repo,
|
|
216
|
+
deps: { bun: 'bun', gh: FAKE_GH, codex: 'echo', git: 'git' },
|
|
217
|
+
})
|
|
218
|
+
expect(exit).toBe(5)
|
|
219
|
+
|
|
220
|
+
const log = await readFile(join(runDir, 'log.jsonl'), 'utf8')
|
|
221
|
+
const entries = log
|
|
222
|
+
.split('\n')
|
|
223
|
+
.filter(Boolean)
|
|
224
|
+
.map((l) => JSON.parse(l))
|
|
225
|
+
// Sharding really did happen before the worktree failure: this isn't a
|
|
226
|
+
// false pass from the worktree step failing before shard ever ran.
|
|
227
|
+
expect(entries.find((e) => e.stage === 'shard')).toMatchObject({ status: 'done', shards: 1 })
|
|
228
|
+
expect(entries.find((e) => e.stage === 'worktree')).toMatchObject({ status: 'error' })
|
|
229
|
+
|
|
230
|
+
const contents = await readdir(runDir).catch(() => [])
|
|
231
|
+
expect(contents).not.toContain('shards')
|
|
232
|
+
expect(contents).not.toContain('worktree')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('runSetup treats a shardDiff throw as a hard stop: logs, cleans up, exits 6', async () => {
|
|
236
|
+
// shardDiff creates $RUN_DIR/shards via a plain recursive mkdir. Seeding
|
|
237
|
+
// 'shards' as a regular file ahead of time forces that mkdir to throw
|
|
238
|
+
// EEXIST, a genuine non-ENOENT failure out of shardDiff. diff.patch itself
|
|
239
|
+
// can't be used for this: fetchPr and applyPathFilter both write it earlier
|
|
240
|
+
// in the pipeline, so pre-seeding it as something unreadable would fail
|
|
241
|
+
// before the shard step is ever reached.
|
|
242
|
+
await writeFile(join(runDir, 'shards'), 'not a directory')
|
|
243
|
+
|
|
244
|
+
const exit = await runSetup({
|
|
245
|
+
runDir,
|
|
246
|
+
prNumber: 1234,
|
|
247
|
+
repoPath: repo,
|
|
248
|
+
deps: { bun: 'bun', gh: FAKE_GH, codex: 'echo', git: 'git' },
|
|
249
|
+
})
|
|
250
|
+
expect(exit).toBe(6)
|
|
251
|
+
|
|
252
|
+
const log = await readFile(join(runDir, 'log.jsonl'), 'utf8')
|
|
253
|
+
const entries = log
|
|
254
|
+
.split('\n')
|
|
255
|
+
.filter(Boolean)
|
|
256
|
+
.map((l) => JSON.parse(l))
|
|
257
|
+
const shardEntry = entries.find((e) => e.stage === 'shard')
|
|
258
|
+
expect(shardEntry).toMatchObject({ status: 'error' })
|
|
259
|
+
expect(typeof (shardEntry as { error: unknown }).error).toBe('string')
|
|
260
|
+
|
|
261
|
+
const contents = await readdir(runDir).catch(() => [])
|
|
262
|
+
expect(contents.filter((c) => c !== 'log.jsonl')).toHaveLength(0)
|
|
263
|
+
})
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { DEFAULT_SHARD_BUDGET, groupKey, planShards, shardDiff } from '../shard.ts'
|
|
7
|
+
|
|
8
|
+
let runDir: string
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
runDir = await mkdtemp(join(tmpdir(), 'magpie-shard-'))
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await rm(runDir, { recursive: true, force: true })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
/** A file chunk with `bodyLines` added lines, shaped like a real patch. */
|
|
19
|
+
function chunk(path: string, bodyLines: number): string {
|
|
20
|
+
const body = Array.from({ length: bodyLines }, (_, i) => `+line ${i}`).join('\n')
|
|
21
|
+
return [
|
|
22
|
+
`diff --git a/${path} b/${path}`,
|
|
23
|
+
'index 0000000..1111111 100644',
|
|
24
|
+
`--- a/${path}`,
|
|
25
|
+
`+++ b/${path}`,
|
|
26
|
+
`@@ -0,0 +1,${bodyLines} @@`,
|
|
27
|
+
body,
|
|
28
|
+
'',
|
|
29
|
+
].join('\n')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test('groupKey caps the grouping directory at two segments', () => {
|
|
33
|
+
expect(groupKey('src/cli/commands/install.ts')).toBe('src/cli')
|
|
34
|
+
expect(groupKey('src/a.ts')).toBe('src')
|
|
35
|
+
expect(groupKey('README.md')).toBe('.')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('planShards keeps a directory group together when it fits', () => {
|
|
39
|
+
const chunks = [
|
|
40
|
+
{ path: 'src/cli/a.ts', text: '', lines: 100 },
|
|
41
|
+
{ path: 'src/web/b.ts', text: '', lines: 100 },
|
|
42
|
+
{ path: 'src/cli/c.ts', text: '', lines: 100 },
|
|
43
|
+
]
|
|
44
|
+
const shards = planShards(chunks, 1000, 80)
|
|
45
|
+
expect(shards).toHaveLength(1)
|
|
46
|
+
// Grouping reorders: both src/cli files are adjacent.
|
|
47
|
+
expect(shards[0]?.map((c) => c.path)).toEqual(['src/cli/a.ts', 'src/cli/c.ts', 'src/web/b.ts'])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('planShards splits when the line budget is exceeded', () => {
|
|
51
|
+
const chunks = [
|
|
52
|
+
{ path: 'src/a/1.ts', text: '', lines: 600 },
|
|
53
|
+
{ path: 'src/b/2.ts', text: '', lines: 600 },
|
|
54
|
+
{ path: 'src/c/3.ts', text: '', lines: 600 },
|
|
55
|
+
]
|
|
56
|
+
const shards = planShards(chunks, 1000, 80)
|
|
57
|
+
expect(shards).toHaveLength(3)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('planShards splits when max-files is reached before the budget', () => {
|
|
61
|
+
const chunks = Array.from({ length: 5 }, (_, i) => ({
|
|
62
|
+
path: `src/g${i}/f.ts`,
|
|
63
|
+
text: '',
|
|
64
|
+
lines: 1,
|
|
65
|
+
}))
|
|
66
|
+
const shards = planShards(chunks, 10_000, 2)
|
|
67
|
+
expect(shards).toHaveLength(3)
|
|
68
|
+
expect(shards[0]).toHaveLength(2)
|
|
69
|
+
expect(shards[2]).toHaveLength(1)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('planShards gives an over-budget file a shard to itself', () => {
|
|
73
|
+
const chunks = [
|
|
74
|
+
{ path: 'src/a/small.ts', text: '', lines: 10 },
|
|
75
|
+
{ path: 'src/b/huge.ts', text: '', lines: 9000 },
|
|
76
|
+
{ path: 'src/c/other.ts', text: '', lines: 10 },
|
|
77
|
+
]
|
|
78
|
+
const shards = planShards(chunks, 1000, 80)
|
|
79
|
+
expect(shards).toHaveLength(3)
|
|
80
|
+
expect(shards[1]?.map((c) => c.path)).toEqual(['src/b/huge.ts'])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('shardDiff writes a single-shard manifest pointing at diff.patch', async () => {
|
|
84
|
+
await writeFile(join(runDir, 'diff.patch'), chunk('src/a.ts', 3))
|
|
85
|
+
const manifest = await shardDiff(runDir)
|
|
86
|
+
expect(manifest.shards).toHaveLength(1)
|
|
87
|
+
expect(manifest.shards[0]?.path).toBe('diff.patch')
|
|
88
|
+
expect(manifest.totalFiles).toBe(1)
|
|
89
|
+
expect(manifest.budget).toBe(DEFAULT_SHARD_BUDGET)
|
|
90
|
+
expect(existsSync(join(runDir, 'shards', 'shard-1.patch'))).toBe(false)
|
|
91
|
+
const onDisk = JSON.parse(await readFile(join(runDir, 'shards', 'manifest.json'), 'utf8'))
|
|
92
|
+
expect(onDisk.shards[0].files).toEqual(['src/a.ts'])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('shardDiff writes one patch per shard when the budget forces a split', async () => {
|
|
96
|
+
const diff = chunk('src/a/one.ts', 200) + chunk('src/b/two.ts', 200)
|
|
97
|
+
await writeFile(join(runDir, 'diff.patch'), diff)
|
|
98
|
+
const manifest = await shardDiff(runDir, { budget: 150 })
|
|
99
|
+
expect(manifest.shards).toHaveLength(2)
|
|
100
|
+
expect(manifest.shards[0]?.path).toBe('shards/shard-1.patch')
|
|
101
|
+
const first = await readFile(join(runDir, 'shards', 'shard-1.patch'), 'utf8')
|
|
102
|
+
expect(first).toContain('diff --git a/src/a/one.ts')
|
|
103
|
+
expect(first).not.toContain('src/b/two.ts')
|
|
104
|
+
// diff.patch is a view source, never replaced.
|
|
105
|
+
const whole = await readFile(join(runDir, 'diff.patch'), 'utf8')
|
|
106
|
+
expect(whole).toContain('src/a/one.ts')
|
|
107
|
+
expect(whole).toContain('src/b/two.ts')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('a re-split into fewer shards leaves no orphaned shard patches behind', async () => {
|
|
111
|
+
const diff =
|
|
112
|
+
chunk('src/a/one.ts', 200) + chunk('src/b/two.ts', 200) + chunk('src/c/three.ts', 200)
|
|
113
|
+
await writeFile(join(runDir, 'diff.patch'), diff)
|
|
114
|
+
const first = await shardDiff(runDir, { budget: 150 })
|
|
115
|
+
expect(first.shards).toHaveLength(3)
|
|
116
|
+
expect(existsSync(join(runDir, 'shards', 'shard-3.patch'))).toBe(true)
|
|
117
|
+
|
|
118
|
+
// Re-split with room for two shards. shard-3.patch describes a file set no
|
|
119
|
+
// shard id owns any more, so it must not survive the new split.
|
|
120
|
+
const second = await shardDiff(runDir, { budget: 450 })
|
|
121
|
+
expect(second.shards).toHaveLength(2)
|
|
122
|
+
const patches = (await readdir(join(runDir, 'shards'))).filter((n) => n.endsWith('.patch')).sort()
|
|
123
|
+
expect(patches).toEqual(['shard-1.patch', 'shard-2.patch'])
|
|
124
|
+
expect(existsSync(join(runDir, 'shards', 'manifest.json'))).toBe(true)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('a re-split down to one shard clears every shard patch and keeps the manifest', async () => {
|
|
128
|
+
const diff = chunk('src/a/one.ts', 200) + chunk('src/b/two.ts', 200)
|
|
129
|
+
await writeFile(join(runDir, 'diff.patch'), diff)
|
|
130
|
+
expect((await shardDiff(runDir, { budget: 150 })).shards).toHaveLength(2)
|
|
131
|
+
const manifest = await shardDiff(runDir, { budget: 10_000 })
|
|
132
|
+
expect(manifest.shards).toHaveLength(1)
|
|
133
|
+
expect(manifest.shards[0]?.path).toBe('diff.patch')
|
|
134
|
+
// Only the manifest is left: the single-shard entry points at diff.patch, and
|
|
135
|
+
// diff.patch itself lives outside shards/ and is never touched.
|
|
136
|
+
expect(await readdir(join(runDir, 'shards'))).toEqual(['manifest.json'])
|
|
137
|
+
expect(existsSync(join(runDir, 'diff.patch'))).toBe(true)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('shardDiff handles an empty diff without writing shard files', async () => {
|
|
141
|
+
await writeFile(join(runDir, 'diff.patch'), '')
|
|
142
|
+
const manifest = await shardDiff(runDir)
|
|
143
|
+
expect(manifest.shards).toEqual([])
|
|
144
|
+
expect(manifest.totalFiles).toBe(0)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('shardDiff propagates a non-ENOENT read failure instead of treating it as an empty diff', async () => {
|
|
148
|
+
// Make diff.patch a directory: reading it as text is a real, portable I/O
|
|
149
|
+
// failure (EISDIR) that is not "file absent" and must not be swallowed.
|
|
150
|
+
await mkdir(join(runDir, 'diff.patch'))
|
|
151
|
+
await expect(shardDiff(runDir)).rejects.toThrow()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('shardDiff treats a diff.patch that does not exist yet as an empty diff', async () => {
|
|
155
|
+
// No writeFile at all here: diff.patch is genuinely absent, the one case
|
|
156
|
+
// the ENOENT branch exists to tolerate. Task 4 wires shardDiff into
|
|
157
|
+
// runSetup and Task 6 reads the manifest unconditionally, so "no diff yet"
|
|
158
|
+
// must still leave a readable manifest rather than a missing file.
|
|
159
|
+
expect(existsSync(join(runDir, 'diff.patch'))).toBe(false)
|
|
160
|
+
const manifest = await shardDiff(runDir)
|
|
161
|
+
expect(manifest.shards).toEqual([])
|
|
162
|
+
expect(manifest.totalFiles).toBe(0)
|
|
163
|
+
expect(manifest.totalLines).toBe(0)
|
|
164
|
+
expect(existsSync(join(runDir, 'shards', 'manifest.json'))).toBe(true)
|
|
165
|
+
})
|