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,644 @@
1
+ /**
2
+ * Render a pull-request context model as Markdown, XML or JSON.
3
+ *
4
+ * Sections follow the long-context layout: the long material (commit log, changed files,
5
+ * methods, diff, tests, file contents) first and the request (instructions, question) last.
6
+ * XML wraps file and method bodies in <documents><document index="n"><source>…</source>
7
+ * <document_content>…</document_content></document></documents>. Bodies are left as they
8
+ * are (not entity-escaped) so the model reads the code verbatim; the result is prompt
9
+ * markup, not a document for an XML parser.
10
+ */
11
+
12
+ import path from 'path';
13
+
14
+ export const SECTION_ORDER = [
15
+ 'summary',
16
+ 'commits',
17
+ 'files',
18
+ 'methods',
19
+ 'diff',
20
+ 'tests',
21
+ 'contents',
22
+ 'request',
23
+ ];
24
+
25
+ export const SECTION_LABELS = {
26
+ summary: 'summary',
27
+ commits: 'commits',
28
+ files: 'files',
29
+ methods: 'methods',
30
+ diff: 'diff',
31
+ tests: 'tests',
32
+ contents: 'file contents',
33
+ request: 'request',
34
+ };
35
+
36
+ const STATUS_WORDS = {
37
+ A: 'added',
38
+ M: 'modified',
39
+ D: 'deleted',
40
+ R: 'renamed',
41
+ C: 'copied',
42
+ T: 'type changed',
43
+ U: 'unmerged',
44
+ };
45
+
46
+ const FENCE_LANGUAGES = {
47
+ '.js': 'javascript',
48
+ '.mjs': 'javascript',
49
+ '.cjs': 'javascript',
50
+ '.jsx': 'jsx',
51
+ '.ts': 'typescript',
52
+ '.mts': 'typescript',
53
+ '.cts': 'typescript',
54
+ '.tsx': 'tsx',
55
+ '.py': 'python',
56
+ '.go': 'go',
57
+ '.java': 'java',
58
+ '.rb': 'ruby',
59
+ '.php': 'php',
60
+ '.rs': 'rust',
61
+ '.cs': 'csharp',
62
+ '.kt': 'kotlin',
63
+ '.kts': 'kotlin',
64
+ '.swift': 'swift',
65
+ '.c': 'c',
66
+ '.h': 'c',
67
+ '.cpp': 'cpp',
68
+ '.cc': 'cpp',
69
+ '.hpp': 'cpp',
70
+ '.scala': 'scala',
71
+ '.json': 'json',
72
+ '.yml': 'yaml',
73
+ '.yaml': 'yaml',
74
+ '.md': 'markdown',
75
+ '.sh': 'bash',
76
+ '.bash': 'bash',
77
+ '.html': 'html',
78
+ '.css': 'css',
79
+ '.scss': 'scss',
80
+ '.sql': 'sql',
81
+ '.toml': 'toml',
82
+ '.xml': 'xml',
83
+ };
84
+
85
+ const BUDGET_NOTE = 'omitted to fit the token budget';
86
+
87
+ /** Fence language for a path */
88
+ export function fenceLanguage(filePath) {
89
+ return FENCE_LANGUAGES[path.extname(filePath).toLowerCase()] || '';
90
+ }
91
+
92
+ /** A Markdown code block whose fence is longer than any backtick run inside it */
93
+ function fence(content, language = '') {
94
+ const longest = Math.max(2, ...(content.match(/`+/g) || []).map((run) => run.length));
95
+ const ticks = '`'.repeat(longest + 1);
96
+ return `${ticks}${language}\n${content.replace(/\n$/, '')}\n${ticks}`;
97
+ }
98
+
99
+ /** Inline code span that survives backticks in the text */
100
+ function code(text) {
101
+ const value = String(text);
102
+ if (!value.includes('`')) return `\`${value}\``;
103
+ return `\`\` ${value} \`\``;
104
+ }
105
+
106
+ // The cells hold code spans, which show backslashes literally, so only the backslashes
107
+ // right before a pipe are doubled: enough to keep the pipe from ending the cell
108
+ const escapeCell = (text) =>
109
+ String(text)
110
+ .replace(/(\\*)\|/g, '$1$1\\|')
111
+ .replace(/\n/g, ' ');
112
+
113
+ const escapeAttr = (text) =>
114
+ String(text)
115
+ .replace(/&/g, '&amp;')
116
+ .replace(/"/g, '&quot;')
117
+ .replace(/</g, '&lt;')
118
+ .replace(/>/g, '&gt;')
119
+ .replace(/\n/g, '&#10;');
120
+
121
+ const formatNumber = (n) => Number(n).toLocaleString('en-US');
122
+
123
+ function statusWord(file) {
124
+ let word = STATUS_WORDS[file.status] || file.status;
125
+ if ((file.status === 'R' || file.status === 'C') && file.similarity != null) {
126
+ word += ` (${file.similarity}%)`;
127
+ }
128
+ if (file.untracked) word += ' (untracked)';
129
+ return word;
130
+ }
131
+
132
+ const lineRange = (item) =>
133
+ item.start === item.end ? `${item.start}` : `${item.start}-${item.end}`;
134
+
135
+ /** "line 4" or "lines 4-9" */
136
+ const linesLabel = (item) =>
137
+ item.start === item.end ? `line ${item.start}` : `lines ${item.start}-${item.end}`;
138
+
139
+ /** Plain-text summary facts shared by every style */
140
+ export function summaryFacts(model) {
141
+ const { stats } = model;
142
+ const facts = [];
143
+ if (model.mode === 'range') {
144
+ facts.push(['Base', `${model.base.ref} (${model.base.short})`]);
145
+ facts.push(['Head', `${model.head.ref} (${model.head.short})`]);
146
+ facts.push(['Merge base', model.mergeBase.short]);
147
+ } else {
148
+ const head =
149
+ model.mode === 'staged'
150
+ ? 'staged changes (index)'
151
+ : 'working tree (uncommitted changes, untracked files included)';
152
+ facts.push(['Base', `${model.base.ref} (${model.base.short})`]);
153
+ if (model.mergeBase && model.mergeBase.sha !== model.base.sha) {
154
+ facts.push(['Merge base', model.mergeBase.short]);
155
+ }
156
+ facts.push(['Head', head]);
157
+ }
158
+ if (model.scope) facts.push(['Scope', model.scope]);
159
+ facts.push(['Commits', String(stats.commits)]);
160
+
161
+ const byStatus = Object.entries(stats.byStatus)
162
+ .map(([status, count]) => `${count} ${STATUS_WORDS[status] || status}`)
163
+ .join(', ');
164
+ facts.push([
165
+ 'Files changed',
166
+ `${stats.files}${byStatus ? ` (${byStatus})` : ''}, +${formatNumber(stats.additions)} -${formatNumber(stats.deletions)} lines`,
167
+ ]);
168
+ if (stats.excluded > 0) {
169
+ facts.push(['Excluded by context filters', `${stats.excluded} (listed, content omitted)`]);
170
+ }
171
+ facts.push([
172
+ 'Changed methods',
173
+ `${stats.changedMethods} (${stats.addedMethods} added, ${stats.changedMethods - stats.addedMethods} modified); deleted methods: ${stats.deletedMethods}`,
174
+ ]);
175
+ facts.push(['Related tests', String(stats.relatedTests)]);
176
+ if (model.impact) facts.push(['Impact (heuristic)', model.impact.level]);
177
+ if (model.tokens) {
178
+ const parts = SECTION_ORDER.filter((name) => model.tokens.sections[name] != null).map(
179
+ (name) => `${SECTION_LABELS[name]} ${formatNumber(model.tokens.sections[name])}`
180
+ );
181
+ facts.push(['Tokens', `~${formatNumber(model.tokens.total)} total; ${parts.join(', ')}`]);
182
+ }
183
+ if (!model.diffEnabled) facts.push(['Diff', 'not included (--no-diff)']);
184
+ if (model.budget) {
185
+ const dropped = model.budget.dropped.length
186
+ ? `; dropped: ${model.budget.dropped.join('; ')}`
187
+ : '';
188
+ const over = model.budget.over ? ' (still over: the required sections alone exceed it)' : '';
189
+ facts.push(['Budget', `${formatNumber(model.budget.limit)} tokens${over}${dropped}`]);
190
+ }
191
+ return facts;
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Markdown
196
+ // ---------------------------------------------------------------------------
197
+
198
+ const markdown = {
199
+ summary(model) {
200
+ const title =
201
+ model.mode === 'range'
202
+ ? `# Pull request context: ${code(model.head.ref)} into ${code(model.base.ref)}`
203
+ : `# Change context: ${model.mode === 'staged' ? 'staged' : 'uncommitted'} changes`;
204
+ const lines = summaryFacts(model).map(([key, value]) => `- ${key}: ${value}`);
205
+ return `${title}\n\n## Summary\n\n${lines.join('\n')}`;
206
+ },
207
+
208
+ commit(commit, model) {
209
+ let text = `### ${code(commit.short)} ${commit.subject}\n${commit.author}, ${commit.date}`;
210
+ if (commit.body && !model.logBodiesDropped) {
211
+ text += `\n\n${commit.body
212
+ .split('\n')
213
+ .map((line) => (line ? `> ${line}` : '>'))
214
+ .join('\n')}`;
215
+ }
216
+ return text;
217
+ },
218
+
219
+ commits(model) {
220
+ if (model.commits.length === 0 || model.logDropped) return null;
221
+ const body = model.commits.map((commit) => markdown.commit(commit, model)).join('\n\n');
222
+ return `## Commits (${model.commits.length})\n\n${body}`;
223
+ },
224
+
225
+ files(model) {
226
+ if (model.filesDropped) return null;
227
+ const head = `## Changed files (${model.files.length})`;
228
+ if (model.files.length === 0) return `${head}\n\nNo changes.`;
229
+ const rows = model.files.map((file) => {
230
+ const name =
231
+ file.oldPath && file.oldPath !== file.path && (file.status === 'R' || file.status === 'C')
232
+ ? `${code(file.oldPath)} → ${code(file.path)}`
233
+ : code(file.path);
234
+ const note = file.excluded ? ` (content excluded by ${file.excluded})` : '';
235
+ const added = file.binary ? 'binary' : file.additions;
236
+ const deleted = file.binary ? '' : file.deletions;
237
+ return `| ${statusWord(file)} | ${escapeCell(name + note)} | ${added} | ${deleted} |`;
238
+ });
239
+ return `${head}\n\n| Status | File | + | - |\n| --- | --- | ---: | ---: |\n${rows.join('\n')}`;
240
+ },
241
+
242
+ methodLabel(method) {
243
+ return `${code(method.file)}: ${code(method.name)} (${linesLabel(method)}, ${method.change})`;
244
+ },
245
+
246
+ method(method) {
247
+ return `### ${markdown.methodLabel(method)}\n\n${fence(method.source, fenceLanguage(method.file))}`;
248
+ },
249
+
250
+ methods(model) {
251
+ if (model.methodsDropped) return null;
252
+ if (model.methods.length === 0 && model.deletedMethods.length === 0) return null;
253
+ const parts = [`## Changed methods (${model.methods.length})`];
254
+ const withSource = model.methods.filter((m) => !m.sourceDropped);
255
+ const withoutSource = model.methods.filter((m) => m.sourceDropped);
256
+ if (withSource.length) parts.push(withSource.map(markdown.method).join('\n\n'));
257
+ if (withoutSource.length) {
258
+ const list = withoutSource.map((m) => `- ${markdown.methodLabel(m)}`).join('\n');
259
+ parts.push(`### Changed methods, source ${BUDGET_NOTE}\n\n${list}`);
260
+ }
261
+ if (model.deletedMethods.length) {
262
+ const list = model.deletedMethods
263
+ .map((m) => `- ${code(m.file)}: ${code(m.name)} (${linesLabel(m)} at base)`)
264
+ .join('\n');
265
+ parts.push(`### Deleted methods (${model.deletedMethods.length})\n\n${list}`);
266
+ }
267
+ return parts.join('\n\n');
268
+ },
269
+
270
+ diff(model) {
271
+ if (!model.diffEnabled) return null;
272
+ const shown = model.diffs.filter((d) => !d.dropped);
273
+ const excluded = model.files
274
+ .filter((f) => f.excluded)
275
+ .map((f) => `${code(f.path)} (${f.excluded})`);
276
+ if (shown.length === 0 && excluded.length === 0) return null;
277
+ const parts = ['## Diff'];
278
+ if (shown.length) parts.push(fence(shown.map((d) => d.text).join('\n'), 'diff'));
279
+ if (excluded.length) parts.push(`_Excluded by context filters: ${excluded.join(', ')}._`);
280
+ return parts.join('\n\n');
281
+ },
282
+
283
+ testLine(test) {
284
+ const note = test.excluded ? ` (content excluded by ${test.excluded})` : '';
285
+ return `- ${code(test.path)}: ${test.reasons.join('; ')}${note}`;
286
+ },
287
+
288
+ testContent(test) {
289
+ return `### ${code(test.path)}\n\n${fence(test.content, fenceLanguage(test.path))}`;
290
+ },
291
+
292
+ tests(model) {
293
+ const listed = model.tests.filter((t) => !t.dropped);
294
+ if (listed.length === 0) return null;
295
+ const parts = [`## Related tests (${listed.length})`, listed.map(markdown.testLine).join('\n')];
296
+ if (model.withTests) {
297
+ parts.push(...listed.filter(hasTestContent).map(markdown.testContent));
298
+ }
299
+ return parts.join('\n\n');
300
+ },
301
+
302
+ content(item) {
303
+ return `### ${code(item.path)}\n\n${fence(item.content, fenceLanguage(item.path))}`;
304
+ },
305
+
306
+ contents(model) {
307
+ if (!model.withFiles) return null;
308
+ const shown = model.contents.filter(hasContent);
309
+ const notes = model.contents.filter((c) => c.note).map((c) => `${code(c.path)} (${c.note})`);
310
+ if (shown.length === 0 && notes.length === 0) return null;
311
+ const parts = [`## Changed file contents (${shown.length})`, ...shown.map(markdown.content)];
312
+ if (notes.length) parts.push(`_Not included: ${notes.join(', ')}._`);
313
+ return parts.join('\n\n');
314
+ },
315
+
316
+ request(model) {
317
+ const parts = [];
318
+ if (model.instructions) parts.push(`## Instructions\n\n${model.instructions.trim()}`);
319
+ if (model.ask) parts.push(`## Question\n\n${model.ask.trim()}`);
320
+ return parts.length ? parts.join('\n\n') : null;
321
+ },
322
+ };
323
+
324
+ const hasContent = (item) => typeof item.content === 'string' && !item.dropped;
325
+ const hasTestContent = (test) => typeof test.content === 'string' && !test.contentDropped;
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // XML
329
+ // ---------------------------------------------------------------------------
330
+
331
+ /** XML renderer with a running document index, unique across the whole output */
332
+ function createXml() {
333
+ let index = 0;
334
+ const document = (source, content) =>
335
+ `<document index="${++index}">\n<source>${source}</source>\n<document_content>\n${content.replace(/\n$/, '')}\n</document_content>\n</document>`;
336
+ const documents = (docs) => `<documents>\n${docs.join('\n')}\n</documents>`;
337
+ const methodAttrs = (m) =>
338
+ `file="${escapeAttr(m.file)}" name="${escapeAttr(m.name)}" lines="${lineRange(m)}" change="${m.change}"`;
339
+
340
+ const xml = {
341
+ summary(model) {
342
+ const lines = summaryFacts(model).map(([key, value]) => `${key}: ${value}`);
343
+ return `<summary>\n${lines.join('\n')}\n</summary>`;
344
+ },
345
+
346
+ commit(commit, model) {
347
+ const body = commit.body && !model.logBodiesDropped ? `\n\n${commit.body}` : '';
348
+ return `<commit sha="${commit.short}" author="${escapeAttr(commit.author)}" date="${commit.date}">\n${commit.subject}${body}\n</commit>`;
349
+ },
350
+
351
+ commits(model) {
352
+ if (model.commits.length === 0 || model.logDropped) return null;
353
+ const list = model.commits.map((c) => xml.commit(c, model)).join('\n');
354
+ return `<commits count="${model.commits.length}">\n${list}\n</commits>`;
355
+ },
356
+
357
+ files(model) {
358
+ if (model.filesDropped) return null;
359
+ const rows = model.files.map((file) => {
360
+ const attrs = [
361
+ `status="${escapeAttr(statusWord(file))}"`,
362
+ `path="${escapeAttr(file.path)}"`,
363
+ ];
364
+ if (file.oldPath && file.oldPath !== file.path) {
365
+ attrs.push(`old_path="${escapeAttr(file.oldPath)}"`);
366
+ }
367
+ if (file.binary) attrs.push('binary="true"');
368
+ else attrs.push(`additions="${file.additions}"`, `deletions="${file.deletions}"`);
369
+ if (file.excluded) attrs.push(`content_excluded_by="${escapeAttr(file.excluded)}"`);
370
+ return `<file ${attrs.join(' ')}/>`;
371
+ });
372
+ return `<changed_files count="${model.files.length}">\n${rows.join('\n')}\n</changed_files>`;
373
+ },
374
+
375
+ method(method) {
376
+ const source = `${method.file}:${lineRange(method)} (${method.name}, ${method.change})`;
377
+ return document(escapeAttr(source), method.source);
378
+ },
379
+
380
+ methods(model) {
381
+ if (model.methodsDropped) return null;
382
+ if (model.methods.length === 0 && model.deletedMethods.length === 0) return null;
383
+ const parts = [`<changed_methods count="${model.methods.length}">`];
384
+ const withSource = model.methods.filter((m) => !m.sourceDropped);
385
+ const withoutSource = model.methods.filter((m) => m.sourceDropped);
386
+ if (withSource.length) parts.push(documents(withSource.map(xml.method)));
387
+ for (const method of withoutSource) {
388
+ parts.push(`<method ${methodAttrs(method)} source="${BUDGET_NOTE}"/>`);
389
+ }
390
+ if (model.deletedMethods.length) {
391
+ const rows = model.deletedMethods.map(
392
+ (m) =>
393
+ `<method file="${escapeAttr(m.file)}" name="${escapeAttr(m.name)}" base_lines="${lineRange(m)}"/>`
394
+ );
395
+ parts.push(
396
+ `<deleted_methods count="${rows.length}">\n${rows.join('\n')}\n</deleted_methods>`
397
+ );
398
+ }
399
+ parts.push('</changed_methods>');
400
+ return parts.join('\n');
401
+ },
402
+
403
+ diff(model) {
404
+ if (!model.diffEnabled) return null;
405
+ const shown = model.diffs.filter((d) => !d.dropped);
406
+ const excluded = model.files
407
+ .filter((f) => f.excluded)
408
+ .map((f) => `<excluded path="${escapeAttr(f.path)}" by="${escapeAttr(f.excluded)}"/>`);
409
+ if (shown.length === 0 && excluded.length === 0) return null;
410
+ const parts = ['<diff>'];
411
+ if (shown.length) parts.push(shown.map((d) => d.text).join('\n'));
412
+ parts.push(...excluded, '</diff>');
413
+ return parts.join('\n');
414
+ },
415
+
416
+ testLine(test) {
417
+ const excluded = test.excluded ? ` content_excluded_by="${escapeAttr(test.excluded)}"` : '';
418
+ return `<test path="${escapeAttr(test.path)}" reasons="${escapeAttr(test.reasons.join('; '))}"${excluded}/>`;
419
+ },
420
+
421
+ testContent(test) {
422
+ return document(escapeAttr(test.path), test.content);
423
+ },
424
+
425
+ tests(model) {
426
+ const listed = model.tests.filter((t) => !t.dropped);
427
+ if (listed.length === 0) return null;
428
+ const parts = [`<related_tests count="${listed.length}">`, ...listed.map(xml.testLine)];
429
+ const docs = model.withTests ? listed.filter(hasTestContent).map(xml.testContent) : [];
430
+ if (docs.length) parts.push(documents(docs));
431
+ parts.push('</related_tests>');
432
+ return parts.join('\n');
433
+ },
434
+
435
+ content(item) {
436
+ return document(escapeAttr(item.path), item.content);
437
+ },
438
+
439
+ contents(model) {
440
+ if (!model.withFiles) return null;
441
+ const shown = model.contents.filter(hasContent);
442
+ const notes = model.contents
443
+ .filter((c) => c.note)
444
+ .map((c) => `<omitted path="${escapeAttr(c.path)}" reason="${escapeAttr(c.note)}"/>`);
445
+ if (shown.length === 0 && notes.length === 0) return null;
446
+ const parts = ['<changed_file_contents>'];
447
+ if (shown.length) parts.push(documents(shown.map(xml.content)));
448
+ parts.push(...notes, '</changed_file_contents>');
449
+ return parts.join('\n');
450
+ },
451
+
452
+ request(model) {
453
+ const parts = [];
454
+ if (model.instructions) {
455
+ parts.push(`<instructions>\n${model.instructions.trim()}\n</instructions>`);
456
+ }
457
+ if (model.ask) parts.push(`<question>\n${model.ask.trim()}\n</question>`);
458
+ return parts.length ? parts.join('\n\n') : null;
459
+ },
460
+ };
461
+ return xml;
462
+ }
463
+
464
+ // ---------------------------------------------------------------------------
465
+ // JSON
466
+ // ---------------------------------------------------------------------------
467
+
468
+ const json = {
469
+ summary(model) {
470
+ const summary = {
471
+ mode: model.mode,
472
+ base: model.base,
473
+ head: model.head,
474
+ mergeBase: model.mergeBase,
475
+ scope: model.scope || null,
476
+ stats: model.stats,
477
+ impact: model.impact,
478
+ diff: model.diffEnabled,
479
+ };
480
+ if (model.tokens) summary.tokens = model.tokens;
481
+ if (model.budget) summary.budget = model.budget;
482
+ return summary;
483
+ },
484
+
485
+ commit(commit, model) {
486
+ const { sha, short, author, date, subject, body } = commit;
487
+ return model.logBodiesDropped
488
+ ? { sha, short, author, date, subject }
489
+ : { sha, short, author, date, subject, body };
490
+ },
491
+
492
+ commits(model) {
493
+ if (model.commits.length === 0 || model.logDropped) return null;
494
+ return model.commits.map((commit) => json.commit(commit, model));
495
+ },
496
+
497
+ files(model) {
498
+ if (model.filesDropped) return null;
499
+ return model.files.map((file) => ({
500
+ status: file.status,
501
+ path: file.path,
502
+ oldPath: file.oldPath,
503
+ similarity: file.similarity,
504
+ additions: file.additions,
505
+ deletions: file.deletions,
506
+ binary: file.binary,
507
+ untracked: file.untracked || undefined,
508
+ contentExcludedBy: file.excluded || undefined,
509
+ }));
510
+ },
511
+
512
+ method(method) {
513
+ const { file, name, start, end, change, changedLines } = method;
514
+ return {
515
+ file,
516
+ name,
517
+ start,
518
+ end,
519
+ change,
520
+ changedLines,
521
+ source: method.sourceDropped ? null : method.source,
522
+ };
523
+ },
524
+
525
+ methods(model) {
526
+ if (model.methodsDropped) return null;
527
+ if (model.methods.length === 0 && model.deletedMethods.length === 0) return null;
528
+ return {
529
+ changed: model.methods.map(json.method),
530
+ deleted: model.deletedMethods.map(({ file, name, start, end }) => ({
531
+ file,
532
+ name,
533
+ start,
534
+ end,
535
+ })),
536
+ };
537
+ },
538
+
539
+ diff(model) {
540
+ if (!model.diffEnabled) return null;
541
+ const files = [
542
+ ...model.diffs.filter((d) => !d.dropped).map((d) => ({ path: d.path, diff: d.text })),
543
+ ...model.files
544
+ .filter((f) => f.excluded)
545
+ .map((f) => ({ path: f.path, contentExcludedBy: f.excluded })),
546
+ ];
547
+ return files.length ? files : null;
548
+ },
549
+
550
+ testLine(test) {
551
+ const entry = { path: test.path, reasons: test.reasons, changed: test.changed };
552
+ if (test.excluded) entry.contentExcludedBy = test.excluded;
553
+ return entry;
554
+ },
555
+
556
+ testContent(test) {
557
+ return { path: test.path, content: test.content };
558
+ },
559
+
560
+ tests(model) {
561
+ const listed = model.tests.filter((t) => !t.dropped);
562
+ if (listed.length === 0) return null;
563
+ return listed.map((test) => {
564
+ const entry = json.testLine(test);
565
+ if (model.withTests && hasTestContent(test)) entry.content = test.content;
566
+ return entry;
567
+ });
568
+ },
569
+
570
+ content(item) {
571
+ return { path: item.path, content: item.content };
572
+ },
573
+
574
+ contents(model) {
575
+ if (!model.withFiles) return null;
576
+ const list = model.contents
577
+ .filter((item) => hasContent(item) || item.note)
578
+ .map((item) => (item.note ? { path: item.path, omitted: item.note } : json.content(item)));
579
+ return list.length ? list : null;
580
+ },
581
+
582
+ request(model) {
583
+ if (!model.instructions && !model.ask) return null;
584
+ return { instructions: model.instructions || null, question: model.ask || null };
585
+ },
586
+ };
587
+
588
+ const JSON_KEYS = {
589
+ summary: 'summary',
590
+ commits: 'commits',
591
+ files: 'files',
592
+ methods: 'methods',
593
+ diff: 'diff',
594
+ tests: 'relatedTests',
595
+ contents: 'fileContents',
596
+ request: 'request',
597
+ };
598
+
599
+ /**
600
+ * Render every section
601
+ * @param {object} model
602
+ * @param {'markdown'|'xml'|'json'} style
603
+ * @returns {{output: string, sections: Object<string, string>}} The output and each
604
+ * non-empty section's text (JSON: the section value serialized on its own)
605
+ */
606
+ export function renderPrContext(model, style) {
607
+ if (style === 'json') {
608
+ const object = {};
609
+ const sections = {};
610
+ for (const name of SECTION_ORDER) {
611
+ const value = json[name](model);
612
+ if (value == null) continue;
613
+ object[JSON_KEYS[name]] = value;
614
+ sections[name] = JSON.stringify(value, null, 2);
615
+ }
616
+ return { output: `${JSON.stringify(object, null, 2)}\n`, sections };
617
+ }
618
+
619
+ const renderer = style === 'xml' ? createXml() : markdown;
620
+ const sections = {};
621
+ for (const name of SECTION_ORDER) {
622
+ const text = renderer[name](model);
623
+ if (text) sections[name] = text;
624
+ }
625
+ const body = SECTION_ORDER.filter((name) => sections[name])
626
+ .map((name) => sections[name])
627
+ .join('\n\n');
628
+ const output = style === 'xml' ? `<pr_context>\n${body}\n</pr_context>\n` : `${body}\n`;
629
+ return { output, sections };
630
+ }
631
+
632
+ /**
633
+ * Text one item contributes to the output, for estimating what dropping it saves
634
+ * @param {'content'|'testContent'|'testLine'|'method'|'commit'} kind
635
+ * @param {object} item
636
+ * @param {object} model
637
+ * @param {'markdown'|'xml'|'json'} style
638
+ * @returns {string}
639
+ */
640
+ export function renderItem(kind, item, model, style) {
641
+ if (style === 'json') return JSON.stringify(json[kind](item, model), null, 2);
642
+ const renderer = style === 'xml' ? createXml() : markdown;
643
+ return renderer[kind](item, model);
644
+ }