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.
- package/.ctxman/llm-profiles.json +1067 -39
- package/.ctxman/profiles/backend-team.json +13 -0
- package/.ctxman/profiles/default.json +12 -0
- package/.ctxman/profiles/devops-team.json +21 -0
- package/.ctxman/profiles/frontend-team.json +20 -0
- package/.ctxman/templates/bug-fix.json +17 -0
- package/.ctxman/templates/code-review.json +9 -0
- package/.ctxman/templates/documentation.json +9 -0
- package/.ctxman/templates/feature.json +9 -0
- package/.ctxman/templates/full-context.json +9 -0
- package/.ctxman/templates/refactoring.json +18 -0
- package/.ctxman/templates/testing.json +9 -0
- package/.ctxman/wizard-profiles/security-audit/profile.json +7 -1
- package/LICENSE +21 -0
- package/README.md +380 -136
- package/bin/cli.js +1880 -529
- package/bin/cm-ask.js +18 -13
- package/bin/cm-audit.js +124 -0
- package/bin/cm-count.js +151 -0
- package/bin/cm-gitingest.js +128 -115
- package/bin/cm-map.js +242 -0
- package/bin/cm-mcp.js +293 -0
- package/bin/cm-pack.js +405 -0
- package/bin/cm-policy.js +170 -0
- package/bin/cm-pr.js +248 -0
- package/bin/cm-search.js +178 -0
- package/bin/cm-update.js +128 -128
- package/bin/cm-why.js +197 -0
- package/bin/mcp-server.js +8 -24
- package/ctxman.js +237 -183
- package/examples/README.md +19 -4
- package/examples/github-actions/ctxman-check.yml +49 -0
- package/examples/wizard-profiles/security-audit/profile.json +7 -1
- package/index.d.ts +1422 -0
- package/index.js +55 -31
- package/lib/agents/pr-failure-agent.js +48 -45
- package/lib/analyzers/ai-suggester.js +451 -0
- package/lib/analyzers/dependency-scanner.js +505 -0
- package/lib/analyzers/duplicate-detector.js +264 -0
- package/lib/analyzers/go-method-analyzer.js +151 -110
- package/lib/analyzers/import-tracker.js +431 -0
- package/lib/analyzers/method-analyzer.js +1055 -434
- package/lib/analyzers/performance-dashboard.js +683 -0
- package/lib/analyzers/token-calculator.js +895 -504
- package/lib/analyzers/unused-exports-detector.js +336 -0
- package/lib/api/mcp/install.js +424 -0
- package/lib/api/mcp/prompts.js +281 -281
- package/lib/api/mcp/resources.js +300 -241
- package/lib/api/mcp/sandbox.js +89 -0
- package/lib/api/mcp/server.js +675 -312
- package/lib/api/mcp/start.js +125 -0
- package/lib/api/mcp/transports/HttpSseTransport.js +60 -52
- package/lib/api/rest/server.js +105 -28
- package/lib/cache/CacheManager.js +9 -17
- package/lib/core/Analyzer.js +16 -10
- package/lib/core/ContextBuilder.js +19 -28
- package/lib/core/Reporter.js +15 -14
- package/lib/core/Scanner.js +81 -12
- package/lib/count/index.js +100 -0
- package/lib/formatters/format-registry.js +394 -372
- package/lib/formatters/gitingest-formatter.js +656 -621
- package/lib/formatters/toon-formatter-v1.3.js +637 -632
- package/lib/formatters/toon-formatter.js +239 -240
- package/lib/integrations/git/BlameTracker.js +4 -4
- package/lib/integrations/git/DiffAnalyzer.js +38 -31
- package/lib/integrations/git/GitClient.js +71 -53
- package/lib/lsp/server.js +311 -0
- package/lib/map/graph.js +299 -0
- package/lib/map/imports.js +233 -0
- package/lib/map/index.js +33 -0
- package/lib/map/rank.js +253 -0
- package/lib/map/render.js +346 -0
- package/lib/map/resolver.js +574 -0
- package/lib/pack/budget.js +111 -0
- package/lib/pack/code-lexer.js +360 -0
- package/lib/pack/compress.js +414 -0
- package/lib/pack/index.js +354 -0
- package/lib/pack/renderers/common.js +134 -0
- package/lib/pack/renderers/index.js +24 -0
- package/lib/pack/renderers/json.js +47 -0
- package/lib/pack/renderers/markdown.js +54 -0
- package/lib/pack/renderers/plain.js +38 -0
- package/lib/pack/renderers/xml.js +85 -0
- package/lib/pack/select.js +177 -0
- package/lib/pack/split.js +182 -0
- package/lib/pack/tree.js +35 -0
- package/lib/parsers/gitignore-parser.js +113 -107
- package/lib/parsers/method-filter-parser.js +50 -33
- package/lib/plugins/ExporterPlugin.js +3 -3
- package/lib/plugins/LanguagePlugin.js +3 -3
- package/lib/plugins/PluginManager.js +7 -13
- package/lib/policy/audit.js +294 -0
- package/lib/policy/enforce.js +125 -0
- package/lib/policy/ignore-files.js +169 -0
- package/lib/policy/index.js +52 -0
- package/lib/policy/policy.js +489 -0
- package/lib/pr/diff-parser.js +267 -0
- package/lib/pr/method-mapper.js +148 -0
- package/lib/pr/pr-context.js +829 -0
- package/lib/pr/pr-git.js +149 -0
- package/lib/pr/pr-renderer.js +644 -0
- package/lib/pr/related-tests.js +296 -0
- package/lib/rag/DataSourcePlugin.js +5 -5
- package/lib/rag/EmbeddingProvider.js +19 -8
- package/lib/rag/Indexer.js +14 -5
- package/lib/rag/VectorStoreFactory.js +3 -3
- package/lib/rag/sources/FileDataSource.js +6 -6
- package/lib/rag/stores/BaseVectorStore.js +5 -5
- package/lib/rag/stores/ChromaDBStore.js +10 -10
- package/lib/rag/stores/LanceDBStore.js +29 -19
- package/lib/rag/stores/PineconeStore.js +11 -9
- package/lib/search/bm25.js +433 -0
- package/lib/search/code-units.js +201 -0
- package/lib/search/symbols.js +189 -0
- package/lib/ui/dashboard.js +120 -89
- package/lib/ui/index.js +1 -6
- package/lib/ui/init-wizard-ui.js +550 -0
- package/lib/ui/progress-bar.js +94 -88
- package/lib/ui/select-input.js +40 -32
- package/lib/ui/template-selector.js +99 -0
- package/lib/ui/wizard.js +374 -329
- package/lib/utils/claude-token-counter.js +151 -0
- package/lib/utils/cli-output.js +17 -0
- package/lib/utils/clipboard-utils.js +49 -50
- package/lib/utils/config-utils.js +81 -66
- package/lib/utils/context-versioning.js +532 -0
- package/lib/utils/error-handler.js +112 -112
- package/lib/utils/file-utils.js +153 -69
- package/lib/utils/format-converter.js +356 -283
- package/lib/utils/git-utils.js +366 -294
- package/lib/utils/llm-cost-estimator.js +281 -0
- package/lib/utils/llm-detector.js +242 -78
- package/lib/utils/logger.js +243 -237
- package/lib/utils/messages.js +355 -0
- package/lib/utils/multi-repo-manager.js +364 -0
- package/lib/utils/profile-manager.js +380 -0
- package/lib/utils/safe-read.js +61 -0
- package/lib/utils/secret-redactor.js +61 -0
- package/lib/utils/snapshot-manager.js +679 -0
- package/lib/utils/template-manager.js +378 -0
- package/lib/utils/token-utils.js +201 -170
- package/lib/utils/tokenizer-adapter.js +421 -377
- package/lib/utils/updater.js +539 -506
- package/lib/watch/FileWatcher.js +12 -8
- package/lib/watch/IncrementalAnalyzer.js +7 -7
- package/lib/why/coupling.js +81 -0
- package/lib/why/errors.js +23 -0
- package/lib/why/git.js +169 -0
- package/lib/why/history.js +108 -0
- package/lib/why/index.js +720 -0
- package/lib/why/ownership.js +79 -0
- package/lib/why/paths.js +207 -0
- package/lib/why/references.js +259 -0
- package/lib/why/render.js +722 -0
- package/lib/why/search.js +358 -0
- package/lib/why/target.js +389 -0
- package/lib/wizards/config-generator.js +255 -0
- package/lib/wizards/project-detector.js +571 -0
- package/package.json +44 -10
- package/.ctxman/logs/ctxman-2026-07-02.log +0 -156
- package/.ctxman/self-knowledge/documents.lance/_transactions/0-b3dd1558-f95b-44b9-a154-18e8c330ae7a.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/1-44bff6d4-e552-4010-a769-e09ad822698d.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/10-37260794-5947-4ecb-b4c8-d732f58f40f1.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/11-0d52a883-5518-463c-9b6d-986c3af89dfd.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/12-ae354646-52f2-49c9-90fb-2e6ed8932663.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/13-5d7d5e57-27da-498b-b734-091b9d38da13.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/14-558ce928-810b-4252-a364-fdd1ee6f8a25.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/15-27e3a58f-8b64-43b8-a9f4-e3369446968b.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/16-a110c390-e5c8-4464-b976-bc7f5195fa15.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/17-97513225-53de-4216-b61a-6337bd0cc4ad.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/18-5ba450f0-c527-459c-bf4f-6d79b5e4285f.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/19-600d3a63-5225-49e9-b8b7-30e037b564ab.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/2-48a61b4e-92cf-4342-8376-914c1b99a9f9.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/20-121dd912-5410-410e-84ff-82c7a23604a0.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/3-bc6844ab-7ea3-4bb3-9ee1-538ce5664a9c.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/4-d5e1754f-dbb5-454e-a9be-2a7da8d7bcf9.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/5-4fae93df-0568-4b6f-8acf-8bef4bba6047.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/6-4b1b8b87-068e-49a3-ba2b-d8dae628b252.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/7-63d040fb-d2fa-4c0f-bed0-53411918a7b0.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/8-c5628cf6-264c-420f-aab8-bc96a9083591.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/9-c94570aa-5e53-4fcb-971f-cecc1d1d5492.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/1.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/10.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/11.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/12.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/13.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/14.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/15.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/16.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/17.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/18.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/19.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/2.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/20.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/21.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/3.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/4.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/5.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/6.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/7.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/8.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/9.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/000010010011011101000010de8621405d9d5cb06540dbf02c.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/0000101001011100011000003fb62a4aa8978974075c144f1c.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/000011010000100111001011c6cc824d628f3bb1f4e04cd5d0.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/001000001000011001011100d8f74349ada090b332cd1df669.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/01000001111010001000010045ab44467bb1e7e4d8ae06bd58.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/01000101101110111110011131ef904241a89e1ffb6ddaf8e3.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/010010110000010000011010fbfcc145c6a8a827b28057a07e.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1000011000101010001110003fb4f045c3835982efa1405324.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/100111011101110111101011f4396b410fa06af3b028d204e6.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/100111100111101000001100596b174e00995d6cc2969cc8dc.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1010001100011000110010102ea16b484d887c7c9725feffd6.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/101101100010101111110111c5230944d9b3af7301b6392dda.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1011011100100111111110105892bf41b5a530fedd7cf516f1.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/10111110000010001011110164e8774742bade87b483c10f36.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1100000100010110001110103ffaca40a2a42c85067ebaf9ae.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/110000010001110110101110664bde4c76a78dfc14b05e83b8.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/11001100111000010100110050645c4281b1f35b2022234f7f.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1101111011001100110111017214924a368f5719eab2246619.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1110000111001010001000002150c24dcab653d39d07032e63.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1110100000000100000011014d20534967a18b5c87721659ff.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/11110101111010000101011135a0114ef2b35dc6f1297a3381.lance +0 -0
package/lib/watch/FileWatcher.js
CHANGED
|
@@ -13,6 +13,7 @@ import fs from 'fs';
|
|
|
13
13
|
import path from 'path';
|
|
14
14
|
import { EventEmitter } from 'events';
|
|
15
15
|
import GitIgnoreParser from '../parsers/gitignore-parser.js';
|
|
16
|
+
import FileUtils from '../utils/file-utils.js';
|
|
16
17
|
import { getLogger } from '../utils/logger.js';
|
|
17
18
|
|
|
18
19
|
const logger = getLogger('FileWatcher');
|
|
@@ -26,7 +27,7 @@ export class FileWatcher extends EventEmitter {
|
|
|
26
27
|
debounce: 1000, // 1 second debounce
|
|
27
28
|
recursive: true,
|
|
28
29
|
ignorePatterns: [],
|
|
29
|
-
...options
|
|
30
|
+
...options,
|
|
30
31
|
};
|
|
31
32
|
|
|
32
33
|
// Initialize GitIgnoreParser with correct file paths
|
|
@@ -42,7 +43,7 @@ export class FileWatcher extends EventEmitter {
|
|
|
42
43
|
this.stats = {
|
|
43
44
|
totalChanges: 0,
|
|
44
45
|
ignoredChanges: 0,
|
|
45
|
-
errors: 0
|
|
46
|
+
errors: 0,
|
|
46
47
|
};
|
|
47
48
|
}
|
|
48
49
|
|
|
@@ -71,7 +72,6 @@ export class FileWatcher extends EventEmitter {
|
|
|
71
72
|
|
|
72
73
|
this.emit('watch:started', { path: this.rootPath });
|
|
73
74
|
logger.info('Watch mode started');
|
|
74
|
-
|
|
75
75
|
} catch (error) {
|
|
76
76
|
logger.error(`Failed to start watch: ${error.message}`);
|
|
77
77
|
this.emit('watch:error', { error });
|
|
@@ -153,7 +153,7 @@ export class FileWatcher extends EventEmitter {
|
|
|
153
153
|
path: filePath,
|
|
154
154
|
relativePath: relativePath,
|
|
155
155
|
timestamp: Date.now(),
|
|
156
|
-
exists: fs.existsSync(filePath)
|
|
156
|
+
exists: fs.existsSync(filePath),
|
|
157
157
|
};
|
|
158
158
|
|
|
159
159
|
// Add file stats if exists
|
|
@@ -162,7 +162,7 @@ export class FileWatcher extends EventEmitter {
|
|
|
162
162
|
const stats = fs.statSync(filePath);
|
|
163
163
|
event.size = stats.size;
|
|
164
164
|
event.modified = stats.mtime;
|
|
165
|
-
} catch (
|
|
165
|
+
} catch (_error) {
|
|
166
166
|
// Stats not available
|
|
167
167
|
}
|
|
168
168
|
}
|
|
@@ -176,8 +176,12 @@ export class FileWatcher extends EventEmitter {
|
|
|
176
176
|
* @returns {boolean}
|
|
177
177
|
*/
|
|
178
178
|
shouldIgnore(relativePath) {
|
|
179
|
+
// node_modules, .git and build output are skipped like in the scanners, .gitignore or not
|
|
180
|
+
if (relativePath.split(path.sep).some((part) => FileUtils.isSkippedDirectory(part))) {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
179
183
|
// Use GitIgnoreParser for ignore rules
|
|
180
|
-
return
|
|
184
|
+
return this.gitIgnore.isIgnored(path.join(this.rootPath, relativePath), relativePath);
|
|
181
185
|
}
|
|
182
186
|
|
|
183
187
|
/**
|
|
@@ -189,7 +193,7 @@ export class FileWatcher extends EventEmitter {
|
|
|
189
193
|
...this.stats,
|
|
190
194
|
isWatching: this.isWatching,
|
|
191
195
|
activeWatchers: this.watchers.length,
|
|
192
|
-
pendingDebounce: this.debounceTimers.size
|
|
196
|
+
pendingDebounce: this.debounceTimers.size,
|
|
193
197
|
};
|
|
194
198
|
}
|
|
195
199
|
|
|
@@ -200,7 +204,7 @@ export class FileWatcher extends EventEmitter {
|
|
|
200
204
|
this.stats = {
|
|
201
205
|
totalChanges: 0,
|
|
202
206
|
ignoredChanges: 0,
|
|
203
|
-
errors: 0
|
|
207
|
+
errors: 0,
|
|
204
208
|
};
|
|
205
209
|
}
|
|
206
210
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { EventEmitter } from 'events';
|
|
13
|
+
import path from 'path';
|
|
13
14
|
import { Analyzer } from '../core/Analyzer.js';
|
|
14
15
|
import { getLogger } from '../utils/logger.js';
|
|
15
16
|
|
|
@@ -21,7 +22,7 @@ export class IncrementalAnalyzer extends EventEmitter {
|
|
|
21
22
|
|
|
22
23
|
this.options = {
|
|
23
24
|
autoAnalyze: true,
|
|
24
|
-
...options
|
|
25
|
+
...options,
|
|
25
26
|
};
|
|
26
27
|
|
|
27
28
|
this.analyzer = new Analyzer(options);
|
|
@@ -52,7 +53,7 @@ export class IncrementalAnalyzer extends EventEmitter {
|
|
|
52
53
|
name: path.basename(changeEvent.path),
|
|
53
54
|
extension: path.extname(changeEvent.path),
|
|
54
55
|
size: changeEvent.size,
|
|
55
|
-
modified: changeEvent.modified
|
|
56
|
+
modified: changeEvent.modified,
|
|
56
57
|
};
|
|
57
58
|
|
|
58
59
|
const analysis = await this.analyzer.analyzeFile(fileInfo);
|
|
@@ -71,17 +72,16 @@ export class IncrementalAnalyzer extends EventEmitter {
|
|
|
71
72
|
file: changeEvent.relativePath,
|
|
72
73
|
analysis,
|
|
73
74
|
elapsed,
|
|
74
|
-
cached: false
|
|
75
|
+
cached: false,
|
|
75
76
|
});
|
|
76
77
|
|
|
77
78
|
logger.debug(`Analysis complete: ${changeEvent.relativePath} (${elapsed}ms)`);
|
|
78
79
|
}
|
|
79
|
-
|
|
80
80
|
} catch (error) {
|
|
81
81
|
logger.error(`Analysis failed for ${changeEvent.relativePath}: ${error.message}`);
|
|
82
82
|
this.emit('analysis:error', {
|
|
83
83
|
file: changeEvent.relativePath,
|
|
84
|
-
error
|
|
84
|
+
error,
|
|
85
85
|
});
|
|
86
86
|
}
|
|
87
87
|
}
|
|
@@ -114,7 +114,7 @@ export class IncrementalAnalyzer extends EventEmitter {
|
|
|
114
114
|
|
|
115
115
|
this.emit('file:deleted', {
|
|
116
116
|
file: changeEvent.relativePath,
|
|
117
|
-
oldTokens: oldAnalysis.tokens
|
|
117
|
+
oldTokens: oldAnalysis.tokens,
|
|
118
118
|
});
|
|
119
119
|
|
|
120
120
|
logger.debug(`File deleted from cache: ${changeEvent.relativePath}`);
|
|
@@ -198,7 +198,7 @@ export class IncrementalAnalyzer extends EventEmitter {
|
|
|
198
198
|
getCacheStats() {
|
|
199
199
|
return {
|
|
200
200
|
cachedFiles: this.fileCache.size,
|
|
201
|
-
cacheSize: this.fileCache.size * 1024 // Rough estimate
|
|
201
|
+
cacheSize: this.fileCache.size * 1024, // Rough estimate
|
|
202
202
|
};
|
|
203
203
|
}
|
|
204
204
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Co-change coupling: files that changed in the same commits as the target. A file that
|
|
3
|
+
* nearly always changes with the target is usually part of the same decision (a test, a
|
|
4
|
+
* schema, a caller, the docs), which makes it a strong "what else" signal
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { literalPathspec, runGit } from './git.js';
|
|
8
|
+
|
|
9
|
+
const RS = '\x1e';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Split `git log --format=<RS>%H --name-only` output into commits and their files
|
|
13
|
+
* @param {string} output
|
|
14
|
+
* @returns {Array<{sha: string, files: string[]}>}
|
|
15
|
+
*/
|
|
16
|
+
function parseNameOnly(output) {
|
|
17
|
+
return output
|
|
18
|
+
.split(RS)
|
|
19
|
+
.map((record) => record.split('\n').filter((line) => line !== ''))
|
|
20
|
+
.filter((lines) => lines.length > 0)
|
|
21
|
+
.map(([sha, ...files]) => ({ sha: sha.trim(), files }));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} top - Work tree root
|
|
26
|
+
* @param {object} query
|
|
27
|
+
* @param {string} query.path - Forward-slash path relative to `top`
|
|
28
|
+
* @param {number} query.window - Commits of the target to examine
|
|
29
|
+
* @param {number} [query.maxFilesPerCommit] - Larger commits are skipped as noise (default 50)
|
|
30
|
+
* @param {string} [query.rev]
|
|
31
|
+
* @param {(gitPath: string) => boolean} [query.keep] - Filter on candidate paths
|
|
32
|
+
* @returns {{window: number, targetChanges: number, skippedLargeCommits: number,
|
|
33
|
+
* files: Array<{path: string, count: number, confidence: number}>}}
|
|
34
|
+
*/
|
|
35
|
+
export function collectCoupling(top, query) {
|
|
36
|
+
const maxFiles = query.maxFilesPerCommit ?? 50;
|
|
37
|
+
const args = ['log', '--follow', '--no-merges', '-n', String(query.window)];
|
|
38
|
+
args.push(`--format=${RS}%H`, '--name-only');
|
|
39
|
+
if (query.rev) args.push(query.rev);
|
|
40
|
+
args.push('--', literalPathspec(query.path));
|
|
41
|
+
|
|
42
|
+
// With a pathspec, --name-only lists only the target, under its name at that commit
|
|
43
|
+
const touches = parseNameOnly(runGit(top, args));
|
|
44
|
+
const aliases = new Set([query.path, ...touches.flatMap((commit) => commit.files)]);
|
|
45
|
+
|
|
46
|
+
let commits = [];
|
|
47
|
+
if (touches.length > 0) {
|
|
48
|
+
const input = touches.map((commit) => commit.sha).join('\n') + '\n';
|
|
49
|
+
commits = parseNameOnly(
|
|
50
|
+
runGit(top, ['log', '--no-walk=unsorted', '--stdin', `--format=${RS}%H`, '--name-only'], {
|
|
51
|
+
input,
|
|
52
|
+
})
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const counts = new Map();
|
|
57
|
+
let considered = 0;
|
|
58
|
+
let skipped = 0;
|
|
59
|
+
for (const commit of commits) {
|
|
60
|
+
if (commit.files.length > maxFiles) {
|
|
61
|
+
skipped++;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
considered++;
|
|
65
|
+
for (const file of new Set(commit.files)) {
|
|
66
|
+
if (aliases.has(file)) continue;
|
|
67
|
+
counts.set(file, (counts.get(file) ?? 0) + 1);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const files = [...counts.entries()]
|
|
72
|
+
.filter(([file]) => !query.keep || query.keep(file))
|
|
73
|
+
.map(([file, count]) => ({
|
|
74
|
+
path: file,
|
|
75
|
+
count,
|
|
76
|
+
confidence: considered ? Math.round((count / considered) * 1000) / 1000 : 0,
|
|
77
|
+
}))
|
|
78
|
+
.sort((a, b) => b.count - a.count || a.path.localeCompare(b.path));
|
|
79
|
+
|
|
80
|
+
return { window: query.window, targetChanges: considered, skippedLargeCommits: skipped, files };
|
|
81
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors raised by `ctxman why`
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* An expected failure (bad target, not a repository, budget too small, ...) with a
|
|
7
|
+
* stable code callers can branch on and a message meant for the user
|
|
8
|
+
*/
|
|
9
|
+
export class WhyError extends Error {
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} code - e.g. NOT_A_GIT_REPO, PATH_OUTSIDE_ROOT, SYMBOL_NOT_FOUND
|
|
12
|
+
* @param {string} message - User-facing message
|
|
13
|
+
* @param {object} [details] - Extra fields copied onto the error (e.g. candidates)
|
|
14
|
+
*/
|
|
15
|
+
constructor(code, message, details = {}) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'WhyError';
|
|
18
|
+
this.code = code;
|
|
19
|
+
Object.assign(this, details);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default WhyError;
|
package/lib/why/git.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git access for `ctxman why`
|
|
3
|
+
*
|
|
4
|
+
* Every command runs through execFileSync with an argument list (no shell). Unlike
|
|
5
|
+
* GitClient.exec() this runner keeps output untrimmed (blame and patches are
|
|
6
|
+
* whitespace-sensitive), allows large outputs, lets callers accept exit codes such as
|
|
7
|
+
* `git grep`'s 1 for "no match", and logs nothing: an expected failure must not print
|
|
8
|
+
* to stdout, which carries the document.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from 'child_process';
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { WhyError } from './errors.js';
|
|
15
|
+
|
|
16
|
+
// Settings that would change the output format (signature blocks, quoted paths, colour)
|
|
17
|
+
const BASE_ARGS = [
|
|
18
|
+
'-c',
|
|
19
|
+
'core.quotePath=false',
|
|
20
|
+
'-c',
|
|
21
|
+
'log.showSignature=false',
|
|
22
|
+
'-c',
|
|
23
|
+
'color.ui=false',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const MAX_BUFFER = 256 * 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run git and return its stdout
|
|
30
|
+
* @param {string} cwd - Working directory
|
|
31
|
+
* @param {string[]} args - Arguments after `git`
|
|
32
|
+
* @param {object} [options]
|
|
33
|
+
* @param {string} [options.input] - Text for stdin
|
|
34
|
+
* @param {number[]} [options.okStatuses] - Exit codes that count as success (default [0])
|
|
35
|
+
* @param {'utf-8'|'buffer'} [options.encoding] - 'buffer' returns the raw bytes
|
|
36
|
+
* @returns {string|Buffer}
|
|
37
|
+
*/
|
|
38
|
+
export function runGit(cwd, args, { input, okStatuses = [0], encoding = 'utf-8' } = {}) {
|
|
39
|
+
try {
|
|
40
|
+
return execFileSync('git', [...BASE_ARGS, ...args], {
|
|
41
|
+
cwd,
|
|
42
|
+
encoding,
|
|
43
|
+
maxBuffer: MAX_BUFFER,
|
|
44
|
+
input,
|
|
45
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
46
|
+
// Read-only use: never take optional locks, never prompt
|
|
47
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' },
|
|
48
|
+
});
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error.code === 'ENOENT') {
|
|
51
|
+
throw new WhyError('GIT_NOT_FOUND', 'git is not installed or not on PATH');
|
|
52
|
+
}
|
|
53
|
+
if (typeof error.status === 'number' && okStatuses.includes(error.status)) {
|
|
54
|
+
if (encoding === 'buffer') return error.stdout ?? Buffer.alloc(0);
|
|
55
|
+
return error.stdout ? String(error.stdout) : '';
|
|
56
|
+
}
|
|
57
|
+
const stderr = error.stderr ? String(error.stderr).trim() : '';
|
|
58
|
+
const failure = new Error(stderr || error.message);
|
|
59
|
+
failure.status = error.status;
|
|
60
|
+
failure.stderr = stderr;
|
|
61
|
+
throw failure;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Like runGit, but returns null instead of throwing on a git error. Success can be an
|
|
67
|
+
* empty string (`git cat-file -e`), so test the result against null, not for truthiness
|
|
68
|
+
* @param {string} cwd
|
|
69
|
+
* @param {string[]} args
|
|
70
|
+
* @param {object} [options]
|
|
71
|
+
* @returns {string|Buffer|null}
|
|
72
|
+
*/
|
|
73
|
+
export function tryGit(cwd, args, options) {
|
|
74
|
+
try {
|
|
75
|
+
return runGit(cwd, args, options);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error instanceof WhyError) throw error;
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Top-level directory of the work tree containing `dir`
|
|
84
|
+
* @param {string} dir
|
|
85
|
+
* @returns {string} Real (symlink-resolved) path
|
|
86
|
+
*/
|
|
87
|
+
export function findRepositoryRoot(dir) {
|
|
88
|
+
if (!dir || !fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory()) {
|
|
89
|
+
throw new WhyError('ROOT_NOT_FOUND', `Not a directory: ${dir}`);
|
|
90
|
+
}
|
|
91
|
+
let top;
|
|
92
|
+
try {
|
|
93
|
+
top = runGit(dir, ['rev-parse', '--show-toplevel']).trim();
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error instanceof WhyError) throw error;
|
|
96
|
+
const reason = error.stderr ? ` (${error.stderr.split('\n')[0]})` : '';
|
|
97
|
+
throw new WhyError(
|
|
98
|
+
'NOT_A_GIT_REPO',
|
|
99
|
+
`Not a git repository: ${dir}${reason}. ctxman why reads git history; run it inside a git work tree.`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (!top) {
|
|
103
|
+
throw new WhyError('NOT_A_GIT_REPO', `Not a git work tree: ${dir}`);
|
|
104
|
+
}
|
|
105
|
+
return fs.realpathSync(top);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Commits whose parents a shallow clone did not fetch (the `shallow` file in the git
|
|
110
|
+
* directory). git shows them as root commits, so their diff looks like they added
|
|
111
|
+
* everything
|
|
112
|
+
* @param {string} top - Work tree root
|
|
113
|
+
* @returns {Set<string>} Full hashes
|
|
114
|
+
*/
|
|
115
|
+
function readShallowBoundary(top) {
|
|
116
|
+
const file = tryGit(top, ['rev-parse', '--git-path', 'shallow'])?.trim();
|
|
117
|
+
if (!file) return new Set();
|
|
118
|
+
try {
|
|
119
|
+
const content = fs.readFileSync(path.resolve(top, file), 'utf-8');
|
|
120
|
+
return new Set(content.split('\n').filter((line) => /^[0-9a-f]{40,64}$/.test(line)));
|
|
121
|
+
} catch (_error) {
|
|
122
|
+
return new Set();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Facts about the repository that shape the output
|
|
128
|
+
* @param {string} top - Work tree root
|
|
129
|
+
* @returns {{shallow: boolean, boundary: Set<string>, head: string|null,
|
|
130
|
+
* remoteUrl: string|null}} boundary: see readShallowBoundary (empty unless shallow)
|
|
131
|
+
*/
|
|
132
|
+
export function readRepositoryInfo(top) {
|
|
133
|
+
const shallow = tryGit(top, ['rev-parse', '--is-shallow-repository'])?.trim() === 'true';
|
|
134
|
+
const boundary = shallow ? readShallowBoundary(top) : new Set();
|
|
135
|
+
const head = tryGit(top, ['rev-parse', '--verify', '--quiet', 'HEAD'])?.trim() || null;
|
|
136
|
+
const remoteUrl = tryGit(top, ['remote', 'get-url', 'origin'])?.trim() || null;
|
|
137
|
+
return { shallow, boundary, head, remoteUrl };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A pathspec matching exactly this path: no glob characters, no pathspec magic
|
|
142
|
+
* @param {string} gitPath
|
|
143
|
+
* @returns {string}
|
|
144
|
+
*/
|
|
145
|
+
export function literalPathspec(gitPath) {
|
|
146
|
+
return `:(literal)${gitPath}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Convert an OS path relative to the work tree into git's forward-slash form
|
|
151
|
+
* @param {string} relativePath
|
|
152
|
+
* @returns {string}
|
|
153
|
+
*/
|
|
154
|
+
export function toGitPath(relativePath) {
|
|
155
|
+
return relativePath.split(path.sep).join('/');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A git date as ISO 8601 in UTC, to the second (2024-01-02T10:00:00Z). Commit dates
|
|
160
|
+
* (`%aI`, author's offset) and blame times (epoch seconds) end up in one form, so dates
|
|
161
|
+
* compare and print alike across sections
|
|
162
|
+
* @param {string|number} value - ISO 8601 text, or seconds since the epoch
|
|
163
|
+
* @returns {string|null} null for a missing or unparsable date
|
|
164
|
+
*/
|
|
165
|
+
export function utcDate(value) {
|
|
166
|
+
const time = typeof value === 'number' ? value * 1000 : Date.parse(value);
|
|
167
|
+
if (!value || !Number.isFinite(time)) return null;
|
|
168
|
+
return new Date(time).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
169
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commit history of a target: `git log -L` for a line range (or git's own funcname
|
|
3
|
+
* matching as a fallback), `git log --follow` for a whole file
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { literalPathspec, runGit, utcDate } from './git.js';
|
|
7
|
+
|
|
8
|
+
// Record separator, field separator, end of header: the patch (if any) follows the header
|
|
9
|
+
const RS = '\x1e';
|
|
10
|
+
const FS = '\x1f';
|
|
11
|
+
const EOH = '\x1d';
|
|
12
|
+
const FORMAT = `--format=${RS}%H${FS}%h${FS}%aI${FS}%an${FS}%ae${FS}%P${FS}%s${FS}%b${EOH}`;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} HistoryCommit
|
|
16
|
+
* @property {string} sha
|
|
17
|
+
* @property {string} shortSha
|
|
18
|
+
* @property {string} date - Author date, ISO 8601 in UTC (2024-01-02T10:00:00Z)
|
|
19
|
+
* @property {string} author
|
|
20
|
+
* @property {string} email
|
|
21
|
+
* @property {boolean} merge
|
|
22
|
+
* @property {string} subject
|
|
23
|
+
* @property {string} body
|
|
24
|
+
* @property {string} [patch] - Diff for the target (only when patches were requested)
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse `git log` output written with FORMAT
|
|
29
|
+
* @param {string} output
|
|
30
|
+
* @param {boolean} keepPatches
|
|
31
|
+
* @returns {HistoryCommit[]}
|
|
32
|
+
*/
|
|
33
|
+
export function parseLog(output, keepPatches) {
|
|
34
|
+
const commits = [];
|
|
35
|
+
for (const record of output.split(RS)) {
|
|
36
|
+
const end = record.indexOf(EOH);
|
|
37
|
+
if (end === -1) continue;
|
|
38
|
+
const fields = record.slice(0, end).split(FS);
|
|
39
|
+
if (fields.length < 8) continue;
|
|
40
|
+
const [sha, shortSha, date, author, email, parents, subject, ...body] = fields;
|
|
41
|
+
const commit = {
|
|
42
|
+
sha,
|
|
43
|
+
shortSha,
|
|
44
|
+
date: utcDate(date) ?? date,
|
|
45
|
+
author,
|
|
46
|
+
email,
|
|
47
|
+
merge: parents.trim().split(/\s+/).length > 1,
|
|
48
|
+
subject,
|
|
49
|
+
body: body.join(FS).trim(),
|
|
50
|
+
};
|
|
51
|
+
if (keepPatches) {
|
|
52
|
+
const patch = record
|
|
53
|
+
.slice(end + 1)
|
|
54
|
+
.replace(/^\n+/, '')
|
|
55
|
+
.trimEnd();
|
|
56
|
+
if (patch) commit.patch = patch;
|
|
57
|
+
}
|
|
58
|
+
commits.push(commit);
|
|
59
|
+
}
|
|
60
|
+
return commits;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Commits that touched the target, newest first
|
|
65
|
+
* @param {string} top - Work tree root (git runs here)
|
|
66
|
+
* @param {object} query
|
|
67
|
+
* @param {string} query.path - Forward-slash path relative to `top`
|
|
68
|
+
* @param {'range'|'funcname'|'file'} query.mode
|
|
69
|
+
* @param {number} [query.startLine] - For 'range' (lines of `rev`)
|
|
70
|
+
* @param {number} [query.endLine]
|
|
71
|
+
* @param {string} [query.funcname] - For 'funcname': a basic regular expression
|
|
72
|
+
* @param {string} [query.rev] - Revision to start from (validated by the caller)
|
|
73
|
+
* @param {number} query.limit
|
|
74
|
+
* @param {string} [query.since]
|
|
75
|
+
* @param {boolean} [query.patches]
|
|
76
|
+
* @returns {HistoryCommit[]}
|
|
77
|
+
*/
|
|
78
|
+
export function collectHistory(top, query) {
|
|
79
|
+
const args = ['log', FORMAT, '-n', String(query.limit), '--no-ext-diff', '--no-color'];
|
|
80
|
+
if (query.since) args.push(`--since=${query.since}`);
|
|
81
|
+
|
|
82
|
+
if (query.mode === 'file') {
|
|
83
|
+
args.push('--follow');
|
|
84
|
+
if (query.patches) args.push('-p');
|
|
85
|
+
if (query.rev) args.push(query.rev);
|
|
86
|
+
args.push('--', literalPathspec(query.path));
|
|
87
|
+
} else {
|
|
88
|
+
const range =
|
|
89
|
+
query.mode === 'range'
|
|
90
|
+
? `${query.startLine},${query.endLine}:${query.path}`
|
|
91
|
+
: `:${query.funcname}:${query.path}`;
|
|
92
|
+
args.push('-L', range);
|
|
93
|
+
// Older git versions ignore -s with -L; parseLog drops the patch text either way
|
|
94
|
+
if (!query.patches) args.push('-s');
|
|
95
|
+
if (query.rev) args.push(query.rev);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return parseLog(runGit(top, args), Boolean(query.patches));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Escape a symbol for git's `-L :<funcname>:<file>` (a POSIX basic regular expression)
|
|
103
|
+
* @param {string} name
|
|
104
|
+
* @returns {string}
|
|
105
|
+
*/
|
|
106
|
+
export function funcnamePattern(name) {
|
|
107
|
+
return name.replace(/[.[\]*^$\\]/g, '\\$&');
|
|
108
|
+
}
|