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.
Files changed (60) hide show
  1. package/.ctxman/llm-profiles.json +6 -6
  2. package/README.md +17 -10
  3. package/bin/cli.js +107 -8
  4. package/bin/cm-ask.js +74 -70
  5. package/bin/cm-audit.js +80 -7
  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/examples/github-actions/ctxman-check.yml +1 -1
  17. package/index.d.ts +931 -1
  18. package/index.js +105 -1
  19. package/lib/analyzers/performance-dashboard.js +7 -3
  20. package/lib/analyzers/token-calculator.js +76 -4
  21. package/lib/api/mcp/server.js +713 -14
  22. package/lib/cache/CacheManager.js +161 -47
  23. package/lib/core/Analyzer.js +61 -5
  24. package/lib/diagnose/index.js +712 -0
  25. package/lib/focus/index.js +790 -0
  26. package/lib/map/call-graph.js +570 -0
  27. package/lib/map/graph.js +30 -5
  28. package/lib/map/index.js +12 -4
  29. package/lib/map/rank.js +45 -7
  30. package/lib/map/render.js +89 -28
  31. package/lib/pack/index.js +20 -1
  32. package/lib/pack/select.js +8 -1
  33. package/lib/parsers/gitignore-parser.js +4 -1
  34. package/lib/policy/audit.js +22 -2
  35. package/lib/policy/index.js +6 -0
  36. package/lib/policy/workspace.js +254 -0
  37. package/lib/pr/pr-context.js +292 -31
  38. package/lib/pr/pr-renderer.js +25 -10
  39. package/lib/rag/EmbeddingProvider.js +139 -2
  40. package/lib/rag/VectorStoreFactory.js +10 -4
  41. package/lib/rag/ask.js +291 -0
  42. package/lib/rag/sources/FileDataSource.js +6 -3
  43. package/lib/rag/stores/SqliteVectorStore.js +595 -0
  44. package/lib/search/hybrid.js +324 -0
  45. package/lib/search/symbols.js +75 -11
  46. package/lib/task/index.js +630 -0
  47. package/lib/utils/file-utils.js +1 -0
  48. package/lib/utils/logger.js +5 -2
  49. package/lib/utils/multi-repo-manager.js +4 -1
  50. package/lib/watch/FileWatcher.js +121 -31
  51. package/lib/watch/IncrementalAnalyzer.js +964 -16
  52. package/lib/who/index.js +893 -0
  53. package/lib/why/analyzer-bridge.js +223 -0
  54. package/lib/why/discussions.js +314 -0
  55. package/lib/why/index.js +356 -30
  56. package/lib/why/render.js +154 -9
  57. package/lib/wizards/config-generator.js +20 -18
  58. package/lib/wizards/project-detector.js +2 -1
  59. package/lib/workspace/index.js +1274 -0
  60. package/package.json +2 -2
package/lib/why/index.js CHANGED
@@ -2,17 +2,18 @@
2
2
  * ctxman why - "why is the code like this?" context from local git history
3
3
  *
4
4
  * For a file, a symbol (`file:symbol`) or a line range (`file#L10-L40`) this collects the
5
- * commits that shaped it, the issues / tickets / URLs those commits reference, blame
6
- * ownership, files that change together with it, docs / ADRs / changelog entries / code
7
- * comments that mention it, TODO-style notes inside it and the tests that reference it,
8
- * and renders all of it as one document for an LLM (or a person). Everything comes from
9
- * the repository itself: no network access.
5
+ * commits that shaped it, the issues / tickets / URLs those commits reference (and
6
+ * optionally their GitHub / GitLab issue, PR & MR discussions when `discussions: true`),
7
+ * blame ownership, files that change together with it, docs / ADRs / changelog entries /
8
+ * code comments that mention it, TODO-style notes inside it and the tests that reference
9
+ * it, and renders all of it as one document for an LLM (or a person). Offline by default.
10
10
  */
11
11
 
12
12
  import fs from 'fs';
13
13
  import path from 'path';
14
14
  import { isValidGitRef } from '../integrations/git/GitClient.js';
15
15
  import { redactSecrets, formatRedactionSummary } from '../utils/secret-redactor.js';
16
+ import { resolveWorkspaceRepos } from '../workspace/index.js';
16
17
  import { WhyError } from './errors.js';
