@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.
Files changed (148) hide show
  1. package/README.md +18 -2
  2. package/dist/cli/index.js +105 -28
  3. package/package.json +1 -1
  4. package/skills/index.json +14 -2
  5. package/skills/magpie/README.md +2 -1
  6. package/skills/magpie/SKILL.md +138 -45
  7. package/skills/magpie/bin/magpie.ts +43 -0
  8. package/skills/magpie/fixtures/example-pr/brief.json +18 -0
  9. package/skills/magpie/fixtures/fake-gh-nodiff.sh +38 -0
  10. package/skills/magpie/fixtures/fake-gh.sh +14 -0
  11. package/skills/magpie/package.json +1 -1
  12. package/skills/magpie/references/peer-review.md +7 -2
  13. package/skills/magpie/references/scout.md +90 -0
  14. package/skills/magpie/references/specialists.md +117 -6
  15. package/skills/magpie/scripts/__tests__/cli.test.ts +101 -1
  16. package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +187 -0
  17. package/skills/magpie/scripts/__tests__/diff-chunks.test.ts +51 -0
  18. package/skills/magpie/scripts/__tests__/filter-diff-preservation.test.ts +54 -0
  19. package/skills/magpie/scripts/__tests__/findings-files.test.ts +35 -0
  20. package/skills/magpie/scripts/__tests__/gh.test.ts +90 -0
  21. package/skills/magpie/scripts/__tests__/git-diff.test.ts +83 -0
  22. package/skills/magpie/scripts/__tests__/helpers/git-fixture.ts +47 -0
  23. package/skills/magpie/scripts/__tests__/path-filter.test.ts +27 -0
  24. package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +16 -0
  25. package/skills/magpie/scripts/__tests__/refresh.test.ts +45 -0
  26. package/skills/magpie/scripts/__tests__/render-cmd.test.ts +173 -1
  27. package/skills/magpie/scripts/__tests__/render-findings.test.ts +151 -1
  28. package/skills/magpie/scripts/__tests__/render-progress.test.ts +42 -0
  29. package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +83 -1
  30. package/skills/magpie/scripts/__tests__/shard.test.ts +165 -0
  31. package/skills/magpie/scripts/__tests__/skill-lint.test.ts +207 -7
  32. package/skills/magpie/scripts/__tests__/types.test.ts +47 -0
  33. package/skills/magpie/scripts/dedupe-cmd.ts +58 -3
  34. package/skills/magpie/scripts/diff-chunks.ts +28 -0
  35. package/skills/magpie/scripts/findings-files.ts +32 -0
  36. package/skills/magpie/scripts/gh.ts +68 -14
  37. package/skills/magpie/scripts/git-diff.ts +111 -0
  38. package/skills/magpie/scripts/path-filter.ts +9 -5
  39. package/skills/magpie/scripts/preview-cmd.ts +11 -1
  40. package/skills/magpie/scripts/refresh.ts +32 -3
  41. package/skills/magpie/scripts/render-cmd.ts +35 -11
  42. package/skills/magpie/scripts/render-findings.ts +78 -2
  43. package/skills/magpie/scripts/render-progress.ts +6 -1
  44. package/skills/magpie/scripts/setup-cmd.ts +38 -1
  45. package/skills/magpie/scripts/shard.ts +171 -0
  46. package/skills/magpie/scripts/status-cmd.ts +7 -4
  47. package/skills/magpie/scripts/types.ts +50 -0
  48. package/skills/magpie/skill.json +2 -2
  49. package/skills/magpie/templates/styles.css +66 -0
  50. package/skills/migrate/README.md +194 -0
  51. package/skills/migrate/SKILL.md +197 -0
  52. package/skills/migrate/bin/migrate +15 -0
  53. package/skills/migrate/bin/migrate.ts +309 -0
  54. package/skills/migrate/biome.json +35 -0
  55. package/skills/migrate/bun.lock +24 -0
  56. package/skills/migrate/docs/architecture.md +294 -0
  57. package/skills/migrate/docs/reference.md +590 -0
  58. package/skills/migrate/fixtures/tiny-express/GROUND-TRUTH.md +39 -0
  59. package/skills/migrate/fixtures/tiny-express/app.js +29 -0
  60. package/skills/migrate/fixtures/tiny-express/cron.js +6 -0
  61. package/skills/migrate/fixtures/tiny-express/reports/daily-users.json +6 -0
  62. package/skills/migrate/fixtures/tiny-express/schema.sql +12 -0
  63. package/skills/migrate/fixtures/tiny-express/settings.json +4 -0
  64. package/skills/migrate/fixtures/tiny-express/views/users.html +9 -0
  65. package/skills/migrate/fixtures/tiny-webforms/Controllers/UsersController.cs +68 -0
  66. package/skills/migrate/fixtures/tiny-webforms/Default.aspx +7 -0
  67. package/skills/migrate/fixtures/tiny-webforms/Default.aspx.cs +14 -0
  68. package/skills/migrate/fixtures/tiny-webforms/GROUND-TRUTH.md +50 -0
  69. package/skills/migrate/fixtures/tiny-webforms/Integrations/BillingClient.cs +16 -0
  70. package/skills/migrate/fixtures/tiny-webforms/Jobs/NightlyDigestJob.cs +33 -0
  71. package/skills/migrate/fixtures/tiny-webforms/Reports/DailyUsers.rdl +11 -0
  72. package/skills/migrate/fixtures/tiny-webforms/Schema.sql +12 -0
  73. package/skills/migrate/fixtures/tiny-webforms/Site.master +16 -0
  74. package/skills/migrate/fixtures/tiny-webforms/Users.aspx +8 -0
  75. package/skills/migrate/fixtures/tiny-webforms/Users.aspx.cs +14 -0
  76. package/skills/migrate/fixtures/tiny-webforms/web.config +10 -0
  77. package/skills/migrate/install.sh +68 -0
  78. package/skills/migrate/package.json +17 -0
  79. package/skills/migrate/references/phases/enumerate.md +291 -0
  80. package/skills/migrate/references/phases/extract.md +652 -0
  81. package/skills/migrate/references/phases/parity.md +275 -0
  82. package/skills/migrate/references/phases/probe.md +135 -0
  83. package/skills/migrate/references/phases/queue.md +242 -0
  84. package/skills/migrate/references/phases/seam.md +416 -0
  85. package/skills/migrate/references/recipes/README.md +116 -0
  86. package/skills/migrate/references/recipes/aspnet.md +287 -0
  87. package/skills/migrate/references/run-ops.md +280 -0
  88. package/skills/migrate/scripts/__tests__/census.test.ts +775 -0
  89. package/skills/migrate/scripts/__tests__/check.test.ts +458 -0
  90. package/skills/migrate/scripts/__tests__/citations.test.ts +156 -0
  91. package/skills/migrate/scripts/__tests__/cli.test.ts +183 -0
  92. package/skills/migrate/scripts/__tests__/concurrency.test.ts +164 -0
  93. package/skills/migrate/scripts/__tests__/config.test.ts +112 -0
  94. package/skills/migrate/scripts/__tests__/e2e-express.test.ts +1093 -0
  95. package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +1276 -0
  96. package/skills/migrate/scripts/__tests__/e2e.test.ts +320 -0
  97. package/skills/migrate/scripts/__tests__/ids.test.ts +38 -0
  98. package/skills/migrate/scripts/__tests__/import.test.ts +155 -0
  99. package/skills/migrate/scripts/__tests__/init.test.ts +192 -0
  100. package/skills/migrate/scripts/__tests__/leaks.test.ts +176 -0
  101. package/skills/migrate/scripts/__tests__/lock.test.ts +183 -0
  102. package/skills/migrate/scripts/__tests__/paths.test.ts +129 -0
  103. package/skills/migrate/scripts/__tests__/phase-cmd.test.ts +151 -0
  104. package/skills/migrate/scripts/__tests__/phases.test.ts +70 -0
  105. package/skills/migrate/scripts/__tests__/queue.test.ts +475 -0
  106. package/skills/migrate/scripts/__tests__/report.test.ts +150 -0
  107. package/skills/migrate/scripts/__tests__/run-state.test.ts +136 -0
  108. package/skills/migrate/scripts/__tests__/status-reset.test.ts +318 -0
  109. package/skills/migrate/scripts/__tests__/store.test.ts +132 -0
  110. package/skills/migrate/scripts/__tests__/validate.test.ts +54 -0
  111. package/skills/migrate/scripts/census-cmd.ts +109 -0
  112. package/skills/migrate/scripts/census.ts +342 -0
  113. package/skills/migrate/scripts/check-cmd.ts +24 -0
  114. package/skills/migrate/scripts/check.ts +376 -0
  115. package/skills/migrate/scripts/citations.ts +92 -0
  116. package/skills/migrate/scripts/config.ts +237 -0
  117. package/skills/migrate/scripts/ids.ts +31 -0
  118. package/skills/migrate/scripts/import-cmd.ts +141 -0
  119. package/skills/migrate/scripts/init-cmd.ts +118 -0
  120. package/skills/migrate/scripts/leaks.ts +184 -0
  121. package/skills/migrate/scripts/lock.ts +188 -0
  122. package/skills/migrate/scripts/paths.ts +103 -0
  123. package/skills/migrate/scripts/phase-cmd.ts +63 -0
  124. package/skills/migrate/scripts/phases.ts +113 -0
  125. package/skills/migrate/scripts/queue-cmd.ts +98 -0
  126. package/skills/migrate/scripts/queue.ts +258 -0
  127. package/skills/migrate/scripts/report-cmd.ts +47 -0
  128. package/skills/migrate/scripts/report.ts +131 -0
  129. package/skills/migrate/scripts/reset-cmd.ts +120 -0
  130. package/skills/migrate/scripts/status-cmd.ts +52 -0
  131. package/skills/migrate/scripts/store.ts +159 -0
  132. package/skills/migrate/scripts/types.ts +137 -0
  133. package/skills/migrate/scripts/validate.ts +221 -0
  134. package/skills/migrate/skill.json +33 -0
  135. package/skills/migrate/templates/config.toml +27 -0
  136. package/skills/migrate/templates/queue-item.md +17 -0
  137. package/skills/migrate/tsconfig.json +18 -0
  138. package/skills/migrate/uninstall.sh +31 -0
  139. package/skills/sluice/SKILL.md +82 -0
  140. package/skills/sluice/references/deep-channel.md +94 -0
  141. package/skills/sluice/references/finish.md +35 -0
  142. package/skills/sluice/references/intent.md +29 -0
  143. package/skills/sluice/references/review.md +42 -0
  144. package/skills/sluice/references/root-cause.md +38 -0
  145. package/skills/sluice/references/show-or-say.md +36 -0
  146. package/skills/sluice/references/test-first.md +35 -0
  147. package/skills/sluice/references/verify.md +26 -0
  148. package/skills/sluice/skill.json +32 -0
