ctxman 3.0.0 → 3.1.0

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 (223) hide show
  1. package/.ctxman/llm-profiles.json +1067 -39
  2. package/.ctxman/profiles/backend-team.json +13 -0
  3. package/.ctxman/profiles/default.json +12 -0
  4. package/.ctxman/profiles/devops-team.json +21 -0
  5. package/.ctxman/profiles/frontend-team.json +20 -0
  6. package/.ctxman/templates/bug-fix.json +17 -0
  7. package/.ctxman/templates/code-review.json +9 -0
  8. package/.ctxman/templates/documentation.json +9 -0
  9. package/.ctxman/templates/feature.json +9 -0
  10. package/.ctxman/templates/full-context.json +9 -0
  11. package/.ctxman/templates/refactoring.json +18 -0
  12. package/.ctxman/templates/testing.json +9 -0
  13. package/.ctxman/wizard-profiles/security-audit/profile.json +7 -1
  14. package/LICENSE +21 -0
  15. package/README.md +380 -136
  16. package/bin/cli.js +1880 -529
  17. package/bin/cm-ask.js +18 -13
  18. package/bin/cm-audit.js +124 -0
  19. package/bin/cm-count.js +151 -0
  20. package/bin/cm-gitingest.js +128 -115
  21. package/bin/cm-map.js +242 -0
  22. package/bin/cm-mcp.js +293 -0
  23. package/bin/cm-pack.js +405 -0
  24. package/bin/cm-policy.js +170 -0
  25. package/bin/cm-pr.js +248 -0
  26. package/bin/cm-search.js +178 -0
  27. package/bin/cm-update.js +128 -128
  28. package/bin/cm-why.js +197 -0
  29. package/bin/mcp-server.js +8 -24
  30. package/ctxman.js +237 -183
  31. package/examples/README.md +19 -4
  32. package/examples/github-actions/ctxman-check.yml +49 -0
  33. package/examples/wizard-profiles/security-audit/profile.json +7 -1
  34. package/index.d.ts +1422 -0
  35. package/index.js +55 -31
  36. package/lib/agents/pr-failure-agent.js +48 -45
  37. package/lib/analyzers/ai-suggester.js +451 -0
  38. package/lib/analyzers/dependency-scanner.js +505 -0
  39. package/lib/analyzers/duplicate-detector.js +264 -0
  40. package/lib/analyzers/go-method-analyzer.js +151 -110
  41. package/lib/analyzers/import-tracker.js +431 -0
  42. package/lib/analyzers/method-analyzer.js +1055 -434
  43. package/lib/analyzers/performance-dashboard.js +683 -0
  44. package/lib/analyzers/token-calculator.js +895 -504
  45. package/lib/analyzers/unused-exports-detector.js +336 -0
  46. package/lib/api/mcp/install.js +424 -0
  47. package/lib/api/mcp/prompts.js +281 -281
  48. package/lib/api/mcp/resources.js +300 -241
  49. package/lib/api/mcp/sandbox.js +89 -0
  50. package/lib/api/mcp/server.js +675 -312
  51. package/lib/api/mcp/start.js +125 -0
  52. package/lib/api/mcp/transports/HttpSseTransport.js +60 -52
  53. package/lib/api/rest/server.js +105 -28
  54. package/lib/cache/CacheManager.js +9 -17
  55. package/lib/core/Analyzer.js +16 -10
  56. package/lib/core/ContextBuilder.js +19 -28
  57. package/lib/core/Reporter.js +15 -14
  58. package/lib/core/Scanner.js +81 -12
  59. package/lib/count/index.js +100 -0
  60. package/lib/formatters/format-registry.js +394 -372
  61. package/lib/formatters/gitingest-formatter.js +656 -621
  62. package/lib/formatters/toon-formatter-v1.3.js +637 -632
  63. package/lib/formatters/toon-formatter.js +239 -240
  64. package/lib/integrations/git/BlameTracker.js +4 -4
  65. package/lib/integrations/git/DiffAnalyzer.js +38 -31
  66. package/lib/integrations/git/GitClient.js +71 -53
  67. package/lib/lsp/server.js +311 -0
  68. package/lib/map/graph.js +299 -0
  69. package/lib/map/imports.js +233 -0
  70. package/lib/map/index.js +33 -0
  71. package/lib/map/rank.js +253 -0
  72. package/lib/map/render.js +346 -0
  73. package/lib/map/resolver.js +574 -0
  74. package/lib/pack/budget.js +111 -0
  75. package/lib/pack/code-lexer.js +360 -0
  76. package/lib/pack/compress.js +414 -0
  77. package/lib/pack/index.js +354 -0
  78. package/lib/pack/renderers/common.js +134 -0
  79. package/lib/pack/renderers/index.js +24 -0
  80. package/lib/pack/renderers/json.js +47 -0
  81. package/lib/pack/renderers/markdown.js +54 -0
  82. package/lib/pack/renderers/plain.js +38 -0
  83. package/lib/pack/renderers/xml.js +85 -0
  84. package/lib/pack/select.js +177 -0
  85. package/lib/pack/split.js +182 -0
  86. package/lib/pack/tree.js +35 -0
  87. package/lib/parsers/gitignore-parser.js +113 -107
  88. package/lib/parsers/method-filter-parser.js +50 -33
  89. package/lib/plugins/ExporterPlugin.js +3 -3
  90. package/lib/plugins/LanguagePlugin.js +3 -3
  91. package/lib/plugins/PluginManager.js +7 -13
  92. package/lib/policy/audit.js +294 -0
  93. package/lib/policy/enforce.js +125 -0
  94. package/lib/policy/ignore-files.js +169 -0
  95. package/lib/policy/index.js +52 -0
  96. package/lib/policy/policy.js +489 -0
  97. package/lib/pr/diff-parser.js +267 -0
  98. package/lib/pr/method-mapper.js +148 -0
  99. package/lib/pr/pr-context.js +829 -0
  100. package/lib/pr/pr-git.js +149 -0
  101. package/lib/pr/pr-renderer.js +644 -0
  102. package/lib/pr/related-tests.js +296 -0
  103. package/lib/rag/DataSourcePlugin.js +5 -5
  104. package/lib/rag/EmbeddingProvider.js +19 -8
  105. package/lib/rag/Indexer.js +14 -5
  106. package/lib/rag/VectorStoreFactory.js +3 -3
  107. package/lib/rag/sources/FileDataSource.js +6 -6
  108. package/lib/rag/stores/BaseVectorStore.js +5 -5
  109. package/lib/rag/stores/ChromaDBStore.js +10 -10
  110. package/lib/rag/stores/LanceDBStore.js +29 -19
  111. package/lib/rag/stores/PineconeStore.js +11 -9
  112. package/lib/search/bm25.js +433 -0
  113. package/lib/search/code-units.js +201 -0
  114. package/lib/search/symbols.js +189 -0
  115. package/lib/ui/dashboard.js +120 -89
  116. package/lib/ui/index.js +1 -6
  117. package/lib/ui/init-wizard-ui.js +550 -0
  118. package/lib/ui/progress-bar.js +94 -88
  119. package/lib/ui/select-input.js +40 -32
  120. package/lib/ui/template-selector.js +99 -0
  121. package/lib/ui/wizard.js +374 -329
  122. package/lib/utils/claude-token-counter.js +151 -0
  123. package/lib/utils/cli-output.js +17 -0
  124. package/lib/utils/clipboard-utils.js +49 -50
  125. package/lib/utils/config-utils.js +81 -66
  126. package/lib/utils/context-versioning.js +532 -0
  127. package/lib/utils/error-handler.js +112 -112
  128. package/lib/utils/file-utils.js +153 -69
  129. package/lib/utils/format-converter.js +356 -283
  130. package/lib/utils/git-utils.js +366 -294
  131. package/lib/utils/llm-cost-estimator.js +281 -0
  132. package/lib/utils/llm-detector.js +242 -78
  133. package/lib/utils/logger.js +243 -237
  134. package/lib/utils/messages.js +355 -0
  135. package/lib/utils/multi-repo-manager.js +364 -0
  136. package/lib/utils/profile-manager.js +380 -0
  137. package/lib/utils/safe-read.js +61 -0
  138. package/lib/utils/secret-redactor.js +61 -0
  139. package/lib/utils/snapshot-manager.js +679 -0
  140. package/lib/utils/template-manager.js +378 -0
  141. package/lib/utils/token-utils.js +201 -170
  142. package/lib/utils/tokenizer-adapter.js +421 -377
  143. package/lib/utils/updater.js +539 -506
  144. package/lib/watch/FileWatcher.js +12 -8
  145. package/lib/watch/IncrementalAnalyzer.js +7 -7
  146. package/lib/why/coupling.js +81 -0
  147. package/lib/why/errors.js +23 -0
  148. package/lib/why/git.js +169 -0
  149. package/lib/why/history.js +108 -0
  150. package/lib/why/index.js +720 -0
  151. package/lib/why/ownership.js +79 -0
  152. package/lib/why/paths.js +207 -0
  153. package/lib/why/references.js +259 -0
  154. package/lib/why/render.js +722 -0
  155. package/lib/why/search.js +358 -0
  156. package/lib/why/target.js +389 -0
  157. package/lib/wizards/config-generator.js +255 -0
  158. package/lib/wizards/project-detector.js +571 -0
  159. package/package.json +44 -10
  160. package/.ctxman/logs/ctxman-2026-07-02.log +0 -156
  161. package/.ctxman/self-knowledge/documents.lance/_transactions/0-b3dd1558-f95b-44b9-a154-18e8c330ae7a.txn +0 -0
  162. package/.ctxman/self-knowledge/documents.lance/_transactions/1-44bff6d4-e552-4010-a769-e09ad822698d.txn +0 -0
  163. package/.ctxman/self-knowledge/documents.lance/_transactions/10-37260794-5947-4ecb-b4c8-d732f58f40f1.txn +0 -0
  164. package/.ctxman/self-knowledge/documents.lance/_transactions/11-0d52a883-5518-463c-9b6d-986c3af89dfd.txn +0 -0
  165. package/.ctxman/self-knowledge/documents.lance/_transactions/12-ae354646-52f2-49c9-90fb-2e6ed8932663.txn +0 -0
  166. package/.ctxman/self-knowledge/documents.lance/_transactions/13-5d7d5e57-27da-498b-b734-091b9d38da13.txn +0 -0
  167. package/.ctxman/self-knowledge/documents.lance/_transactions/14-558ce928-810b-4252-a364-fdd1ee6f8a25.txn +0 -0
  168. package/.ctxman/self-knowledge/documents.lance/_transactions/15-27e3a58f-8b64-43b8-a9f4-e3369446968b.txn +0 -0
  169. package/.ctxman/self-knowledge/documents.lance/_transactions/16-a110c390-e5c8-4464-b976-bc7f5195fa15.txn +0 -0
  170. package/.ctxman/self-knowledge/documents.lance/_transactions/17-97513225-53de-4216-b61a-6337bd0cc4ad.txn +0 -0
  171. package/.ctxman/self-knowledge/documents.lance/_transactions/18-5ba450f0-c527-459c-bf4f-6d79b5e4285f.txn +0 -0
  172. package/.ctxman/self-knowledge/documents.lance/_transactions/19-600d3a63-5225-49e9-b8b7-30e037b564ab.txn +0 -0
  173. package/.ctxman/self-knowledge/documents.lance/_transactions/2-48a61b4e-92cf-4342-8376-914c1b99a9f9.txn +0 -0
  174. package/.ctxman/self-knowledge/documents.lance/_transactions/20-121dd912-5410-410e-84ff-82c7a23604a0.txn +0 -0
  175. package/.ctxman/self-knowledge/documents.lance/_transactions/3-bc6844ab-7ea3-4bb3-9ee1-538ce5664a9c.txn +0 -0
  176. package/.ctxman/self-knowledge/documents.lance/_transactions/4-d5e1754f-dbb5-454e-a9be-2a7da8d7bcf9.txn +0 -0
  177. package/.ctxman/self-knowledge/documents.lance/_transactions/5-4fae93df-0568-4b6f-8acf-8bef4bba6047.txn +0 -0
  178. package/.ctxman/self-knowledge/documents.lance/_transactions/6-4b1b8b87-068e-49a3-ba2b-d8dae628b252.txn +0 -0
  179. package/.ctxman/self-knowledge/documents.lance/_transactions/7-63d040fb-d2fa-4c0f-bed0-53411918a7b0.txn +0 -0
  180. package/.ctxman/self-knowledge/documents.lance/_transactions/8-c5628cf6-264c-420f-aab8-bc96a9083591.txn +0 -0
  181. package/.ctxman/self-knowledge/documents.lance/_transactions/9-c94570aa-5e53-4fcb-971f-cecc1d1d5492.txn +0 -0
  182. package/.ctxman/self-knowledge/documents.lance/_versions/1.manifest +0 -0
  183. package/.ctxman/self-knowledge/documents.lance/_versions/10.manifest +0 -0
  184. package/.ctxman/self-knowledge/documents.lance/_versions/11.manifest +0 -0
  185. package/.ctxman/self-knowledge/documents.lance/_versions/12.manifest +0 -0
  186. package/.ctxman/self-knowledge/documents.lance/_versions/13.manifest +0 -0
  187. package/.ctxman/self-knowledge/documents.lance/_versions/14.manifest +0 -0
  188. package/.ctxman/self-knowledge/documents.lance/_versions/15.manifest +0 -0
  189. package/.ctxman/self-knowledge/documents.lance/_versions/16.manifest +0 -0
  190. package/.ctxman/self-knowledge/documents.lance/_versions/17.manifest +0 -0
  191. package/.ctxman/self-knowledge/documents.lance/_versions/18.manifest +0 -0
  192. package/.ctxman/self-knowledge/documents.lance/_versions/19.manifest +0 -0
  193. package/.ctxman/self-knowledge/documents.lance/_versions/2.manifest +0 -0
  194. package/.ctxman/self-knowledge/documents.lance/_versions/20.manifest +0 -0
  195. package/.ctxman/self-knowledge/documents.lance/_versions/21.manifest +0 -0
  196. package/.ctxman/self-knowledge/documents.lance/_versions/3.manifest +0 -0
  197. package/.ctxman/self-knowledge/documents.lance/_versions/4.manifest +0 -0
  198. package/.ctxman/self-knowledge/documents.lance/_versions/5.manifest +0 -0
  199. package/.ctxman/self-knowledge/documents.lance/_versions/6.manifest +0 -0
  200. package/.ctxman/self-knowledge/documents.lance/_versions/7.manifest +0 -0
  201. package/.ctxman/self-knowledge/documents.lance/_versions/8.manifest +0 -0
  202. package/.ctxman/self-knowledge/documents.lance/_versions/9.manifest +0 -0
  203. package/.ctxman/self-knowledge/documents.lance/data/000010010011011101000010de8621405d9d5cb06540dbf02c.lance +0 -0
  204. package/.ctxman/self-knowledge/documents.lance/data/0000101001011100011000003fb62a4aa8978974075c144f1c.lance +0 -0
  205. package/.ctxman/self-knowledge/documents.lance/data/000011010000100111001011c6cc824d628f3bb1f4e04cd5d0.lance +0 -0
  206. package/.ctxman/self-knowledge/documents.lance/data/001000001000011001011100d8f74349ada090b332cd1df669.lance +0 -0
  207. package/.ctxman/self-knowledge/documents.lance/data/01000001111010001000010045ab44467bb1e7e4d8ae06bd58.lance +0 -0
  208. package/.ctxman/self-knowledge/documents.lance/data/01000101101110111110011131ef904241a89e1ffb6ddaf8e3.lance +0 -0
  209. package/.ctxman/self-knowledge/documents.lance/data/010010110000010000011010fbfcc145c6a8a827b28057a07e.lance +0 -0
  210. package/.ctxman/self-knowledge/documents.lance/data/1000011000101010001110003fb4f045c3835982efa1405324.lance +0 -0
  211. package/.ctxman/self-knowledge/documents.lance/data/100111011101110111101011f4396b410fa06af3b028d204e6.lance +0 -0
  212. package/.ctxman/self-knowledge/documents.lance/data/100111100111101000001100596b174e00995d6cc2969cc8dc.lance +0 -0
  213. package/.ctxman/self-knowledge/documents.lance/data/1010001100011000110010102ea16b484d887c7c9725feffd6.lance +0 -0
  214. package/.ctxman/self-knowledge/documents.lance/data/101101100010101111110111c5230944d9b3af7301b6392dda.lance +0 -0
  215. package/.ctxman/self-knowledge/documents.lance/data/1011011100100111111110105892bf41b5a530fedd7cf516f1.lance +0 -0
  216. package/.ctxman/self-knowledge/documents.lance/data/10111110000010001011110164e8774742bade87b483c10f36.lance +0 -0
  217. package/.ctxman/self-knowledge/documents.lance/data/1100000100010110001110103ffaca40a2a42c85067ebaf9ae.lance +0 -0
  218. package/.ctxman/self-knowledge/documents.lance/data/110000010001110110101110664bde4c76a78dfc14b05e83b8.lance +0 -0
  219. package/.ctxman/self-knowledge/documents.lance/data/11001100111000010100110050645c4281b1f35b2022234f7f.lance +0 -0
  220. package/.ctxman/self-knowledge/documents.lance/data/1101111011001100110111017214924a368f5719eab2246619.lance +0 -0
  221. package/.ctxman/self-knowledge/documents.lance/data/1110000111001010001000002150c24dcab653d39d07032e63.lance +0 -0
  222. package/.ctxman/self-knowledge/documents.lance/data/1110100000000100000011014d20534967a18b5c87721659ff.lance +0 -0
  223. package/.ctxman/self-knowledge/documents.lance/data/11110101111010000101011135a0114ef2b35dc6f1297a3381.lance +0 -0
@@ -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;