ctxman 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/.ctxman/llm-profiles.json +1067 -39
  2. package/.ctxman/profiles/backend-team.json +13 -0
  3. package/.ctxman/profiles/default.json +12 -0
  4. package/.ctxman/profiles/devops-team.json +21 -0
  5. package/.ctxman/profiles/frontend-team.json +20 -0
  6. package/.ctxman/templates/bug-fix.json +17 -0
  7. package/.ctxman/templates/code-review.json +9 -0
  8. package/.ctxman/templates/documentation.json +9 -0
  9. package/.ctxman/templates/feature.json +9 -0
  10. package/.ctxman/templates/full-context.json +9 -0
  11. package/.ctxman/templates/refactoring.json +18 -0
  12. package/.ctxman/templates/testing.json +9 -0
  13. package/.ctxman/wizard-profiles/security-audit/profile.json +7 -1
  14. package/LICENSE +21 -0
  15. package/README.md +380 -136
  16. package/bin/cli.js +1880 -529
  17. package/bin/cm-ask.js +18 -13
  18. package/bin/cm-audit.js +124 -0
  19. package/bin/cm-count.js +151 -0
  20. package/bin/cm-gitingest.js +128 -115
  21. package/bin/cm-map.js +242 -0
  22. package/bin/cm-mcp.js +293 -0
  23. package/bin/cm-pack.js +405 -0
  24. package/bin/cm-policy.js +170 -0
  25. package/bin/cm-pr.js +248 -0
  26. package/bin/cm-search.js +178 -0
  27. package/bin/cm-update.js +128 -128
  28. package/bin/cm-why.js +197 -0
  29. package/bin/mcp-server.js +8 -24
  30. package/ctxman.js +237 -183
  31. package/examples/README.md +19 -4
  32. package/examples/github-actions/ctxman-check.yml +49 -0
  33. package/examples/wizard-profiles/security-audit/profile.json +7 -1
  34. package/index.d.ts +1422 -0
  35. package/index.js +55 -31
  36. package/lib/agents/pr-failure-agent.js +48 -45
  37. package/lib/analyzers/ai-suggester.js +451 -0
  38. package/lib/analyzers/dependency-scanner.js +505 -0
  39. package/lib/analyzers/duplicate-detector.js +264 -0
  40. package/lib/analyzers/go-method-analyzer.js +151 -110
  41. package/lib/analyzers/import-tracker.js +431 -0
  42. package/lib/analyzers/method-analyzer.js +1055 -434
  43. package/lib/analyzers/performance-dashboard.js +683 -0
  44. package/lib/analyzers/token-calculator.js +895 -504
  45. package/lib/analyzers/unused-exports-detector.js +336 -0
  46. package/lib/api/mcp/install.js +424 -0
  47. package/lib/api/mcp/prompts.js +281 -281
  48. package/lib/api/mcp/resources.js +300 -241
  49. package/lib/api/mcp/sandbox.js +89 -0
  50. package/lib/api/mcp/server.js +675 -312
  51. package/lib/api/mcp/start.js +125 -0
  52. package/lib/api/mcp/transports/HttpSseTransport.js +60 -52
  53. package/lib/api/rest/server.js +105 -28
  54. package/lib/cache/CacheManager.js +9 -17
  55. package/lib/core/Analyzer.js +16 -10
  56. package/lib/core/ContextBuilder.js +19 -28
  57. package/lib/core/Reporter.js +15 -14
  58. package/lib/core/Scanner.js +81 -12
  59. package/lib/count/index.js +100 -0
  60. package/lib/formatters/format-registry.js +394 -372
  61. package/lib/formatters/gitingest-formatter.js +656 -621
  62. package/lib/formatters/toon-formatter-v1.3.js +637 -632
  63. package/lib/formatters/toon-formatter.js +239 -240
  64. package/lib/integrations/git/BlameTracker.js +4 -4
  65. package/lib/integrations/git/DiffAnalyzer.js +38 -31
  66. package/lib/integrations/git/GitClient.js +71 -53
  67. package/lib/lsp/server.js +311 -0
  68. package/lib/map/graph.js +299 -0
  69. package/lib/map/imports.js +233 -0
  70. package/lib/map/index.js +33 -0
  71. package/lib/map/rank.js +253 -0
  72. package/lib/map/render.js +346 -0
  73. package/lib/map/resolver.js +574 -0
  74. package/lib/pack/budget.js +111 -0
  75. package/lib/pack/code-lexer.js +360 -0
  76. package/lib/pack/compress.js +414 -0
  77. package/lib/pack/index.js +354 -0
  78. package/lib/pack/renderers/common.js +134 -0
  79. package/lib/pack/renderers/index.js +24 -0
  80. package/lib/pack/renderers/json.js +47 -0
  81. package/lib/pack/renderers/markdown.js +54 -0
  82. package/lib/pack/renderers/plain.js +38 -0
  83. package/lib/pack/renderers/xml.js +85 -0
  84. package/lib/pack/select.js +177 -0
  85. package/lib/pack/split.js +182 -0
  86. package/lib/pack/tree.js +35 -0
  87. package/lib/parsers/gitignore-parser.js +113 -107
  88. package/lib/parsers/method-filter-parser.js +50 -33
  89. package/lib/plugins/ExporterPlugin.js +3 -3
  90. package/lib/plugins/LanguagePlugin.js +3 -3
  91. package/lib/plugins/PluginManager.js +7 -13
  92. package/lib/policy/audit.js +294 -0
  93. package/lib/policy/enforce.js +125 -0
  94. package/lib/policy/ignore-files.js +169 -0
  95. package/lib/policy/index.js +52 -0
  96. package/lib/policy/policy.js +489 -0
  97. package/lib/pr/diff-parser.js +267 -0
  98. package/lib/pr/method-mapper.js +148 -0
  99. package/lib/pr/pr-context.js +829 -0
  100. package/lib/pr/pr-git.js +149 -0
  101. package/lib/pr/pr-renderer.js +644 -0
  102. package/lib/pr/related-tests.js +296 -0
  103. package/lib/rag/DataSourcePlugin.js +5 -5
  104. package/lib/rag/EmbeddingProvider.js +19 -8
  105. package/lib/rag/Indexer.js +14 -5
  106. package/lib/rag/VectorStoreFactory.js +3 -3
  107. package/lib/rag/sources/FileDataSource.js +6 -6
  108. package/lib/rag/stores/BaseVectorStore.js +5 -5
  109. package/lib/rag/stores/ChromaDBStore.js +10 -10
  110. package/lib/rag/stores/LanceDBStore.js +29 -19
  111. package/lib/rag/stores/PineconeStore.js +11 -9
  112. package/lib/search/bm25.js +433 -0
  113. package/lib/search/code-units.js +201 -0
  114. package/lib/search/symbols.js +189 -0
  115. package/lib/ui/dashboard.js +120 -89
  116. package/lib/ui/index.js +1 -6
  117. package/lib/ui/init-wizard-ui.js +550 -0
  118. package/lib/ui/progress-bar.js +94 -88
  119. package/lib/ui/select-input.js +40 -32
  120. package/lib/ui/template-selector.js +99 -0
  121. package/lib/ui/wizard.js +374 -329
  122. package/lib/utils/claude-token-counter.js +151 -0
  123. package/lib/utils/cli-output.js +17 -0
  124. package/lib/utils/clipboard-utils.js +49 -50
  125. package/lib/utils/config-utils.js +81 -66
  126. package/lib/utils/context-versioning.js +532 -0
  127. package/lib/utils/error-handler.js +112 -112
  128. package/lib/utils/file-utils.js +153 -69
  129. package/lib/utils/format-converter.js +356 -283
  130. package/lib/utils/git-utils.js +366 -294
  131. package/lib/utils/llm-cost-estimator.js +281 -0
  132. package/lib/utils/llm-detector.js +242 -78
  133. package/lib/utils/logger.js +243 -237
  134. package/lib/utils/messages.js +355 -0
  135. package/lib/utils/multi-repo-manager.js +364 -0
  136. package/lib/utils/profile-manager.js +380 -0
  137. package/lib/utils/safe-read.js +61 -0
  138. package/lib/utils/secret-redactor.js +61 -0
  139. package/lib/utils/snapshot-manager.js +679 -0
  140. package/lib/utils/template-manager.js +378 -0
  141. package/lib/utils/token-utils.js +201 -170
  142. package/lib/utils/tokenizer-adapter.js +421 -377
  143. package/lib/utils/updater.js +539 -506
  144. package/lib/watch/FileWatcher.js +12 -8
  145. package/lib/watch/IncrementalAnalyzer.js +7 -7
  146. package/lib/why/coupling.js +81 -0
  147. package/lib/why/errors.js +23 -0
  148. package/lib/why/git.js +169 -0
  149. package/lib/why/history.js +108 -0
  150. package/lib/why/index.js +720 -0
  151. package/lib/why/ownership.js +79 -0
  152. package/lib/why/paths.js +207 -0
  153. package/lib/why/references.js +259 -0
  154. package/lib/why/render.js +722 -0
  155. package/lib/why/search.js +358 -0
  156. package/lib/why/target.js +389 -0
  157. package/lib/wizards/config-generator.js +255 -0
  158. package/lib/wizards/project-detector.js +571 -0
  159. package/package.json +44 -10
  160. package/.ctxman/logs/ctxman-2026-07-02.log +0 -156
  161. package/.ctxman/self-knowledge/documents.lance/_transactions/0-b3dd1558-f95b-44b9-a154-18e8c330ae7a.txn +0 -0
  162. package/.ctxman/self-knowledge/documents.lance/_transactions/1-44bff6d4-e552-4010-a769-e09ad822698d.txn +0 -0
  163. package/.ctxman/self-knowledge/documents.lance/_transactions/10-37260794-5947-4ecb-b4c8-d732f58f40f1.txn +0 -0
  164. package/.ctxman/self-knowledge/documents.lance/_transactions/11-0d52a883-5518-463c-9b6d-986c3af89dfd.txn +0 -0
  165. package/.ctxman/self-knowledge/documents.lance/_transactions/12-ae354646-52f2-49c9-90fb-2e6ed8932663.txn +0 -0
  166. package/.ctxman/self-knowledge/documents.lance/_transactions/13-5d7d5e57-27da-498b-b734-091b9d38da13.txn +0 -0
  167. package/.ctxman/self-knowledge/documents.lance/_transactions/14-558ce928-810b-4252-a364-fdd1ee6f8a25.txn +0 -0
  168. package/.ctxman/self-knowledge/documents.lance/_transactions/15-27e3a58f-8b64-43b8-a9f4-e3369446968b.txn +0 -0
  169. package/.ctxman/self-knowledge/documents.lance/_transactions/16-a110c390-e5c8-4464-b976-bc7f5195fa15.txn +0 -0
  170. package/.ctxman/self-knowledge/documents.lance/_transactions/17-97513225-53de-4216-b61a-6337bd0cc4ad.txn +0 -0
  171. package/.ctxman/self-knowledge/documents.lance/_transactions/18-5ba450f0-c527-459c-bf4f-6d79b5e4285f.txn +0 -0
  172. package/.ctxman/self-knowledge/documents.lance/_transactions/19-600d3a63-5225-49e9-b8b7-30e037b564ab.txn +0 -0
  173. package/.ctxman/self-knowledge/documents.lance/_transactions/2-48a61b4e-92cf-4342-8376-914c1b99a9f9.txn +0 -0
  174. package/.ctxman/self-knowledge/documents.lance/_transactions/20-121dd912-5410-410e-84ff-82c7a23604a0.txn +0 -0
  175. package/.ctxman/self-knowledge/documents.lance/_transactions/3-bc6844ab-7ea3-4bb3-9ee1-538ce5664a9c.txn +0 -0
  176. package/.ctxman/self-knowledge/documents.lance/_transactions/4-d5e1754f-dbb5-454e-a9be-2a7da8d7bcf9.txn +0 -0
  177. package/.ctxman/self-knowledge/documents.lance/_transactions/5-4fae93df-0568-4b6f-8acf-8bef4bba6047.txn +0 -0
  178. package/.ctxman/self-knowledge/documents.lance/_transactions/6-4b1b8b87-068e-49a3-ba2b-d8dae628b252.txn +0 -0
  179. package/.ctxman/self-knowledge/documents.lance/_transactions/7-63d040fb-d2fa-4c0f-bed0-53411918a7b0.txn +0 -0
  180. package/.ctxman/self-knowledge/documents.lance/_transactions/8-c5628cf6-264c-420f-aab8-bc96a9083591.txn +0 -0
  181. package/.ctxman/self-knowledge/documents.lance/_transactions/9-c94570aa-5e53-4fcb-971f-cecc1d1d5492.txn +0 -0
  182. package/.ctxman/self-knowledge/documents.lance/_versions/1.manifest +0 -0
  183. package/.ctxman/self-knowledge/documents.lance/_versions/10.manifest +0 -0
  184. package/.ctxman/self-knowledge/documents.lance/_versions/11.manifest +0 -0
  185. package/.ctxman/self-knowledge/documents.lance/_versions/12.manifest +0 -0
  186. package/.ctxman/self-knowledge/documents.lance/_versions/13.manifest +0 -0
  187. package/.ctxman/self-knowledge/documents.lance/_versions/14.manifest +0 -0
  188. package/.ctxman/self-knowledge/documents.lance/_versions/15.manifest +0 -0
  189. package/.ctxman/self-knowledge/documents.lance/_versions/16.manifest +0 -0
  190. package/.ctxman/self-knowledge/documents.lance/_versions/17.manifest +0 -0
  191. package/.ctxman/self-knowledge/documents.lance/_versions/18.manifest +0 -0
  192. package/.ctxman/self-knowledge/documents.lance/_versions/19.manifest +0 -0
  193. package/.ctxman/self-knowledge/documents.lance/_versions/2.manifest +0 -0
  194. package/.ctxman/self-knowledge/documents.lance/_versions/20.manifest +0 -0
  195. package/.ctxman/self-knowledge/documents.lance/_versions/21.manifest +0 -0
  196. package/.ctxman/self-knowledge/documents.lance/_versions/3.manifest +0 -0
  197. package/.ctxman/self-knowledge/documents.lance/_versions/4.manifest +0 -0
  198. package/.ctxman/self-knowledge/documents.lance/_versions/5.manifest +0 -0
  199. package/.ctxman/self-knowledge/documents.lance/_versions/6.manifest +0 -0
  200. package/.ctxman/self-knowledge/documents.lance/_versions/7.manifest +0 -0
  201. package/.ctxman/self-knowledge/documents.lance/_versions/8.manifest +0 -0
  202. package/.ctxman/self-knowledge/documents.lance/_versions/9.manifest +0 -0
  203. package/.ctxman/self-knowledge/documents.lance/data/000010010011011101000010de8621405d9d5cb06540dbf02c.lance +0 -0
  204. package/.ctxman/self-knowledge/documents.lance/data/0000101001011100011000003fb62a4aa8978974075c144f1c.lance +0 -0
  205. package/.ctxman/self-knowledge/documents.lance/data/000011010000100111001011c6cc824d628f3bb1f4e04cd5d0.lance +0 -0
  206. package/.ctxman/self-knowledge/documents.lance/data/001000001000011001011100d8f74349ada090b332cd1df669.lance +0 -0
  207. package/.ctxman/self-knowledge/documents.lance/data/01000001111010001000010045ab44467bb1e7e4d8ae06bd58.lance +0 -0
  208. package/.ctxman/self-knowledge/documents.lance/data/01000101101110111110011131ef904241a89e1ffb6ddaf8e3.lance +0 -0
  209. package/.ctxman/self-knowledge/documents.lance/data/010010110000010000011010fbfcc145c6a8a827b28057a07e.lance +0 -0
  210. package/.ctxman/self-knowledge/documents.lance/data/1000011000101010001110003fb4f045c3835982efa1405324.lance +0 -0
  211. package/.ctxman/self-knowledge/documents.lance/data/100111011101110111101011f4396b410fa06af3b028d204e6.lance +0 -0
  212. package/.ctxman/self-knowledge/documents.lance/data/100111100111101000001100596b174e00995d6cc2969cc8dc.lance +0 -0
  213. package/.ctxman/self-knowledge/documents.lance/data/1010001100011000110010102ea16b484d887c7c9725feffd6.lance +0 -0
  214. package/.ctxman/self-knowledge/documents.lance/data/101101100010101111110111c5230944d9b3af7301b6392dda.lance +0 -0
  215. package/.ctxman/self-knowledge/documents.lance/data/1011011100100111111110105892bf41b5a530fedd7cf516f1.lance +0 -0
  216. package/.ctxman/self-knowledge/documents.lance/data/10111110000010001011110164e8774742bade87b483c10f36.lance +0 -0
  217. package/.ctxman/self-knowledge/documents.lance/data/1100000100010110001110103ffaca40a2a42c85067ebaf9ae.lance +0 -0
  218. package/.ctxman/self-knowledge/documents.lance/data/110000010001110110101110664bde4c76a78dfc14b05e83b8.lance +0 -0
  219. package/.ctxman/self-knowledge/documents.lance/data/11001100111000010100110050645c4281b1f35b2022234f7f.lance +0 -0
  220. package/.ctxman/self-knowledge/documents.lance/data/1101111011001100110111017214924a368f5719eab2246619.lance +0 -0
  221. package/.ctxman/self-knowledge/documents.lance/data/1110000111001010001000002150c24dcab653d39d07032e63.lance +0 -0
  222. package/.ctxman/self-knowledge/documents.lance/data/1110100000000100000011014d20534967a18b5c87721659ff.lance +0 -0
  223. package/.ctxman/self-knowledge/documents.lance/data/11110101111010000101011135a0114ef2b35dc6f1297a3381.lance +0 -0
