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
@@ -24,8 +24,10 @@ export class PineconeStore extends BaseVectorStore {
24
24
  this.client = new Pinecone({ apiKey: this.apiKey });
25
25
  this.index = this.client.index(this.indexName);
26
26
  } catch (error) {
27
- if (error.code === 'ERR_MODULE_NOT_FOUND') {
28
- throw new Error('Please install @pinecone-database/pinecone to use PineconeStore: npm install @pinecone-database/pinecone');
27
+ if (error.code === 'ERR_MODULE_NOT_FOUND') {
28
+ throw new Error(
29
+ 'Please install @pinecone-database/pinecone to use PineconeStore: npm install @pinecone-database/pinecone'
30
+ );
29
31
  }
30
32
  throw error;
31
33
  }
@@ -42,16 +44,16 @@ export class PineconeStore extends BaseVectorStore {
42
44
  id: doc.id || crypto.randomUUID(),
43
45
  values,
44
46
  metadata: {
45
- ...doc.metadata,
46
- text: doc.text // Pinecone metadata is limited, but storing text is useful
47
- }
47
+ ...doc.metadata,
48
+ text: doc.text, // Pinecone metadata is limited, but storing text is useful
49
+ },
48
50
  });
49
51
  }
50
52
 
51
53
  // Pinecone likes batches
52
54
  const batchSize = 100;
53
55
  for (let i = 0; i < vectors.length; i += batchSize) {
54
- await this.index.upsert(vectors.slice(i, i + batchSize));
56
+ await this.index.upsert(vectors.slice(i, i + batchSize));
55
57
  }
56
58
  }
57
59
 
