@iceinvein/agent-skills 0.23.9 → 0.23.13

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 (75) hide show
  1. package/package.json +1 -1
  2. package/skills/index.json +2 -2
  3. package/skills/magpie/SKILL.md +21 -20
  4. package/skills/magpie/bin/magpie.ts +63 -2
  5. package/skills/magpie/evals/quality/README.md +74 -1
  6. package/skills/magpie/evals/quality/__tests__/probe-specialist.test.ts +498 -0
  7. package/skills/magpie/evals/quality/__tests__/replay-full.test.ts +9 -0
  8. package/skills/magpie/evals/quality/probe-specialist.ts +672 -0
  9. package/skills/magpie/evals/quality/replay-full.ts +6 -2
  10. package/skills/magpie/fixtures/fake-gh-with-lockfile.sh +37 -2
  11. package/skills/magpie/fixtures/fake-gh.sh +49 -2
  12. package/skills/magpie/references/critic.md +20 -4
  13. package/skills/magpie/references/proof.md +102 -0
  14. package/skills/magpie/references/scout.md +6 -1
  15. package/skills/magpie/references/specialists.md +15 -9
  16. package/skills/magpie/scripts/__tests__/cleanup-cmd.test.ts +103 -7
  17. package/skills/magpie/scripts/__tests__/cli.test.ts +69 -0
  18. package/skills/magpie/scripts/__tests__/critic-cmd.test.ts +46 -0
  19. package/skills/magpie/scripts/__tests__/critic.test.ts +11 -1
  20. package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +20 -1
  21. package/skills/magpie/scripts/__tests__/git-diff.test.ts +23 -4
  22. package/skills/magpie/scripts/__tests__/handoff-cmd.test.ts +68 -0
  23. package/skills/magpie/scripts/__tests__/history.test.ts +232 -0
  24. package/skills/magpie/scripts/__tests__/impact-cmd.test.ts +477 -0
  25. package/skills/magpie/scripts/__tests__/impact-render.test.ts +365 -0
  26. package/skills/magpie/scripts/__tests__/impact.test.ts +915 -0
  27. package/skills/magpie/scripts/__tests__/incremental.test.ts +59 -8
  28. package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +1 -1
  29. package/skills/magpie/scripts/__tests__/post-cmd.test.ts +58 -0
  30. package/skills/magpie/scripts/__tests__/pr-activity.test.ts +281 -0
  31. package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +4 -4
  32. package/skills/magpie/scripts/__tests__/previous-findings.test.ts +267 -0
  33. package/skills/magpie/scripts/__tests__/proof-cmd.test.ts +260 -0
  34. package/skills/magpie/scripts/__tests__/proof.test.ts +226 -0
  35. package/skills/magpie/scripts/__tests__/refresh.test.ts +71 -0
  36. package/skills/magpie/scripts/__tests__/render-annotation.test.ts +34 -0
  37. package/skills/magpie/scripts/__tests__/render-findings.test.ts +75 -0
  38. package/skills/magpie/scripts/__tests__/render-progress.test.ts +9 -0
  39. package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +376 -1
  40. package/skills/magpie/scripts/__tests__/shard-coverage.test.ts +79 -0
  41. package/skills/magpie/scripts/__tests__/skill-lint.test.ts +48 -2
  42. package/skills/magpie/scripts/__tests__/status-cmd.test.ts +20 -8
  43. package/skills/magpie/scripts/__tests__/types.test.ts +46 -0
  44. package/skills/magpie/scripts/cleanup-cmd.ts +88 -0
  45. package/skills/magpie/scripts/critic-cmd.ts +2 -0
  46. package/skills/magpie/scripts/critic.ts +4 -0
  47. package/skills/magpie/scripts/dedupe-cmd.ts +4 -0
  48. package/skills/magpie/scripts/evidence-filter.ts +10 -0
  49. package/skills/magpie/scripts/git-diff.ts +50 -19
  50. package/skills/magpie/scripts/handoff-cmd.ts +42 -3
  51. package/skills/magpie/scripts/history.ts +143 -0
  52. package/skills/magpie/scripts/impact-cmd.ts +202 -0
  53. package/skills/magpie/scripts/impact-render.ts +158 -0
  54. package/skills/magpie/scripts/impact.ts +622 -0
  55. package/skills/magpie/scripts/incremental.ts +44 -5
  56. package/skills/magpie/scripts/labels-cmd.ts +7 -3
  57. package/skills/magpie/scripts/post-cmd.ts +46 -0
  58. package/skills/magpie/scripts/pr-activity.ts +243 -0
  59. package/skills/magpie/scripts/preview-cmd.ts +3 -0
  60. package/skills/magpie/scripts/previous-findings.ts +181 -0
  61. package/skills/magpie/scripts/proof-cmd.ts +226 -0
  62. package/skills/magpie/scripts/proof.ts +150 -0
  63. package/skills/magpie/scripts/refresh.ts +11 -0
  64. package/skills/magpie/scripts/render-annotation.ts +17 -0
  65. package/skills/magpie/scripts/render-cmd.ts +12 -0
  66. package/skills/magpie/scripts/render-findings.ts +37 -1
  67. package/skills/magpie/scripts/render-progress.ts +3 -0
  68. package/skills/magpie/scripts/setup-cmd.ts +142 -11
  69. package/skills/magpie/scripts/shard-coverage.ts +53 -0
  70. package/skills/magpie/scripts/status-cmd.ts +2 -0
  71. package/skills/magpie/scripts/types.ts +39 -0
  72. package/skills/magpie/skill.json +1 -1
  73. package/skills/magpie/templates/styles.css +30 -0
  74. package/skills/sluice/scripts/status.sh +32 -13
  75. package/skills/sluice/skill.json +1 -1