@@ -13,6 +13,7 @@ import fs from 'fs';
13
13
  import path from 'path';
14
14
  import { EventEmitter } from 'events';
15
15
  import GitIgnoreParser from '../parsers/gitignore-parser.js';
16
+ import FileUtils from '../utils/file-utils.js';
16
17
  import { getLogger } from '../utils/logger.js';
17
18
 
18
19
  const logger = getLogger('FileWatcher');
@@ -26,7 +27,7 @@ export class FileWatcher extends EventEmitter {
26
27
  debounce: 1000, // 1 second debounce
27
28
  recursive: true,
28
29
  ignorePatterns: [],
29
- ...options
30
+ ...options,
30
31
  };
31
32
 
32
33
  // Initialize GitIgnoreParser with correct file paths
@@ -42,7 +43,7 @@ export class FileWatcher extends EventEmitter {
42
43
  this.stats = {
43
44
  totalChanges: 0,
44
45
  ignoredChanges: 0,
45
- errors: 0
46
+ errors: 0,
46
47
  };
47
48
  }
48
49
 
@@ -71,7 +72,6 @@ export class FileWatcher extends EventEmitter {
71
72
 
72
73
  this.emit('watch:started', { path: this.rootPath });
73
74
  logger.info('Watch mode started');
74
-
75
75
  } catch (error) {
76
76
  logger.error(`Failed to start watch: ${error.message}`);
77
77
  this.emit('watch:error', { error });
@@ -153,7 +153,7 @@ export class FileWatcher extends EventEmitter {
153
153
  path: filePath,
154
154
  relativePath: relativePath,
155
155
  timestamp: Date.now(),
156
- exists: fs.existsSync(filePath)
156
+ exists: fs.existsSync(filePath),
157
157
  };
158
158
 
159
159
  // Add file stats if exists
@@ -162,7 +162,7 @@ export class FileWatcher extends EventEmitter {
162
162
  const stats = fs.statSync(filePath);
163
163
  event.size = stats.size;
164
164
  event.modified = stats.mtime;
165
- } catch (error) {
165
+ } catch (_error) {
166
166
  // Stats not available
167
167
  }
168
168
  }
