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,358 @@
1
+ /**
2
+ * Text around a target: docs, ADRs, changelog entries and code comments that mention
3
+ * it, tests that reference it, and TODO/FIXME-style notes inside it. Searching uses
4
+ * `git grep` (tracked files only, binary files skipped)
5
+ */
6
+
7
+ import path from 'path';
8
+ import { readFileChecked } from '../utils/safe-read.js';
9
+ import { tryGit } from './git.js';
10
+ import {
11
+ DOC_EXTENSIONS,
12
+ TEST_PATHSPECS,
13
+ classifyTextFile,
14
+ isExportableText,
15
+ isTestFile,
16
+ } from './paths.js';
17
+
18
+ const GREP_CONFIG = ['-c', 'grep.fullName=false', '-c', 'grep.column=false'];
19
+ const MAX_GREP_HITS = 20000;
20
+ const KIND_ORDER = { adr: 0, changelog: 1, doc: 2, comment: 3 };
21
+ // TODO: text, FIXME(alice): text, HACK - text
22
+ const NOTE_TAGS = /\b(TODO|FIXME|HACK|XXX|NOTE)\b(?:\(([^)]*)\))?[\s:-]*(.*)$/;
23
+
24
+ const SLASH_COMMENTS = new Set([
25
+ '.js',
26
+ '.jsx',
27
+ '.ts',
28
+ '.tsx',
29
+ '.mjs',
30
+ '.cjs',
31
+ '.java',
32
+ '.c',
33
+ '.cc',
34
+ '.cpp',
35
+ '.h',
36
+ '.hpp',
37
+ '.cs',
38
+ '.go',
39
+ '.rs',
40
+ '.swift',
41
+ '.kt',
42
+ '.kts',
43
+ '.scala',
44
+ '.php',
45
+ '.dart',
46
+ '.groovy',
47
+ '.gradle',
48
+ '.proto',
49
+ '.scss',
50
+ '.less',
51
+ '.sol',
52
+ '.zig',
53
+ '.vue',
54
+ '.svelte',
55
+ ]);
56
+ const HASH_COMMENTS = new Set([
57
+ '.py',
58
+ '.rb',
59
+ '.sh',
60
+ '.bash',
61
+ '.zsh',
62
+ '.yml',
63
+ '.yaml',
64
+ '.toml',
65
+ '.ini',
66
+ '.conf',
67
+ '.cfg',
68
+ '.properties',
69
+ '.tf',
70
+ '.nix',
71
+ '.ex',
72
+ '.exs',
73
+ '.r',
74
+ '.pl',
75
+ '.ps1',
76
+ '.cmake',
77
+ '.coffee',
78
+ '.jl',
79
+ '.php',
80
+ ]);
81
+ const DASH_COMMENTS = new Set(['.sql', '.lua', '.hs', '.elm', '.ada']);
82
+ const MARKUP_COMMENTS = new Set(['.html', '.htm', '.xml', '.svg', '.vue', '.svelte']);
83
+ const BLOCK_ONLY_COMMENTS = new Set(['.css']);
84
+
85
+ /**
86
+ * Position where a comment starts on `line`, or -1. Quoted strings are skipped so `//`
87
+ * in a string literal does not count; prose files are all "comment"
88
+ * @param {string} line
89
+ * @param {string} filePath
90
+ * @returns {number}
91
+ */
92
+ export function commentStart(line, filePath) {
93
+ const ext = path.extname(filePath).toLowerCase();
94
+ const base = path.basename(filePath).toLowerCase();
95
+ if (DOC_EXTENSIONS.has(ext)) return 0;
96
+
97
+ const slash = SLASH_COMMENTS.has(ext);
98
+ const hash = HASH_COMMENTS.has(ext) || ['makefile', 'dockerfile', 'gemfile'].includes(base);
99
+ const dash = DASH_COMMENTS.has(ext);
100
+ const markup = MARKUP_COMMENTS.has(ext);
101
+ const block = slash || BLOCK_ONLY_COMMENTS.has(ext);
102
+
103
+ const trimmed = line.trimStart();
104
+ const indent = line.length - trimmed.length;
105
+ // Continuation line of a /* ... */ block
106
+ if (block && /^\*(\s|\/|$)/.test(trimmed)) return indent;
107
+
108
+ let quote = null;
109
+ for (let i = 0; i < line.length; i++) {
110
+ const ch = line[i];
111
+ if (quote) {
112
+ if (ch === '\\') i++;
113
+ else if (ch === quote) quote = null;
114
+ continue;
115
+ }
116
+ if (ch === '"' || ch === "'" || ch === '`') quote = ch;
117
+ else if (slash && ch === '/' && line[i + 1] === '/') return i;
118
+ else if (block && ch === '/' && line[i + 1] === '*') return i;
119
+ else if (hash && ch === '#') return i;
120
+ else if (dash && ch === '-' && line[i + 1] === '-') return i;
121
+ else if (markup && line.startsWith('<!--', i)) return i;
122
+ }
123
+ return -1;
124
+ }
125
+
126
+ /**
127
+ * TODO / FIXME / HACK / XXX / NOTE comments inside lines startLine..endLine
128
+ * @param {string[]} lines - File lines
129
+ * @param {number} startLine
130
+ * @param {number} endLine
131
+ * @param {string} filePath
132
+ * @returns {Array<{line: number, tag: string, text: string}>}
133
+ */
134
+ export function findNotes(lines, startLine, endLine, filePath) {
135
+ const notes = [];
136
+ for (let n = startLine; n <= endLine && n <= lines.length; n++) {
137
+ const line = lines[n - 1];
138
+ const start = commentStart(line, filePath);
139
+ if (start === -1) continue;
140
+ const match = line.slice(start).match(NOTE_TAGS);
141
+ if (!match) continue;
142
+ const text = match[3].replace(/\s*(\*\/|--!?>)\s*$/, '').trim();
143
+ const note = { line: n, tag: match[1], text: shorten(text, 0, 200) };
144
+ if (match[2]) note.author = match[2].trim();
145
+ notes.push(note);
146
+ }
147
+ return notes;
148
+ }
149
+
150
+ /**
151
+ * Word-boundary matcher for a term (identifiers and file names)
152
+ * @param {string} term
153
+ * @returns {RegExp}
154
+ */
155
+ function termPattern(term) {
156
+ const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
157
+ return new RegExp(`(?<![\\w$])${escaped}(?![\\w$])`);
158
+ }
159
+
160
+ /**
161
+ * Shorten a line to `max` characters around position `at`
162
+ */
163
+ function shorten(text, at, max) {
164
+ const clean = text.replace(/\s+/g, ' ').trim();
165
+ if (clean.length <= max) return clean;
166
+ const offset = Math.max(0, text.length - text.trimStart().length);
167
+ const center = Math.max(0, at - offset);
168
+ const start = Math.max(0, Math.min(center - Math.floor(max / 3), clean.length - max));
169
+ const slice = clean.slice(start, start + max).trim();
170
+ return `${start > 0 ? '…' : ''}${slice}${start + max < clean.length ? '…' : ''}`;
171
+ }
172
+
173
+ /**
174
+ * Run git grep for fixed-string, whole-word terms and parse `path\0line\0text`
175
+ */
176
+ function grep(root, terms, pathspecs = []) {
177
+ const args = [...GREP_CONFIG, 'grep', '-n', '-z', '-I', '--no-color', '-F', '-w'];
178
+ for (const term of terms) args.push('-e', term);
179
+ if (pathspecs.length) args.push('--', ...pathspecs);
180
+ const output = tryGit(root, args, { okStatuses: [0, 1] }) ?? '';
181
+
182
+ const hits = [];
183
+ for (const record of output.split('\n')) {
184
+ if (hits.length >= MAX_GREP_HITS) break;
185
+ const first = record.indexOf('\0');
186
+ const second = record.indexOf('\0', first + 1);
187
+ if (first === -1 || second === -1) continue;
188
+ hits.push({
189
+ path: record.slice(0, first),
190
+ line: Number(record.slice(first + 1, second)),
191
+ text: record.slice(second + 1).replace(/\r$/, ''),
192
+ });
193
+ }
194
+ return hits;
195
+ }
196
+
197
+ /**
198
+ * Nearest heading above a line of a Markdown / AsciiDoc / reStructuredText file
199
+ */
200
+ function headingFinder(root) {
201
+ const cache = new Map();
202
+ return (relativePath, lineNumber) => {
203
+ if (!cache.has(relativePath)) {
204
+ // No links: a tracked symlink may point outside the project. Unreadable: no heading
205
+ const file = path.join(root, ...relativePath.split('/'));
206
+ const read = readFileChecked(file, { maxBytes: 2 * 1024 * 1024, followLinks: false });
207
+ cache.set(
208
+ relativePath,
209
+ read.status === 'ok' ? read.buffer.toString('utf-8').split('\n') : []
210
+ );
211
+ }
212
+ const lines = cache.get(relativePath);
213
+ const text = (i) => lines[i].replace(/\r$/, '');
214
+ for (let i = Math.min(lineNumber, lines.length) - 1; i >= 0; i--) {
215
+ // "# Title" (Markdown) or "== Title" (AsciiDoc)
216
+ const atx = text(i).match(/^(#{1,6}|={1,6})\s+(.+?)\s*#*\s*$/);
217
+ if (atx) return atx[2];
218
+ // A line underlined with ===, ---, ~~~ (Markdown setext, reStructuredText)
219
+ if (i > 0 && /^([=\-~^"'`#*+])\1{2,}\s*$/.test(text(i)) && text(i - 1).trim()) {
220
+ return text(i - 1).trim();
221
+ }
222
+ }
223
+ return null;
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Related text and tests for a target
229
+ * @param {string} root - Project root (git grep runs here; paths are relative to it)
230
+ * @param {object} options
231
+ * @param {string[]} options.symbols - Symbol names to look for
232
+ * @param {string|null} options.fileTerm - File name (or short path) to look for
233
+ * @param {string|null} options.stem - Module name for import lines in tests
234
+ * @param {{path: string, startLine: number|null, endLine: number|null}} options.target
235
+ * @param {(relativePath: string) => boolean} options.isExcluded
236
+ * @param {number} [options.maxRelated]
237
+ * @param {number} [options.maxTests]
238
+ * @returns {{related: object[], tests: object[], truncated: {related: number, tests: number}}}
239
+ */
240
+ export function searchRelated(root, options) {
241
+ const { symbols, fileTerm, stem, target, isExcluded } = options;
242
+ const maxRelated = options.maxRelated ?? 20;
243
+ const maxTests = options.maxTests ?? 10;
244
+ const terms = [...new Set([...symbols, fileTerm].filter((term) => term && term.length >= 3))];
245
+ const symbolPatterns = symbols.filter((s) => s && s.length >= 3).map(termPattern);
246
+ const filePattern = fileTerm ? termPattern(fileTerm) : null;
247
+
248
+ const excludedCache = new Map();
249
+ const allowed = (relativePath) => {
250
+ if (!excludedCache.has(relativePath)) {
251
+ const ext = path.extname(relativePath).toLowerCase();
252
+ excludedCache.set(
253
+ relativePath,
254
+ !isExcluded(relativePath) && (isExportableText(relativePath) || DOC_EXTENSIONS.has(ext))
255
+ );
256
+ }
257
+ return excludedCache.get(relativePath);
258
+ };
259
+ const inTarget = (hit) =>
260
+ hit.path === target.path &&
261
+ (target.startLine === null || (hit.line >= target.startLine && hit.line <= target.endLine));
262
+
263
+ const related = [];
264
+ const testHits = new Map();
265
+ const addTestHit = (hit, symbolHit) => {
266
+ const entry = testHits.get(hit.path) ?? { path: hit.path, matches: [], symbolHits: 0 };
267
+ if (entry.matches.some((match) => match.line === hit.line)) return;
268
+ entry.matches.push({ line: hit.line, text: shorten(hit.text, 0, 200) });
269
+ if (symbolHit) entry.symbolHits++;
270
+ testHits.set(hit.path, entry);
271
+ };
272
+
273
+ const heading = headingFinder(root);
274
+ for (const hit of terms.length ? grep(root, terms) : []) {
275
+ if (inTarget(hit) || !allowed(hit.path)) continue;
276
+ const symbolIndex = firstIndex(hit.text, symbolPatterns);
277
+ const fileIndex = filePattern ? firstIndex(hit.text, [filePattern]) : -1;
278
+ if (symbolIndex === -1 && fileIndex === -1) continue;
279
+
280
+ if (isTestFile(hit.path)) {
281
+ addTestHit(hit, symbolIndex !== -1);
282
+ continue;
283
+ }
284
+
285
+ const at = symbolIndex !== -1 ? symbolIndex : fileIndex;
286
+ let kind = classifyTextFile(hit.path);
287
+ if (!kind) {
288
+ const start = commentStart(hit.text, hit.path);
289
+ if (start === -1 || at < start) continue;
290
+ kind = 'comment';
291
+ }
292
+ related.push({
293
+ path: hit.path,
294
+ line: hit.line,
295
+ kind,
296
+ mentions: symbolIndex !== -1 ? 'symbol' : 'file',
297
+ heading: kind === 'comment' ? null : heading(hit.path, hit.line),
298
+ snippet: shorten(hit.text, at, 200),
299
+ });
300
+ }
301
+
302
+ // Test files that import the module by name (`require('../cart')`, `from shop.cart import`)
303
+ if (stem && stem.length >= 3) {
304
+ const importLine = /\b(import|require|from|include|use|using|load|source|mod)\b|['"`]/;
305
+ const stemPattern = termPattern(stem);
306
+ for (const hit of grep(root, [stem], TEST_PATHSPECS)) {
307
+ if (inTarget(hit) || !isTestFile(hit.path) || !allowed(hit.path)) continue;
308
+ if (importLine.test(hit.text) && stemPattern.test(hit.text)) addTestHit(hit, false);
309
+ }
310
+ }
311
+
312
+ related.sort(
313
+ (a, b) =>
314
+ KIND_ORDER[a.kind] - KIND_ORDER[b.kind] ||
315
+ (a.mentions === 'symbol' ? 0 : 1) - (b.mentions === 'symbol' ? 0 : 1) ||
316
+ a.path.localeCompare(b.path) ||
317
+ a.line - b.line
318
+ );
319
+ const perFile = new Map();
320
+ const keptRelated = related.filter((entry) => {
321
+ const count = perFile.get(entry.path) ?? 0;
322
+ perFile.set(entry.path, count + 1);
323
+ return count < 3;
324
+ });
325
+
326
+ const tests = [...testHits.values()]
327
+ .map((entry) => ({ ...entry, matches: entry.matches.sort((a, b) => a.line - b.line) }))
328
+ .sort(
329
+ (a, b) =>
330
+ b.symbolHits - a.symbolHits ||
331
+ b.matches.length - a.matches.length ||
332
+ a.path.localeCompare(b.path)
333
+ )
334
+ .map((entry) => ({
335
+ path: entry.path,
336
+ references: entry.symbolHits > 0 ? 'symbol' : 'file',
337
+ matches: entry.matches.slice(0, 5),
338
+ totalMatches: entry.matches.length,
339
+ }));
340
+
341
+ return {
342
+ related: keptRelated.slice(0, maxRelated),
343
+ tests: tests.slice(0, maxTests),
344
+ truncated: {
345
+ related: Math.max(0, keptRelated.length - maxRelated),
346
+ tests: Math.max(0, tests.length - maxTests),
347
+ },
348
+ };
349
+ }
350
+
351
+ function firstIndex(text, patterns) {
352
+ let best = -1;
353
+ for (const pattern of patterns) {
354
+ const match = pattern.exec(text);
355
+ if (match && (best === -1 || match.index < best)) best = match.index;
356
+ }
357
+ return best;
358
+ }