@iceinvein/agent-skills 0.1.28 → 0.1.30

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/dist/cli/index.js CHANGED
@@ -1278,6 +1278,18 @@ async function checkForUpdates(cwd) {
1278
1278
  }
1279
1279
  }
1280
1280
 
1281
+ // src/cli/lockfile.ts
1282
+ import { join as join8 } from "node:path";
1283
+ var LOCKFILE_NAME2 = ".agent-skills.lock";
1284
+ async function readLockfile2(cwd) {
1285
+ const path = join8(cwd, LOCKFILE_NAME2);
1286
+ const file = Bun.file(path);
1287
+ if (!await file.exists()) {
1288
+ return { skills: {} };
1289
+ }
1290
+ return file.json();
1291
+ }
1292
+
1281
1293
  // src/cli/types.ts
1282
1294
  var TOOL_NAMES2 = ["claude", "cursor", "codex", "gemini"];
1283
1295
 
@@ -3150,8 +3162,19 @@ async function pickActivation(skillName, modes) {
3150
3162
 
3151
3163
  // src/cli/index.ts
3152
3164
  import { mkdirSync as mkdirSync4 } from "fs";
3153
- import { join as join8 } from "path";
3165
+ import { join as join9 } from "path";
3154
3166
  import { homedir } from "os";
3167
+ async function otherScopeSkillCount(currentDir) {
3168
+ const home = homedir();
3169
+ const otherDir = currentDir === home ? process.cwd() : home;
3170
+ if (otherDir === currentDir)
3171
+ return { scope: "global", count: 0 };
3172
+ const lockfile = await readLockfile2(otherDir);
3173
+ return {
3174
+ scope: otherDir === home ? "global" : "local",
3175
+ count: Object.keys(lockfile.skills).length
3176
+ };
3177
+ }
3155
3178
  function resolveInstallDir(flags) {
3156
3179
  if (flags.global !== undefined || flags.g !== undefined) {
3157
3180
  return homedir();
@@ -3243,7 +3266,7 @@ async function resolveTargetTools(installDir, isGlobal, flags) {
3243
3266
  for (const tool of picked) {
3244
3267
  const dir = TOOL_DIRS[tool];
3245
3268
  if (dir)
3246
- mkdirSync4(join8(installDir, dir), { recursive: true });
3269
+ mkdirSync4(join9(installDir, dir), { recursive: true });
3247
3270
  }
3248
3271
  return picked;
3249
3272
  }
@@ -3357,18 +3380,25 @@ Installing ${names.length} skills...
3357
3380
  `);
3358
3381
  const results = await updateAllSkills(updateDir);
3359
3382
  if (results.length === 0) {
3360
- console.log("No skills installed.");
3361
- break;
3362
- }
3363
- for (const r of results) {
3364
- if (!r.ok) {
3365
- console.error(` \u2717 ${r.name}: ${r.error}`);
3366
- } else if (r.from === r.to) {
3367
- console.log(` \u2298 ${r.name} already up to date (v${r.to})`);
3368
- } else {
3369
- console.log(` \u2713 ${r.name} v${r.from} \u2192 v${r.to}`);
3383
+ console.log("No skills installed in this scope.");
3384
+ } else {
3385
+ for (const r of results) {
3386
+ if (!r.ok) {
3387
+ console.error(` \u2717 ${r.name}: ${r.error}`);
3388
+ } else if (r.from === r.to) {
3389
+ console.log(` \u2298 ${r.name} already up to date (v${r.to})`);
3390
+ } else {
3391
+ console.log(` \u2713 ${r.name} v${r.from} \u2192 v${r.to}`);
3392
+ }
3370
3393
  }
3371
3394
  }
3395
+ const other = await otherScopeSkillCount(updateDir);
3396
+ if (other.count > 0) {
3397
+ const command2 = other.scope === "global" ? "agent-skills update --all -g" : "agent-skills update --all";
3398
+ const noun = other.count === 1 ? "skill" : "skills";
3399
+ console.log(`
3400
+ Note: ${other.count} ${noun} installed in the ${other.scope} scope. Run \`${command2}\` to update those too.`);
3401
+ }
3372
3402
  break;
3373
3403
  }