@@ -0,0 +1,915 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { dirname, join } from 'node:path'
5
+ import {
6
+ buildImpactMap,
7
+ type DiffNames,
8
+ extractDiffNames,
9
+ type GrepHit,
10
+ type GrepPlan,
11
+ type ImpactRef,
12
+ parseGrepOutput,
13
+ planSearches,
14
+ } from '../impact.ts'
15
+
16
+ function fileDiff(path: string, hunks: string): string {
17
+ return `diff --git a/${path} b/${path}
18
+ index 1111111..2222222 100644
19
+ --- a/${path}
20
+ +++ b/${path}
21
+ ${hunks}`
22
+ }
23
+
24
+ function noNames(): DiffNames {
25
+ return { defined: [], used: [], stores: [], routes: [], changedFiles: [], addedLines: new Map() }
26
+ }
27
+
28
+ function noHits(): Record<GrepPlan['kind'], GrepHit[]> {
29
+ return { references: [], definitions: [], writers: [], routes: [], docs: [] }
30
+ }
31
+
32
+ const noShard = (_file: string): number | null => null
33
+ const keepAll = (_file: string): boolean => false
34
+
35
+ describe('extractDiffNames: definitions', () => {
36
+ test('an exported TS function on an added line is defined as added', () => {
37
+ const diff = fileDiff(
38
+ 'src/jobs.ts',
39
+ `@@ -1,2 +1,3 @@
40
+ import x from 'y'
41
+ +export function scheduleJob(id: string) {
42
+ const a = 1
43
+ `,
44
+ )
45
+ expect(extractDiffNames(diff).defined).toEqual([
46
+ { name: 'scheduleJob', kind: 'added', file: 'src/jobs.ts', line: 2 },
47
+ ])
48
+ })
49
+
50
+ test('a definition on both a removed and an added line is changed', () => {
51
+ const diff = fileDiff(
52
+ 'src/jobs.ts',
53
+ `@@ -4,1 +4,1 @@
54
+ -export function scheduleJob(id: string) {
55
+ +export function scheduleJob(id: string, at: Date) {
56
+ `,
57
+ )
58
+ expect(extractDiffNames(diff).defined).toEqual([
59
+ { name: 'scheduleJob', kind: 'changed', file: 'src/jobs.ts', line: 4 },
60
+ ])
61
+ })
62
+
63
+ test('a definition only on a removed line is removed', () => {
64
+ const diff = fileDiff(
65
+ 'src/jobs.ts',
66
+ `@@ -7,2 +7,1 @@
67
+ -export class JobRunner {
68
+ const a = 1
69
+ `,
70
+ )
71
+ expect(extractDiffNames(diff).defined).toEqual([
72
+ { name: 'JobRunner', kind: 'removed', file: 'src/jobs.ts', line: 7 },
73
+ ])
74
+ })
75
+
76
+ test('the function named in a hunk header with a changed body is changed', () => {
77
+ const diff = fileDiff(
78
+ 'src/wire.rs',
79
+ `@@ -10,3 +10,4 @@ fn wire_job(
80
+ let a = 1;
81
+ + let b = 2;
82
+ a
83
+ }
84
+ `,
85
+ )
86
+ expect(extractDiffNames(diff).defined).toEqual([
87
+ { name: 'wire_job', kind: 'changed', file: 'src/wire.rs', line: null },
88
+ ])
89
+ })
90
+
91
+ test('a Rust pub(crate) async fn is recognised', () => {
92
+ const diff = fileDiff('src/a.rs', '@@ -1,0 +1,1 @@\n+pub(crate) async fn load_rows() {\n')
93
+ expect(extractDiffNames(diff).defined.map((d) => d.name)).toEqual(['load_rows'])
94
+ })
95
+
96
+ test('a Python def is recognised', () => {
97
+ const diff = fileDiff('app/a.py', '@@ -1,0 +1,1 @@\n+def fetch_rows(conn):\n')
98
+ expect(extractDiffNames(diff).defined.map((d) => d.name)).toEqual(['fetch_rows'])
99
+ })
100
+
101
+ test('a Go method func with a receiver is recognised', () => {
102
+ const diff = fileDiff(
103
+ 'pkg/a.go',
104
+ '@@ -1,0 +1,1 @@\n+func (s *Server) HandleVerify(w Writer) {\n',
105
+ )
106
+ expect(extractDiffNames(diff).defined.map((d) => d.name)).toEqual(['HandleVerify'])
107
+ })
108
+
109
+ test('a shell name() { function is recognised', () => {
110
+ const diff = fileDiff('bin/run.sh', '@@ -1,0 +1,1 @@\n+deploy_stack() {\n')
111
+ expect(extractDiffNames(diff).defined.map((d) => d.name)).toEqual(['deploy_stack'])
112
+ })
113
+
114
+ test('names shorter than four characters are skipped', () => {
115
+ const diff = fileDiff('src/a.ts', '@@ -1,0 +1,1 @@\n+export function run() {\n')
116
+ expect(extractDiffNames(diff).defined).toEqual([])
117
+ })
118
+
119
+ test('stoplisted names are skipped', () => {
120
+ const diff = fileDiff('src/a.ts', '@@ -1,0 +1,1 @@\n+export const config = {}\n')
121
+ expect(extractDiffNames(diff).defined).toEqual([])
122
+ })
123
+ })
124
+
125
+ describe('extractDiffNames: uses, stores, routes', () => {
126
+ test('a called name not defined in the diff goes to used', () => {
127
+ const diff = fileDiff('src/a.ts', '@@ -1,0 +1,1 @@\n+ await verifyToken(req)\n')
128
+ expect(extractDiffNames(diff).used).toEqual([
129
+ { name: 'verifyToken', file: 'src/a.ts', line: 1 },
130
+ ])
131
+ })
132
+
133
+ test('a called name the diff itself defines is not used', () => {
134
+ const diff = fileDiff(
135
+ 'src/a.ts',
136
+ '@@ -1,0 +1,2 @@\n+export function verifyToken(t) {\n+ return verifyToken(t)\n',
137
+ )
138
+ expect(extractDiffNames(diff).used).toEqual([])
139
+ })
140
+
141
+ test('a backticked name on an added markdown line goes to used', () => {
142
+ const diff = fileDiff('docs/guide.md', '@@ -1,0 +1,1 @@\n+Call `rotateKeys` before deploy.\n')
143
+ expect(extractDiffNames(diff).used).toEqual([
144
+ { name: 'rotateKeys', file: 'docs/guide.md', line: 1 },
145
+ ])
146
+ })
147
+
148
+ test('a .from(identifier) call yields a table store', () => {
149
+ const diff = fileDiff('src/q.ts', '@@ -1,0 +1,1 @@\n+ db.select().from(videoVerification)\n')
150
+ expect(extractDiffNames(diff).stores).toEqual([
151
+ { name: 'videoVerification', kind: 'table', file: 'src/q.ts', line: 1 },
152
+ ])
153
+ })
154
+
155
+ test('an INSERT INTO statement yields a table store', () => {
156
+ const diff = fileDiff(
157
+ 'src/q.py',
158
+ '@@ -1,0 +1,1 @@\n+ sql = "INSERT INTO report_locks (id) VALUES (?)"\n',
159
+ )
160
+ expect(extractDiffNames(diff).stores).toEqual([
161
+ { name: 'report_locks', kind: 'table', file: 'src/q.py', line: 1 },
162
+ ])
163
+ })
164
+
165
+ test('a .db string literal yields a file store', () => {
166
+ const diff = fileDiff('src/h.py', '@@ -1,0 +1,1 @@\n+ path = "history.db"\n')
167
+ expect(extractDiffNames(diff).stores).toEqual([
168
+ { name: 'history.db', kind: 'file', file: 'src/h.py', line: 1 },
169
+ ])
170
+ })
171
+
172
+ test('a slash-rooted string literal yields a route', () => {
173
+ const diff = fileDiff('src/r.ts', "@@ -1,0 +1,1 @@\n+ await fetch('/api/verify/x')\n")
174
+ expect(extractDiffNames(diff).routes).toEqual([
175
+ { path: '/api/verify/x', file: 'src/r.ts', line: 1 },
176
+ ])
177
+ })
178
+
179
+ test('Array.from on a built-in receiver yields no store', () => {
180
+ const diff = fileDiff('src/q.ts', '@@ -1,0 +1,1 @@\n+ const all = Array.from(rows)\n')
181
+ expect(extractDiffNames(diff).stores).toEqual([])
182
+ })
183
+
184
+ test('an update( call yields no store', () => {
185
+ const diff = fileDiff('src/q.ts', '@@ -1,0 +1,1 @@\n+ hash.update(payload)\n')
186
+ expect(extractDiffNames(diff).stores).toEqual([])
187
+ })
188
+
189
+ test('lowercase SQL inside a string yields a table store', () => {
190
+ const diff = fileDiff(
191
+ 'src/q.py',
192
+ '@@ -1,0 +1,1 @@\n+ sql = "select id from report_locks where id = ?"\n',
193
+ )
194
+ expect(extractDiffNames(diff).stores).toEqual([
195
+ { name: 'report_locks', kind: 'table', file: 'src/q.py', line: 1 },
196
+ ])
197
+ })
198
+
199
+ test('a Python from-import yields no store', () => {
200
+ const diff = fileDiff('src/q.py', '@@ -1,0 +1,1 @@\n+from report_tools import build\n')
201
+ expect(extractDiffNames(diff).stores).toEqual([])
202
+ })
203
+
204
+ function storesOnAddedLine(file: string, line: string): DiffNames['stores'] {
205
+ return extractDiffNames(fileDiff(file, `@@ -1,0 +1,1 @@\n+${line}\n`)).stores
206
+ }
207
+
208
+ test('English "read from" inside an error message yields no store', () => {
209
+ expect(storesOnAddedLine('src/a.ts', 'throw new Error("could not read from disk")')).toEqual([])
210
+ })
211
+
212
+ test('English "update" inside a toast message yields no store', () => {
213
+ expect(storesOnAddedLine('src/a.ts', 'toast("Please update your password")')).toEqual([])
214
+ })
215
+
216
+ test('English "from" inside a template literal yields no store', () => {
217
+ expect(storesOnAddedLine('src/a.ts', 'log(`Loaded items from cache`)')).toEqual([])
218
+ })
219
+
220
+ test('a Python import naming SQL verbs yields no store', () => {
221
+ expect(storesOnAddedLine('app/db.py', 'from sqlalchemy import select, update')).toEqual([])
222
+ })
223
+
224
+ test('a lowercase insert statement in a string yields its table', () => {
225
+ expect(
226
+ storesOnAddedLine('src/db.rs', 'sqlx::query("insert into report_locks (id) values ($1)")'),
227
+ ).toEqual([{ name: 'report_locks', kind: 'table', file: 'src/db.rs', line: 1 }])
228
+ })
229
+
230
+ test('an uppercase SELECT in a string yields its table', () => {
231
+ expect(storesOnAddedLine('src/db.ts', 'db.query("SELECT id FROM report_locks")')).toEqual([
232
+ { name: 'report_locks', kind: 'table', file: 'src/db.ts', line: 1 },
233
+ ])
234
+ })
235
+
236
+ test('a lowercase statement in a .sql file yields its table', () => {
237
+ expect(storesOnAddedLine('db/up.sql', 'delete from report_locks where id = 1;')).toEqual([
238
+ { name: 'report_locks', kind: 'table', file: 'db/up.sql', line: 1 },
239
+ ])
240
+ })
241
+
242
+ test('addedLines records the new-side numbers of added lines only', () => {
243
+ const diff = fileDiff('src/a.ts', '@@ -3,2 +3,3 @@\n keep\n+one\n-gone\n+two\n')
244
+ const names = extractDiffNames(diff)
245
+ expect(names.changedFiles).toEqual(['src/a.ts'])
246
+ expect(names.addedLines.get('src/a.ts')).toEqual(new Set([4, 5]))
247
+ })
248
+ })
249
+
250
+ describe('parseGrepOutput', () => {
251
+ test('splits a path containing a colon at the first colon followed by digits and a colon', () => {
252
+ expect(parseGrepOutput('docs/a:b.md:12: see foo \n')).toEqual([
253
+ { file: 'docs/a:b.md', line: 12, text: 'see foo', indented: true },
254
+ ])
255
+ })
256
+
257
+ test('cuts text at 160 characters', () => {
258
+ const long = 'x'.repeat(200)
259
+ const hits = parseGrepOutput(`src/a.ts:3:${long}\n`)
260
+ expect(hits[0]?.text).toBe('x'.repeat(160))
261
+ })
262
+
263
+ test('a cut at 160 characters keeps a character outside the BMP whole', () => {
264
+ const line = `${'a'.repeat(159)}\u{1F600} tail`
265
+ const [hit] = parseGrepOutput(`src/x.ts:3:${line}\n`)
266
+ expect(hit?.text).toBe(`${'a'.repeat(159)}\u{1F600}`)
267
+ })
268
+
269
+ test('keeps a carriage return line whole', () => {
270
+ expect(parseGrepOutput('src/win.ts:4:const a = 1\r\n')).toEqual([
271
+ { file: 'src/win.ts', line: 4, text: 'const a = 1', indented: false },
272
+ ])
273
+ })
274
+
275
+ test('keeps a line containing U+2028 whole', () => {
276
+ expect(parseGrepOutput('src/a.ts:2:x = "a\u2028b"\n')).toEqual([
277
+ { file: 'src/a.ts', line: 2, text: 'x = "a\u2028b"', indented: false },
278
+ ])
279
+ })
280
+
281
+ test('reads an unquoted non-ASCII path as is', () => {
282
+ expect(parseGrepOutput('café.ts:1:x\n')).toEqual([
283
+ { file: 'café.ts', line: 1, text: 'x', indented: false },
284
+ ])
285
+ })
286
+
287
+ test('records that the matched line was indented', () => {
288
+ expect(parseGrepOutput('src/a.ts:9: const helperFn = 2\n')).toEqual([
289
+ { file: 'src/a.ts', line: 9, text: 'const helperFn = 2', indented: true },
290
+ ])
291
+ })
292
+
293
+ test('throws on a line without a line number', () => {
294
+ expect(() => parseGrepOutput('src/a.ts: no number here\n')).toThrow()
295
+ })
296
+ })
297
+
298
+ describe('planSearches', () => {
299
+ test('references search each defined name as a whole fixed word read from stdin', () => {
300
+ const names = noNames()
301
+ names.defined = [
302
+ { name: 'scheduleJob', kind: 'added', file: 'src/a.ts', line: 1 },
303
+ { name: 'JobRunner', kind: 'removed', file: 'src/a.ts', line: 9 },
304
+ ]
305
+ const refs = planSearches(names).find((p) => p.kind === 'references')
306
+ expect(refs).toEqual({
307
+ kind: 'references',
308
+ args: ['-c', 'core.quotePath=false', 'grep', '-n', '-I', '-F', '-w', '-f', '-'],
309
+ patterns: ['scheduleJob', 'JobRunner'],
310
+ })
311
+ })
312
+
313
+ test('used names are searched as whole fixed words, not definition regexes', () => {
314
+ const names = noNames()
315
+ names.used = [{ name: 'verifyToken', file: 'src/a.ts', line: 2 }]
316
+ expect(planSearches(names)).toEqual([
317
+ {
318
+ kind: 'definitions',
319
+ args: ['-c', 'core.quotePath=false', 'grep', '-n', '-I', '-F', '-w', '-f', '-'],
320
+ patterns: ['verifyToken'],
321
+ },
322
+ ])
323
+ })
324
+
325
+ test('table names are one case-insensitive whole-word search', () => {
326
+ const names = noNames()
327
+ names.stores = [
328
+ { name: 'report_locks', kind: 'table', file: 'src/a.ts', line: 3 },
329
+ { name: 'asset_salts', kind: 'table', file: 'src/a.ts', line: 4 },
330
+ ]
331
+ expect(planSearches(names)).toEqual([
332
+ {
333
+ kind: 'writers',
334
+ args: ['-c', 'core.quotePath=false', 'grep', '-n', '-I', '-F', '-w', '-i', '-f', '-'],
335
+ patterns: ['report_locks', 'asset_salts'],
336
+ },
337
+ ])
338
+ })
339
+
340
+ test('file-store literals are searched as fixed strings without -w', () => {
341
+ const names = noNames()
342
+ names.stores = [{ name: 'history.db', kind: 'file', file: 'src/h.py', line: 1 }]
343
+ const writers = planSearches(names).filter((p) => p.kind === 'writers')
344
+ expect(writers).toEqual([
345
+ {
346
+ kind: 'writers',
347
+ args: ['-c', 'core.quotePath=false', 'grep', '-n', '-I', '-F', '-f', '-'],
348
+ patterns: ['history.db'],
349
+ },
350
+ ])
351
+ })
352
+
353
+ test('route prefixes are searched as fixed strings without -w', () => {
354
+ const names = noNames()
355
+ names.routes = [{ path: '/api/verify/x', file: 'src/r.ts', line: 1 }]
356
+ expect(planSearches(names)).toEqual([
357
+ {
358
+ kind: 'routes',
359
+ args: ['-c', 'core.quotePath=false', 'grep', '-n', '-I', '-F', '-f', '-'],
360
+ patterns: ['/api/verify'],
361
+ },
362
+ ])
363
+ })
364
+
365
+ test('docs search names and changed basenames as whole words in markdown only', () => {
366
+ const names = noNames()
367
+ names.changedFiles = ['src/jobs.ts']
368
+ expect(planSearches(names)).toEqual([
369
+ {
370
+ kind: 'docs',
371
+ args: [
372
+ '-c',
373
+ 'core.quotePath=false',
374
+ 'grep',
375
+ '-n',
376
+ '-I',
377
+ '-F',
378
+ '-w',
379
+ '-f',
380
+ '-',
381
+ '--',
382
+ '*.md',
383
+ '*.mdx',
384
+ ],
385
+ patterns: ['jobs.ts'],
386
+ },
387
+ ])
388
+ })
389
+
390
+ test('more than five names are split into searches of at most five', () => {
391
+ const names = noNames()
392
+ names.used = Array.from({ length: 12 }, (_, i) => ({
393
+ name: `helper${i}`,
394
+ file: 'src/a.ts',
395
+ line: i + 1,
396
+ }))
397
+ expect(planSearches(names).map((p) => [p.kind, p.patterns])).toEqual([
398
+ ['definitions', ['helper0', 'helper1', 'helper2', 'helper3', 'helper4']],
399
+ ['definitions', ['helper5', 'helper6', 'helper7', 'helper8', 'helper9']],
400
+ ['definitions', ['helper10', 'helper11']],
401
+ ])
402
+ })
403
+
404
+ test('every plan turns off path quoting before grep', () => {
405
+ const names = noNames()
406
+ names.defined = [{ name: 'scheduleJob', kind: 'added', file: 'src/a.ts', line: 1 }]
407
+ names.used = [{ name: 'verifyToken', file: 'src/a.ts', line: 2 }]
408
+ names.stores = [
409
+ { name: 'report_locks', kind: 'table', file: 'src/a.ts', line: 3 },
410
+ { name: 'history.db', kind: 'file', file: 'src/a.ts', line: 4 },
411
+ ]
412
+ names.routes = [{ path: '/api/verify/x', file: 'src/a.ts', line: 5 }]
413
+ names.changedFiles = ['src/a.ts']
414
+ const plans = planSearches(names)
415
+ expect(plans.length).toBeGreaterThan(5)
416
+ for (const p of plans)
417
+ expect(p.args.slice(0, 3)).toEqual(['-c', 'core.quotePath=false', 'grep'])
418
+ })
419
+
420
+ test('no plan is emitted for a kind with nothing to search', () => {
421
+ expect(planSearches(noNames())).toEqual([])
422
+ })
423
+ })
424
+
425
+ describe('buildImpactMap', () => {
426
+ test('a header-derived symbol with no definition line in its file is left out', () => {
427
+ const names = noNames()
428
+ names.defined = [{ name: 'sealReport', kind: 'changed', file: 'src/report.ts', line: null }]
429
+ const hits = noHits()
430
+ hits.definitions = [
431
+ { file: 'src/other.ts', line: 3, text: 'export function sealReport() {', indented: false },
432
+ { file: 'src/report.ts', line: 9, text: 'return sealReport(id)', indented: true },
433
+ ]
434
+ const map = buildImpactMap({ names, hits, shardOf: noShard, excluded: keepAll })
435
+ expect(map.symbols).toEqual([])
436
+ })
437
+
438
+ function oneSymbol(): DiffNames {
439
+ const names = noNames()
440
+ names.defined = [{ name: 'scheduleJob', kind: 'changed', file: 'src/jobs.ts', line: 4 }]
441
+ names.changedFiles = ['src/jobs.ts']
442
+ names.addedLines = new Map([['src/jobs.ts', new Set([4])]])
443
+ return names
444
+ }
445
+
446
+ test('a reference on an added line of the diff is dropped', () => {
447
+ const hits = noHits()
448
+ hits.references = [
449
+ {
450
+ file: 'src/jobs.ts',
451
+ line: 4,
452
+ text: 'export function scheduleJob(id, at) {',
453
+ indented: false,
454
+ },
455
+ { file: 'src/jobs.ts', line: 30, text: 'scheduleJob(id, now)', indented: false },
456
+ ]
457
+ const map = buildImpactMap({ names: oneSymbol(), hits, shardOf: noShard, excluded: keepAll })
458
+ expect(map.symbols[0]?.references).toEqual([
459
+ { file: 'src/jobs.ts', line: 30, text: 'scheduleJob(id, now)' },
460
+ ])
461
+ })
462
+
463
+ test('a reference in an excluded path is dropped', () => {
464
+ const hits = noHits()
465
+ hits.references = [
466
+ { file: 'dist/jobs.js', line: 1, text: 'scheduleJob(a)', indented: false },
467
+ { file: 'src/cron.ts', line: 2, text: 'scheduleJob(b)', indented: false },
468
+ ]
469
+ const map = buildImpactMap({
470
+ names: oneSymbol(),
471
+ hits,
472
+ shardOf: noShard,
473
+ excluded: (f) => f.startsWith('dist/'),
474
+ })
475
+ expect(map.symbols[0]?.references).toEqual([
476
+ { file: 'src/cron.ts', line: 2, text: 'scheduleJob(b)' },
477
+ ])
478
+ })
479
+
480
+ test('a line returned by two searches counts once', () => {
481
+ const hit = {
482
+ file: 'src/a.ts',
483
+ line: 7,
484
+ text: 'scheduleJob(rotateKeys())',
485
+ indented: true,
486
+ }
487
+ const hits = noHits()
488
+ hits.references = [hit, { ...hit }]
489
+ const map = buildImpactMap({ names: oneSymbol(), hits, shardOf: noShard, excluded: keepAll })
490
+ expect(map.symbols[0]?.hits).toBe(1)
491
+ expect(map.symbols[0]?.references).toEqual([
492
+ { file: 'src/a.ts', line: 7, text: 'scheduleJob(rotateKeys())' },
493
+ ])
494
+ })
495
+
496
+ test('references are capped at twelve while hits keeps the true count', () => {
497
+ const hits = noHits()
498
+ hits.references = Array.from({ length: 15 }, (_, i) => ({
499
+ file: 'src/cron.ts',
500
+ line: i + 1,
501
+ text: 'scheduleJob(x)',
502
+ indented: false,
503
+ }))
504
+ const map = buildImpactMap({ names: oneSymbol(), hits, shardOf: noShard, excluded: keepAll })
505
+ expect(map.symbols[0]?.hits).toBe(15)
506
+ expect(map.symbols[0]?.references.length).toBe(12)
507
+ })
508
+
509
+ test('a name with more than two hundred hits lists no references', () => {
510
+ const hits = noHits()
511
+ hits.references = Array.from({ length: 201 }, (_, i) => ({
512
+ file: 'src/cron.ts',
513
+ line: i + 1,
514
+ text: 'scheduleJob(x)',
515
+ indented: false,
516
+ }))
517
+ const map = buildImpactMap({ names: oneSymbol(), hits, shardOf: noShard, excluded: keepAll })
518
+ expect(map.symbols[0]?.hits).toBe(201)
519
+ expect(map.symbols[0]?.references).toEqual([])
520
+ })
521
+
522
+ test('a reference hit is assigned only to names it contains as a whole word', () => {
523
+ const hits = noHits()
524
+ hits.references = [
525
+ { file: 'src/cron.ts', line: 2, text: 'scheduleJobLater(x)', indented: false },
526
+ ]
527
+ const map = buildImpactMap({ names: oneSymbol(), hits, shardOf: noShard, excluded: keepAll })
528
+ expect(map.symbols[0]?.hits).toBe(0)
529
+ })
530
+
531
+ test('shard is taken from shardOf on the defining file', () => {
532
+ const map = buildImpactMap({
533
+ names: oneSymbol(),
534
+ hits: noHits(),
535
+ shardOf: (f) => (f === 'src/jobs.ts' ? 3 : null),
536
+ excluded: keepAll,
537
+ })
538
+ expect(map.symbols[0]?.shard).toBe(3)
539
+ })
540
+
541
+ test('a used name gets only definition hits that define it', () => {
542
+ const names = noNames()
543
+ names.used = [{ name: 'verifyToken', file: 'src/a.ts', line: 1 }]
544
+ const hits = noHits()
545
+ hits.definitions = [
546
+ {
547
+ file: 'src/auth.ts',
548
+ line: 8,
549
+ text: 'export async function verifyToken(t: string) {',
550
+ indented: false,
551
+ },
552
+ { file: 'src/b.ts', line: 3, text: '// type verifyToken is legacy', indented: false },
553
+ ]
554
+ const map = buildImpactMap({ names, hits, shardOf: noShard, excluded: keepAll })
555
+ expect(map.uses).toEqual([
556
+ {
557
+ name: 'verifyToken',
558
+ usedIn: { file: 'src/a.ts', line: 1 },
559
+ shard: null,
560
+ definedAt: [
561
+ { file: 'src/auth.ts', line: 8, text: 'export async function verifyToken(t: string) {' },
562
+ ],
563
+ },
564
+ ])
565
+ })
566
+
567
+ test('a table store collects outside writer lines naming it', () => {
568
+ const names = noNames()
569
+ names.stores = [{ name: 'report_locks', kind: 'table', file: 'src/q.py', line: 1 }]
570
+ const hits = noHits()
571
+ hits.writers = [
572
+ { file: 'src/w.py', line: 5, text: 'DELETE FROM report_locks WHERE id = ?', indented: false },
573
+ { file: 'src/w.py', line: 9, text: 'DELETE FROM report_locks_old', indented: false },
574
+ ]
575
+ const map = buildImpactMap({ names, hits, shardOf: noShard, excluded: keepAll })
576
+ expect(map.stores[0]?.writers).toEqual([
577
+ { file: 'src/w.py', line: 5, text: 'DELETE FROM report_locks WHERE id = ?' },
578
+ ])
579
+ })
580
+
581
+ test('a file store counts any outside line containing the literal as a writer', () => {
582
+ const names = noNames()
583
+ names.stores = [{ name: 'history.db', kind: 'file', file: 'src/h.py', line: 1 }]
584
+ const hits = noHits()
585
+ hits.writers = [
586
+ { file: 'tools/prune.py', line: 2, text: 'open("data/history.db", "w")', indented: false },
587
+ ]
588
+ const map = buildImpactMap({ names, hits, shardOf: noShard, excluded: keepAll })
589
+ expect(map.stores[0]?.writers).toEqual([
590
+ { file: 'tools/prune.py', line: 2, text: 'open("data/history.db", "w")' },
591
+ ])
592
+ })
593
+
594
+ test('route siblings are other literals sharing the first two segments', () => {
595
+ const names = noNames()
596
+ names.routes = [{ path: '/api/verify/x', file: 'src/r.ts', line: 1 }]
597
+ const hits = noHits()
598
+ hits.routes = [
599
+ { file: 'src/r.ts', line: 4, text: "app.get('/api/verify/x', h)", indented: false },
600
+ { file: 'src/server.ts', line: 5, text: "app.post('/api/verify/y', h)", indented: false },
601
+ { file: 'src/server.ts', line: 6, text: "app.get('/api/verifyall', h)", indented: false },
602
+ ]
603
+ const map = buildImpactMap({ names, hits, shardOf: noShard, excluded: keepAll })
604
+ expect(map.routes[0]?.siblings).toEqual([
605
+ { file: 'src/server.ts', line: 5, text: "app.post('/api/verify/y', h)" },
606
+ ])
607
+ })
608
+
609
+ test('route siblings keep the same path used in another file', () => {
610
+ const names = noNames()
611
+ names.routes = [{ path: '/api/verify/x', file: 'src/r.ts', line: 1 }]
612
+ const hits = noHits()
613
+ hits.routes = [
614
+ { file: 'src/server.ts', line: 4, text: "app.get('/api/verify/x', h)", indented: false },
615
+ ]
616
+ const map = buildImpactMap({ names, hits, shardOf: noShard, excluded: keepAll })
617
+ expect(map.routes[0]?.siblings).toEqual([
618
+ { file: 'src/server.ts', line: 4, text: "app.get('/api/verify/x', h)" },
619
+ ])
620
+ })
621
+
622
+ test('an indented TS const is not taken as the definition of a used name', () => {
623
+ const names = noNames()
624
+ names.used = [{ name: 'helperFn', file: 'src/a.ts', line: 1 }]
625
+ const hits = noHits()
626
+ hits.definitions = [
627
+ { file: 'src/b.ts', line: 7, text: 'const helperFn = 2', indented: true },
628
+ { file: 'src/c.ts', line: 1, text: 'export const helperFn = () => 1', indented: false },
629
+ ]
630
+ const map = buildImpactMap({ names, hits, shardOf: noShard, excluded: keepAll })
631
+ expect(map.uses[0]?.definedAt).toEqual([
632
+ { file: 'src/c.ts', line: 1, text: 'export const helperFn = () => 1' },
633
+ ])
634
+ })
635
+
636
+ test('a lowercase SQL writer counts for a table store', () => {
637
+ const names = noNames()
638
+ names.stores = [{ name: 'report_locks', kind: 'table', file: 'src/q.py', line: 1 }]
639
+ const hits = noHits()
640
+ hits.writers = [
641
+ {
642
+ file: 'src/w.py',
643
+ line: 5,
644
+ text: 'cur.execute("delete from report_locks")',
645
+ indented: false,
646
+ },
647
+ ]
648
+ const map = buildImpactMap({ names, hits, shardOf: noShard, excluded: keepAll })
649
+ expect(map.stores[0]?.writers).toEqual([
650
+ { file: 'src/w.py', line: 5, text: 'cur.execute("delete from report_locks")' },
651
+ ])
652
+ })
653
+
654
+ function tableWriters(writerHits: GrepHit[]): ImpactRef[] | undefined {
655
+ const names = noNames()
656
+ names.stores = [{ name: 'report_locks', kind: 'table', file: 'src/q.py', line: 1 }]
657
+ const hits = noHits()
658
+ hits.writers = writerHits
659
+ return buildImpactMap({ names, hits, shardOf: noShard, excluded: keepAll }).stores[0]?.writers
660
+ }
661
+
662
+ test('a line that only reads the table is not a writer', () => {
663
+ expect(
664
+ tableWriters([
665
+ { file: 'src/q.ts', line: 3, text: 'db.select().from(report_locks)', indented: true },
666
+ ]),
667
+ ).toEqual([])
668
+ })
669
+
670
+ test('a comment mentioning an update of the table is not a writer', () => {
671
+ expect(
672
+ tableWriters([
673
+ { file: 'src/job.ts', line: 3, text: '// we update report_locks nightly', indented: true },
674
+ ]),
675
+ ).toEqual([])
676
+ })
677
+
678
+ test('a markdown line is not a writer', () => {
679
+ expect(
680
+ tableWriters([
681
+ { file: 'docs/ops.md', line: 2, text: 'UPDATE report_locks SET owner', indented: false },
682
+ ]),
683
+ ).toEqual([])
684
+ })
685
+
686
+ test('an UPDATE statement in a .sql file is a writer', () => {
687
+ expect(
688
+ tableWriters([
689
+ {
690
+ file: 'db/fix.sql',
691
+ line: 1,
692
+ text: 'UPDATE report_locks SET owner = 1;',
693
+ indented: false,
694
+ },
695
+ ]),
696
+ ).toEqual([{ file: 'db/fix.sql', line: 1, text: 'UPDATE report_locks SET owner = 1;' }])
697
+ })
698
+
699
+ test('docs list markdown lines with the changed names they mention', () => {
700
+ const hits = noHits()
701
+ hits.docs = [
702
+ {
703
+ file: 'README.md',
704
+ line: 7,
705
+ text: 'Use scheduleJob from jobs.ts to queue work.',
706
+ indented: false,
707
+ },
708
+ { file: 'src/notes.ts', line: 1, text: '// scheduleJob', indented: false },
709
+ ]
710
+ const map = buildImpactMap({ names: oneSymbol(), hits, shardOf: noShard, excluded: keepAll })
711
+ expect(map.docs).toEqual([
712
+ {
713
+ file: 'README.md',
714
+ line: 7,
715
+ text: 'Use scheduleJob from jobs.ts to queue work.',
716
+ mentions: ['scheduleJob', 'jobs.ts'],
717
+ shards: [],
718
+ },
719
+ ])
720
+ })
721
+
722
+ function docLines(count: number, text: string): GrepHit[] {
723
+ return Array.from({ length: count }, (_, i) => ({
724
+ file: 'docs/guide.md',
725
+ line: i + 1,
726
+ text,
727
+ indented: false,
728
+ }))
729
+ }
730
+
731
+ test('a name with more than two hundred doc lines appears in no doc', () => {
732
+ const hits = noHits()
733
+ hits.docs = docLines(201, 'scheduleJob runs here.')
734
+ const map = buildImpactMap({ names: oneSymbol(), hits, shardOf: noShard, excluded: keepAll })
735
+ expect(map.docs).toEqual([])
736
+ })
737
+
738
+ test('a name with fifteen doc lines keeps the first twelve', () => {
739
+ const hits = noHits()
740
+ hits.docs = docLines(15, 'scheduleJob runs here.')
741
+ const map = buildImpactMap({ names: oneSymbol(), hits, shardOf: noShard, excluded: keepAll })
742
+ expect(map.docs.map((d) => d.line)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
743
+ })
744
+
745
+ test('a line naming a common and a rare name keeps only the rare one', () => {
746
+ const names = oneSymbol()
747
+ names.defined.push({ name: 'rotateKeys', kind: 'changed', file: 'src/keys.ts', line: 2 })
748
+ const hits = noHits()
749
+ hits.docs = [
750
+ ...docLines(201, 'scheduleJob runs here.'),
751
+ { file: 'README.md', line: 4, text: 'scheduleJob calls rotateKeys.', indented: false },
752
+ ]
753
+ const map = buildImpactMap({
754
+ names,
755
+ hits,
756
+ shardOf: (f) => (f === 'src/jobs.ts' ? 1 : f === 'src/keys.ts' ? 2 : null),
757
+ excluded: keepAll,
758
+ })
759
+ expect(map.docs).toEqual([
760
+ {
761
+ file: 'README.md',
762
+ line: 4,
763
+ text: 'scheduleJob calls rotateKeys.',
764
+ mentions: ['rotateKeys'],
765
+ shards: [2],
766
+ },
767
+ ])
768
+ })
769
+
770
+ test('a doc carries the sorted shards of the symbols and changed files it names', () => {
771
+ const names = noNames()
772
+ names.defined = [
773
+ { name: 'scheduleJob', kind: 'changed', file: 'src/jobs.ts', line: 4 },
774
+ { name: 'rotateKeys', kind: 'added', file: 'src/keys.ts', line: 1 },
775
+ ]
776
+ names.changedFiles = ['src/jobs.ts', 'src/keys.ts', 'src/queue.ts']
777
+ const shards: Record<string, number> = { 'src/jobs.ts': 2, 'src/keys.ts': 0, 'src/queue.ts': 2 }
778
+ const hits = noHits()
779
+ hits.docs = [
780
+ {
781
+ file: 'README.md',
782
+ line: 3,
783
+ text: 'rotateKeys then scheduleJob, see queue.ts',
784
+ indented: false,
785
+ },
786
+ { file: 'README.md', line: 9, text: 'jobs.ts is old', indented: false },
787
+ ]
788
+ const map = buildImpactMap({
789
+ names,
790
+ hits,
791
+ shardOf: (f) => shards[f] ?? null,
792
+ excluded: keepAll,
793
+ })
794
+ expect(map.docs.map((d) => d.shards)).toEqual([[0, 2], [2]])
795
+ })
796
+
797
+ test('a doc whose names resolve to no shard carries no shards', () => {
798
+ const hits = noHits()
799
+ hits.docs = [{ file: 'README.md', line: 1, text: 'see scheduleJob', indented: false }]
800
+ const map = buildImpactMap({ names: oneSymbol(), hits, shardOf: noShard, excluded: keepAll })
801
+ expect(map.docs.map((d) => d.shards)).toEqual([[]])
802
+ })
803
+ })
804
+
805
+ describe('impact map over a real repository', () => {
806
+ const REPO_FILES: Record<string, string> = {
807
+ 'src/caller.ts': [
808
+ 'export function rotateKeys() {',
809
+ ' scheduleJob(1)',
810
+ ' load_rows()',
811
+ ' db.select().from(report_locks)',
812
+ '}',
813
+ '',
814
+ ].join('\n'),
815
+ 'src/jobs.ts': 'export function scheduleJob(id: number) {\n return id\n}\n',
816
+ 'src/db.rs': 'use std::fmt;\n\npub(crate) async fn load_rows() {\n}\n',
817
+ 'src/w.ts': 'db.insert(report_locks).values(row)\n',
818
+ 'src/w.py': [
819
+ 'def save(cur):',
820
+ ' cur.execute("INSERT INTO report_locks (id) VALUES (?)")',
821
+ ' cur.execute("INSERT INTO report_locks_old (id) VALUES (?)")',
822
+ '',
823
+ ].join('\n'),
824
+ 'docs/ops.md': 'Run rotateKeys nightly.\n',
825
+ }
826
+
827
+ const DIFF = `diff --git a/src/caller.ts b/src/caller.ts
828
+ new file mode 100644
829
+ --- /dev/null
830
+ +++ b/src/caller.ts
831
+ @@ -0,0 +1,5 @@
832
+ +export function rotateKeys() {
833
+ + scheduleJob(1)
834
+ + load_rows()
835
+ + db.select().from(report_locks)
836
+ +}
837
+ `
838
+
839
+ async function gitIn(cwd: string, args: string[], stdin = ''): Promise<string> {
840
+ const proc = Bun.spawn(['git', ...args], {
841
+ cwd,
842
+ stdin: new TextEncoder().encode(stdin),
843
+ stdout: 'pipe',
844
+ stderr: 'pipe',
845
+ })
846
+ const [out, err, code] = await Promise.all([
847
+ new Response(proc.stdout).text(),
848
+ new Response(proc.stderr).text(),
849
+ proc.exited,
850
+ ])
851
+ // git grep exits 1 when nothing matches.
852
+ if (code === 1 && args.includes('grep')) return ''
853
+ if (code !== 0) throw new Error(`git ${args.join(' ')} failed: ${err}`)
854
+ return out
855
+ }
856
+
857
+ test('real git grep results feed the expected uses, writers and docs', async () => {
858
+ const repo = await mkdtemp(join(tmpdir(), 'magpie-impact-'))
859
+ try {
860
+ for (const [file, body] of Object.entries(REPO_FILES)) {
861
+ await mkdir(dirname(join(repo, file)), { recursive: true })
862
+ await writeFile(join(repo, file), body)
863
+ }
864
+ await gitIn(repo, ['init', '-q'])
865
+ await gitIn(repo, ['add', '.'])
866
+
867
+ const names = extractDiffNames(DIFF)
868
+ const hits = noHits()
869
+ for (const plan of planSearches(names)) {
870
+ hits[plan.kind].push(
871
+ ...parseGrepOutput(await gitIn(repo, plan.args, `${plan.patterns.join('\n')}\n`)),
872
+ )
873
+ }
874
+ const map = buildImpactMap({ names, hits, shardOf: noShard, excluded: keepAll })
875
+
876
+ expect(map.uses).toEqual([
877
+ {
878
+ name: 'scheduleJob',
879
+ usedIn: { file: 'src/caller.ts', line: 2 },
880
+ shard: null,
881
+ definedAt: [
882
+ { file: 'src/jobs.ts', line: 1, text: 'export function scheduleJob(id: number) {' },
883
+ ],
884
+ },
885
+ {
886
+ name: 'load_rows',
887
+ usedIn: { file: 'src/caller.ts', line: 3 },
888
+ shard: null,
889
+ definedAt: [{ file: 'src/db.rs', line: 3, text: 'pub(crate) async fn load_rows() {' }],
890
+ },
891
+ ])
892
+ expect(map.stores.map((s) => s.writers)).toEqual([
893
+ [
894
+ {
895
+ file: 'src/w.py',
896
+ line: 2,
897
+ text: 'cur.execute("INSERT INTO report_locks (id) VALUES (?)")',
898
+ },
899
+ { file: 'src/w.ts', line: 1, text: 'db.insert(report_locks).values(row)' },
900
+ ],
901
+ ])
902
+ expect(map.docs).toEqual([
903
+ {
904
+ file: 'docs/ops.md',
905
+ line: 1,
906
+ text: 'Run rotateKeys nightly.',
907
+ mentions: ['rotateKeys'],
908
+ shards: [],
909
+ },
910
+ ])
911
+ } finally {
912
+ await rm(repo, { recursive: true, force: true })
913
+ }
914
+ })
915
+ })