@@ -1,9 +1,12 @@
1
1
  # Specialist prompts
2
2
 
3
- Stage 3 of the walkthrough dispatches five subagents from this file. Build each prompt from three parts, in this order, and send it as the agent's entire task:
3
+ Stage 4 of the walkthrough dispatches five subagents per shard from this file. Build
4
+ each prompt from up to six parts, in this order, and send it as the agent's entire
5
+ task. Each part is a fenced block or the named section it points at; the prose around
6
+ them is instruction to you, not text for the specialist.
4
7
 
5
8
  1. The focus block for that focus (the fenced `magpie-specialist-<focus>` blocks below), verbatim.
6
- 2. The run header, with the two placeholders filled in:
9
+ 2. The run header, with the placeholders filled in. Unsharded:
7
10
 
8
11
  ```
9
12
  You are reviewing PR #<PR_NUMBER>.
@@ -11,15 +14,76 @@ Working directory: <RUN_DIR>/worktree
11
14
  Diff: <RUN_DIR>/diff.patch
12
15
  ```
13
16
 
17
+ When stage 4 is fanning this focus across shards, use instead:
18
+
19
+ ```
20
+ You are reviewing PR #<PR_NUMBER>.
21
+ Working directory: <RUN_DIR>/worktree
22
+ Shard: <n> of <N>
23
+ Diff: <RUN_DIR>/shards/shard-<n>.patch
24
+
25
+ This shard is your review scope. The other shards belong to other agents working
26
+ in parallel: do not review files outside your shard, and do not report findings
27
+ anchored to them. Reading any file in the worktree for context is expected and
28
+ encouraged.
29
+ ```
30
+
14
31
  3. The `## Output Contract` section below, verbatim.
