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
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import resolution for the repository map
|
|
3
|
+
*
|
|
4
|
+
* Turns the raw references from imports.js into files of the repository. Only files the
|
|
5
|
+
* scanner returned can be targets, so anything outside the project (packages, the
|
|
6
|
+
* standard library, ignored files) stays unresolved and is ignored. All paths are
|
|
7
|
+
* project-relative with forward slashes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
|
|
13
|
+
const posix = path.posix;
|
|
14
|
+
|
|
15
|
+
// TypeScript sources first: they win over compiled JavaScript next to them
|
|
16
|
+
const JS_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts', '.d.ts'];
|
|
17
|
+
// An ESM import names the compiled file (`./a.js`) even when the source is `a.ts`
|
|
18
|
+
const JS_TO_TS = { '.js': ['.ts', '.tsx'], '.jsx': ['.tsx'], '.mjs': ['.mts'], '.cjs': ['.cts'] };
|
|
19
|
+
const RUST_ROOT_DIRS = new Set(['bin', 'examples', 'tests', 'benches']);
|
|
20
|
+
|
|
21
|
+
/** Directory of a project-relative path ('' for the root) */
|
|
22
|
+
function dirOf(filePath) {
|
|
23
|
+
const dir = posix.dirname(filePath);
|
|
24
|
+
return dir === '.' ? '' : dir;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Join and normalise; null when the result leaves the project */
|
|
28
|
+
function joinInside(...parts) {
|
|
29
|
+
const joined = posix.normalize(posix.join(...parts.filter((part) => part !== '')));
|
|
30
|
+
if (joined === '..' || joined.startsWith('../') || posix.isAbsolute(joined)) return null;
|
|
31
|
+
return joined === '.' ? '' : joined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Number of leading directory names two paths share */
|
|
35
|
+
function sharedDepth(a, b) {
|
|
36
|
+
const left = dirOf(a).split('/');
|
|
37
|
+
const right = dirOf(b).split('/');
|
|
38
|
+
let depth = 0;
|
|
39
|
+
while (depth < left.length && depth < right.length && left[depth] === right[depth]) depth++;
|
|
40
|
+
return depth;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Remove comments and trailing commas from JSON with comments (tsconfig.json)
|
|
45
|
+
* @param {string} text
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
export function stripJsonComments(text) {
|
|
49
|
+
let out = '';
|
|
50
|
+
let inString = false;
|
|
51
|
+
for (let i = 0; i < text.length; i++) {
|
|
52
|
+
const ch = text[i];
|
|
53
|
+
if (inString) {
|
|
54
|
+
out += ch;
|
|
55
|
+
if (ch === '\\') out += text[++i] ?? '';
|
|
56
|
+
else if (ch === '"') inString = false;
|
|
57
|
+
} else if (ch === '"') {
|
|
58
|
+
inString = true;
|
|
59
|
+
out += ch;
|
|
60
|
+
} else if (ch === '/' && text[i + 1] === '/') {
|
|
61
|
+
while (i < text.length && text[i] !== '\n') i++;
|
|
62
|
+
out += '\n';
|
|
63
|
+
} else if (ch === '/' && text[i + 1] === '*') {
|
|
64
|
+
i = text.indexOf('*/', i + 2);
|
|
65
|
+
if (i === -1) break;
|
|
66
|
+
i++;
|
|
67
|
+
} else {
|
|
68
|
+
out += ch;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out.replace(/,(\s*[}\]])/g, '$1');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class ImportResolver {
|
|
75
|
+
/**
|
|
76
|
+
* @param {string} root - Absolute project root
|
|
77
|
+
* @param {Array<{path: string, language: string, declarations: string[],
|
|
78
|
+
* methods: Array<{name: string}>}>} nodes - Files of the map
|
|
79
|
+
*/
|
|
80
|
+
constructor(root, nodes) {
|
|
81
|
+
this.root = root;
|
|
82
|
+
this.nodes = new Map(nodes.map((node) => [node.path, node]));
|
|
83
|
+
this.dirFiles = new Map();
|
|
84
|
+
this.byBasename = new Map();
|
|
85
|
+
this.typeIndex = new Map(); // fully qualified type name -> file (Java, Kotlin, Scala, C#)
|
|
86
|
+
this.packageIndex = new Map(); // package or namespace -> files
|
|
87
|
+
this.phpIndex = new Map(); // lower-case PHP class name -> file
|
|
88
|
+
this.phpNamespaces = new Map(); // lower-case PHP namespace -> files
|
|
89
|
+
this.existsCache = new Map();
|
|
90
|
+
this.goModules = null;
|
|
91
|
+
this.cargoCache = new Map();
|
|
92
|
+
this.cratesByName = null;
|
|
93
|
+
this.jsAliases = undefined;
|
|
94
|
+
|
|
95
|
+
for (const node of nodes) {
|
|
96
|
+
const dir = dirOf(node.path);
|
|
97
|
+
if (!this.dirFiles.has(dir)) this.dirFiles.set(dir, []);
|
|
98
|
+
this.dirFiles.get(dir).push(node.path);
|
|
99
|
+
const base = posix.basename(node.path);
|
|
100
|
+
if (!this.byBasename.has(base)) this.byBasename.set(base, []);
|
|
101
|
+
this.byBasename.get(base).push(node.path);
|
|
102
|
+
|
|
103
|
+
const typeName = posix.basename(node.path).replace(/\.[^.]+$/, '');
|
|
104
|
+
for (const declared of node.declarations || []) {
|
|
105
|
+
if (node.language === 'php') {
|
|
106
|
+
addTo(this.phpNamespaces, declared.toLowerCase(), node.path);
|
|
107
|
+
} else {
|
|
108
|
+
addTo(this.packageIndex, declared, node.path);
|
|
109
|
+
if (!this.typeIndex.has(`${declared}.${typeName}`)) {
|
|
110
|
+
this.typeIndex.set(`${declared}.${typeName}`, node.path);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (node.language === 'php') {
|
|
115
|
+
const namespace = node.declarations?.[0];
|
|
116
|
+
const name = (namespace ? `${namespace}\\${typeName}` : typeName).toLowerCase();
|
|
117
|
+
if (!this.phpIndex.has(name)) this.phpIndex.set(name, node.path);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Files a node depends on
|
|
124
|
+
* @param {Object} node - Node with `path`, `language` and `imports` (from imports.js)
|
|
125
|
+
* @returns {{edges: Array<{to: string, weight: number}>, unresolved: number}}
|
|
126
|
+
*/
|
|
127
|
+
resolve(node) {
|
|
128
|
+
const edges = new Map();
|
|
129
|
+
let unresolved = 0;
|
|
130
|
+
for (const ref of node.imports || []) {
|
|
131
|
+
const targets = this.resolveOne(node, ref).filter((target) => target.to !== node.path);
|
|
132
|
+
if (targets.length === 0) unresolved++;
|
|
133
|
+
for (const { to, weight } of targets) {
|
|
134
|
+
edges.set(to, Math.max(edges.get(to) || 0, weight));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
edges: [...edges].map(([to, weight]) => ({ to, weight })),
|
|
139
|
+
unresolved,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** @returns {Array<{to: string, weight: number}>} */
|
|
144
|
+
resolveOne(node, ref) {
|
|
145
|
+
switch (node.language) {
|
|
146
|
+
case 'javascript':
|
|
147
|
+
case 'typescript':
|
|
148
|
+
return single(this.resolveJs(node.path, ref.spec));
|
|
149
|
+
case 'python':
|
|
150
|
+
return this.resolvePython(node.path, ref).map((to) => ({ to, weight: 1 }));
|
|
151
|
+
case 'go':
|
|
152
|
+
return spread(this.resolveGo(node.path, ref.spec));
|
|
153
|
+
case 'java':
|
|
154
|
+
case 'kotlin':
|
|
155
|
+
case 'scala':
|
|
156
|
+
return this.resolveJvm(ref);
|
|
157
|
+
case 'ruby':
|
|
158
|
+
return single(this.resolveRuby(node.path, ref));
|
|
159
|
+
case 'php':
|
|
160
|
+
return this.resolvePhp(node, ref);
|
|
161
|
+
case 'c':
|
|
162
|
+
case 'cpp':
|
|
163
|
+
return single(this.resolveInclude(node.path, ref.include));
|
|
164
|
+
case 'rust':
|
|
165
|
+
return single(this.resolveRust(node.path, ref));
|
|
166
|
+
case 'csharp':
|
|
167
|
+
return this.resolveCSharp(ref);
|
|
168
|
+
default:
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
has(filePath) {
|
|
174
|
+
return filePath !== null && this.nodes.has(filePath);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
firstExisting(candidates) {
|
|
178
|
+
return candidates.find((candidate) => this.has(candidate)) ?? null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
existsOnDisk(relativePath) {
|
|
182
|
+
if (!this.existsCache.has(relativePath)) {
|
|
183
|
+
this.existsCache.set(relativePath, fs.existsSync(path.join(this.root, relativePath)));
|
|
184
|
+
}
|
|
185
|
+
return this.existsCache.get(relativePath);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
readText(relativePath) {
|
|
189
|
+
try {
|
|
190
|
+
return fs.readFileSync(path.join(this.root, relativePath), 'utf-8');
|
|
191
|
+
} catch (_error) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------- JavaScript/TypeScript
|
|
197
|
+
|
|
198
|
+
resolveJs(fromPath, rawSpec) {
|
|
199
|
+
// `./worker.js?url` (bundler query), but `#internal` (subpath import) stays whole
|
|
200
|
+
const cut = rawSpec.slice(1).search(/[?#]/);
|
|
201
|
+
const spec = cut === -1 ? rawSpec : rawSpec.slice(0, cut + 1);
|
|
202
|
+
let bases;
|
|
203
|
+
if (spec === '.' || spec === '..' || spec.startsWith('./') || spec.startsWith('../')) {
|
|
204
|
+
bases = [joinInside(dirOf(fromPath), spec)];
|
|
205
|
+
} else {
|
|
206
|
+
bases = this.aliasTargets(spec);
|
|
207
|
+
}
|
|
208
|
+
for (const base of bases) {
|
|
209
|
+
if (base === null) continue;
|
|
210
|
+
const hit = this.firstExisting(this.jsCandidates(base));
|
|
211
|
+
if (hit) return hit;
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
jsCandidates(base) {
|
|
217
|
+
const candidates = [base];
|
|
218
|
+
const ext = posix.extname(base);
|
|
219
|
+
for (const tsExt of JS_TO_TS[ext] || []) candidates.push(base.slice(0, -ext.length) + tsExt);
|
|
220
|
+
for (const extension of JS_EXTENSIONS) candidates.push(base + extension);
|
|
221
|
+
const index = base === '' ? 'index' : `${base}/index`;
|
|
222
|
+
for (const extension of JS_EXTENSIONS) candidates.push(index + extension);
|
|
223
|
+
return candidates;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Targets of a bare specifier under the `paths`/`baseUrl` of tsconfig.json/jsconfig.json */
|
|
227
|
+
aliasTargets(spec) {
|
|
228
|
+
if (this.jsAliases === undefined) this.jsAliases = this.loadJsAliases();
|
|
229
|
+
if (!this.jsAliases) return [];
|
|
230
|
+
const { baseUrl, paths, explicitBaseUrl } = this.jsAliases;
|
|
231
|
+
const targets = [];
|
|
232
|
+
for (const [pattern, replacements] of paths) {
|
|
233
|
+
const star = pattern.indexOf('*');
|
|
234
|
+
let captured = null;
|
|
235
|
+
if (star === -1) {
|
|
236
|
+
if (spec === pattern) captured = '';
|
|
237
|
+
} else {
|
|
238
|
+
const prefix = pattern.slice(0, star);
|
|
239
|
+
const suffix = pattern.slice(star + 1);
|
|
240
|
+
if (
|
|
241
|
+
spec.startsWith(prefix) &&
|
|
242
|
+
spec.endsWith(suffix) &&
|
|
243
|
+
spec.length >= prefix.length + suffix.length
|
|
244
|
+
) {
|
|
245
|
+
captured = spec.slice(prefix.length, spec.length - suffix.length);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (captured === null) continue;
|
|
249
|
+
for (const replacement of replacements) {
|
|
250
|
+
targets.push(joinInside(baseUrl, String(replacement).split('*').join(captured)));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (explicitBaseUrl) targets.push(joinInside(baseUrl, spec));
|
|
254
|
+
return targets;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
loadJsAliases() {
|
|
258
|
+
for (const name of ['tsconfig.json', 'jsconfig.json']) {
|
|
259
|
+
const text = this.readText(name);
|
|
260
|
+
if (text === null) continue;
|
|
261
|
+
try {
|
|
262
|
+
const options = JSON.parse(stripJsonComments(text)).compilerOptions || {};
|
|
263
|
+
const baseUrl = joinInside(options.baseUrl || '.');
|
|
264
|
+
if (baseUrl === null) return null;
|
|
265
|
+
const paths = Object.entries(options.paths || {}).filter(([, value]) =>
|
|
266
|
+
Array.isArray(value)
|
|
267
|
+
);
|
|
268
|
+
return { baseUrl, paths, explicitBaseUrl: Boolean(options.baseUrl) };
|
|
269
|
+
} catch (_error) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------- Python
|
|
277
|
+
|
|
278
|
+
resolvePython(fromPath, ref) {
|
|
279
|
+
const names = ref.names.filter((name) => name !== '*');
|
|
280
|
+
const dots = ref.module.match(/^\.*/)[0].length;
|
|
281
|
+
const dotted = ref.module.slice(dots);
|
|
282
|
+
|
|
283
|
+
if (dots > 0) {
|
|
284
|
+
let base = dirOf(fromPath);
|
|
285
|
+
for (let level = 1; level < dots; level++) {
|
|
286
|
+
if (base === '') return [];
|
|
287
|
+
base = dirOf(base);
|
|
288
|
+
}
|
|
289
|
+
return this.pythonTargets([base], dotted, names);
|
|
290
|
+
}
|
|
291
|
+
return this.pythonTargets(this.pythonRoots(fromPath), dotted, names);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Module (and imported submodules) under the first source root that has it */
|
|
295
|
+
pythonTargets(roots, dotted, names) {
|
|
296
|
+
for (const root of roots) {
|
|
297
|
+
const found = [];
|
|
298
|
+
const module = dotted ? this.pythonModule(root, dotted) : null;
|
|
299
|
+
if (module) found.push(module);
|
|
300
|
+
for (const name of names) {
|
|
301
|
+
const submodule = this.pythonModule(root, dotted ? `${dotted}.${name}` : name);
|
|
302
|
+
if (submodule) found.push(submodule);
|
|
303
|
+
}
|
|
304
|
+
if (found.length === 0 && !dotted) {
|
|
305
|
+
const init = this.firstExisting([joinInside(root, '__init__.py')]);
|
|
306
|
+
if (init) found.push(init);
|
|
307
|
+
}
|
|
308
|
+
if (found.length > 0) return [...new Set(found)];
|
|
309
|
+
}
|
|
310
|
+
return [];
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
pythonModule(root, dotted) {
|
|
314
|
+
const base = joinInside(root, dotted.split('.').join('/'));
|
|
315
|
+
if (base === null) return null;
|
|
316
|
+
return this.firstExisting([`${base}.py`, joinInside(base, '__init__.py')]);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Project root, src/, the folder above the importer's top-level package, its own folder */
|
|
320
|
+
pythonRoots(fromPath) {
|
|
321
|
+
let dir = dirOf(fromPath);
|
|
322
|
+
while (dir !== '' && this.has(joinInside(dir, '__init__.py'))) dir = dirOf(dir);
|
|
323
|
+
return [...new Set(['', 'src', dir, dirOf(fromPath)])];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// -------------------------------------------------------------------------------- Go
|
|
327
|
+
|
|
328
|
+
resolveGo(fromPath, spec) {
|
|
329
|
+
if (this.goModules === null) this.goModules = this.findGoModules();
|
|
330
|
+
let best = null;
|
|
331
|
+
for (const module of this.goModules) {
|
|
332
|
+
if (spec !== module.path && !spec.startsWith(`${module.path}/`)) continue;
|
|
333
|
+
if (!best || module.path.length > best.path.length) best = module;
|
|
334
|
+
}
|
|
335
|
+
if (!best) return [];
|
|
336
|
+
const dir = joinInside(best.dir, spec.slice(best.path.length).replace(/^\//, ''));
|
|
337
|
+
if (dir === null) return [];
|
|
338
|
+
return (this.dirFiles.get(dir) || []).filter(
|
|
339
|
+
(file) => file.endsWith('.go') && !file.endsWith('_test.go') && file !== fromPath
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Every go.mod at or above a folder holding Go files */
|
|
344
|
+
findGoModules() {
|
|
345
|
+
const modules = new Map();
|
|
346
|
+
for (const [dir, files] of this.dirFiles) {
|
|
347
|
+
if (!files.some((file) => file.endsWith('.go'))) continue;
|
|
348
|
+
let current = dir;
|
|
349
|
+
for (;;) {
|
|
350
|
+
const goMod = current === '' ? 'go.mod' : `${current}/go.mod`;
|
|
351
|
+
if (!modules.has(current) && this.existsOnDisk(goMod)) {
|
|
352
|
+
const match = (this.readText(goMod) || '').match(/^\s*module\s+(\S+)/m);
|
|
353
|
+
modules.set(current, match ? { dir: current, path: match[1].replace(/"/g, '') } : null);
|
|
354
|
+
}
|
|
355
|
+
if (current === '') break;
|
|
356
|
+
current = dirOf(current);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return [...modules.values()].filter(Boolean);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// -------------------------------------------------------------- Java / Kotlin / Scala
|
|
363
|
+
|
|
364
|
+
resolveJvm(ref) {
|
|
365
|
+
if (ref.members.length > 0) {
|
|
366
|
+
return ref.members.flatMap((member) => this.resolveTypeName(`${ref.name}.${member}`));
|
|
367
|
+
}
|
|
368
|
+
if (ref.wildcard) {
|
|
369
|
+
if (this.packageIndex.has(ref.name)) return spread(this.packageIndex.get(ref.name));
|
|
370
|
+
return single(this.typeIndex.get(ref.name) ?? null);
|
|
371
|
+
}
|
|
372
|
+
return this.resolveTypeName(ref.name);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** `a.b.Outer.Inner` → the file of a.b.Outer; `a.b.topLevelFun` → files defining it */
|
|
376
|
+
resolveTypeName(name) {
|
|
377
|
+
const segments = name.split('.');
|
|
378
|
+
for (let length = segments.length; length >= 2; length--) {
|
|
379
|
+
const hit = this.typeIndex.get(segments.slice(0, length).join('.'));
|
|
380
|
+
if (hit) return single(hit);
|
|
381
|
+
}
|
|
382
|
+
return this.membersOf(this.packageIndex.get(segments.slice(0, -1).join('.')), segments.at(-1));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Files of a package that define a function named `member` */
|
|
386
|
+
membersOf(files, member) {
|
|
387
|
+
if (!files) return [];
|
|
388
|
+
return spread(
|
|
389
|
+
files.filter((file) => this.nodes.get(file).methods?.some((m) => m.name === member))
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ------------------------------------------------------------------------------ Ruby
|
|
394
|
+
|
|
395
|
+
resolveRuby(fromPath, ref) {
|
|
396
|
+
const spec = ref.spec.replace(/\.rb$/, '');
|
|
397
|
+
const bases = ref.relative
|
|
398
|
+
? [joinInside(dirOf(fromPath), spec)]
|
|
399
|
+
: [joinInside('lib', spec), joinInside(spec), joinInside(dirOf(fromPath), spec)];
|
|
400
|
+
return this.firstExisting(bases.filter((base) => base !== null).map((base) => `${base}.rb`));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ------------------------------------------------------------------------------- PHP
|
|
404
|
+
|
|
405
|
+
resolvePhp(node, ref) {
|
|
406
|
+
if (ref.include !== undefined) {
|
|
407
|
+
const spec = ref.include;
|
|
408
|
+
const candidates = ref.fromDir
|
|
409
|
+
? [joinInside(dirOf(node.path), spec.replace(/^\/+/, ''))]
|
|
410
|
+
: spec.startsWith('/')
|
|
411
|
+
? []
|
|
412
|
+
: [joinInside(dirOf(node.path), spec), joinInside(spec)];
|
|
413
|
+
return single(this.firstExisting(candidates));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const name = ref.name.replace(/^\\/, '');
|
|
417
|
+
const namespace = node.declarations?.[0];
|
|
418
|
+
const names = [name];
|
|
419
|
+
if (namespace && !name.includes('\\')) names.push(`${namespace}\\${name}`);
|
|
420
|
+
for (const candidate of names) {
|
|
421
|
+
const hit = this.phpIndex.get(candidate.toLowerCase());
|
|
422
|
+
if (hit) return single(hit);
|
|
423
|
+
}
|
|
424
|
+
// `use function App\Support\helper;`: the namespace's files defining it
|
|
425
|
+
const parts = name.split('\\');
|
|
426
|
+
return this.membersOf(
|
|
427
|
+
this.phpNamespaces.get(parts.slice(0, -1).join('\\').toLowerCase()),
|
|
428
|
+
parts.at(-1)
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ----------------------------------------------------------------------------- C/C++
|
|
433
|
+
|
|
434
|
+
resolveInclude(fromPath, include) {
|
|
435
|
+
const direct = this.firstExisting([
|
|
436
|
+
joinInside(dirOf(fromPath), include),
|
|
437
|
+
joinInside(include),
|
|
438
|
+
joinInside('include', include),
|
|
439
|
+
joinInside('src', include),
|
|
440
|
+
]);
|
|
441
|
+
if (direct) return direct;
|
|
442
|
+
|
|
443
|
+
// Include directories we do not know: any file whose path ends with the include,
|
|
444
|
+
// the one closest to the includer
|
|
445
|
+
const normalized = posix.normalize(include).replace(/^(\.\.\/)+/, '');
|
|
446
|
+
const matches = (this.byBasename.get(posix.basename(normalized)) || []).filter(
|
|
447
|
+
(file) => file === normalized || file.endsWith(`/${normalized}`)
|
|
448
|
+
);
|
|
449
|
+
let best = null;
|
|
450
|
+
for (const file of matches) {
|
|
451
|
+
if (!best || sharedDepth(file, fromPath) > sharedDepth(best, fromPath)) best = file;
|
|
452
|
+
}
|
|
453
|
+
return best;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ------------------------------------------------------------------------------ Rust
|
|
457
|
+
|
|
458
|
+
resolveRust(fromPath, ref) {
|
|
459
|
+
const moduleDir = this.rustModuleDir(fromPath);
|
|
460
|
+
if (ref.mod !== undefined) {
|
|
461
|
+
return this.firstExisting([
|
|
462
|
+
joinInside(moduleDir, `${ref.mod}.rs`),
|
|
463
|
+
joinInside(moduleDir, ref.mod, 'mod.rs'),
|
|
464
|
+
]);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
let startDir;
|
|
468
|
+
const segments = [...ref.segments];
|
|
469
|
+
if (ref.crate !== undefined) {
|
|
470
|
+
startDir = this.crateSources().get(ref.crate);
|
|
471
|
+
if (startDir === undefined) return null;
|
|
472
|
+
} else if (ref.anchor === 'crate') {
|
|
473
|
+
startDir = this.rustSourceDir(fromPath);
|
|
474
|
+
} else {
|
|
475
|
+
startDir = moduleDir;
|
|
476
|
+
if (ref.anchor === 'super') startDir = dirOf(moduleDir);
|
|
477
|
+
while (segments[0] === 'super') {
|
|
478
|
+
segments.shift();
|
|
479
|
+
startDir = dirOf(startDir);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (startDir === null) return null;
|
|
483
|
+
|
|
484
|
+
let dir = startDir;
|
|
485
|
+
let found = null;
|
|
486
|
+
for (const segment of segments) {
|
|
487
|
+
const hit = this.firstExisting([
|
|
488
|
+
joinInside(dir, `${segment}.rs`),
|
|
489
|
+
joinInside(dir, segment, 'mod.rs'),
|
|
490
|
+
]);
|
|
491
|
+
if (!hit) break;
|
|
492
|
+
found = hit;
|
|
493
|
+
dir = joinInside(dir, segment);
|
|
494
|
+
}
|
|
495
|
+
return found ?? this.rustModuleFile(startDir);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Folder holding the submodules of a file: its own folder for crate roots and mod.rs */
|
|
499
|
+
rustModuleDir(fromPath) {
|
|
500
|
+
const base = posix.basename(fromPath);
|
|
501
|
+
const dir = dirOf(fromPath);
|
|
502
|
+
if (['lib.rs', 'main.rs', 'mod.rs', 'build.rs'].includes(base)) return dir;
|
|
503
|
+
if (RUST_ROOT_DIRS.has(posix.basename(dir))) return dir;
|
|
504
|
+
return fromPath.slice(0, -'.rs'.length);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** File of the module whose submodules live in `dir` */
|
|
508
|
+
rustModuleFile(dir) {
|
|
509
|
+
return this.firstExisting([
|
|
510
|
+
`${dir}.rs`,
|
|
511
|
+
joinInside(dir, 'mod.rs'),
|
|
512
|
+
joinInside(dir, 'lib.rs'),
|
|
513
|
+
joinInside(dir, 'main.rs'),
|
|
514
|
+
]);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** `src/` of the crate (nearest Cargo.toml) a file belongs to */
|
|
518
|
+
rustSourceDir(fromPath) {
|
|
519
|
+
const crateDir = this.cargoDir(dirOf(fromPath));
|
|
520
|
+
return crateDir === null ? null : joinInside(crateDir, 'src');
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
cargoDir(dir) {
|
|
524
|
+
if (this.cargoCache.has(dir)) return this.cargoCache.get(dir);
|
|
525
|
+
let result = null;
|
|
526
|
+
if (this.existsOnDisk(dir === '' ? 'Cargo.toml' : `${dir}/Cargo.toml`)) result = dir;
|
|
527
|
+
else if (dir !== '') result = this.cargoDir(dirOf(dir));
|
|
528
|
+
this.cargoCache.set(dir, result);
|
|
529
|
+
return result;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Workspace crates by name (`my-crate` is used as `my_crate`) → their src/ folder */
|
|
533
|
+
crateSources() {
|
|
534
|
+
if (this.cratesByName) return this.cratesByName;
|
|
535
|
+
this.cratesByName = new Map();
|
|
536
|
+
const crateDirs = new Set();
|
|
537
|
+
for (const file of this.nodes.keys()) {
|
|
538
|
+
if (!file.endsWith('.rs')) continue;
|
|
539
|
+
const crateDir = this.cargoDir(dirOf(file));
|
|
540
|
+
if (crateDir !== null) crateDirs.add(crateDir);
|
|
541
|
+
}
|
|
542
|
+
for (const crateDir of crateDirs) {
|
|
543
|
+
const manifest = this.readText(crateDir === '' ? 'Cargo.toml' : `${crateDir}/Cargo.toml`);
|
|
544
|
+
const match = manifest?.match(/^\s*\[package\][^[]*?^\s*name\s*=\s*"([^"]+)"/m);
|
|
545
|
+
if (match) this.cratesByName.set(match[1].replace(/-/g, '_'), joinInside(crateDir, 'src'));
|
|
546
|
+
}
|
|
547
|
+
return this.cratesByName;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ---------------------------------------------------------------------------- C#
|
|
551
|
+
|
|
552
|
+
resolveCSharp(ref) {
|
|
553
|
+
if (!ref.isStatic && this.packageIndex.has(ref.name)) {
|
|
554
|
+
return spread(this.packageIndex.get(ref.name));
|
|
555
|
+
}
|
|
556
|
+
return single(this.typeIndex.get(ref.name) ?? null);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function addTo(map, key, value) {
|
|
561
|
+
if (!map.has(key)) map.set(key, []);
|
|
562
|
+
map.get(key).push(value);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function single(target) {
|
|
566
|
+
return target ? [{ to: target, weight: 1 }] : [];
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** A dependency on a package of n files: each file gets 1/n of the edge */
|
|
570
|
+
function spread(targets) {
|
|
571
|
+
return targets.map((to) => ({ to, weight: 1 / targets.length }));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export default ImportResolver;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token budget for `pack --budget`
|
|
3
|
+
*
|
|
4
|
+
* Files are ranked with the repository-map ranking of `ctxman map` (lib/map): PageRank over
|
|
5
|
+
* the import graph blended with git change frequency and symbol references, so the files
|
|
6
|
+
* the rest of the code depends on come first. Files the graph does not cover (documentation,
|
|
7
|
+
* configuration, data, languages without import parsing, files the map skips as minified)
|
|
8
|
+
* follow in path order. They are packed greedily: a file that does not fit is skipped and
|
|
9
|
+
* smaller, lower-ranked files can still use the room. The per-file costs are estimates, so
|
|
10
|
+
* the rendered document is then counted exactly and files are dropped (lowest rank first)
|
|
11
|
+
* until it fits: the result never exceeds the budget.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { buildRepoGraph, rankFiles } from '../map/index.js';
|
|
15
|
+
import { PackError } from './select.js';
|
|
16
|
+
|
|
17
|
+
// Exact re-renders spent trying to use the room left after the estimate-based pass
|
|
18
|
+
const MAX_TOP_UP_ATTEMPTS = 8;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Rank entries most important first
|
|
22
|
+
* @param {string} root - Project root (the import graph and git history are read from it)
|
|
23
|
+
* @param {Array<{path: string}>} entries - Project-relative paths with `/` separators
|
|
24
|
+
* @param {object} [options]
|
|
25
|
+
* @param {number} [options.history] - Commits of git history to mine (map default: 300)
|
|
26
|
+
* @returns {{ranked: Array, scores: Map<string, number>}} The same entry objects, reordered,
|
|
27
|
+
* and the map score (0..1) of each ranked path
|
|
28
|
+
*/
|
|
29
|
+
export function rankEntries(root, entries, options = {}) {
|
|
30
|
+
const graph = buildRepoGraph(root, { history: options.history, redact: false });
|
|
31
|
+
const rows = rankFiles(graph);
|
|
32
|
+
const position = new Map(rows.map((row, i) => [row.path, i]));
|
|
33
|
+
const scores = new Map(rows.map((row) => [row.path, row.score]));
|
|
34
|
+
|
|
35
|
+
const inGraph = entries.filter((entry) => position.has(entry.path));
|
|
36
|
+
const rest = entries.filter((entry) => !position.has(entry.path));
|
|
37
|
+
inGraph.sort((a, b) => position.get(a.path) - position.get(b.path));
|
|
38
|
+
rest.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
39
|
+
return { ranked: [...inGraph, ...rest], scores };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Choose the entries that fit in the budget
|
|
44
|
+
* @param {Array} entries - Candidates in output order
|
|
45
|
+
* @param {Array} ranked - The same entries, most important first
|
|
46
|
+
* @param {number} budget - Token limit for the whole rendered document
|
|
47
|
+
* @param {object} helpers
|
|
48
|
+
* @param {function(Array, Array): {text: string, tokens: number}} helpers.measure - Render
|
|
49
|
+
* and count a document with these packed entries (output order) and omitted entries (rank
|
|
50
|
+
* order)
|
|
51
|
+
* @param {function(object): number} helpers.cost - Estimated tokens an entry adds
|
|
52
|
+
* @returns {{packed: Array, omitted: Array, text: string, tokens: number}}
|
|
53
|
+
*/
|
|
54
|
+
export function fitToBudget(entries, ranked, budget, { measure, cost }) {
|
|
55
|
+
const selected = new Set();
|
|
56
|
+
const evaluate = () =>
|
|
57
|
+
measure(
|
|
58
|
+
entries.filter((entry) => selected.has(entry)),
|
|
59
|
+
ranked.filter((entry) => !selected.has(entry))
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
let current = evaluate();
|
|
63
|
+
if (current.tokens > budget) {
|
|
64
|
+
throw new PackError(
|
|
65
|
+
'BUDGET_TOO_SMALL',
|
|
66
|
+
`The document needs ${current.tokens} tokens without any file (header, tree, omitted ` +
|
|
67
|
+
`list, instructions); the budget is ${budget}`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let used = current.tokens;
|
|
72
|
+
for (const entry of ranked) {
|
|
73
|
+
const entryCost = cost(entry);
|
|
74
|
+
if (used + entryCost <= budget) {
|
|
75
|
+
selected.add(entry);
|
|
76
|
+
used += entryCost;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
current = evaluate();
|
|
80
|
+
|
|
81
|
+
// Estimates can be a little low: drop the lowest-ranked files until the exact count fits
|
|
82
|
+
while (current.tokens > budget) {
|
|
83
|
+
// Without files the document was measured to fit, so this cannot loop forever
|
|
84
|
+
if (!selected.size) throw new PackError('BUDGET_TOO_SMALL', `Cannot fit in ${budget} tokens`);
|
|
85
|
+
let excess = current.tokens - budget;
|
|
86
|
+
for (let i = ranked.length - 1; i >= 0 && excess > 0; i--) {
|
|
87
|
+
if (selected.delete(ranked[i])) excess -= cost(ranked[i]);
|
|
88
|
+
}
|
|
89
|
+
current = evaluate();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ... or a little high: try the best-ranked files left out while there may be room
|
|
93
|
+
let attempts = 0;
|
|
94
|
+
for (const entry of ranked) {
|
|
95
|
+
if (attempts >= MAX_TOP_UP_ATTEMPTS) break;
|
|
96
|
+
if (selected.has(entry) || current.tokens + entry.tokens > budget) continue;
|
|
97
|
+
attempts++;
|
|
98
|
+
selected.add(entry);
|
|
99
|
+
const next = evaluate();
|
|
100
|
+
if (next.tokens <= budget) current = next;
|
|
101
|
+
else selected.delete(entry);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
packed: entries.filter((entry) => selected.has(entry)),
|
|
106
|
+
omitted: ranked.filter((entry) => !selected.has(entry)),
|
|
107
|
+
...current,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export default { rankEntries, fitToBudget };
|