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
@@ -9,7 +9,7 @@
9
9
  * - Generate change summaries
10
10
  */
11
11
 
12
- import GitClient from './GitClient.js';
12
+ import GitClient, { assertGitRef } from './GitClient.js';
13
13
  import { getLogger } from '../../utils/logger.js';
14
14
 
15
15
  const logger = getLogger('DiffAnalyzer');
@@ -28,16 +28,14 @@ export class DiffAnalyzer {
28
28
  analyzeChanges(since = null) {
29
29
  logger.info(`Analyzing changes since: ${since || 'uncommitted'}`);
30
30
 
31
- const changedFiles = since
32
- ? this.git.getChangedFiles(since)
33
- : this.git.getAllModifiedFiles();
31
+ const changedFiles = since ? this.git.getChangedFiles(since) : this.git.getAllModifiedFiles();
34
32
 
35
33
  const analysis = {
36
34
  changedFiles: changedFiles,
37
35
  totalChangedFiles: changedFiles.length,
38
36
  since: since,
39
37
  relatedFiles: [],
40
- impact: this.calculateImpact(changedFiles)
38
+ impact: this.calculateImpact(changedFiles),
41
39
  };
42
40
 
43
41
  return analysis;
@@ -49,15 +47,13 @@ export class DiffAnalyzer {
49
47
  * @returns {Array<FileChange>}
50
48
  */
51
49
  getDetailedChanges(since = null) {
52
- const changedFiles = since
53
- ? this.git.getChangedFiles(since)
54
- : this.git.getAllModifiedFiles();
50
+ const changedFiles = since ? this.git.getChangedFiles(since) : this.git.getAllModifiedFiles();
55
51
 
56
- return changedFiles.map(filePath => {
52
+ return changedFiles.map((filePath) => {
57
53
  const diff = this.getFileDiff(filePath, since);
58
54
  return {
59
55
  path: filePath,
60
- ...diff
56
+ ...diff,
61
57
  };
62
58
  });
63
59
  }
@@ -70,18 +66,17 @@ export class DiffAnalyzer {
70
66
  */
71
67
  getFileDiff(filePath, since = null) {
72
68
  try {
73
- const command = since
74
- ? `diff ${since} -- "${filePath}"`
75
- : `diff -- "${filePath}"`;
69
+ if (since) assertGitRef(since);
70
+ const args = since ? ['diff', since, '--', filePath] : ['diff', '--', filePath];
76
71
 
77
- const diffOutput = this.git.exec(command);
72
+ const diffOutput = this.git.exec(args);
78
73
  const stats = this.parseDiffStats(diffOutput);
79
74
 
80
75
  return {
81
76
  added: stats.added,
82
77
  deleted: stats.deleted,
83
78
  modified: stats.added + stats.deleted,
84
- diff: diffOutput
79
+ diff: diffOutput,
85
80
  };
86
81
  } catch (error) {
87
82
  logger.warn(`Failed to get diff for ${filePath}: ${error.message}`);
@@ -123,24 +118,33 @@ export class DiffAnalyzer {
123
118
  coreFiles: 0,
124
119
  configFiles: 0,
125
120
  testFiles: 0,
126
- documentationFiles: 0
127
- }
121
+ documentationFiles: 0,
122
+ },
128
123
  };
129
124
 
130
125
  for (const file of changedFiles) {
131
126
  const fileLower = file.toLowerCase();
132
127
 
133
128
  // Core files (high impact)
134
- if (fileLower.includes('src/') || fileLower.includes('lib/') ||
135
- fileLower.includes('index.') || fileLower.includes('main.') ||
136
- fileLower.includes('app.') || fileLower.includes('server.')) {
129
+ if (
130
+ fileLower.includes('src/') ||
131
+ fileLower.includes('lib/') ||
132
+ fileLower.includes('index.') ||
133
+ fileLower.includes('main.') ||
134
+ fileLower.includes('app.') ||
135
+ fileLower.includes('server.')
136
+ ) {
137
137
  impact.details.coreFiles++;
138
138
  impact.score += 10;
139
139
  }
140
140
 
141
141
  // Config files (medium-high impact)
142
- if (fileLower.endsWith('.json') || fileLower.endsWith('.yml') ||
143
- fileLower.endsWith('.yaml') || fileLower.endsWith('.config.js')) {
142
+ if (
143
+ fileLower.endsWith('.json') ||
144
+ fileLower.endsWith('.yml') ||
145
+ fileLower.endsWith('.yaml') ||
146
+ fileLower.endsWith('.config.js')
147
+ ) {
144
148
  impact.details.configFiles++;
145
149
  impact.score += 7;
146
150
  }
@@ -177,7 +181,7 @@ export class DiffAnalyzer {
177
181
  * @param {Array<string>} changedFiles
178
182
  * @returns {Array<string>}
179
183
  */
180
- findRelatedFiles(changedFiles) {
184
+ findRelatedFiles(_changedFiles) {
181
185
  // TODO: Implement import graph analysis
182
186
  // For now, return empty array
183
187
  logger.debug('Related files detection not yet implemented');
@@ -201,14 +205,15 @@ export class DiffAnalyzer {
201
205
  */
202
206
  compareBranches(base, compare) {
203
207
  try {
204
- const command = `diff --name-status ${base}...${compare}`;
205
- const output = this.git.exec(command);
208
+ assertGitRef(base);
209
+ assertGitRef(compare);
210
+ const output = this.git.exec(['diff', '--name-status', `${base}...${compare}`]);
206
211
 
207
212
  const changes = {
208
213
  added: [],
209
214
  modified: [],
210
215
  deleted: [],
211
- renamed: []
216
+ renamed: [],
212
217
  };
213
218
 
214
219
  if (!output) return changes;
@@ -238,20 +243,22 @@ export class DiffAnalyzer {
238
243
  */
239
244
  getRangeStats(range) {
240
245
  try {
241
- const command = `diff --shortstat ${range}`;
242
- const output = this.git.exec(command);
246
+ assertGitRef(range);
247
+ const output = this.git.exec(['diff', '--shortstat', range]);
243
248
 
244
249
  // Parse: "3 files changed, 45 insertions(+), 12 deletions(-)"
245
- const match = output.match(/(\d+) file[s]? changed(?:, (\d+) insertion[s]?\(\+\))?(?:, (\d+) deletion[s]?\(-\))?/);
250
+ const match = output.match(
251
+ /(\d+) file[s]? changed(?:, (\d+) insertion[s]?\(\+\))?(?:, (\d+) deletion[s]?\(-\))?/
252
+ );
246
253
 
247
254
  if (!match) return null;
248
255
 
249
256
  return {
250
257
  filesChanged: parseInt(match[1], 10),
251
258
  insertions: parseInt(match[2] || 0, 10),
252
- deletions: parseInt(match[3] || 0, 10)
259
+ deletions: parseInt(match[3] || 0, 10),
253
260
  };
254
- } catch (error) {
261
+ } catch (_error) {
255
262
  return null;
256
263
  }
257
264
  }
@@ -9,13 +9,34 @@
9
9
  * - Cache git metadata
10
10
  */
11
11
 
12
- import { execSync } from 'child_process';
12
+ import { execFileSync } from 'child_process';
13
13
  import fs from 'fs';
14
14
  import path from 'path';
15
15
  import { getLogger } from '../../utils/logger.js';
16
16
 
17
17
  const logger = getLogger('GitClient');
18
18
 
19
+ /**
20
+ * Check that a user-supplied revision (branch, tag, commit, HEAD~2, a..b) is safe to
21
+ * hand to git: allowlisted characters only, and no leading '-' that git would parse
22
+ * as an option (e.g. --output=<file>)
23
+ * @param {string} ref
24
+ * @returns {boolean}
25
+ */
26
+ export function isValidGitRef(ref) {
27
+ return typeof ref === 'string' && !ref.startsWith('-') && /^[a-zA-Z0-9/\-_.@^~]+$/.test(ref);
28
+ }
29
+
30
+ /**
31
+ * Throw unless isValidGitRef(ref)
32
+ * @param {string} ref
33
+ */
34
+ export function assertGitRef(ref) {
35
+ if (!isValidGitRef(ref)) {
36
+ throw new Error(`Invalid git reference: ${JSON.stringify(ref)}`);
37
+ }
38
+ }
39
+
19
40
  export class GitClient {
20
41
  constructor(repositoryPath) {
21
42
  this.repoPath = repositoryPath;
@@ -30,51 +51,45 @@ export class GitClient {
30
51
  try {
31
52
  const gitDir = path.join(this.repoPath, '.git');
32
53
  return fs.existsSync(gitDir);
33
- } catch (error) {
54
+ } catch (_error) {
34
55
  return false;
35
56
  }
36
57
  }
37
58
 
38
59
  /**
39
60
  * Validate git reference to prevent command injection
40
- * @param {string} ref
61
+ * @param {string} ref
41
62
  * @returns {boolean}
42
63
  */
43
64
  validateGitRef(ref) {
44
65
  if (!ref) return true; // null/undefined is safe (handled logic elsewhere)
45
- // Allow alphanumeric, /, -, _, ., @, ^, ~
46
- // Disallow ; | & $ > < ` \ !
47
- return /^[a-zA-Z0-9/\-_.\@\^\~]+$/.test(ref);
66
+ return isValidGitRef(ref);
48
67
  }
49
68
 
50
69
  /**
51
- * Execute git command
52
- * @param {string} command - Git command (without 'git' prefix)
70
+ * Execute git with an argument list. No shell is involved, so arguments reach git
71
+ * verbatim; refs from users must still go through isValidGitRef()
72
+ * @param {string[]} args - Arguments after `git`
53
73
  * @returns {string} Command output
54
74
  */
55
- exec(command) {
75
+ exec(args) {
56
76
  if (!this.isGitRepo) {
57
77
  throw new Error('Not a git repository');
58
78
  }
59
-
60
- // Basic sanity check against shell injection if the command string was constructed with user input
61
- // This is a last line of defense; specific methods should validate their inputs.
62
- if (command.includes(';') || command.includes('&')) {
63
- logger.warn(`Potential injection detected in command: ${command}`);
64
- throw new Error('Invalid characters in git command');
79
+ if (!Array.isArray(args)) {
80
+ throw new TypeError('GitClient.exec() expects an array of arguments');
65
81
  }
66
82
 
67
83
  try {
68
- const fullCommand = `git ${command}`;
69
- const output = execSync(fullCommand, {
84
+ const output = execFileSync('git', args, {
70
85
  cwd: this.repoPath,
71
86
  encoding: 'utf-8',
72
- stdio: ['pipe', 'pipe', 'pipe']
87
+ stdio: ['pipe', 'pipe', 'pipe'],
73
88
  });
74
89
 
75
90
  return output.trim();
76
91
  } catch (error) {
77
- logger.error(`Git command failed: ${command}`);
92
+ logger.error(`Git command failed: git ${args.join(' ')}`);
78
93
  throw new Error(`Git command failed: ${error.message}`);
79
94
  }
80
95
  }
@@ -84,7 +99,7 @@ export class GitClient {
84
99
  * @returns {string}
85
100
  */
86
101
  getCurrentBranch() {
87
- return this.exec('rev-parse --abbrev-ref HEAD');
102
+ return this.exec(['rev-parse', '--abbrev-ref', 'HEAD']);
88
103
  }
89
104
 
90
105
  /**
@@ -93,8 +108,8 @@ export class GitClient {
93
108
  */
94
109
  getDefaultBranch() {
95
110
  try {
96
- return this.exec('symbolic-ref refs/remotes/origin/HEAD --short').split('/').pop();
97
- } catch (error) {
111
+ return this.exec(['symbolic-ref', 'refs/remotes/origin/HEAD', '--short']).split('/').pop();
112
+ } catch (_error) {
98
113
  return 'main'; // Fallback
99
114
  }
100
115
  }
@@ -105,17 +120,18 @@ export class GitClient {
105
120
  * @returns {Array<string>}
106
121
  */
107
122
  getChangedFiles(since = null) {
108
- let command;
123
+ let args;
109
124
 
110
125
  if (since) {
111
126
  // Files changed between commits/branches
112
- command = `diff --name-only ${since}`;
127
+ assertGitRef(since);
128
+ args = ['diff', '--name-only', since, '--'];
113
129
  } else {
114
130
  // Files with uncommitted changes
115
- command = 'diff --name-only';
131
+ args = ['diff', '--name-only'];
116
132
  }
117
133
 
118
- const output = this.exec(command);
134
+ const output = this.exec(args);
119
135
  return output ? output.split('\n').filter(Boolean) : [];
120
136
  }
121
137
 
@@ -124,7 +140,7 @@ export class GitClient {
124
140
  * @returns {Array<string>}
125
141
  */
126
142
  getStagedFiles() {
127
- const output = this.exec('diff --cached --name-only');
143
+ const output = this.exec(['diff', '--cached', '--name-only']);
128
144
  return output ? output.split('\n').filter(Boolean) : [];
129
145
  }
130
146
 
@@ -133,7 +149,7 @@ export class GitClient {
133
149
  * @returns {Array<string>}
134
150
  */
135
151
  getUnstagedFiles() {
136
- const output = this.exec('diff --name-only');
152
+ const output = this.exec(['diff', '--name-only']);
137
153
  return output ? output.split('\n').filter(Boolean) : [];
138
154
  }
139
155
 
@@ -142,7 +158,7 @@ export class GitClient {
142
158
  * @returns {Array<string>}
143
159
  */
144
160
  getUntrackedFiles() {
145
- const output = this.exec('ls-files --others --exclude-standard');
161
+ const output = this.exec(['ls-files', '--others', '--exclude-standard']);
146
162
  return output ? output.split('\n').filter(Boolean) : [];
147
163
  }
148
164
 
@@ -166,12 +182,19 @@ export class GitClient {
166
182
  * @returns {Array<Commit>}
167
183
  */
168
184
  getFileHistory(filePath, limit = 10) {
169
- const command = `log --follow --pretty=format:"%H|%an|%ae|%at|%s" -n ${limit} -- "${filePath}"`;
170
- const output = this.exec(command);
185
+ const output = this.exec([
186
+ 'log',
187
+ '--follow',
188
+ '--pretty=format:%H|%an|%ae|%at|%s',
189
+ '-n',
190
+ String(limit),
191
+ '--',
192
+ filePath,
193
+ ]);
171
194
 
172
195
  if (!output) return [];
173
196
 
174
- return output.split('\n').map(line => {
197
+ return output.split('\n').map((line) => {
175
198
  const [hash, author, email, timestamp, subject] = line.split('|');
176
199
  return {
177
200
  hash,
@@ -179,7 +202,7 @@ export class GitClient {
179
202
  email,
180
203
  timestamp: parseInt(timestamp, 10),
181
204
  date: new Date(parseInt(timestamp, 10) * 1000),
182
- subject
205
+ subject,
183
206
  };
184
207
  });
185
208
  }
@@ -191,8 +214,7 @@ export class GitClient {
191
214
  */
192
215
  getBlame(filePath) {
193
216
  try {
194
- const command = `blame --line-porcelain "${filePath}"`;
195
- const output = this.exec(command);
217
+ const output = this.exec(['blame', '--line-porcelain', '--', filePath]);
196
218
 
197
219
  return this.parseBlameOutput(output);
198
220
  } catch (error) {
@@ -228,7 +250,7 @@ export class GitClient {
228
250
  author: currentAuthor,
229
251
  timestamp: currentTimestamp,
230
252
  date: new Date(currentTimestamp * 1000),
231
- code: line.substring(1)
253
+ code: line.substring(1),
232
254
  });
233
255
  }
234
256
  }
@@ -243,9 +265,9 @@ export class GitClient {
243
265
  */
244
266
  getCommitCount(filePath) {
245
267
  try {
246
- const output = this.exec(`log --follow --oneline "${filePath}"`);
268
+ const output = this.exec(['log', '--follow', '--oneline', '--', filePath]);
247
269
  return output ? output.split('\n').length : 0;
248
- } catch (error) {
270
+ } catch (_error) {
249
271
  return 0;
250
272
  }
251
273
  }
@@ -257,14 +279,13 @@ export class GitClient {
257
279
  */
258
280
  getFileAuthors(filePath) {
259
281
  try {
260
- const command = `log --follow --pretty=format:"%an|%ae" -- "${filePath}"`;
261
- const output = this.exec(command);
282
+ const output = this.exec(['log', '--follow', '--pretty=format:%an|%ae', '--', filePath]);
262
283
 
263
284
  if (!output) return [];
264
285
 
265
286
  const authorsMap = new Map();
266
287
 
267
- output.split('\n').forEach(line => {
288
+ output.split('\n').forEach((line) => {
268
289
  const [name, email] = line.split('|');
269
290
  if (!authorsMap.has(email)) {
270
291
  authorsMap.set(email, { name, email, commits: 0 });
@@ -272,10 +293,8 @@ export class GitClient {
272
293
  authorsMap.get(email).commits++;
273
294
  });
274
295
 
275
- return Array.from(authorsMap.values())
276
- .sort((a, b) => b.commits - a.commits);
277
-
278
- } catch (error) {
296
+ return Array.from(authorsMap.values()).sort((a, b) => b.commits - a.commits);
297
+ } catch (_error) {
279
298
  return [];
280
299
  }
281
300
  }
@@ -290,15 +309,15 @@ export class GitClient {
290
309
  }
291
310
 
292
311
  try {
293
- const totalCommits = this.exec('rev-list --count HEAD');
294
- const totalFiles = this.exec('ls-files').split('\n').length;
295
- const contributors = this.exec('shortlog -sn --all').split('\n').length;
312
+ const totalCommits = this.exec(['rev-list', '--count', 'HEAD']);
313
+ const totalFiles = this.exec(['ls-files']).split('\n').length;
314
+ const contributors = this.exec(['shortlog', '-sn', '--all']).split('\n').length;
296
315
 
297
316
  return {
298
317
  totalCommits: parseInt(totalCommits, 10),
299
318
  totalFiles,
300
319
  contributors,
301
- currentBranch: this.getCurrentBranch()
320
+ currentBranch: this.getCurrentBranch(),
302
321
  };
303
322
  } catch (error) {
304
323
  logger.warn(`Failed to get repo stats: ${error.message}`);
@@ -313,8 +332,7 @@ export class GitClient {
313
332
  */
314
333
  getLastCommit(filePath) {
315
334
  try {
316
- const command = `log -1 --pretty=format:"%H|%an|%ae|%at|%s" -- "${filePath}"`;
317
- const output = this.exec(command);
335
+ const output = this.exec(['log', '-1', '--pretty=format:%H|%an|%ae|%at|%s', '--', filePath]);
318
336
 
319
337
  if (!output) return null;
320
338
 
@@ -325,9 +343,9 @@ export class GitClient {
325
343
  email,
326
344
  timestamp: parseInt(timestamp, 10),
327
345
  date: new Date(parseInt(timestamp, 10) * 1000),
328
- subject
346
+ subject,
329
347
  };
330
- } catch (error) {
348
+ } catch (_error) {
331
349
  return null;
332
350
  }
333
351
  }