32
+ 4. The excluded-files block below, verbatim, when `<RUN_DIR>/excluded-files.json`
33
+ exists (setup writes it only when the path filter removed something, and without
34
+ it the two paths the block names do not exist):
35
+
36
+ ```
37
+ Files excluded by the path filter are absent from your diff but present in the
38
+ worktree, with their full patches in <RUN_DIR>/diff.full.patch and the exclusion
39
+ list in <RUN_DIR>/excluded-files.json. When a change in your scope implies a change
40
+ in an excluded file (a migration implies a model snapshot, a schema change implies
41
+ generated types), open it and cross-check rather than treating it as out of scope.
42
+ ```
43
+
44
+ 5. The `magpie-codebase-intelligence` block below, verbatim, **only** when the context
45
+ stage logged `codeIntelligence: true`. Omit it entirely otherwise: telling a
46
+ specialist to use tools it does not have wastes a turn per specialist on discovery.
47
+ 6. The brief, when `<RUN_DIR>/brief.json` exists, rendered as:
15
48
 
16
- Replace every `<RUN_DIR>` and `<PR_NUMBER>` with the real values before sending: the subagent has no shell variables from your session, so an unexpanded path means it writes its findings where nothing will read them. Leave `<focus>` as written; the contract tells the subagent to substitute it.
49
+ ```
50
+ ## What this PR is for
17
51
 
18
- Send all three parts every time. The contract is what makes the output parseable by `magpie dedupe`, and the focus blocks are what keep the five reviews from collapsing into the same generic pass. Do not paraphrase, summarise, or trim either one.
52
+ <purpose>
53
+
54
+ What it does:
55
+ - <each entry of changes>
56
+
57
+ Subsystems it lands in:
58
+ - <name>: <role> (omit this heading when subsystems is empty)
59
+
60
+ Watch items:
61
+ - <each entry of watchItems> (omit this heading when watchItems is empty)
62
+
63
+ Open questions the scout could not resolve:
64
+ - <each entry of unclear> (omit this heading when unclear is empty)
65
+
66
+ This brief is the author's claim as understood by a reader who has not reviewed the
67
+ code. It is not ground truth. Where the diff contradicts it, that is a finding in
68
+ your domain, not a correction to the brief. A watch item is a pointer, not a verdict:
69
+ escalate it into a finding with your own risk fields, or leave it alone.
70
+ ```
71
+
72
+ Replace every `<RUN_DIR>` and `<PR_NUMBER>` with the real values before sending: the
73
+ subagent has no shell variables from your session, so an unexpanded path means it
74
+ writes its findings where nothing will read them. Leave `<focus>` as written; the
75
+ contract tells the subagent to substitute it.
76
+
77
+ Parts 1, 2, and 3 go every time. The contract is what makes the output parseable by
78
+ `magpie dedupe`, and the focus blocks are what keep the five reviews from collapsing
79
+ into the same generic pass. Do not paraphrase, summarise, or trim either one.
19
80
 
