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,267 @@
1
+ /**
2
+ * Parsers for the git output `ctxman pr` reads: `--name-status -z`, `--numstat -z` and
3
+ * unified diffs (split per file, then hunks mapped to new-side line numbers)
4
+ */
5
+
6
+ /**
7
+ * @typedef {object} NameStatusEntry
8
+ * @property {string} status - A, M, D, R, C, T (U for unmerged)
9
+ * @property {string} path - Path at head (the old path for a deleted file)
10
+ * @property {string|null} oldPath - Path at base (null for an added file)
11
+ * @property {number|null} similarity - Rename/copy similarity in percent
12
+ */
13
+
14
+ /**
15
+ * Parse `git diff --name-status -z`
16
+ * @param {string} output
17
+ * @returns {NameStatusEntry[]}
18
+ */
19
+ export function parseNameStatus(output) {
20
+ const parts = output.split('\0');
21
+ const entries = [];
22
+ let i = 0;
23
+ while (i < parts.length) {
24
+ const code = parts[i++];
25
+ if (!code) continue;
26
+ const status = code[0];
27
+ if (status === 'R' || status === 'C') {
28
+ const oldPath = parts[i++];
29
+ const newPath = parts[i++];
30
+ entries.push({ status, path: newPath, oldPath, similarity: Number(code.slice(1)) || 0 });
31
+ } else {
32
+ const filePath = parts[i++];
33
+ entries.push({
34
+ status,
35
+ path: filePath,
36
+ oldPath: status === 'A' ? null : filePath,
37
+ similarity: null,
38
+ });
39
+ }
40
+ }
41
+ return entries;
42
+ }
43
+
44
+ /**
45
+ * Parse `git diff --numstat -z` into per-path line counts (binary files count 0/0)
46
+ * @param {string} output
47
+ * @returns {Map<string, {additions: number, deletions: number, binary: boolean}>}
48
+ * Keyed by the head-side path
49
+ */
50
+ export function parseNumstat(output) {
51
+ const parts = output.split('\0');
52
+ const stats = new Map();
53
+ let i = 0;
54
+ while (i < parts.length) {
55
+ const record = parts[i++];
56
+ if (!record) continue;
57
+ const firstTab = record.indexOf('\t');
58
+ const secondTab = record.indexOf('\t', firstTab + 1);
59
+ if (firstTab === -1 || secondTab === -1) continue;
60
+ const added = record.slice(0, firstTab);
61
+ const deleted = record.slice(firstTab + 1, secondTab);
62
+ let filePath = record.slice(secondTab + 1);
63
+ if (filePath === '') {
64
+ // Rename or copy: old and new paths follow as separate fields
65
+ i++;
66
+ filePath = parts[i++];
67
+ }
68
+ const binary = added === '-' || deleted === '-';
69
+ stats.set(filePath, {
70
+ additions: binary ? 0 : Number(added) || 0,
71
+ deletions: binary ? 0 : Number(deleted) || 0,
72
+ binary,
73
+ });
74
+ }
75
+ return stats;
76
+ }
77
+
78
+ /**
79
+ * Undo git's C-style quoting of a path ("a/t\303\244b\tc" → a/täb<TAB>c)
80
+ * @param {string} text
81
+ * @returns {string}
82
+ */
83
+ export function unquoteGitPath(text) {
84
+ if (!(text.length >= 2 && text.startsWith('"') && text.endsWith('"'))) return text;
85
+ const body = text.slice(1, -1);
86
+ const bytes = [];
87
+ const escapes = { n: 10, t: 9, r: 13, a: 7, b: 8, f: 12, v: 11, '"': 34, '\\': 92 };
88
+ for (let i = 0; i < body.length; i++) {
89
+ const ch = body[i];
90
+ if (ch !== '\\') {
91
+ bytes.push(...Buffer.from(ch, 'utf8'));
92
+ continue;
93
+ }
94
+ const next = body[i + 1];
95
+ if (/[0-7]/.test(next)) {
96
+ const octal = body.slice(i + 1, i + 4);
97
+ bytes.push(parseInt(octal, 8));
98
+ i += 3;
99
+ } else {
100
+ bytes.push(escapes[next] ?? next.charCodeAt(0));
101
+ i += 1;
102
+ }
103
+ }
104
+ return Buffer.from(bytes).toString('utf8');
105
+ }
106
+
107
+ /** Strip the a/ or b/ prefix (and the tab git appends to names with spaces) */
108
+ function headerPath(raw, prefix) {
109
+ let value = raw.replace(/\t$/, '');
110
+ if (value === '/dev/null') return null;
111
+ value = unquoteGitPath(value);
112
+ return value.startsWith(prefix) ? value.slice(prefix.length) : value;
113
+ }
114
+
115
+ /**
116
+ * Paths from a `diff --git a/x b/y` line when nothing else names them (binary files,
117
+ * mode-only changes); exact only when both sides are equal, which is the common case
118
+ * @param {string} rest - Text after "diff --git "
119
+ */
120
+ function pathsFromGitLine(rest) {
121
+ if (rest.startsWith('"')) {
122
+ const match = /^("(?:[^"\\]|\\.)*") ("(?:[^"\\]|\\.)*"|\S.*)$/.exec(rest);
123
+ if (match) return [headerPath(match[1], 'a/'), headerPath(match[2], 'b/')];
124
+ }
125
+ const length = (rest.length - 5) / 2;
126
+ if (Number.isInteger(length) && length > 0) {
127
+ const oldPath = rest.slice(2, 2 + length);
128
+ if (rest.slice(2 + length) === ` b/${oldPath}`) return [oldPath, oldPath];
129
+ }
130
+ const split = rest.indexOf(' b/');
131
+ return split === -1 ? [null, null] : [rest.slice(2, split), rest.slice(split + 3)];
132
+ }
133
+
134
+ /**
135
+ * @typedef {object} FileDiff
136
+ * @property {string|null} oldPath
137
+ * @property {string|null} newPath
138
+ * @property {string} text - The block from its `diff --git` line on
139
+ */
140
+
141
+ /**
142
+ * Split a multi-file unified diff (made with --src-prefix=a/ --dst-prefix=b/) per file
143
+ * @param {string} diffText
144
+ * @returns {FileDiff[]}
145
+ */
146
+ export function splitDiff(diffText) {
147
+ if (!diffText) return [];
148
+ const lines = diffText.split('\n');
149
+ const blocks = [];
150
+ let current = null;
151
+
152
+ for (const line of lines) {
153
+ if (line.startsWith('diff --git ')) {
154
+ current = { lines: [line], header: true };
155
+ blocks.push(current);
156
+ } else if (current) {
157
+ current.lines.push(line);
158
+ }
159
+ }
160
+
161
+ return blocks.map(({ lines: blockLines }) => {
162
+ let oldPath;
163
+ let newPath;
164
+ let renameFrom = null;
165
+ let renameTo = null;
166
+ for (const line of blockLines.slice(1)) {
167
+ if (line.startsWith('@@')) break;
168
+ if (line.startsWith('rename from ') || line.startsWith('copy from ')) {
169
+ renameFrom = unquoteGitPath(line.slice(line.indexOf(' from ') + 6));
170
+ } else if (line.startsWith('rename to ') || line.startsWith('copy to ')) {
171
+ renameTo = unquoteGitPath(line.slice(line.indexOf(' to ') + 4));
172
+ } else if (line.startsWith('--- ')) {
173
+ oldPath = headerPath(line.slice(4), 'a/');
174
+ } else if (line.startsWith('+++ ')) {
175
+ newPath = headerPath(line.slice(4), 'b/');
176
+ }
177
+ }
178
+ if (oldPath === undefined || newPath === undefined) {
179
+ const [gitOld, gitNew] = pathsFromGitLine(blockLines[0].slice('diff --git '.length));
180
+ const isNew = blockLines.some((line) => line.startsWith('new file mode'));
181
+ const isDeleted = blockLines.some((line) => line.startsWith('deleted file mode'));
182
+ if (oldPath === undefined) oldPath = isNew ? null : (renameFrom ?? gitOld);
183
+ if (newPath === undefined) newPath = isDeleted ? null : (renameTo ?? gitNew);
184
+ }
185
+ // Drop the empty string left by the diff's final newline
186
+ while (blockLines.length > 1 && blockLines[blockLines.length - 1] === '') blockLines.pop();
187
+ return { oldPath, newPath, text: blockLines.join('\n') };
188
+ });
189
+ }
190
+
191
+ /**
192
+ * New-side lines a file diff touches: the line numbers of added lines, and for each run
193
+ * of deleted lines the new-side number of the line that follows it
194
+ * @param {string} fileDiffText
195
+ * @returns {{addedLines: number[], deletionAnchors: number[]}}
196
+ */
197
+ export function parseHunks(fileDiffText) {
198
+ const addedLines = [];
199
+ const deletionAnchors = [];
200
+ let newLine = 0;
201
+ let inHunk = false;
202
+ let previousWasDeletion = false;
203
+
204
+ for (const line of fileDiffText.split('\n')) {
205
+ if (line.startsWith('@@')) {
206
+ const match = /^@@+ (?:-\d+(?:,\d+)? )+\+(\d+)(?:,(\d+))? @@/.exec(line);
207
+ inHunk = Boolean(match);
208
+ if (match) {
209
+ const start = Number(match[1]);
210
+ // An empty new side (-U0 pure deletion) names the line before the gap
211
+ newLine = match[2] === '0' ? start + 1 : start;
212
+ }
213
+ previousWasDeletion = false;
214
+ continue;
215
+ }
216
+ if (!inHunk) continue;
217
+ if (line.startsWith('+')) {
218
+ addedLines.push(newLine);
219
+ newLine++;
220
+ previousWasDeletion = false;
221
+ } else if (line.startsWith('-')) {
222
+ if (!previousWasDeletion) deletionAnchors.push(newLine);
223
+ previousWasDeletion = true;
224
+ } else if (line.startsWith(' ')) {
225
+ newLine++;
226
+ previousWasDeletion = false;
227
+ } else if (!line.startsWith('\\')) {
228
+ // Anything else ends the hunk (the next file's header)
229
+ inHunk = false;
230
+ }
231
+ }
232
+
233
+ return { addedLines, deletionAnchors };
234
+ }
235
+
236
+ /**
237
+ * A unified diff that adds a whole file, for untracked files in --working mode
238
+ * @param {string} filePath
239
+ * @param {string|null} content - null for binary content
240
+ * @returns {string}
241
+ */
242
+ export function syntheticAddedDiff(filePath, content) {
243
+ const header = `diff --git a/${filePath} b/${filePath}\nnew file mode 100644`;
244
+ if (content === null) {
245
+ return `${header}\nBinary files /dev/null and b/${filePath} differ`;
246
+ }
247
+ if (content === '') return header;
248
+ const lines = content.split('\n');
249
+ const missingNewline = lines[lines.length - 1] !== '';
250
+ if (!missingNewline) lines.pop();
251
+ const body = lines.map((line) => `+${line}`).join('\n');
252
+ return (
253
+ `${header}\n--- /dev/null\n+++ b/${filePath}\n@@ -0,0 +1${lines.length === 1 ? '' : `,${lines.length}`} @@\n` +
254
+ body +
255
+ (missingNewline ? '\n\' : '')
256
+ );
257
+ }
258
+
259
+ /**
260
+ * Number of lines in file content, as git counts them for --numstat
261
+ * @param {string} content
262
+ */
263
+ export function countLines(content) {
264
+ if (!content) return 0;
265
+ const newlines = content.split('\n').length - 1;
266
+ return content.endsWith('\n') ? newlines : newlines + 1;
267
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Map diff lines to the methods that contain them, and find methods a change removed,
3
+ * with the existing regex method analyzers (lib/analyzers/method-analyzer.js, which hands
4
+ * Go files to go-method-analyzer.js)
5
+ */
6
+
7
+ import path from 'path';
8
+ import MethodAnalyzer from '../analyzers/method-analyzer.js';
9
+ import FileUtils from '../utils/file-utils.js';
10
+
11
+ // The analyzer treats unknown extensions as JavaScript; these are JavaScript too
12
+ const EXTRA_JS_EXTENSIONS = new Set(['.mjs', '.cjs', '.mts', '.cts']);
13
+
14
+ const analyzer = new MethodAnalyzer();
15
+
16
+ /**
17
+ * @typedef {object} MethodRange
18
+ * @property {string} name
19
+ * @property {number} start - First line (1-based)
20
+ * @property {number} end - Last line (inclusive)
21
+ * @property {string} source - Lines start..end
22
+ */
23
+
24
+ /**
25
+ * Whether method extraction knows the file's language
26
+ * @param {string} filePath
27
+ * @returns {boolean}
28
+ */
29
+ export function supportsMethods(filePath) {
30
+ return (
31
+ FileUtils.isCode(filePath) || EXTRA_JS_EXTENSIONS.has(path.extname(filePath).toLowerCase())
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Methods of a file with their line ranges, sorted by start line
37
+ * @param {string} content
38
+ * @param {string} filePath - Used to pick the language
39
+ * @returns {MethodRange[]}
40
+ */
41
+ export function extractMethodRanges(content, filePath) {
42
+ let methods;
43
+ let sources;
44
+ try {
45
+ methods = analyzer.extractMethods(content, filePath);
46
+ sources = analyzer.extractMethodSources(content, methods, filePath);
47
+ } catch (_error) {
48
+ return [];
49
+ }
50
+ return methods
51
+ .map((method, i) => ({
52
+ name: method.name,
53
+ start: method.line,
54
+ end: method.line + sources[i].split('\n').length - 1,
55
+ source: sources[i],
56
+ }))
57
+ .sort((a, b) => a.start - b.start || b.end - a.end);
58
+ }
59
+
60
+ /** The smallest method that satisfies `contains` */
61
+ function innermost(methods, contains) {
62
+ let best = null;
63
+ for (const method of methods) {
64
+ if (contains(method) && (!best || method.end - method.start < best.end - best.start)) {
65
+ best = method;
66
+ }
67
+ }
68
+ return best;
69
+ }
70
+
71
+ /**
72
+ * Methods whose lines a diff touches. Each added line belongs to the innermost method
73
+ * around it; a deletion belongs to a method when it happened after the method's first
74
+ * line and before or at its last. A touched method nested in another touched method is
75
+ * left out, since the outer one's source already contains it.
76
+ * @param {MethodRange[]} methods - Methods of the head version
77
+ * @param {{addedLines: number[], deletionAnchors: number[]}} hunks - From parseHunks()
78
+ * @returns {Array<MethodRange & {changedLines: number}>}
79
+ */
80
+ export function findTouchedMethods(methods, { addedLines, deletionAnchors }) {
81
+ if (methods.length === 0) return [];
82
+ const touched = new Map();
83
+ const mark = (method) => method && touched.set(method, (touched.get(method) || 0) + 1);
84
+
85
+ for (const line of addedLines) {
86
+ mark(innermost(methods, (m) => m.start <= line && line <= m.end));
87
+ }
88
+ for (const anchor of deletionAnchors) {
89
+ mark(innermost(methods, (m) => m.start < anchor && anchor <= m.end));
90
+ }
91
+
92
+ const list = [...touched.keys()];
93
+ const containedIn = (inner) =>
94
+ list.find(
95
+ (outer) =>
96
+ outer !== inner &&
97
+ outer.start <= inner.start &&
98
+ inner.end <= outer.end &&
99
+ outer.end - outer.start > inner.end - inner.start
100
+ );
101
+
102
+ const result = new Map();
103
+ for (const method of list) {
104
+ let target = method;
105
+ let outer;
106
+ while ((outer = containedIn(target))) target = outer;
107
+ result.set(target, (result.get(target) || 0) + touched.get(method));
108
+ }
109
+
110
+ return [...result.entries()]
111
+ .map(([method, changedLines]) => ({ ...method, changedLines }))
112
+ .sort((a, b) => a.start - b.start);
113
+ }
114
+
115
+ /**
116
+ * Methods of the base version that the head version no longer has. A name that still
117
+ * exists fewer times than before reports the base occurrences whose source no longer
118
+ * appears in the head content.
119
+ * @param {MethodRange[]} baseMethods
120
+ * @param {MethodRange[]} headMethods
121
+ * @param {string} headContent
122
+ * @returns {MethodRange[]}
123
+ */
124
+ export function findDeletedMethods(baseMethods, headMethods, headContent = '') {
125
+ const headCount = new Map();
126
+ for (const method of headMethods) {
127
+ headCount.set(method.name, (headCount.get(method.name) || 0) + 1);
128
+ }
129
+ const byName = new Map();
130
+ for (const method of baseMethods) {
131
+ if (!byName.has(method.name)) byName.set(method.name, []);
132
+ byName.get(method.name).push(method);
133
+ }
134
+
135
+ const deleted = [];
136
+ for (const [name, occurrences] of byName) {
137
+ const missing = occurrences.length - (headCount.get(name) || 0);
138
+ if (missing <= 0) continue;
139
+ if (missing === occurrences.length) {
140
+ deleted.push(...occurrences);
141
+ continue;
142
+ }
143
+ const gone = occurrences.filter((method) => !headContent.includes(method.source));
144
+ const rest = occurrences.filter((method) => !gone.includes(method));
145
+ deleted.push(...[...gone, ...rest].slice(0, missing));
146
+ }
147
+ return deleted.sort((a, b) => a.start - b.start);
148
+ }