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,201 @@
1
+ /**
2
+ * Code units of a source file: methods/functions (from the existing method analyzers) and
3
+ * class-like declarations, each with the line range of its source.
4
+ *
5
+ * Shared by the ranked search (lib/search/bm25.js) and symbol lookup (lib/search/symbols.js).
6
+ */
7
+
8
+ import path from 'path';
9
+ import MethodAnalyzer from '../analyzers/method-analyzer.js';
10
+
11
+ /** Extensions the method analyzers understand; other files have no code units */
12
+ export const CODE_EXTENSIONS = new Set([
13
+ '.js',
14
+ '.jsx',
15
+ '.mjs',
16
+ '.cjs',
17
+ '.ts',
18
+ '.tsx',
19
+ '.mts',
20
+ '.cts',
21
+ '.rs',
22
+ '.go',
23
+ '.java',
24
+ '.cs',
25
+ '.py',
26
+ '.php',
27
+ '.rb',
28
+ '.kt',
29
+ '.kts',
30
+ '.swift',
31
+ '.c',
32
+ '.cpp',
33
+ '.cc',
34
+ '.h',
35
+ '.hpp',
36
+ '.scala',
37
+ ]);
38
+
39
+ const MAX_AVERAGE_LINE_LENGTH = 250;
40
+
41
+ // Modifiers that may precede a class-like keyword in the brace languages
42
+ const MODIFIERS =
43
+ '(?:(?:export|default|declare|public|private|protected|internal|static|abstract|final|sealed|' +
44
+ 'partial|open|data|inner|value|annotation|readonly|override|case|implicit|lazy|unsafe|' +
45
+ 'pub(?:\\([^)]*\\))?|fileprivate)\\s+)*';
46
+
47
+ const CLASS_PATTERNS = {
48
+ brace: new RegExp(
49
+ `^[ \\t]*${MODIFIERS}(class|interface|enum(?:\\s+(?:class|struct))?|struct|record|trait|object|protocol|extension|union)\\s+([A-Za-z_$][\\w$]*)`,
50
+ 'gm'
51
+ ),
52
+ rustImpl:
53
+ /^[ \t]*(?:unsafe\s+)?impl(?:\s*<[^>]*>)?\s+(?:[\w:]+(?:<[^>]*>)?\s+for\s+)?([A-Za-z_]\w*)/gm,
54
+ goType: /^type\s+([A-Za-z_]\w*)\s+(struct|interface)\b/gm,
55
+ python: /^[ \t]*class\s+([A-Za-z_]\w*)/gm,
56
+ ruby: /^[ \t]*(class|module)\s+([A-Z]\w*(?:::\w+)*)/gm,
57
+ };
58
+
59
+ const analyzer = new MethodAnalyzer();
60
+
61
+ /**
62
+ * Function mapping a string index to its 1-based line number
63
+ * @param {string} content
64
+ * @returns {(index: number) => number}
65
+ */
66
+ function lineLocator(content) {
67
+ const breaks = [];
68
+ for (let i = content.indexOf('\n'); i !== -1; i = content.indexOf('\n', i + 1)) breaks.push(i);
69
+ return (index) => {
70
+ // Number of line breaks before `index`
71
+ let low = 0;
72
+ let high = breaks.length;
73
+ while (low < high) {
74
+ const mid = (low + high) >> 1;
75
+ if (breaks[mid] < index) low = mid + 1;
76
+ else high = mid;
77
+ }
78
+ return low + 1;
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Class-like declarations (classes, interfaces, structs, enums, traits, modules, impl blocks)
84
+ * @param {string} content
85
+ * @param {string} ext - Lower-case file extension
86
+ * @returns {Array<{name: string, kind: string, line: number}>}
87
+ */
88
+ function findClassDeclarations(content, ext) {
89
+ const found = [];
90
+ const lineAt = lineLocator(content);
91
+ const collect = (regex, pick) => {
92
+ for (const match of content.matchAll(regex)) {
93
+ const { name, kind } = pick(match);
94
+ found.push({ name, kind, line: lineAt(match.index + match[0].lastIndexOf(name)) });
95
+ }
96
+ };
97
+
98
+ if (ext === '.py') {
99
+ collect(CLASS_PATTERNS.python, (m) => ({ name: m[1], kind: 'class' }));
100
+ } else if (ext === '.rb') {
101
+ collect(CLASS_PATTERNS.ruby, (m) => ({ name: m[2], kind: m[1] }));
102
+ } else if (ext === '.go') {
103
+ collect(CLASS_PATTERNS.goType, (m) => ({ name: m[1], kind: m[2] }));
104
+ } else {
105
+ collect(CLASS_PATTERNS.brace, (m) => ({ name: m[2], kind: m[1].replace(/\s+/g, ' ') }));
106
+ if (ext === '.rs') {
107
+ collect(CLASS_PATTERNS.rustImpl, (m) => ({ name: m[1], kind: 'impl' }));
108
+ }
109
+ }
110
+ return found;
111
+ }
112
+
113
+ /**
114
+ * Methods, functions and class-like declarations of a file with their line ranges.
115
+ *
116
+ * Method ranges come from MethodAnalyzer.extractMethodSources(); class ranges use the same
117
+ * block rules (braces, Python indentation, Ruby `end`). Units are sorted by start line.
118
+ *
119
+ * @param {string} content - File content
120
+ * @param {string} filePath - Used for the language (extension)
121
+ * @returns {Array<{name: string, kind: string, startLine: number, endLine: number,
122
+ * container?: boolean, parent?: string}>} `container` marks class-like declarations;
123
+ * `parent` names the class-like declaration a method lies in
124
+ */
125
+ export function extractCodeUnits(content, filePath) {
126
+ const ext = path.extname(filePath).toLowerCase();
127
+ if (!CODE_EXTENSIONS.has(ext)) return [];
128
+
129
+ const lines = content.split('\n');
130
+ // Minified or generated code: the regex analyzers crawl over very long lines and find
131
+ // nothing useful, so such files are searched by line windows only
132
+ if (content.length / lines.length > MAX_AVERAGE_LINE_LENGTH) return [];
133
+ let methods = [];
134
+ try {
135
+ methods = analyzer.extractMethods(content, filePath);
136
+ } catch (_error) {
137
+ // A regex analyzer failing on odd input leaves the file to the line windows
138
+ }
139
+ const methodSources = analyzer.extractMethodSources(content, methods, filePath);
140
+ const units = methods.map((method, i) => ({
141
+ name: method.name,
142
+ kind: method.type || 'function',
143
+ startLine: method.line,
144
+ endLine: method.line + methodSources[i].split('\n').length - 1,
145
+ }));
146
+
147
+ const classes = findClassDeclarations(content, ext);
148
+ const starts = [...new Set([...units, ...classes].map((u) => u.startLine ?? u.line))].sort(
149
+ (a, b) => a - b
150
+ );
151
+ for (const decl of classes) {
152
+ const start = decl.line - 1;
153
+ let end;
154
+ if (ext === '.py') {
155
+ end = analyzer.findIndentedBlockEnd(lines, start);
156
+ } else if (ext === '.rb') {
157
+ end = analyzer.findRubyBlockEnd(lines, start);
158
+ } else if (ext === '.go') {
159
+ end = analyzer.findBraceBlockEnd(lines, start, lines.length);
160
+ } else {
161
+ // A declaration without a body (`class Foo;`, `case class Foo(x: Int)`) must not run
162
+ // into the body of the next unit
163
+ const next = starts.find((line) => line > decl.line);
164
+ end = analyzer.findBraceBlockEnd(lines, start, next ? next - 1 : lines.length);
165
+ }
166
+ units.push({
167
+ name: decl.name,
168
+ kind: decl.kind,
169
+ startLine: decl.line,
170
+ endLine: end + 1,
171
+ container: true,
172
+ });
173
+ }
174
+
175
+ units.sort((a, b) => a.startLine - b.startLine || b.endLine - a.endLine);
176
+
177
+ // Go methods belong to their receiver type: `func (s *Server) Start()`
178
+ if (ext === '.go') {
179
+ for (const unit of units) {
180
+ const receiver = /^\s*func\s*\(\s*(?:\w+\s+)?\*?\s*([A-Za-z_]\w*)/.exec(
181
+ lines[unit.startLine - 1] ?? ''
182
+ );
183
+ if (receiver && !unit.container) unit.parent = receiver[1];
184
+ }
185
+ }
186
+
187
+ // Functions inside a class-like block are its methods
188
+ const containers = units.filter((unit) => unit.container);
189
+ for (const unit of units) {
190
+ if (unit.container) continue;
191
+ const parent = containers
192
+ .filter((c) => c.startLine < unit.startLine && c.endLine >= unit.endLine)
193
+ .at(-1);
194
+ if (parent) {
195
+ unit.parent = parent.name;
196
+ if (unit.kind === 'function') unit.kind = 'method';
197
+ }
198
+ }
199
+
200
+ return units;
201
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Symbol lookup: the full source of a named method, function or class.
3
+ *
4
+ * Names are matched exactly first, then ignoring case; when neither finds anything, similar
5
+ * names are suggested. `Class.method` (also `Class::method`, `Class#method`) narrows the
6
+ * lookup to methods of that class.
7
+ */
8
+
9
+ import path from 'path';
10
+ import TokenUtils from '../utils/token-utils.js';
11
+ import { redactSecrets } from '../utils/secret-redactor.js';
12
+ import { compareStrings, fitToTokenBudget, getProjectIndex, sliceLines } from './bm25.js';
13
+
14
+ export const DEFAULT_SYMBOL_LIMIT = 10;
15
+ export const DEFAULT_SYMBOL_MAX_TOKENS = 20000;
16
+ const MAX_SUGGESTIONS = 10;
17
+
18
+ /**
19
+ * Name without an accessor prefix: `get value` -> `value`
20
+ * @param {string} name
21
+ * @returns {string}
22
+ */
23
+ function bareName(name) {
24
+ return name.replace(/^(?:get|set)\s+/, '');
25
+ }
26
+
27
+ /**
28
+ * Edit distance between two strings, or `max + 1` once it is certain to exceed `max`
29
+ * @param {string} a
30
+ * @param {string} b
31
+ * @param {number} [max=Infinity]
32
+ * @returns {number}
33
+ */
34
+ export function editDistance(a, b, max = Infinity) {
35
+ if (Math.abs(a.length - b.length) > max) return max + 1;
36
+ let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
37
+ for (let i = 1; i <= a.length; i++) {
38
+ const current = [i];
39
+ let rowMin = i;
40
+ for (let j = 1; j <= b.length; j++) {
41
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
42
+ current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost);
43
+ rowMin = Math.min(rowMin, current[j]);
44
+ }
45
+ if (rowMin > max) return max + 1;
46
+ previous = current;
47
+ }
48
+ return previous[b.length];
49
+ }
50
+
51
+ /**
52
+ * Split a qualified name: `Store.get`, `Store::get` and `Store#get` give
53
+ * `{ parent: 'Store', name: 'get' }`
54
+ * @param {string} query
55
+ * @returns {{parent: string|null, name: string}}
56
+ */
57
+ export function parseSymbolQuery(query) {
58
+ const match = /^(.+?)(?:\.|::|#)([^.:#]+)$/.exec(query);
59
+ return match ? { parent: match[1], name: match[2] } : { parent: null, name: query };
60
+ }
61
+
62
+ /**
63
+ * Similar names for a query that matched nothing
64
+ * @private
65
+ */
66
+ function suggest(candidates, name) {
67
+ const query = name.toLowerCase();
68
+ const threshold = Math.max(2, Math.floor(query.length / 3));
69
+ const best = new Map();
70
+ for (const { entry, unit } of candidates) {
71
+ const candidate = bareName(unit.name);
72
+ const lower = candidate.toLowerCase();
73
+ let distance = editDistance(lower, query, threshold);
74
+ // `user` suggests `getUserName`: containment counts as close
75
+ const shorter = Math.min(lower.length, query.length);
76
+ if (shorter >= 3 && (lower.includes(query) || query.includes(lower))) {
77
+ distance = Math.min(distance, 1);
78
+ }
79
+ if (distance > threshold || best.has(candidate)) continue;
80
+ best.set(candidate, {
81
+ name: unit.name,
82
+ kind: unit.kind,
83
+ ...(unit.parent ? { parent: unit.parent } : {}),
84
+ file: entry.relativePath,
85
+ startLine: unit.startLine,
86
+ distance,
87
+ });
88
+ }
89
+ return [...best.values()]
90
+ .sort(
91
+ (a, b) =>
92
+ a.distance - b.distance ||
93
+ Math.abs(a.name.length - query.length) - Math.abs(b.name.length - query.length) ||
94
+ compareStrings(a.name, b.name)
95
+ )
96
+ .slice(0, MAX_SUGGESTIONS)
97
+ .map(({ distance: _distance, ...rest }) => rest);
98
+ }
99
+
100
+ /**
101
+ * Find the units named by a query
102
+ * @param {Array<{entry: Object, unit: Object}>} candidates - Units with their file entries
103
+ * @param {string} query - `name` or `Parent.name`
104
+ * @returns {{match: 'exact'|'case-insensitive'|'none', found: Array, suggestions: Array}}
105
+ */
106
+ export function lookupSymbols(candidates, query) {
107
+ const { parent, name } = parseSymbolQuery(query);
108
+ const tiers = [
109
+ ['exact', (a, b) => a === b],
110
+ ['case-insensitive', (a, b) => a.toLowerCase() === b.toLowerCase()],
111
+ ];
112
+
113
+ for (const [match, same] of tiers) {
114
+ const named = (unitName, wanted) => same(unitName, wanted) || same(bareName(unitName), wanted);
115
+ const found = candidates.filter(
116
+ ({ unit }) =>
117
+ named(unit.name, query) ||
118
+ (parent !== null && unit.parent && same(unit.parent, parent) && named(unit.name, name))
119
+ );
120
+ if (found.length > 0) return { match, found, suggestions: [] };
121
+ }
122
+ return { match: 'none', found: [], suggestions: suggest(candidates, name) };
123
+ }
124
+
125
+ /**
126
+ * Source of the methods, functions or classes with a given name in a project.
127
+ *
128
+ * @param {string} root - Project directory (files found with the Scanner, so the ignore
129
+ * rules apply)
130
+ * @param {string} query - Symbol name, or `Class.method`
131
+ * @param {Object} [options]
132
+ * @param {string} [options.file] - Only this project-relative file
133
+ * @param {number} [options.limit=10] - Maximum number of symbols returned with source
134
+ * @param {number} [options.maxTokens=20000] - Token budget for the sources; sources past it
135
+ * are cut (`truncated: true`)
136
+ * @param {boolean} [options.redact=true] - Redact secrets in the sources
137
+ * @returns {{query: string, match: string, total: number, symbols: Array,
138
+ * suggestions?: Array}}
139
+ * @throws {Error} When the query is empty or `file` is not a project file
140
+ */
141
+ export function getSymbol(
142
+ root,
143
+ query,
144
+ { file, limit = DEFAULT_SYMBOL_LIMIT, maxTokens = DEFAULT_SYMBOL_MAX_TOKENS, redact = true } = {}
145
+ ) {
146
+ const wanted = typeof query === 'string' ? query.trim() : '';
147
+ if (!wanted) throw new Error('The symbol name is empty');
148
+
149
+ const project = getProjectIndex(root);
150
+ let entries = [...project.files.values()];
151
+ if (file) {
152
+ const target = path.normalize(file);
153
+ entries = entries.filter((entry) => path.normalize(entry.relativePath) === target);
154
+ if (entries.length === 0) throw new Error(`File not found in project: ${file}`);
155
+ }
156
+
157
+ const candidates = entries.flatMap((entry) => entry.units.map((unit) => ({ entry, unit })));
158
+ const { match, found, suggestions } = lookupSymbols(candidates, wanted);
159
+
160
+ let used = 0;
161
+ const symbols = found.slice(0, Math.max(0, limit)).map(({ entry, unit }) => {
162
+ const raw = sliceLines(entry.content, unit.startLine, unit.endLine);
163
+ let source = redact ? redactSecrets(raw).text : raw;
164
+ let tokens = TokenUtils.calculate(source, entry.path);
165
+ const symbol = {
166
+ name: unit.name,
167
+ kind: unit.kind,
168
+ ...(unit.parent ? { parent: unit.parent } : {}),
169
+ file: entry.relativePath,
170
+ startLine: unit.startLine,
171
+ endLine: unit.endLine,
172
+ };
173
+ if (used + tokens > maxTokens) {
174
+ source = fitToTokenBudget(source, maxTokens - used, entry.path);
175
+ tokens = source ? TokenUtils.calculate(source, entry.path) : 0;
176
+ symbol.truncated = true;
177
+ }
178
+ used += tokens;
179
+ return { ...symbol, tokens, source };
180
+ });
181
+
182
+ return {
183
+ query: wanted,
184
+ match,
185
+ total: found.length,
186
+ symbols,
187
+ ...(match === 'none' ? { suggestions } : {}),
188
+ };
189
+ }
@@ -6,104 +6,135 @@ import { Box, Text, useInput, useStdin } from 'ink';
6
6
  * Real-time statistics and progress display
7
7
  */
8
8
  export default function Dashboard({ stats, topFiles = [], status = 'analyzing' }) {
9
- const [shouldRefresh, setShouldRefresh] = useState(false);
10
- const { isRawModeSupported } = useStdin();
9
+ const [shouldRefresh, setShouldRefresh] = useState(false);
10
+ const { isRawModeSupported } = useStdin();
11
11
 
12
- // Keyboard controls
13
- useInput((input, key) => {
14
- if (input === 'r' || input === 'R') {
15
- setShouldRefresh(!shouldRefresh);
16
- }
17
- if (input === 'q' || input === 'Q') {
18
- process.exit(0);
19
- }
20
- }, { isActive: isRawModeSupported });
12
+ // Keyboard controls
13
+ useInput(
14
+ (input, _key) => {
15
+ if (input === 'r' || input === 'R') {
16
+ setShouldRefresh(!shouldRefresh);
17
+ }
18
+ if (input === 'q' || input === 'Q') {
19
+ process.exit(0);
20
+ }
21
+ },
22
+ { isActive: isRawModeSupported }
23
+ );
21
24
 
22
- const {
23
- totalFiles = 0,
24
- totalTokens = 0,
25
- totalBytes = 0,
26
- totalLines = 0,
27
- byExtension = {}
28
- } = stats;
25
+ const {
26
+ totalFiles = 0,
27
+ totalTokens = 0,
28
+ totalBytes = 0,
29
+ totalLines = 0,
30
+ byExtension = {},
31
+ } = stats;
29
32
 
30
- const avgTokensPerFile = totalFiles > 0 ? Math.round(totalTokens / totalFiles) : 0;
31
- const sizeInMB = (totalBytes / (1024 * 1024)).toFixed(2);
33
+ const avgTokensPerFile = totalFiles > 0 ? Math.round(totalTokens / totalFiles) : 0;
34
+ const sizeInMB = (totalBytes / (1024 * 1024)).toFixed(2);
32
35
 
33
- // Get dominant language
34
- const extensions = Object.entries(byExtension);
35
- const dominant = extensions.length > 0
36
- ? extensions.sort((a, b) => b[1].count - a[1].count)[0]
37
- : null;
36
+ // Get dominant language
37
+ const extensions = Object.entries(byExtension);
38
+ const dominant =
39
+ extensions.length > 0 ? extensions.sort((a, b) => b[1].count - a[1].count)[0] : null;
38
40
 
39
- return React.createElement(Box, {
40
- flexDirection: 'column',
41
- padding: 1,
42
- borderStyle: 'round',
43
- borderColor: 'green',
44
- width: 76 // Max width to prevent scrollbar overlap (reduced from 80)
45
- },
46
- React.createElement(Box, { marginBottom: 1 },
47
- React.createElement(Text, { bold: true, color: 'green' }, '📊 Live Analysis Dashboard'),
48
- React.createElement(Text, { color: 'gray' }, ` (${status})`)
49
- ),
41
+ return React.createElement(
42
+ Box,
43
+ {
44
+ flexDirection: 'column',
45
+ padding: 1,
46
+ borderStyle: 'round',
47
+ borderColor: 'green',
48
+ width: 76, // Max width to prevent scrollbar overlap (reduced from 80)
49
+ },
50
+ React.createElement(
51
+ Box,
52
+ { marginBottom: 1 },
53
+ React.createElement(Text, { bold: true, color: 'green' }, '📊 Live Analysis Dashboard'),
54
+ React.createElement(Text, { color: 'gray' }, ` (${status})`)
55
+ ),
50
56
 
51
- // Summary Stats
52
- React.createElement(Box, { flexDirection: 'column', marginBottom: 1 },
53
- React.createElement(Box, null,
54
- React.createElement(Text, null, 'Files: '),
55
- React.createElement(Text, { bold: true, color: 'cyan' }, totalFiles.toLocaleString()),
56
- React.createElement(Text, null, ' Methods: '),
57
- React.createElement(Text, { bold: true, color: 'cyan' }, '-'),
58
- React.createElement(Text, null, ' Tokens: '),
59
- React.createElement(Text, { bold: true, color: 'cyan' }, totalTokens.toLocaleString())
60
- ),
57
+ // Summary Stats
58
+ React.createElement(
59
+ Box,
60
+ { flexDirection: 'column', marginBottom: 1 },
61
+ React.createElement(
62
+ Box,
63
+ null,
64
+ React.createElement(Text, null, 'Files: '),
65
+ React.createElement(Text, { bold: true, color: 'cyan' }, totalFiles.toLocaleString()),
66
+ React.createElement(Text, null, ' Methods: '),
67
+ React.createElement(Text, { bold: true, color: 'cyan' }, '-'),
68
+ React.createElement(Text, null, ' Tokens: '),
69
+ React.createElement(Text, { bold: true, color: 'cyan' }, totalTokens.toLocaleString())
70
+ ),
61
71
 
62
- React.createElement(Box, null,
63
- React.createElement(Text, null, 'Size: '),
64
- React.createElement(Text, { bold: true, color: 'cyan' }, `${sizeInMB} MB`),
65
- React.createElement(Text, null, ' Lines: '),
66
- React.createElement(Text, { bold: true, color: 'cyan' }, totalLines.toLocaleString()),
67
- React.createElement(Text, null, ' Avg: '),
68
- React.createElement(Text, { bold: true, color: 'cyan' }, `${avgTokensPerFile.toLocaleString()} tok/file`)
69
- )
70
- ),
72
+ React.createElement(
73
+ Box,
74
+ null,
75
+ React.createElement(Text, null, 'Size: '),
76
+ React.createElement(Text, { bold: true, color: 'cyan' }, `${sizeInMB} MB`),
77
+ React.createElement(Text, null, ' Lines: '),
78
+ React.createElement(Text, { bold: true, color: 'cyan' }, totalLines.toLocaleString()),
79
+ React.createElement(Text, null, ' Avg: '),
80
+ React.createElement(
81
+ Text,
82
+ { bold: true, color: 'cyan' },
83
+ `${avgTokensPerFile.toLocaleString()} tok/file`
84
+ )
85
+ )
86
+ ),
71
87
 
72
- // Top Languages
73
- dominant && React.createElement(Box, { flexDirection: 'column', marginBottom: 1 },
74
- React.createElement(Text, { bold: true }, 'Top Languages:'),
75
- React.createElement(Box, { marginLeft: 2 },
76
- React.createElement(Text, { color: 'green' }, '▓'.repeat(30)),
77
- React.createElement(Text, null, ` ${dominant[0]} (${dominant[1].count} files)`)
78
- )
79
- ),
88
+ // Top Languages
89
+ dominant &&
90
+ React.createElement(
91
+ Box,
92
+ { flexDirection: 'column', marginBottom: 1 },
93
+ React.createElement(Text, { bold: true }, 'Top Languages:'),
94
+ React.createElement(
95
+ Box,
96
+ { marginLeft: 2 },
97
+ React.createElement(Text, { color: 'green' }, '▓'.repeat(30)),
98
+ React.createElement(Text, null, ` ${dominant[0]} (${dominant[1].count} files)`)
99
+ )
100
+ ),
80
101
 
81
- // Largest Files
82
- topFiles.length > 0 && React.createElement(Box, { flexDirection: 'column', marginBottom: 1 },
83
- React.createElement(Text, { bold: true }, 'Largest Files:'),
84
- ...topFiles.slice(0, 3).map((file, index) => {
85
- const barLength = Math.round((file.tokens / topFiles[0].tokens) * 20);
86
- const percentage = ((file.tokens / totalTokens) * 100).toFixed(1);
87
- const fileName = (file.name || file.relativePath || file.path || 'unknown').substring(0, 30);
102
+ // Largest Files
103
+ topFiles.length > 0 &&
104
+ React.createElement(
105
+ Box,
106
+ { flexDirection: 'column', marginBottom: 1 },
107
+ React.createElement(Text, { bold: true }, 'Largest Files:'),
108
+ ...topFiles.slice(0, 3).map((file, index) => {
109
+ const barLength = Math.round((file.tokens / topFiles[0].tokens) * 20);
110
+ const percentage = ((file.tokens / totalTokens) * 100).toFixed(1);
111
+ const fileName = (file.name || file.relativePath || file.path || 'unknown').substring(
112
+ 0,
113
+ 30
114
+ );
88
115
 
89
- return React.createElement(Box, { key: index, marginLeft: 2 },
90
- React.createElement(Text, null, '• '),
91
- React.createElement(Text, { color: 'cyan' }, fileName),
92
- React.createElement(Text, null, ` ${file.tokens.toLocaleString()} tokens `),
93
- React.createElement(Text, { color: 'green' }, '▓'.repeat(barLength)),
94
- React.createElement(Text, { color: 'gray' }, ` (${percentage}%)`)
95
- );
96
- })
97
- ),
116
+ return React.createElement(
117
+ Box,
118
+ { key: index, marginLeft: 2 },
119
+ React.createElement(Text, null, '• '),
120
+ React.createElement(Text, { color: 'cyan' }, fileName),
121
+ React.createElement(Text, null, ` ${file.tokens.toLocaleString()} tokens `),
122
+ React.createElement(Text, { color: 'green' }, '▓'.repeat(barLength)),
123
+ React.createElement(Text, { color: 'gray' }, ` (${percentage}%)`)
124
+ );
125
+ })
126
+ ),
98
127
 
99
- // Controls
100
- React.createElement(Box, {
101
- marginTop: 1,
102
- borderStyle: 'single',
103
- borderColor: 'gray',
104
- padding: 1
105
- },
106
- React.createElement(Text, { dimColor: true }, '[R] Refresh [S] Save [E] Export [Q] Quit')
107
- )
108
- );
128
+ // Controls
129
+ React.createElement(
130
+ Box,
131
+ {
132
+ marginTop: 1,
133
+ borderStyle: 'single',
134
+ borderColor: 'gray',
135
+ padding: 1,
136
+ },
137
+ React.createElement(Text, { dimColor: true }, '[R] Refresh [S] Save [E] Export [Q] Quit')
138
+ )
139
+ );
109
140
  }
package/lib/ui/index.js CHANGED
@@ -7,9 +7,4 @@ import { ProgressBar, SpinnerWithText } from './progress-bar.js';
7
7
  import Wizard from './wizard.js';
8
8
  import Dashboard from './dashboard.js';
9
9
 
10
- export {
11
- ProgressBar,
12
- SpinnerWithText,
13
- Wizard,
14
- Dashboard
15
- };
10
+ export { ProgressBar, SpinnerWithText, Wizard, Dashboard };