20
81
  ## Output Contract
21
82
 
22
- Write findings to <RUN_DIR>/findings/<focus>.json before returning. The file MUST be a JSON array. Each entry MUST conform to this schema exactly (no extra top-level keys, no renamed keys):
83
+ Write findings to <RUN_DIR>/findings/<focus>.json before returning, or to
84
+ <RUN_DIR>/findings/<focus>.shard-<n>.json when your run header names a shard. The file
85
+ MUST be a JSON array. Each entry MUST conform to this schema exactly (no extra
86
+ top-level keys, no renamed keys):
23
87
 
24
88
  ```
25
89
  {
@@ -66,7 +130,7 @@ Good:
66
130
  - `Observation: <one idea, what the diff actually does and where>`
67
131
  - `Why it matters: <impact at realistic scale or on a real user path>`
68
132
  - `Suggested direction: <one concrete next step, optional if the fix isn't obvious>`
69
- - `Needs verification: <what you couldn't confirm from the bundle, optional, low/medium severity only>` This labelled paragraph is the only channel for uncertainty: never hedge inside another section, and never raise `severity` to compensate for what you couldn't verify (a blocker/high you cannot stand behind is not a blocker/high). Use the exact `Needs verification:` prefix, not inline phrasing.
133
+ - `Needs verification: <what you couldn't confirm from the bundle, optional, low/medium severity only>` This labelled paragraph is the only channel for uncertainty: never hedge inside another section, and never raise `severity` to compensate for what you couldn't verify (a blocker/high you cannot stand behind is not a blocker/high). Use the exact `Needs verification:` prefix, not inline phrasing. When the codebase-intelligence tools are available and one of them could answer the question, look before you hedge. A question you resolved is not a `Needs verification:` paragraph, it is evidence: cite the file:line you found under `Observation:` and omit the paragraph entirely.
70
134
 
