@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.
- package/package.json +1 -1
- package/skills/index.json +2 -2
- package/skills/magpie/SKILL.md +21 -20
- package/skills/magpie/bin/magpie.ts +63 -2
- package/skills/magpie/evals/quality/README.md +74 -1
- package/skills/magpie/evals/quality/__tests__/probe-specialist.test.ts +498 -0
- package/skills/magpie/evals/quality/__tests__/replay-full.test.ts +9 -0
- package/skills/magpie/evals/quality/probe-specialist.ts +672 -0
- package/skills/magpie/evals/quality/replay-full.ts +6 -2
- package/skills/magpie/fixtures/fake-gh-with-lockfile.sh +37 -2
- package/skills/magpie/fixtures/fake-gh.sh +49 -2
- package/skills/magpie/references/critic.md +20 -4
- package/skills/magpie/references/proof.md +102 -0
- package/skills/magpie/references/scout.md +6 -1
- package/skills/magpie/references/specialists.md +15 -9
- package/skills/magpie/scripts/__tests__/cleanup-cmd.test.ts +103 -7
- package/skills/magpie/scripts/__tests__/cli.test.ts +69 -0
- package/skills/magpie/scripts/__tests__/critic-cmd.test.ts +46 -0
- package/skills/magpie/scripts/__tests__/critic.test.ts +11 -1
- package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +20 -1
- package/skills/magpie/scripts/__tests__/git-diff.test.ts +23 -4
- package/skills/magpie/scripts/__tests__/handoff-cmd.test.ts +68 -0
- package/skills/magpie/scripts/__tests__/history.test.ts +232 -0
- package/skills/magpie/scripts/__tests__/impact-cmd.test.ts +477 -0
- package/skills/magpie/scripts/__tests__/impact-render.test.ts +365 -0
- package/skills/magpie/scripts/__tests__/impact.test.ts +915 -0
- package/skills/magpie/scripts/__tests__/incremental.test.ts +59 -8
- package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +1 -1
- package/skills/magpie/scripts/__tests__/post-cmd.test.ts +58 -0
- package/skills/magpie/scripts/__tests__/pr-activity.test.ts +281 -0
- package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +4 -4
- package/skills/magpie/scripts/__tests__/previous-findings.test.ts +267 -0
- package/skills/magpie/scripts/__tests__/proof-cmd.test.ts +260 -0
- package/skills/magpie/scripts/__tests__/proof.test.ts +226 -0
- package/skills/magpie/scripts/__tests__/refresh.test.ts +71 -0
- package/skills/magpie/scripts/__tests__/render-annotation.test.ts +34 -0
- package/skills/magpie/scripts/__tests__/render-findings.test.ts +75 -0
- package/skills/magpie/scripts/__tests__/render-progress.test.ts +9 -0
- package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +376 -1
- package/skills/magpie/scripts/__tests__/shard-coverage.test.ts +79 -0
- package/skills/magpie/scripts/__tests__/skill-lint.test.ts +48 -2
- package/skills/magpie/scripts/__tests__/status-cmd.test.ts +20 -8
- package/skills/magpie/scripts/__tests__/types.test.ts +46 -0
- package/skills/magpie/scripts/cleanup-cmd.ts +88 -0
- package/skills/magpie/scripts/critic-cmd.ts +2 -0
- package/skills/magpie/scripts/critic.ts +4 -0
- package/skills/magpie/scripts/dedupe-cmd.ts +4 -0
- package/skills/magpie/scripts/evidence-filter.ts +10 -0
- package/skills/magpie/scripts/git-diff.ts +50 -19
- package/skills/magpie/scripts/handoff-cmd.ts +42 -3
- package/skills/magpie/scripts/history.ts +143 -0
- package/skills/magpie/scripts/impact-cmd.ts +202 -0
- package/skills/magpie/scripts/impact-render.ts +158 -0
- package/skills/magpie/scripts/impact.ts +622 -0
- package/skills/magpie/scripts/incremental.ts +44 -5
- package/skills/magpie/scripts/labels-cmd.ts +7 -3
- package/skills/magpie/scripts/post-cmd.ts +46 -0
- package/skills/magpie/scripts/pr-activity.ts +243 -0
- package/skills/magpie/scripts/preview-cmd.ts +3 -0
- package/skills/magpie/scripts/previous-findings.ts +181 -0
- package/skills/magpie/scripts/proof-cmd.ts +226 -0
- package/skills/magpie/scripts/proof.ts +150 -0
- package/skills/magpie/scripts/refresh.ts +11 -0
- package/skills/magpie/scripts/render-annotation.ts +17 -0
- package/skills/magpie/scripts/render-cmd.ts +12 -0
- package/skills/magpie/scripts/render-findings.ts +37 -1
- package/skills/magpie/scripts/render-progress.ts +3 -0
- package/skills/magpie/scripts/setup-cmd.ts +142 -11
- package/skills/magpie/scripts/shard-coverage.ts +53 -0
- package/skills/magpie/scripts/status-cmd.ts +2 -0
- package/skills/magpie/scripts/types.ts +39 -0
- package/skills/magpie/skill.json +1 -1
- package/skills/magpie/templates/styles.css +30 -0
- package/skills/sluice/scripts/status.sh +32 -13
- 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)
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|