ctxman 3.0.0 → 3.1.1
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 +1067 -39
- package/.ctxman/profiles/backend-team.json +13 -0
- package/.ctxman/profiles/default.json +12 -0
- package/.ctxman/profiles/devops-team.json +21 -0
- package/.ctxman/profiles/frontend-team.json +20 -0
- package/.ctxman/templates/bug-fix.json +17 -0
- package/.ctxman/templates/code-review.json +9 -0
- package/.ctxman/templates/documentation.json +9 -0
- package/.ctxman/templates/feature.json +9 -0
- package/.ctxman/templates/full-context.json +9 -0
- package/.ctxman/templates/refactoring.json +18 -0
- package/.ctxman/templates/testing.json +9 -0
- package/.ctxman/wizard-profiles/security-audit/profile.json +7 -1
- package/LICENSE +21 -0
- package/README.md +381 -136
- package/bin/cli.js +1880 -529
- package/bin/cm-ask.js +18 -67
- package/bin/cm-audit.js +121 -0
- package/bin/cm-count.js +151 -0
- package/bin/cm-gitingest.js +128 -115
- package/bin/cm-map.js +242 -0
- package/bin/cm-mcp.js +293 -0
- package/bin/cm-pack.js +405 -0
- package/bin/cm-policy.js +170 -0
- package/bin/cm-pr.js +248 -0
- package/bin/cm-search.js +178 -0
- package/bin/cm-update.js +128 -128
- package/bin/cm-why.js +197 -0
- package/bin/mcp-server.js +8 -24
- package/ctxman.js +237 -183
- package/examples/README.md +19 -4
- package/examples/github-actions/ctxman-check.yml +49 -0
- package/examples/wizard-profiles/security-audit/profile.json +7 -1
- package/index.d.ts +1422 -0
- package/index.js +55 -31
- package/lib/agents/pr-failure-agent.js +48 -45
- package/lib/analyzers/ai-suggester.js +451 -0
- package/lib/analyzers/dependency-scanner.js +505 -0
- package/lib/analyzers/duplicate-detector.js +264 -0
- package/lib/analyzers/go-method-analyzer.js +151 -110
- package/lib/analyzers/import-tracker.js +431 -0
- package/lib/analyzers/method-analyzer.js +1055 -434
- package/lib/analyzers/performance-dashboard.js +687 -0
- package/lib/analyzers/token-calculator.js +895 -504
- package/lib/analyzers/unused-exports-detector.js +336 -0
- package/lib/api/mcp/install.js +424 -0
- package/lib/api/mcp/prompts.js +281 -281
- package/lib/api/mcp/resources.js +300 -241
- package/lib/api/mcp/sandbox.js +89 -0
- package/lib/api/mcp/server.js +772 -312
- package/lib/api/mcp/start.js +125 -0
- package/lib/api/mcp/transports/HttpSseTransport.js +60 -52
- package/lib/api/rest/server.js +105 -28
- package/lib/cache/CacheManager.js +9 -17
- package/lib/core/Analyzer.js +16 -10
- package/lib/core/ContextBuilder.js +19 -28
- package/lib/core/Reporter.js +15 -14
- package/lib/core/Scanner.js +81 -12
- package/lib/count/index.js +100 -0
- package/lib/formatters/format-registry.js +394 -372
- package/lib/formatters/gitingest-formatter.js +656 -621
- package/lib/formatters/toon-formatter-v1.3.js +637 -632
- package/lib/formatters/toon-formatter.js +239 -240
- package/lib/integrations/git/BlameTracker.js +4 -4
- package/lib/integrations/git/DiffAnalyzer.js +38 -31
- package/lib/integrations/git/GitClient.js +71 -53
- package/lib/lsp/server.js +311 -0
- package/lib/map/graph.js +299 -0
- package/lib/map/imports.js +233 -0
- package/lib/map/index.js +33 -0
- package/lib/map/rank.js +253 -0
- package/lib/map/render.js +346 -0
- package/lib/map/resolver.js +574 -0
- package/lib/pack/budget.js +111 -0
- package/lib/pack/code-lexer.js +360 -0
- package/lib/pack/compress.js +414 -0
- package/lib/pack/index.js +354 -0
- package/lib/pack/renderers/common.js +134 -0
- package/lib/pack/renderers/index.js +24 -0
- package/lib/pack/renderers/json.js +47 -0
- package/lib/pack/renderers/markdown.js +54 -0
- package/lib/pack/renderers/plain.js +38 -0
- package/lib/pack/renderers/xml.js +85 -0
- package/lib/pack/select.js +177 -0
- package/lib/pack/split.js +182 -0
- package/lib/pack/tree.js +35 -0
- package/lib/parsers/gitignore-parser.js +116 -107
- package/lib/parsers/method-filter-parser.js +50 -33
- package/lib/plugins/ExporterPlugin.js +3 -3
- package/lib/plugins/LanguagePlugin.js +3 -3
- package/lib/plugins/PluginManager.js +7 -13
- package/lib/policy/audit.js +312 -0
- package/lib/policy/enforce.js +125 -0
- package/lib/policy/ignore-files.js +169 -0
- package/lib/policy/index.js +53 -0
- package/lib/policy/policy.js +489 -0
- package/lib/pr/diff-parser.js +267 -0
- package/lib/pr/method-mapper.js +148 -0
- package/lib/pr/pr-context.js +829 -0
- package/lib/pr/pr-git.js +149 -0
- package/lib/pr/pr-renderer.js +644 -0
- package/lib/pr/related-tests.js +296 -0
- package/lib/rag/DataSourcePlugin.js +5 -5
- package/lib/rag/EmbeddingProvider.js +19 -8
- package/lib/rag/Indexer.js +14 -5
- package/lib/rag/VectorStoreFactory.js +3 -3
- package/lib/rag/ask.js +122 -0
- package/lib/rag/sources/FileDataSource.js +12 -9
- package/lib/rag/stores/BaseVectorStore.js +5 -5
- package/lib/rag/stores/ChromaDBStore.js +10 -10
- package/lib/rag/stores/LanceDBStore.js +29 -19
- package/lib/rag/stores/PineconeStore.js +11 -9
- package/lib/search/bm25.js +433 -0
- package/lib/search/code-units.js +201 -0
- package/lib/search/symbols.js +189 -0
- package/lib/ui/dashboard.js +120 -89
- package/lib/ui/index.js +1 -6
- package/lib/ui/init-wizard-ui.js +550 -0
- package/lib/ui/progress-bar.js +94 -88
- package/lib/ui/select-input.js +40 -32
- package/lib/ui/template-selector.js +99 -0
- package/lib/ui/wizard.js +374 -329
- package/lib/utils/claude-token-counter.js +151 -0
- package/lib/utils/cli-output.js +17 -0
- package/lib/utils/clipboard-utils.js +49 -50
- package/lib/utils/config-utils.js +81 -66
- package/lib/utils/context-versioning.js +532 -0
- package/lib/utils/error-handler.js +112 -112
- package/lib/utils/file-utils.js +153 -69
- package/lib/utils/format-converter.js +356 -283
- package/lib/utils/git-utils.js +366 -294
- package/lib/utils/llm-cost-estimator.js +281 -0
- package/lib/utils/llm-detector.js +242 -78
- package/lib/utils/logger.js +246 -237
- package/lib/utils/messages.js +355 -0
- package/lib/utils/multi-repo-manager.js +364 -0
- package/lib/utils/profile-manager.js +380 -0
- package/lib/utils/safe-read.js +61 -0
- package/lib/utils/secret-redactor.js +61 -0
- package/lib/utils/snapshot-manager.js +679 -0
- package/lib/utils/template-manager.js +378 -0
- package/lib/utils/token-utils.js +201 -170
- package/lib/utils/tokenizer-adapter.js +421 -377
- package/lib/utils/updater.js +539 -506
- package/lib/watch/FileWatcher.js +12 -8
- package/lib/watch/IncrementalAnalyzer.js +7 -7
- package/lib/why/coupling.js +81 -0
- package/lib/why/errors.js +23 -0
- package/lib/why/git.js +169 -0
- package/lib/why/history.js +108 -0
- package/lib/why/index.js +720 -0
- package/lib/why/ownership.js +79 -0
- package/lib/why/paths.js +207 -0
- package/lib/why/references.js +259 -0
- package/lib/why/render.js +722 -0
- package/lib/why/search.js +358 -0
- package/lib/why/target.js +389 -0
- package/lib/wizards/config-generator.js +257 -0
- package/lib/wizards/project-detector.js +572 -0
- package/package.json +44 -10
- package/.ctxman/logs/ctxman-2026-07-02.log +0 -156
- package/.ctxman/self-knowledge/documents.lance/_transactions/0-b3dd1558-f95b-44b9-a154-18e8c330ae7a.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/1-44bff6d4-e552-4010-a769-e09ad822698d.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/10-37260794-5947-4ecb-b4c8-d732f58f40f1.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/11-0d52a883-5518-463c-9b6d-986c3af89dfd.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/12-ae354646-52f2-49c9-90fb-2e6ed8932663.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/13-5d7d5e57-27da-498b-b734-091b9d38da13.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/14-558ce928-810b-4252-a364-fdd1ee6f8a25.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/15-27e3a58f-8b64-43b8-a9f4-e3369446968b.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/16-a110c390-e5c8-4464-b976-bc7f5195fa15.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/17-97513225-53de-4216-b61a-6337bd0cc4ad.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/18-5ba450f0-c527-459c-bf4f-6d79b5e4285f.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/19-600d3a63-5225-49e9-b8b7-30e037b564ab.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/2-48a61b4e-92cf-4342-8376-914c1b99a9f9.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/20-121dd912-5410-410e-84ff-82c7a23604a0.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/3-bc6844ab-7ea3-4bb3-9ee1-538ce5664a9c.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/4-d5e1754f-dbb5-454e-a9be-2a7da8d7bcf9.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/5-4fae93df-0568-4b6f-8acf-8bef4bba6047.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/6-4b1b8b87-068e-49a3-ba2b-d8dae628b252.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/7-63d040fb-d2fa-4c0f-bed0-53411918a7b0.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/8-c5628cf6-264c-420f-aab8-bc96a9083591.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/9-c94570aa-5e53-4fcb-971f-cecc1d1d5492.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/1.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/10.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/11.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/12.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/13.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/14.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/15.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/16.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/17.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/18.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/19.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/2.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/20.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/21.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/3.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/4.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/5.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/6.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/7.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/8.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/9.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/000010010011011101000010de8621405d9d5cb06540dbf02c.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/0000101001011100011000003fb62a4aa8978974075c144f1c.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/000011010000100111001011c6cc824d628f3bb1f4e04cd5d0.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/001000001000011001011100d8f74349ada090b332cd1df669.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/01000001111010001000010045ab44467bb1e7e4d8ae06bd58.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/01000101101110111110011131ef904241a89e1ffb6ddaf8e3.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/010010110000010000011010fbfcc145c6a8a827b28057a07e.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1000011000101010001110003fb4f045c3835982efa1405324.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/100111011101110111101011f4396b410fa06af3b028d204e6.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/100111100111101000001100596b174e00995d6cc2969cc8dc.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1010001100011000110010102ea16b484d887c7c9725feffd6.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/101101100010101111110111c5230944d9b3af7301b6392dda.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1011011100100111111110105892bf41b5a530fedd7cf516f1.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/10111110000010001011110164e8774742bade87b483c10f36.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1100000100010110001110103ffaca40a2a42c85067ebaf9ae.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/110000010001110110101110664bde4c76a78dfc14b05e83b8.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/11001100111000010100110050645c4281b1f35b2022234f7f.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1101111011001100110111017214924a368f5719eab2246619.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1110000111001010001000002150c24dcab653d39d07032e63.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1110100000000100000011014d20534967a18b5c87721659ff.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/11110101111010000101011135a0114ef2b35dc6f1297a3381.lance +0 -0
package/lib/why/index.js
ADDED
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ctxman why - "why is the code like this?" context from local git history
|
|
3
|
+
*
|
|
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.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { isValidGitRef } from '../integrations/git/GitClient.js';
|
|
15
|
+
import { redactSecrets, formatRedactionSummary } from '../utils/secret-redactor.js';
|
|
16
|
+
import { WhyError } from './errors.js';
|
|
17
|
+
import {
|
|
18
|
+
findRepositoryRoot,
|
|
19
|
+
literalPathspec as literal,
|
|
20
|
+
readRepositoryInfo,
|
|
21
|
+
runGit,
|
|
22
|
+
toGitPath,
|
|
23
|
+
tryGit,
|
|
24
|
+
} from './git.js';
|
|
25
|
+
import { createPathFilter, isBinaryBuffer, isExportableText, resolveInsideRoot } from './paths.js';
|
|
26
|
+
import {
|
|
27
|
+
closeMatches,
|
|
28
|
+
funcnameRange,
|
|
29
|
+
isValidSymbol,
|
|
30
|
+
listDefinitions,
|
|
31
|
+
mapRangeToOld,
|
|
32
|
+
parseHunks,
|
|
33
|
+
parseTargetSpec,
|
|
34
|
+
resolveSymbol,
|
|
35
|
+
} from './target.js';
|
|
36
|
+
import { collectHistory, funcnamePattern } from './history.js';
|
|
37
|
+
import { mineReferences, parseRemote, referencesOf } from './references.js';
|
|
38
|
+
import { collectOwnership } from './ownership.js';
|
|
39
|
+
import { collectCoupling } from './coupling.js';
|
|
40
|
+
import { findNotes, searchRelated } from './search.js';
|
|
41
|
+
import { STYLES, renderWithinBudget } from './render.js';
|
|
42
|
+
|
|
43
|
+
export { WhyError, findRepositoryRoot, parseTargetSpec, parseRemote, mineReferences };
|
|
44
|
+
|
|
45
|
+
const DEFAULTS = {
|
|
46
|
+
limit: 20,
|
|
47
|
+
since: null,
|
|
48
|
+
rev: null,
|
|
49
|
+
patches: false,
|
|
50
|
+
couplingWindow: 500,
|
|
51
|
+
couplingTop: 10,
|
|
52
|
+
maxFilesPerCommit: 50,
|
|
53
|
+
maxRelated: 20,
|
|
54
|
+
maxTests: 10,
|
|
55
|
+
style: 'markdown',
|
|
56
|
+
budget: null,
|
|
57
|
+
ask: null,
|
|
58
|
+
redact: true,
|
|
59
|
+
cwd: null,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const MAX_NOTES = 50;
|
|
63
|
+
const NEVER_COMMITTED = 'the file is staged but has never been committed';
|
|
64
|
+
|
|
65
|
+
// File names too common to search for on their own: `index.js` becomes `cart/index.js`
|
|
66
|
+
const GENERIC_STEMS = new Set([
|
|
67
|
+
'index',
|
|
68
|
+
'main',
|
|
69
|
+
'mod',
|
|
70
|
+
'__init__',
|
|
71
|
+
'init',
|
|
72
|
+
'utils',
|
|
73
|
+
'util',
|
|
74
|
+
'helpers',
|
|
75
|
+
'helper',
|
|
76
|
+
'types',
|
|
77
|
+
'constants',
|
|
78
|
+
'config',
|
|
79
|
+
'app',
|
|
80
|
+
'lib',
|
|
81
|
+
'server',
|
|
82
|
+
'client',
|
|
83
|
+
'setup',
|
|
84
|
+
'common',
|
|
85
|
+
'base',
|
|
86
|
+
'core',
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Validate a whole number option (numeric strings from the CLI or MCP are accepted)
|
|
91
|
+
*/
|
|
92
|
+
function integerOption(name, value, min, max) {
|
|
93
|
+
const number = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
|
|
94
|
+
if (!Number.isInteger(number) || number < min || number > max) {
|
|
95
|
+
throw new WhyError(
|
|
96
|
+
'INVALID_OPTION',
|
|
97
|
+
`${name} must be a whole number between ${min} and ${max} (got ${JSON.stringify(value)})`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Fill in defaults and validate options
|
|
105
|
+
* @param {object} options
|
|
106
|
+
* @returns {typeof DEFAULTS}
|
|
107
|
+
*/
|
|
108
|
+
function normalizeOptions(options) {
|
|
109
|
+
const opts = { ...DEFAULTS };
|
|
110
|
+
for (const [key, value] of Object.entries(options ?? {})) {
|
|
111
|
+
if (value !== undefined && value !== null) opts[key] = value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
opts.limit = integerOption('limit', opts.limit, 1, 10000);
|
|
115
|
+
opts.couplingWindow = integerOption('couplingWindow', opts.couplingWindow, 0, 100000);
|
|
116
|
+
opts.couplingTop = integerOption('couplingTop', opts.couplingTop, 1, 1000);
|
|
117
|
+
opts.maxFilesPerCommit = integerOption('maxFilesPerCommit', opts.maxFilesPerCommit, 1, 100000);
|
|
118
|
+
opts.maxRelated = integerOption('maxRelated', opts.maxRelated, 0, 1000);
|
|
119
|
+
opts.maxTests = integerOption('maxTests', opts.maxTests, 0, 1000);
|
|
120
|
+
if (opts.budget !== null) opts.budget = integerOption('budget', opts.budget, 1, 100000000);
|
|
121
|
+
|
|
122
|
+
if (!STYLES.includes(opts.style)) {
|
|
123
|
+
throw new WhyError(
|
|
124
|
+
'INVALID_OPTION',
|
|
125
|
+
`style must be one of ${STYLES.join(', ')} (got ${JSON.stringify(opts.style)})`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (opts.since !== null) {
|
|
129
|
+
const since = String(opts.since).trim();
|
|
130
|
+
if (!since || since.length > 100 || since.startsWith('-') || !/^[\w .:+\-/,]+$/.test(since)) {
|
|
131
|
+
throw new WhyError('INVALID_OPTION', `Invalid --since date: ${JSON.stringify(opts.since)}`);
|
|
132
|
+
}
|
|
133
|
+
opts.since = since;
|
|
134
|
+
}
|
|
135
|
+
if (opts.rev !== null && !isValidGitRef(opts.rev)) {
|
|
136
|
+
throw new WhyError('INVALID_REF', `Invalid git reference: ${JSON.stringify(opts.rev)}`);
|
|
137
|
+
}
|
|
138
|
+
if (opts.ask !== null) {
|
|
139
|
+
opts.ask = String(opts.ask).trim() || null;
|
|
140
|
+
}
|
|
141
|
+
opts.patches = Boolean(opts.patches);
|
|
142
|
+
opts.redact = opts.redact !== false;
|
|
143
|
+
return opts;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Count the lines of a text the way an editor numbers them
|
|
148
|
+
*/
|
|
149
|
+
function countLines(content) {
|
|
150
|
+
if (content === '') return 0;
|
|
151
|
+
const lines = content.split('\n');
|
|
152
|
+
return content.endsWith('\n') ? lines.length - 1 : lines.length;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Terms to search the repository for: the symbol(s), the file name (with its directory
|
|
157
|
+
* when the name alone is too common) and the module name tests import
|
|
158
|
+
*/
|
|
159
|
+
function searchTerms(relativePath, symbols) {
|
|
160
|
+
const parts = relativePath.split('/');
|
|
161
|
+
const base = parts[parts.length - 1];
|
|
162
|
+
const ext = path.extname(base);
|
|
163
|
+
const stem = ext ? base.slice(0, -ext.length) : base;
|
|
164
|
+
const parent = parts.length > 1 ? parts[parts.length - 2] : null;
|
|
165
|
+
const generic = GENERIC_STEMS.has(stem.toLowerCase());
|
|
166
|
+
return {
|
|
167
|
+
symbols,
|
|
168
|
+
fileTerm: generic && parent ? `${parent}/${base}` : base,
|
|
169
|
+
stem: generic ? (['index', 'mod', '__init__'].includes(stem) ? parent : null) : stem,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Replace secrets in every string of `value` (in place for arrays / objects)
|
|
175
|
+
*/
|
|
176
|
+
function redactDeep(value, counts) {
|
|
177
|
+
if (typeof value === 'string') {
|
|
178
|
+
const { text, findings } = redactSecrets(value);
|
|
179
|
+
for (const type of findings) counts[type] = (counts[type] ?? 0) + 1;
|
|
180
|
+
return text;
|
|
181
|
+
}
|
|
182
|
+
if (Array.isArray(value)) return value.map((item) => redactDeep(item, counts));
|
|
183
|
+
if (value && typeof value === 'object') {
|
|
184
|
+
return Object.fromEntries(
|
|
185
|
+
Object.entries(value).map(([key, item]) => [key, redactDeep(item, counts)])
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Normalize a target given as an object ({file, symbol} / {file, startLine, endLine})
|
|
193
|
+
*/
|
|
194
|
+
function objectTarget(target) {
|
|
195
|
+
if (!target || typeof target !== 'object' || typeof target.file !== 'string') {
|
|
196
|
+
throw new WhyError(
|
|
197
|
+
'INVALID_TARGET',
|
|
198
|
+
'target must be a string like "src/cart.js:total" or an object with a file property'
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const spec = { file: target.file };
|
|
202
|
+
if (target.symbol) spec.symbol = target.symbol;
|
|
203
|
+
if (target.startLine !== undefined) {
|
|
204
|
+
spec.startLine = Number(target.startLine);
|
|
205
|
+
spec.endLine = Number(target.endLine ?? target.startLine);
|
|
206
|
+
}
|
|
207
|
+
return spec;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The project, its repository and the rules that apply to it
|
|
212
|
+
*/
|
|
213
|
+
function openProject(root, opts) {
|
|
214
|
+
if (typeof root !== 'string' || !fs.statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
|
|
215
|
+
throw new WhyError('ROOT_NOT_FOUND', `Project root is not a directory: ${root}`);
|
|
216
|
+
}
|
|
217
|
+
const projectRoot = fs.realpathSync(root);
|
|
218
|
+
const top = findRepositoryRoot(projectRoot);
|
|
219
|
+
if (opts.rev && !tryGit(top, ['rev-parse', '--verify', '--quiet', `${opts.rev}^{commit}`])) {
|
|
220
|
+
throw new WhyError('UNKNOWN_REF', `Unknown revision: ${opts.rev}`);
|
|
221
|
+
}
|
|
222
|
+
const info = readRepositoryInfo(top);
|
|
223
|
+
const warnings = [];
|
|
224
|
+
if (info.shallow) {
|
|
225
|
+
warnings.push(
|
|
226
|
+
'This is a shallow clone, so history is truncated at the clone depth: the oldest commit shown may not be where the code started (`git fetch --unshallow` fetches the rest).'
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
projectRoot,
|
|
231
|
+
top,
|
|
232
|
+
base: opts.cwd ? path.resolve(projectRoot, opts.cwd) : projectRoot,
|
|
233
|
+
rev: opts.rev,
|
|
234
|
+
info,
|
|
235
|
+
remote: parseRemote(info.remoteUrl),
|
|
236
|
+
isExcluded: createPathFilter(projectRoot),
|
|
237
|
+
warnings,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Point a missing file at the commit that removed (or renamed) it, if history has one
|
|
243
|
+
* @param {object} project
|
|
244
|
+
* @param {string} file - Target path, already checked to lie inside the project
|
|
245
|
+
* @returns {string} Text to append to the error, or ''
|
|
246
|
+
*/
|
|
247
|
+
function deletedFileHint(project, file) {
|
|
248
|
+
const gitPath = toGitPath(path.relative(project.top, path.resolve(project.base, file)));
|
|
249
|
+
const last = tryGit(project.top, [
|
|
250
|
+
'log',
|
|
251
|
+
'-n',
|
|
252
|
+
'1',
|
|
253
|
+
'--format=%h',
|
|
254
|
+
'--',
|
|
255
|
+
literal(gitPath),
|
|
256
|
+
])?.trim();
|
|
257
|
+
return last
|
|
258
|
+
? ` (deleted or renamed in ${last}; use --rev ${last}~1 to explain its last version)`
|
|
259
|
+
: '';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Locate the target file and read it (from the work tree, or at `rev`)
|
|
264
|
+
*/
|
|
265
|
+
function readTarget(project, target) {
|
|
266
|
+
const { projectRoot, top, base, rev, isExcluded } = project;
|
|
267
|
+
const spec =
|
|
268
|
+
typeof target === 'string'
|
|
269
|
+
? parseTargetSpec(target, (file) =>
|
|
270
|
+
Boolean(fs.statSync(path.resolve(base, file), { throwIfNoEntry: false })?.isFile())
|
|
271
|
+
)
|
|
272
|
+
: objectTarget(target);
|
|
273
|
+
let located;
|
|
274
|
+
try {
|
|
275
|
+
located = resolveInsideRoot(projectRoot, base, spec.file, {
|
|
276
|
+
mustExist: !rev,
|
|
277
|
+
label: projectRoot === top ? 'the repository' : `the project directory ${projectRoot}`,
|
|
278
|
+
});
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (error.code === 'FILE_NOT_FOUND') error.message += deletedFileHint(project, spec.file);
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
const { absolute, relative } = located;
|
|
284
|
+
const relPath = toGitPath(relative);
|
|
285
|
+
const gitPath = toGitPath(path.relative(top, absolute));
|
|
286
|
+
if (isExcluded.isDenied(relPath)) {
|
|
287
|
+
throw new WhyError('POLICY_DENIED', `${relPath} is denied by the export policy`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
let buffer;
|
|
291
|
+
if (rev) {
|
|
292
|
+
// `<rev>:<path>` names a blob only when the file existed there (a tree for a folder)
|
|
293
|
+
const object = `${rev}:${gitPath}`;
|
|
294
|
+
if (tryGit(top, ['cat-file', '-t', object])?.trim() !== 'blob') {
|
|
295
|
+
throw new WhyError('FILE_NOT_FOUND', `${relPath} does not exist at ${rev}`);
|
|
296
|
+
}
|
|
297
|
+
buffer = runGit(top, ['cat-file', 'blob', object], { encoding: 'buffer' });
|
|
298
|
+
} else {
|
|
299
|
+
if (tryGit(top, ['ls-files', '--error-unmatch', '--', literal(gitPath)]) === null) {
|
|
300
|
+
throw new WhyError(
|
|
301
|
+
'NOT_TRACKED',
|
|
302
|
+
`${relPath} is not tracked by git, so it has no history to explain (commit it first)`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
buffer = fs.readFileSync(absolute);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const binary = isBinaryBuffer(buffer);
|
|
309
|
+
const content = binary ? '' : buffer.toString('utf-8');
|
|
310
|
+
let contentOmitted = null;
|
|
311
|
+
if (binary) contentOmitted = 'binary file';
|
|
312
|
+
else if (!isExportableText(relPath)) {
|
|
313
|
+
contentOmitted =
|
|
314
|
+
'not a text source file under ctxman scanner rules, which keep binary and secret-bearing files such as .env or *.pem out of exports';
|
|
315
|
+
} else if (isExcluded(relPath)) {
|
|
316
|
+
contentOmitted = 'excluded by .gitignore, .contextignore or .contextinclude';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
spec,
|
|
321
|
+
relPath,
|
|
322
|
+
gitPath,
|
|
323
|
+
binary,
|
|
324
|
+
content,
|
|
325
|
+
lines: content.split('\n'),
|
|
326
|
+
totalLines: countLines(content),
|
|
327
|
+
contentOmitted,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The lines the target covers: a resolved symbol, an explicit range, or the whole file.
|
|
333
|
+
* `range` is null for a whole file, and for a symbol only git's funcname rule found
|
|
334
|
+
*/
|
|
335
|
+
function selectLines(project, file) {
|
|
336
|
+
const { spec, relPath, content, lines, totalLines, binary } = file;
|
|
337
|
+
const wholeFile = !spec.symbol && spec.startLine === undefined;
|
|
338
|
+
if (wholeFile) return { wholeFile, range: null, symbol: null, funcname: null };
|
|
339
|
+
if (binary) {
|
|
340
|
+
throw new WhyError(
|
|
341
|
+
'BINARY_TARGET',
|
|
342
|
+
`${relPath} is a binary file: only whole-file history is available`
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (spec.startLine !== undefined) {
|
|
347
|
+
const { startLine, endLine } = spec;
|
|
348
|
+
if (
|
|
349
|
+
!Number.isInteger(startLine) ||
|
|
350
|
+
!Number.isInteger(endLine) ||
|
|
351
|
+
startLine < 1 ||
|
|
352
|
+
endLine < startLine
|
|
353
|
+
) {
|
|
354
|
+
throw new WhyError('INVALID_RANGE', `Invalid line range: ${startLine}-${endLine}`);
|
|
355
|
+
}
|
|
356
|
+
if (startLine > totalLines) {
|
|
357
|
+
throw new WhyError(
|
|
358
|
+
'INVALID_RANGE',
|
|
359
|
+
`Line ${startLine} is past the end of ${relPath} (${totalLines} lines)`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
if (endLine > totalLines) {
|
|
363
|
+
project.warnings.push(
|
|
364
|
+
`${relPath} has ${totalLines} lines; the range ends at line ${totalLines}.`
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
wholeFile,
|
|
369
|
+
range: { startLine, endLine: Math.min(endLine, totalLines) },
|
|
370
|
+
symbol: null,
|
|
371
|
+
funcname: null,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (!isValidSymbol(spec.symbol)) {
|
|
376
|
+
throw new WhyError('INVALID_SYMBOL', `Invalid symbol name: ${JSON.stringify(spec.symbol)}`);
|
|
377
|
+
}
|
|
378
|
+
const symbol = resolveSymbol(content, relPath, spec.symbol);
|
|
379
|
+
if (symbol) {
|
|
380
|
+
if (symbol.matches > 1) {
|
|
381
|
+
project.warnings.push(
|
|
382
|
+
`${symbol.matches} definitions match ${spec.symbol}; using the one on line ${symbol.line}. Use Class.method or #L<start>-L<end> to pick another.`
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
wholeFile,
|
|
387
|
+
range: { startLine: symbol.startLine, endLine: Math.min(symbol.endLine, totalLines) },
|
|
388
|
+
symbol,
|
|
389
|
+
funcname: null,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Last resort for a plain name: git's own funcname matching (`git log -L :name:file`)
|
|
394
|
+
const plain = !/\.|::|#/.test(spec.symbol);
|
|
395
|
+
const funcname = funcnamePattern(spec.symbol);
|
|
396
|
+
const probe = plain
|
|
397
|
+
? tryGit(project.top, [
|
|
398
|
+
'log',
|
|
399
|
+
'-n',
|
|
400
|
+
'1',
|
|
401
|
+
'-s',
|
|
402
|
+
'--format=%H',
|
|
403
|
+
'-L',
|
|
404
|
+
`:${funcname}:${file.gitPath}`,
|
|
405
|
+
...(project.rev ? [project.rev] : []),
|
|
406
|
+
])
|
|
407
|
+
: null;
|
|
408
|
+
if (!probe?.trim()) {
|
|
409
|
+
const candidates = closeMatches(content, relPath, spec.symbol);
|
|
410
|
+
const hint = candidates.length ? ` Did you mean: ${candidates.join(', ')}?` : '';
|
|
411
|
+
throw new WhyError(
|
|
412
|
+
'SYMBOL_NOT_FOUND',
|
|
413
|
+
`Symbol ${spec.symbol} not found in ${relPath}.${hint}`,
|
|
414
|
+
{
|
|
415
|
+
candidates,
|
|
416
|
+
}
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
const approximate = funcnameRange(lines, spec.symbol);
|
|
420
|
+
project.warnings.push(
|
|
421
|
+
`${spec.symbol} was not recognised as a definition; history comes from git's funcname matching (\`git log -L :${spec.symbol}:${relPath}\`), so the range is approximate.`
|
|
422
|
+
);
|
|
423
|
+
return {
|
|
424
|
+
wholeFile,
|
|
425
|
+
range: approximate,
|
|
426
|
+
symbol: {
|
|
427
|
+
kind: 'funcname match',
|
|
428
|
+
line: approximate?.startLine ?? null,
|
|
429
|
+
resolvedBy: 'git-funcname',
|
|
430
|
+
},
|
|
431
|
+
funcname,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Commits that touched the selection. Line numbers of a modified work tree file are
|
|
437
|
+
* mapped onto HEAD first, since `git log -L` reads them in the committed version
|
|
438
|
+
*/
|
|
439
|
+
function historySection(project, file, selection, opts) {
|
|
440
|
+
const { top, rev, warnings } = project;
|
|
441
|
+
const { relPath, gitPath, contentOmitted } = file;
|
|
442
|
+
const { range, funcname } = selection;
|
|
443
|
+
const history = {
|
|
444
|
+
mode: 'none',
|
|
445
|
+
startLine: null,
|
|
446
|
+
endLine: null,
|
|
447
|
+
since: opts.since,
|
|
448
|
+
limit: opts.limit,
|
|
449
|
+
limitReached: false,
|
|
450
|
+
commits: [],
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const revArgs = rev ? [rev] : [];
|
|
454
|
+
const lastCommit = tryGit(top, [
|
|
455
|
+
'log',
|
|
456
|
+
'-n',
|
|
457
|
+
'1',
|
|
458
|
+
'--format=%H',
|
|
459
|
+
...revArgs,
|
|
460
|
+
'--',
|
|
461
|
+
literal(gitPath),
|
|
462
|
+
]);
|
|
463
|
+
if (!lastCommit?.trim()) {
|
|
464
|
+
history.reason = NEVER_COMMITTED;
|
|
465
|
+
return history;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (funcname) {
|
|
469
|
+
history.mode = 'funcname';
|
|
470
|
+
history.funcname = funcname;
|
|
471
|
+
} else if (range) {
|
|
472
|
+
let committed = range;
|
|
473
|
+
const diff = rev
|
|
474
|
+
? ''
|
|
475
|
+
: (tryGit(top, [
|
|
476
|
+
'diff',
|
|
477
|
+
'-U0',
|
|
478
|
+
'--no-ext-diff',
|
|
479
|
+
'--no-color',
|
|
480
|
+
'HEAD',
|
|
481
|
+
'--',
|
|
482
|
+
literal(gitPath),
|
|
483
|
+
]) ?? '');
|
|
484
|
+
if (diff.trim()) {
|
|
485
|
+
committed = mapRangeToOld(parseHunks(diff), range.startLine, range.endLine);
|
|
486
|
+
if (committed) {
|
|
487
|
+
warnings.push(
|
|
488
|
+
`${relPath} has uncommitted changes: history follows lines ${committed.startLine}-${committed.endLine} of the committed version (HEAD).`
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (!committed) {
|
|
493
|
+
history.reason = 'the selected lines are new, uncommitted changes';
|
|
494
|
+
return history;
|
|
495
|
+
}
|
|
496
|
+
history.mode = 'range';
|
|
497
|
+
history.startLine = committed.startLine;
|
|
498
|
+
history.endLine = committed.endLine;
|
|
499
|
+
} else {
|
|
500
|
+
history.mode = 'file';
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (opts.patches && contentOmitted) {
|
|
504
|
+
warnings.push('Patches not shown: the file content is not exported.');
|
|
505
|
+
}
|
|
506
|
+
try {
|
|
507
|
+
// One commit more than the limit tells whether older commits exist
|
|
508
|
+
const commits = collectHistory(top, {
|
|
509
|
+
path: gitPath,
|
|
510
|
+
mode: history.mode,
|
|
511
|
+
startLine: history.startLine,
|
|
512
|
+
endLine: history.endLine,
|
|
513
|
+
funcname: history.funcname,
|
|
514
|
+
rev,
|
|
515
|
+
limit: opts.limit + 1,
|
|
516
|
+
since: opts.since,
|
|
517
|
+
patches: opts.patches && !contentOmitted,
|
|
518
|
+
});
|
|
519
|
+
history.limitReached = commits.length > opts.limit;
|
|
520
|
+
history.commits = commits.slice(0, opts.limit);
|
|
521
|
+
for (const commit of history.commits) {
|
|
522
|
+
if (project.info.boundary.has(commit.sha)) commit.shallowBoundary = true;
|
|
523
|
+
}
|
|
524
|
+
} catch (error) {
|
|
525
|
+
warnings.push(`git log failed: ${error.message.split('\n')[0]}`);
|
|
526
|
+
history.mode = 'none';
|
|
527
|
+
history.reason = 'git log failed';
|
|
528
|
+
}
|
|
529
|
+
return history;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Files changed in the same commits as the target, limited to paths inside the project
|
|
534
|
+
* root that the ignore rules keep
|
|
535
|
+
*/
|
|
536
|
+
function couplingSection(project, file, opts) {
|
|
537
|
+
const { top, projectRoot, rev, isExcluded, warnings } = project;
|
|
538
|
+
if (opts.couplingWindow === 0) return null;
|
|
539
|
+
|
|
540
|
+
const prefix = toGitPath(path.relative(top, projectRoot));
|
|
541
|
+
const toProject = (gitFile) => {
|
|
542
|
+
if (prefix === '') return gitFile;
|
|
543
|
+
return gitFile.startsWith(`${prefix}/`) ? gitFile.slice(prefix.length + 1) : null;
|
|
544
|
+
};
|
|
545
|
+
try {
|
|
546
|
+
const raw = collectCoupling(top, {
|
|
547
|
+
path: file.gitPath,
|
|
548
|
+
window: opts.couplingWindow,
|
|
549
|
+
maxFilesPerCommit: opts.maxFilesPerCommit,
|
|
550
|
+
rev,
|
|
551
|
+
keep: (gitFile) => {
|
|
552
|
+
const inProject = toProject(gitFile);
|
|
553
|
+
return inProject !== null && !isExcluded(inProject);
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
return {
|
|
557
|
+
...raw,
|
|
558
|
+
maxFilesPerCommit: opts.maxFilesPerCommit,
|
|
559
|
+
totalFiles: raw.files.length,
|
|
560
|
+
files: raw.files.slice(0, opts.couplingTop).map((entry) => {
|
|
561
|
+
const inProject = toProject(entry.path);
|
|
562
|
+
return {
|
|
563
|
+
...entry,
|
|
564
|
+
path: inProject,
|
|
565
|
+
exists: fs.existsSync(path.join(projectRoot, ...inProject.split('/'))),
|
|
566
|
+
};
|
|
567
|
+
}),
|
|
568
|
+
};
|
|
569
|
+
} catch (error) {
|
|
570
|
+
warnings.push(`Co-change analysis failed: ${error.message.split('\n')[0]}`);
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Explain why code looks the way it does, from the local git history
|
|
577
|
+
*
|
|
578
|
+
* @param {string} root - Project directory inside a git work tree. Target paths must stay
|
|
579
|
+
* inside it; its .gitignore / .contextignore / .contextinclude apply
|
|
580
|
+
* @param {string|{file: string, symbol?: string, startLine?: number, endLine?: number}} target
|
|
581
|
+
* `file`, `file:symbol` (function, method, class; `Class.method` too), `file#L10-L40`
|
|
582
|
+
* or `file:10-40`
|
|
583
|
+
* @param {object} [options]
|
|
584
|
+
* @param {number} [options.limit=20] - Commits to include
|
|
585
|
+
* @param {string} [options.since] - Only commits after this date (git date syntax)
|
|
586
|
+
* @param {string} [options.rev] - Explain the code as of this revision instead of the work tree
|
|
587
|
+
* @param {boolean} [options.patches=false] - Include each commit's diff of the target
|
|
588
|
+
* @param {number} [options.couplingWindow=500] - Commits of the file examined for co-changes (0 = off)
|
|
589
|
+
* @param {number} [options.couplingTop=10] - Co-changed files listed
|
|
590
|
+
* @param {number} [options.maxFilesPerCommit=50] - Larger commits are ignored for coupling
|
|
591
|
+
* @param {number} [options.maxRelated=20] - Related text snippets listed
|
|
592
|
+
* @param {number} [options.maxTests=10] - Test files listed
|
|
593
|
+
* @param {'markdown'|'xml'|'json'} [options.style='markdown']
|
|
594
|
+
* @param {number} [options.budget] - Maximum tokens of the output
|
|
595
|
+
* @param {string} [options.ask] - Question appended at the end of the document
|
|
596
|
+
* @param {boolean} [options.redact=true] - Mask secrets (lib/utils/secret-redactor.js)
|
|
597
|
+
* @param {string} [options.cwd] - Directory relative target paths start from (default: root)
|
|
598
|
+
* @returns {Promise<{text: string, style: string, tokens: number, budget: number|null,
|
|
599
|
+
* omitted: Record<string, number>, warnings: string[], data: object}>}
|
|
600
|
+
*/
|
|
601
|
+
export async function explainHistory(root, target, options = {}) {
|
|
602
|
+
const opts = normalizeOptions(options);
|
|
603
|
+
const project = openProject(root, opts);
|
|
604
|
+
const file = readTarget(project, target);
|
|
605
|
+
const selection = selectLines(project, file);
|
|
606
|
+
const { wholeFile, range, symbol } = selection;
|
|
607
|
+
const { spec, relPath, lines, totalLines, contentOmitted } = file;
|
|
608
|
+
|
|
609
|
+
const history = historySection(project, file, selection, opts);
|
|
610
|
+
const references = mineReferences(history.commits, project.remote);
|
|
611
|
+
for (const commit of history.commits) commit.refs = referencesOf(references, commit.shortSha);
|
|
612
|
+
|
|
613
|
+
// Blame of the current lines
|
|
614
|
+
const covered = wholeFile || range !== null;
|
|
615
|
+
let ownership = null;
|
|
616
|
+
if (!file.binary && covered) {
|
|
617
|
+
ownership = collectOwnership(project.top, {
|
|
618
|
+
path: file.gitPath,
|
|
619
|
+
startLine: range?.startLine,
|
|
620
|
+
endLine: range?.endLine,
|
|
621
|
+
rev: project.rev,
|
|
622
|
+
});
|
|
623
|
+
if (!ownership) project.warnings.push('git blame failed; ownership not available.');
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const coupling = history.reason === NEVER_COMMITTED ? null : couplingSection(project, file, opts);
|
|
627
|
+
|
|
628
|
+
// Definitions inside a line range double as search terms
|
|
629
|
+
const symbolsInRange =
|
|
630
|
+
range && !spec.symbol
|
|
631
|
+
? [
|
|
632
|
+
...new Set(
|
|
633
|
+
listDefinitions(file.content, relPath)
|
|
634
|
+
.filter((d) => d.line >= range.startLine && d.line <= range.endLine)
|
|
635
|
+
.map((d) => d.name)
|
|
636
|
+
),
|
|
637
|
+
].slice(0, 10)
|
|
638
|
+
: [];
|
|
639
|
+
|
|
640
|
+
// Docs, comments and tests that mention the target (work tree)
|
|
641
|
+
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
|
+
});
|
|
653
|
+
|
|
654
|
+
const shown = !contentOmitted && covered;
|
|
655
|
+
const first = range?.startLine ?? 1;
|
|
656
|
+
const last = range?.endLine ?? totalLines;
|
|
657
|
+
const notes = shown ? findNotes(lines, first, last, relPath).slice(0, MAX_NOTES) : [];
|
|
658
|
+
let source = null;
|
|
659
|
+
if (shown) {
|
|
660
|
+
source = wholeFile ? file.content.replace(/\n$/, '') : lines.slice(first - 1, last).join('\n');
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const { info, remote, rev } = project;
|
|
664
|
+
let data = {
|
|
665
|
+
target: {
|
|
666
|
+
path: relPath,
|
|
667
|
+
symbol: spec.symbol ?? null,
|
|
668
|
+
kind: symbol?.kind ?? null,
|
|
669
|
+
line: symbol?.line ?? null,
|
|
670
|
+
resolvedBy: symbol?.resolvedBy ?? null,
|
|
671
|
+
startLine: range?.startLine ?? (wholeFile && totalLines ? 1 : null),
|
|
672
|
+
endLine: range?.endLine ?? (wholeFile && totalLines ? totalLines : null),
|
|
673
|
+
totalLines,
|
|
674
|
+
wholeFile,
|
|
675
|
+
binary: file.binary,
|
|
676
|
+
revision:
|
|
677
|
+
rev ?? (info.head ? `working tree (HEAD ${info.head.slice(0, 12)})` : 'working tree'),
|
|
678
|
+
symbolsInRange,
|
|
679
|
+
contentOmitted,
|
|
680
|
+
source,
|
|
681
|
+
},
|
|
682
|
+
repository: {
|
|
683
|
+
remote: remote?.provider ? remote.webUrl : null,
|
|
684
|
+
provider: remote?.provider ?? null,
|
|
685
|
+
shallow: info.shallow,
|
|
686
|
+
},
|
|
687
|
+
history,
|
|
688
|
+
references,
|
|
689
|
+
ownership,
|
|
690
|
+
coupling,
|
|
691
|
+
related: found.related,
|
|
692
|
+
notes,
|
|
693
|
+
tests: found.tests,
|
|
694
|
+
warnings: project.warnings,
|
|
695
|
+
question: opts.ask,
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
if (opts.redact) {
|
|
699
|
+
const counts = {};
|
|
700
|
+
data = redactDeep(data, counts);
|
|
701
|
+
if (Object.keys(counts).length) {
|
|
702
|
+
data.warnings.push(
|
|
703
|
+
`Secrets masked as [REDACTED:<type>]: ${formatRedactionSummary(counts)} (use --no-redact to keep them).`
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const rendered = renderWithinBudget(data, { style: opts.style, budget: opts.budget });
|
|
709
|
+
return {
|
|
710
|
+
text: rendered.text,
|
|
711
|
+
style: opts.style,
|
|
712
|
+
tokens: rendered.tokens,
|
|
713
|
+
budget: opts.budget,
|
|
714
|
+
omitted: rendered.omitted,
|
|
715
|
+
warnings: data.warnings,
|
|
716
|
+
data,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
export default explainHistory;
|