71
135
  One idea per paragraph. Do not collapse them into a single wall of text. Do not invent extra labels. If a section doesn't apply, omit it. The interactive report and the GitHub comment both parse these labels and render them as section headers, so missing labels degrade the output.
72
136
 
@@ -79,6 +143,47 @@ One idea per paragraph. Do not collapse them into a single wall of text. Do not
79
143
 
80
144
  If you have no findings, write []. Return as your final tool result a single line: `<focus>: <N> findings (<blocker>/<high>/<medium>/<low>)`. Do not include other prose.
81
145
 
146
+ ## Codebase intelligence
147
+
148
+ Include this block as part 5 only when the context stage logged `codeIntelligence: true`.
149
+
150
+ ```magpie-codebase-intelligence
151
+ ## Codebase intelligence
152
+
153
+ You have code-intelligence MCP tools against an index of this exact worktree,
154
+ including the PR's own changes. Call `bind_workspace` with `<RUN_DIR>/worktree` before
155
+ your first query.
156
+
157
+ These answer the cross-file questions a diff cannot:
158
+
159
+ - `ask_code`: a natural-language question, answered with grounded evidence. Start here
160
+ when you do not yet know which symbol to pivot on.
161
+ - `find_references` / `get_call_hierarchy`: who calls this, and what does it call.
162
+ Use for reachability: is the path you are worried about actually reachable.
163
+ - `find_affected_code`: the full reverse-dependency set for a symbol. Use for blast
164
+ radius before claiming a change is safe or unsafe.
165
+ - `trace_data_flow`: follow a value from its origin to where it is used. Use to
166
+ confirm that untrusted input actually reaches the sink you are worried about.
167
+ - `search_code`: hybrid semantic and literal search. Use to check whether something
168
+ already exists before claiming the PR should add it.
169
+ - `get_definition`: the body of a symbol the diff calls but does not show.
170
+ - `explore_dependency_graph`: module-level edges. Use for cycles and boundaries.
171
+ - `find_tests_for_symbol`: whether the symbol you are flagging is covered.
172
+
173
+ Rules:
174
+
175
+ - Cite what you find as ordinary `file:line` evidence under `Observation:`. Do not
176
+ say "code intelligence told me"; the location is the evidence.
177
+ - Verify before you report. A finding you could have refuted with one query and did
178
+ not is worse than no finding: it costs the author trust and the critic a slot.
179
+ - Verify before you hedge. If a tool can answer the question, a `Needs verification:`
180
+ paragraph is a failure to look, not honest uncertainty.
181
+ - If a tool returns `indexing_in_progress`, finish reading the diff and retry once.
182
+ If it is still not ready, review from the diff and worktree alone. Do not block.
183
+ - Never call `approve_indexing`. Never call `refresh_index`. Starting a full index is
184
+ a consent-gated operation that is not yours to start.
185
+ ```
186
+
82
187
  ## Focus blocks
83
188
 
84
189
  ### security
@@ -132,6 +237,7 @@ For each potential finding:
132
237
  2. Identify the trust boundary: is this crossing from untrusted to trusted context?
133
238
  3. Assess exploitability: can an attacker realistically trigger this?
134
239
  4. Evaluate impact: what's the blast radius if exploited?
