@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,622 @@
1
+ import { basename, extname } from 'node:path'
2
+ import { parseUnifiedDiffToHunks, splitDiffByFile } from './diff-utils.ts'
3
+
4
+ export const MIN_NAME_LENGTH = 4
5
+ export const MAX_REFS_PER_NAME = 12
6
+ export const COMMON_NAME_HITS = 200
7
+ export const TEXT_MAX_CHARS = 160
8
+
9
+ export const NAME_STOPLIST = [
10
+ 'self',
11
+ 'this',
12
+ 'that',
13
+ 'data',
14
+ 'value',
15
+ 'values',
16
+ 'result',
17
+ 'error',
18
+ 'props',
19
+ 'index',
20
+ 'name',
21
+ 'test',
22
+ 'main',
23
+ 'type',
24
+ 'item',
25
+ 'items',
26
+ 'list',
27
+ 'info',
28
+ 'args',
29
+ 'opts',
30
+ 'options',
31
+ 'config',
32
+ 'state',
33
+ 'event',
34
+ 'input',
35
+ 'output',
36
+ 'path',
37
+ 'file',
38
+ 'text',
39
+ 'line',
40
+ 'next',
41
+ 'done',
42
+ 'none',
43
+ 'true',
44
+ 'false',
45
+ 'null',
46
+ 'undefined',
47
+ 'return',
48
+ 'async',
49
+ 'await',
50
+ 'export',
51
+ 'import',
52
+ 'const',
53
+ 'string',
54
+ 'number',
55
+ 'boolean',
56
+ 'object',
57
+ ]
58
+
59
+ export type ImpactLocation = { file: string; line: number }
60
+ export type ImpactRef = { file: string; line: number; text: string }
61
+ export type ImpactSymbol = {
62
+ name: string
63
+ kind: 'added' | 'changed' | 'removed'
64
+ definedAt: ImpactLocation[]
65
+ shard: number | null
66
+ hits: number
67
+ references: ImpactRef[]
68
+ }
69
+ export type ImpactUse = {
70
+ name: string
71
+ usedIn: ImpactLocation
72
+ shard: number | null
73
+ definedAt: ImpactRef[]
74
+ }
75
+ export type ImpactStore = {
76
+ name: string
77
+ kind: 'table' | 'file'
78
+ usedIn: ImpactLocation[]
79
+ shard: number | null
80
+ writers: ImpactRef[]
81
+ }
82
+ export type ImpactRoute = {
83
+ path: string
84
+ usedIn: ImpactLocation
85
+ shard: number | null
86
+ siblings: ImpactRef[]
87
+ }
88
+ export type ImpactDoc = {
89
+ file: string
90
+ line: number
91
+ text: string
92
+ mentions: string[]
93
+ shards: number[]
94
+ }
95
+ export type ImpactMap = {
96
+ version: 1
97
+ symbols: ImpactSymbol[]
98
+ uses: ImpactUse[]
99
+ stores: ImpactStore[]
100
+ routes: ImpactRoute[]
101
+ docs: ImpactDoc[]
102
+ }
103
+
104
+ export type DiffNames = {
105
+ // `line` is null for a name seen only in a hunk header: the header carries the
106
+ // enclosing definition's text but not its line, which the definitions search finds.
107
+ defined: Array<{
108
+ name: string
109
+ kind: 'added' | 'changed' | 'removed'
110
+ file: string
111
+ line: number | null
112
+ }>
113
+ used: Array<{ name: string; file: string; line: number }>
114
+ stores: Array<{ name: string; kind: 'table' | 'file'; file: string; line: number }>
115
+ routes: Array<{ path: string; file: string; line: number }>
116
+ changedFiles: string[]
117
+ addedLines: Map<string, Set<number>>
118
+ }
119
+
120
+ // `indented` survives trimming because a column-0 TS `const` is a module
121
+ // definition while an indented one is a local.
122
+ export type GrepHit = { file: string; line: number; text: string; indented: boolean }
123
+ // `args` is the argv after `git`, starting `-c core.quotePath=false grep`:
124
+ // quoted non-ASCII paths would never match the diff's paths. `patterns` are
125
+ // fixed strings for `-f -`, one per line on stdin.
126
+ export type GrepPlan = {
127
+ kind: 'references' | 'definitions' | 'writers' | 'routes' | 'docs'
128
+ args: string[]
129
+ patterns: string[]
130
+ }
131
+
132
+ const IDENT = '[A-Za-z_][A-Za-z0-9_]*'
133
+
134
+ const TS_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']
135
+ const DOC_EXTS = ['.md', '.mdx']
136
+
137
+ // Top-level const/let/var only: indented ones are locals, and searching every
138
+ // local name across the repo is noise.
139
+ const TS_DEFS = [
140
+ /^\s*(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?(?:function\s*\*?|class|interface|type|enum)\s+([A-Za-z_$][\w$]*)/,
141
+ /^(?:export\s+)?(?:declare\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]*)?=/,
142
+ ]
143
+ const RS_DEFS = [
144
+ /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:unsafe\s+)?(?:fn|struct|enum|trait|type|const|static)\s+(?:mut\s+)?([A-Za-z_]\w*)/,
145
+ ]
146
+ const PY_DEFS = [/^\s*(?:async\s+)?(?:def|class)\s+([A-Za-z_]\w*)/]
147
+ const GO_DEFS = [/^func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)/, /^\s*type\s+([A-Za-z_]\w*)/]
148
+ const SH_DEFS = [/^\s*([A-Za-z_]\w*)\s*\(\)\s*\{/, /^\s*function\s+([A-Za-z_]\w*)/]
149
+
150
+ function definitionPatterns(file: string): RegExp[] {
151
+ const ext = extname(file)
152
+ if (TS_EXTS.includes(ext)) return TS_DEFS
153
+ if (ext === '.rs') return RS_DEFS
154
+ if (ext === '.py') return PY_DEFS
155
+ if (ext === '.go') return GO_DEFS
156
+ if (ext === '.sh' || ext === '.bash') return SH_DEFS
157
+ return []
158
+ }
159
+
160
+ function definedNamesIn(file: string, text: string): string[] {
161
+ const names: string[] = []
162
+ for (const re of definitionPatterns(file)) {
163
+ const m = text.match(re)
164
+ if (m?.[1]) names.push(m[1])
165
+ }
166
+ return names
167
+ }
168
+
169
+ function isDoc(file: string): boolean {
170
+ return DOC_EXTS.includes(extname(file))
171
+ }
172
+
173
+ function isSearchableName(name: string): boolean {
174
+ return name.length >= MIN_NAME_LENGTH && !NAME_STOPLIST.includes(name)
175
+ }
176
+
177
+ // Control-flow keywords sit directly before `(` in most languages but name
178
+ // nothing a reviewer could look up.
179
+ const CALL_KEYWORDS = ['while', 'switch', 'catch', 'function', 'typeof', 'elif', 'match']
180
+
181
+ const CALL = new RegExp(`(?<![A-Za-z0-9_$])(${IDENT})\\(`, 'g')
182
+ const BACKTICKED = new RegExp(`\`(${IDENT})(?:\\(\\))?\``, 'g')
183
+
184
+ // update( and delete( stay writer verbs on the grep side, but as names they
185
+ // catch hash.update(payload) and map.delete(key), which are not tables.
186
+ const TABLE_CALL = new RegExp(
187
+ `(?:([A-Za-z_$][\\w$]*)\\s*)?\\.(?:from|insert|into)\\(\\s*["'\`]?(${IDENT})`,
188
+ 'g',
189
+ )
190
+ const BUILTIN_RECEIVERS = [
191
+ 'Array',
192
+ 'Buffer',
193
+ 'Object',
194
+ 'Promise',
195
+ 'JSON',
196
+ 'Math',
197
+ 'Date',
198
+ 'String',
199
+ 'Number',
200
+ 'URL',
201
+ 'Symbol',
202
+ 'Reflect',
203
+ ]
204
+ const TABLE_SQL = new RegExp(
205
+ `(?:INSERT\\s+INTO|REPLACE\\s+INTO|DELETE\\s+FROM|UPDATE|FROM|JOIN)\\s+["\`]?(${IDENT})`,
206
+ 'gi',
207
+ )
208
+ const SQL_VERB = /(?<![A-Za-z0-9_])(?:select|insert|update|delete|replace)(?![A-Za-z0-9_])/i
209
+ const FILE_STORE = /["'`]([^"'`\s]+\.(?:db|sqlite|sqlite3))["'`]/g
210
+ const ROUTE = /["'`](\/[A-Za-z0-9_\-.:{}$]+(?:\/[A-Za-z0-9_\-.:{}$]*)+)["'`]/g
211
+
212
+ function captures(re: RegExp, text: string): string[] {
213
+ return [...text.matchAll(re)].flatMap((m) => (m[1] ? [m[1]] : []))
214
+ }
215
+
216
+ function tableCalls(text: string): string[] {
217
+ return [...text.matchAll(TABLE_CALL)].flatMap((m) =>
218
+ m[2] && !(m[1] && BUILTIN_RECEIVERS.includes(m[1])) ? [m[2]] : [],
219
+ )
220
+ }
221
+
222
+ const PYTHON_IMPORT = /^\s*from\s+\S+\s+import(?![A-Za-z0-9_])/
223
+ const SQL_START = /^\s*(?:select|insert|update|delete|replace|with)(?![A-Za-z0-9_])/i
224
+
225
+ // The content of the string literal open at `index`, from its opening quote,
226
+ // or null when `index` is outside any literal.
227
+ function openLiteral(text: string, index: number): string | null {
228
+ const before = text.slice(0, index)
229
+ let open = -1
230
+ for (const q of ['"', "'", '`']) {
231
+ if (before.split(q).length % 2 === 0) open = Math.max(open, before.lastIndexOf(q))
232
+ }
233
+ return open < 0 ? null : text.slice(open + 1)
234
+ }
235
+
236
+ // Lowercase `from`, `update` and the rest are everyday English, so they count
237
+ // only in a literal that reads as SQL, or in a .sql file. Uppercase ones are
238
+ // trusted with any SQL context on the line.
239
+ function sqlTables(file: string, text: string): string[] {
240
+ if (PYTHON_IMPORT.test(text)) return []
241
+ const sqlLine = SQL_VERB.test(text)
242
+ return [...text.matchAll(TABLE_SQL)].flatMap((m) => {
243
+ if (!m[1]) return []
244
+ const keyword = m[0].slice(0, m[0].search(/\s/))
245
+ const literal = openLiteral(text, m.index)
246
+ const counts =
247
+ keyword === keyword.toUpperCase()
248
+ ? sqlLine || literal !== null
249
+ : literal !== null
250
+ ? SQL_START.test(literal)
251
+ : extname(file) === '.sql' && sqlLine
252
+ return counts ? [m[1]] : []
253
+ })
254
+ }
255
+
256
+ function routePrefix(path: string): string {
257
+ return path.split('/').slice(0, 3).join('/')
258
+ }
259
+
260
+ function escapeRegex(s: string): string {
261
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
262
+ }
263
+
264
+ function wholeWord(name: string): RegExp {
265
+ return new RegExp(`(?<![A-Za-z0-9_])${escapeRegex(name)}(?![A-Za-z0-9_])`)
266
+ }
267
+
268
+ export function extractDiffNames(diff: string): DiffNames {
269
+ type Seen = { file: string; line: number | null }
270
+ const added = new Map<string, Seen[]>()
271
+ const removed = new Map<string, Seen[]>()
272
+ const fromHeader = new Map<string, Seen[]>()
273
+ const calls: DiffNames['used'] = []
274
+ const stores: DiffNames['stores'] = []
275
+ const routes: DiffNames['routes'] = []
276
+ const changedFiles: string[] = []
277
+ const addedLines = new Map<string, Set<number>>()
278
+
279
+ const note = (into: Map<string, Seen[]>, name: string, at: Seen) => {
280
+ if (!isSearchableName(name)) return
281
+ const list = into.get(name)
282
+ if (list) list.push(at)
283
+ else into.set(name, [at])
284
+ }
285
+
286
+ for (const [file, chunk] of splitDiffByFile(diff)) {
287
+ changedFiles.push(file)
288
+ const lines = new Set<number>()
289
+ addedLines.set(file, lines)
290
+ const doc = isDoc(file)
291
+ for (const hunk of parseUnifiedDiffToHunks(chunk)) {
292
+ const touched = hunk.lines.some((l) => l.type !== 'context')
293
+ if (touched) {
294
+ for (const name of definedNamesIn(file, hunk.header.trim())) {
295
+ note(fromHeader, name, { file, line: null })
296
+ }
297
+ }
298
+ for (const l of hunk.lines) {
299
+ if (l.type === 'removed' && l.oldLineNo !== null) {
300
+ for (const name of definedNamesIn(file, l.content)) {
301
+ note(removed, name, { file, line: l.oldLineNo })
302
+ }
303
+ }
304
+ if (l.type !== 'added' || l.newLineNo === null) continue
305
+ const line = l.newLineNo
306
+ lines.add(line)
307
+ for (const name of definedNamesIn(file, l.content)) note(added, name, { file, line })
308
+ if (doc) {
309
+ for (const name of captures(BACKTICKED, l.content)) calls.push({ name, file, line })
310
+ continue
311
+ }
312
+ for (const name of captures(CALL, l.content)) {
313
+ if (!CALL_KEYWORDS.includes(name)) calls.push({ name, file, line })
314
+ }
315
+ const tables = [...tableCalls(l.content), ...sqlTables(file, l.content)]
316
+ for (const name of new Set(tables)) {
317
+ if (isSearchableName(name)) stores.push({ name, kind: 'table', file, line })
318
+ }
319
+ for (const name of new Set(captures(FILE_STORE, l.content))) {
320
+ stores.push({ name, kind: 'file', file, line })
321
+ }
322
+ for (const path of new Set(captures(ROUTE, l.content))) routes.push({ path, file, line })
323
+ }
324
+ }
325
+ }
326
+
327
+ const defined: DiffNames['defined'] = []
328
+ const names = new Set([...added.keys(), ...removed.keys(), ...fromHeader.keys()])
329
+ for (const name of names) {
330
+ const a = added.get(name)
331
+ const r = removed.get(name)
332
+ const kind = a && !r ? 'added' : r && !a ? 'removed' : 'changed'
333
+ const sites = a ?? r ?? fromHeader.get(name) ?? []
334
+ const perFile = new Map<string, Seen>()
335
+ for (const s of sites) if (!perFile.has(s.file)) perFile.set(s.file, s)
336
+ for (const s of perFile.values()) defined.push({ name, kind, file: s.file, line: s.line })
337
+ }
338
+
339
+ const used: DiffNames['used'] = []
340
+ const usedNames = new Set<string>()
341
+ for (const c of calls) {
342
+ if (names.has(c.name) || usedNames.has(c.name) || !isSearchableName(c.name)) continue
343
+ usedNames.add(c.name)
344
+ used.push(c)
345
+ }
346
+
347
+ const seenRoutes = new Set<string>()
348
+ const uniqueRoutes = routes.filter((r) => {
349
+ if (seenRoutes.has(r.path)) return false
350
+ seenRoutes.add(r.path)
351
+ return true
352
+ })
353
+
354
+ return { defined, used, stores, routes: uniqueRoutes, changedFiles, addedLines }
355
+ }
356
+
357
+ const LINE_NUMBER = /:(\d+):/
358
+
359
+ export function parseGrepOutput(out: string): GrepHit[] {
360
+ const hits: GrepHit[] = []
361
+ for (const raw of out.split('\n')) {
362
+ if (raw.length === 0) continue
363
+ const m = raw.match(LINE_NUMBER)
364
+ if (!m?.[1] || m.index === undefined || m.index === 0) {
365
+ throw new Error(`unparseable git grep line: ${raw.slice(0, TEXT_MAX_CHARS)}`)
366
+ }
367
+ const rest = raw.slice(m.index + m[0].length)
368
+ hits.push({
369
+ file: raw.slice(0, m.index),
370
+ line: Number(m[1]),
371
+ // Cut by code point: slicing UTF-16 units can split a surrogate pair.
372
+ text: Array.from(rest.trim()).slice(0, TEXT_MAX_CHARS).join(''),
373
+ indented: /^\s/.test(rest),
374
+ })
375
+ }
376
+ return hits
377
+ }
378
+
379
+ const COMMENT_START = /^(?:\/\/|#|\*|\/\*|--)/
380
+
381
+ function isTableWriter(table: string, text: string): boolean {
382
+ const t = escapeRegex(table)
383
+ const call = new RegExp(`\\.(?:insert|update|delete|into)\\(\\s*["'\`]?${t}(?![A-Za-z0-9_])`)
384
+ const sql = new RegExp(
385
+ `(?:INSERT\\s+INTO|REPLACE\\s+INTO|DELETE\\s+FROM|UPDATE)\\s+["\`]?${t}(?![A-Za-z0-9_])`,
386
+ 'i',
387
+ )
388
+ // A case-insensitive verb in a comment is prose about the table, not a write.
389
+ return call.test(text) || (sql.test(text) && !COMMENT_START.test(text))
390
+ }
391
+
392
+ function unique(values: string[]): string[] {
393
+ return [...new Set(values)]
394
+ }
395
+
396
+ function changedBasenames(names: DiffNames): string[] {
397
+ return unique(names.changedFiles.filter((f) => !isDoc(f)).map((f) => basename(f)))
398
+ }
399
+
400
+ // Fixed strings read from stdin: thousands of names stay under ARG_MAX, and
401
+ // -F matching is fast where ERE alternations ran for minutes. The shape checks
402
+ // a regex would have made run in buildImpactMap instead.
403
+ const GREP = ['-c', 'core.quotePath=false', 'grep', '-n', '-I', '-F']
404
+ const WORDS = [...GREP, '-w']
405
+ const STDIN = ['-f', '-']
406
+ // git grep checks every pattern on every line once it has more than one, so a
407
+ // search's time grows with its pattern count. On a 4000-file repo, 50 names
408
+ // took 4.9s as one search, 2.2s as 50 searches and 0.9s as ten searches of five.
409
+ const GREP_BATCH = 5
410
+
411
+ export function planSearches(names: DiffNames): GrepPlan[] {
412
+ const plans: GrepPlan[] = []
413
+ const add = (plan: GrepPlan): void => {
414
+ for (let i = 0; i < plan.patterns.length; i += GREP_BATCH) {
415
+ plans.push({ ...plan, patterns: plan.patterns.slice(i, i + GREP_BATCH) })
416
+ }
417
+ }
418
+ const definedNames = unique(names.defined.map((d) => d.name))
419
+ if (definedNames.length > 0) {
420
+ add({ kind: 'references', args: [...WORDS, ...STDIN], patterns: definedNames })
421
+ }
422
+ const headerNames = names.defined.filter((d) => d.line === null).map((d) => d.name)
423
+ const lookupNames = unique([...names.used.map((u) => u.name), ...headerNames])
424
+ if (lookupNames.length > 0) {
425
+ add({ kind: 'definitions', args: [...WORDS, ...STDIN], patterns: lookupNames })
426
+ }
427
+ const tables = unique(names.stores.filter((s) => s.kind === 'table').map((s) => s.name))
428
+ if (tables.length > 0) {
429
+ // -i because SQL verbs and table names appear in either case.
430
+ add({ kind: 'writers', args: [...WORDS, '-i', ...STDIN], patterns: tables })
431
+ }
432
+ const files = unique(names.stores.filter((s) => s.kind === 'file').map((s) => s.name))
433
+ if (files.length > 0) {
434
+ add({ kind: 'writers', args: [...GREP, ...STDIN], patterns: files })
435
+ }
436
+ const prefixes = unique(names.routes.map((r) => routePrefix(r.path)))
437
+ if (prefixes.length > 0) {
438
+ add({ kind: 'routes', args: [...GREP, ...STDIN], patterns: prefixes })
439
+ }
440
+ const mentions = unique([...definedNames, ...changedBasenames(names)])
441
+ if (mentions.length > 0) {
442
+ add({
443
+ kind: 'docs',
444
+ args: [...WORDS, ...STDIN, '--', '*.md', '*.mdx'],
445
+ patterns: mentions,
446
+ })
447
+ }
448
+ return plans
449
+ }
450
+
451
+ function byFileThenLine(a: ImpactLocation, b: ImpactLocation): number {
452
+ if (a.file !== b.file) return a.file < b.file ? -1 : 1
453
+ return a.line - b.line
454
+ }
455
+
456
+ export function buildImpactMap(input: {
457
+ names: DiffNames
458
+ hits: Record<GrepPlan['kind'], GrepHit[]>
459
+ shardOf: (file: string) => number | null
460
+ excluded: (file: string) => boolean
461
+ }): ImpactMap {
462
+ const { names, hits, shardOf, excluded } = input
463
+ // Batched searches return a line once per batch whose names it holds.
464
+ const outside = (list: GrepHit[]): GrepHit[] => {
465
+ const seen = new Set<string>()
466
+ return list
467
+ .filter((h) => {
468
+ const key = `${h.file}:${h.line}`
469
+ if (seen.has(key)) return false
470
+ seen.add(key)
471
+ return !excluded(h.file) && !names.addedLines.get(h.file)?.has(h.line)
472
+ })
473
+ .sort(byFileThenLine)
474
+ }
475
+ const ref = (h: GrepHit): ImpactRef => ({ file: h.file, line: h.line, text: h.text })
476
+
477
+ // A header-derived name is defined in a file the diff changes, so the path
478
+ // filter, which is about code outside the diff, does not apply to it.
479
+ const headerDefinitionLines = (name: string, file: string): ImpactLocation[] => {
480
+ const lines = new Set<number>()
481
+ for (const h of hits.definitions) {
482
+ if (h.file !== file || names.addedLines.get(file)?.has(h.line)) continue
483
+ if (definedNamesIn(file, h.indented ? ` ${h.text}` : h.text).includes(name)) lines.add(h.line)
484
+ }
485
+ return [...lines].sort((a, b) => a - b).map((line) => ({ file, line }))
486
+ }
487
+
488
+ const references = outside(hits.references)
489
+ const symbols: ImpactSymbol[] = []
490
+ const symbolIndex = new Map<string, ImpactSymbol>()
491
+ for (const d of names.defined) {
492
+ // A header-derived name with no definition line found in its file gets no
493
+ // location rather than a guessed one.
494
+ const locations =
495
+ d.line !== null ? [{ file: d.file, line: d.line }] : headerDefinitionLines(d.name, d.file)
496
+ if (locations.length === 0) continue
497
+ const existing = symbolIndex.get(d.name)
498
+ if (existing) {
499
+ existing.definedAt.push(...locations)
500
+ continue
501
+ }
502
+ const word = wholeWord(d.name)
503
+ const matched = references.filter((h) => word.test(h.text))
504
+ const symbol: ImpactSymbol = {
505
+ name: d.name,
506
+ kind: d.kind,
507
+ definedAt: locations,
508
+ shard: shardOf(d.file),
509
+ hits: matched.length,
510
+ references:
511
+ matched.length > COMMON_NAME_HITS ? [] : matched.slice(0, MAX_REFS_PER_NAME).map(ref),
512
+ }
513
+ symbolIndex.set(d.name, symbol)
514
+ symbols.push(symbol)
515
+ }
516
+
517
+ // Indexed once: a definitions search returns every line naming a used name,
518
+ // and only the lines shaped as a definition of it count.
519
+ const definedBy = new Map<string, ImpactRef[]>()
520
+ for (const h of outside(hits.definitions)) {
521
+ // The leading space restores the indentation the column-0 TS rule reads.
522
+ for (const name of new Set(definedNamesIn(h.file, h.indented ? ` ${h.text}` : h.text))) {
523
+ const list = definedBy.get(name)
524
+ if (list) list.push(ref(h))
525
+ else definedBy.set(name, [ref(h)])
526
+ }
527
+ }
528
+
529
+ const uses: ImpactUse[] = []
530
+ for (const u of names.used) {
531
+ const definedAt = (definedBy.get(u.name) ?? []).slice(0, MAX_REFS_PER_NAME)
532
+ if (definedAt.length === 0) continue
533
+ uses.push({
534
+ name: u.name,
535
+ usedIn: { file: u.file, line: u.line },
536
+ shard: shardOf(u.file),
537
+ definedAt,
538
+ })
539
+ }
540
+
541
+ const writers = outside(hits.writers).filter((h) => !isDoc(h.file))
542
+ const stores: ImpactStore[] = []
543
+ const storeIndex = new Map<string, ImpactStore>()
544
+ for (const s of names.stores) {
545
+ const existing = storeIndex.get(s.name)
546
+ if (existing) {
547
+ existing.usedIn.push({ file: s.file, line: s.line })
548
+ continue
549
+ }
550
+ const matches =
551
+ s.kind === 'file'
552
+ ? (h: GrepHit) => h.text.includes(s.name)
553
+ : (h: GrepHit) => isTableWriter(s.name, h.text)
554
+ const store: ImpactStore = {
555
+ name: s.name,
556
+ kind: s.kind,
557
+ usedIn: [{ file: s.file, line: s.line }],
558
+ shard: shardOf(s.file),
559
+ writers: writers.filter(matches).slice(0, MAX_REFS_PER_NAME).map(ref),
560
+ }
561
+ storeIndex.set(s.name, store)
562
+ stores.push(store)
563
+ }
564
+
565
+ const routeHits = outside(hits.routes)
566
+ const routes: ImpactRoute[] = names.routes.map((r) => {
567
+ const prefix = routePrefix(r.path)
568
+ const siblings = routeHits
569
+ .filter((h) =>
570
+ captures(ROUTE, h.text).some((p) =>
571
+ p === r.path ? h.file !== r.file : routePrefix(p) === prefix,
572
+ ),
573
+ )
574
+ .slice(0, MAX_REFS_PER_NAME)
575
+ .map(ref)
576
+ return {
577
+ path: r.path,
578
+ usedIn: { file: r.file, line: r.line },
579
+ shard: shardOf(r.file),
580
+ siblings,
581
+ }
582
+ })
583
+
584
+ const mentionable = unique([...names.defined.map((d) => d.name), ...changedBasenames(names)])
585
+ const mentionWords = mentionable.map((m) => ({ name: m, word: wholeWord(m) }))
586
+ const shardsNamed = (mentions: string[]): number[] => {
587
+ const found = new Set<number>()
588
+ for (const m of mentions) {
589
+ const symbolShard = symbolIndex.get(m)?.shard
590
+ if (symbolShard !== undefined && symbolShard !== null) found.add(symbolShard)
591
+ for (const f of names.changedFiles) {
592
+ const fileShard = basename(f) === m ? shardOf(f) : null
593
+ if (fileShard !== null) found.add(fileShard)
594
+ }
595
+ }
596
+ return [...found].sort((a, b) => a - b)
597
+ }
598
+ const docHits = outside(hits.docs)
599
+ .filter((h) => isDoc(h.file))
600
+ .map((h) => ({ hit: h, mentions: mentionWords.filter((m) => m.word.test(h.text)) }))
601
+ // The symbol rules, per name: a name on too many doc lines is a common word
602
+ // that names nothing, and a rare one lists only its first few lines.
603
+ const docLineCount = new Map<string, number>()
604
+ for (const d of docHits) {
605
+ for (const m of d.mentions) docLineCount.set(m.name, (docLineCount.get(m.name) ?? 0) + 1)
606
+ }
607
+ const docLinesKept = new Map<string, number>()
608
+ const docs: ImpactDoc[] = []
609
+ for (const { hit: h, mentions: found } of docHits) {
610
+ const mentions: string[] = []
611
+ for (const { name } of found) {
612
+ const kept = docLinesKept.get(name) ?? 0
613
+ if ((docLineCount.get(name) ?? 0) > COMMON_NAME_HITS || kept >= MAX_REFS_PER_NAME) continue
614
+ docLinesKept.set(name, kept + 1)
615
+ mentions.push(name)
616
+ }
617
+ if (mentions.length === 0) continue
618
+ docs.push({ file: h.file, line: h.line, text: h.text, mentions, shards: shardsNamed(mentions) })
619
+ }
620
+
621
+ return { version: 1, symbols, uses, stores, routes, docs }
622
+ }
@@ -8,6 +8,8 @@ export type IncrementalContext = {
8
8
  currentSha: string
9
9
  /** True when the head SHA is unchanged from the previous run (no new commits). */
10
10
  sameSha: boolean
11
+ /** Files changed from previousSha to currentSha; null when previousSha is not in the clone. */
12
+ changedSincePrevious?: string[] | null
11
13
  }
12
14
 
13
15
  const RUN_PATTERN = /^pr-(\d+)-(\d+)(?:\.archived-\d+)?$/
@@ -17,12 +19,15 @@ function reviewHome(): string {
17
19
  }
18
20
 
19
21
  /**
20
- * Find the most recent prior run (active or archived) for the same PR number,
21
- * excluding the current run. Returns null when no prior run exists. The current
22
- * run is identified by basename match so we don't accidentally pick it up.
22
+ * Find the most recent prior run (active or archived) of the same PR, excluding
23
+ * the current run. Run ids carry only the PR number, so the repository is
24
+ * confirmed by matching pr.json's `url`; a run without one is skipped, since
25
+ * another repository's dismissals would make the critic drop findings here.
26
+ * Returns null when no prior run exists.
23
27
  */
24
28
  export async function findPreviousRun(
25
29
  prNumber: number,
30
+ prUrl: string,
26
31
  currentRunDir: string,
27
32
  home?: string,
28
33
  ): Promise<{ runId: string; runDir: string; prJson: Record<string, unknown> } | null> {
@@ -48,12 +53,14 @@ export async function findPreviousRun(
48
53
  const s = await stat(runDir).catch(() => null)
49
54
  if (!s?.isDirectory()) continue
50
55
  const prJsonPath = join(runDir, 'pr.json')
56
+ let prJson: Record<string, unknown>
51
57
  try {
52
- const text = await readFile(prJsonPath, 'utf8')
53
- return { runId: c.id, runDir, prJson: JSON.parse(text) }
58
+ prJson = JSON.parse(await readFile(prJsonPath, 'utf8'))
54
59
  } catch {
55
60
  // Missing or malformed pr.json; try the next candidate.
61
+ continue
56
62
  }
63
+ if (prJson.url === prUrl) return { runId: c.id, runDir, prJson }
57
64
  }
58
65
  return null
59
66
  }
@@ -73,3 +80,35 @@ export function buildIncrementalContext(
73
80
  sameSha: previousSha === currentSha,
74
81
  }
75
82
  }
83
+
84
+ /**
85
+ * Files changed between two commits, or null when either is not in the clone
86
+ * (a force-push can leave the earlier head unreachable). Names are the new side.
87
+ */
88
+ export async function changedFilesSince(input: {
89
+ gitBin: string
90
+ repoPath: string
91
+ fromSha: string
92
+ toSha: string
93
+ }): Promise<string[] | null> {
94
+ for (const sha of [input.fromSha, input.toSha]) {
95
+ const probe = Bun.spawn([input.gitBin, 'cat-file', '-e', `${sha}^{commit}`], {
96
+ cwd: input.repoPath,
97
+ stdout: 'ignore',
98
+ stderr: 'ignore',
99
+ })
100
+ if ((await probe.exited) !== 0) return null
101
+ }
102
+ const proc = Bun.spawn(
103
+ [input.gitBin, 'diff', '--name-only', '--no-renames', input.fromSha, input.toSha],
104
+ { cwd: input.repoPath, stdout: 'pipe', stderr: 'pipe' },
105
+ )
106
+ const [stdout, stderr] = await Promise.all([
107
+ new Response(proc.stdout).text(),
108
+ new Response(proc.stderr).text(),
109
+ ])
110
+ if ((await proc.exited) !== 0) {
111
+ throw new Error(`git diff --name-only ${input.fromSha} ${input.toSha}: ${stderr.trim()}`)
112
+ }
113
+ return stdout.split('\n').filter((l) => l !== '')
114
+ }