ctxman 3.1.1 → 3.2.8
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 +16 -10
- package/bin/cli.js +109 -10
- package/bin/cm-ask.js +64 -6
- package/bin/cm-audit.js +76 -0
- package/bin/cm-count.js +45 -6
- 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/index.d.ts +940 -1
- package/index.js +105 -1
- package/lib/analyzers/token-calculator.js +76 -4
- package/lib/api/mcp/server.js +843 -29
- package/lib/cache/CacheManager.js +161 -47
- package/lib/core/Analyzer.js +61 -5
- package/lib/count/index.js +74 -14
- 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/policy/audit.js +3 -1
- package/lib/policy/index.js +5 -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 +186 -17
- 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/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/workspace/index.js +1274 -0
- package/package.json +1 -1
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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 {
|
|
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(', ')}
|
|
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
|
|
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 (
|
|
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
|
|
604
|
-
|
|
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
|
-
|
|
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
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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:
|
|
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:
|
|
1002
|
+
related: relatedList,
|
|
692
1003
|
notes,
|
|
693
|
-
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,
|