240
+ 5. Confirm the flow with `trace_data_flow` from the entry point to the sink before reporting. A taint path you asserted but did not trace is a guess.
135
241
 
136
242
  **Risk guide:**
137
243
  - blocker: Realistic path to remote code execution, auth bypass, data breach, or privilege escalation
@@ -196,6 +302,8 @@ For each potential bug:
196
302
  3. What's the consequence: crash, data corruption, silent wrong behavior?
197
303
  4. Is there an existing guard I'm not seeing?
198
304
 
305
+ Question 4 is answerable: `get_call_hierarchy` on the changed symbol shows every caller, and `find_references` shows where the guard would have to live. Check before you file.
306
+
199
307
  **Risk guide:**
200
308
  - blocker: Data loss, data corruption, broken auth/session behavior, or consistently crashing a major workflow
201
309
  - high: Reachable incorrect behavior, race, resource leak, or crash in a meaningful workflow
@@ -254,6 +362,7 @@ For each potential issue:
254
362
  2. How often does this code path execute? (once on init vs. every keystroke)
255
363
  3. What's the measurable impact? (milliseconds vs. seconds)
256
364
  4. Is the optimization worth the complexity cost?
365
+ 5. Establish the call frequency with `find_affected_code` before claiming a path is hot. "Called from one cold init path" and "called per keystroke" are different findings.
257
366
 
258
367
  **Risk guide:**
259
368
  - blocker: Change can make a major workflow unusable or cause unbounded production resource exhaustion
@@ -315,6 +424,7 @@ For each potential smell:
315
424
  2. Confirm the smell is introduced or materially worsened by this PR, not merely pre-existing nearby code.
316
425
  3. Suggest the smallest refactor that fits the surrounding codebase patterns.
317
426
  4. Weigh the cost: do not ask for a new abstraction unless it reduces real duplication, coupling, or reasoning burden now.
427
+ 5. Before claiming the PR duplicates something or should reuse an existing helper, find it with `search_code`. Name the file:line of the thing it should have reused, or do not make the claim.
318
428
 
319
429
  **Risk guide:**
320
430
  - blocker: Smell creates a high-risk maintenance trap likely to cause defects across modules soon
@@ -373,6 +483,7 @@ For each potential issue:
373
483
  2. Is this coupling necessary or incidental?
374
484
  3. Would a new team member understand where to make changes?
375
485
  4. Is this over-engineered for the current requirements, or appropriately future-proofed?
486
+ 5. Confirm boundary and cycle claims with `explore_dependency_graph` on the touched modules. A cycle you inferred from import statements in the diff may already be broken by an interface you cannot see.
376
487
 
377
488
  **Risk guide:**
378
489
  - blocker: Change introduces a serious boundary violation or contract break likely to cascade across subsystems
@@ -1,5 +1,6 @@
1
1
  import { expect, test } from 'bun:test'
2
- import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
2
+ import { existsSync } from 'node:fs'
3
+ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
3
4
  import { tmpdir } from 'node:os'
4
5
  import { join } from 'node:path'
5
6
 
@@ -274,3 +275,102 @@ test('top-level --help surfaces the preview subcommand', async () => {
274
275
  expect(stdout).toContain('preview')
275
276
  expect(stdout).toContain('--help-preview')
276
277
  })
