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.
Files changed (224) 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 +381 -136
  16. package/bin/cli.js +1880 -529
  17. package/bin/cm-ask.js +18 -67
  18. package/bin/cm-audit.js +121 -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 +687 -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 +772 -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 +116 -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 +312 -0
  93. package/lib/policy/enforce.js +125 -0
  94. package/lib/policy/ignore-files.js +169 -0
  95. package/lib/policy/index.js +53 -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/ask.js +122 -0
  108. package/lib/rag/sources/FileDataSource.js +12 -9
  109. package/lib/rag/stores/BaseVectorStore.js +5 -5
  110. package/lib/rag/stores/ChromaDBStore.js +10 -10
  111. package/lib/rag/stores/LanceDBStore.js +29 -19
  112. package/lib/rag/stores/PineconeStore.js +11 -9
  113. package/lib/search/bm25.js +433 -0
  114. package/lib/search/code-units.js +201 -0
  115. package/lib/search/symbols.js +189 -0
  116. package/lib/ui/dashboard.js +120 -89
  117. package/lib/ui/index.js +1 -6
  118. package/lib/ui/init-wizard-ui.js +550 -0
  119. package/lib/ui/progress-bar.js +94 -88
  120. package/lib/ui/select-input.js +40 -32
  121. package/lib/ui/template-selector.js +99 -0
  122. package/lib/ui/wizard.js +374 -329
  123. package/lib/utils/claude-token-counter.js +151 -0
  124. package/lib/utils/cli-output.js +17 -0
  125. package/lib/utils/clipboard-utils.js +49 -50
  126. package/lib/utils/config-utils.js +81 -66
  127. package/lib/utils/context-versioning.js +532 -0
  128. package/lib/utils/error-handler.js +112 -112
  129. package/lib/utils/file-utils.js +153 -69
  130. package/lib/utils/format-converter.js +356 -283
  131. package/lib/utils/git-utils.js +366 -294
  132. package/lib/utils/llm-cost-estimator.js +281 -0
  133. package/lib/utils/llm-detector.js +242 -78
  134. package/lib/utils/logger.js +246 -237
  135. package/lib/utils/messages.js +355 -0
  136. package/lib/utils/multi-repo-manager.js +364 -0
  137. package/lib/utils/profile-manager.js +380 -0
  138. package/lib/utils/safe-read.js +61 -0
  139. package/lib/utils/secret-redactor.js +61 -0
  140. package/lib/utils/snapshot-manager.js +679 -0
  141. package/lib/utils/template-manager.js +378 -0
  142. package/lib/utils/token-utils.js +201 -170
  143. package/lib/utils/tokenizer-adapter.js +421 -377
  144. package/lib/utils/updater.js +539 -506
  145. package/lib/watch/FileWatcher.js +12 -8
  146. package/lib/watch/IncrementalAnalyzer.js +7 -7
  147. package/lib/why/coupling.js +81 -0
  148. package/lib/why/errors.js +23 -0
  149. package/lib/why/git.js +169 -0
  150. package/lib/why/history.js +108 -0
  151. package/lib/why/index.js +720 -0
  152. package/lib/why/ownership.js +79 -0
  153. package/lib/why/paths.js +207 -0
  154. package/lib/why/references.js +259 -0
  155. package/lib/why/render.js +722 -0
  156. package/lib/why/search.js +358 -0
  157. package/lib/why/target.js +389 -0
  158. package/lib/wizards/config-generator.js +257 -0
  159. package/lib/wizards/project-detector.js +572 -0
  160. package/package.json +44 -10
  161. package/.ctxman/logs/ctxman-2026-07-02.log +0 -156
  162. package/.ctxman/self-knowledge/documents.lance/_transactions/0-b3dd1558-f95b-44b9-a154-18e8c330ae7a.txn +0 -0
  163. package/.ctxman/self-knowledge/documents.lance/_transactions/1-44bff6d4-e552-4010-a769-e09ad822698d.txn +0 -0
  164. package/.ctxman/self-knowledge/documents.lance/_transactions/10-37260794-5947-4ecb-b4c8-d732f58f40f1.txn +0 -0
  165. package/.ctxman/self-knowledge/documents.lance/_transactions/11-0d52a883-5518-463c-9b6d-986c3af89dfd.txn +0 -0
  166. package/.ctxman/self-knowledge/documents.lance/_transactions/12-ae354646-52f2-49c9-90fb-2e6ed8932663.txn +0 -0
  167. package/.ctxman/self-knowledge/documents.lance/_transactions/13-5d7d5e57-27da-498b-b734-091b9d38da13.txn +0 -0
  168. package/.ctxman/self-knowledge/documents.lance/_transactions/14-558ce928-810b-4252-a364-fdd1ee6f8a25.txn +0 -0
  169. package/.ctxman/self-knowledge/documents.lance/_transactions/15-27e3a58f-8b64-43b8-a9f4-e3369446968b.txn +0 -0
  170. package/.ctxman/self-knowledge/documents.lance/_transactions/16-a110c390-e5c8-4464-b976-bc7f5195fa15.txn +0 -0
  171. package/.ctxman/self-knowledge/documents.lance/_transactions/17-97513225-53de-4216-b61a-6337bd0cc4ad.txn +0 -0
  172. package/.ctxman/self-knowledge/documents.lance/_transactions/18-5ba450f0-c527-459c-bf4f-6d79b5e4285f.txn +0 -0
  173. package/.ctxman/self-knowledge/documents.lance/_transactions/19-600d3a63-5225-49e9-b8b7-30e037b564ab.txn +0 -0
  174. package/.ctxman/self-knowledge/documents.lance/_transactions/2-48a61b4e-92cf-4342-8376-914c1b99a9f9.txn +0 -0
  175. package/.ctxman/self-knowledge/documents.lance/_transactions/20-121dd912-5410-410e-84ff-82c7a23604a0.txn +0 -0
  176. package/.ctxman/self-knowledge/documents.lance/_transactions/3-bc6844ab-7ea3-4bb3-9ee1-538ce5664a9c.txn +0 -0
  177. package/.ctxman/self-knowledge/documents.lance/_transactions/4-d5e1754f-dbb5-454e-a9be-2a7da8d7bcf9.txn +0 -0
  178. package/.ctxman/self-knowledge/documents.lance/_transactions/5-4fae93df-0568-4b6f-8acf-8bef4bba6047.txn +0 -0
  179. package/.ctxman/self-knowledge/documents.lance/_transactions/6-4b1b8b87-068e-49a3-ba2b-d8dae628b252.txn +0 -0
  180. package/.ctxman/self-knowledge/documents.lance/_transactions/7-63d040fb-d2fa-4c0f-bed0-53411918a7b0.txn +0 -0
  181. package/.ctxman/self-knowledge/documents.lance/_transactions/8-c5628cf6-264c-420f-aab8-bc96a9083591.txn +0 -0
  182. package/.ctxman/self-knowledge/documents.lance/_transactions/9-c94570aa-5e53-4fcb-971f-cecc1d1d5492.txn +0 -0
  183. package/.ctxman/self-knowledge/documents.lance/_versions/1.manifest +0 -0
  184. package/.ctxman/self-knowledge/documents.lance/_versions/10.manifest +0 -0
  185. package/.ctxman/self-knowledge/documents.lance/_versions/11.manifest +0 -0
  186. package/.ctxman/self-knowledge/documents.lance/_versions/12.manifest +0 -0
  187. package/.ctxman/self-knowledge/documents.lance/_versions/13.manifest +0 -0
  188. package/.ctxman/self-knowledge/documents.lance/_versions/14.manifest +0 -0
  189. package/.ctxman/self-knowledge/documents.lance/_versions/15.manifest +0 -0
  190. package/.ctxman/self-knowledge/documents.lance/_versions/16.manifest +0 -0
  191. package/.ctxman/self-knowledge/documents.lance/_versions/17.manifest +0 -0
  192. package/.ctxman/self-knowledge/documents.lance/_versions/18.manifest +0 -0
  193. package/.ctxman/self-knowledge/documents.lance/_versions/19.manifest +0 -0
  194. package/.ctxman/self-knowledge/documents.lance/_versions/2.manifest +0 -0
  195. package/.ctxman/self-knowledge/documents.lance/_versions/20.manifest +0 -0
  196. package/.ctxman/self-knowledge/documents.lance/_versions/21.manifest +0 -0
  197. package/.ctxman/self-knowledge/documents.lance/_versions/3.manifest +0 -0
  198. package/.ctxman/self-knowledge/documents.lance/_versions/4.manifest +0 -0
  199. package/.ctxman/self-knowledge/documents.lance/_versions/5.manifest +0 -0
  200. package/.ctxman/self-knowledge/documents.lance/_versions/6.manifest +0 -0
  201. package/.ctxman/self-knowledge/documents.lance/_versions/7.manifest +0 -0
  202. package/.ctxman/self-knowledge/documents.lance/_versions/8.manifest +0 -0
  203. package/.ctxman/self-knowledge/documents.lance/_versions/9.manifest +0 -0
  204. package/.ctxman/self-knowledge/documents.lance/data/000010010011011101000010de8621405d9d5cb06540dbf02c.lance +0 -0
  205. package/.ctxman/self-knowledge/documents.lance/data/0000101001011100011000003fb62a4aa8978974075c144f1c.lance +0 -0
  206. package/.ctxman/self-knowledge/documents.lance/data/000011010000100111001011c6cc824d628f3bb1f4e04cd5d0.lance +0 -0
  207. package/.ctxman/self-knowledge/documents.lance/data/001000001000011001011100d8f74349ada090b332cd1df669.lance +0 -0
  208. package/.ctxman/self-knowledge/documents.lance/data/01000001111010001000010045ab44467bb1e7e4d8ae06bd58.lance +0 -0
  209. package/.ctxman/self-knowledge/documents.lance/data/01000101101110111110011131ef904241a89e1ffb6ddaf8e3.lance +0 -0
  210. package/.ctxman/self-knowledge/documents.lance/data/010010110000010000011010fbfcc145c6a8a827b28057a07e.lance +0 -0
  211. package/.ctxman/self-knowledge/documents.lance/data/1000011000101010001110003fb4f045c3835982efa1405324.lance +0 -0
  212. package/.ctxman/self-knowledge/documents.lance/data/100111011101110111101011f4396b410fa06af3b028d204e6.lance +0 -0
  213. package/.ctxman/self-knowledge/documents.lance/data/100111100111101000001100596b174e00995d6cc2969cc8dc.lance +0 -0
  214. package/.ctxman/self-knowledge/documents.lance/data/1010001100011000110010102ea16b484d887c7c9725feffd6.lance +0 -0
  215. package/.ctxman/self-knowledge/documents.lance/data/101101100010101111110111c5230944d9b3af7301b6392dda.lance +0 -0
  216. package/.ctxman/self-knowledge/documents.lance/data/1011011100100111111110105892bf41b5a530fedd7cf516f1.lance +0 -0
  217. package/.ctxman/self-knowledge/documents.lance/data/10111110000010001011110164e8774742bade87b483c10f36.lance +0 -0
  218. package/.ctxman/self-knowledge/documents.lance/data/1100000100010110001110103ffaca40a2a42c85067ebaf9ae.lance +0 -0
  219. package/.ctxman/self-knowledge/documents.lance/data/110000010001110110101110664bde4c76a78dfc14b05e83b8.lance +0 -0
  220. package/.ctxman/self-knowledge/documents.lance/data/11001100111000010100110050645c4281b1f35b2022234f7f.lance +0 -0
  221. package/.ctxman/self-knowledge/documents.lance/data/1101111011001100110111017214924a368f5719eab2246619.lance +0 -0
  222. package/.ctxman/self-knowledge/documents.lance/data/1110000111001010001000002150c24dcab653d39d07032e63.lance +0 -0
  223. package/.ctxman/self-knowledge/documents.lance/data/1110100000000100000011014d20534967a18b5c87721659ff.lance +0 -0
  224. package/.ctxman/self-knowledge/documents.lance/data/11110101111010000101011135a0114ef2b35dc6f1297a3381.lance +0 -0
