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,829 @@
1
+ /**
2
+ * Pull-request / branch review context (`ctxman pr`)
3
+ *
4
+ * Collects everything a reviewer (human or model) needs about a branch in one document:
5
+ * a summary, the commit log, the changed files, the full source of every method the diff
6
+ * touches (plus the methods it deletes), the unified diff, related tests and, on request,
7
+ * the changed files' full contents, followed by the question or instructions. A token
8
+ * budget drops the least important material first.
9
+ */
10
+
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import { isValidGitRef } from '../integrations/git/GitClient.js';
14
+ import DiffAnalyzer from '../integrations/git/DiffAnalyzer.js';
15
+ import { Scanner } from '../core/Scanner.js';
16
+ import FileUtils from '../utils/file-utils.js';
17
+ import TokenUtils from '../utils/token-utils.js';
18
+ import { redactSecrets } from '../utils/secret-redactor.js';
19
+ import { readFileChecked } from '../utils/safe-read.js';
20
+ import PrGit, { bufferToText } from './pr-git.js';
21
+ import {
22
+ parseNameStatus,
23
+ parseNumstat,
24
+ splitDiff,
25
+ parseHunks,
26
+ syntheticAddedDiff,
27
+ countLines,
28
+ } from './diff-parser.js';
29
+ import {
30
+ supportsMethods,
31
+ extractMethodRanges,
32
+ findTouchedMethods,
33
+ findDeletedMethods,
34
+ } from './method-mapper.js';
35
+ import { findRelatedTests, isTestFile } from './related-tests.js';
36
+ import { renderPrContext, renderItem } from './pr-renderer.js';
37
+
38
+ export const PR_STYLES = ['markdown', 'xml', 'json'];
39
+
40
+ const DEFAULTS = {
41
+ base: null,
42
+ head: null,
43
+ staged: false,
44
+ working: false,
45
+ unified: 3,
46
+ diff: true,
47
+ withTests: false,
48
+ withFiles: false,
49
+ ask: null,
50
+ instructions: null,
51
+ budget: null,
52
+ style: 'markdown',
53
+ redact: true,
54
+ maxFileBytes: 1024 * 1024,
55
+ maxRelatedTests: 50,
56
+ };
57
+
58
+ // Test files read to look for imports and references: per file, and for all of them
59
+ const TEST_READ_LIMIT = 512 * 1024;
60
+ const TEST_READ_TOTAL = 64 * 1024 * 1024;
61
+ // ctxman's own run-time output (logs written by any ctxman command), never part of a change
62
+ const OWN_OUTPUT = /^\.ctxman\/(logs|temp|metrics)\//;
63
+ const FILTER_NAMES = {
64
+ gitignore: '.gitignore',
65
+ context: '.contextignore',
66
+ 'context-include': '.contextinclude',
67
+ };
68
+
69
+ /**
70
+ * Check and complete the options
71
+ * @param {object} options
72
+ */
73
+ export function normalizePrOptions(options = {}) {
74
+ const defined = Object.fromEntries(
75
+ Object.entries(options).filter(([, value]) => value !== undefined)
76
+ );
77
+ const opts = { ...DEFAULTS, ...defined };
78
+
79
+ if (!PR_STYLES.includes(opts.style)) {
80
+ throw new Error(`Unknown style ${JSON.stringify(opts.style)} (use markdown, xml or json)`);
81
+ }
82
+ for (const key of ['base', 'head']) {
83
+ if (opts[key] != null && !isValidGitRef(opts[key])) {
84
+ throw new Error(`Invalid git reference for ${key}: ${JSON.stringify(opts[key])}`);
85
+ }
86
+ }
87
+ if (opts.staged && opts.working) {
88
+ throw new Error('--staged and --working cannot be used together');
89
+ }
90
+ if ((opts.staged || opts.working) && opts.head) {
91
+ throw new Error('--head cannot be used with --staged or --working');
92
+ }
93
+ if (!Number.isInteger(opts.unified) || opts.unified < 0 || opts.unified > 10000) {
94
+ throw new Error(`Invalid number of context lines: ${JSON.stringify(opts.unified)}`);
95
+ }
96
+ if (opts.budget != null && (!Number.isInteger(opts.budget) || opts.budget <= 0)) {
97
+ throw new Error(`Invalid token budget: ${JSON.stringify(opts.budget)}`);
98
+ }
99
+ return opts;
100
+ }
101
+
102
+ /** Remote default branch (origin/HEAD), else main, else master */
103
+ function detectBaseRef(git) {
104
+ const originHead = git.run(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], {
105
+ allowFail: true,
106
+ });
107
+ const candidates = [originHead && originHead.trim(), 'main', 'master'].filter(Boolean);
108
+ for (const ref of candidates) {
109
+ if (isValidGitRef(ref) && git.resolveCommit(ref)) return ref;
110
+ }
111
+ throw new Error('Could not find a base branch (origin/HEAD, main or master); pass --base <ref>');
112
+ }
113
+
114
+ /** Resolve what is compared with what */
115
+ function resolveRange(git, opts) {
116
+ const mode = opts.staged ? 'staged' : opts.working ? 'working' : 'range';
117
+ const resolve = (ref, what) => {
118
+ const sha = git.resolveCommit(ref);
119
+ if (!sha) throw new Error(`Unknown ${what} revision ${JSON.stringify(ref)}`);
120
+ return sha;
121
+ };
122
+ const mergeBaseOf = (a, b) => {
123
+ const out = git.run(['merge-base', a, b], { allowFail: true });
124
+ if (!out || !out.trim()) {
125
+ throw new Error(
126
+ 'Base and head have no common ancestor (in a shallow clone, fetch more history)'
127
+ );
128
+ }
129
+ return out.trim();
130
+ };
131
+
132
+ if (mode === 'range') {
133
+ const headRef = opts.head || 'HEAD';
134
+ const headSha = resolve(headRef, 'head');
135
+ const baseRef = opts.base || detectBaseRef(git);
136
+ const baseSha = resolve(baseRef, 'base');
137
+ let headName = headRef;
138
+ if (headRef === 'HEAD') {
139
+ const branch = git.run(['rev-parse', '--abbrev-ref', 'HEAD'], { allowFail: true });
140
+ if (branch && branch.trim() && branch.trim() !== 'HEAD') headName = branch.trim();
141
+ }
142
+ const mergeBase = mergeBaseOf(baseSha, headSha);
143
+ return { mode, baseRef, baseSha, headRef: headName, headSha, from: mergeBase, mergeBase };
144
+ }
145
+
146
+ const headSha = git.resolveCommit('HEAD');
147
+ if (!headSha) throw new Error('The repository has no commits yet');
148
+ const baseRef = opts.base || 'HEAD';
149
+ const baseSha = resolve(baseRef, 'base');
150
+ const mergeBase = baseSha === headSha ? headSha : mergeBaseOf(baseSha, headSha);
151
+ return {
152
+ mode,
153
+ baseRef,
154
+ baseSha,
155
+ headRef: mode === 'staged' ? 'index' : 'working tree',
156
+ headSha,
157
+ from: mergeBase,
158
+ mergeBase,
159
+ };
160
+ }
161
+
162
+ /** Commits from `from` to `to`, oldest first */
163
+ function readCommits(git, from, to, pathspec) {
164
+ if (from === to) return [];
165
+ const out = git.run([
166
+ 'log',
167
+ '--reverse',
168
+ '--no-color',
169
+ '--format=%x1e%H%x00%h%x00%an%x00%aI%x00%s%x00%b',
170
+ `${from}..${to}`,
171
+ '--',
172
+ ...pathspec,
173
+ ]);
174
+ return out
175
+ .split('\x1e')
176
+ .filter((record) => record.trim())
177
+ .map((record) => {
178
+ const [sha, short, author, date, subject, body = ''] = record.split('\0');
179
+ return { sha, short, author, date: date.slice(0, 10), subject, body: body.trim() };
180
+ });
181
+ }
182
+
183
+ const isInside = (root, target) => {
184
+ const relative = path.relative(root, target);
185
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
186
+ };
187
+
188
+ /** Read a working-tree file without following links out of the repository */
189
+ function readWorkingFile(realRoot, relativePath, maxBytes) {
190
+ const fullPath = path.resolve(realRoot, relativePath);
191
+ if (!isInside(realRoot, fullPath)) return { status: 'missing' };
192
+ const read = readFileChecked(fullPath, { maxBytes, followLinks: false, within: realRoot });
193
+ if (read.status === 'not-file') return { status: 'not-blob' };
194
+ if (read.status === 'too-large') return { status: 'too-large', size: read.stat.size };
195
+ if (read.status !== 'ok') return { status: 'missing' };
196
+ return { status: 'ok', text: bufferToText(read.buffer), size: read.buffer.length };
197
+ }
198
+
199
+ /** Why the context filters keep a file's content out, or null */
200
+ function createExclusionCheck(rootPath, repoRoot) {
201
+ const scanner = new Scanner(rootPath);
202
+ const parser = scanner.gitIgnore;
203
+ return (repoRelativePath) => {
204
+ const relative = path
205
+ .relative(rootPath, path.join(repoRoot, repoRelativePath))
206
+ .split(path.sep)
207
+ .join('/');
208
+ if (relative.startsWith('../')) return 'scope';
209
+ const segments = relative.split('/');
210
+ if (segments.slice(0, -1).some((name) => FileUtils.isSkippedDirectory(name))) {
211
+ return 'a skipped directory';
212
+ }
213
+ // The scanner never enters an ignored directory, so check the parents too (with an
214
+ // include file, directories are only traversal hints, so only the file counts)
215
+ const paths = parser.hasIncludeFile
216
+ ? [relative]
217
+ : [...segments.slice(0, -1).map((_, i) => segments.slice(0, i + 1).join('/')), relative];
218
+ for (const candidate of paths) {
219
+ if (scanner.shouldIgnore(candidate, path.join(rootPath, candidate))) {
220
+ return FILTER_NAMES[parser._lastIgnoreReason] || 'context filters';
221
+ }
222
+ }
223
+ return null;
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Whether the export policy denies a repository path. Denied files are left out entirely,
229
+ * as the scanner does, so neither their content nor their name reaches the output
230
+ */
231
+ function createDenyCheck(rootPath, repoRoot) {
232
+ const scanner = new Scanner(rootPath);
233
+ // Load now: an invalid policy fails the command instead of being skipped
234
+ scanner.getPolicy();
235
+ return (repoRelativePath) => {
236
+ if (!repoRelativePath) return false;
237
+ const relative = path
238
+ .relative(rootPath, path.join(repoRoot, repoRelativePath))
239
+ .split(path.sep)
240
+ .join('/');
241
+ return !relative.startsWith('../') && scanner.isDenied(relative);
242
+ };
243
+ }
244
+
245
+ /** Labels for what the budget dropped, grouped by kind */
246
+ function describeDropped(dropped) {
247
+ const list = (items) => {
248
+ const shown = items.slice(0, 5).join(', ');
249
+ return items.length > 5 ? `${shown}, +${items.length - 5} more` : shown;
250
+ };
251
+ const labels = [];
252
+ if (dropped.contents.length) {
253
+ labels.push(`full contents of ${dropped.contents.length} file(s) (${list(dropped.contents)})`);
254
+ }
255
+ if (dropped.testContents.length) {
256
+ labels.push(
257
+ `contents of ${dropped.testContents.length} related test(s) (${list(dropped.testContents)})`
258
+ );
259
+ }
260
+ if (dropped.tests.length) labels.push(`${dropped.tests.length} related test path(s)`);
261
+ if (dropped.diff.length) {
262
+ labels.push(`diff of ${dropped.diff.length} file(s) (${list(dropped.diff)})`);
263
+ }
264
+ if (dropped.methodSources.length) {
265
+ labels.push(
266
+ `source of ${dropped.methodSources.length} changed method(s) (${list(dropped.methodSources)})`
267
+ );
268
+ }
269
+ if (dropped.methods) labels.push('the changed methods list');
270
+ if (dropped.commitBodies) labels.push('commit message bodies');
271
+ if (dropped.commits) labels.push('the commit log');
272
+ if (dropped.files) labels.push('the changed files list');
273
+ return labels;
274
+ }
275
+
276
+ /**
277
+ * Build the review context for a branch, the staged changes or the working tree
278
+ * @param {string} [root=process.cwd()] - Project directory (inside a git work tree); a
279
+ * subdirectory limits the change to that directory
280
+ * @param {object} [options]
281
+ * @param {string} [options.base] - Base revision (default: origin/HEAD, else main, else master;
282
+ * HEAD with staged/working)
283
+ * @param {string} [options.head='HEAD'] - Head revision
284
+ * @param {boolean} [options.staged] - Compare the index with the base instead
285
+ * @param {boolean} [options.working] - Compare the working tree (with untracked files)
286
+ * @param {number} [options.unified=3] - Context lines in the diff
287
+ * @param {boolean} [options.diff=true] - Include the unified diff
288
+ * @param {boolean} [options.withTests] - Include related tests' contents
289
+ * @param {boolean} [options.withFiles] - Include changed files' full contents
290
+ * @param {string} [options.ask] - Question appended at the end
291
+ * @param {string} [options.instructions] - Instructions appended at the end
292
+ * @param {number} [options.budget] - Maximum tokens of the output
293
+ * @param {'markdown'|'xml'|'json'} [options.style='markdown']
294
+ * @param {boolean} [options.redact=true] - Mask secrets in contents, diff and commit messages
295
+ * @returns {Promise<object>} `output` (the rendered text), `tokens`, per-section `sections`
296
+ * token counts, the collected `commits`, `files`, `methods`, `relatedTests`, `stats`,
297
+ * `budget` (limit, dropped, over), `redactions` and `warnings`
298
+ */
299
+ export async function buildPrContext(root = process.cwd(), options = {}) {
300
+ const opts = normalizePrOptions(options);
301
+ const rootPath = path.resolve(root);
302
+ if (!fs.existsSync(rootPath) || !fs.statSync(rootPath).isDirectory()) {
303
+ throw new Error(`Not a directory: ${rootPath}`);
304
+ }
305
+ const repoTop = PrGit.findRepoRoot(rootPath);
306
+ if (!repoTop) throw new Error(`Not inside a git repository: ${rootPath}`);
307
+ const repoRoot = fs.realpathSync(repoTop);
308
+ const realRootPath = fs.realpathSync(rootPath);
309
+ const scope = path.relative(repoRoot, realRootPath).split(path.sep).join('/');
310
+ const pathspec = scope ? [scope] : [];
311
+ const git = new PrGit(repoRoot);
312
+ const warnings = [];
313
+
314
+ // --- what is compared --------------------------------------------------------------
315
+ const range = resolveRange(git, opts);
316
+ const isDenied = createDenyCheck(realRootPath, repoRoot);
317
+ const denied = new Set();
318
+ const allowed = (repoPath, oldPath = null) => {
319
+ if (!isDenied(repoPath) && !isDenied(oldPath)) return true;
320
+ denied.add(repoPath);
321
+ return false;
322
+ };
323
+ // `rev-parse --short` takes one revision and `show` lists a repeated one once, so map
324
+ const shortSha = new Map(
325
+ git
326
+ .run(['show', '--no-patch', '--format=%H %h', range.baseSha, range.headSha, range.mergeBase])
327
+ .trim()
328
+ .split('\n')
329
+ .map((line) => line.split(' '))
330
+ );
331
+ const commits = readCommits(git, range.from, range.headSha, pathspec);
332
+
333
+ const diffArgs = ['--no-color', '--no-ext-diff', '--no-textconv', '-M'];
334
+ if (range.mode === 'staged') diffArgs.push('--cached');
335
+ diffArgs.push(range.from);
336
+ if (range.mode === 'range') diffArgs.push(range.headSha);
337
+ const tail = ['--', ...pathspec];
338
+ const nameStatus = parseNameStatus(
339
+ git.run(['diff', '--name-status', '-z', ...diffArgs, ...tail])
340
+ ).filter((entry) => allowed(entry.path, entry.oldPath));
341
+ const numstat = parseNumstat(git.run(['diff', '--numstat', '-z', ...diffArgs, ...tail]));
342
+ const patch = git.run([
343
+ 'diff',
344
+ `--unified=${opts.unified}`,
345
+ '--src-prefix=a/',
346
+ '--dst-prefix=b/',
347
+ ...diffArgs,
348
+ ...tail,
349
+ ]);
350
+ const blocks = new Map();
351
+ for (const block of splitDiff(patch)) blocks.set(block.newPath ?? block.oldPath, block);
352
+
353
+ // --- head/base contents ---------------------------------------------------------------
354
+ const headCache = new Map();
355
+ const readHead = (paths, maxBytes = opts.maxFileBytes, maxTotalBytes = Infinity) => {
356
+ const found = new Map();
357
+ const todo = [];
358
+ for (const p of new Set(paths)) {
359
+ if (headCache.has(p)) found.set(p, headCache.get(p));
360
+ else todo.push(p);
361
+ }
362
+ let results = [];
363
+ if (range.mode === 'working') {
364
+ let total = 0;
365
+ results = todo.map((p) => {
366
+ const entry = readWorkingFile(repoRoot, p, Math.min(maxBytes, maxTotalBytes - total));
367
+ if (entry.status === 'ok') total += entry.size;
368
+ return entry;
369
+ });
370
+ } else if (todo.length) {
371
+ const prefix = range.mode === 'staged' ? ':' : `${range.headSha}:`;
372
+ results = git.readBlobs(
373
+ todo.map((p) => prefix + p),
374
+ { maxBytes, maxTotalBytes }
375
+ );
376
+ }
377
+ todo.forEach((p, i) => {
378
+ found.set(p, results[i]);
379
+ // A size limit depends on the caller, so only definite answers are kept
380
+ if (results[i].status !== 'too-large') headCache.set(p, results[i]);
381
+ });
382
+ return found;
383
+ };
384
+ const readBase = (paths) => {
385
+ const results = git.readBlobs(
386
+ paths.map((p) => `${range.from}:${p}`),
387
+ { maxBytes: opts.maxFileBytes }
388
+ );
389
+ return new Map(paths.map((p, i) => [p, results[i]]));
390
+ };
391
+ const textOf = (entry) => (entry && entry.status === 'ok' ? entry.text : null);
392
+
393
+ // --- changed files ----------------------------------------------------------------------
394
+ const isExcluded = createExclusionCheck(realRootPath, repoRoot);
395
+ const files = nameStatus.map((entry) => {
396
+ const stat = numstat.get(entry.path) || { additions: 0, deletions: 0, binary: false };
397
+ return {
398
+ ...entry,
399
+ ...stat,
400
+ untracked: false,
401
+ block: blocks.get(entry.path) || blocks.get(entry.oldPath) || null,
402
+ excluded: isExcluded(entry.path),
403
+ };
404
+ });
405
+ if (range.mode === 'working') {
406
+ const untracked = git
407
+ .run(['ls-files', '-z', '--others', '--exclude-standard', ...tail])
408
+ .split('\0')
409
+ .filter((p) => p && !OWN_OUTPUT.test(p) && allowed(p));
410
+ const contents = readHead(untracked);
411
+ for (const filePath of untracked) {
412
+ const entry = contents.get(filePath);
413
+ if (!entry || entry.status === 'missing' || entry.status === 'not-blob') continue;
414
+ const text = textOf(entry);
415
+ files.push({
416
+ status: 'A',
417
+ path: filePath,
418
+ oldPath: null,
419
+ similarity: null,
420
+ additions: text ? countLines(text) : 0,
421
+ deletions: 0,
422
+ binary: entry.status === 'ok' && text === null,
423
+ untracked: true,
424
+ block:
425
+ entry.status === 'ok'
426
+ ? { oldPath: null, newPath: filePath, text: syntheticAddedDiff(filePath, text) }
427
+ : null,
428
+ excluded: isExcluded(filePath),
429
+ });
430
+ }
431
+ files.sort((a, b) => a.path.localeCompare(b.path));
432
+ }
433
+
434
+ // --- redaction ------------------------------------------------------------------------
435
+ const redactions = {};
436
+ const redact = (text) => {
437
+ if (!opts.redact || typeof text !== 'string') return text;
438
+ const result = redactSecrets(text);
439
+ for (const type of result.findings) redactions[type] = (redactions[type] || 0) + 1;
440
+ return result.text;
441
+ };
442
+
443
+ // --- changed and deleted methods ------------------------------------------------------
444
+ const methodFiles = files.filter(
445
+ (f) => !f.excluded && !f.binary && (supportsMethods(f.path) || supportsMethods(f.oldPath || ''))
446
+ );
447
+ const headTexts = readHead(methodFiles.filter((f) => f.status !== 'D').map((f) => f.path));
448
+ const withBase = new Set(
449
+ methodFiles.filter((f) => f.oldPath && f.status !== 'A' && f.status !== 'C')
450
+ );
451
+ const baseTexts = readBase([...withBase].map((f) => f.oldPath));
452
+ const methods = [];
453
+ const deletedMethods = [];
454
+ let tooLarge = 0;
455
+ for (const file of methodFiles) {
456
+ const headEntry = file.status === 'D' ? null : headTexts.get(file.path);
457
+ const baseEntry = withBase.has(file) ? baseTexts.get(file.oldPath) : null;
458
+ if (headEntry?.status === 'too-large' || baseEntry?.status === 'too-large') tooLarge++;
459
+ const head = textOf(headEntry);
460
+ const base = textOf(baseEntry);
461
+ const headMethods =
462
+ head != null && supportsMethods(file.path) ? extractMethodRanges(head, file.path) : [];
463
+ const baseMethods =
464
+ base != null && supportsMethods(file.oldPath) ? extractMethodRanges(base, file.oldPath) : [];
465
+ const baseNames = new Set(baseMethods.map((m) => m.name));
466
+ const hunks = file.block
467
+ ? parseHunks(file.block.text)
468
+ : { addedLines: [], deletionAnchors: [] };
469
+ for (const method of findTouchedMethods(headMethods, hunks)) {
470
+ methods.push({
471
+ file: file.path,
472
+ name: method.name,
473
+ start: method.start,
474
+ end: method.end,
475
+ change: baseNames.has(method.name) ? 'modified' : 'added',
476
+ changedLines: method.changedLines,
477
+ source: redact(method.source),
478
+ sourceDropped: false,
479
+ });
480
+ }
481
+ const deleted =
482
+ file.status === 'D' ? baseMethods : findDeletedMethods(baseMethods, headMethods, head ?? '');
483
+ for (const method of deleted) {
484
+ deletedMethods.push({
485
+ file: file.oldPath,
486
+ name: method.name,
487
+ start: method.start,
488
+ end: method.end,
489
+ });
490
+ }
491
+ }
492
+ if (tooLarge) {
493
+ warnings.push(
494
+ `${tooLarge} file(s) over ${opts.maxFileBytes} bytes were skipped for method analysis`
495
+ );
496
+ }
497
+
498
+ // --- related tests ----------------------------------------------------------------------
499
+ let candidates;
500
+ if (range.mode === 'range') {
501
+ candidates = git.run(['ls-tree', '-r', '-z', '--name-only', range.headSha, ...tail]);
502
+ } else if (range.mode === 'staged') {
503
+ candidates = git.run(['ls-files', '-z', '--cached', ...tail]);
504
+ } else {
505
+ candidates = git.run(['ls-files', '-z', '--cached', '--others', '--exclude-standard', ...tail]);
506
+ }
507
+ candidates = [...new Set(candidates.split('\0').filter((p) => p && !isDenied(p)))];
508
+ const testTexts = new Map();
509
+ const readContents = (paths) => {
510
+ const entries = readHead(paths, TEST_READ_LIMIT, TEST_READ_TOTAL);
511
+ for (const [p, entry] of entries) testTexts.set(p, textOf(entry));
512
+ return new Map([...entries].map(([p, entry]) => [p, textOf(entry)]));
513
+ };
514
+ const allRelated = findRelatedTests({
515
+ candidates,
516
+ changedFiles: files,
517
+ // Helpers changed inside test files are not the code under test
518
+ methodNames: [...methods, ...deletedMethods]
519
+ .filter((m) => !isTestFile(m.file))
520
+ .map((m) => m.name),
521
+ readContents,
522
+ });
523
+ const related = allRelated.slice(0, opts.maxRelatedTests);
524
+ if (opts.withTests) {
525
+ const missing = related.map((t) => t.path).filter((p) => !testTexts.has(p));
526
+ if (missing.length) readContents(missing);
527
+ }
528
+ const tests = related.map((test) => {
529
+ const excluded = isExcluded(test.path);
530
+ const content = opts.withTests && !excluded ? (testTexts.get(test.path) ?? null) : null;
531
+ return {
532
+ ...test,
533
+ excluded,
534
+ content: typeof content === 'string' ? redact(content) : null,
535
+ contentDropped: false,
536
+ dropped: false,
537
+ };
538
+ });
539
+
540
+ // --- full contents ----------------------------------------------------------------------
541
+ const contents = [];
542
+ if (opts.withFiles) {
543
+ const wanted = files.filter((f) => f.status !== 'D');
544
+ const entries = readHead(wanted.filter((f) => !f.excluded).map((f) => f.path));
545
+ for (const file of wanted) {
546
+ if (file.excluded) {
547
+ contents.push({ path: file.path, content: null, note: `excluded by ${file.excluded}` });
548
+ continue;
549
+ }
550
+ const entry = entries.get(file.path);
551
+ const text = textOf(entry);
552
+ let note = null;
553
+ if (entry?.status === 'too-large') note = `larger than ${opts.maxFileBytes} bytes`;
554
+ else if (entry?.status === 'ok' && text === null) note = 'binary';
555
+ else if (text === null) note = 'not readable';
556
+ contents.push({ path: file.path, content: redact(text), note, dropped: false });
557
+ }
558
+ }
559
+
560
+ // --- model --------------------------------------------------------------------------------
561
+ const byStatus = {};
562
+ for (const file of files) byStatus[file.status] = (byStatus[file.status] || 0) + 1;
563
+ const impact = new DiffAnalyzer(repoRoot).calculateImpact(files.map((f) => f.path));
564
+ const model = {
565
+ mode: range.mode,
566
+ base: { ref: range.baseRef, sha: range.baseSha, short: shortSha.get(range.baseSha) },
567
+ head:
568
+ range.mode === 'range'
569
+ ? { ref: range.headRef, sha: range.headSha, short: shortSha.get(range.headSha) }
570
+ : { ref: range.headRef, sha: null, short: null },
571
+ mergeBase: { sha: range.mergeBase, short: shortSha.get(range.mergeBase) },
572
+ scope,
573
+ impact: { level: impact.level, score: impact.score },
574
+ stats: {
575
+ commits: commits.length,
576
+ files: files.length,
577
+ byStatus,
578
+ additions: files.reduce((sum, f) => sum + f.additions, 0),
579
+ deletions: files.reduce((sum, f) => sum + f.deletions, 0),
580
+ excluded: files.filter((f) => f.excluded).length,
581
+ changedMethods: methods.length,
582
+ addedMethods: methods.filter((m) => m.change === 'added').length,
583
+ deletedMethods: deletedMethods.length,
584
+ relatedTests: tests.length,
585
+ },
586
+ commits: commits.map((c) => ({ ...c, subject: redact(c.subject), body: redact(c.body) })),
587
+ files: files.map(
588
+ ({
589
+ status,
590
+ path: filePath,
591
+ oldPath,
592
+ similarity,
593
+ additions,
594
+ deletions,
595
+ binary,
596
+ untracked,
597
+ excluded,
598
+ }) => ({
599
+ status,
600
+ path: filePath,
601
+ oldPath,
602
+ similarity,
603
+ additions,
604
+ deletions,
605
+ binary,
606
+ untracked,
607
+ excluded,
608
+ })
609
+ ),
610
+ methods,
611
+ deletedMethods,
612
+ diffEnabled: opts.diff,
613
+ diffs: files
614
+ .filter((f) => f.block && !f.excluded)
615
+ .map((f) => ({ path: f.path, text: redact(f.block.text), dropped: false })),
616
+ tests,
617
+ withTests: opts.withTests,
618
+ withFiles: opts.withFiles,
619
+ contents,
620
+ instructions: opts.instructions,
621
+ ask: opts.ask,
622
+ budget: opts.budget ? { limit: opts.budget, dropped: [], over: false } : null,
623
+ tokens: null,
624
+ logBodiesDropped: false,
625
+ logDropped: false,
626
+ filesDropped: false,
627
+ methodsDropped: false,
628
+ };
629
+ if (denied.size) {
630
+ warnings.push(`${denied.size} changed file(s) denied by the export policy were left out`);
631
+ }
632
+ if (allRelated.length > related.length) {
633
+ warnings.push(
634
+ `${allRelated.length} related tests found; the first ${related.length} are listed`
635
+ );
636
+ }
637
+
638
+ // --- render within the budget ------------------------------------------------------------
639
+ const count = (text) => TokenUtils.calculate(text, 'pr-context.md');
640
+ const dropped = {
641
+ contents: [],
642
+ testContents: [],
643
+ tests: [],
644
+ diff: [],
645
+ methodSources: [],
646
+ methods: false,
647
+ commitBodies: false,
648
+ commits: false,
649
+ files: false,
650
+ };
651
+ const measure = () => {
652
+ if (model.budget) model.budget.dropped = describeDropped(dropped);
653
+ model.tokens = null;
654
+ let { sections } = renderPrContext(model, opts.style);
655
+ const counts = {};
656
+ for (const [name, text] of Object.entries(sections)) counts[name] = count(text);
657
+ const sum = () => Object.values(counts).reduce((total, n) => total + n, 0);
658
+ model.tokens = { sections: counts, total: sum() };
659
+ ({ sections } = renderPrContext(model, opts.style));
660
+ counts.summary = count(sections.summary);
661
+ model.tokens.total = sum();
662
+ const { output } = renderPrContext(model, opts.style);
663
+ return { output, tokens: count(output) };
664
+ };
665
+
666
+ let result = measure();
667
+ if (model.budget && result.tokens > model.budget.limit) {
668
+ const limit = model.budget.limit;
669
+ const steps = buildDropSteps(model, opts.style, dropped, count);
670
+ let estimate = result.tokens;
671
+ let stale = false;
672
+ for (const step of steps) {
673
+ if (stale || step.tokens == null || estimate <= limit) {
674
+ result = measure();
675
+ estimate = result.tokens;
676
+ stale = false;
677
+ if (estimate <= limit) break;
678
+ }
679
+ step.apply();
680
+ if (step.tokens == null) stale = true;
681
+ else estimate -= step.tokens;
682
+ }
683
+ result = measure();
684
+ model.budget.over = result.tokens > limit;
685
+ if (model.budget.over) {
686
+ result = measure();
687
+ warnings.push(
688
+ `The required sections alone take ${result.tokens} tokens, over the ${limit}-token budget`
689
+ );
690
+ }
691
+ }
692
+
693
+ return {
694
+ output: result.output,
695
+ tokens: result.tokens,
696
+ style: opts.style,
697
+ mode: model.mode,
698
+ base: model.base,
699
+ head: model.head,
700
+ mergeBase: model.mergeBase,
701
+ scope,
702
+ stats: model.stats,
703
+ sections: model.tokens.sections,
704
+ budget: model.budget,
705
+ commits: model.commits,
706
+ files: model.files,
707
+ methods: { changed: methods, deleted: deletedMethods },
708
+ relatedTests: tests.map(({ path: testPath, reasons, changed, excluded }) => ({
709
+ path: testPath,
710
+ reasons,
711
+ changed,
712
+ excluded,
713
+ })),
714
+ redactions,
715
+ warnings,
716
+ };
717
+ }
718
+
719
+ /**
720
+ * What the budget drops, least important first: full file contents, test contents, related
721
+ * test paths, per-file diffs, method sources, the methods list, commit bodies, the commit
722
+ * log, the file list. Item steps carry the tokens their text takes (largest items go first
723
+ * within a kind); whole-section steps have tokens null, so the caller measures before them.
724
+ */
725
+ function buildDropSteps(model, style, dropped, count) {
726
+ const steps = [];
727
+ const bySize = (items) => items.sort((a, b) => b.tokens - a.tokens);
728
+
729
+ steps.push(
730
+ ...bySize(
731
+ model.contents
732
+ .filter((item) => typeof item.content === 'string')
733
+ .map((item) => ({
734
+ tokens: count(renderItem('content', item, model, style)),
735
+ apply: () => {
736
+ item.dropped = true;
737
+ dropped.contents.push(item.path);
738
+ },
739
+ }))
740
+ )
741
+ );
742
+ if (model.withTests) {
743
+ steps.push(
744
+ ...bySize(
745
+ model.tests
746
+ .filter((test) => typeof test.content === 'string')
747
+ .map((test) => ({
748
+ tokens: count(renderItem('testContent', test, model, style)),
749
+ apply: () => {
750
+ test.contentDropped = true;
751
+ dropped.testContents.push(test.path);
752
+ },
753
+ }))
754
+ )
755
+ );
756
+ }
757
+ for (const test of [...model.tests].reverse()) {
758
+ steps.push({
759
+ tokens: count(renderItem('testLine', test, model, style)),
760
+ apply: () => {
761
+ test.dropped = true;
762
+ dropped.tests.push(test.path);
763
+ },
764
+ });
765
+ }
766
+ if (model.diffEnabled) {
767
+ steps.push(
768
+ ...bySize(
769
+ model.diffs.map((diff) => ({
770
+ tokens: count(diff.text),
771
+ apply: () => {
772
+ diff.dropped = true;
773
+ dropped.diff.push(diff.path);
774
+ },
775
+ }))
776
+ )
777
+ );
778
+ }
779
+ steps.push(
780
+ ...bySize(
781
+ model.methods.map((method) => ({
782
+ tokens: count(method.source),
783
+ apply: () => {
784
+ method.sourceDropped = true;
785
+ dropped.methodSources.push(`${method.file}:${method.name}`);
786
+ },
787
+ }))
788
+ )
789
+ );
790
+ if (model.methods.length || model.deletedMethods.length) {
791
+ steps.push({
792
+ tokens: null,
793
+ apply: () => {
794
+ model.methodsDropped = true;
795
+ dropped.methods = true;
796
+ },
797
+ });
798
+ }
799
+ if (model.commits.some((commit) => commit.body)) {
800
+ steps.push({
801
+ tokens: null,
802
+ apply: () => {
803
+ model.logBodiesDropped = true;
804
+ dropped.commitBodies = true;
805
+ },
806
+ });
807
+ }
808
+ if (model.commits.length) {
809
+ steps.push({
810
+ tokens: null,
811
+ apply: () => {
812
+ model.logDropped = true;
813
+ dropped.commits = true;
814
+ },
815
+ });
816
+ }
817
+ if (model.files.length) {
818
+ steps.push({
819
+ tokens: null,
820
+ apply: () => {
821
+ model.filesDropped = true;
822
+ dropped.files = true;
823
+ },
824
+ });
825
+ }
826
+ return steps;
827
+ }
828
+
829
+ export default buildPrContext;