278
+
279
+ test('shard rejects missing <run-dir>', async () => {
280
+ const proc = Bun.spawn(['bun', CLI, 'shard'], { stdout: 'pipe', stderr: 'pipe' })
281
+ const stderr = await new Response(proc.stderr).text()
282
+ const exit = await proc.exited
283
+ expect(exit).toBe(2)
284
+ expect(stderr).toContain('shard: missing <run-dir>')
285
+ })
286
+
287
+ test('shard rejects a zero, negative, or non-numeric --budget, naming the flag and value', async () => {
288
+ for (const bad of ['0', '-5', 'abc']) {
289
+ const proc = Bun.spawn(['bun', CLI, 'shard', '/tmp/does-not-matter', '--budget', bad], {
290
+ stdout: 'pipe',
291
+ stderr: 'pipe',
292
+ })
293
+ const stderr = await new Response(proc.stderr).text()
294
+ const exit = await proc.exited
295
+ expect(exit).toBe(2)
296
+ expect(stderr).toContain(`shard: invalid --budget ${bad}`)
297
+ }
298
+ })
299
+
300
+ test('shard rejects an invalid --max-files, naming the flag and value', async () => {
301
+ const proc = Bun.spawn(['bun', CLI, 'shard', '/tmp/does-not-matter', '--max-files', '-1'], {
302
+ stdout: 'pipe',
303
+ stderr: 'pipe',
304
+ })
305
+ const stderr = await new Response(proc.stderr).text()
306
+ const exit = await proc.exited
307
+ expect(exit).toBe(2)
308
+ expect(stderr).toContain('shard: invalid --max-files -1')
309
+ })
310
+
311
+ test('shard rejects a run directory that does not exist instead of creating it', async () => {
312
+ const missing = join(tmpdir(), `magpie-cli-no-run-${Date.now()}`, 'nested')
313
+ const proc = Bun.spawn(['bun', CLI, 'shard', missing], { stdout: 'pipe', stderr: 'pipe' })
314
+ const stdout = await new Response(proc.stdout).text()
315
+ const stderr = await new Response(proc.stderr).text()
316
+ const exit = await proc.exited
317
+ expect(exit).toBe(2)
318
+ expect(stderr).toContain(`shard: no such run directory: ${missing}`)
319
+ // The old behaviour mkdir -p'd the typo and reported success.
320
+ expect(stdout).not.toContain('shard(s)')
321
+ expect(existsSync(missing)).toBe(false)
322
+ })
323
+
324
+ test('--help shows the shard budget flags', async () => {
325
+ const proc = Bun.spawn(['bun', CLI, '--help'], { stdout: 'pipe' })
326
+ const stdout = await new Response(proc.stdout).text()
327
+ expect(await proc.exited).toBe(0)
328
+ expect(stdout).toContain('shard <run-dir> [--budget N] [--max-files N]')
329
+ })
330
+
331
+ test('shard with a custom --budget and --max-files reaches shardDiff with those values', async () => {
332
+ const runDir = await mkdtemp(join(tmpdir(), 'magpie-cli-shard-'))
333
+ try {
334
+ // Two files under the same two-segment directory group ('src'), each with
335
+ // its own diff --git block, so a --max-files 1 cap forces the group apart
336
+ // into two shards regardless of --budget: proof the flags actually reach
337
+ // shardDiff rather than the defaults (budget 6000, max-files 80) taking
338
+ // over silently.
339
+ const diff = [
340
+ 'diff --git a/src/a.ts b/src/a.ts',
341
+ 'index 0000000..1111111 100644',
342
+ '--- a/src/a.ts',
343
+ '+++ b/src/a.ts',
344
+ '@@ -1 +1 @@',
345
+ '-x',
346
+ '+y',
347
+ 'diff --git a/src/b.ts b/src/b.ts',
348
+ 'index 0000000..1111111 100644',
349
+ '--- a/src/b.ts',
350
+ '+++ b/src/b.ts',
351
+ '@@ -1 +1 @@',
352
+ '-x',
353
+ '+y',
354
+ '',
355
+ ].join('\n')
356
+ await writeFile(join(runDir, 'diff.patch'), diff)
357
+
358
+ const proc = Bun.spawn(['bun', CLI, 'shard', runDir, '--budget', '3', '--max-files', '1'], {
359
+ stdout: 'pipe',
360
+ stderr: 'pipe',
361
+ })
362
+ const stdout = await new Response(proc.stdout).text()
363
+ const exit = await proc.exited
364
+ expect(exit).toBe(0)
365
+ expect(stdout).toContain('2 shard(s)')
366
+
367
+ const manifest = JSON.parse(
368
+ await readFile(join(runDir, 'shards', 'manifest.json'), 'utf8'),
369
+ ) as { budget: number; maxFiles: number; shards: unknown[] }
370
+ expect(manifest.budget).toBe(3)
371
+ expect(manifest.maxFiles).toBe(1)
372
+ expect(manifest.shards).toHaveLength(2)
373
+ } finally {
374
+ await rm(runDir, { recursive: true, force: true })
375
+ }
376
+ })
@@ -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
+ })