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
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Targets of `ctxman why`: `file`, `file:symbol`, `file#L10-L40` (or `file:10-40`),
3
+ * symbol resolution to a line range with the method analyzers, and mapping a
4
+ * working-tree range onto HEAD through the diff hunks between them
5
+ */
6
+
7
+ import path from 'path';
8
+ import MethodAnalyzer from '../analyzers/method-analyzer.js';
9
+ import { WhyError } from './errors.js';
10
+
11
+ const RANGE_SUFFIX = /^(.*?)#L(\d+)(?:-L?(\d+))?$/i;
12
+ const LINE_SUFFIX = /^(\d+)(?:-(\d+))?$/;
13
+ const SYMBOL_PATTERN = /^~?[A-Za-z_$][\w$]*(?:(?:\.|::|#)~?[A-Za-z_$][\w$]*)*[?!]?$/;
14
+ const MEMBER_SEPARATOR = /\.|::|#/;
15
+
16
+ // class Foo, interface Foo, struct Foo, impl<T> Trait for Foo, type Foo struct, module Foo, ...
17
+ const CONTAINER_LINE =
18
+ /^\s*(?:template\s*<[^>]*>\s*)?(?:(?:export|default|public|private|protected|internal|abstract|final|sealed|open|data|static|partial|declare|unsafe|case|inline|value|annotation|pub(?:\([^)]*\))?|enum(?=\s+class))\s+)*(class|interface|enum|struct|record|trait|object|protocol|extension|module|namespace|impl|union|type)(?:\s*<[^>]*>)?\s+(?:[\w$:<>,\s]+?\s+for\s+)?([A-Za-z_$][\w$]*)/;
19
+ const NOT_A_NAME = new Set(['extends', 'implements', 'for', 'where', 'of', 'is']);
20
+
21
+ // Comment-only lines, for the doc comments and decorators kept with a definition
22
+ const HASH_COMMENT_EXTENSIONS = new Set(['.py', '.rb', '.sh', '.bash', '.zsh', '.pl', '.r']);
23
+
24
+ let sharedAnalyzer = null;
25
+ const analyzer = () => (sharedAnalyzer ??= new MethodAnalyzer());
26
+
27
+ /**
28
+ * Split a target string into file and symbol or line range. `exists` decides between
29
+ * readings when a path itself contains ':'
30
+ * @param {string} spec
31
+ * @param {(file: string) => boolean} exists
32
+ * @returns {{file: string, symbol?: string, startLine?: number, endLine?: number}}
33
+ */
34
+ export function parseTargetSpec(spec, exists = () => false) {
35
+ if (typeof spec !== 'string' || spec.trim() === '') {
36
+ throw new WhyError(
37
+ 'INVALID_TARGET',
38
+ 'A target is required: <file>, <file>:<symbol> or <file>#L10-L40'
39
+ );
40
+ }
41
+ const target = spec.trim();
42
+
43
+ const range = target.match(RANGE_SUFFIX);
44
+ if (range && range[1]) {
45
+ return lineTarget(range[1], range[2], range[3] ?? range[2]);
46
+ }
47
+ if (exists(target)) return { file: target };
48
+
49
+ const readings = [];
50
+ for (let i = target.indexOf(':'); i !== -1; i = target.indexOf(':', i + 1)) {
51
+ // C:\path on Windows: a drive letter, not a separator
52
+ if (i === 1 && /^[A-Za-z]$/.test(target[0])) continue;
53
+ const file = target.slice(0, i);
54
+ const rest = target.slice(i + 1);
55
+ const lines = rest.match(LINE_SUFFIX);
56
+ if (lines) readings.push(lineTarget(file, lines[1], lines[2] ?? lines[1]));
57
+ else if (SYMBOL_PATTERN.test(rest)) readings.push({ file, symbol: rest });
58
+ }
59
+ return readings.find((reading) => exists(reading.file)) ?? readings[0] ?? { file: target };
60
+ }
61
+
62
+ function lineTarget(file, start, end) {
63
+ return { file, startLine: Number.parseInt(start, 10), endLine: Number.parseInt(end, 10) };
64
+ }
65
+
66
+ /**
67
+ * Check a symbol name before it reaches a regular expression or git
68
+ * @param {string} symbol
69
+ * @returns {boolean}
70
+ */
71
+ export function isValidSymbol(symbol) {
72
+ return typeof symbol === 'string' && symbol.length <= 200 && SYMBOL_PATTERN.test(symbol);
73
+ }
74
+
75
+ /**
76
+ * Functions, methods and type-like containers (class, struct, impl, ...) in a file, with
77
+ * their line ranges (1-based, inclusive). A range starts at the decorators and doc
78
+ * comment directly above the declaration
79
+ * @param {string} content
80
+ * @param {string} filePath - Picks the language rules
81
+ * @returns {Array<{name: string, kind: string, line: number, startLine: number, endLine: number, container: boolean}>}
82
+ */
83
+ export function listDefinitions(content, filePath) {
84
+ const methodAnalyzer = analyzer();
85
+ const lines = content.split('\n');
86
+ const ext = path.extname(filePath).toLowerCase();
87
+
88
+ const methods = methodAnalyzer
89
+ .extractMethods(content, filePath)
90
+ .filter((method) => method.line >= 1 && method.line <= lines.length);
91
+ const sources = methodAnalyzer.extractMethodSources(content, methods, filePath);
92
+
93
+ const containers = [];
94
+ lines.forEach((line, index) => {
95
+ const match = line.match(CONTAINER_LINE);
96
+ if (match && !NOT_A_NAME.has(match[2])) {
97
+ containers.push({ name: match[2], kind: match[1], line: index + 1 });
98
+ }
99
+ });
100
+
101
+ const declarationLines = [...new Set([...methods, ...containers].map((d) => d.line))].sort(
102
+ (a, b) => a - b
103
+ );
104
+
105
+ const definitions = methods.map((method, index) => ({
106
+ name: method.name.replace(/^(get|set) /, ''),
107
+ kind:
108
+ method.name.startsWith('get ') || method.name.startsWith('set ') ? 'accessor' : 'function',
109
+ line: method.line,
110
+ endLine: method.line + sources[index].split('\n').length - 1,
111
+ container: false,
112
+ }));
113
+
114
+ for (const container of containers) {
115
+ const next = declarationLines.find((line) => line > container.line);
116
+ const end = blockEnd(lines, container.line - 1, ext, next ? next - 1 : lines.length);
117
+ definitions.push({ ...container, endLine: end + 1, container: true });
118
+ }
119
+
120
+ return definitions
121
+ .map((definition) => ({
122
+ ...definition,
123
+ startLine: leadingStart(lines, definition.line - 1, ext) + 1,
124
+ }))
125
+ .sort((a, b) => a.line - b.line || a.name.localeCompare(b.name));
126
+ }
127
+
128
+ /**
129
+ * Last line index (0-based) of the block opened at `start`
130
+ */
131
+ function blockEnd(lines, start, ext, limit) {
132
+ const methodAnalyzer = analyzer();
133
+ if (ext === '.py') return methodAnalyzer.findIndentedBlockEnd(lines, start);
134
+ if (ext === '.rb') return methodAnalyzer.findRubyBlockEnd(lines, start);
135
+ return methodAnalyzer.findBraceBlockEnd(lines, start, limit);
136
+ }
137
+
138
+ /**
139
+ * Index of the first line of the decorators / attributes / doc comment directly above
140
+ * line `index` (no blank line in between)
141
+ */
142
+ function leadingStart(lines, index, ext) {
143
+ const hashComments = HASH_COMMENT_EXTENSIONS.has(ext);
144
+ let first = index;
145
+ for (let i = index - 1; i >= 0 && index - i <= 40; i--) {
146
+ const text = lines[i].trim();
147
+ const isLeading =
148
+ text.startsWith('@') ||
149
+ text.startsWith('#[') ||
150
+ /^\[[A-Z][\w.]*(\(.*\))?\]$/.test(text) ||
151
+ /^(\/\/|\/\*|\*)/.test(text) ||
152
+ (hashComments && text.startsWith('#') && !text.startsWith('#!'));
153
+ if (!text || !isLeading) break;
154
+ first = i;
155
+ }
156
+ return first;
157
+ }
158
+
159
+ /**
160
+ * Resolve a symbol (`name`, `Class.method`, `Type::method`, `Class#method`) to a line range
161
+ * @param {string} content
162
+ * @param {string} filePath
163
+ * @param {string} symbol
164
+ * @returns {{name: string, kind: string, line: number, startLine: number, endLine: number,
165
+ * resolvedBy: string, matches: number}|null} null when nothing matches
166
+ */
167
+ export function resolveSymbol(content, filePath, symbol) {
168
+ const definitions = listDefinitions(content, filePath);
169
+ const lines = content.split('\n');
170
+ const ext = path.extname(filePath).toLowerCase();
171
+ const parts = symbol.split(MEMBER_SEPARATOR);
172
+ const member = parts[parts.length - 1];
173
+
174
+ let matches;
175
+ if (parts.length === 1) {
176
+ matches = definitions.filter((definition) => definition.name === member);
177
+ } else {
178
+ const containerName = parts[parts.length - 2];
179
+ const scopes = definitions.filter((d) => d.name === containerName && d.container);
180
+ const fallbackScope = scopes.length ? null : fallbackDefinition(lines, containerName, ext);
181
+ if (fallbackScope) scopes.push(fallbackScope);
182
+ matches = definitions.filter(
183
+ (d) =>
184
+ d.name === member && scopes.some((scope) => d.line > scope.line && d.line <= scope.endLine)
185
+ );
186
+ if (!matches.length) {
187
+ const qualified = qualifiedDefinition(lines, containerName, member, ext);
188
+ if (qualified) matches = [qualified];
189
+ }
190
+ }
191
+
192
+ if (!matches.length && parts.length === 1) {
193
+ const fallback = fallbackDefinition(lines, member, ext);
194
+ if (fallback) matches = [fallback];
195
+ }
196
+ if (!matches.length) return null;
197
+
198
+ const [best] = matches;
199
+ return {
200
+ name: symbol,
201
+ kind: best.kind,
202
+ line: best.line,
203
+ startLine: best.startLine,
204
+ endLine: Math.max(best.endLine, best.line),
205
+ resolvedBy: best.resolvedBy ?? 'analyzer',
206
+ matches: matches.length,
207
+ };
208
+ }
209
+
210
+ function escapeRegExp(text) {
211
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
212
+ }
213
+
214
+ /**
215
+ * A definition the analyzers do not report: `func name`, `const name =`, `name: ...`,
216
+ * lowercase Go functions, Lua/Elixir/shell functions and so on
217
+ */
218
+ function fallbackDefinition(lines, name, ext) {
219
+ const n = escapeRegExp(name);
220
+ const patterns = [
221
+ new RegExp(
222
+ `\\b(?:def|defp|defn|defmacro|function|func|fn|fun|sub|proc|class|struct|interface|enum|trait|type|const|let|var|val|macro|module|object|alias)\\s+\\*?\\s*${n}(?![\\w$])`
223
+ ),
224
+ new RegExp(`\\bfunc\\s*\\([^)]*\\)\\s*${n}\\s*[(<\\[]`),
225
+ new RegExp(
226
+ `^\\s*(?:(?:export|static|public|private|protected|readonly)\\s+)*(?:[\\w$]+\\.)*${n}\\s*(?::[^=\\n]*)?=(?![=>])`
227
+ ),
228
+ new RegExp(`^\\s*['"]?${n}['"]?\\s*:(?!:)`),
229
+ ];
230
+ return findDefinitionLine(lines, ext, patterns, 'definition');
231
+ }
232
+
233
+ /**
234
+ * A member defined outside its container's block: Go receivers, C++ `Type::method`,
235
+ * JavaScript `Type.prototype.method =` and `Type.method =`
236
+ */
237
+ function qualifiedDefinition(lines, container, member, ext) {
238
+ const c = escapeRegExp(container);
239
+ const m = escapeRegExp(member);
240
+ const patterns = [
241
+ new RegExp(`\\bfunc\\s*\\([^)]*\\b${c}\\s*\\)\\s*${m}\\s*[(<\\[]`),
242
+ new RegExp(`\\b${c}::${m}\\s*\\(`),
243
+ new RegExp(`\\b${c}\\.(?:prototype\\.)?${m}\\s*=(?!=)`),
244
+ ];
245
+ return findDefinitionLine(lines, ext, patterns, 'qualified');
246
+ }
247
+
248
+ function findDefinitionLine(lines, ext, patterns, resolvedBy) {
249
+ for (let i = 0; i < lines.length; i++) {
250
+ const text = lines[i].trim();
251
+ if (/^(\/\/|\/\*|\*|#|--)/.test(text) && !/^#\[/.test(text)) continue;
252
+ if (patterns.some((pattern) => pattern.test(lines[i]))) {
253
+ const end = blockEnd(lines, i, ext, lines.length);
254
+ return {
255
+ kind: 'definition',
256
+ line: i + 1,
257
+ startLine: leadingStart(lines, i, ext) + 1,
258
+ endLine: end + 1,
259
+ resolvedBy,
260
+ };
261
+ }
262
+ }
263
+ return null;
264
+ }
265
+
266
+ /**
267
+ * Names in the file closest to a symbol that was not found
268
+ * @param {string} content
269
+ * @param {string} filePath
270
+ * @param {string} symbol
271
+ * @param {number} [limit]
272
+ * @returns {string[]}
273
+ */
274
+ export function closeMatches(content, filePath, symbol, limit = 5) {
275
+ const definitions = listDefinitions(content, filePath);
276
+ const names = new Set(definitions.map((d) => d.name));
277
+ for (const d of definitions.filter((definition) => !definition.container)) {
278
+ const scope = definitions.find((c) => c.container && d.line > c.line && d.line <= c.endLine);
279
+ if (scope) names.add(`${scope.name}.${d.name}`);
280
+ }
281
+
282
+ const wanted = symbol.toLowerCase();
283
+ const wantedMember = wanted.split(MEMBER_SEPARATOR).pop();
284
+ const scored = [];
285
+ for (const name of names) {
286
+ const lower = name.toLowerCase();
287
+ const member = lower.split('.').pop();
288
+ let score;
289
+ if (lower === wanted || member === wantedMember) score = 0;
290
+ else if (lower.includes(wantedMember) || wantedMember.includes(member)) score = 1;
291
+ else {
292
+ const distance = levenshtein(wantedMember, member);
293
+ if (distance <= Math.max(2, Math.floor(wantedMember.length / 3))) score = 1 + distance;
294
+ }
295
+ if (score !== undefined) scored.push({ name, score });
296
+ }
297
+ return scored
298
+ .sort((a, b) => a.score - b.score || a.name.localeCompare(b.name))
299
+ .slice(0, limit)
300
+ .map((entry) => entry.name);
301
+ }
302
+
303
+ function levenshtein(a, b) {
304
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
305
+ for (let i = 1; i <= a.length; i++) {
306
+ const current = [i];
307
+ for (let j = 1; j <= b.length; j++) {
308
+ current[j] = Math.min(
309
+ previous[j] + 1,
310
+ current[j - 1] + 1,
311
+ previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
312
+ );
313
+ }
314
+ previous = current;
315
+ }
316
+ return previous[b.length];
317
+ }
318
+
319
+ /**
320
+ * Hunks of a `git diff -U0` (old = HEAD, new = working tree)
321
+ * @param {string} diff
322
+ * @returns {Array<{oldStart: number, oldCount: number, newStart: number, newCount: number}>}
323
+ */
324
+ export function parseHunks(diff) {
325
+ const hunks = [];
326
+ for (const match of diff.matchAll(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm)) {
327
+ hunks.push({
328
+ oldStart: Number(match[1]),
329
+ oldCount: match[2] === undefined ? 1 : Number(match[2]),
330
+ newStart: Number(match[3]),
331
+ newCount: match[4] === undefined ? 1 : Number(match[4]),
332
+ });
333
+ }
334
+ return hunks;
335
+ }
336
+
337
+ /**
338
+ * Map a working-tree line range onto the old (HEAD) side of the diff. Lines inside a
339
+ * changed block widen to the whole old block; a range made only of added lines has no
340
+ * old counterpart
341
+ * @param {ReturnType<typeof parseHunks>} hunks
342
+ * @param {number} start
343
+ * @param {number} end
344
+ * @returns {{startLine: number, endLine: number}|null}
345
+ */
346
+ export function mapRangeToOld(hunks, start, end) {
347
+ const mapLine = (line, edge) => {
348
+ let offset = 0;
349
+ for (const hunk of hunks) {
350
+ const newEnd = hunk.newStart + hunk.newCount - 1;
351
+ if (hunk.newCount > 0 && line >= hunk.newStart && line <= newEnd) {
352
+ if (hunk.oldCount === 0) return edge === 'start' ? hunk.oldStart + 1 : hunk.oldStart;
353
+ return edge === 'start' ? hunk.oldStart : hunk.oldStart + hunk.oldCount - 1;
354
+ }
355
+ const before = hunk.newCount > 0 ? newEnd < line : hunk.newStart < line;
356
+ if (!before) break;
357
+ offset += hunk.newCount - hunk.oldCount;
358
+ }
359
+ return line - offset;
360
+ };
361
+ const startLine = mapLine(start, 'start');
362
+ const endLine = mapLine(end, 'end');
363
+ return startLine <= endLine ? { startLine, endLine } : null;
364
+ }
365
+
366
+ /**
367
+ * The range git's default funcname rule gives a name: from the first line that starts
368
+ * with a letter, `_` or `$` and contains the name, up to the next such line (trailing
369
+ * blank lines dropped). Used to show the source when only `git log -L :name:file` found
370
+ * the symbol
371
+ * @param {string[]} lines
372
+ * @param {string} name
373
+ * @returns {{startLine: number, endLine: number}|null}
374
+ */
375
+ export function funcnameRange(lines, name) {
376
+ const word = new RegExp(`(?<![\\w$])${escapeRegExp(name)}(?![\\w$])`);
377
+ const isFuncname = (line) => /^[A-Za-z_$]/.test(line);
378
+ const start = lines.findIndex((line) => isFuncname(line) && word.test(line));
379
+ if (start === -1) return null;
380
+ let end = lines.length - 1;
381
+ for (let i = start + 1; i < lines.length; i++) {
382
+ if (isFuncname(lines[i])) {
383
+ end = i - 1;
384
+ break;
385
+ }
386
+ }
387
+ while (end > start && lines[end].trim() === '') end--;
388
+ return { startLine: start + 1, endLine: end + 1 };
389
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Config Generator
3
+ * Generates configuration files for ctxman
4
+ * FEAT-001: Configuration Wizard
5
+ */
6
+
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+
10
+ /**
11
+ * Configuration file names
12
+ */
13
+ const CONFIG_FILES = {
14
+ contextignore: '.contextignore',
15
+ contextinclude: '.contextinclude',
16
+ methodinclude: '.methodinclude',
17
+ };
18
+
19
+ /**
20
+ * Default minimal configuration
21
+ */
22
+ const MINIMAL_CONFIG = {
23
+ contextignore: ['node_modules/', '.git/', 'dist/', 'build/', 'coverage/'],
24
+ contextinclude: ['src/**', 'lib/**'],
25
+ methodinclude: [],
26
+ };
27
+
28
+ class ConfigGenerator {
29
+ /**
30
+ * Create a ConfigGenerator instance
31
+ * @param {string} projectRoot - Project root directory
32
+ */
33
+ constructor(projectRoot = process.cwd()) {
34
+ this.projectRoot = projectRoot;
35
+ }
36
+
37
+ /**
38
+ * Check if configuration files exist
39
+ * @returns {object} Object with existence status for each config file
40
+ */
41
+ checkExisting() {
42
+ const existing = {};
43
+
44
+ for (const [type, filename] of Object.entries(CONFIG_FILES)) {
45
+ const filePath = path.join(this.projectRoot, filename);
46
+ existing[type] = fs.existsSync(filePath);
47
+ }
48
+
49
+ return existing;
50
+ }
51
+
52
+ /**
53
+ * Generate configuration files
54
+ * @param {object} config - Configuration object
55
+ * @param {object} options - Generation options
56
+ * @param {boolean} options.force - Overwrite existing files
57
+ * @param {boolean} options.minimal - Use minimal configuration
58
+ * @param {boolean} options.dryRun - Don't write files, just return what would be written
59
+ * @returns {object} Generation result
60
+ */
61
+ generate(config, options = {}) {
62
+ const { force = false, minimal = false, dryRun = false } = options;
63
+
64
+ // Use minimal config if requested
65
+ const configToUse = minimal ? MINIMAL_CONFIG : config;
66
+
67
+ const result = {
68
+ created: [],
69
+ skipped: [],
70
+ overwritten: [],
71
+ errors: [],
72
+ };
73
+
74
+ // Generate each config file
75
+ for (const [type, filename] of Object.entries(CONFIG_FILES)) {
76
+ const patterns = configToUse[type] || [];
77
+
78
+ // Skip methodinclude if empty
79
+ if (type === 'methodinclude' && patterns.length === 0) {
80
+ continue;
81
+ }
82
+
83
+ const filePath = path.join(this.projectRoot, filename);
84
+ const exists = fs.existsSync(filePath);
85
+
86
+ // Check if should skip
87
+ if (exists && !force) {
88
+ result.skipped.push({
89
+ type,
90
+ filename,
91
+ reason: 'File already exists (use --force to overwrite)',
92
+ });
93
+ continue;
94
+ }
95
+
96
+ // Generate file content
97
+ const content = this.generateFileContent(type, patterns);
98
+
99
+ if (dryRun) {
100
+ if (exists) {
101
+ result.overwritten.push({ type, filename, content });
102
+ } else {
103
+ result.created.push({ type, filename, content });
104
+ }
105
+ continue;
106
+ }
107
+
108
+ // Write file
109
+ try {
110
+ fs.writeFileSync(filePath, content, 'utf-8');
111
+ if (exists) {
112
+ result.overwritten.push({ type, filename });
113
+ } else {
114
+ result.created.push({ type, filename });
115
+ }
116
+ } catch (error) {
117
+ result.errors.push({
118
+ type,
119
+ filename,
120
+ error: error.message,
121
+ });
122
+ }
123
+ }
124
+
125
+ return result;
126
+ }
127
+
128
+ /**
129
+ * Generate file content for a config type
130
+ * @param {string} type - Config type
131
+ * @param {Array<string>} patterns - Array of patterns
132
+ * @returns {string} File content
133
+ */
134
+ generateFileContent(type, patterns) {
135
+ const header = this.getFileHeader(type);
136
+ const lines = patterns.filter((p) => p && p.trim());
137
+
138
+ return header + lines.join('\n') + '\n';
139
+ }
140
+
141
+ /**
142
+ * Get header comment for config file
143
+ * @param {string} type - Config type
144
+ * @returns {string} Header comment
145
+ */
146
+ getFileHeader(type) {
147
+ const descriptions = {
148
+ contextignore:
149
+ '# Files and patterns to exclude from context generation\n# One pattern per line\n',
150
+ contextinclude:
151
+ '# Files and patterns to include in context generation\n# One pattern per line\n',
152
+ methodinclude:
153
+ '# Method patterns to include in method-level analysis\n# One pattern per line\n',
154
+ };
155
+
156
+ return `# Generated by ctxman init\n# ${descriptions[type] || ''}\n`;
157
+ }
158
+
159
+ /**
160
+ * Read existing configuration
161
+ * @returns {object} Existing configuration
162
+ */
163
+ readExisting() {
164
+ const config = {
165
+ contextignore: [],
166
+ contextinclude: [],
167
+ methodinclude: [],
168
+ };
169
+
170
+ for (const [type, filename] of Object.entries(CONFIG_FILES)) {
171
+ const filePath = path.join(this.projectRoot, filename);
172
+
173
+ if (fs.existsSync(filePath)) {
174
+ try {
175
+ const content = fs.readFileSync(filePath, 'utf-8');
176
+ config[type] = content
177
+ .split('\n')
178
+ .map((line) => line.trim())
179
+ .filter((line) => line && !line.startsWith('#'));
180
+ } catch (_error) {
181
+ // Ignore read errors
182
+ }
183
+ }
184
+ }
185
+
186
+ return config;
187
+ }
188
+
189
+ /**
190
+ * Merge existing config with new config
191
+ * @param {object} existing - Existing configuration
192
+ * @param {object} newConfig - New configuration
193
+ * @returns {object} Merged configuration
194
+ */
195
+ mergeConfigs(existing, newConfig) {
196
+ const merged = {};
197
+
198
+ for (const type of Object.keys(CONFIG_FILES)) {
199
+ const existingPatterns = existing[type] || [];
200
+ const newPatterns = newConfig[type] || [];
201
+
202
+ // Combine and deduplicate
203
+ merged[type] = [...new Set([...existingPatterns, ...newPatterns])];
204
+ }
205
+
206
+ return merged;
207
+ }
208
+
209
+ /**
210
+ * Format config for display
211
+ * @param {object} config - Configuration object
212
+ * @param {string} type - Config type
213
+ * @returns {string} Formatted string
214
+ */
215
+ formatForDisplay(config, type) {
216
+ const patterns = config[type] || [];
217
+ if (patterns.length === 0) {
218
+ return ' (empty)';
219
+ }
220
+
221
+ return patterns.map((p) => ` ${p}`).join('\n');
222
+ }
223
+
224
+ /**
225
+ * Preview configuration without writing
226
+ * @param {object} config - Configuration object
227
+ * @returns {string} Preview string
228
+ */
229
+ preview(config) {
230
+ const lines = [];
231
+
232
+ lines.push('📝 Configuration Preview:');
233
+ lines.push('');
234
+
235
+ for (const [type, filename] of Object.entries(CONFIG_FILES)) {
236
+ const patterns = config[type] || [];
237
+ if (type === 'methodinclude' && patterns.length === 0) {
238
+ continue;
239
+ }
240
+
241
+ lines.push(`${filename}:`);
242
+ if (patterns.length === 0) {
243
+ lines.push(' (no patterns)');
244
+ } else {
245
+ patterns.forEach((p) => lines.push(` ${p}`));
246
+ }
247
+ lines.push('');
248
+ }
249
+
250
+ return lines.join('\n');
251
+ }
252
+ }
253
+
254
+ export default ConfigGenerator;
255
+ export { CONFIG_FILES, MINIMAL_CONFIG };