@@ -62,13 +64,13 @@ export class PineconeStore extends BaseVectorStore {
62
64
  const results = await this.index.query({
63
65
  vector,
64
66
  topK: limit,
65
- includeMetadata: true
67
+ includeMetadata: true,
66
68
  });
67
69
 
68
- return results.matches.map(match => ({
70
+ return results.matches.map((match) => ({
69
71
  text: match.metadata.text || '', // Assuming text is stored in metadata
70
72
  score: match.score,
71
- metadata: match.metadata
73
+ metadata: match.metadata,
72
74
  }));
73
75
  }
74
76
  }
@@ -0,0 +1,433 @@
1
+ /**
2
+ * Ranked code search: BM25 over code chunks.
3
+ *
4
+ * A chunk is a method/function found by the method analyzers or, for code outside them and
5
+ * for files without methods, a window of up to 40 lines. Identifiers are split into their
6
+ * words (camelCase, snake_case, kebab-case) and lowercased, so `getUserName`, `get_user_name`
7
+ * and "user name" find each other. Pure JavaScript, no dependencies.
8
+ *
9
+ * The per-project index lives in memory and is rebuilt only for files whose modification
10
+ * time or size changed since the last search.
11
+ */
12
+
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+ import { Scanner } from '../core/Scanner.js';
16
+ import TokenUtils from '../utils/token-utils.js';
17
+ import { redactSecrets } from '../utils/secret-redactor.js';
18
+ import { readFileChecked } from '../utils/safe-read.js';
19
+ import { extractCodeUnits } from './code-units.js';
20
+
21
+ export const DEFAULT_LIMIT = 10;
22
+ export const DEFAULT_MAX_TOKENS = 4000;
23
+ export const WINDOW_LINES = 40;
24
+ /** Files above this size (minified bundles, generated data) are not indexed */
25
+ export const MAX_FILE_BYTES = 1024 * 1024;
26
+ const MAX_CACHED_PROJECTS = 8;
27
+
28
+ // A word: letters, digits, `_` and `$`, optionally joined by `-` (kebab-case)
29
+ const WORD = /[\p{L}\p{N}_$]+(?:-[\p{L}\p{N}_$]+)*/gu;
30
+ // Parts of a camelCase word: `HTTPServer2Go` -> HTTP, Server, 2, Go
31
+ const PART = /\p{Lu}+(?!\p{Ll})|\p{Lu}?[\p{Ll}\p{Lo}\p{Lm}]+|\p{N}+/gu;
32
+
33
+ /**
34
+ * Fold an English plural to its singular, conservatively: `users` -> `user`,
35
+ * `classes` -> `class`; `status`, `class` and `analysis` stay as they are
36
+ * @param {string} term - Lower-case term
37
+ * @returns {string}
38
+ */
39
+ export function normalizeTerm(term) {
40
+ if (term.length <= 3 || !term.endsWith('s')) return term;
41
+ if (/(?:ss|us|is)$/.test(term)) return term;
42
+ if (/(?:sses|shes|ches|xes|zes)$/.test(term)) return term.slice(0, -2);
43
+ if (term.endsWith('ies') && term.length > 4) return `${term.slice(0, -3)}y`;
44
+ return term.slice(0, -1);
45
+ }
46
+
47
+ /**
48
+ * Split text into search terms.
49
+ *
50
+ * Each identifier yields its lower-cased parts (`parseHTTPResponse` -> parse, http,
51
+ * response) plus, when it has several parts, the joined form (`parsehttpresponse`), so an
52
+ * exact identifier query ranks the identifier above code that merely uses the same words.
53
+ * Single characters are dropped.
54
+ *
55
+ * @param {string} text
56
+ * @returns {string[]}
57
+ */
58
+ export function tokenize(text) {
59
+ const terms = [];
60
+ for (const [word] of String(text).matchAll(WORD)) {
61
+ const parts = [];
62
+ for (const piece of word.split(/[-_$]+/)) {
63
+ for (const [part] of piece.matchAll(PART)) parts.push(part.toLowerCase());
64
+ }
65
+ for (const part of parts) {
66
+ if (part.length > 1) terms.push(normalizeTerm(part));
67
+ }
68
+ if (parts.length > 1) {
69
+ terms.push(normalizeTerm(parts.join('')));
70
+ }
71
+ }
72
+ return terms;
73
+ }
74
+
75
+ /**
76
+ * Okapi BM25 index over documents given as term lists.
77
+ *
78
+ * IDF is the non-negative variant ln(1 + (N - n + 0.5) / (n + 0.5)). Results with equal
79
+ * scores keep the order in which their documents were added.
80
+ */
81
+ export class BM25Index {
82
+ /**
83
+ * @param {Object} [options]
84
+ * @param {number} [options.k1=1.2] - Term frequency saturation
85
+ * @param {number} [options.b=0.75] - Length normalisation
86
+ */
87
+ constructor({ k1 = 1.2, b = 0.75 } = {}) {
88
+ this.k1 = k1;
89
+ this.b = b;
90
+ this.docs = [];
91
+ this.lengths = [];
92
+ this.postings = new Map();
93
+ this.totalLength = 0;
94
+ }
95
+
96
+ /** Number of documents */
97
+ get size() {
98
+ return this.docs.length;
99
+ }
100
+
101
+ /**
102
+ * Add a document
103
+ * @param {*} doc - Returned with the search results
104
+ * @param {string[]} terms - Terms of the document (see tokenize())
105
+ * @returns {number} Document id
106
+ */
107
+ add(doc, terms) {
108
+ const id = this.docs.length;
109
+ const frequencies = new Map();
110
+ for (const term of terms) frequencies.set(term, (frequencies.get(term) || 0) + 1);
111
+ for (const [term, tf] of frequencies) {
112
+ let list = this.postings.get(term);
113
+ if (!list) {
114
+ list = [];
115
+ this.postings.set(term, list);
116
+ }
117
+ list.push(id, tf);
118
+ }
119
+ this.docs.push(doc);
120
+ this.lengths.push(terms.length);
121
+ this.totalLength += terms.length;
122
+ return id;
123
+ }
124
+
125
+ /**
126
+ * Inverse document frequency of a term
127
+ * @param {string} term
128
+ * @returns {number}
129
+ */
130
+ idf(term) {
131
+ const n = (this.postings.get(term)?.length ?? 0) / 2;
132
+ return Math.log(1 + (this.docs.length - n + 0.5) / (n + 0.5));
133
+ }
134
+
135
+ /**
136
+ * Rank documents for a query
137
+ * @param {string|string[]} query - Query text, or terms
138
+ * @param {Object} [options]
139
+ * @param {number} [options.limit=10] - Maximum number of results
140
+ * @returns {Array<{doc: *, id: number, score: number}>} Best first
141
+ */
142
+ search(query, { limit = DEFAULT_LIMIT } = {}) {
143
+ const terms = [...new Set(Array.isArray(query) ? query : tokenize(query))];
144
+ if (this.docs.length === 0 || terms.length === 0) return [];
145
+
146
+ const avgLength = this.totalLength / this.docs.length || 1;
147
+ const scores = new Map();
148
+ for (const term of terms) {
149
+ const list = this.postings.get(term);
150
+ if (!list) continue;
151
+ const idf = this.idf(term);
152
+ for (let i = 0; i < list.length; i += 2) {
153
+ const id = list[i];
154
+ const tf = list[i + 1];
155
+ const norm = this.k1 * (1 - this.b + (this.b * this.lengths[id]) / avgLength);
156
+ scores.set(id, (scores.get(id) || 0) + (idf * tf * (this.k1 + 1)) / (tf + norm));
157
+ }
158
+ }
159
+
160
+ return [...scores]
161
+ .sort((a, b) => b[1] - a[1] || a[0] - b[0])
162
+ .slice(0, Math.max(0, limit))
163
+ .map(([id, score]) => ({ doc: this.docs[id], id, score }));
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Split a file into search chunks: its methods/functions, plus windows of at most
169
+ * `windowLines` lines over the code outside them (the whole file when it has none).
170
+ * Windows without a single term are dropped.
171
+ *
172
+ * @param {string} content - File content
173
+ * @param {string} filePath - Path used for the language (extension)
174
+ * @param {Object} [options]
175
+ * @param {number} [options.windowLines=40]
176
+ * @param {Array} [options.units] - Code units, when already extracted
177
+ * @returns {Array<{name: string|null, kind: string, parent?: string, startLine: number,
178
+ * endLine: number}>} Sorted by start line
179
+ */
180
+ export function chunkFile(content, filePath, { windowLines = WINDOW_LINES, units } = {}) {
181
+ const lines = content.split('\n');
182
+ // A trailing newline does not start another line
183
+ const lineCount = lines.length > 1 && lines.at(-1) === '' ? lines.length - 1 : lines.length;
184
+ const methods = (units ?? extractCodeUnits(content, filePath)).filter((u) => !u.container);
185
+
186
+ const chunks = methods.map((unit) => ({
187
+ name: unit.name,
188
+ kind: unit.kind,
189
+ ...(unit.parent ? { parent: unit.parent } : {}),
190
+ startLine: unit.startLine,
191
+ endLine: Math.min(unit.endLine, lineCount),
192
+ }));
193
+
194
+ const covered = new Uint8Array(lineCount + 2);
195
+ for (const chunk of chunks) covered.fill(1, chunk.startLine, chunk.endLine + 1);
196
+
197
+ let line = 1;
198
+ while (line <= lineCount) {
199
+ // Windows start and end on a non-blank line
200
+ if (covered[line] || !lines[line - 1].trim()) {
201
+ line++;
202
+ continue;
203
+ }
204
+ const start = line;
205
+ while (line <= lineCount && !covered[line] && line - start < windowLines) line++;
206
+ let end = line - 1;
207
+ while (end > start && !lines[end - 1].trim()) end--;
208
+ // Same test as tokenize() yielding a term: a word of two or more characters
209
+ if (/[\p{L}\p{N}]{2}/u.test(lines.slice(start - 1, end).join('\n'))) {
210
+ chunks.push({ name: null, kind: 'window', startLine: start, endLine: end });
211
+ }
212
+ }
213
+
214
+ return chunks.sort((a, b) => a.startLine - b.startLine || a.endLine - b.endLine);
215
+ }
216
+
217
+ /**
218
+ * Source text of a line range (1-based, inclusive)
219
+ * @param {string} content
220
+ * @param {number} startLine
221
+ * @param {number} endLine
222
+ * @returns {string}
223
+ */
224
+ export function sliceLines(content, startLine, endLine) {
225
+ return content
226
+ .split('\n')
227
+ .slice(startLine - 1, endLine)
228
+ .join('\n');
229
+ }
230
+
231
+ /**
232
+ * Code-point order, independent of the locale
233
+ * @param {string} a
234
+ * @param {string} b
235
+ * @returns {number}
236
+ */
237
+ export function compareStrings(a, b) {
238
+ if (a < b) return -1;
239
+ return a > b ? 1 : 0;
240
+ }
241
+
242
+ // root + options -> { signature, files: Map<relativePath, entry>, index }
243
+ const projectCache = new Map();
244
+
245
+ /** Forget every cached project index (tests, long-running servers after big changes) */
246
+ export function clearSearchCache() {
247
+ projectCache.clear();
248
+ }
249
+
250
+ /**
251
+ * Load a file entry: content, code units and chunks, reused while mtime and size match
252
+ * @private
253
+ */
254
+ function loadFile(file, previous, windowLines) {
255
+ let stat;
256
+ try {
257
+ stat = fs.statSync(file.path);
258
+ } catch (_error) {
259
+ return null;
260
+ }
261
+ const signature = `${stat.mtimeMs}:${stat.size}`;
262
+ if (previous && previous.signature === signature) return previous;
263
+
264
+ const entry = {
265
+ path: file.path,
266
+ relativePath: file.relativePath,
267
+ signature,
268
+ content: '',
269
+ units: [],
270
+ chunks: [],
271
+ };
272
+ const read = readFileChecked(file.path, { maxBytes: MAX_FILE_BYTES });
273
+ if (read.status === 'too-large') return { ...entry, skipped: 'too large' };
274
+ if (read.status !== 'ok') return null;
275
+ entry.content = read.buffer.toString('utf-8');
276
+ entry.units = extractCodeUnits(entry.content, file.path);
277
+ entry.chunks = chunkFile(entry.content, file.path, { windowLines, units: entry.units });
278
+ return entry;
279
+ }
280
+
281
+ /**
282
+ * The in-memory index of a project, built on first use and refreshed when files are added,
283
+ * removed or changed (by modification time and size). Files are found with the Scanner, so
284
+ * .gitignore, .contextignore and .contextinclude apply.
285
+ *
286
+ * @param {string} root - Project directory
287
+ * @param {Object} [options]
288
+ * @param {number} [options.windowLines=40]
289
+ * @returns {{root: string, files: Map<string, Object>, index: BM25Index, signature: string,
290
+ * reused: boolean}}
291
+ * @throws {Error} When root is not a directory
292
+ */
293
+ export function getProjectIndex(root, { windowLines = WINDOW_LINES } = {}) {
294
+ const resolvedRoot = path.resolve(root);
295
+ if (!fs.statSync(resolvedRoot, { throwIfNoEntry: false })?.isDirectory()) {
296
+ throw new Error(`Not a directory: ${root}`);
297
+ }
298
+ const key = `${resolvedRoot}\0${windowLines}`;
299
+ const cached = projectCache.get(key);
300
+
301
+ const scanned = new Scanner(resolvedRoot)
302
+ .scan()
303
+ .sort((a, b) => compareStrings(a.relativePath, b.relativePath));
304
+
305
+ const files = new Map();
306
+ const signatureParts = [];
307
+ for (const file of scanned) {
308
+ const entry = loadFile(file, cached?.files.get(file.relativePath), windowLines);
309
+ if (!entry) continue;
310
+ files.set(file.relativePath, entry);
311
+ signatureParts.push(`${file.relativePath}\0${entry.signature}`);
312
+ }
313
+ const signature = signatureParts.join('\n');
314
+
315
+ // Most recently used last, so the first key is the one to evict
316
+ projectCache.delete(key);
317
+ if (cached && cached.signature === signature) {
318
+ projectCache.set(key, cached);
319
+ return { ...cached, reused: true };
320
+ }
321
+
322
+ const index = new BM25Index();
323
+ for (const entry of files.values()) {
324
+ const pathTerms = tokenize(entry.relativePath);
325
+ const lines = entry.content.split('\n');
326
+ for (const chunk of entry.chunks) {
327
+ // Index the redacted text, so the ranking cannot be used to probe secret values
328
+ const text = redactSecrets(lines.slice(chunk.startLine - 1, chunk.endLine).join('\n')).text;
329
+ const nameTerms = chunk.name ? tokenize(chunk.name) : [];
330
+ // The name counts three times and the file path once on top of the text
331
+ index.add({ entry, chunk }, [
332
+ ...tokenize(text),
333
+ ...nameTerms,
334
+ ...nameTerms,
335
+ ...nameTerms,
336
+ ...pathTerms,
337
+ ]);
338
+ }
339
+ }
340
+
341
+ const project = { root: resolvedRoot, files, index, signature };
342
+ projectCache.set(key, project);
343
+ while (projectCache.size > MAX_CACHED_PROJECTS) {
344
+ projectCache.delete(projectCache.keys().next().value);
345
+ }
346
+ return { ...project, reused: false };
347
+ }
348
+
349
+ /**
350
+ * Cut text to its first lines that fit in a token budget
351
+ * @param {string} text
352
+ * @param {number} budget - Tokens
353
+ * @param {string} filePath - For the token estimate when tiktoken is missing
354
+ * @returns {string} The fitting prefix ('' when not even one line fits)
355
+ */
356
+ export function fitToTokenBudget(text, budget, filePath) {
357
+ const lines = text.split('\n');
358
+ let low = 0;
359
+ let high = lines.length;
360
+ // Largest line count whose text fits
361
+ while (low < high) {
362
+ const mid = (low + high + 1) >> 1;
363
+ if (TokenUtils.calculate(lines.slice(0, mid).join('\n'), filePath) <= budget) low = mid;
364
+ else high = mid - 1;
365
+ }
366
+ return lines.slice(0, low).join('\n');
367
+ }
368
+
369
+ /**
370
+ * Ranked search over a project.
371
+ *
372
+ * @param {string} root - Project directory
373
+ * @param {string} query - Words or identifiers to look for
374
+ * @param {Object} [options]
375
+ * @param {number} [options.limit=10] - Maximum number of chunks
376
+ * @param {number} [options.maxTokens=4000] - Token budget for the chunk texts; chunks past
377
+ * it are cut (`truncated: true`) or listed without text
378
+ * @param {boolean} [options.redact=true] - Redact secrets in the returned text
379
+ * @param {number} [options.windowLines=40] - Window size for code outside methods
380
+ * @returns {{query: string, root: string, files: number, chunks: number, tokens: number,
381
+ * results: Array<{file: string, name: string|null, kind: string, parent?: string,
382
+ * startLine: number, endLine: number, score: number, tokens: number, text: string|null,
383
+ * truncated?: boolean}>}}
384
+ */
385
+ export function searchProject(
386
+ root,
387
+ query,
388
+ {
389
+ limit = DEFAULT_LIMIT,
390
+ maxTokens = DEFAULT_MAX_TOKENS,
391
+ redact = true,
392
+ windowLines = WINDOW_LINES,
393
+ } = {}
394
+ ) {
395
+ if (typeof query !== 'string' || !query.trim()) {
396
+ throw new Error('The search query is empty');
397
+ }
398
+ const project = getProjectIndex(root, { windowLines });
399
+ const hits = project.index.search(query, { limit });
400
+
401
+ let used = 0;
402
+ const results = hits.map(({ doc: { entry, chunk }, score }) => {
403
+ const raw = sliceLines(entry.content, chunk.startLine, chunk.endLine);
404
+ let text = redact ? redactSecrets(raw).text : raw;
405
+ let tokens = TokenUtils.calculate(text, entry.path);
406
+ const result = {
407
+ file: entry.relativePath,
408
+ name: chunk.name,
409
+ kind: chunk.kind,
410
+ ...(chunk.parent ? { parent: chunk.parent } : {}),
411
+ startLine: chunk.startLine,
412
+ endLine: chunk.endLine,
413
+ score: Math.round(score * 10000) / 10000,
414
+ };
415
+
416
+ if (used + tokens > maxTokens) {
417
+ text = fitToTokenBudget(text, maxTokens - used, entry.path);
418
+ tokens = text ? TokenUtils.calculate(text, entry.path) : 0;
419
+ result.truncated = true;
420
+ }
421
+ used += tokens;
422
+ return { ...result, tokens, text: text || null };
423
+ });
424
+
425
+ return {
426
+ query,
427
+ root: project.root,
428
+ files: project.files.size,
429
+ chunks: project.index.size,
430
+ tokens: used,
431
+ results,
432
+ };
433
+ }
@@ -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
+ }