@@ -176,8 +176,12 @@ export class FileWatcher extends EventEmitter {
176
176
  * @returns {boolean}
177
177
  */
178
178
  shouldIgnore(relativePath) {
179
+ // node_modules, .git and build output are skipped like in the scanners, .gitignore or not
180
+ if (relativePath.split(path.sep).some((part) => FileUtils.isSkippedDirectory(part))) {
181
+ return true;
182
+ }
179
183
  // Use GitIgnoreParser for ignore rules
180
- return !this.gitIgnore.shouldIncludeFile(relativePath);
184
+ return this.gitIgnore.isIgnored(path.join(this.rootPath, relativePath), relativePath);
181
185
  }
182
186
 
183
187
  /**
@@ -189,7 +193,7 @@ export class FileWatcher extends EventEmitter {
189
193
  ...this.stats,
190
194
  isWatching: this.isWatching,
191
195
  activeWatchers: this.watchers.length,
192
- pendingDebounce: this.debounceTimers.size
196
+ pendingDebounce: this.debounceTimers.size,
193
197
  };
194
198
  }
195
199
 
@@ -200,7 +204,7 @@ export class FileWatcher extends EventEmitter {
200
204
  this.stats = {
201
205
  totalChanges: 0,
202
206
  ignoredChanges: 0,
203
- errors: 0
207
+ errors: 0,
204
208
  };
205
209
  }
206
210
  }
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { EventEmitter } from 'events';
13
+ import path from 'path';
13
14
  import { Analyzer } from '../core/Analyzer.js';
14
15
  import { getLogger } from '../utils/logger.js';
15
16
 
@@ -21,7 +22,7 @@ export class IncrementalAnalyzer extends EventEmitter {
21
22
 
22
23
  this.options = {
23
24
  autoAnalyze: true,
24
- ...options
25
+ ...options,
25
26
  };
26
27
 
27
28
  this.analyzer = new Analyzer(options);
@@ -52,7 +53,7 @@ export class IncrementalAnalyzer extends EventEmitter {
52
53
  name: path.basename(changeEvent.path),
53
54
  extension: path.extname(changeEvent.path),
54
55
  size: changeEvent.size,
55
- modified: changeEvent.modified
56
+ modified: changeEvent.modified,
56
57
  };
57
58
 
58
59
  const analysis = await this.analyzer.analyzeFile(fileInfo);
@@ -71,17 +72,16 @@ export class IncrementalAnalyzer extends EventEmitter {
71
72
  file: changeEvent.relativePath,
72
73
  analysis,
73
74
  elapsed,
74
- cached: false
75
+ cached: false,
75
76
  });
76
77
 
77
78
  logger.debug(`Analysis complete: ${changeEvent.relativePath} (${elapsed}ms)`);
78
79
  }
79
-
80
80
  } catch (error) {
81
81
  logger.error(`Analysis failed for ${changeEvent.relativePath}: ${error.message}`);
82
82
  this.emit('analysis:error', {
83
83
  file: changeEvent.relativePath,
84
- error
84
+ error,
85
85
  });
86
86
  }
87
87
  }
@@ -114,7 +114,7 @@ export class IncrementalAnalyzer extends EventEmitter {
114
114
 
115
115
  this.emit('file:deleted', {
116
116
  file: changeEvent.relativePath,
117
- oldTokens: oldAnalysis.tokens
117
+ oldTokens: oldAnalysis.tokens,
118
118
  });
119
119
 
120
120
  logger.debug(`File deleted from cache: ${changeEvent.relativePath}`);
@@ -198,7 +198,7 @@ export class IncrementalAnalyzer extends EventEmitter {
198
198
  getCacheStats() {
199
199
  return {
200
200
  cachedFiles: this.fileCache.size,
201
- cacheSize: this.fileCache.size * 1024 // Rough estimate
201
+ cacheSize: this.fileCache.size * 1024, // Rough estimate
202
202
  };
203
203
  }
204
204
  }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Co-change coupling: files that changed in the same commits as the target. A file that
3
+ * nearly always changes with the target is usually part of the same decision (a test, a
4
+ * schema, a caller, the docs), which makes it a strong "what else" signal
5
+ */
6
+
7
+ import { literalPathspec, runGit } from './git.js';
8
+
9
+ const RS = '\x1e';
10
+
11
+ /**
12
+ * Split `git log --format=<RS>%H --name-only` output into commits and their files
13
+ * @param {string} output
14
+ * @returns {Array<{sha: string, files: string[]}>}
15
+ */
16
+ function parseNameOnly(output) {
17
+ return output
18
+ .split(RS)
19
+ .map((record) => record.split('\n').filter((line) => line !== ''))
20
+ .filter((lines) => lines.length > 0)
21
+ .map(([sha, ...files]) => ({ sha: sha.trim(), files }));
22
+ }
23
+
24
+ /**
25
+ * @param {string} top - Work tree root
26
+ * @param {object} query
27
+ * @param {string} query.path - Forward-slash path relative to `top`
28
+ * @param {number} query.window - Commits of the target to examine
29
+ * @param {number} [query.maxFilesPerCommit] - Larger commits are skipped as noise (default 50)
30
+ * @param {string} [query.rev]
31
+ * @param {(gitPath: string) => boolean} [query.keep] - Filter on candidate paths
32
+ * @returns {{window: number, targetChanges: number, skippedLargeCommits: number,
33
+ * files: Array<{path: string, count: number, confidence: number}>}}
34
+ */
35
+ export function collectCoupling(top, query) {
36
+ const maxFiles = query.maxFilesPerCommit ?? 50;
37
+ const args = ['log', '--follow', '--no-merges', '-n', String(query.window)];
38
+ args.push(`--format=${RS}%H`, '--name-only');
39
+ if (query.rev) args.push(query.rev);
40
+ args.push('--', literalPathspec(query.path));
41
+
42
+ // With a pathspec, --name-only lists only the target, under its name at that commit
43
+ const touches = parseNameOnly(runGit(top, args));
44
+ const aliases = new Set([query.path, ...touches.flatMap((commit) => commit.files)]);
45
+
46
+ let commits = [];
47
+ if (touches.length > 0) {
48
+ const input = touches.map((commit) => commit.sha).join('\n') + '\n';
49
+ commits = parseNameOnly(
50
+ runGit(top, ['log', '--no-walk=unsorted', '--stdin', `--format=${RS}%H`, '--name-only'], {
51
+ input,
52
+ })
53
+ );
54
+ }
55
+
56
+ const counts = new Map();
57
+ let considered = 0;
58
+ let skipped = 0;
59
+ for (const commit of commits) {
60
+ if (commit.files.length > maxFiles) {
61
+ skipped++;
62
+ continue;
63
+ }
64
+ considered++;
65
+ for (const file of new Set(commit.files)) {
66
+ if (aliases.has(file)) continue;
67
+ counts.set(file, (counts.get(file) ?? 0) + 1);
68
+ }
69
+ }
70
+
71
+ const files = [...counts.entries()]
72
+ .filter(([file]) => !query.keep || query.keep(file))
73
+ .map(([file, count]) => ({
74
+ path: file,
75
+ count,
76
+ confidence: considered ? Math.round((count / considered) * 1000) / 1000 : 0,
77
+ }))
78
+ .sort((a, b) => b.count - a.count || a.path.localeCompare(b.path));
79
+
80
+ return { window: query.window, targetChanges: considered, skippedLargeCommits: skipped, files };
81
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Errors raised by `ctxman why`
3
+ */
4
+
5
+ /**
6
+ * An expected failure (bad target, not a repository, budget too small, ...) with a
7
+ * stable code callers can branch on and a message meant for the user
8
+ */
9
+ export class WhyError extends Error {
10
+ /**
11
+ * @param {string} code - e.g. NOT_A_GIT_REPO, PATH_OUTSIDE_ROOT, SYMBOL_NOT_FOUND
12
+ * @param {string} message - User-facing message
13
+ * @param {object} [details] - Extra fields copied onto the error (e.g. candidates)
14
+ */
15
+ constructor(code, message, details = {}) {
16
+ super(message);
17
+ this.name = 'WhyError';
18
+ this.code = code;
19
+ Object.assign(this, details);
20
+ }
21
+ }
22
+
23
+ export default WhyError;
package/lib/why/git.js ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Git access for `ctxman why`
3
+ *
4
+ * Every command runs through execFileSync with an argument list (no shell). Unlike
5
+ * GitClient.exec() this runner keeps output untrimmed (blame and patches are
6
+ * whitespace-sensitive), allows large outputs, lets callers accept exit codes such as
7
+ * `git grep`'s 1 for "no match", and logs nothing: an expected failure must not print
8
+ * to stdout, which carries the document.
9
+ */
10
+
11
+ import { execFileSync } from 'child_process';
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import { WhyError } from './errors.js';
15
+
16
+ // Settings that would change the output format (signature blocks, quoted paths, colour)
17
+ const BASE_ARGS = [
18
+ '-c',
19
+ 'core.quotePath=false',
20
+ '-c',
21
+ 'log.showSignature=false',
22
+ '-c',
23
+ 'color.ui=false',
24
+ ];
25
+
26
+ const MAX_BUFFER = 256 * 1024 * 1024;
27
+
28
+ /**
29
+ * Run git and return its stdout
30
+ * @param {string} cwd - Working directory
31
+ * @param {string[]} args - Arguments after `git`
32
+ * @param {object} [options]
33
+ * @param {string} [options.input] - Text for stdin
34
+ * @param {number[]} [options.okStatuses] - Exit codes that count as success (default [0])
35
+ * @param {'utf-8'|'buffer'} [options.encoding] - 'buffer' returns the raw bytes
36
+ * @returns {string|Buffer}
37
+ */
38
+ export function runGit(cwd, args, { input, okStatuses = [0], encoding = 'utf-8' } = {}) {
39
+ try {
40
+ return execFileSync('git', [...BASE_ARGS, ...args], {
41
+ cwd,
42
+ encoding,
43
+ maxBuffer: MAX_BUFFER,
44
+ input,
45
+ stdio: ['pipe', 'pipe', 'pipe'],
46
+ // Read-only use: never take optional locks, never prompt
47
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' },
48
+ });
49
+ } catch (error) {
50
+ if (error.code === 'ENOENT') {
51
+ throw new WhyError('GIT_NOT_FOUND', 'git is not installed or not on PATH');
52
+ }
53
+ if (typeof error.status === 'number' && okStatuses.includes(error.status)) {
54
+ if (encoding === 'buffer') return error.stdout ?? Buffer.alloc(0);
55
+ return error.stdout ? String(error.stdout) : '';
56
+ }
57
+ const stderr = error.stderr ? String(error.stderr).trim() : '';
58
+ const failure = new Error(stderr || error.message);
59
+ failure.status = error.status;
60
+ failure.stderr = stderr;
61
+ throw failure;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Like runGit, but returns null instead of throwing on a git error. Success can be an
67
+ * empty string (`git cat-file -e`), so test the result against null, not for truthiness
68
+ * @param {string} cwd
69
+ * @param {string[]} args
70
+ * @param {object} [options]
71
+ * @returns {string|Buffer|null}
72
+ */
73
+ export function tryGit(cwd, args, options) {
74
+ try {
75
+ return runGit(cwd, args, options);
76
+ } catch (error) {
77
+ if (error instanceof WhyError) throw error;
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Top-level directory of the work tree containing `dir`
84
+ * @param {string} dir
85
+ * @returns {string} Real (symlink-resolved) path
86
+ */
87
+ export function findRepositoryRoot(dir) {
88
+ if (!dir || !fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory()) {
89
+ throw new WhyError('ROOT_NOT_FOUND', `Not a directory: ${dir}`);
90
+ }
91
+ let top;
92
+ try {
93
+ top = runGit(dir, ['rev-parse', '--show-toplevel']).trim();
94
+ } catch (error) {
95
+ if (error instanceof WhyError) throw error;
96
+ const reason = error.stderr ? ` (${error.stderr.split('\n')[0]})` : '';
97
+ throw new WhyError(
98
+ 'NOT_A_GIT_REPO',
99
+ `Not a git repository: ${dir}${reason}. ctxman why reads git history; run it inside a git work tree.`
100
+ );
101
+ }
102
+ if (!top) {
103
+ throw new WhyError('NOT_A_GIT_REPO', `Not a git work tree: ${dir}`);
104
+ }
105
+ return fs.realpathSync(top);
106
+ }
107
+
108
+ /**
109
+ * Commits whose parents a shallow clone did not fetch (the `shallow` file in the git
110
+ * directory). git shows them as root commits, so their diff looks like they added
111
+ * everything
112
+ * @param {string} top - Work tree root
113
+ * @returns {Set<string>} Full hashes
114
+ */
115
+ function readShallowBoundary(top) {
116
+ const file = tryGit(top, ['rev-parse', '--git-path', 'shallow'])?.trim();
117
+ if (!file) return new Set();
118
+ try {
119
+ const content = fs.readFileSync(path.resolve(top, file), 'utf-8');
120
+ return new Set(content.split('\n').filter((line) => /^[0-9a-f]{40,64}$/.test(line)));
121
+ } catch (_error) {
122
+ return new Set();
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Facts about the repository that shape the output
128
+ * @param {string} top - Work tree root
129
+ * @returns {{shallow: boolean, boundary: Set<string>, head: string|null,
130
+ * remoteUrl: string|null}} boundary: see readShallowBoundary (empty unless shallow)
131
+ */
132
+ export function readRepositoryInfo(top) {
133
+ const shallow = tryGit(top, ['rev-parse', '--is-shallow-repository'])?.trim() === 'true';
134
+ const boundary = shallow ? readShallowBoundary(top) : new Set();
135
+ const head = tryGit(top, ['rev-parse', '--verify', '--quiet', 'HEAD'])?.trim() || null;
136
+ const remoteUrl = tryGit(top, ['remote', 'get-url', 'origin'])?.trim() || null;
137
+ return { shallow, boundary, head, remoteUrl };
138
+ }
139
+
140
+ /**
141
+ * A pathspec matching exactly this path: no glob characters, no pathspec magic
142
+ * @param {string} gitPath
143
+ * @returns {string}
144
+ */
145
+ export function literalPathspec(gitPath) {
146
+ return `:(literal)${gitPath}`;
147
+ }
148
+
149
+ /**
150
+ * Convert an OS path relative to the work tree into git's forward-slash form
151
+ * @param {string} relativePath
152
+ * @returns {string}
153
+ */
154
+ export function toGitPath(relativePath) {
155
+ return relativePath.split(path.sep).join('/');
156
+ }
157
+
158
+ /**
159
+ * A git date as ISO 8601 in UTC, to the second (2024-01-02T10:00:00Z). Commit dates
160
+ * (`%aI`, author's offset) and blame times (epoch seconds) end up in one form, so dates
161
+ * compare and print alike across sections
162
+ * @param {string|number} value - ISO 8601 text, or seconds since the epoch
163
+ * @returns {string|null} null for a missing or unparsable date
164
+ */
165
+ export function utcDate(value) {
166
+ const time = typeof value === 'number' ? value * 1000 : Date.parse(value);
167
+ if (!value || !Number.isFinite(time)) return null;
168
+ return new Date(time).toISOString().replace(/\.\d{3}Z$/, 'Z');
169
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Commit history of a target: `git log -L` for a line range (or git's own funcname
3
+ * matching as a fallback), `git log --follow` for a whole file
4
+ */
5
+
6
+ import { literalPathspec, runGit, utcDate } from './git.js';
7
+
8
+ // Record separator, field separator, end of header: the patch (if any) follows the header
9
+ const RS = '\x1e';
10
+ const FS = '\x1f';
11
+ const EOH = '\x1d';
12
+ const FORMAT = `--format=${RS}%H${FS}%h${FS}%aI${FS}%an${FS}%ae${FS}%P${FS}%s${FS}%b${EOH}`;
13
+
14
+ /**
15
+ * @typedef {object} HistoryCommit
16
+ * @property {string} sha
17
+ * @property {string} shortSha
18
+ * @property {string} date - Author date, ISO 8601 in UTC (2024-01-02T10:00:00Z)
19
+ * @property {string} author
20
+ * @property {string} email
21
+ * @property {boolean} merge
22
+ * @property {string} subject
23
+ * @property {string} body
24
+ * @property {string} [patch] - Diff for the target (only when patches were requested)
25
+ */
26
+
27
+ /**
28
+ * Parse `git log` output written with FORMAT
29
+ * @param {string} output
30
+ * @param {boolean} keepPatches
31
+ * @returns {HistoryCommit[]}
32
+ */
33
+ export function parseLog(output, keepPatches) {
34
+ const commits = [];
35
+ for (const record of output.split(RS)) {
36
+ const end = record.indexOf(EOH);
37
+ if (end === -1) continue;
38
+ const fields = record.slice(0, end).split(FS);
39
+ if (fields.length < 8) continue;
40
+ const [sha, shortSha, date, author, email, parents, subject, ...body] = fields;
41
+ const commit = {
42
+ sha,
43
+ shortSha,
44
+ date: utcDate(date) ?? date,
45
+ author,
46
+ email,
47
+ merge: parents.trim().split(/\s+/).length > 1,
48
+ subject,
49
+ body: body.join(FS).trim(),
50
+ };
51
+ if (keepPatches) {
52
+ const patch = record
53
+ .slice(end + 1)
54
+ .replace(/^\n+/, '')
55
+ .trimEnd();
56
+ if (patch) commit.patch = patch;
57
+ }
58
+ commits.push(commit);
59
+ }
60
+ return commits;
61
+ }
62
+
63
+ /**
64
+ * Commits that touched the target, newest first
65
+ * @param {string} top - Work tree root (git runs here)
66
+ * @param {object} query
67
+ * @param {string} query.path - Forward-slash path relative to `top`
68
+ * @param {'range'|'funcname'|'file'} query.mode
69
+ * @param {number} [query.startLine] - For 'range' (lines of `rev`)
70
+ * @param {number} [query.endLine]
71
+ * @param {string} [query.funcname] - For 'funcname': a basic regular expression
72
+ * @param {string} [query.rev] - Revision to start from (validated by the caller)
73
+ * @param {number} query.limit
74
+ * @param {string} [query.since]
75
+ * @param {boolean} [query.patches]
76
+ * @returns {HistoryCommit[]}
77
+ */
78
+ export function collectHistory(top, query) {
79
+ const args = ['log', FORMAT, '-n', String(query.limit), '--no-ext-diff', '--no-color'];
80
+ if (query.since) args.push(`--since=${query.since}`);
81
+
82
+ if (query.mode === 'file') {
83
+ args.push('--follow');
84
+ if (query.patches) args.push('-p');
85
+ if (query.rev) args.push(query.rev);
86
+ args.push('--', literalPathspec(query.path));
87
+ } else {
88
+ const range =
89
+ query.mode === 'range'
90
+ ? `${query.startLine},${query.endLine}:${query.path}`
91
+ : `:${query.funcname}:${query.path}`;
92
+ args.push('-L', range);
93
+ // Older git versions ignore -s with -L; parseLog drops the patch text either way
94
+ if (!query.patches) args.push('-s');
95
+ if (query.rev) args.push(query.rev);
96
+ }
97
+
98
+ return parseLog(runGit(top, args), Boolean(query.patches));
99
+ }
100
+
101
+ /**
102
+ * Escape a symbol for git's `-L :<funcname>:<file>` (a POSIX basic regular expression)
103
+ * @param {string} name
104
+ * @returns {string}
105
+ */
106
+ export function funcnamePattern(name) {
107
+ return name.replace(/[.[\]*^$\\]/g, '\\$&');
108
+ }