17
18
  import {
18
19
  findRepositoryRoot,
@@ -35,32 +36,58 @@ import {
35
36
  } from './target.js';
36
37
  import { collectHistory, funcnamePattern } from './history.js';
37
38
  import { mineReferences, parseRemote, referencesOf } from './references.js';
39
+ import {
40
+ fetchDiscussions,
41
+ DEFAULT_MAX_DISCUSSIONS,
42
+ DEFAULT_MAX_COMMENTS,
43
+ DEFAULT_DISCUSSION_TIMEOUT_MS,
44
+ } from './discussions.js';
38
45
  import { collectOwnership } from './ownership.js';
39
46
  import { collectCoupling } from './coupling.js';
40
47
  import { findNotes, searchRelated } from './search.js';
41
48
  import { STYLES, renderWithinBudget } from './render.js';
49
+ import { queryAnalyzerCitations, resolveAnalyzerDbPath } from './analyzer-bridge.js';
42
50
 
43
- export { WhyError, findRepositoryRoot, parseTargetSpec, parseRemote, mineReferences };
51
+ export {
52
+ WhyError,
53
+ queryAnalyzerCitations,
54
+ findRepositoryRoot,
55
+ parseTargetSpec,
56
+ parseRemote,
57
+ mineReferences,
58
+ fetchDiscussions,
59
+ };
44
60
 
45
61
  const DEFAULTS = {
46
62
  limit: 20,
47
63
  since: null,
48
64
  rev: null,
49
65
  patches: false,
66
+ discussions: false,
67
+ maxDiscussions: DEFAULT_MAX_DISCUSSIONS,
68
+ maxComments: DEFAULT_MAX_COMMENTS,
69
+ discussionTimeoutMs: DEFAULT_DISCUSSION_TIMEOUT_MS,
70
+ token: null,
71
+ fetch: null,
72
+ env: null,
50
73
  couplingWindow: 500,
51
74
  couplingTop: 10,
52
75
  maxFilesPerCommit: 50,
53
76
  maxRelated: 20,
54
77
  maxTests: 10,
55
78
  style: 'markdown',
79
+ analyzerDb: null,
80
+ analyzer: false,
56
81
  budget: null,
57
82
  ask: null,
58
83
  redact: true,
59
84
  cwd: null,
85
+ repos: null,
60
86
  };
61
87
 
62
88
  const MAX_NOTES = 50;
63
89
  const NEVER_COMMITTED = 'the file is staged but has never been committed';
90
+ const RELATED_KIND_ORDER = { adr: 0, changelog: 1, doc: 2, comment: 3 };
64
91
 
65
92
  // File names too common to search for on their own: `index.js` becomes `cart/index.js`
66
93
  const GENERIC_STEMS = new Set([
@@ -112,6 +139,14 @@ function normalizeOptions(options) {
112
139
  }
113
140
 
114
141
  opts.limit = integerOption('limit', opts.limit, 1, 10000);
142
+ opts.maxDiscussions = integerOption('maxDiscussions', opts.maxDiscussions, 0, 100);
143
+ opts.maxComments = integerOption('maxComments', opts.maxComments, 0, 50);
144
+ opts.discussionTimeoutMs = integerOption(
145
+ 'discussionTimeoutMs',
146
+ opts.discussionTimeoutMs,
147
+ 100,
148
+ 60000
149
+ );
115
150
  opts.couplingWindow = integerOption('couplingWindow', opts.couplingWindow, 0, 100000);
116
151
  opts.couplingTop = integerOption('couplingTop', opts.couplingTop, 1, 1000);
117
152
  opts.maxFilesPerCommit = integerOption('maxFilesPerCommit', opts.maxFilesPerCommit, 1, 100000);
@@ -122,12 +157,15 @@ function normalizeOptions(options) {
122
157
  if (!STYLES.includes(opts.style)) {
123
158
  throw new WhyError(
124
159
  'INVALID_OPTION',
125
- `style must be one of ${STYLES.join(', ')} (got ${JSON.stringify(opts.style)})`
160
+ `style must be one of ${STYLES.join(', ')}(got ${JSON.stringify(opts.style)})`.replace(
161
+ ')(',
162
+ ') ('
163
+ )
126
164
  );
127
165
  }
128
166
  if (opts.since !== null) {
129
167
  const since = String(opts.since).trim();
130
- if (!since || since.length > 100 || since.startsWith('-') || !/^[\w .:+\-/,]+$/.test(since)) {
168
+ if (!since || since.length > 100 || since.startsWith('-') || !/^[\w .:+/\-,]+$/.test(since)) {
131
169
  throw new WhyError('INVALID_OPTION', `Invalid --since date: ${JSON.stringify(opts.since)}`);
132
170
  }
133
171
  opts.since = since;
@@ -139,6 +177,7 @@ function normalizeOptions(options) {
139
177
  opts.ask = String(opts.ask).trim() || null;
140
178
  }
141
179
  opts.patches = Boolean(opts.patches);
180
+ opts.discussions = Boolean(opts.discussions);
142
181
  opts.redact = opts.redact !== false;
143
182
  return opts;
144
183
  }
@@ -210,13 +249,17 @@ function objectTarget(target) {
210
249
  /**
211
250
  * The project, its repository and the rules that apply to it
212
251
  */
213
- function openProject(root, opts) {
252
+ function openProject(root, opts, { checkRev = true } = {}) {
214
253
  if (typeof root !== 'string' || !fs.statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
215
254
  throw new WhyError('ROOT_NOT_FOUND', `Project root is not a directory: ${root}`);
216
255
  }
217
256
  const projectRoot = fs.realpathSync(root);
218
257
  const top = findRepositoryRoot(projectRoot);
219
- if (opts.rev && !tryGit(top, ['rev-parse', '--verify', '--quiet', `${opts.rev}^{commit}`])) {
258
+ if (
259
+ checkRev &&
260
+ opts.rev &&
261
+ !tryGit(top, ['rev-parse', '--verify', '--quiet', `${opts.rev}^{commit}`])
262
+ ) {
220
263
  throw new WhyError('UNKNOWN_REF', `Unknown revision: ${opts.rev}`);
221
264
  }
222
265
  const info = readRepositoryInfo(top);
@@ -238,6 +281,126 @@ function openProject(root, opts) {
238
281
  };
239
282
  }
240
283
 
284
+ function isGitWorkTree(dir) {
285
+ try {
286
+ return Boolean(findRepositoryRoot(dir));
287
+ } catch {
288
+ return false;
289
+ }
290
+ }
291
+
292
+ function buildWorkspaceRepoInputs(primaryRoot, extraRepos) {
293
+ const realPrimary = fs.existsSync(primaryRoot)
294
+ ? fs.realpathSync(primaryRoot)
295
+ : path.resolve(primaryRoot);
296
+ const isContainerOfExtra = extraRepos.some((item) => {
297
+ const raw = typeof item === 'string' ? item : item?.path;
298
+ if (typeof raw !== 'string' || !raw.trim()) return false;
299
+ const resolved = path.resolve(raw);
300
+ const real = fs.existsSync(resolved) ? fs.realpathSync(resolved) : resolved;
301
+ return real !== realPrimary && real.startsWith(realPrimary + path.sep);
302
+ });
303
+ if (isContainerOfExtra || !isGitWorkTree(realPrimary)) {
304
+ return [...extraRepos];
305
+ }
306
+ return [primaryRoot, ...extraRepos];
307
+ }
308
+
309
+ function resolveWorkspaceWhyTarget(workspaceRepos, target, opts) {
310
+ const repoByAlias = new Map(workspaceRepos.map((r) => [r.alias, r]));
311
+ const realCwd =
312
+ opts.cwd && fs.existsSync(opts.cwd)
313
+ ? fs.realpathSync(opts.cwd)
314
+ : opts.cwd
315
+ ? path.resolve(opts.cwd)
316
+ : null;
317
+ const cwdRepo = realCwd
318
+ ? workspaceRepos.find(
319
+ (r) => realCwd === r.realRoot || realCwd.startsWith(r.realRoot + path.sep)
320
+ )
321
+ : null;
322
+
323
+ // 1. Check explicit `<alias>/<rest>` prefix
324
+ if (typeof target === 'string') {
325
+ const posix = target.replace(/\\/g, '/').replace(/^\/+/, '');
326
+ const slashIdx = posix.indexOf('/');
327
+ if (slashIdx > 0) {
328
+ const alias = posix.slice(0, slashIdx);
329
+ const rest = posix.slice(slashIdx + 1);
330
+ const matched = repoByAlias.get(alias);
331
+ if (matched && rest) {
332
+ matched.project.base = matched.project.projectRoot;
333
+ return { targetRepo: matched, innerTarget: rest };
334
+ }
335
+ }
336
+ } else if (target && typeof target === 'object' && typeof target.file === 'string') {
337
+ const posix = target.file.replace(/\\/g, '/').replace(/^\/+/, '');
338
+ const slashIdx = posix.indexOf('/');
339
+ if (slashIdx > 0) {
340
+ const alias = posix.slice(0, slashIdx);
341
+ const rest = posix.slice(slashIdx + 1);
342
+ const matched = repoByAlias.get(alias);
343
+ if (matched && rest) {
344
+ matched.project.base = matched.project.projectRoot;
345
+ return { targetRepo: matched, innerTarget: { ...target, file: rest } };
346
+ }
347
+ }
348
+ }
349
+
350
+ // 2. Unprefixed target: check cwdRepo first (if cwd is inside a workspace repo), then each repo
351
+ const ordered = cwdRepo
352
+ ? [cwdRepo, ...workspaceRepos.filter((r) => r.alias !== cwdRepo.alias)]
353
+ : workspaceRepos;
354
+
355
+ const hasCandidateInRepo = (repo, useCwdBase) => {
356
+ const base = useCwdBase ? realCwd : repo.project.projectRoot;
357
+ try {
358
+ const spec =
359
+ typeof target === 'string'
360
+ ? parseTargetSpec(target, (file) =>
361
+ Boolean(fs.statSync(path.resolve(base, file), { throwIfNoEntry: false })?.isFile())
362
+ )
363
+ : objectTarget(target);
364
+ const located = resolveInsideRoot(repo.project.projectRoot, base, spec.file, {
365
+ mustExist: false,
366
+ });
367
+ const gitPath = toGitPath(path.relative(repo.project.top, located.absolute));
368
+ if (opts.rev) {
369
+ return (
370
+ tryGit(repo.project.top, ['cat-file', '-t', `${opts.rev}:${gitPath}`])?.trim() === 'blob'
371
+ );
372
+ }
373
+ if (fs.statSync(located.absolute, { throwIfNoEntry: false })?.isFile()) {
374
+ return true;
375
+ }
376
+ const deleted = tryGit(repo.project.top, [
377
+ 'log',
378
+ '-n',
379
+ '1',
380
+ '--format=%h',
381
+ '--',
382
+ literal(gitPath),
383
+ ])?.trim();
384
+ return Boolean(deleted);
385
+ } catch {
386
+ return false;
387
+ }
388
+ };
389
+
390
+ for (const repo of ordered) {
391
+ const useCwdBase = Boolean(cwdRepo && repo.alias === cwdRepo.alias);
392
+ if (hasCandidateInRepo(repo, useCwdBase)) {
393
+ repo.project.base = useCwdBase ? realCwd : repo.project.projectRoot;
394
+ return { targetRepo: repo, innerTarget: target };
395
+ }
396
+ }
397
+
398
+ const fallback = cwdRepo ?? workspaceRepos[0];
399
+ fallback.project.base =
400
+ cwdRepo && fallback.alias === cwdRepo.alias ? realCwd : fallback.project.projectRoot;
401
+ return { targetRepo: fallback, innerTarget: target };
402
+ }
403
+
241
404
  /**
242
405
  * Point a missing file at the commit that removed (or renamed) it, if history has one
243
406
  * @param {object} project
@@ -579,12 +742,19 @@ function couplingSection(project, file, opts) {
579
742
  * inside it; its .gitignore / .contextignore / .contextinclude apply
580
743
  * @param {string|{file: string, symbol?: string, startLine?: number, endLine?: number}} target
581
744
  * `file`, `file:symbol` (function, method, class; `Class.method` too), `file#L10-L40`
582
- * or `file:10-40`
745
+ * or `file:10-40` (or `<alias>/<file>...` when `options.repos` is set)
583
746
  * @param {object} [options]
584
747
  * @param {number} [options.limit=20] - Commits to include
585
748
  * @param {string} [options.since] - Only commits after this date (git date syntax)
586
749
  * @param {string} [options.rev] - Explain the code as of this revision instead of the work tree
587
750
  * @param {boolean} [options.patches=false] - Include each commit's diff of the target
751
+ * @param {boolean} [options.discussions=false] - Fetch referenced GitHub/GitLab issue/PR/MR discussions
752
+ * @param {number} [options.maxDiscussions=10] - Maximum remote discussions fetched
753
+ * @param {number} [options.maxComments=5] - Maximum comments fetched per discussion
754
+ * @param {number} [options.discussionTimeoutMs=5000] - Per-request timeout (ms)
755
+ * @param {string} [options.token] - Explicit GitHub/GitLab API token override
756
+ * @param {typeof globalThis.fetch} [options.fetch] - Injectable fetch implementation
757
+ * @param {NodeJS.ProcessEnv} [options.env] - Environment override for token lookup
588
758
  * @param {number} [options.couplingWindow=500] - Commits of the file examined for co-changes (0 = off)
589
759
  * @param {number} [options.couplingTop=10] - Co-changed files listed
590
760
  * @param {number} [options.maxFilesPerCommit=50] - Larger commits are ignored for coupling
@@ -595,21 +765,98 @@ function couplingSection(project, file, opts) {
595
765
  * @param {string} [options.ask] - Question appended at the end of the document
596
766
  * @param {boolean} [options.redact=true] - Mask secrets (lib/utils/secret-redactor.js)
597
767
  * @param {string} [options.cwd] - Directory relative target paths start from (default: root)
768
+ * @param {Array<string|{path: string, alias?: string}>} [options.repos] - Additional workspace repositories
598
769
  * @returns {Promise<{text: string, style: string, tokens: number, budget: number|null,
599
- * omitted: Record<string, number>, warnings: string[], data: object}>}
770
+ * omitted: Record<string, number>, warnings: string[], data: object, repos?: Array<{alias: string, root: string}>}>}
600
771
  */
601
772
  export async function explainHistory(root, target, options = {}) {
602
773
  const opts = normalizeOptions(options);
603
- const project = openProject(root, opts);
604
- const file = readTarget(project, target);
774
+ const isWorkspace = Array.isArray(options.repos) && options.repos.length > 0;
775
+
776
+ let project;
777
+ let effectiveTarget = target;
778
+ let workspaceRepos = null;
779
+ let targetRepo = null;
780
+
781
+ if (isWorkspace) {
782
+ if (typeof root !== 'string' || !fs.statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
783
+ throw new WhyError('ROOT_NOT_FOUND', `Project root is not a directory: ${root}`);
784
+ }
785
+ let resolvedRepos;
786
+ try {
787
+ resolvedRepos = resolveWorkspaceRepos(
788
+ buildWorkspaceRepoInputs(path.resolve(root), options.repos)
789
+ );
790
+ } catch (error) {
791
+ if (error instanceof WhyError) throw error;
792
+ throw new WhyError('ROOT_NOT_FOUND', error.message);
793
+ }
794
+
795
+ if (!opts.redact) {
796
+ const requiringRepo = resolvedRepos.find((r) => r.policy?.requireRedaction);
797
+ if (requiringRepo) {
798
+ throw new WhyError(
799
+ 'POLICY_DENIED',
800
+ `Secret redaction is required by export policy in ${requiringRepo.alias} (--no-redact is refused)`
801
+ );
802
+ }
803
+ }
804
+
805
+ workspaceRepos = resolvedRepos.map((repo) => ({
806
+ ...repo,
807
+ project: openProject(repo.realRoot, { ...opts, cwd: null }, { checkRev: false }),
808
+ }));
809
+
810
+ const resolved = resolveWorkspaceWhyTarget(workspaceRepos, target, opts);
811
+ targetRepo = resolved.targetRepo;
812
+ effectiveTarget = resolved.innerTarget;
813
+ project = targetRepo.project;
814
+
815
+ if (
816
+ opts.rev &&
817
+ !tryGit(project.top, ['rev-parse', '--verify', '--quiet', `${opts.rev}^{commit}`])
818
+ ) {
819
+ throw new WhyError('UNKNOWN_REF', `Unknown revision: ${opts.rev}`);
820
+ }
821
+ } else {
822
+ project = openProject(root, opts);
823
+ }
824
+
825
+ const file = readTarget(project, effectiveTarget);
605
826
  const selection = selectLines(project, file);
606
827
  const { wholeFile, range, symbol } = selection;
607
828
  const { spec, relPath, lines, totalLines, contentOmitted } = file;
829
+ const displayTargetPath = isWorkspace ? `${targetRepo.alias}/${relPath}` : relPath;
608
830
 
609
831
  const history = historySection(project, file, selection, opts);
610
832
  const references = mineReferences(history.commits, project.remote);
611
833
  for (const commit of history.commits) commit.refs = referencesOf(references, commit.shortSha);
612
834
 
835
+ let discussions = [];
836
+ if (opts.discussions) {
837
+ const fetched = await fetchDiscussions(references.issues, project.remote, {
838
+ maxDiscussions: opts.maxDiscussions,
839
+ maxComments: opts.maxComments,
840
+ timeoutMs: opts.discussionTimeoutMs,
841
+ token: opts.token ?? undefined,
842
+ env: opts.env ?? undefined,
843
+ fetch: opts.fetch ?? undefined,
844
+ });
845
+ discussions = fetched.discussions;
846
+ project.warnings.push(...fetched.warnings);
847
+ }
848
+
849
+ let institutionalCitations = [];
850
+ if (opts.analyzerDb || opts.analyzer || resolveAnalyzerDbPath(project.projectRoot) !== null) {
851
+ const dbPath = opts.analyzerDb || project.projectRoot;
852
+ institutionalCitations = queryAnalyzerCitations(dbPath, {
853
+ file: relPath,
854
+ symbol: spec.symbol,
855
+ commits: history.commits.map((c) => c.sha),
856
+ redact: opts.redact,
857
+ });
858
+ }
859
+
613
860
  // Blame of the current lines
614
861
  const covered = wholeFile || range !== null;
615
862
  let ownership = null;
@@ -623,7 +870,16 @@ export async function explainHistory(root, target, options = {}) {
623
870
  if (!ownership) project.warnings.push('git blame failed; ownership not available.');
624
871
  }
625
872
 
626
- const coupling = history.reason === NEVER_COMMITTED ? null : couplingSection(project, file, opts);
873
+ let coupling = history.reason === NEVER_COMMITTED ? null : couplingSection(project, file, opts);
874
+ if (isWorkspace && coupling) {
875
+ coupling = {
876
+ ...coupling,
877
+ files: coupling.files.map((entry) => ({
878
+ ...entry,
879
+ path: `${targetRepo.alias}/${entry.path}`,
880
+ })),
881
+ };
882
+ }
627
883
 
628
884
  // Definitions inside a line range double as search terms
629
885
  const symbolsInRange =
@@ -637,19 +893,67 @@ export async function explainHistory(root, target, options = {}) {
637
893
  ].slice(0, 10)
638
894
  : [];
639
895
 
640
- // Docs, comments and tests that mention the target (work tree)
896
+ // Docs, comments and tests that mention the target (work tree across all workspace repos)
641
897
  const member = spec.symbol ? spec.symbol.split(/\.|::|#/).pop() : null;
642
- const found = searchRelated(project.projectRoot, {
643
- ...searchTerms(relPath, member ? [member] : symbolsInRange.slice(0, 3)),
644
- target: {
645
- path: relPath,
646
- startLine: range ? range.startLine : null,
647
- endLine: range ? range.endLine : null,
648
- },
649
- isExcluded: project.isExcluded,
650
- maxRelated: opts.maxRelated,
651
- maxTests: opts.maxTests,
652
- });
898
+ const terms = searchTerms(relPath, member ? [member] : symbolsInRange.slice(0, 3));
899
+
900
+ let relatedList;
901
+ let testList;
902
+ if (isWorkspace) {
903
+ const mergedRelated = [];
904
+ const mergedTests = [];
905
+ for (const repo of workspaceRepos) {
906
+ const isTargetRepo = repo.alias === targetRepo.alias;
907
+ const repoFound = searchRelated(repo.project.projectRoot, {
908
+ ...terms,
909
+ target: isTargetRepo
910
+ ? {
911
+ path: relPath,
912
+ startLine: range ? range.startLine : null,
913
+ endLine: range ? range.endLine : null,
914
+ }
915
+ : { path: '\0__other_repo__', startLine: null, endLine: null },
916
+ isExcluded: repo.project.isExcluded,
917
+ maxRelated: opts.maxRelated,
918
+ maxTests: opts.maxTests,
919
+ });
920
+ for (const item of repoFound.related) {
921
+ mergedRelated.push({ ...item, path: `${repo.alias}/${item.path}` });
922
+ }
923
+ for (const testItem of repoFound.tests) {
924
+ mergedTests.push({ ...testItem, path: `${repo.alias}/${testItem.path}` });
925
+ }
926
+ }
927
+ mergedRelated.sort(
928
+ (a, b) =>
929
+ RELATED_KIND_ORDER[a.kind] - RELATED_KIND_ORDER[b.kind] ||
930
+ (a.mentions === 'symbol' ? 0 : 1) - (b.mentions === 'symbol' ? 0 : 1) ||
931
+ a.path.localeCompare(b.path) ||
932
+ a.line - b.line
933
+ );
934
+ mergedTests.sort(
935
+ (a, b) =>
936
+ (a.references === 'symbol' ? 0 : 1) - (b.references === 'symbol' ? 0 : 1) ||
937
+ b.totalMatches - a.totalMatches ||
938
+ a.path.localeCompare(b.path)
939
+ );
940
+ relatedList = mergedRelated.slice(0, opts.maxRelated);
941
+ testList = mergedTests.slice(0, opts.maxTests);
942
+ } else {
943
+ const found = searchRelated(project.projectRoot, {
944
+ ...terms,
945
+ target: {
946
+ path: relPath,
947
+ startLine: range ? range.startLine : null,
948
+ endLine: range ? range.endLine : null,
949
+ },
950
+ isExcluded: project.isExcluded,
951
+ maxRelated: opts.maxRelated,
952
+ maxTests: opts.maxTests,
953
+ });
954
+ relatedList = found.related;
955
+ testList = found.tests;
956
+ }
653
957
 
654
958
  const shown = !contentOmitted && covered;
655
959
  const first = range?.startLine ?? 1;
@@ -661,9 +965,14 @@ export async function explainHistory(root, target, options = {}) {
661
965
  }
662
966
 
663
967
  const { info, remote, rev } = project;
968
+ const reposMeta = isWorkspace
969
+ ? workspaceRepos.map(({ alias, root: repoRoot }) => ({ alias, root: repoRoot }))
970
+ : undefined;
971
+
664
972
  let data = {
973
+ ...(reposMeta ? { repos: reposMeta } : {}),
665
974
  target: {
666
- path: relPath,
975
+ path: displayTargetPath,
667
976
  symbol: spec.symbol ?? null,
668
977
  kind: symbol?.kind ?? null,
669
978
  line: symbol?.line ?? null,
@@ -686,11 +995,13 @@ export async function explainHistory(root, target, options = {}) {
686
995
  },
687
996
  history,
688
997
  references,
998
+ discussions,
999
+ institutionalCitations,
689
1000
  ownership,
690
1001
  coupling,
691
- related: found.related,
1002
+ related: relatedList,
692
1003
  notes,
693
- tests: found.tests,
1004
+ tests: testList,
694
1005
  warnings: project.warnings,
695
1006
  question: opts.ask,
696
1007
  };
@@ -706,7 +1017,22 @@ export async function explainHistory(root, target, options = {}) {
706
1017
  }
707
1018
 
708
1019
  const rendered = renderWithinBudget(data, { style: opts.style, budget: opts.budget });
1020
+
1021
+ if (isWorkspace) {
1022
+ for (const repo of workspaceRepos) {
1023
+ if (repo.policy?.maxExportTokens !== null && repo.policy?.maxExportTokens !== undefined) {
1024
+ if (rendered.tokens > repo.policy.maxExportTokens) {
1025
+ throw new WhyError(
1026
+ 'POLICY_DENIED',
1027
+ `Export of ${rendered.tokens} tokens exceeds maxExportTokens (${repo.policy.maxExportTokens}) in ${repo.alias}`
1028
+ );
1029
+ }
1030
+ }
1031
+ }
1032
+ }
1033
+
709
1034
  return {
1035
+ ...(reposMeta ? { repos: reposMeta } : {}),
710
1036
  text: rendered.text,
711
1037
  style: opts.style,
712
1038
  tokens: rendered.tokens,