@@ -0,0 +1,722 @@
1
+ /**
2
+ * Output of `ctxman why`: Markdown (default, for pasting into an LLM), XML and JSON, and
3
+ * fitting the document into a token budget by dropping the least useful material first
4
+ */
5
+
6
+ import path from 'path';
7
+ import TokenUtils from '../utils/token-utils.js';
8
+ import { WhyError } from './errors.js';
9
+
10
+ export const STYLES = ['markdown', 'xml', 'json'];
11
+
12
+ /** Sections a budget can shorten */
13
+ export const DROP_ORDER = [
14
+ 'patches',
15
+ 'related',
16
+ 'tests',
17
+ 'coupling',
18
+ 'commits',
19
+ 'references',
20
+ 'notes',
21
+ 'ownership',
22
+ 'source',
23
+ ];
24
+
25
+ /**
26
+ * What a budget drops, first to last, as [section, items kept at least]. The first five
27
+ * steps follow the documented priority (patches, related text, tests, coupling details,
28
+ * older commits, keeping the newest one); the rest only run when the budget is smaller
29
+ * than the core of the document
30
+ */
31
+ const DROP_STEPS = [
32
+ ['patches', 0],
33
+ ['related', 0],
34
+ ['tests', 0],
35
+ ['coupling', 0],
36
+ ['commits', 1],
37
+ ['references', 0],
38
+ ['notes', 0],
39
+ ['ownership', 0],
40
+ ['source', 0],
41
+ ['commits', 0],
42
+ ];
43
+
44
+ const OMITTED_LABELS = {
45
+ patches: 'patches',
46
+ related: 'related text snippets',
47
+ tests: 'test files',
48
+ coupling: 'co-changed files',
49
+ commits: 'commits',
50
+ references: 'references',
51
+ notes: 'notes',
52
+ ownership: 'blame authors',
53
+ source: 'source lines',
54
+ };
55
+
56
+ const LANGUAGES = {
57
+ '.js': 'javascript',
58
+ '.mjs': 'javascript',
59
+ '.cjs': 'javascript',
60
+ '.jsx': 'jsx',
61
+ '.ts': 'typescript',
62
+ '.tsx': 'tsx',
63
+ '.py': 'python',
64
+ '.rb': 'ruby',
65
+ '.rs': 'rust',
66
+ '.go': 'go',
67
+ '.java': 'java',
68
+ '.kt': 'kotlin',
69
+ '.kts': 'kotlin',
70
+ '.cs': 'csharp',
71
+ '.c': 'c',
72
+ '.h': 'c',
73
+ '.cc': 'cpp',
74
+ '.cpp': 'cpp',
75
+ '.hpp': 'cpp',
76
+ '.swift': 'swift',
77
+ '.scala': 'scala',
78
+ '.php': 'php',
79
+ '.md': 'markdown',
80
+ '.yml': 'yaml',
81
+ '.yaml': 'yaml',
82
+ '.json': 'json',
83
+ '.sh': 'bash',
84
+ '.sql': 'sql',
85
+ };
86
+
87
+ /**
88
+ * Flattened reference list, highest value first
89
+ */
90
+ function flattenReferences(references) {
91
+ return [
92
+ ...references.issues.map((entry) => ({ type: 'issue', ...entry })),
93
+ ...references.tickets.map((entry) => ({ type: 'ticket', ...entry })),
94
+ ...references.cves.map((entry) => ({ type: 'cve', ...entry })),
95
+ ...references.urls.map((entry) => ({ type: 'url', ...entry })),
96
+ ...references.coAuthors.map((entry) => ({ type: 'co-author', ...entry })),
97
+ ...references.reviewers.map((entry) => ({ type: 'reviewer', ...entry })),
98
+ ];
99
+ }
100
+
101
+ /**
102
+ * Number of droppable items per DROP_ORDER key
103
+ * @param {object} data
104
+ * @returns {Record<string, number>}
105
+ */
106
+ export function countItems(data) {
107
+ return {
108
+ patches: data.history.commits.filter((commit) => commit.patch).length,
109
+ related: data.related.length,
110
+ tests: data.tests.length,
111
+ coupling: data.coupling?.files.length ?? 0,
112
+ commits: data.history.commits.length,
113
+ references: flattenReferences(data.references).length,
114
+ notes: data.notes.length,
115
+ ownership: data.ownership?.authors.length ?? 0,
116
+ source: data.target.source === null ? 0 : data.target.source.split('\n').length,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Copy of `data` with only the first `view[key]` items of each section
122
+ * @param {object} data
123
+ * @param {Record<string, number>} view
124
+ * @returns {object}
125
+ */
126
+ export function applyView(data, view) {
127
+ const totals = countItems(data);
128
+ const omitted = {};
129
+ for (const key of DROP_ORDER) {
130
+ if (view[key] < totals[key]) omitted[key] = totals[key] - view[key];
131
+ }
132
+
133
+ let patchesKept = 0;
134
+ const commits = data.history.commits.slice(0, view.commits).map((commit) => {
135
+ if (!commit.patch) return commit;
136
+ const { patch, ...rest } = commit;
137
+ return patchesKept++ < view.patches ? { ...rest, patch } : rest;
138
+ });
139
+
140
+ const kept = flattenReferences(data.references).slice(0, view.references);
141
+ const pick = (type) =>
142
+ kept.filter((entry) => entry.type === type).map(({ type: _type, ...entry }) => entry);
143
+ const references = {
144
+ issues: pick('issue'),
145
+ tickets: pick('ticket'),
146
+ cves: pick('cve'),
147
+ urls: pick('url'),
148
+ coAuthors: pick('co-author'),
149
+ reviewers: pick('reviewer'),
150
+ };
151
+
152
+ let target = data.target;
153
+ if (target.source !== null && view.source < totals.source) {
154
+ target =
155
+ view.source === 0
156
+ ? { ...target, source: null, contentOmitted: 'omitted to fit the token budget' }
157
+ : { ...target, source: target.source.split('\n').slice(0, view.source).join('\n') };
158
+ }
159
+
160
+ return {
161
+ ...data,
162
+ target,
163
+ history: { ...data.history, commits },
164
+ references,
165
+ ownership: data.ownership && {
166
+ ...data.ownership,
167
+ authors: data.ownership.authors.slice(0, view.ownership),
168
+ },
169
+ coupling: data.coupling && {
170
+ ...data.coupling,
171
+ files: data.coupling.files.slice(0, view.coupling),
172
+ },
173
+ related: data.related.slice(0, view.related),
174
+ notes: data.notes.slice(0, view.notes),
175
+ tests: data.tests.slice(0, view.tests),
176
+ omitted,
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Render a (trimmed) document
182
+ * @param {object} data - Output of applyView
183
+ * @param {string} style - markdown | xml | json
184
+ * @param {number|null} budget
185
+ * @returns {string}
186
+ */
187
+ export function render(data, style, budget = null) {
188
+ if (style === 'json') return renderJson(data, budget);
189
+ if (style === 'xml') return renderXml(data, budget);
190
+ return renderMarkdown(data, budget);
191
+ }
192
+
193
+ /**
194
+ * Render `data`, dropping material in DROP_ORDER until the text fits `budget` tokens
195
+ * @param {object} data
196
+ * @param {object} options
197
+ * @param {string} options.style
198
+ * @param {number|null} options.budget
199
+ * @returns {{text: string, tokens: number, omitted: Record<string, number>}}
200
+ */
201
+ export function renderWithinBudget(data, { style, budget }) {
202
+ const fileName = `why.${style === 'markdown' ? 'md' : style}`;
203
+ const count = (text) => TokenUtils.calculate(text, fileName);
204
+ const totals = countItems(data);
205
+ const attempt = (view) => {
206
+ const trimmed = applyView(data, view);
207
+ const text = render(trimmed, style, budget);
208
+ return { text, tokens: count(text), omitted: trimmed.omitted };
209
+ };
210
+
211
+ let view = { ...totals };
212
+ let result = attempt(view);
213
+ if (!budget || result.tokens <= budget) return result;
214
+
215
+ for (const [key, floor] of DROP_STEPS) {
216
+ if (view[key] <= floor) continue;
217
+ const without = attempt({ ...view, [key]: floor });
218
+ if (without.tokens > budget) {
219
+ view = { ...view, [key]: floor };
220
+ result = without;
221
+ continue;
222
+ }
223
+ // Largest number of items of this kind that still fits
224
+ let low = floor;
225
+ let high = view[key] - 1;
226
+ let best = without;
227
+ while (low < high) {
228
+ const middle = Math.ceil((low + high) / 2);
229
+ const candidate = attempt({ ...view, [key]: middle });
230
+ if (candidate.tokens <= budget) {
231
+ low = middle;
232
+ best = candidate;
233
+ } else {
234
+ high = middle - 1;
235
+ }
236
+ }
237
+ return best;
238
+ }
239
+
240
+ throw new WhyError(
241
+ 'BUDGET_TOO_SMALL',
242
+ `--budget ${budget} is too small: the target summary alone needs ${result.tokens} tokens`,
243
+ { minimum: result.tokens }
244
+ );
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Markdown
249
+ // ---------------------------------------------------------------------------
250
+
251
+ function fence(text, language = '') {
252
+ const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map((match) => match[0].length));
253
+ const marks = '`'.repeat(Math.max(3, longest + 1));
254
+ return `${marks}${language}\n${text}\n${marks}`;
255
+ }
256
+
257
+ function code(text) {
258
+ const value = String(text);
259
+ const longest = Math.max(0, ...[...value.matchAll(/`+/g)].map((match) => match[0].length));
260
+ const marks = '`'.repeat(longest + 1);
261
+ const pad = value.startsWith('`') || value.endsWith('`') ? ' ' : '';
262
+ return `${marks}${pad}${value}${pad}${marks}`;
263
+ }
264
+
265
+ const lineSpan = (start, end) => (start === end ? `line ${start}` : `lines ${start}-${end}`);
266
+ const day = (iso) => (iso ? iso.slice(0, 10) : 'unknown');
267
+ const percent = (share) => `${Math.round(share * 100)}%`;
268
+
269
+ function title(target) {
270
+ if (target.symbol) return `${code(target.symbol)} in ${code(target.path)}`;
271
+ if (target.wholeFile) return code(target.path);
272
+ return `${code(target.path)} ${lineSpan(target.startLine, target.endLine)}`;
273
+ }
274
+
275
+ function omittedSummary(omitted) {
276
+ return Object.entries(omitted)
277
+ .map(([key, value]) => `${value} ${OMITTED_LABELS[key]}`)
278
+ .join(', ');
279
+ }
280
+
281
+ function historyDescription(history) {
282
+ switch (history.mode) {
283
+ case 'range':
284
+ return `that changed ${lineSpan(history.startLine, history.endLine)} (\`git log -L\`)`;
285
+ case 'funcname':
286
+ return `that changed the function (\`git log -L :${history.funcname}\`)`;
287
+ case 'file':
288
+ return 'that changed the file, following renames (`git log --follow`)';
289
+ default:
290
+ return '';
291
+ }
292
+ }
293
+
294
+ function renderMarkdown(data, budget) {
295
+ const out = [`# Why: ${title(data.target)}`, ''];
296
+ out.push(
297
+ 'Context for explaining why this code looks the way it does, collected by `ctxman why` from the local git history and the repository (no network).',
298
+ ''
299
+ );
300
+ for (const warning of data.warnings) out.push(`> **Warning:** ${warning}`);
301
+ if (data.warnings.length) out.push('');
302
+ if (Object.keys(data.omitted).length) {
303
+ out.push(`> Trimmed to fit ${budget} tokens: omitted ${omittedSummary(data.omitted)}.`, '');
304
+ }
305
+
306
+ for (const section of [
307
+ markdownTarget,
308
+ markdownHistory,
309
+ markdownReferences,
310
+ markdownOwnership,
311
+ markdownCoupling,
312
+ markdownRelated,
313
+ markdownNotes,
314
+ markdownTests,
315
+ markdownQuestion,
316
+ ]) {
317
+ const lines = section(data);
318
+ if (lines.length) out.push(...lines, '');
319
+ }
320
+ return out.join('\n');
321
+ }
322
+
323
+ function markdownTarget({ target, repository, omitted }) {
324
+ const out = ['## Target', '', `- File: ${code(target.path)}`];
325
+ if (target.symbol) {
326
+ const line = target.line ? `, declared on line ${target.line}` : '';
327
+ out.push(`- Symbol: ${code(target.symbol)} (${target.kind}${line})`);
328
+ }
329
+ if (target.binary) out.push('- Content: binary file');
330
+ else if (target.wholeFile) {
331
+ out.push(
332
+ `- Lines: whole file (${target.totalLines} line${target.totalLines === 1 ? '' : 's'})`
333
+ );
334
+ } else if (target.startLine === target.endLine && target.startLine !== null) {
335
+ out.push(`- Line: ${target.startLine} of ${target.totalLines}`);
336
+ } else if (target.startLine !== null) {
337
+ out.push(`- Lines: ${target.startLine}-${target.endLine} of ${target.totalLines}`);
338
+ }
339
+ if (target.symbolsInRange.length) {
340
+ out.push(`- Definitions in range: ${target.symbolsInRange.map(code).join(', ')}`);
341
+ }
342
+ out.push(`- Revision: ${target.revision}`);
343
+ if (repository.remote) out.push(`- Repository: ${repository.remote}`);
344
+ if (target.contentOmitted) out.push(`- Source: not shown (${target.contentOmitted})`);
345
+ if (target.source !== null) {
346
+ out.push('', fence(target.source, LANGUAGES[path.extname(target.path).toLowerCase()] ?? ''));
347
+ if (omitted.source)
348
+ out.push(`_${omitted.source} more lines not shown to fit the token budget._`);
349
+ }
350
+ return out;
351
+ }
352
+
353
+ function markdownHistory({ history, omitted }) {
354
+ const out = ['## History', ''];
355
+ if (history.mode === 'none') return [...out, `No committed history: ${history.reason}.`];
356
+
357
+ const total = history.commits.length + (omitted.commits ?? 0);
358
+ const since = history.since ? ` since ${history.since}` : '';
359
+ const more = history.limitReached
360
+ ? ` Showing the newest ${history.limit}; older commits exist (raise --limit).`
361
+ : '';
362
+ out.push(
363
+ `${total} commit${total === 1 ? '' : 's'}${since} ${historyDescription(history)}, newest first.${more}`
364
+ );
365
+ history.commits.forEach((commit, index) => {
366
+ out.push('', `### ${index + 1}. ${commit.subject || '(no subject)'}`, '');
367
+ const meta = [code(commit.shortSha), day(commit.date), commit.author];
368
+ if (commit.merge) meta.push('merge');
369
+ if (commit.shallowBoundary) {
370
+ meta.push(
371
+ 'shallow clone boundary: older history is missing, so it looks like it added the code'
372
+ );
373
+ }
374
+ if (commit.refs?.length) meta.push(`refs: ${commit.refs.join(', ')}`);
375
+ out.push(meta.join(' · '));
376
+ if (commit.body) {
377
+ out.push('', commit.body.replace(/^/gm, '> ').replace(/^> $/gm, '>'));
378
+ }
379
+ if (commit.patch) out.push('', fence(commit.patch, 'diff'));
380
+ });
381
+ if (omitted.commits) {
382
+ out.push('', `_${omitted.commits} older commits not shown to fit the token budget._`);
383
+ }
384
+ return out;
385
+ }
386
+
387
+ function markdownReferences({ references, history, omitted }) {
388
+ const entries = flattenReferences(references);
389
+ // Nothing to say without commits, or when the budget dropped every reference
390
+ if (!entries.length && (omitted.references || !history.commits.length)) return [];
391
+ const out = ['## References', ''];
392
+ if (!entries.length) {
393
+ return [...out, 'No issue, pull request, ticket or URL references in these commits.'];
394
+ }
395
+ for (const entry of entries) {
396
+ const where = `(${entry.commits.join(', ')})`;
397
+ if (entry.type === 'issue') {
398
+ const label = { pr: 'Pull request', mr: 'Merge request' }[entry.kind] ?? 'Issue';
399
+ const closes = entry.closes ? ' (fixes / closes)' : '';
400
+ out.push(`- ${label} ${entry.id}${closes}${entry.url ? `: ${entry.url}` : ''} ${where}`);
401
+ } else if (entry.type === 'ticket') out.push(`- Ticket ${entry.id} ${where}`);
402
+ else if (entry.type === 'cve') out.push(`- ${entry.id} ${where}`);
403
+ else if (entry.type === 'url') out.push(`- URL ${entry.url} ${where}`);
404
+ else if (entry.type === 'co-author') out.push(`- Co-authored-by: ${entry.value} ${where}`);
405
+ else out.push(`- Reviewed-by: ${entry.value} ${where}`);
406
+ }
407
+ return out;
408
+ }
409
+
410
+ function markdownOwnership({ ownership, omitted }) {
411
+ if (!ownership || !ownership.authors.length) return [];
412
+ const scope =
413
+ ownership.startLine === null
414
+ ? 'the whole file'
415
+ : lineSpan(ownership.startLine, ownership.endLine);
416
+ const out = [
417
+ '## Ownership',
418
+ '',
419
+ `\`git blame\` of ${scope} (${ownership.totalLines} lines), last changed ${day(ownership.lastChanged)}:`,
420
+ '',
421
+ '| Author | Lines | Share | Commits | Last touched |',
422
+ '|---|---:|---:|---:|---|',
423
+ ];
424
+ for (const author of ownership.authors) {
425
+ const name = author.name.replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
426
+ out.push(
427
+ `| ${name} | ${author.lines} | ${percent(author.share)} | ${author.commits} | ${day(author.lastTouched)} |`
428
+ );
429
+ }
430
+ if (omitted.ownership) out.push(`| … ${omitted.ownership} more | | | | |`);
431
+ return out;
432
+ }
433
+
434
+ function markdownCoupling({ coupling, target, omitted }) {
435
+ if (!coupling || (!coupling.files.length && omitted.coupling)) return [];
436
+ const skipped = coupling.skippedLargeCommits
437
+ ? ` (${coupling.skippedLargeCommits} commit${coupling.skippedLargeCommits === 1 ? '' : 's'} with more than ${coupling.maxFilesPerCommit} files skipped)`
438
+ : '';
439
+ const out = [
440
+ '## Co-change coupling',
441
+ '',
442
+ `Commits that changed ${code(target.path)}: ${coupling.targetChanges} examined (window: the last ${coupling.window})${skipped}.`,
443
+ ];
444
+ if (!coupling.files.length) return [...out, 'No other file changed in the same commits.'];
445
+ out.push(
446
+ 'Files changed in the same commits (confidence = co-changes / changes of the target):',
447
+ '',
448
+ '| File | Co-changes | Confidence |',
449
+ '|---|---:|---:|'
450
+ );
451
+ for (const file of coupling.files) {
452
+ const gone = file.exists === false ? ' (no longer in the tree)' : '';
453
+ out.push(`| ${code(file.path)}${gone} | ${file.count} | ${percent(file.confidence)} |`);
454
+ }
455
+ if (omitted.coupling) out.push(`| … ${omitted.coupling} more | | |`);
456
+ return out;
457
+ }
458
+
459
+ function markdownRelated({ related }) {
460
+ if (!related.length) return [];
461
+ const labels = { adr: 'ADR', changelog: 'changelog', doc: 'doc', comment: 'comment' };
462
+ return [
463
+ '## Related text',
464
+ '',
465
+ ...related.map((entry) => {
466
+ const heading = entry.heading ? `, "${entry.heading}"` : '';
467
+ const where = code(`${entry.path}:${entry.line}`);
468
+ return `- ${where} (${labels[entry.kind]}${heading}): ${entry.snippet}`;
469
+ }),
470
+ ];
471
+ }
472
+
473
+ function markdownNotes({ notes }) {
474
+ if (!notes.length) return [];
475
+ return [
476
+ '## Notes in the target',
477
+ '',
478
+ ...notes.map(
479
+ (note) =>
480
+ `- Line ${note.line} ${note.tag}${note.author ? ` (${note.author})` : ''}: ${note.text}`
481
+ ),
482
+ ];
483
+ }
484
+
485
+ function markdownTests({ tests }) {
486
+ if (!tests.length) return [];
487
+ const out = ['## Tests', ''];
488
+ for (const test of tests) {
489
+ const what = test.references === 'symbol' ? 'references the symbol' : 'references the file';
490
+ const count = `${test.totalMatches} matching line${test.totalMatches === 1 ? '' : 's'}`;
491
+ out.push(`- ${code(test.path)} (${what}; ${count})`);
492
+ for (const match of test.matches) out.push(` - ${match.line}: ${code(match.text)}`);
493
+ }
494
+ return out;
495
+ }
496
+
497
+ function markdownQuestion({ question }) {
498
+ if (!question) return [];
499
+ return [
500
+ '## Question',
501
+ '',
502
+ question,
503
+ '',
504
+ 'Answer from the context above. Cite the commit hashes, references and file paths that support the answer, and say so when the history does not explain something.',
505
+ ];
506
+ }
507
+
508
+ // ---------------------------------------------------------------------------
509
+ // XML
510
+ // ---------------------------------------------------------------------------
511
+
512
+ function xml(text) {
513
+ return String(text)
514
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
515
+ .replace(/&/g, '&amp;')
516
+ .replace(/</g, '&lt;')
517
+ .replace(/>/g, '&gt;')
518
+ .replace(/"/g, '&quot;');
519
+ }
520
+
521
+ function attributes(values) {
522
+ return Object.entries(values)
523
+ .filter(
524
+ ([, value]) =>
525
+ value !== null &&
526
+ value !== undefined &&
527
+ value !== '' &&
528
+ !(Array.isArray(value) && value.length === 0)
529
+ )
530
+ .map(([key, value]) => ` ${key}="${xml(Array.isArray(value) ? value.join(' ') : value)}"`)
531
+ .join('');
532
+ }
533
+
534
+ function renderXml(data, budget) {
535
+ const { target, history } = data;
536
+ const out = ['<why_context generator="ctxman why">'];
537
+ if (Object.keys(data.omitted).length) {
538
+ out.push(`<trimmed${attributes({ budget, ...data.omitted })}/>`);
539
+ }
540
+ for (const warning of data.warnings) out.push(`<warning>${xml(warning)}</warning>`);
541
+
542
+ out.push(
543
+ `<target${attributes({
544
+ path: target.path,
545
+ symbol: target.symbol,
546
+ kind: target.kind,
547
+ declaration_line: target.line,
548
+ start_line: target.startLine,
549
+ end_line: target.endLine,
550
+ total_lines: target.totalLines,
551
+ whole_file: target.wholeFile,
552
+ revision: target.revision,
553
+ repository: data.repository.remote,
554
+ definitions_in_range: target.symbolsInRange,
555
+ source_omitted: target.contentOmitted,
556
+ })}>`
557
+ );
558
+ if (target.source !== null) {
559
+ out.push(
560
+ `<source${attributes({ omitted_lines: data.omitted.source })}>${xml(target.source)}</source>`
561
+ );
562
+ }
563
+ out.push('</target>');
564
+
565
+ out.push(
566
+ `<history${attributes({
567
+ mode: history.mode,
568
+ start_line: history.startLine,
569
+ end_line: history.endLine,
570
+ since: history.since,
571
+ limit: history.limit,
572
+ limit_reached: history.limitReached,
573
+ reason: history.reason,
574
+ })}>`
575
+ );
576
+ for (const commit of history.commits) {
577
+ out.push(
578
+ `<commit${attributes({
579
+ sha: commit.sha,
580
+ short_sha: commit.shortSha,
581
+ date: commit.date,
582
+ author: commit.author,
583
+ email: commit.email,
584
+ merge: commit.merge || null,
585
+ shallow_boundary: commit.shallowBoundary || null,
586
+ refs: commit.refs,
587
+ })}>`
588
+ );
589
+ out.push(`<subject>${xml(commit.subject)}</subject>`);
590
+ if (commit.body) out.push(`<body>${xml(commit.body)}</body>`);
591
+ if (commit.patch) out.push(`<patch>${xml(commit.patch)}</patch>`);
592
+ out.push('</commit>');
593
+ }
594
+ out.push('</history>');
595
+
596
+ out.push('<references>');
597
+ for (const entry of flattenReferences(data.references)) {
598
+ const commits = entry.commits;
599
+ switch (entry.type) {
600
+ case 'issue':
601
+ out.push(
602
+ `<issue${attributes({ id: entry.id, kind: entry.kind, closes: entry.closes || null, url: entry.url, commits })}/>`
603
+ );
604
+ break;
605
+ case 'ticket':
606
+ case 'cve':
607
+ out.push(`<${entry.type}${attributes({ id: entry.id, commits })}/>`);
608
+ break;
609
+ case 'url':
610
+ out.push(`<url${attributes({ href: entry.url, commits })}/>`);
611
+ break;
612
+ default: {
613
+ const tag = entry.type === 'co-author' ? 'co_author' : 'reviewer';
614
+ out.push(`<${tag}${attributes({ commits })}>${xml(entry.value)}</${tag}>`);
615
+ }
616
+ }
617
+ }
618
+ out.push('</references>');
619
+
620
+ if (data.ownership) {
621
+ const own = data.ownership;
622
+ out.push(
623
+ `<ownership${attributes({
624
+ start_line: own.startLine,
625
+ end_line: own.endLine,
626
+ total_lines: own.totalLines,
627
+ last_changed: own.lastChanged,
628
+ })}>`
629
+ );
630
+ for (const author of own.authors) {
631
+ out.push(
632
+ `<author${attributes({
633
+ name: author.name,
634
+ lines: author.lines,
635
+ share: author.share,
636
+ commits: author.commits,
637
+ last_touched: author.lastTouched,
638
+ })}/>`
639
+ );
640
+ }
641
+ out.push('</ownership>');
642
+ }
643
+
644
+ if (data.coupling) {
645
+ const coupling = data.coupling;
646
+ out.push(
647
+ `<coupling${attributes({
648
+ window: coupling.window,
649
+ target_changes: coupling.targetChanges,
650
+ skipped_large_commits: coupling.skippedLargeCommits,
651
+ })}>`
652
+ );
653
+ for (const file of coupling.files) {
654
+ out.push(
655
+ `<file${attributes({
656
+ path: file.path,
657
+ count: file.count,
658
+ confidence: file.confidence,
659
+ exists: file.exists === false ? 'false' : null,
660
+ })}/>`
661
+ );
662
+ }
663
+ out.push('</coupling>');
664
+ }
665
+
666
+ out.push('<related>');
667
+ for (const entry of data.related) {
668
+ out.push(
669
+ `<mention${attributes({
670
+ path: entry.path,
671
+ line: entry.line,
672
+ kind: entry.kind,
673
+ heading: entry.heading,
674
+ mentions: entry.mentions,
675
+ })}>${xml(entry.snippet)}</mention>`
676
+ );
677
+ }
678
+ out.push('</related>');
679
+
680
+ out.push('<notes>');
681
+ for (const note of data.notes) {
682
+ out.push(
683
+ `<note${attributes({ line: note.line, tag: note.tag, author: note.author })}>${xml(note.text)}</note>`
684
+ );
685
+ }
686
+ out.push('</notes>');
687
+
688
+ out.push('<tests>');
689
+ for (const test of data.tests) {
690
+ out.push(
691
+ `<test_file${attributes({
692
+ path: test.path,
693
+ references: test.references,
694
+ total_matches: test.totalMatches,
695
+ })}>`
696
+ );
697
+ for (const match of test.matches) {
698
+ out.push(`<match${attributes({ line: match.line })}>${xml(match.text)}</match>`);
699
+ }
700
+ out.push('</test_file>');
701
+ }
702
+ out.push('</tests>');
703
+
704
+ if (data.question) out.push(`<question>${xml(data.question)}</question>`);
705
+ out.push('</why_context>');
706
+ return out.join('\n') + '\n';
707
+ }
708
+
709
+ // ---------------------------------------------------------------------------
710
+ // JSON
711
+ // ---------------------------------------------------------------------------
712
+
713
+ function renderJson(data, budget) {
714
+ const { question, omitted, ...rest } = data;
715
+ const document = {
716
+ generator: 'ctxman why',
717
+ ...rest,
718
+ trimmed: Object.keys(omitted).length ? { budget, omitted } : null,
719
+ question: question ?? null,
720
+ };
721
+ return JSON.stringify(document, null, 2) + '\n';
722
+ }