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,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,257 @@
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 skip = () =>
85
+ result.skipped.push({
86
+ type,
87
+ filename,
88
+ reason: 'File already exists (use --force to overwrite)',
89
+ });
90
+
91
+ if (dryRun) {
92
+ const exists = fs.existsSync(filePath);
93
+ if (exists && !force) {
94
+ skip();
95
+ continue;
96
+ }
97
+ const content = this.generateFileContent(type, patterns);
98
+ (exists ? result.overwritten : result.created).push({ type, filename, content });
99
+ continue;
100
+ }
101
+
102
+ const content = this.generateFileContent(type, patterns);
103
+ // Create exclusively first, so an existing file is found by the write itself rather
104
+ // than by a separate check that could be out of date
105
+ try {
106
+ try {
107
+ fs.writeFileSync(filePath, content, { encoding: 'utf-8', flag: 'wx' });
108
+ result.created.push({ type, filename });
109
+ } catch (error) {
110
+ if (error.code !== 'EEXIST') throw error;
111
+ if (!force) {
112
+ skip();
113
+ continue;
114
+ }
115
+ fs.writeFileSync(filePath, content, 'utf-8');
116
+ result.overwritten.push({ type, filename });
117
+ }
118
+ } catch (error) {
119
+ result.errors.push({
120
+ type,
121
+ filename,
122
+ error: error.message,
123
+ });
124
+ }
125
+ }
126
+
127
+ return result;
128
+ }
129
+
130
+ /**
131
+ * Generate file content for a config type
132
+ * @param {string} type - Config type
133
+ * @param {Array<string>} patterns - Array of patterns
134
+ * @returns {string} File content
135
+ */
136
+ generateFileContent(type, patterns) {
137
+ const header = this.getFileHeader(type);
138
+ const lines = patterns.filter((p) => p && p.trim());
139
+
140
+ return header + lines.join('\n') + '\n';
141
+ }
142
+
143
+ /**
144
+ * Get header comment for config file
145
+ * @param {string} type - Config type
146
+ * @returns {string} Header comment
147
+ */
148
+ getFileHeader(type) {
149
+ const descriptions = {
150
+ contextignore:
151
+ '# Files and patterns to exclude from context generation\n# One pattern per line\n',
152
+ contextinclude:
153
+ '# Files and patterns to include in context generation\n# One pattern per line\n',
154
+ methodinclude:
155
+ '# Method patterns to include in method-level analysis\n# One pattern per line\n',
156
+ };
157
+
158
+ return `# Generated by ctxman init\n# ${descriptions[type] || ''}\n`;
159
+ }
160
+
161
+ /**
162
+ * Read existing configuration
163
+ * @returns {object} Existing configuration
164
+ */
165
+ readExisting() {
166
+ const config = {
167
+ contextignore: [],
168
+ contextinclude: [],
169
+ methodinclude: [],
170
+ };
171
+
172
+ for (const [type, filename] of Object.entries(CONFIG_FILES)) {
173
+ const filePath = path.join(this.projectRoot, filename);
174
+
175
+ if (fs.existsSync(filePath)) {
176
+ try {
177
+ const content = fs.readFileSync(filePath, 'utf-8');
178
+ config[type] = content
179
+ .split('\n')
180
+ .map((line) => line.trim())
181
+ .filter((line) => line && !line.startsWith('#'));
182
+ } catch (_error) {
183
+ // Ignore read errors
184
+ }
185
+ }
186
+ }
187
+
188
+ return config;
189
+ }
190
+
191
+ /**
192
+ * Merge existing config with new config
193
+ * @param {object} existing - Existing configuration
194
+ * @param {object} newConfig - New configuration
195
+ * @returns {object} Merged configuration
196
+ */
197
+ mergeConfigs(existing, newConfig) {
198
+ const merged = {};
199
+
200
+ for (const type of Object.keys(CONFIG_FILES)) {
201
+ const existingPatterns = existing[type] || [];
202
+ const newPatterns = newConfig[type] || [];
203
+
204
+ // Combine and deduplicate
205
+ merged[type] = [...new Set([...existingPatterns, ...newPatterns])];
206
+ }
207
+
208
+ return merged;
209
+ }
210
+
211
+ /**
212
+ * Format config for display
213
+ * @param {object} config - Configuration object
214
+ * @param {string} type - Config type
215
+ * @returns {string} Formatted string
216
+ */
217
+ formatForDisplay(config, type) {
218
+ const patterns = config[type] || [];
219
+ if (patterns.length === 0) {
220
+ return ' (empty)';
221
+ }
222
+
223
+ return patterns.map((p) => ` ${p}`).join('\n');
224
+ }
225
+
226
+ /**
227
+ * Preview configuration without writing
228
+ * @param {object} config - Configuration object
229
+ * @returns {string} Preview string
230
+ */
231
+ preview(config) {
232
+ const lines = [];
233
+
234
+ lines.push('📝 Configuration Preview:');
235
+ lines.push('');
236
+
237
+ for (const [type, filename] of Object.entries(CONFIG_FILES)) {
238
+ const patterns = config[type] || [];
239
+ if (type === 'methodinclude' && patterns.length === 0) {
240
+ continue;
241
+ }
242
+
243
+ lines.push(`${filename}:`);
244
+ if (patterns.length === 0) {
245
+ lines.push(' (no patterns)');
246
+ } else {
247
+ patterns.forEach((p) => lines.push(` ${p}`));
248
+ }
249
+ lines.push('');
250
+ }
251
+
252
+ return lines.join('\n');
253
+ }
254
+ }
255
+
256
+ export default ConfigGenerator;
257
+ export { CONFIG_FILES, MINIMAL_CONFIG };