ctxman 3.1.0 → 3.2.7
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/.ctxman/llm-profiles.json +6 -6
- package/README.md +17 -10
- package/bin/cli.js +107 -8
- package/bin/cm-ask.js +74 -70
- package/bin/cm-audit.js +80 -7
- package/bin/cm-diagnose.js +199 -0
- package/bin/cm-focus.js +322 -0
- package/bin/cm-map.js +71 -18
- package/bin/cm-pack.js +36 -5
- package/bin/cm-policy.js +107 -2
- package/bin/cm-pr.js +18 -3
- package/bin/cm-search.js +84 -19
- package/bin/cm-task.js +192 -0
- package/bin/cm-who.js +119 -0
- package/bin/cm-why.js +41 -5
- package/examples/github-actions/ctxman-check.yml +1 -1
- package/index.d.ts +931 -1
- package/index.js +105 -1
- package/lib/analyzers/performance-dashboard.js +7 -3
- package/lib/analyzers/token-calculator.js +76 -4
- package/lib/api/mcp/server.js +713 -14
- package/lib/cache/CacheManager.js +161 -47
- package/lib/core/Analyzer.js +61 -5
- package/lib/diagnose/index.js +712 -0
- package/lib/focus/index.js +790 -0
- package/lib/map/call-graph.js +570 -0
- package/lib/map/graph.js +30 -5
- package/lib/map/index.js +12 -4
- package/lib/map/rank.js +45 -7
- package/lib/map/render.js +89 -28
- package/lib/pack/index.js +20 -1
- package/lib/pack/select.js +8 -1
- package/lib/parsers/gitignore-parser.js +4 -1
- package/lib/policy/audit.js +22 -2
- package/lib/policy/index.js +6 -0
- package/lib/policy/workspace.js +254 -0
- package/lib/pr/pr-context.js +292 -31
- package/lib/pr/pr-renderer.js +25 -10
- package/lib/rag/EmbeddingProvider.js +139 -2
- package/lib/rag/VectorStoreFactory.js +10 -4
- package/lib/rag/ask.js +291 -0
- package/lib/rag/sources/FileDataSource.js +6 -3
- package/lib/rag/stores/SqliteVectorStore.js +595 -0
- package/lib/search/hybrid.js +324 -0
- package/lib/search/symbols.js +75 -11
- package/lib/task/index.js +630 -0
- package/lib/utils/file-utils.js +1 -0
- package/lib/utils/logger.js +5 -2
- package/lib/utils/multi-repo-manager.js +4 -1
- package/lib/watch/FileWatcher.js +121 -31
- package/lib/watch/IncrementalAnalyzer.js +964 -16
- package/lib/who/index.js +893 -0
- package/lib/why/analyzer-bridge.js +223 -0
- package/lib/why/discussions.js +314 -0
- package/lib/why/index.js +356 -30
- package/lib/why/render.js +154 -9
- package/lib/wizards/config-generator.js +20 -18
- package/lib/wizards/project-detector.js +2 -1
- package/lib/workspace/index.js +1274 -0
- package/package.json +2 -2
package/lib/who/index.js
ADDED
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expert Finder (`ctxman who <target-or-topic>`)
|
|
3
|
+
*
|
|
4
|
+
* Answers "Who knows about this?" by combining:
|
|
5
|
+
* 1. Surviving line ownership (`git blame -w`, honoring `.git-blame-ignore-revs`)
|
|
6
|
+
* 2. Historical commit & line-churn depth with exponential recency decay
|
|
7
|
+
* 3. Declared stewardship from `CODEOWNERS` (`.github/CODEOWNERS`, `CODEOWNERS`, `docs/CODEOWNERS`)
|
|
8
|
+
* 4. Automatic BM25 topic-to-file resolution when `<target>` is a concept/query rather than a path
|
|
9
|
+
* 5. Multi-repository workspace aggregation (`--repo <dir>` / `repos`) across `<alias>/<path>`
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { findRepositoryRoot, tryGit, utcDate } from '../why/git.js';
|
|
15
|
+
import { collectOwnership } from '../why/ownership.js';
|
|
16
|
+
import { parseTargetSpec, resolveSymbol } from '../why/target.js';
|
|
17
|
+
import { searchProject } from '../search/bm25.js';
|
|
18
|
+
import { resolveWorkspaceRepos, searchWorkspace } from '../workspace/index.js';
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_WHO_LIMIT = 5;
|
|
21
|
+
export const DEFAULT_COMMIT_WINDOW = 200;
|
|
22
|
+
export const DEFAULT_HALF_LIFE_DAYS = 90;
|
|
23
|
+
const DAY_SECONDS = 86400;
|
|
24
|
+
|
|
25
|
+
export class WhoError extends Error {
|
|
26
|
+
constructor(message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'WhoError';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Convert a CODEOWNERS pattern into a RegExp matching repo-relative forward-slash paths.
|
|
34
|
+
*/
|
|
35
|
+
function codeownersPatternToRegex(pattern) {
|
|
36
|
+
let clean = pattern.trim();
|
|
37
|
+
const anchored = clean.startsWith('/');
|
|
38
|
+
if (anchored) clean = clean.slice(1);
|
|
39
|
+
const dirOnly = clean.endsWith('/');
|
|
40
|
+
if (dirOnly) clean = clean.slice(0, -1);
|
|
41
|
+
|
|
42
|
+
const escaped = clean
|
|
43
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
44
|
+
.replace(/\*\*/g, '\u0000')
|
|
45
|
+
.replace(/\*/g, '[^/]*')
|
|
46
|
+
.replace(/\?/g, '[^/]')
|
|
47
|
+
.replace(/\u0000/g, '.*');
|
|
48
|
+
|
|
49
|
+
if (dirOnly) {
|
|
50
|
+
return anchored ? new RegExp(`^${escaped}(?:/.*)?$`) : new RegExp(`(?:^|/)${escaped}(?:/.*)?$`);
|
|
51
|
+
}
|
|
52
|
+
const hasSlash = clean.includes('/');
|
|
53
|
+
if (anchored || hasSlash) {
|
|
54
|
+
return new RegExp(`^${escaped}(?:/.*)?$`);
|
|
55
|
+
}
|
|
56
|
+
return new RegExp(`(?:^|/)${escaped}(?:/.*)?$`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parse CODEOWNERS file if present in standard locations.
|
|
61
|
+
*/
|
|
62
|
+
export function loadCodeowners(repoRoot) {
|
|
63
|
+
const candidates = ['.github/CODEOWNERS', 'CODEOWNERS', 'docs/CODEOWNERS', '.gitlab/CODEOWNERS'];
|
|
64
|
+
for (const rel of candidates) {
|
|
65
|
+
const full = path.join(repoRoot, rel);
|
|
66
|
+
if (!fs.existsSync(full) || !fs.statSync(full).isFile()) continue;
|
|
67
|
+
const content = fs.readFileSync(full, 'utf8');
|
|
68
|
+
const rules = [];
|
|
69
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
70
|
+
const line = rawLine.replace(/#.*$/, '').trim();
|
|
71
|
+
if (!line) continue;
|
|
72
|
+
const parts = line.split(/\s+/);
|
|
73
|
+
if (parts.length < 2) continue;
|
|
74
|
+
const [pattern, ...owners] = parts;
|
|
75
|
+
rules.push({
|
|
76
|
+
pattern,
|
|
77
|
+
regex: codeownersPatternToRegex(pattern),
|
|
78
|
+
owners,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return { file: rel, rules };
|
|
82
|
+
}
|
|
83
|
+
return { file: null, rules: [] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve CODEOWNERS for a list of repo-relative forward-slash file paths (last matching rule wins).
|
|
88
|
+
*/
|
|
89
|
+
export function matchCodeowners(codeowners, relFiles) {
|
|
90
|
+
if (!codeowners.rules.length) return [];
|
|
91
|
+
const owners = new Set();
|
|
92
|
+
for (const file of relFiles) {
|
|
93
|
+
let matched = null;
|
|
94
|
+
for (const rule of codeowners.rules) {
|
|
95
|
+
if (rule.regex.test(file)) matched = rule.owners;
|
|
96
|
+
}
|
|
97
|
+
if (matched) {
|
|
98
|
+
for (const o of matched) owners.add(o);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return [...owners];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve user target (file, file:symbol, file#L1-L10, directory, or natural-language topic) into repo-relative files.
|
|
106
|
+
*/
|
|
107
|
+
function resolveTargetFiles(repoRoot, cwd, rawTarget, maxFiles = 8) {
|
|
108
|
+
const trimmed = String(rawTarget ?? '').trim();
|
|
109
|
+
if (!trimmed) {
|
|
110
|
+
throw new WhoError('A file, directory, symbol target, or topic query is required.');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const realRepoRoot = fs.existsSync(repoRoot) ? fs.realpathSync(repoRoot) : repoRoot;
|
|
114
|
+
const realCwd = fs.existsSync(cwd) ? fs.realpathSync(cwd) : cwd;
|
|
115
|
+
|
|
116
|
+
// 1. Check if rawTarget is an existing directory or file directly
|
|
117
|
+
const rawCandidate = path.resolve(realCwd, trimmed);
|
|
118
|
+
const directCandidate = fs.existsSync(rawCandidate)
|
|
119
|
+
? fs.realpathSync(rawCandidate)
|
|
120
|
+
: rawCandidate;
|
|
121
|
+
if (
|
|
122
|
+
(directCandidate === realRepoRoot || directCandidate.startsWith(realRepoRoot + path.sep)) &&
|
|
123
|
+
fs.existsSync(directCandidate)
|
|
124
|
+
) {
|
|
125
|
+
const stat = fs.statSync(directCandidate);
|
|
126
|
+
const rel = path.relative(realRepoRoot, directCandidate).split(path.sep).join('/');
|
|
127
|
+
if (stat.isFile()) {
|
|
128
|
+
return {
|
|
129
|
+
mode: 'file',
|
|
130
|
+
label: rel,
|
|
131
|
+
files: [rel],
|
|
132
|
+
lineRange: null,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (stat.isDirectory()) {
|
|
136
|
+
const tracked = tryGit(realRepoRoot, ['ls-files', '--', ...(rel ? [rel] : [])]);
|
|
137
|
+
const list = (tracked ?? '')
|
|
138
|
+
.split(/\r?\n/)
|
|
139
|
+
.map((s) => s.trim())
|
|
140
|
+
.filter(Boolean);
|
|
141
|
+
return {
|
|
142
|
+
mode: 'directory',
|
|
143
|
+
label: rel || '.',
|
|
144
|
+
files: list.slice(0, Math.max(maxFiles, 25)),
|
|
145
|
+
blameFiles: list.slice(0, maxFiles),
|
|
146
|
+
lineRange: null,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 2. Try `ctxman why` target syntax (`file:symbol` or `file#L10-L40`)
|
|
152
|
+
try {
|
|
153
|
+
const existsInCwd = (f) => {
|
|
154
|
+
const p = path.resolve(realCwd, f);
|
|
155
|
+
return fs.existsSync(p) && fs.statSync(p).isFile();
|
|
156
|
+
};
|
|
157
|
+
const spec = parseTargetSpec(trimmed, existsInCwd);
|
|
158
|
+
const specFull = path.resolve(realCwd, spec.file);
|
|
159
|
+
if (
|
|
160
|
+
fs.existsSync(specFull) &&
|
|
161
|
+
fs.statSync(specFull).isFile() &&
|
|
162
|
+
fs.realpathSync(specFull).startsWith(realRepoRoot + path.sep)
|
|
163
|
+
) {
|
|
164
|
+
const rel = path.relative(realRepoRoot, fs.realpathSync(specFull)).split(path.sep).join('/');
|
|
165
|
+
let lineRange =
|
|
166
|
+
spec.startLine && spec.endLine
|
|
167
|
+
? { startLine: spec.startLine, endLine: spec.endLine }
|
|
168
|
+
: null;
|
|
169
|
+
if (spec.symbol) {
|
|
170
|
+
const content = fs.readFileSync(specFull, 'utf8');
|
|
171
|
+
const sym = resolveSymbol(content, rel, spec.symbol);
|
|
172
|
+
if (sym && sym.startLine && sym.endLine) {
|
|
173
|
+
lineRange = { startLine: sym.startLine, endLine: sym.endLine };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
mode: spec.symbol ? 'symbol' : lineRange ? 'lines' : 'file',
|
|
178
|
+
label: spec.symbol
|
|
179
|
+
? `${rel}:${spec.symbol}`
|
|
180
|
+
: lineRange
|
|
181
|
+
? `${rel}#L${lineRange.startLine}-L${lineRange.endLine}`
|
|
182
|
+
: rel,
|
|
183
|
+
files: [rel],
|
|
184
|
+
lineRange,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
// Fall through to topic search when target is a natural-language topic rather than a path
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 3. Natural-language topic resolution via BM25 code search
|
|
192
|
+
const searchResult = searchProject(realRepoRoot, trimmed, {
|
|
193
|
+
limit: maxFiles * 2,
|
|
194
|
+
maxTokens: 4000,
|
|
195
|
+
});
|
|
196
|
+
const seen = new Set();
|
|
197
|
+
const topicFiles = [];
|
|
198
|
+
for (const hit of searchResult.results ?? []) {
|
|
199
|
+
const rel = String(hit.file ?? '')
|
|
200
|
+
.split(path.sep)
|
|
201
|
+
.join('/');
|
|
202
|
+
if (rel && !seen.has(rel)) {
|
|
203
|
+
seen.add(rel);
|
|
204
|
+
topicFiles.push(rel);
|
|
205
|
+
if (topicFiles.length >= maxFiles) break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (topicFiles.length === 0) {
|
|
210
|
+
throw new WhoError(`No matching files or symbols found for "${trimmed}".`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
mode: 'topic',
|
|
215
|
+
label: trimmed,
|
|
216
|
+
files: topicFiles,
|
|
217
|
+
lineRange: null,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Resolve user target across a multi-repository workspace (`<alias>/<path>`, file:symbol, directory, or topic).
|
|
223
|
+
*/
|
|
224
|
+
function resolveWorkspaceTargetFiles(reposWithGit, cwd, rawTarget, maxFiles = 8) {
|
|
225
|
+
const trimmed = String(rawTarget ?? '').trim();
|
|
226
|
+
if (!trimmed) {
|
|
227
|
+
throw new WhoError('A file, directory, symbol target, or topic query is required.');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const posixTarget = trimmed.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
231
|
+
const repoByAlias = new Map(reposWithGit.map((r) => [r.alias, r]));
|
|
232
|
+
|
|
233
|
+
// Helper to resolve a file or directory inside a specific repo
|
|
234
|
+
const tryDirectInRepo = (repo, candidateRel) => {
|
|
235
|
+
const full = path.resolve(repo.gitRoot, candidateRel);
|
|
236
|
+
const realFull = fs.existsSync(full) ? fs.realpathSync(full) : full;
|
|
237
|
+
if (
|
|
238
|
+
(realFull === repo.gitRoot || realFull.startsWith(repo.gitRoot + path.sep)) &&
|
|
239
|
+
fs.existsSync(realFull)
|
|
240
|
+
) {
|
|
241
|
+
const stat = fs.statSync(realFull);
|
|
242
|
+
const rel = path.relative(repo.gitRoot, realFull).split(path.sep).join('/');
|
|
243
|
+
if (stat.isFile()) {
|
|
244
|
+
return {
|
|
245
|
+
mode: 'file',
|
|
246
|
+
label: `${repo.alias}/${rel}`,
|
|
247
|
+
entries: [{ repo, rel, prefixed: `${repo.alias}/${rel}` }],
|
|
248
|
+
lineRange: null,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (stat.isDirectory()) {
|
|
252
|
+
const tracked = tryGit(repo.gitRoot, ['ls-files', '--', ...(rel ? [rel] : [])]);
|
|
253
|
+
const list = (tracked ?? '')
|
|
254
|
+
.split(/\r?\n/)
|
|
255
|
+
.map((s) => s.trim())
|
|
256
|
+
.filter(Boolean);
|
|
257
|
+
const allEntries = list
|
|
258
|
+
.slice(0, Math.max(maxFiles, 25))
|
|
259
|
+
.map((f) => ({ repo, rel: f, prefixed: `${repo.alias}/${f}` }));
|
|
260
|
+
return {
|
|
261
|
+
mode: 'directory',
|
|
262
|
+
label: rel ? `${repo.alias}/${rel}` : repo.alias,
|
|
263
|
+
entries: allEntries,
|
|
264
|
+
blameEntries: allEntries.slice(0, maxFiles),
|
|
265
|
+
lineRange: null,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// 1a. Check `<alias>` or `<alias>/<subpath>`
|
|
273
|
+
if (repoByAlias.has(posixTarget)) {
|
|
274
|
+
const matched = tryDirectInRepo(repoByAlias.get(posixTarget), '.');
|
|
275
|
+
if (matched && matched.entries.length > 0) return matched;
|
|
276
|
+
}
|
|
277
|
+
const slashIdx = posixTarget.indexOf('/');
|
|
278
|
+
if (slashIdx > 0) {
|
|
279
|
+
const prefixAlias = posixTarget.slice(0, slashIdx);
|
|
280
|
+
const rest = posixTarget.slice(slashIdx + 1);
|
|
281
|
+
const targetRepo = repoByAlias.get(prefixAlias);
|
|
282
|
+
if (targetRepo && rest) {
|
|
283
|
+
const direct = tryDirectInRepo(targetRepo, rest);
|
|
284
|
+
if (direct && direct.entries.length > 0) return direct;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 1b. Check direct path relative to cwd or across any workspace repo
|
|
289
|
+
const matchedDirectEntries = [];
|
|
290
|
+
let matchedDirectMode = null;
|
|
291
|
+
for (const repo of reposWithGit) {
|
|
292
|
+
const direct = tryDirectInRepo(repo, trimmed);
|
|
293
|
+
if (direct && direct.entries.length > 0) {
|
|
294
|
+
matchedDirectMode ??= direct.mode;
|
|
295
|
+
matchedDirectEntries.push(...direct.entries);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (matchedDirectEntries.length > 0) {
|
|
299
|
+
return {
|
|
300
|
+
mode: matchedDirectMode,
|
|
301
|
+
label: matchedDirectEntries.length === 1 ? matchedDirectEntries[0].prefixed : trimmed,
|
|
302
|
+
entries: matchedDirectEntries.slice(0, Math.max(maxFiles, 25)),
|
|
303
|
+
blameEntries: matchedDirectEntries.slice(0, maxFiles),
|
|
304
|
+
lineRange: null,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 2. Try `file:symbol` or `file#L10-L40` (with or without `<alias>/` prefix)
|
|
309
|
+
const specCandidates = [];
|
|
310
|
+
if (slashIdx > 0 && repoByAlias.has(posixTarget.slice(0, slashIdx))) {
|
|
311
|
+
specCandidates.push({
|
|
312
|
+
repo: repoByAlias.get(posixTarget.slice(0, slashIdx)),
|
|
313
|
+
rawSpec: posixTarget.slice(slashIdx + 1),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
for (const repo of reposWithGit) {
|
|
317
|
+
specCandidates.push({ repo, rawSpec: trimmed });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
for (const { repo, rawSpec } of specCandidates) {
|
|
321
|
+
try {
|
|
322
|
+
const existsInRepo = (f) => {
|
|
323
|
+
const p = path.resolve(repo.gitRoot, f);
|
|
324
|
+
return fs.existsSync(p) && fs.statSync(p).isFile();
|
|
325
|
+
};
|
|
326
|
+
const spec = parseTargetSpec(rawSpec, existsInRepo);
|
|
327
|
+
const specFull = path.resolve(repo.gitRoot, spec.file);
|
|
328
|
+
if (
|
|
329
|
+
fs.existsSync(specFull) &&
|
|
330
|
+
fs.statSync(specFull).isFile() &&
|
|
331
|
+
fs.realpathSync(specFull).startsWith(repo.gitRoot + path.sep)
|
|
332
|
+
) {
|
|
333
|
+
const rel = path
|
|
334
|
+
.relative(repo.gitRoot, fs.realpathSync(specFull))
|
|
335
|
+
.split(path.sep)
|
|
336
|
+
.join('/');
|
|
337
|
+
const prefixed = `${repo.alias}/${rel}`;
|
|
338
|
+
let lineRange =
|
|
339
|
+
spec.startLine && spec.endLine
|
|
340
|
+
? { startLine: spec.startLine, endLine: spec.endLine }
|
|
341
|
+
: null;
|
|
342
|
+
if (spec.symbol) {
|
|
343
|
+
const content = fs.readFileSync(specFull, 'utf8');
|
|
344
|
+
const sym = resolveSymbol(content, rel, spec.symbol);
|
|
345
|
+
if (sym && sym.startLine && sym.endLine) {
|
|
346
|
+
lineRange = { startLine: sym.startLine, endLine: sym.endLine };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
mode: spec.symbol ? 'symbol' : lineRange ? 'lines' : 'file',
|
|
351
|
+
label: spec.symbol
|
|
352
|
+
? `${prefixed}:${spec.symbol}`
|
|
353
|
+
: lineRange
|
|
354
|
+
? `${prefixed}#L${lineRange.startLine}-L${lineRange.endLine}`
|
|
355
|
+
: prefixed,
|
|
356
|
+
entries: [{ repo, rel, prefixed }],
|
|
357
|
+
lineRange,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
} catch {
|
|
361
|
+
// Continue trying next repo or fall through to workspace topic search
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// 3. Natural-language topic resolution via multi-repo BM25 search (`searchWorkspace`)
|
|
366
|
+
const searchResult = searchWorkspace(reposWithGit, trimmed, {
|
|
367
|
+
limit: maxFiles * 2,
|
|
368
|
+
maxTokens: 4000,
|
|
369
|
+
});
|
|
370
|
+
const seen = new Set();
|
|
371
|
+
const topicEntries = [];
|
|
372
|
+
for (const hit of searchResult.results ?? []) {
|
|
373
|
+
const prefixed = String(hit.file ?? '')
|
|
374
|
+
.split(path.sep)
|
|
375
|
+
.join('/');
|
|
376
|
+
if (!prefixed || seen.has(prefixed)) continue;
|
|
377
|
+
const hitRepo =
|
|
378
|
+
repoByAlias.get(hit.repo) ?? reposWithGit.find((r) => prefixed.startsWith(`${r.alias}/`));
|
|
379
|
+
if (!hitRepo) continue;
|
|
380
|
+
const rel = prefixed.startsWith(`${hitRepo.alias}/`)
|
|
381
|
+
? prefixed.slice(hitRepo.alias.length + 1)
|
|
382
|
+
: prefixed;
|
|
383
|
+
seen.add(prefixed);
|
|
384
|
+
topicEntries.push({ repo: hitRepo, rel, prefixed });
|
|
385
|
+
if (topicEntries.length >= maxFiles) break;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (topicEntries.length === 0) {
|
|
389
|
+
throw new WhoError(`No matching files or symbols found for "${trimmed}".`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
mode: 'topic',
|
|
394
|
+
label: trimmed,
|
|
395
|
+
entries: topicEntries,
|
|
396
|
+
lineRange: null,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Collect git commit & numstat history across target files.
|
|
402
|
+
*/
|
|
403
|
+
function collectCommitSignals(repoRoot, files, { commitWindow, since, nowSec, halfLifeDays }) {
|
|
404
|
+
if (!files.length) return new Map();
|
|
405
|
+
const args = [
|
|
406
|
+
'log',
|
|
407
|
+
'--no-merges',
|
|
408
|
+
'-n',
|
|
409
|
+
String(commitWindow),
|
|
410
|
+
'--numstat',
|
|
411
|
+
'--format=COMMIT\t%H\t%an\t%ae\t%at\t%s',
|
|
412
|
+
];
|
|
413
|
+
if (since) args.push(`--since=${since}`);
|
|
414
|
+
args.push('--', ...files);
|
|
415
|
+
|
|
416
|
+
const out = tryGit(repoRoot, args);
|
|
417
|
+
if (!out) return new Map();
|
|
418
|
+
|
|
419
|
+
const byAuthor = new Map();
|
|
420
|
+
let current = null;
|
|
421
|
+
const decayLambda = Math.LN2 / (Math.max(1, halfLifeDays) * DAY_SECONDS);
|
|
422
|
+
|
|
423
|
+
for (const line of out.split(/\r?\n/)) {
|
|
424
|
+
if (line.startsWith('COMMIT\t')) {
|
|
425
|
+
const [, sha, rawName, rawEmail, rawTs, subject] = line.split('\t');
|
|
426
|
+
const name = (rawName || '(unknown)').trim();
|
|
427
|
+
const email = (rawEmail || '').trim();
|
|
428
|
+
const ts = Number(rawTs) || 0;
|
|
429
|
+
const ageSec = Math.max(0, nowSec - ts);
|
|
430
|
+
const decay = ts > 0 ? Math.exp(-decayLambda * ageSec) : 0.1;
|
|
431
|
+
|
|
432
|
+
const entry = byAuthor.get(name) ?? {
|
|
433
|
+
name,
|
|
434
|
+
emails: new Set(),
|
|
435
|
+
commits: 0,
|
|
436
|
+
decayedCommits: 0,
|
|
437
|
+
additions: 0,
|
|
438
|
+
deletions: 0,
|
|
439
|
+
lastCommitTs: 0,
|
|
440
|
+
recentSubjects: [],
|
|
441
|
+
filesTouched: new Set(),
|
|
442
|
+
};
|
|
443
|
+
if (email) entry.emails.add(email);
|
|
444
|
+
entry.commits += 1;
|
|
445
|
+
entry.decayedCommits += decay;
|
|
446
|
+
if (ts > entry.lastCommitTs) entry.lastCommitTs = ts;
|
|
447
|
+
if (subject && entry.recentSubjects.length < 3) {
|
|
448
|
+
entry.recentSubjects.push({ sha: sha.slice(0, 7), date: utcDate(ts), subject });
|
|
449
|
+
}
|
|
450
|
+
byAuthor.set(name, entry);
|
|
451
|
+
current = entry;
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (current && line.trim()) {
|
|
456
|
+
const parts = line.split('\t');
|
|
457
|
+
if (parts.length >= 3) {
|
|
458
|
+
const add = Number(parts[0]);
|
|
459
|
+
const del = Number(parts[1]);
|
|
460
|
+
const file = parts[2];
|
|
461
|
+
if (Number.isFinite(add)) current.additions += add;
|
|
462
|
+
if (Number.isFinite(del)) current.deletions += del;
|
|
463
|
+
if (file) current.filesTouched.add(file);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return byAuthor;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Check if an author's name or email matches any CODEOWNERS handle/email.
|
|
473
|
+
*/
|
|
474
|
+
function authorMatchesCodeowners(name, emails, codeOwners) {
|
|
475
|
+
if (!codeOwners.length) return [];
|
|
476
|
+
const lowerName = name.toLowerCase().replace(/\s+/g, '');
|
|
477
|
+
const emailList = [...emails].map((e) => e.toLowerCase());
|
|
478
|
+
const matched = [];
|
|
479
|
+
for (const owner of codeOwners) {
|
|
480
|
+
const clean = owner.toLowerCase().replace(/^@/, '');
|
|
481
|
+
const handle = clean.includes('/') ? clean.split('/').pop() : clean;
|
|
482
|
+
if (
|
|
483
|
+
emailList.includes(clean) ||
|
|
484
|
+
emailList.some((e) => e.split('@')[0] === handle) ||
|
|
485
|
+
lowerName === handle ||
|
|
486
|
+
lowerName.includes(handle)
|
|
487
|
+
) {
|
|
488
|
+
matched.push(owner);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return matched;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Render Expert Finder result in markdown, xml, or json.
|
|
496
|
+
*/
|
|
497
|
+
export function renderExperts(data, style = 'markdown') {
|
|
498
|
+
if (style === 'json') {
|
|
499
|
+
return `${JSON.stringify(data, null, 2)}\n`;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (style === 'xml') {
|
|
503
|
+
const lines = [
|
|
504
|
+
`<experts target="${escapeXml(data.target.label)}" mode="${data.target.mode}">`,
|
|
505
|
+
` <files count="${data.target.files.length}">`,
|
|
506
|
+
...data.target.files.map((f) => ` <file>${escapeXml(f)}</file>`),
|
|
507
|
+
` </files>`,
|
|
508
|
+
];
|
|
509
|
+
if (data.codeOwners.owners.length > 0) {
|
|
510
|
+
lines.push(
|
|
511
|
+
` <codeowners source="${escapeXml(data.codeOwners.file ?? '')}">${data.codeOwners.owners.map(escapeXml).join(' ')}</codeowners>`
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
lines.push(' <ranked_experts>');
|
|
515
|
+
for (const exp of data.experts) {
|
|
516
|
+
lines.push(
|
|
517
|
+
` <expert name="${escapeXml(exp.name)}" email="${escapeXml(exp.email ?? '')}" score="${exp.score}" blame_share="${exp.blameShare}" commits="${exp.commits}" last_active="${exp.lastActive ?? ''}" codeowner="${exp.isCodeOwner}">`
|
|
518
|
+
);
|
|
519
|
+
for (const c of exp.recentCommits) {
|
|
520
|
+
lines.push(
|
|
521
|
+
` <commit sha="${c.sha}" date="${c.date ?? ''}">${escapeXml(c.subject)}</commit>`
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
lines.push(' </expert>');
|
|
525
|
+
}
|
|
526
|
+
lines.push(' </ranked_experts>', '</experts>\n');
|
|
527
|
+
return lines.join('\n');
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const out = [];
|
|
531
|
+
out.push(`# Experts for \`${data.target.label}\` (${data.target.mode})`);
|
|
532
|
+
out.push('');
|
|
533
|
+
if (Array.isArray(data.repos) && data.repos.length > 0) {
|
|
534
|
+
out.push(
|
|
535
|
+
`**Repositories (${data.repos.length}):** ${data.repos.map((r) => `\`${r.alias}\``).join(', ')}`
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
out.push(
|
|
539
|
+
`**Analyzed files (${data.target.files.length}):** ${data.target.files
|
|
540
|
+
.slice(0, 8)
|
|
541
|
+
.map((f) => `\`${f}\``)
|
|
542
|
+
.join(', ')}${data.target.files.length > 8 ? ` (+${data.target.files.length - 8} more)` : ''}`
|
|
543
|
+
);
|
|
544
|
+
if (data.codeOwners.owners.length > 0) {
|
|
545
|
+
out.push(
|
|
546
|
+
`**Declared CODEOWNERS (${data.codeOwners.file}):** ${data.codeOwners.owners.map((o) => `\`${o}\``).join(', ')}`
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
out.push('');
|
|
550
|
+
|
|
551
|
+
if (data.experts.length === 0) {
|
|
552
|
+
out.push('_No git authorship history found for the selected target._');
|
|
553
|
+
return `${out.join('\n')}\n`;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
out.push('| Rank | Expert | Score | Blame Share | Commits | Churn (+/-) | Last Active | Role |');
|
|
557
|
+
out.push('| ---: | :--- | ---: | ---: | ---: | ---: | :--- | :--- |');
|
|
558
|
+
data.experts.forEach((exp, idx) => {
|
|
559
|
+
const badge = exp.isCodeOwner
|
|
560
|
+
? 'CODEOWNER'
|
|
561
|
+
: exp.blameShare >= 0.4
|
|
562
|
+
? 'Primary Author'
|
|
563
|
+
: exp.recencyShare >= 0.35
|
|
564
|
+
? 'Active Maintainer'
|
|
565
|
+
: 'Contributor';
|
|
566
|
+
const label = exp.email ? `${exp.name} <${exp.email}>` : exp.name;
|
|
567
|
+
out.push(
|
|
568
|
+
`| ${idx + 1} | ${label} | ${(exp.score * 100).toFixed(1)}% | ${(exp.blameShare * 100).toFixed(1)}% | ${exp.commits} | +${exp.additions}/-${exp.deletions} | ${exp.lastActive ?? 'n/a'} | ${badge} |`
|
|
569
|
+
);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
out.push('');
|
|
573
|
+
out.push('## Key Recent Context by Expert');
|
|
574
|
+
for (const exp of data.experts.slice(0, 3)) {
|
|
575
|
+
if (!exp.recentCommits.length) continue;
|
|
576
|
+
out.push(`- **${exp.name}**:`);
|
|
577
|
+
for (const c of exp.recentCommits) {
|
|
578
|
+
out.push(` - \`${c.sha}\` (${c.date ?? 'unknown'}): ${c.subject}`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return `${out.join('\n')}\n`;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function escapeXml(str) {
|
|
586
|
+
return String(str)
|
|
587
|
+
.replace(/&/g, '&')
|
|
588
|
+
.replace(/</g, '<')
|
|
589
|
+
.replace(/>/g, '>')
|
|
590
|
+
.replace(/"/g, '"');
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function rankExpertSignals({ blameByAuthor, totalBlamedLines, commitSignals, matchedCodeOwners }) {
|
|
594
|
+
const allNames = new Set([...blameByAuthor.keys(), ...commitSignals.keys()]);
|
|
595
|
+
let totalDecayedCommits = 0;
|
|
596
|
+
let totalCommits = 0;
|
|
597
|
+
for (const entry of commitSignals.values()) {
|
|
598
|
+
totalDecayedCommits += entry.decayedCommits;
|
|
599
|
+
totalCommits += entry.commits;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const ranked = [];
|
|
603
|
+
for (const name of allNames) {
|
|
604
|
+
const blame = blameByAuthor.get(name) ?? { lines: 0, lastTouched: null };
|
|
605
|
+
const hist = commitSignals.get(name) ?? {
|
|
606
|
+
emails: new Set(),
|
|
607
|
+
commits: 0,
|
|
608
|
+
decayedCommits: 0,
|
|
609
|
+
additions: 0,
|
|
610
|
+
deletions: 0,
|
|
611
|
+
lastCommitTs: 0,
|
|
612
|
+
recentSubjects: [],
|
|
613
|
+
filesTouched: new Set(),
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
const blameShare = totalBlamedLines > 0 ? blame.lines / totalBlamedLines : 0;
|
|
617
|
+
const recencyShare = totalDecayedCommits > 0 ? hist.decayedCommits / totalDecayedCommits : 0;
|
|
618
|
+
const commitShare = totalCommits > 0 ? hist.commits / totalCommits : 0;
|
|
619
|
+
const ownerHandles = authorMatchesCodeowners(name, hist.emails, matchedCodeOwners);
|
|
620
|
+
const isCodeOwner = ownerHandles.length > 0;
|
|
621
|
+
|
|
622
|
+
const baseScore =
|
|
623
|
+
totalBlamedLines > 0 && totalCommits > 0
|
|
624
|
+
? 0.45 * blameShare + 0.35 * recencyShare + 0.2 * commitShare
|
|
625
|
+
: totalBlamedLines > 0
|
|
626
|
+
? blameShare
|
|
627
|
+
: 0.65 * recencyShare + 0.35 * commitShare;
|
|
628
|
+
const score = Math.min(1, Math.round((baseScore + (isCodeOwner ? 0.15 : 0)) * 1000) / 1000);
|
|
629
|
+
|
|
630
|
+
const email = [...hist.emails][0] ?? null;
|
|
631
|
+
const lastActive = utcDate(hist.lastCommitTs) ?? blame.lastTouched ?? null;
|
|
632
|
+
|
|
633
|
+
ranked.push({
|
|
634
|
+
name,
|
|
635
|
+
email,
|
|
636
|
+
score,
|
|
637
|
+
blameLines: blame.lines,
|
|
638
|
+
blameShare: Math.round(blameShare * 1000) / 1000,
|
|
639
|
+
commits: hist.commits,
|
|
640
|
+
commitShare: Math.round(commitShare * 1000) / 1000,
|
|
641
|
+
recencyShare: Math.round(recencyShare * 1000) / 1000,
|
|
642
|
+
additions: hist.additions,
|
|
643
|
+
deletions: hist.deletions,
|
|
644
|
+
lastActive,
|
|
645
|
+
isCodeOwner,
|
|
646
|
+
codeOwnerHandles: ownerHandles,
|
|
647
|
+
filesTouched: hist.filesTouched.size || (blame.lines > 0 ? 1 : 0),
|
|
648
|
+
recentCommits: hist.recentSubjects,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
ranked.sort(
|
|
653
|
+
(a, b) =>
|
|
654
|
+
b.score - a.score ||
|
|
655
|
+
b.commits - a.commits ||
|
|
656
|
+
b.blameLines - a.blameLines ||
|
|
657
|
+
a.name.localeCompare(b.name)
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
return ranked;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function buildWorkspaceRepoInputs(primaryRoot, extraRepos) {
|
|
664
|
+
const realPrimary = fs.existsSync(primaryRoot)
|
|
665
|
+
? fs.realpathSync(primaryRoot)
|
|
666
|
+
: path.resolve(primaryRoot);
|
|
667
|
+
const isContainerOfExtra = extraRepos.some((item) => {
|
|
668
|
+
const raw = typeof item === 'string' ? item : item?.path;
|
|
669
|
+
if (typeof raw !== 'string' || !raw.trim()) return false;
|
|
670
|
+
const resolved = path.resolve(raw);
|
|
671
|
+
const real = fs.existsSync(resolved) ? fs.realpathSync(resolved) : resolved;
|
|
672
|
+
return real !== realPrimary && real.startsWith(realPrimary + path.sep);
|
|
673
|
+
});
|
|
674
|
+
return isContainerOfExtra ? [...extraRepos] : [primaryRoot, ...extraRepos];
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Find ranked code/domain experts for a file, symbol, directory, or natural-language topic.
|
|
679
|
+
*/
|
|
680
|
+
export async function findExperts(projectDir, target, options = {}) {
|
|
681
|
+
const cwd = path.resolve(projectDir || process.cwd());
|
|
682
|
+
const limit = Math.max(1, Number(options.limit) || DEFAULT_WHO_LIMIT);
|
|
683
|
+
const commitWindow = Math.max(1, Number(options.commitWindow) || DEFAULT_COMMIT_WINDOW);
|
|
684
|
+
const halfLifeDays = Math.max(1, Number(options.halfLifeDays) || DEFAULT_HALF_LIFE_DAYS);
|
|
685
|
+
const style = options.style || 'markdown';
|
|
686
|
+
const nowSec = Math.floor((options.nowMs ?? Date.now()) / 1000);
|
|
687
|
+
const isWorkspace = Array.isArray(options.repos) && options.repos.length > 0;
|
|
688
|
+
|
|
689
|
+
if (isWorkspace) {
|
|
690
|
+
const resolvedRepos = resolveWorkspaceRepos(buildWorkspaceRepoInputs(cwd, options.repos));
|
|
691
|
+
const reposWithGit = resolvedRepos.map((repo) => {
|
|
692
|
+
let gitRoot;
|
|
693
|
+
try {
|
|
694
|
+
gitRoot = findRepositoryRoot(repo.root);
|
|
695
|
+
} catch {
|
|
696
|
+
throw new WhoError(`"${repo.root}" is not inside a git repository.`);
|
|
697
|
+
}
|
|
698
|
+
if (!gitRoot) {
|
|
699
|
+
throw new WhoError(`"${repo.root}" is not inside a git repository.`);
|
|
700
|
+
}
|
|
701
|
+
return {
|
|
702
|
+
...repo,
|
|
703
|
+
gitRoot: fs.existsSync(gitRoot) ? fs.realpathSync(gitRoot) : gitRoot,
|
|
704
|
+
};
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
const resolved = resolveWorkspaceTargetFiles(reposWithGit, cwd, target);
|
|
708
|
+
const blameEntries = resolved.blameEntries ?? resolved.entries.slice(0, 8);
|
|
709
|
+
|
|
710
|
+
const blameByAuthor = new Map();
|
|
711
|
+
let totalBlamedLines = 0;
|
|
712
|
+
for (const item of blameEntries) {
|
|
713
|
+
const ownership = collectOwnership(item.repo.gitRoot, {
|
|
714
|
+
path: item.rel,
|
|
715
|
+
startLine: resolved.lineRange?.startLine,
|
|
716
|
+
endLine: resolved.lineRange?.endLine,
|
|
717
|
+
});
|
|
718
|
+
if (!ownership) continue;
|
|
719
|
+
for (const a of ownership.authors) {
|
|
720
|
+
if (a.name === '(uncommitted)') continue;
|
|
721
|
+
totalBlamedLines += a.lines;
|
|
722
|
+
const cur = blameByAuthor.get(a.name) ?? { lines: 0, lastTouched: null };
|
|
723
|
+
cur.lines += a.lines;
|
|
724
|
+
if (a.lastTouched && (!cur.lastTouched || a.lastTouched > cur.lastTouched)) {
|
|
725
|
+
cur.lastTouched = a.lastTouched;
|
|
726
|
+
}
|
|
727
|
+
blameByAuthor.set(a.name, cur);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const entriesByRepo = new Map();
|
|
732
|
+
for (const item of resolved.entries) {
|
|
733
|
+
const list = entriesByRepo.get(item.repo.alias) ?? [];
|
|
734
|
+
list.push(item.rel);
|
|
735
|
+
entriesByRepo.set(item.repo.alias, list);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const commitSignals = new Map();
|
|
739
|
+
const matchedOwnersSet = new Set();
|
|
740
|
+
const codeOwnerFiles = [];
|
|
741
|
+
|
|
742
|
+
for (const repo of reposWithGit) {
|
|
743
|
+
const relFiles = entriesByRepo.get(repo.alias);
|
|
744
|
+
if (!relFiles || relFiles.length === 0) continue;
|
|
745
|
+
|
|
746
|
+
const repoSignals = collectCommitSignals(repo.gitRoot, relFiles, {
|
|
747
|
+
commitWindow,
|
|
748
|
+
since: options.since,
|
|
749
|
+
nowSec,
|
|
750
|
+
halfLifeDays,
|
|
751
|
+
});
|
|
752
|
+
for (const [authorName, entry] of repoSignals.entries()) {
|
|
753
|
+
const merged = commitSignals.get(authorName) ?? {
|
|
754
|
+
name: authorName,
|
|
755
|
+
emails: new Set(),
|
|
756
|
+
commits: 0,
|
|
757
|
+
decayedCommits: 0,
|
|
758
|
+
additions: 0,
|
|
759
|
+
deletions: 0,
|
|
760
|
+
lastCommitTs: 0,
|
|
761
|
+
recentSubjects: [],
|
|
762
|
+
filesTouched: new Set(),
|
|
763
|
+
};
|
|
764
|
+
for (const e of entry.emails) merged.emails.add(e);
|
|
765
|
+
merged.commits += entry.commits;
|
|
766
|
+
merged.decayedCommits += entry.decayedCommits;
|
|
767
|
+
merged.additions += entry.additions;
|
|
768
|
+
merged.deletions += entry.deletions;
|
|
769
|
+
if (entry.lastCommitTs > merged.lastCommitTs) merged.lastCommitTs = entry.lastCommitTs;
|
|
770
|
+
for (const f of entry.filesTouched) merged.filesTouched.add(`${repo.alias}/${f}`);
|
|
771
|
+
for (const s of entry.recentSubjects) {
|
|
772
|
+
if (!merged.recentSubjects.some((existing) => existing.sha === s.sha)) {
|
|
773
|
+
merged.recentSubjects.push(s);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
merged.recentSubjects.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
|
|
777
|
+
if (merged.recentSubjects.length > 3) merged.recentSubjects.length = 3;
|
|
778
|
+
commitSignals.set(authorName, merged);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const co = loadCodeowners(repo.gitRoot);
|
|
782
|
+
const owners = matchCodeowners(co, relFiles);
|
|
783
|
+
if (owners.length > 0 && co.file) {
|
|
784
|
+
codeOwnerFiles.push(`${repo.alias}/${co.file}`);
|
|
785
|
+
for (const o of owners) matchedOwnersSet.add(o);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const matchedCodeOwners = [...matchedOwnersSet];
|
|
790
|
+
const ranked = rankExpertSignals({
|
|
791
|
+
blameByAuthor,
|
|
792
|
+
totalBlamedLines,
|
|
793
|
+
commitSignals,
|
|
794
|
+
matchedCodeOwners,
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
const reposMeta = reposWithGit.map(({ alias, root }) => ({ alias, root }));
|
|
798
|
+
const data = {
|
|
799
|
+
repos: reposMeta,
|
|
800
|
+
target: {
|
|
801
|
+
query: String(target),
|
|
802
|
+
mode: resolved.mode,
|
|
803
|
+
label: resolved.label,
|
|
804
|
+
files: resolved.entries.map((e) => e.prefixed),
|
|
805
|
+
},
|
|
806
|
+
codeOwners: {
|
|
807
|
+
file: codeOwnerFiles.length > 0 ? codeOwnerFiles.join(', ') : null,
|
|
808
|
+
owners: matchedCodeOwners,
|
|
809
|
+
},
|
|
810
|
+
totalAuthors: ranked.length,
|
|
811
|
+
experts: ranked.slice(0, limit),
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
return {
|
|
815
|
+
repos: reposMeta,
|
|
816
|
+
data,
|
|
817
|
+
experts: data.experts,
|
|
818
|
+
text: renderExperts(data, style),
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
let repoRoot;
|
|
823
|
+
try {
|
|
824
|
+
repoRoot = findRepositoryRoot(cwd);
|
|
825
|
+
} catch {
|
|
826
|
+
throw new WhoError(`"${cwd}" is not inside a git repository.`);
|
|
827
|
+
}
|
|
828
|
+
if (!repoRoot) {
|
|
829
|
+
throw new WhoError(`"${cwd}" is not inside a git repository.`);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const resolved = resolveTargetFiles(repoRoot, cwd, target);
|
|
833
|
+
const blameTargets = resolved.blameFiles ?? resolved.files.slice(0, 8);
|
|
834
|
+
|
|
835
|
+
const blameByAuthor = new Map();
|
|
836
|
+
let totalBlamedLines = 0;
|
|
837
|
+
for (const relFile of blameTargets) {
|
|
838
|
+
const ownership = collectOwnership(repoRoot, {
|
|
839
|
+
path: relFile,
|
|
840
|
+
startLine: resolved.lineRange?.startLine,
|
|
841
|
+
endLine: resolved.lineRange?.endLine,
|
|
842
|
+
});
|
|
843
|
+
if (!ownership) continue;
|
|
844
|
+
for (const a of ownership.authors) {
|
|
845
|
+
if (a.name === '(uncommitted)') continue;
|
|
846
|
+
totalBlamedLines += a.lines;
|
|
847
|
+
const cur = blameByAuthor.get(a.name) ?? { lines: 0, lastTouched: null };
|
|
848
|
+
cur.lines += a.lines;
|
|
849
|
+
if (a.lastTouched && (!cur.lastTouched || a.lastTouched > cur.lastTouched)) {
|
|
850
|
+
cur.lastTouched = a.lastTouched;
|
|
851
|
+
}
|
|
852
|
+
blameByAuthor.set(a.name, cur);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const commitSignals = collectCommitSignals(repoRoot, resolved.files, {
|
|
857
|
+
commitWindow,
|
|
858
|
+
since: options.since,
|
|
859
|
+
nowSec,
|
|
860
|
+
halfLifeDays,
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
const codeowners = loadCodeowners(repoRoot);
|
|
864
|
+
const matchedCodeOwners = matchCodeowners(codeowners, resolved.files);
|
|
865
|
+
|
|
866
|
+
const ranked = rankExpertSignals({
|
|
867
|
+
blameByAuthor,
|
|
868
|
+
totalBlamedLines,
|
|
869
|
+
commitSignals,
|
|
870
|
+
matchedCodeOwners,
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
const data = {
|
|
874
|
+
target: {
|
|
875
|
+
query: String(target),
|
|
876
|
+
mode: resolved.mode,
|
|
877
|
+
label: resolved.label,
|
|
878
|
+
files: resolved.files,
|
|
879
|
+
},
|
|
880
|
+
codeOwners: {
|
|
881
|
+
file: codeowners.file,
|
|
882
|
+
owners: matchedCodeOwners,
|
|
883
|
+
},
|
|
884
|
+
totalAuthors: ranked.length,
|
|
885
|
+
experts: ranked.slice(0, limit),
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
return {
|
|
889
|
+
data,
|
|
890
|
+
experts: data.experts,
|
|
891
|
+
text: renderExperts(data, style),
|
|
892
|
+
};
|
|
893
|
+
}
|