ctxman 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/.ctxman/llm-profiles.json +1067 -39
  2. package/.ctxman/profiles/backend-team.json +13 -0
  3. package/.ctxman/profiles/default.json +12 -0
  4. package/.ctxman/profiles/devops-team.json +21 -0
  5. package/.ctxman/profiles/frontend-team.json +20 -0
  6. package/.ctxman/templates/bug-fix.json +17 -0
  7. package/.ctxman/templates/code-review.json +9 -0
  8. package/.ctxman/templates/documentation.json +9 -0
  9. package/.ctxman/templates/feature.json +9 -0
  10. package/.ctxman/templates/full-context.json +9 -0
  11. package/.ctxman/templates/refactoring.json +18 -0
  12. package/.ctxman/templates/testing.json +9 -0
  13. package/.ctxman/wizard-profiles/security-audit/profile.json +7 -1
  14. package/LICENSE +21 -0
  15. package/README.md +380 -136
  16. package/bin/cli.js +1880 -529
  17. package/bin/cm-ask.js +18 -13
  18. package/bin/cm-audit.js +124 -0
  19. package/bin/cm-count.js +151 -0
  20. package/bin/cm-gitingest.js +128 -115
  21. package/bin/cm-map.js +242 -0
  22. package/bin/cm-mcp.js +293 -0
  23. package/bin/cm-pack.js +405 -0
  24. package/bin/cm-policy.js +170 -0
  25. package/bin/cm-pr.js +248 -0
  26. package/bin/cm-search.js +178 -0
  27. package/bin/cm-update.js +128 -128
  28. package/bin/cm-why.js +197 -0
  29. package/bin/mcp-server.js +8 -24
  30. package/ctxman.js +237 -183
  31. package/examples/README.md +19 -4
  32. package/examples/github-actions/ctxman-check.yml +49 -0
  33. package/examples/wizard-profiles/security-audit/profile.json +7 -1
  34. package/index.d.ts +1422 -0
  35. package/index.js +55 -31
  36. package/lib/agents/pr-failure-agent.js +48 -45
  37. package/lib/analyzers/ai-suggester.js +451 -0
  38. package/lib/analyzers/dependency-scanner.js +505 -0
  39. package/lib/analyzers/duplicate-detector.js +264 -0
  40. package/lib/analyzers/go-method-analyzer.js +151 -110
  41. package/lib/analyzers/import-tracker.js +431 -0
  42. package/lib/analyzers/method-analyzer.js +1055 -434
  43. package/lib/analyzers/performance-dashboard.js +683 -0
  44. package/lib/analyzers/token-calculator.js +895 -504
  45. package/lib/analyzers/unused-exports-detector.js +336 -0
  46. package/lib/api/mcp/install.js +424 -0
  47. package/lib/api/mcp/prompts.js +281 -281
  48. package/lib/api/mcp/resources.js +300 -241
  49. package/lib/api/mcp/sandbox.js +89 -0
  50. package/lib/api/mcp/server.js +675 -312
  51. package/lib/api/mcp/start.js +125 -0
  52. package/lib/api/mcp/transports/HttpSseTransport.js +60 -52
  53. package/lib/api/rest/server.js +105 -28
  54. package/lib/cache/CacheManager.js +9 -17
  55. package/lib/core/Analyzer.js +16 -10
  56. package/lib/core/ContextBuilder.js +19 -28
  57. package/lib/core/Reporter.js +15 -14
  58. package/lib/core/Scanner.js +81 -12
  59. package/lib/count/index.js +100 -0
  60. package/lib/formatters/format-registry.js +394 -372
  61. package/lib/formatters/gitingest-formatter.js +656 -621
  62. package/lib/formatters/toon-formatter-v1.3.js +637 -632
  63. package/lib/formatters/toon-formatter.js +239 -240
  64. package/lib/integrations/git/BlameTracker.js +4 -4
  65. package/lib/integrations/git/DiffAnalyzer.js +38 -31
  66. package/lib/integrations/git/GitClient.js +71 -53
  67. package/lib/lsp/server.js +311 -0
  68. package/lib/map/graph.js +299 -0
  69. package/lib/map/imports.js +233 -0
  70. package/lib/map/index.js +33 -0
  71. package/lib/map/rank.js +253 -0
  72. package/lib/map/render.js +346 -0
  73. package/lib/map/resolver.js +574 -0
  74. package/lib/pack/budget.js +111 -0
  75. package/lib/pack/code-lexer.js +360 -0
  76. package/lib/pack/compress.js +414 -0
  77. package/lib/pack/index.js +354 -0
  78. package/lib/pack/renderers/common.js +134 -0
  79. package/lib/pack/renderers/index.js +24 -0
  80. package/lib/pack/renderers/json.js +47 -0
  81. package/lib/pack/renderers/markdown.js +54 -0
  82. package/lib/pack/renderers/plain.js +38 -0
  83. package/lib/pack/renderers/xml.js +85 -0
  84. package/lib/pack/select.js +177 -0
  85. package/lib/pack/split.js +182 -0
  86. package/lib/pack/tree.js +35 -0
  87. package/lib/parsers/gitignore-parser.js +113 -107
  88. package/lib/parsers/method-filter-parser.js +50 -33
  89. package/lib/plugins/ExporterPlugin.js +3 -3
  90. package/lib/plugins/LanguagePlugin.js +3 -3
  91. package/lib/plugins/PluginManager.js +7 -13
  92. package/lib/policy/audit.js +294 -0
  93. package/lib/policy/enforce.js +125 -0
  94. package/lib/policy/ignore-files.js +169 -0
  95. package/lib/policy/index.js +52 -0
  96. package/lib/policy/policy.js +489 -0
  97. package/lib/pr/diff-parser.js +267 -0
  98. package/lib/pr/method-mapper.js +148 -0
  99. package/lib/pr/pr-context.js +829 -0
  100. package/lib/pr/pr-git.js +149 -0
  101. package/lib/pr/pr-renderer.js +644 -0
  102. package/lib/pr/related-tests.js +296 -0
  103. package/lib/rag/DataSourcePlugin.js +5 -5
  104. package/lib/rag/EmbeddingProvider.js +19 -8
  105. package/lib/rag/Indexer.js +14 -5
  106. package/lib/rag/VectorStoreFactory.js +3 -3
  107. package/lib/rag/sources/FileDataSource.js +6 -6
  108. package/lib/rag/stores/BaseVectorStore.js +5 -5
  109. package/lib/rag/stores/ChromaDBStore.js +10 -10
  110. package/lib/rag/stores/LanceDBStore.js +29 -19
  111. package/lib/rag/stores/PineconeStore.js +11 -9
  112. package/lib/search/bm25.js +433 -0
  113. package/lib/search/code-units.js +201 -0
  114. package/lib/search/symbols.js +189 -0
  115. package/lib/ui/dashboard.js +120 -89
  116. package/lib/ui/index.js +1 -6
  117. package/lib/ui/init-wizard-ui.js +550 -0
  118. package/lib/ui/progress-bar.js +94 -88
  119. package/lib/ui/select-input.js +40 -32
  120. package/lib/ui/template-selector.js +99 -0
  121. package/lib/ui/wizard.js +374 -329
  122. package/lib/utils/claude-token-counter.js +151 -0
  123. package/lib/utils/cli-output.js +17 -0
  124. package/lib/utils/clipboard-utils.js +49 -50
  125. package/lib/utils/config-utils.js +81 -66
  126. package/lib/utils/context-versioning.js +532 -0
  127. package/lib/utils/error-handler.js +112 -112
  128. package/lib/utils/file-utils.js +153 -69
  129. package/lib/utils/format-converter.js +356 -283
  130. package/lib/utils/git-utils.js +366 -294
  131. package/lib/utils/llm-cost-estimator.js +281 -0
  132. package/lib/utils/llm-detector.js +242 -78
  133. package/lib/utils/logger.js +243 -237
  134. package/lib/utils/messages.js +355 -0
  135. package/lib/utils/multi-repo-manager.js +364 -0
  136. package/lib/utils/profile-manager.js +380 -0
  137. package/lib/utils/safe-read.js +61 -0
  138. package/lib/utils/secret-redactor.js +61 -0
  139. package/lib/utils/snapshot-manager.js +679 -0
  140. package/lib/utils/template-manager.js +378 -0
  141. package/lib/utils/token-utils.js +201 -170
  142. package/lib/utils/tokenizer-adapter.js +421 -377
  143. package/lib/utils/updater.js +539 -506
  144. package/lib/watch/FileWatcher.js +12 -8
  145. package/lib/watch/IncrementalAnalyzer.js +7 -7
  146. package/lib/why/coupling.js +81 -0
  147. package/lib/why/errors.js +23 -0
  148. package/lib/why/git.js +169 -0
  149. package/lib/why/history.js +108 -0
  150. package/lib/why/index.js +720 -0
  151. package/lib/why/ownership.js +79 -0
  152. package/lib/why/paths.js +207 -0
  153. package/lib/why/references.js +259 -0
  154. package/lib/why/render.js +722 -0
  155. package/lib/why/search.js +358 -0
  156. package/lib/why/target.js +389 -0
  157. package/lib/wizards/config-generator.js +255 -0
  158. package/lib/wizards/project-detector.js +571 -0
  159. package/package.json +44 -10
  160. package/.ctxman/logs/ctxman-2026-07-02.log +0 -156
  161. package/.ctxman/self-knowledge/documents.lance/_transactions/0-b3dd1558-f95b-44b9-a154-18e8c330ae7a.txn +0 -0
  162. package/.ctxman/self-knowledge/documents.lance/_transactions/1-44bff6d4-e552-4010-a769-e09ad822698d.txn +0 -0
  163. package/.ctxman/self-knowledge/documents.lance/_transactions/10-37260794-5947-4ecb-b4c8-d732f58f40f1.txn +0 -0
  164. package/.ctxman/self-knowledge/documents.lance/_transactions/11-0d52a883-5518-463c-9b6d-986c3af89dfd.txn +0 -0
  165. package/.ctxman/self-knowledge/documents.lance/_transactions/12-ae354646-52f2-49c9-90fb-2e6ed8932663.txn +0 -0
  166. package/.ctxman/self-knowledge/documents.lance/_transactions/13-5d7d5e57-27da-498b-b734-091b9d38da13.txn +0 -0
  167. package/.ctxman/self-knowledge/documents.lance/_transactions/14-558ce928-810b-4252-a364-fdd1ee6f8a25.txn +0 -0
  168. package/.ctxman/self-knowledge/documents.lance/_transactions/15-27e3a58f-8b64-43b8-a9f4-e3369446968b.txn +0 -0
  169. package/.ctxman/self-knowledge/documents.lance/_transactions/16-a110c390-e5c8-4464-b976-bc7f5195fa15.txn +0 -0
  170. package/.ctxman/self-knowledge/documents.lance/_transactions/17-97513225-53de-4216-b61a-6337bd0cc4ad.txn +0 -0
  171. package/.ctxman/self-knowledge/documents.lance/_transactions/18-5ba450f0-c527-459c-bf4f-6d79b5e4285f.txn +0 -0
  172. package/.ctxman/self-knowledge/documents.lance/_transactions/19-600d3a63-5225-49e9-b8b7-30e037b564ab.txn +0 -0
  173. package/.ctxman/self-knowledge/documents.lance/_transactions/2-48a61b4e-92cf-4342-8376-914c1b99a9f9.txn +0 -0
  174. package/.ctxman/self-knowledge/documents.lance/_transactions/20-121dd912-5410-410e-84ff-82c7a23604a0.txn +0 -0
  175. package/.ctxman/self-knowledge/documents.lance/_transactions/3-bc6844ab-7ea3-4bb3-9ee1-538ce5664a9c.txn +0 -0
  176. package/.ctxman/self-knowledge/documents.lance/_transactions/4-d5e1754f-dbb5-454e-a9be-2a7da8d7bcf9.txn +0 -0
  177. package/.ctxman/self-knowledge/documents.lance/_transactions/5-4fae93df-0568-4b6f-8acf-8bef4bba6047.txn +0 -0
  178. package/.ctxman/self-knowledge/documents.lance/_transactions/6-4b1b8b87-068e-49a3-ba2b-d8dae628b252.txn +0 -0
  179. package/.ctxman/self-knowledge/documents.lance/_transactions/7-63d040fb-d2fa-4c0f-bed0-53411918a7b0.txn +0 -0
  180. package/.ctxman/self-knowledge/documents.lance/_transactions/8-c5628cf6-264c-420f-aab8-bc96a9083591.txn +0 -0
  181. package/.ctxman/self-knowledge/documents.lance/_transactions/9-c94570aa-5e53-4fcb-971f-cecc1d1d5492.txn +0 -0
  182. package/.ctxman/self-knowledge/documents.lance/_versions/1.manifest +0 -0
  183. package/.ctxman/self-knowledge/documents.lance/_versions/10.manifest +0 -0
  184. package/.ctxman/self-knowledge/documents.lance/_versions/11.manifest +0 -0
  185. package/.ctxman/self-knowledge/documents.lance/_versions/12.manifest +0 -0
  186. package/.ctxman/self-knowledge/documents.lance/_versions/13.manifest +0 -0
  187. package/.ctxman/self-knowledge/documents.lance/_versions/14.manifest +0 -0
  188. package/.ctxman/self-knowledge/documents.lance/_versions/15.manifest +0 -0
  189. package/.ctxman/self-knowledge/documents.lance/_versions/16.manifest +0 -0
  190. package/.ctxman/self-knowledge/documents.lance/_versions/17.manifest +0 -0
  191. package/.ctxman/self-knowledge/documents.lance/_versions/18.manifest +0 -0
  192. package/.ctxman/self-knowledge/documents.lance/_versions/19.manifest +0 -0
  193. package/.ctxman/self-knowledge/documents.lance/_versions/2.manifest +0 -0
  194. package/.ctxman/self-knowledge/documents.lance/_versions/20.manifest +0 -0
  195. package/.ctxman/self-knowledge/documents.lance/_versions/21.manifest +0 -0
  196. package/.ctxman/self-knowledge/documents.lance/_versions/3.manifest +0 -0
  197. package/.ctxman/self-knowledge/documents.lance/_versions/4.manifest +0 -0
  198. package/.ctxman/self-knowledge/documents.lance/_versions/5.manifest +0 -0
  199. package/.ctxman/self-knowledge/documents.lance/_versions/6.manifest +0 -0
  200. package/.ctxman/self-knowledge/documents.lance/_versions/7.manifest +0 -0
  201. package/.ctxman/self-knowledge/documents.lance/_versions/8.manifest +0 -0
  202. package/.ctxman/self-knowledge/documents.lance/_versions/9.manifest +0 -0
  203. package/.ctxman/self-knowledge/documents.lance/data/000010010011011101000010de8621405d9d5cb06540dbf02c.lance +0 -0
  204. package/.ctxman/self-knowledge/documents.lance/data/0000101001011100011000003fb62a4aa8978974075c144f1c.lance +0 -0
  205. package/.ctxman/self-knowledge/documents.lance/data/000011010000100111001011c6cc824d628f3bb1f4e04cd5d0.lance +0 -0
  206. package/.ctxman/self-knowledge/documents.lance/data/001000001000011001011100d8f74349ada090b332cd1df669.lance +0 -0
  207. package/.ctxman/self-knowledge/documents.lance/data/01000001111010001000010045ab44467bb1e7e4d8ae06bd58.lance +0 -0
  208. package/.ctxman/self-knowledge/documents.lance/data/01000101101110111110011131ef904241a89e1ffb6ddaf8e3.lance +0 -0
  209. package/.ctxman/self-knowledge/documents.lance/data/010010110000010000011010fbfcc145c6a8a827b28057a07e.lance +0 -0
  210. package/.ctxman/self-knowledge/documents.lance/data/1000011000101010001110003fb4f045c3835982efa1405324.lance +0 -0
  211. package/.ctxman/self-knowledge/documents.lance/data/100111011101110111101011f4396b410fa06af3b028d204e6.lance +0 -0
  212. package/.ctxman/self-knowledge/documents.lance/data/100111100111101000001100596b174e00995d6cc2969cc8dc.lance +0 -0
  213. package/.ctxman/self-knowledge/documents.lance/data/1010001100011000110010102ea16b484d887c7c9725feffd6.lance +0 -0
  214. package/.ctxman/self-knowledge/documents.lance/data/101101100010101111110111c5230944d9b3af7301b6392dda.lance +0 -0
  215. package/.ctxman/self-knowledge/documents.lance/data/1011011100100111111110105892bf41b5a530fedd7cf516f1.lance +0 -0
  216. package/.ctxman/self-knowledge/documents.lance/data/10111110000010001011110164e8774742bade87b483c10f36.lance +0 -0
  217. package/.ctxman/self-knowledge/documents.lance/data/1100000100010110001110103ffaca40a2a42c85067ebaf9ae.lance +0 -0
  218. package/.ctxman/self-knowledge/documents.lance/data/110000010001110110101110664bde4c76a78dfc14b05e83b8.lance +0 -0
  219. package/.ctxman/self-knowledge/documents.lance/data/11001100111000010100110050645c4281b1f35b2022234f7f.lance +0 -0
  220. package/.ctxman/self-knowledge/documents.lance/data/1101111011001100110111017214924a368f5719eab2246619.lance +0 -0
  221. package/.ctxman/self-knowledge/documents.lance/data/1110000111001010001000002150c24dcab653d39d07032e63.lance +0 -0
  222. package/.ctxman/self-knowledge/documents.lance/data/1110100000000100000011014d20534967a18b5c87721659ff.lance +0 -0
  223. package/.ctxman/self-knowledge/documents.lance/data/11110101111010000101011135a0114ef2b35dc6f1297a3381.lance +0 -0
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Ownership of a target from `git blame --line-porcelain`: who wrote which share of the
3
+ * lines, and when each author last touched them
4
+ */
5
+
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import { GitClient } from '../integrations/git/GitClient.js';
9
+ import { tryGit, utcDate } from './git.js';
10
+
11
+ const UNCOMMITTED = /^0+$/;
12
+
13
+ /**
14
+ * Blame summary for a line range (or the whole file)
15
+ * @param {string} top - Work tree root
16
+ * @param {object} query
17
+ * @param {string} query.path - Forward-slash path relative to `top`
18
+ * @param {number} [query.startLine]
19
+ * @param {number} [query.endLine]
20
+ * @param {string} [query.rev] - Blame this revision instead of the working tree
21
+ * @returns {{startLine: number|null, endLine: number|null, totalLines: number,
22
+ * lastChanged: string|null, authors: Array<{name: string, lines: number, share: number,
23
+ * commits: number, lastTouched: string|null}>}|null} null when blame fails
24
+ */
25
+ export function collectOwnership(top, query) {
26
+ const args = ['blame', '--line-porcelain', '-w'];
27
+ if (query.startLine) args.push('-L', `${query.startLine},${query.endLine}`);
28
+ if (query.rev) args.push(query.rev);
29
+ args.push('--', query.path);
30
+
31
+ // Honour the conventional list of formatting-only commits, when the project has one
32
+ const ignoreRevs = path.join(top, '.git-blame-ignore-revs');
33
+ let output = null;
34
+ if (fs.existsSync(ignoreRevs)) {
35
+ output = tryGit(top, [
36
+ args[0],
37
+ '--ignore-revs-file',
38
+ '.git-blame-ignore-revs',
39
+ ...args.slice(1),
40
+ ]);
41
+ }
42
+ output ??= tryGit(top, args);
43
+ if (output === null) return null;
44
+
45
+ // GitClient knows the porcelain format: one entry per line with commit, author, time
46
+ const lines = GitClient.prototype.parseBlameOutput.call(null, output);
47
+ const authors = new Map();
48
+ let lastChanged = 0;
49
+
50
+ for (const line of lines) {
51
+ const uncommitted = UNCOMMITTED.test(line.commit ?? '');
52
+ const name = uncommitted ? '(uncommitted)' : line.author || '(unknown)';
53
+ const entry = authors.get(name) ?? { name, lines: 0, commits: new Set(), lastTouched: 0 };
54
+ entry.lines++;
55
+ if (!uncommitted) {
56
+ entry.commits.add(line.commit);
57
+ entry.lastTouched = Math.max(entry.lastTouched, line.timestamp || 0);
58
+ lastChanged = Math.max(lastChanged, line.timestamp || 0);
59
+ }
60
+ authors.set(name, entry);
61
+ }
62
+
63
+ const total = lines.length;
64
+ return {
65
+ startLine: query.startLine ?? null,
66
+ endLine: query.endLine ?? null,
67
+ totalLines: total,
68
+ lastChanged: utcDate(lastChanged),
69
+ authors: [...authors.values()]
70
+ .map((entry) => ({
71
+ name: entry.name,
72
+ lines: entry.lines,
73
+ share: total ? Math.round((entry.lines / total) * 1000) / 1000 : 0,
74
+ commits: entry.commits.size,
75
+ lastTouched: utcDate(entry.lastTouched),
76
+ }))
77
+ .sort((a, b) => b.lines - a.lines || a.name.localeCompare(b.name)),
78
+ };
79
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Path rules for `ctxman why`: keeping targets inside the project, applying the
3
+ * project's ignore files, and classifying files (tests, docs, ADRs, changelogs)
4
+ */
5
+
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import { Scanner } from '../core/Scanner.js';
9
+ import FileUtils from '../utils/file-utils.js';
10
+ import { WhyError } from './errors.js';
11
+
12
+ /** Extensions searched as prose: every line that mentions a term counts */
13
+ export const DOC_EXTENSIONS = new Set([
14
+ '.md',
15
+ '.markdown',
16
+ '.mdx',
17
+ '.adoc',
18
+ '.asciidoc',
19
+ '.asc',
20
+ '.rst',
21
+ '.txt',
22
+ ]);
23
+
24
+ /**
25
+ * Resolve `file` (relative to `base`, or absolute) and check that it stays inside `root`,
26
+ * also after following symlinks
27
+ * @param {string} root - Real path of the project root
28
+ * @param {string} base - Directory relative paths start from
29
+ * @param {string} file
30
+ * @param {object} [options]
31
+ * @param {boolean} [options.mustExist=true] - false: a path that only exists in history
32
+ * @param {string} [options.label='the repository'] - What `root` is, for error messages
33
+ * @returns {{absolute: string, relative: string}} relative uses the OS separator
34
+ */
35
+ export function resolveInsideRoot(
36
+ root,
37
+ base,
38
+ file,
39
+ { mustExist = true, label = 'the repository' } = {}
40
+ ) {
41
+ if (typeof file !== 'string' || file.trim() === '' || file.includes('\0')) {
42
+ throw new WhyError('INVALID_TARGET', 'A file path is required');
43
+ }
44
+ const absolute = path.resolve(base, file);
45
+ // The root itself, or a path above it (`..foo` is a valid name inside it)
46
+ const outside = (candidate) => {
47
+ const relative = path.relative(root, candidate);
48
+ return (
49
+ relative === '' ||
50
+ relative === '..' ||
51
+ relative.startsWith(`..${path.sep}`) ||
52
+ path.isAbsolute(relative)
53
+ );
54
+ };
55
+ if (outside(absolute)) {
56
+ throw new WhyError('PATH_OUTSIDE_ROOT', `Path is outside ${label}: ${file}`);
57
+ }
58
+
59
+ const stat = fs.statSync(absolute, { throwIfNoEntry: false });
60
+ if (!stat && !mustExist) {
61
+ return { absolute, relative: path.relative(root, absolute) };
62
+ }
63
+ if (!stat) {
64
+ throw new WhyError('FILE_NOT_FOUND', `File not found: ${file}`);
65
+ }
66
+ if (!stat.isFile()) {
67
+ throw new WhyError('NOT_A_FILE', `Not a file: ${file}`);
68
+ }
69
+ const real = fs.realpathSync(absolute);
70
+ if (outside(real)) {
71
+ throw new WhyError('PATH_OUTSIDE_ROOT', `Path leads outside ${label}: ${file}`);
72
+ }
73
+
74
+ const relative = path.relative(root, real);
75
+ if (relative.split(path.sep)[0] === '.git') {
76
+ throw new WhyError('PATH_OUTSIDE_ROOT', `Path is inside the .git directory: ${file}`);
77
+ }
78
+ return { absolute: real, relative };
79
+ }
80
+
81
+ /**
82
+ * The scanner's rules as a predicate on project-relative paths: .gitignore,
83
+ * .contextignore / .contextinclude and the always-skipped directories, applied to every
84
+ * ancestor directory the way a scan would meet them
85
+ * @param {string} root
86
+ * @returns {(relativePath: string) => boolean} true when the path is excluded
87
+ */
88
+ export function createPathFilter(root) {
89
+ const scanner = new Scanner(root);
90
+ // Load now: an invalid export policy fails the command instead of being skipped
91
+ scanner.getPolicy();
92
+ const cache = new Map();
93
+
94
+ const excludedDirectory = (relativeDir) => {
95
+ if (cache.has(relativeDir)) return cache.get(relativeDir);
96
+ const name = relativeDir.split('/').pop();
97
+ const parent = relativeDir.includes('/')
98
+ ? relativeDir.slice(0, relativeDir.lastIndexOf('/'))
99
+ : '';
100
+ const excluded =
101
+ (parent !== '' && excludedDirectory(parent)) ||
102
+ FileUtils.isSkippedDirectory(name) ||
103
+ scanner.shouldIgnore(
104
+ relativeDir.split('/').join(path.sep),
105
+ path.join(root, ...relativeDir.split('/'))
106
+ );
107
+ cache.set(relativeDir, excluded);
108
+ return excluded;
109
+ };
110
+
111
+ const isExcluded = (relativePath) => {
112
+ const posix = relativePath.split(path.sep).join('/');
113
+ // Denied by the export policy: left out like an ignored file, and never named
114
+ if (scanner.isDenied(posix)) return true;
115
+ const parent = posix.includes('/') ? posix.slice(0, posix.lastIndexOf('/')) : '';
116
+ if (parent !== '' && excludedDirectory(parent)) return true;
117
+ return scanner.shouldIgnore(
118
+ posix.split('/').join(path.sep),
119
+ path.join(root, ...posix.split('/'))
120
+ );
121
+ };
122
+ isExcluded.isDenied = (relativePath) => scanner.isDenied(relativePath.split(path.sep).join('/'));
123
+ return isExcluded;
124
+ }
125
+
126
+ /**
127
+ * Whether a file holds binary data (a NUL byte in its first 8000 bytes, git's heuristic)
128
+ * @param {Buffer} buffer
129
+ * @returns {boolean}
130
+ */
131
+ export function isBinaryBuffer(buffer) {
132
+ return buffer.subarray(0, 8000).includes(0);
133
+ }
134
+
135
+ /**
136
+ * Whether ctxman may print this file's content: the scanner's text-file rule, which
137
+ * also keeps secret-bearing files such as .env or *.pem out of exports
138
+ * @param {string} relativePath
139
+ * @returns {boolean}
140
+ */
141
+ export function isExportableText(relativePath) {
142
+ return FileUtils.isText(relativePath);
143
+ }
144
+
145
+ /**
146
+ * Test files by common conventions (test/ and spec/ folders, *.test.*, *_test.go,
147
+ * test_*.py, FooTest.java, ...)
148
+ * @param {string} relativePath - Forward-slash path
149
+ * @returns {boolean}
150
+ */
151
+ export function isTestFile(relativePath) {
152
+ const posix = relativePath.split(path.sep).join('/');
153
+ const base = posix.split('/').pop();
154
+ return (
155
+ /(^|\/)(test|tests|__tests__|spec|specs|testing)\//i.test(posix) ||
156
+ /[._-](test|spec)s?\.[^./]+$/i.test(base) ||
157
+ /^test_.+\.py$/i.test(base) ||
158
+ /(Test|Tests|Spec|IT)\.(java|kt|kts|cs|scala|swift|php|groovy)$/.test(base)
159
+ );
160
+ }
161
+
162
+ /**
163
+ * git pathspecs that select the files isTestFile() accepts (a superset; results are
164
+ * filtered with isTestFile again)
165
+ */
166
+ export const TEST_PATHSPECS = [
167
+ ':(glob)**/test/**',
168
+ ':(glob)**/tests/**',
169
+ ':(glob)**/__tests__/**',
170
+ ':(glob)**/spec/**',
171
+ ':(glob)**/specs/**',
172
+ ':(glob)**/testing/**',
173
+ ':(glob)**/*.test.*',
174
+ ':(glob)**/*.spec.*',
175
+ ':(glob)**/*_test.*',
176
+ ':(glob)**/*-test.*',
177
+ ':(glob)**/*.tests.*',
178
+ ':(glob)**/test_*',
179
+ ':(glob)**/*Test.*',
180
+ ':(glob)**/*Tests.*',
181
+ ':(glob)**/*Spec.*',
182
+ ':(glob)**/*IT.*',
183
+ ];
184
+
185
+ /**
186
+ * Kind of prose file: 'adr' (architecture decision records), 'changelog', 'doc', or
187
+ * null for anything else
188
+ * @param {string} relativePath
189
+ * @returns {'adr'|'changelog'|'doc'|null}
190
+ */
191
+ export function classifyTextFile(relativePath) {
192
+ const posix = relativePath.split(path.sep).join('/');
193
+ const base = posix.split('/').pop();
194
+ const ext = path.extname(base).toLowerCase();
195
+ const prose = DOC_EXTENSIONS.has(ext);
196
+
197
+ if (
198
+ /(^|\/)(adr|adrs|decisions|architecture[-_]decisions?)\//i.test(posix) ||
199
+ /(^|[^a-z])adr([^a-z]|$)/i.test(base)
200
+ ) {
201
+ return prose || ext === '' ? 'adr' : null;
202
+ }
203
+ if (/^(changelog|changes|history|news|releases?)(\.|$)/i.test(base)) {
204
+ return 'changelog';
205
+ }
206
+ return prose ? 'doc' : null;
207
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * References mined from commit messages: issue and pull request numbers, ticket keys,
3
+ * CVE ids, URLs and Co-authored-by / Reviewed-by trailers, with web links when `origin`
4
+ * is a GitHub, GitLab or Bitbucket remote
5
+ */
6
+
7
+ // Upper-case words followed by a number that are not ticket keys
8
+ const NOT_TICKETS = new Set([
9
+ 'AES',
10
+ 'ANSI',
11
+ 'ASCII',
12
+ 'BASE',
13
+ 'BSD',
14
+ 'CC',
15
+ 'CP',
16
+ 'CRC',
17
+ 'CVE',
18
+ 'CWE',
19
+ 'DES',
20
+ 'ECMA',
21
+ 'ES',
22
+ 'GH',
23
+ 'GPL',
24
+ 'HTTP',
25
+ 'HTTPS',
26
+ 'IEC',
27
+ 'IEEE',
28
+ 'IPV',
29
+ 'ISO',
30
+ 'LGPL',
31
+ 'AGPL',
32
+ 'MD',
33
+ 'MPL',
34
+ 'PEP',
35
+ 'POSIX',
36
+ 'RFC',
37
+ 'RSA',
38
+ 'SHA',
39
+ 'SSL',
40
+ 'TLS',
41
+ 'UCS',
42
+ 'UTF',
43
+ 'WCAG',
44
+ 'WIN',
45
+ 'X',
46
+ ]);
47
+
48
+ const KIND_RANK = { pr: 2, mr: 2, issue: 1 };
49
+
50
+ /**
51
+ * Parse a remote URL (https, ssh, scp-like `git@host:owner/repo.git`) into a provider and
52
+ * a web base URL. Credentials and ports of ssh remotes are dropped
53
+ * @param {string|null} url
54
+ * @returns {{provider: 'github'|'gitlab'|'bitbucket'|null, host: string, repoPath: string,
55
+ * webUrl: string}|null} null for local paths and unparsable URLs
56
+ */
57
+ export function parseRemote(url) {
58
+ if (!url || typeof url !== 'string') return null;
59
+ const trimmed = url.trim();
60
+ let host;
61
+ let port = '';
62
+ let repoPath;
63
+
64
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
65
+ let parsed;
66
+ try {
67
+ parsed = new URL(trimmed);
68
+ } catch (_error) {
69
+ return null;
70
+ }
71
+ if (parsed.protocol === 'file:') return null;
72
+ host = parsed.hostname;
73
+ if (/^https?:$/.test(parsed.protocol) && parsed.port) port = `:${parsed.port}`;
74
+ repoPath = decodeURIComponent(parsed.pathname);
75
+ } else {
76
+ // scp-like syntax; a single letter before ':' is a Windows drive
77
+ const scp = trimmed.match(/^(?:[^@/\s]+@)?([^:/\s]{2,}):(?!\/\/)(.+)$/);
78
+ if (!scp) return null;
79
+ host = scp[1];
80
+ repoPath = scp[2];
81
+ }
82
+
83
+ repoPath = repoPath.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '');
84
+ if (!host || !repoPath.includes('/')) return null;
85
+
86
+ const provider = /github/i.test(host)
87
+ ? 'github'
88
+ : /gitlab/i.test(host)
89
+ ? 'gitlab'
90
+ : /bitbucket/i.test(host)
91
+ ? 'bitbucket'
92
+ : null;
93
+ if (provider === 'github' || provider === 'bitbucket') {
94
+ repoPath = repoPath.split('/').slice(0, 2).join('/');
95
+ }
96
+ // ssh.github.com / altssh.gitlab.com serve ssh only
97
+ const webHost = host.replace(/^(alt)?ssh\./i, '').toLowerCase();
98
+ return { provider, host: webHost, repoPath, webUrl: `https://${webHost}${port}/${repoPath}` };
99
+ }
100
+
101
+ /**
102
+ * Web link for an issue / pull request / merge request
103
+ * @param {ReturnType<typeof parseRemote>} remote
104
+ * @param {string|null} repoPath - Another repository on the same host (owner/repo#12)
105
+ * @param {number} number
106
+ * @param {string} kind
107
+ * @returns {string|null}
108
+ */
109
+ export function issueLink(remote, repoPath, number, kind) {
110
+ if (!remote?.provider) return null;
111
+ const base = repoPath
112
+ ? remote.webUrl.slice(0, remote.webUrl.length - remote.repoPath.length) + repoPath
113
+ : remote.webUrl;
114
+ const isChange = kind === 'pr' || kind === 'mr';
115
+ switch (remote.provider) {
116
+ case 'github':
117
+ // GitHub redirects /issues/N to /pull/N for pull requests
118
+ return `${base}/${isChange ? 'pull' : 'issues'}/${number}`;
119
+ case 'gitlab':
120
+ return `${base}/-/${isChange ? 'merge_requests' : 'issues'}/${number}`;
121
+ case 'bitbucket':
122
+ return `${base}/${isChange ? 'pull-requests' : 'issues'}/${number}`;
123
+ default:
124
+ return null;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Remove user:password@ from a URL
130
+ * @param {string} url
131
+ * @returns {string}
132
+ */
133
+ function stripCredentials(url) {
134
+ return url.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/i, '$1');
135
+ }
136
+
137
+ /**
138
+ * Collect references from commits (newest first). Each entry lists the short hashes of
139
+ * the commits that mention it, in history order
140
+ * @param {Array<{shortSha: string, subject: string, body: string}>} commits
141
+ * @param {ReturnType<typeof parseRemote>} remote
142
+ * @returns {{issues: object[], tickets: object[], cves: object[], urls: object[],
143
+ * coAuthors: object[], reviewers: object[]}}
144
+ */
145
+ export function mineReferences(commits, remote) {
146
+ const issues = new Map();
147
+ const tickets = new Map();
148
+ const cves = new Map();
149
+ const urls = new Map();
150
+ const coAuthors = new Map();
151
+ const reviewers = new Map();
152
+
153
+ const note = (map, key, entry, sha) => {
154
+ const existing = map.get(key);
155
+ if (!existing) {
156
+ map.set(key, { ...entry, commits: [sha] });
157
+ return;
158
+ }
159
+ if (!existing.commits.includes(sha)) existing.commits.push(sha);
160
+ if (entry.closes) existing.closes = true;
161
+ if (entry.kind && (KIND_RANK[entry.kind] ?? 0) > (KIND_RANK[existing.kind] ?? 0)) {
162
+ existing.kind = entry.kind;
163
+ existing.url = entry.url;
164
+ }
165
+ };
166
+
167
+ // kind: issue, pr (pull request) or mr (GitLab merge request); closes: a closing
168
+ // keyword (fixes / closes / resolves) referred to it
169
+ const addIssue = (repoPath, number, kind, sha, closes = false) => {
170
+ if (!/^[1-9]\d{0,8}$/.test(number)) return;
171
+ const id = `${repoPath ?? ''}#${number}`;
172
+ note(
173
+ issues,
174
+ id,
175
+ {
176
+ id,
177
+ number: Number(number),
178
+ repo: repoPath ?? null,
179
+ kind,
180
+ closes,
181
+ url: issueLink(remote, repoPath, number, kind),
182
+ },
183
+ sha
184
+ );
185
+ };
186
+
187
+ for (const commit of commits) {
188
+ const sha = commit.shortSha;
189
+ const message = `${commit.subject}\n\n${commit.body}`;
190
+
191
+ // URLs first, then blank them out so their fragments and paths are not read as refs
192
+ let text = message.replace(/\bhttps?:\/\/[^\s<>"'`]+/gi, (raw) => {
193
+ const url = stripCredentials(raw.replace(/[).,;:!?\]}>]+$/, ''));
194
+ note(urls, url, { url }, sha);
195
+ return ' '.repeat(raw.length);
196
+ });
197
+
198
+ for (const match of text.matchAll(/^\s*(Co-authored-by|Reviewed-by)\s*:\s*(.+?)\s*$/gim)) {
199
+ const value = match[2];
200
+ const map = match[1].toLowerCase() === 'co-authored-by' ? coAuthors : reviewers;
201
+ note(map, value.toLowerCase(), { value }, sha);
202
+ }
203
+ // Trailers carry e-mail addresses, not references
204
+ text = text.replace(/^\s*[\w-]+-by\s*:.*$/gim, '');
205
+
206
+ // Pull / merge requests: squash suffix "(#45)", merge commits, GitLab "!45"
207
+ const squash = commit.subject.match(/\(#(\d+)\)\s*$/);
208
+ if (squash) addIssue(null, squash[1], remote?.provider === 'gitlab' ? 'issue' : 'pr', sha);
209
+ for (const match of text.matchAll(/\b(?:Merge pull request|pull request) #(\d+)/gi)) {
210
+ addIssue(null, match[1], 'pr', sha);
211
+ }
212
+ if (remote?.provider === 'gitlab') {
213
+ for (const match of text.matchAll(/(?:^|[\s(])(?:([\w.-]+(?:\/[\w.-]+)+))?!(\d+)\b/g)) {
214
+ addIssue(match[1] ?? null, match[2], 'mr', sha);
215
+ }
216
+ }
217
+
218
+ for (const match of text.matchAll(
219
+ /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+(?:([\w.-]+\/[\w.-]+))?#(\d+)\b/gi
220
+ )) {
221
+ addIssue(match[1] ?? null, match[2], 'issue', sha, true);
222
+ }
223
+ for (const match of text.matchAll(/(?:^|[^\w&/#])(?:([\w.-]+\/[\w.-]+))?#(\d+)(?!\w)/g)) {
224
+ addIssue(match[1] ?? null, match[2], 'issue', sha);
225
+ }
226
+ for (const match of text.matchAll(/\bGH-(\d+)\b/g)) {
227
+ addIssue(null, match[1], 'issue', sha);
228
+ }
229
+
230
+ for (const match of text.matchAll(/\bCVE-\d{4}-\d{4,}\b/g)) {
231
+ note(cves, match[0], { id: match[0] }, sha);
232
+ }
233
+ for (const match of text.matchAll(/(?<![\w-])([A-Z][A-Z0-9]{1,9})-(\d{1,7})(?![\w])/g)) {
234
+ if (NOT_TICKETS.has(match[1]) || /^0/.test(match[2])) continue;
235
+ note(tickets, match[0], { id: match[0] }, sha);
236
+ }
237
+ }
238
+
239
+ return {
240
+ issues: [...issues.values()],
241
+ tickets: [...tickets.values()],
242
+ cves: [...cves.values()],
243
+ urls: [...urls.values()],
244
+ coAuthors: [...coAuthors.values()],
245
+ reviewers: [...reviewers.values()],
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Ids of the references a single commit mentions (for the history section)
251
+ * @param {ReturnType<typeof mineReferences>} references
252
+ * @param {string} shortSha
253
+ * @returns {string[]}
254
+ */
255
+ export function referencesOf(references, shortSha) {
256
+ return [...references.issues, ...references.tickets, ...references.cves]
257
+ .filter((entry) => entry.commits.includes(shortSha))
258
+ .map((entry) => entry.id);
259
+ }