ctxman 3.1.1 → 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.
Files changed (53) hide show
  1. package/.ctxman/llm-profiles.json +6 -6
  2. package/README.md +16 -10
  3. package/bin/cli.js +107 -8
  4. package/bin/cm-ask.js +64 -6
  5. package/bin/cm-audit.js +76 -0
  6. package/bin/cm-diagnose.js +199 -0
  7. package/bin/cm-focus.js +322 -0
  8. package/bin/cm-map.js +71 -18
  9. package/bin/cm-pack.js +36 -5
  10. package/bin/cm-policy.js +107 -2
  11. package/bin/cm-pr.js +18 -3
  12. package/bin/cm-search.js +84 -19
  13. package/bin/cm-task.js +192 -0
  14. package/bin/cm-who.js +119 -0
  15. package/bin/cm-why.js +41 -5
  16. package/index.d.ts +931 -1
  17. package/index.js +105 -1
  18. package/lib/analyzers/token-calculator.js +76 -4
  19. package/lib/api/mcp/server.js +618 -16
  20. package/lib/cache/CacheManager.js +161 -47
  21. package/lib/core/Analyzer.js +61 -5
  22. package/lib/diagnose/index.js +712 -0
  23. package/lib/focus/index.js +790 -0
  24. package/lib/map/call-graph.js +570 -0
  25. package/lib/map/graph.js +30 -5
  26. package/lib/map/index.js +12 -4
  27. package/lib/map/rank.js +45 -7
  28. package/lib/map/render.js +89 -28
  29. package/lib/pack/index.js +20 -1
  30. package/lib/pack/select.js +8 -1
  31. package/lib/policy/audit.js +3 -1
  32. package/lib/policy/index.js +5 -0
  33. package/lib/policy/workspace.js +254 -0
  34. package/lib/pr/pr-context.js +292 -31
  35. package/lib/pr/pr-renderer.js +25 -10
  36. package/lib/rag/EmbeddingProvider.js +139 -2
  37. package/lib/rag/VectorStoreFactory.js +10 -4
  38. package/lib/rag/ask.js +186 -17
  39. package/lib/rag/stores/SqliteVectorStore.js +595 -0
  40. package/lib/search/hybrid.js +324 -0
  41. package/lib/search/symbols.js +75 -11
  42. package/lib/task/index.js +630 -0
  43. package/lib/utils/file-utils.js +1 -0
  44. package/lib/utils/multi-repo-manager.js +4 -1
  45. package/lib/watch/FileWatcher.js +121 -31
  46. package/lib/watch/IncrementalAnalyzer.js +964 -16
  47. package/lib/who/index.js +893 -0
  48. package/lib/why/analyzer-bridge.js +223 -0
  49. package/lib/why/discussions.js +314 -0
  50. package/lib/why/index.js +356 -30
  51. package/lib/why/render.js +154 -9
  52. package/lib/workspace/index.js +1274 -0
  53. package/package.json +1 -1
@@ -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, '&amp;')
588
+ .replace(/</g, '&lt;')
589
+ .replace(/>/g, '&gt;')
590
+ .replace(/"/g, '&quot;');
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
+ }