3374
3404
  const skillName = args[0];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iceinvein/agent-skills",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "Install agent skills into AI coding tools",
5
5
  "author": "iceinvein",
6
6
  "license": "MIT",
package/skills/index.json CHANGED
@@ -197,7 +197,7 @@
197
197
  "name": "magpie",
198
198
  "description": "Interactive PR review pipeline. Runs five parallel specialist subagents (security, bugs, performance, code-smells, architecture), dedupes findings, applies a critic rubric, peer-reviews via codex exec, and serves an interactive HTML report for selecting findings to post via gh. Bundles a Bun CLI installed onto PATH via the skill's postinstall step. Use when the user asks to review a GitHub pull request.",
199
199
  "type": "prompt",
200
- "version": "0.3.0"
200
+ "version": "0.4.0"
201
201
  },
202
202
  {
203
203
  "name": "module-secret-auditor",
@@ -32,6 +32,12 @@ magpie setup "$RUN_DIR" --pr $PR_NUMBER --repo "$REPO"
32
32
 
33
33
  If exit is non-zero, surface stderr verbatim and stop. The CLI removes the worktree and subdirs on failure; the run directory itself plus `log.jsonl` are kept for diagnostics.
34
34
 
35
+ Setup automatically filters lockfiles, build output, generated source, and snapshot fixtures from `diff.patch` before specialists see it. Users can override by placing `.magpie.json` at the repo root: `{"exclude": [...glob], "include": [...glob], "useDefaults": true|false}`. When anything is filtered, the raw diff is preserved as `$RUN_DIR/diff.full.patch` and the exclusion list as `$RUN_DIR/excluded-files.json`.
36
+
37
+ When a prior run exists for the same PR (active or archived under `~/.magpie/`), setup writes `$RUN_DIR/incremental.json` with `{previousRunId, previousSha, currentSha, sameSha}`. The post stage surfaces this as a "Incremental review since `<sha>`" trailer on the summary comment.
38
+
39
+ Setup also runs a deterministic test-coverage check: when the diff contains zero test or spec files anywhere, each non-test source file with `>= 10` added code lines gets a `domain: "tests"` finding written to `$RUN_DIR/findings/tests.json`. This is a sixth domain that flows through dedupe/critic/peer-review alongside the five LLM specialists. No specialist subagent is dispatched for it.
40
+
35
41
  ### 2. Serve
36
42
 
37
43
  Start the HTML server in the background using the Bash tool with `run_in_background: true`:
@@ -48,35 +54,7 @@ Render the first progress paint:
48
54
  magpie render "$RUN_DIR" progress
49
55
  ```
50
56
 
51
- ### 3. Context (optional)
52
-
53
- If the `mcp__code-intelligence__search_code` tool is available in this conversation, build the context bundle by calling code-intelligence MCP tools for each changed file and writing the result to `$RUN_DIR/pr-context.json`. If MCP is not available, log `{stage: context, status: skipped, reason: mcp-unavailable}` to `$RUN_DIR/log.jsonl` and continue. Re-render progress.
54
-
55
- Expected shape of `pr-context.json`:
56
-
57
- ```json
58
- {
59
- "symbols": [
60
- {
61
- "name": "createProject",
62
- "kind": "function",
63
- "file": "apps/server/src/resolvers/Mutation/createProject.ts",
64
- "line": 8,
65
- "definition": "<source of the symbol, trimmed>",
66
- "references": [
67
- { "file": "apps/server/src/resolvers/Mutation/index.ts", "line": 5, "context": "<one-line snippet>" }
68
- ],
69
- "tests": [
70
- { "file": "apps/server/src/resolvers/Mutation/__tests__/createProject.test.ts", "line": 12, "context": "<one-line snippet>" }
71
- ]
72
- }
73
- ]
74
- }
75
- ```
76
-
77
- Cap `references` at 20 per symbol; include only tests that import or call the symbol.
78
-
79
- ### 4. Specialists
57
+ ### 3. Specialists
80
58
 
81
59
  Dispatch the five specialist subagents in a single message using five Agent tool calls in parallel. For each focus in (security, bugs, performance, code-smells, architecture), the prompt is:
82
60
 
@@ -86,7 +64,6 @@ Dispatch the five specialist subagents in a single message using five Agent tool
86
64
  You are reviewing PR #<PR_NUMBER>.
87
65
  Working directory: $RUN_DIR/worktree
88
66
  Diff: $RUN_DIR/diff.patch
89
- Code context (if exists): $RUN_DIR/pr-context.json
90
67
 
91
68
  ## Output Contract
92
69
 
@@ -129,19 +106,23 @@ After each subagent returns, append `{stage: specialist, focus: <focus>, status:
129
106
 
130
107
  If all five specialists fail (no findings files written), log `{stage: specialists, status: error}` and stop. Otherwise mark `{stage: specialists, status: done}`.
131
108
 
132
- ### 5. Dedupe
109
+ ### 4. Dedupe
133
110
 
134
111
  ```
135
- magpie dedupe "$RUN_DIR"
112
+ magpie dedupe "$RUN_DIR" [--threshold <0-10>]
136
113
  ```
137
114
 
115
+ `magpie dedupe` also runs a deterministic evidence check against the worktree: findings whose `file` is missing or whose `line` is out of range are dropped. Drops are logged and recorded to `$RUN_DIR/evidence-dropped.json`. The check is skipped if the worktree is no longer present (archived run replay).
116
+
117
+ Each finding receives a derived 0-10 `score` from its risk fields. Findings below `--threshold` (default 3) are dropped before the critic LLM runs and recorded to `$RUN_DIR/threshold-dropped.json`. Pass `--threshold 0` to keep everything.
118
+
138
119
  Re-render progress.
139
120
 
140
- ### 6. Critic
121
+ ### 5. Critic
141
122
 
142
123
  Read `$RUN_DIR/findings.deduped.json`. Apply the critic rubric from this SKILL.md verbatim (one verdict per finding). Write the kept subset to `$RUN_DIR/findings.kept.json`. Append `{stage: critic, status: done}` and re-render progress.
143
124
 
144
- ### 7. Peer review
125
+ ### 6. Peer review
145
126
 
146
127
  Build the peer-review prompt by taking the `magpie-peer-review` block from this SKILL.md and substituting the placeholders listed in its `## Substitute before use` preamble. Write the substituted prompt to `$RUN_DIR/peer-prompt.md`. Then run codex with the prompt piped on stdin:
147
128
 
@@ -155,7 +136,7 @@ If codex returns non-zero, ask the user once: "Codex peer-review failed: <stderr
155
136
 
156
137
  Otherwise parse the verdicts JSON, apply them (drop / downgrade), and write `findings.final.json`. Append `{stage: peer-review, status: done}` and re-render progress.
157
138
 
158
- ### 8. Report
139
+ ### 7. Report
159
140
 
160
141
  ```
161
142
  magpie render "$RUN_DIR" findings
@@ -165,7 +146,7 @@ Print to the terminal: "Findings ready at <url>. Click checkboxes to select what
165
146
 
166
147
  End the turn.
167
148
 
168
- ### 9. Post
149
+ ### 8. Post
169
150
 
170
151
  Most users will tick the checkboxes in the served report and click "Post to PR"; the report server handles the rest. The agent only handles posts when the user explicitly types `post` (optionally `post 1,3,7` for indices) in the conversation.
171
152
 
@@ -188,7 +169,7 @@ Pass `--dry-run` to record the would-be gh commands without invoking gh. After p
188
169
  magpie render "$RUN_DIR" findings
189
170
  ```
190
171
 
191
- ### 10. Cleanup
172
+ ### 9. Cleanup
192
173
 
193
174
  ```
194
175
  magpie cleanup "$RUN_DIR" --repo "$REPO"
@@ -99,11 +99,22 @@ const HANDLERS: Record<string, Handler> = {
99
99
  dedupe: async (args) => {
100
100
  const runDir = args[0]
101
101
  if (!runDir) {
102
- process.stderr.write('dedupe: missing <run-dir>\n')
102
+ process.stderr.write('dedupe: missing <run-dir> [--threshold <0-10>]\n')
103
103
  return 2
104
104
  }
105
+ const thresholdFlag = args.indexOf('--threshold')
106
+ const thresholdRaw = thresholdFlag !== -1 ? args[thresholdFlag + 1] : undefined
107
+ let threshold: number | undefined
108
+ if (thresholdRaw !== undefined) {
109
+ const n = Number(thresholdRaw)
110
+ if (!Number.isFinite(n) || n < 0 || n > 10) {
111
+ process.stderr.write(`dedupe: invalid --threshold ${thresholdRaw} (want 0-10)\n`)
112
+ return 2
113
+ }
114
+ threshold = n
115
+ }
105
116
  const { runDedupe } = await import('../scripts/dedupe-cmd.ts')
106
- return runDedupe(runDir)
117
+ return runDedupe(runDir, threshold !== undefined ? { threshold } : {})
107
118
  },
108
119
  render: async (args) => {
109
120
  const runDir = args[0]
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env bash
2
+ case "$1 $2" in
3
+ "pr view")
4
+ cat <<'JSON'
5
+ {
6
+ "number": 1234,
7
+ "title": "Fake PR",
8
+ "headRefName": "feature-x",
9
+ "baseRefName": "main",
10
+ "headRefOid": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
11
+ "baseRefOid": "cafebabecafebabecafebabecafebabecafebabe",
12
+ "author": { "login": "octocat" },
13
+ "body": "Fake body",
14
+ "url": "https://github.com/octocat/Hello-World/pull/1234",
15
+ "files": [
16
+ { "path": "src/a.ts", "additions": 1, "deletions": 0, "changeType": "modified" },
17
+ { "path": "bun.lock", "additions": 1, "deletions": 0, "changeType": "modified" },
18
+ { "path": "dist/x.js", "additions": 1, "deletions": 0, "changeType": "modified" }
19
+ ]
20
+ }
21
+ JSON
22
+ ;;
23
+ "pr diff")
24
+ cat <<'DIFF'
25
+ diff --git a/src/a.ts b/src/a.ts
26
+ index 1111111..2222222 100644
27
+ --- a/src/a.ts
28
+ +++ b/src/a.ts
29
+ @@ -1 +1 @@
30
+ -export const x = 1
31
+ +export const x = 2
32
+ diff --git a/bun.lock b/bun.lock
33
+ index 3333333..4444444 100644
34
+ --- a/bun.lock
35
+ +++ b/bun.lock
36
+ @@ -1 +1 @@
37
+ -{}
38
+ +{"a":1}
39
+ diff --git a/dist/x.js b/dist/x.js
40
+ index 5555555..6666666 100644
41
+ --- a/dist/x.js
42
+ +++ b/dist/x.js
43
+ @@ -1 +1 @@
44
+ -1
45
+ +2
46
+ DIFF
47
+ ;;
48
+ *)
49
+ echo "fake-gh: unsupported args: $*" >&2
50
+ exit 1
51
+ ;;
52
+ esac
@@ -77,3 +77,48 @@ test('runDedupe with no findings files writes empty array', async () => {
77
77
  const out = JSON.parse(await readFile(join(runDir, 'findings.deduped.json'), 'utf8'))
78
78
  expect(out).toHaveLength(0)
79
79
  })
80
+
81
+ test('runDedupe attaches score to each finding', async () => {
82
+ await writeFile(
83
+ join(runDir, 'findings', 'bugs.json'),
84
+ JSON.stringify([f('1', 'a.ts', 10, 'mid title', 'bugs')]),
85
+ )
86
+ const exit = await runDedupe(runDir)
87
+ expect(exit).toBe(0)
88
+ const out = JSON.parse(await readFile(join(runDir, 'findings.deduped.json'), 'utf8'))
89
+ expect(out[0]?.score).toBeGreaterThan(0)
90
+ expect(out[0]?.score).toBeLessThanOrEqual(10)
91
+ })
92
+
93
+ test('runDedupe drops findings below threshold and writes sidecar', async () => {
94
+ const low: ReviewFinding = {
95
+ ...f('low-1', 'a.ts', 10, 'tiny cleanup', 'code-smells'),
96
+ severity: 'low',
97
+ risk: { impact: 'low', likelihood: 'edge-case', confidence: 'low', action: 'optional' },
98
+ }
99
+ const high: ReviewFinding = {
100
+ ...f('high-1', 'b.ts', 10, 'serious bug', 'bugs'),
101
+ severity: 'blocker',
102
+ risk: { impact: 'critical', likelihood: 'likely', confidence: 'high', action: 'must-fix' },
103
+ }
104
+ await writeFile(join(runDir, 'findings', 'bugs.json'), JSON.stringify([low, high]))
105
+ const exit = await runDedupe(runDir, { threshold: 5 })
106
+ expect(exit).toBe(0)
107
+ const kept = JSON.parse(await readFile(join(runDir, 'findings.deduped.json'), 'utf8'))
108
+ expect(kept.map((x: ReviewFinding) => x.id)).toEqual(['high-1'])
109
+ const dropped = JSON.parse(await readFile(join(runDir, 'threshold-dropped.json'), 'utf8'))
110
+ expect(dropped[0]?.id).toBe('low-1')
111
+ })
112
+
113
+ test('runDedupe with threshold 0 keeps everything', async () => {
114
+ const low: ReviewFinding = {
115
+ ...f('low-1', 'a.ts', 10, 'tiny cleanup', 'code-smells'),
116
+ severity: 'low',
117
+ risk: { impact: 'low', likelihood: 'edge-case', confidence: 'low', action: 'optional' },
118
+ }
119
+ await writeFile(join(runDir, 'findings', 'bugs.json'), JSON.stringify([low]))
120
+ const exit = await runDedupe(runDir, { threshold: 0 })
121
+ expect(exit).toBe(0)
122
+ const kept = JSON.parse(await readFile(join(runDir, 'findings.deduped.json'), 'utf8'))
123
+ expect(kept).toHaveLength(1)
124
+ })
@@ -0,0 +1,83 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { verifyEvidence } from '../evidence-filter.ts'
6
+ import type { ReviewFinding } from '../types.ts'
7
+
8
+ let worktree: string
9
+
10
+ function finding(overrides: Partial<ReviewFinding>): ReviewFinding {
11
+ return {
12
+ id: 'x-1',
13
+ file: 'src/a.ts',
14
+ line: 1,
15
+ severity: 'medium',
16
+ risk: { impact: 'medium', likelihood: 'possible', confidence: 'medium', action: 'should-fix' },
17
+ title: 't',
18
+ description: 'd',
19
+ domain: 'bugs',
20
+ ...overrides,
21
+ }
22
+ }
23
+
24
+ beforeEach(async () => {
25
+ worktree = await mkdtemp(join(tmpdir(), 'magpie-evidence-'))
26
+ await mkdir(join(worktree, 'src'), { recursive: true })
27
+ await writeFile(join(worktree, 'src/a.ts'), 'line1\nline2\nline3\n')
28
+ })
29
+
30
+ afterEach(async () => {
31
+ await rm(worktree, { recursive: true, force: true })
32
+ })
33
+
34
+ test('keeps finding with valid file and line', async () => {
35
+ const result = await verifyEvidence([finding({ line: 2 })], worktree)
36
+ expect(result.kept).toHaveLength(1)
37
+ expect(result.dropped).toHaveLength(0)
38
+ expect(result.skipped).toBe(false)
39
+ })
40
+
41
+ test('drops finding referencing missing file', async () => {
42
+ const result = await verifyEvidence([finding({ file: 'src/ghost.ts', line: 1 })], worktree)
43
+ expect(result.kept).toHaveLength(0)
44
+ expect(result.dropped[0]?.reason).toBe('hallucinated-file')
45
+ })
46
+
47
+ test('drops finding when line exceeds file length', async () => {
48
+ const result = await verifyEvidence([finding({ line: 999 })], worktree)
49
+ expect(result.kept).toHaveLength(0)
50
+ expect(result.dropped[0]?.reason).toBe('invented-line')
51
+ })
52
+
53
+ test('drops finding when line is < 1', async () => {
54
+ const result = await verifyEvidence([finding({ line: 0 })], worktree)
55
+ expect(result.kept).toHaveLength(0)
56
+ expect(result.dropped[0]?.reason).toBe('invented-line')
57
+ })
58
+
59
+ test('keeps unanchored finding (line=null) without checking file', async () => {
60
+ const result = await verifyEvidence([finding({ file: 'src/ghost.ts', line: null })], worktree)
61
+ expect(result.kept).toHaveLength(1)
62
+ expect(result.dropped).toHaveLength(0)
63
+ })
64
+
65
+ test('skips verification when worktree is missing', async () => {
66
+ await rm(worktree, { recursive: true, force: true })
67
+ const result = await verifyEvidence([finding({ line: 999 })], worktree)
68
+ expect(result.skipped).toBe(true)
69
+ expect(result.kept).toHaveLength(1)
70
+ expect(result.dropped).toHaveLength(0)
71
+ })
72
+
73
+ test('caches line counts across findings in the same file', async () => {
74
+ const findings = [finding({ id: 'a', line: 1 }), finding({ id: 'b', line: 3 })]
75
+ const result = await verifyEvidence(findings, worktree)
76
+ expect(result.kept.map((f) => f.id)).toEqual(['a', 'b'])
77
+ })
78
+
79
+ test('rejects directory paths', async () => {
80
+ const result = await verifyEvidence([finding({ file: 'src', line: 1 })], worktree)
81
+ expect(result.kept).toHaveLength(0)
82
+ expect(result.dropped[0]?.reason).toBe('hallucinated-file')
83
+ })
@@ -0,0 +1,87 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { buildIncrementalContext, findPreviousRun } from '../incremental.ts'
6
+
7
+ let home: string
8
+
9
+ beforeEach(async () => {
10
+ home = await mkdtemp(join(tmpdir(), 'magpie-incremental-home-'))
11
+ })
12
+
13
+ afterEach(async () => {
14
+ await rm(home, { recursive: true, force: true })
15
+ })
16
+
17
+ async function seedRun(id: string, prJson: Record<string, unknown>): Promise<string> {
18
+ const dir = join(home, id)
19
+ await mkdir(dir, { recursive: true })
20
+ await writeFile(join(dir, 'pr.json'), JSON.stringify(prJson))
21
+ return dir
22
+ }
23
+
24
+ test('findPreviousRun returns null when no prior runs exist', async () => {
25
+ const result = await findPreviousRun(42, join(home, 'pr-42-100'), home)
26
+ expect(result).toBeNull()
27
+ })
28
+
29
+ test('findPreviousRun returns the most recent prior run for the PR', async () => {
30
+ await seedRun('pr-42-100', { headRefOid: 'aaa' })
31
+ await seedRun('pr-42-200', { headRefOid: 'bbb' })
32
+ const currentRun = join(home, 'pr-42-300')
33
+ const result = await findPreviousRun(42, currentRun, home)
34
+ expect(result?.runId).toBe('pr-42-200')
35
+ })
36
+
37
+ test('findPreviousRun excludes the current run from candidates', async () => {
38
+ await seedRun('pr-42-100', { headRefOid: 'aaa' })
39
+ const currentRun = join(home, 'pr-42-200')
40
+ await mkdir(currentRun, { recursive: true })
41
+ const result = await findPreviousRun(42, currentRun, home)
42
+ expect(result?.runId).toBe('pr-42-100')
43
+ })
44
+
45
+ test('findPreviousRun ignores other PR numbers', async () => {
46
+ await seedRun('pr-99-100', { headRefOid: 'other' })
47
+ const result = await findPreviousRun(42, join(home, 'pr-42-200'), home)
48
+ expect(result).toBeNull()
49
+ })
50
+
51
+ test('findPreviousRun matches archived runs', async () => {
52
+ await seedRun('pr-42-100.archived-200', { headRefOid: 'old' })
53
+ const result = await findPreviousRun(42, join(home, 'pr-42-300'), home)
54
+ expect(result?.runId).toBe('pr-42-100.archived-200')
55
+ })
56
+
57
+ test('findPreviousRun skips candidates with missing pr.json', async () => {
58
+ await mkdir(join(home, 'pr-42-100'), { recursive: true })
59
+ await seedRun('pr-42-50', { headRefOid: 'older' })
60
+ const result = await findPreviousRun(42, join(home, 'pr-42-200'), home)
61
+ expect(result?.runId).toBe('pr-42-50')
62
+ })
63
+
64
+ test('buildIncrementalContext detects new commits', () => {
65
+ const ctx = buildIncrementalContext(
66
+ { headRefOid: 'newsha' },
67
+ { runId: 'pr-42-100', prJson: { headRefOid: 'oldsha' } },
68
+ )
69
+ expect(ctx?.previousSha).toBe('oldsha')
70
+ expect(ctx?.currentSha).toBe('newsha')
71
+ expect(ctx?.sameSha).toBe(false)
72
+ })
73
+
74
+ test('buildIncrementalContext flags same-sha re-review', () => {
75
+ const ctx = buildIncrementalContext(
76
+ { headRefOid: 'samesha' },
77
+ { runId: 'pr-42-100', prJson: { headRefOid: 'samesha' } },
78
+ )
79
+ expect(ctx?.sameSha).toBe(true)
80
+ })
81
+
82
+ test('buildIncrementalContext returns null when either sha is missing', () => {
83
+ const noPrev = buildIncrementalContext({ headRefOid: 'new' }, { runId: 'x', prJson: {} })
84
+ expect(noPrev).toBeNull()
85
+ const noCurr = buildIncrementalContext({}, { runId: 'x', prJson: { headRefOid: 'a' } })
86
+ expect(noCurr).toBeNull()
87
+ })
@@ -0,0 +1,161 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import {
6
+ DEFAULT_EXCLUDES,
7
+ filterDiff,
8
+ globToRegex,
9
+ loadPathFilterConfig,
10
+ matchesAny,
11
+ } from '../path-filter.ts'
12
+
13
+ let cwd: string
14
+
15
+ beforeEach(async () => {
16
+ cwd = await mkdtemp(join(tmpdir(), 'magpie-pathfilter-'))
17
+ })
18
+
19
+ afterEach(async () => {
20
+ await rm(cwd, { recursive: true, force: true })
21
+ })
22
+
23
+ test('globToRegex: * does not cross path segments', () => {
24
+ expect(globToRegex('*.lock').test('bun.lock')).toBe(true)
25
+ expect(globToRegex('*.lock').test('a/bun.lock')).toBe(false)
26
+ })
27
+
28
+ test('globToRegex: ** matches across segments', () => {
29
+ expect(globToRegex('**/*.lock').test('bun.lock')).toBe(true)
30
+ expect(globToRegex('**/*.lock').test('a/b/bun.lock')).toBe(true)
31
+ expect(globToRegex('dist/**').test('dist/index.js')).toBe(true)
32
+ expect(globToRegex('dist/**').test('dist/sub/index.js')).toBe(true)
33
+ expect(globToRegex('dist/**').test('src/index.js')).toBe(false)
34
+ })
35
+
36
+ test('globToRegex: dot is literal', () => {
37
+ expect(globToRegex('*.generated.*').test('user.generated.ts')).toBe(true)
38
+ expect(globToRegex('*.generated.*').test('usergeneratedts')).toBe(false)
39
+ })
40
+
41
+ test('matchesAny returns the matched pattern or null', () => {
42
+ expect(matchesAny('bun.lock', ['**/*.lock'])).toBe('**/*.lock')
43
+ expect(matchesAny('src/a.ts', ['**/*.lock', 'dist/**'])).toBeNull()
44
+ })
45
+
46
+ test('DEFAULT_EXCLUDES covers common targets', () => {
47
+ const cases = [
48
+ 'package-lock.json',
49
+ 'pnpm-lock.yaml',
50
+ 'bun.lock',
51
+ 'dist/index.js',
52
+ 'node_modules/foo/index.js',
53
+ 'coverage/lcov.info',
54
+ 'src/types.generated.ts',
55
+ 'src/types.pb.go',
56
+ 'src/__snapshots__/foo.test.ts.snap',
57
+ ]
58
+ for (const c of cases) {
59
+ expect(matchesAny(c, DEFAULT_EXCLUDES)).not.toBeNull()
60
+ }
61
+ })
62
+
63
+ test('DEFAULT_EXCLUDES keeps normal source', () => {
64
+ for (const c of ['src/index.ts', 'apps/server/src/main.ts', 'README.md']) {
65
+ expect(matchesAny(c, DEFAULT_EXCLUDES)).toBeNull()
66
+ }
67
+ })
68
+
69
+ test('loadPathFilterConfig: missing file returns defaults', async () => {
70
+ const cfg = await loadPathFilterConfig(cwd)
71
+ expect(cfg.useDefaults).toBe(true)
72
+ expect(cfg.exclude).toEqual([...DEFAULT_EXCLUDES])
73
+ expect(cfg.include).toEqual([])
74
+ })
75
+
76
+ test('loadPathFilterConfig: user exclude appends to defaults', async () => {
77
+ await writeFile(join(cwd, '.magpie.json'), JSON.stringify({ exclude: ['fixtures/**'] }))
78
+ const cfg = await loadPathFilterConfig(cwd)
79
+ expect(cfg.exclude).toContain('fixtures/**')
80
+ expect(cfg.exclude.length).toBe(DEFAULT_EXCLUDES.length + 1)
81
+ })
82
+
83
+ test('loadPathFilterConfig: useDefaults false drops defaults', async () => {
84
+ await writeFile(
85
+ join(cwd, '.magpie.json'),
86
+ JSON.stringify({ useDefaults: false, exclude: ['fixtures/**'] }),
87
+ )
88
+ const cfg = await loadPathFilterConfig(cwd)
89
+ expect(cfg.exclude).toEqual(['fixtures/**'])
90
+ })
91
+
92
+ test('loadPathFilterConfig: malformed JSON falls back to defaults', async () => {
93
+ await writeFile(join(cwd, '.magpie.json'), 'not json {')
94
+ const cfg = await loadPathFilterConfig(cwd)
95
+ expect(cfg.useDefaults).toBe(true)
96
+ expect(cfg.exclude).toEqual([...DEFAULT_EXCLUDES])
97
+ })
98
+
99
+ const DIFF = `diff --git a/src/a.ts b/src/a.ts
100
+ index 1..2 100644
101
+ --- a/src/a.ts
102
+ +++ b/src/a.ts
103
+ @@ -1 +1 @@
104
+ -x
105
+ +y
106
+ diff --git a/bun.lock b/bun.lock
107
+ index 1..2 100644
108
+ --- a/bun.lock
109
+ +++ b/bun.lock
110
+ @@ -1 +1 @@
111
+ -{}
112
+ +{"a":1}
113
+ diff --git a/dist/index.js b/dist/index.js
114
+ index 1..2 100644
115
+ --- a/dist/index.js
116
+ +++ b/dist/index.js
117
+ @@ -1 +1 @@
118
+ -1
119
+ +2
120
+ `
121
+
122
+ test('filterDiff: keeps source, drops lockfile and dist/', () => {
123
+ const { filtered, excluded } = filterDiff(DIFF, {
124
+ exclude: [...DEFAULT_EXCLUDES],
125
+ include: [],
126
+ useDefaults: true,
127
+ })
128
+ expect(filtered).toContain('src/a.ts')
129
+ expect(filtered).not.toContain('bun.lock')
130
+ expect(filtered).not.toContain('dist/index.js')
131
+ expect(excluded.map((e) => e.path).sort()).toEqual(['bun.lock', 'dist/index.js'])
132
+ })
133
+
134
+ test('filterDiff: empty diff', () => {
135
+ const { filtered, excluded } = filterDiff('', {
136
+ exclude: ['**/*'],
137
+ include: [],
138
+ useDefaults: false,
139
+ })
140
+ expect(filtered).toBe('')
141
+ expect(excluded).toEqual([])
142
+ })
143
+
144
+ test('filterDiff: include overrides excludes', () => {
145
+ const { filtered, excluded } = filterDiff(DIFF, {
146
+ exclude: ['**/*'],
147
+ include: ['src/**'],
148
+ useDefaults: false,
149
+ })
150
+ expect(filtered).toContain('src/a.ts')
151
+ expect(excluded.map((e) => e.path).sort()).toEqual(['bun.lock', 'dist/index.js'])
152
+ })
153
+
154
+ test('filterDiff: include records not-in-include reason', () => {
155
+ const { excluded } = filterDiff(DIFF, {
156
+ exclude: [],
157
+ include: ['src/**'],
158
+ useDefaults: false,
159
+ })
160
+ expect(excluded.find((e) => e.path === 'bun.lock')?.pattern).toBe('not-in-include')
161
+ })