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
|
@@ -24,8 +24,10 @@ export class PineconeStore extends BaseVectorStore {
|
|
|
24
24
|
this.client = new Pinecone({ apiKey: this.apiKey });
|
|
25
25
|
this.index = this.client.index(this.indexName);
|
|
26
26
|
} catch (error) {
|
|
27
|
-
|
|
28
|
-
throw new Error(
|
|
27
|
+
if (error.code === 'ERR_MODULE_NOT_FOUND') {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'Please install @pinecone-database/pinecone to use PineconeStore: npm install @pinecone-database/pinecone'
|
|
30
|
+
);
|
|
29
31
|
}
|
|
30
32
|
throw error;
|
|
31
33
|
}
|
|
@@ -42,16 +44,16 @@ export class PineconeStore extends BaseVectorStore {
|
|
|
42
44
|
id: doc.id || crypto.randomUUID(),
|
|
43
45
|
values,
|
|
44
46
|
metadata: {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
47
|
+
...doc.metadata,
|
|
48
|
+
text: doc.text, // Pinecone metadata is limited, but storing text is useful
|
|
49
|
+
},
|
|
48
50
|
});
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
// Pinecone likes batches
|
|
52
54
|
const batchSize = 100;
|
|
53
55
|
for (let i = 0; i < vectors.length; i += batchSize) {
|
|
54
|
-
|
|
56
|
+
await this.index.upsert(vectors.slice(i, i + batchSize));
|
|
55
57
|
}
|
|
56
58
|
}
|
|
57
59
|
|
|
@@ -62,13 +64,13 @@ export class PineconeStore extends BaseVectorStore {
|
|
|
62
64
|
const results = await this.index.query({
|
|
63
65
|
vector,
|
|
64
66
|
topK: limit,
|
|
65
|
-
includeMetadata: true
|
|
67
|
+
includeMetadata: true,
|
|
66
68
|
});
|
|
67
69
|
|
|
68
|
-
return results.matches.map(match => ({
|
|
70
|
+
return results.matches.map((match) => ({
|
|
69
71
|
text: match.metadata.text || '', // Assuming text is stored in metadata
|
|
70
72
|
score: match.score,
|
|
71
|
-
metadata: match.metadata
|
|
73
|
+
metadata: match.metadata,
|
|
72
74
|
}));
|
|
73
75
|
}
|
|
74
76
|
}
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ranked code search: BM25 over code chunks.
|
|
3
|
+
*
|
|
4
|
+
* A chunk is a method/function found by the method analyzers or, for code outside them and
|
|
5
|
+
* for files without methods, a window of up to 40 lines. Identifiers are split into their
|
|
6
|
+
* words (camelCase, snake_case, kebab-case) and lowercased, so `getUserName`, `get_user_name`
|
|
7
|
+
* and "user name" find each other. Pure JavaScript, no dependencies.
|
|
8
|
+
*
|
|
9
|
+
* The per-project index lives in memory and is rebuilt only for files whose modification
|
|
10
|
+
* time or size changed since the last search.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { Scanner } from '../core/Scanner.js';
|
|
16
|
+
import TokenUtils from '../utils/token-utils.js';
|
|
17
|
+
import { redactSecrets } from '../utils/secret-redactor.js';
|
|
18
|
+
import { readFileChecked } from '../utils/safe-read.js';
|
|
19
|
+
import { extractCodeUnits } from './code-units.js';
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_LIMIT = 10;
|
|
22
|
+
export const DEFAULT_MAX_TOKENS = 4000;
|
|
23
|
+
export const WINDOW_LINES = 40;
|
|
24
|
+
/** Files above this size (minified bundles, generated data) are not indexed */
|
|
25
|
+
export const MAX_FILE_BYTES = 1024 * 1024;
|
|
26
|
+
const MAX_CACHED_PROJECTS = 8;
|
|
27
|
+
|
|
28
|
+
// A word: letters, digits, `_` and `$`, optionally joined by `-` (kebab-case)
|
|
29
|
+
const WORD = /[\p{L}\p{N}_$]+(?:-[\p{L}\p{N}_$]+)*/gu;
|
|
30
|
+
// Parts of a camelCase word: `HTTPServer2Go` -> HTTP, Server, 2, Go
|
|
31
|
+
const PART = /\p{Lu}+(?!\p{Ll})|\p{Lu}?[\p{Ll}\p{Lo}\p{Lm}]+|\p{N}+/gu;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Fold an English plural to its singular, conservatively: `users` -> `user`,
|
|
35
|
+
* `classes` -> `class`; `status`, `class` and `analysis` stay as they are
|
|
36
|
+
* @param {string} term - Lower-case term
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function normalizeTerm(term) {
|
|
40
|
+
if (term.length <= 3 || !term.endsWith('s')) return term;
|
|
41
|
+
if (/(?:ss|us|is)$/.test(term)) return term;
|
|
42
|
+
if (/(?:sses|shes|ches|xes|zes)$/.test(term)) return term.slice(0, -2);
|
|
43
|
+
if (term.endsWith('ies') && term.length > 4) return `${term.slice(0, -3)}y`;
|
|
44
|
+
return term.slice(0, -1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Split text into search terms.
|
|
49
|
+
*
|
|
50
|
+
* Each identifier yields its lower-cased parts (`parseHTTPResponse` -> parse, http,
|
|
51
|
+
* response) plus, when it has several parts, the joined form (`parsehttpresponse`), so an
|
|
52
|
+
* exact identifier query ranks the identifier above code that merely uses the same words.
|
|
53
|
+
* Single characters are dropped.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} text
|
|
56
|
+
* @returns {string[]}
|
|
57
|
+
*/
|
|
58
|
+
export function tokenize(text) {
|
|
59
|
+
const terms = [];
|
|
60
|
+
for (const [word] of String(text).matchAll(WORD)) {
|
|
61
|
+
const parts = [];
|
|
62
|
+
for (const piece of word.split(/[-_$]+/)) {
|
|
63
|
+
for (const [part] of piece.matchAll(PART)) parts.push(part.toLowerCase());
|
|
64
|
+
}
|
|
65
|
+
for (const part of parts) {
|
|
66
|
+
if (part.length > 1) terms.push(normalizeTerm(part));
|
|
67
|
+
}
|
|
68
|
+
if (parts.length > 1) {
|
|
69
|
+
terms.push(normalizeTerm(parts.join('')));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return terms;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Okapi BM25 index over documents given as term lists.
|
|
77
|
+
*
|
|
78
|
+
* IDF is the non-negative variant ln(1 + (N - n + 0.5) / (n + 0.5)). Results with equal
|
|
79
|
+
* scores keep the order in which their documents were added.
|
|
80
|
+
*/
|
|
81
|
+
export class BM25Index {
|
|
82
|
+
/**
|
|
83
|
+
* @param {Object} [options]
|
|
84
|
+
* @param {number} [options.k1=1.2] - Term frequency saturation
|
|
85
|
+
* @param {number} [options.b=0.75] - Length normalisation
|
|
86
|
+
*/
|
|
87
|
+
constructor({ k1 = 1.2, b = 0.75 } = {}) {
|
|
88
|
+
this.k1 = k1;
|
|
89
|
+
this.b = b;
|
|
90
|
+
this.docs = [];
|
|
91
|
+
this.lengths = [];
|
|
92
|
+
this.postings = new Map();
|
|
93
|
+
this.totalLength = 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Number of documents */
|
|
97
|
+
get size() {
|
|
98
|
+
return this.docs.length;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Add a document
|
|
103
|
+
* @param {*} doc - Returned with the search results
|
|
104
|
+
* @param {string[]} terms - Terms of the document (see tokenize())
|
|
105
|
+
* @returns {number} Document id
|
|
106
|
+
*/
|
|
107
|
+
add(doc, terms) {
|
|
108
|
+
const id = this.docs.length;
|
|
109
|
+
const frequencies = new Map();
|
|
110
|
+
for (const term of terms) frequencies.set(term, (frequencies.get(term) || 0) + 1);
|
|
111
|
+
for (const [term, tf] of frequencies) {
|
|
112
|
+
let list = this.postings.get(term);
|
|
113
|
+
if (!list) {
|
|
114
|
+
list = [];
|
|
115
|
+
this.postings.set(term, list);
|
|
116
|
+
}
|
|
117
|
+
list.push(id, tf);
|
|
118
|
+
}
|
|
119
|
+
this.docs.push(doc);
|
|
120
|
+
this.lengths.push(terms.length);
|
|
121
|
+
this.totalLength += terms.length;
|
|
122
|
+
return id;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Inverse document frequency of a term
|
|
127
|
+
* @param {string} term
|
|
128
|
+
* @returns {number}
|
|
129
|
+
*/
|
|
130
|
+
idf(term) {
|
|
131
|
+
const n = (this.postings.get(term)?.length ?? 0) / 2;
|
|
132
|
+
return Math.log(1 + (this.docs.length - n + 0.5) / (n + 0.5));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Rank documents for a query
|
|
137
|
+
* @param {string|string[]} query - Query text, or terms
|
|
138
|
+
* @param {Object} [options]
|
|
139
|
+
* @param {number} [options.limit=10] - Maximum number of results
|
|
140
|
+
* @returns {Array<{doc: *, id: number, score: number}>} Best first
|
|
141
|
+
*/
|
|
142
|
+
search(query, { limit = DEFAULT_LIMIT } = {}) {
|
|
143
|
+
const terms = [...new Set(Array.isArray(query) ? query : tokenize(query))];
|
|
144
|
+
if (this.docs.length === 0 || terms.length === 0) return [];
|
|
145
|
+
|
|
146
|
+
const avgLength = this.totalLength / this.docs.length || 1;
|
|
147
|
+
const scores = new Map();
|
|
148
|
+
for (const term of terms) {
|
|
149
|
+
const list = this.postings.get(term);
|
|
150
|
+
if (!list) continue;
|
|
151
|
+
const idf = this.idf(term);
|
|
152
|
+
for (let i = 0; i < list.length; i += 2) {
|
|
153
|
+
const id = list[i];
|
|
154
|
+
const tf = list[i + 1];
|
|
155
|
+
const norm = this.k1 * (1 - this.b + (this.b * this.lengths[id]) / avgLength);
|
|
156
|
+
scores.set(id, (scores.get(id) || 0) + (idf * tf * (this.k1 + 1)) / (tf + norm));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return [...scores]
|
|
161
|
+
.sort((a, b) => b[1] - a[1] || a[0] - b[0])
|
|
162
|
+
.slice(0, Math.max(0, limit))
|
|
163
|
+
.map(([id, score]) => ({ doc: this.docs[id], id, score }));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Split a file into search chunks: its methods/functions, plus windows of at most
|
|
169
|
+
* `windowLines` lines over the code outside them (the whole file when it has none).
|
|
170
|
+
* Windows without a single term are dropped.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} content - File content
|
|
173
|
+
* @param {string} filePath - Path used for the language (extension)
|
|
174
|
+
* @param {Object} [options]
|
|
175
|
+
* @param {number} [options.windowLines=40]
|
|
176
|
+
* @param {Array} [options.units] - Code units, when already extracted
|
|
177
|
+
* @returns {Array<{name: string|null, kind: string, parent?: string, startLine: number,
|
|
178
|
+
* endLine: number}>} Sorted by start line
|
|
179
|
+
*/
|
|
180
|
+
export function chunkFile(content, filePath, { windowLines = WINDOW_LINES, units } = {}) {
|
|
181
|
+
const lines = content.split('\n');
|
|
182
|
+
// A trailing newline does not start another line
|
|
183
|
+
const lineCount = lines.length > 1 && lines.at(-1) === '' ? lines.length - 1 : lines.length;
|
|
184
|
+
const methods = (units ?? extractCodeUnits(content, filePath)).filter((u) => !u.container);
|
|
185
|
+
|
|
186
|
+
const chunks = methods.map((unit) => ({
|
|
187
|
+
name: unit.name,
|
|
188
|
+
kind: unit.kind,
|
|
189
|
+
...(unit.parent ? { parent: unit.parent } : {}),
|
|
190
|
+
startLine: unit.startLine,
|
|
191
|
+
endLine: Math.min(unit.endLine, lineCount),
|
|
192
|
+
}));
|
|
193
|
+
|
|
194
|
+
const covered = new Uint8Array(lineCount + 2);
|
|
195
|
+
for (const chunk of chunks) covered.fill(1, chunk.startLine, chunk.endLine + 1);
|
|
196
|
+
|
|
197
|
+
let line = 1;
|
|
198
|
+
while (line <= lineCount) {
|
|
199
|
+
// Windows start and end on a non-blank line
|
|
200
|
+
if (covered[line] || !lines[line - 1].trim()) {
|
|
201
|
+
line++;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const start = line;
|
|
205
|
+
while (line <= lineCount && !covered[line] && line - start < windowLines) line++;
|
|
206
|
+
let end = line - 1;
|
|
207
|
+
while (end > start && !lines[end - 1].trim()) end--;
|
|
208
|
+
// Same test as tokenize() yielding a term: a word of two or more characters
|
|
209
|
+
if (/[\p{L}\p{N}]{2}/u.test(lines.slice(start - 1, end).join('\n'))) {
|
|
210
|
+
chunks.push({ name: null, kind: 'window', startLine: start, endLine: end });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return chunks.sort((a, b) => a.startLine - b.startLine || a.endLine - b.endLine);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Source text of a line range (1-based, inclusive)
|
|
219
|
+
* @param {string} content
|
|
220
|
+
* @param {number} startLine
|
|
221
|
+
* @param {number} endLine
|
|
222
|
+
* @returns {string}
|
|
223
|
+
*/
|
|
224
|
+
export function sliceLines(content, startLine, endLine) {
|
|
225
|
+
return content
|
|
226
|
+
.split('\n')
|
|
227
|
+
.slice(startLine - 1, endLine)
|
|
228
|
+
.join('\n');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Code-point order, independent of the locale
|
|
233
|
+
* @param {string} a
|
|
234
|
+
* @param {string} b
|
|
235
|
+
* @returns {number}
|
|
236
|
+
*/
|
|
237
|
+
export function compareStrings(a, b) {
|
|
238
|
+
if (a < b) return -1;
|
|
239
|
+
return a > b ? 1 : 0;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// root + options -> { signature, files: Map<relativePath, entry>, index }
|
|
243
|
+
const projectCache = new Map();
|
|
244
|
+
|
|
245
|
+
/** Forget every cached project index (tests, long-running servers after big changes) */
|
|
246
|
+
export function clearSearchCache() {
|
|
247
|
+
projectCache.clear();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Load a file entry: content, code units and chunks, reused while mtime and size match
|
|
252
|
+
* @private
|
|
253
|
+
*/
|
|
254
|
+
function loadFile(file, previous, windowLines) {
|
|
255
|
+
let stat;
|
|
256
|
+
try {
|
|
257
|
+
stat = fs.statSync(file.path);
|
|
258
|
+
} catch (_error) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
const signature = `${stat.mtimeMs}:${stat.size}`;
|
|
262
|
+
if (previous && previous.signature === signature) return previous;
|
|
263
|
+
|
|
264
|
+
const entry = {
|
|
265
|
+
path: file.path,
|
|
266
|
+
relativePath: file.relativePath,
|
|
267
|
+
signature,
|
|
268
|
+
content: '',
|
|
269
|
+
units: [],
|
|
270
|
+
chunks: [],
|
|
271
|
+
};
|
|
272
|
+
const read = readFileChecked(file.path, { maxBytes: MAX_FILE_BYTES });
|
|
273
|
+
if (read.status === 'too-large') return { ...entry, skipped: 'too large' };
|
|
274
|
+
if (read.status !== 'ok') return null;
|
|
275
|
+
entry.content = read.buffer.toString('utf-8');
|
|
276
|
+
entry.units = extractCodeUnits(entry.content, file.path);
|
|
277
|
+
entry.chunks = chunkFile(entry.content, file.path, { windowLines, units: entry.units });
|
|
278
|
+
return entry;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The in-memory index of a project, built on first use and refreshed when files are added,
|
|
283
|
+
* removed or changed (by modification time and size). Files are found with the Scanner, so
|
|
284
|
+
* .gitignore, .contextignore and .contextinclude apply.
|
|
285
|
+
*
|
|
286
|
+
* @param {string} root - Project directory
|
|
287
|
+
* @param {Object} [options]
|
|
288
|
+
* @param {number} [options.windowLines=40]
|
|
289
|
+
* @returns {{root: string, files: Map<string, Object>, index: BM25Index, signature: string,
|
|
290
|
+
* reused: boolean}}
|
|
291
|
+
* @throws {Error} When root is not a directory
|
|
292
|
+
*/
|
|
293
|
+
export function getProjectIndex(root, { windowLines = WINDOW_LINES } = {}) {
|
|
294
|
+
const resolvedRoot = path.resolve(root);
|
|
295
|
+
if (!fs.statSync(resolvedRoot, { throwIfNoEntry: false })?.isDirectory()) {
|
|
296
|
+
throw new Error(`Not a directory: ${root}`);
|
|
297
|
+
}
|
|
298
|
+
const key = `${resolvedRoot}\0${windowLines}`;
|
|
299
|
+
const cached = projectCache.get(key);
|
|
300
|
+
|
|
301
|
+
const scanned = new Scanner(resolvedRoot)
|
|
302
|
+
.scan()
|
|
303
|
+
.sort((a, b) => compareStrings(a.relativePath, b.relativePath));
|
|
304
|
+
|
|
305
|
+
const files = new Map();
|
|
306
|
+
const signatureParts = [];
|
|
307
|
+
for (const file of scanned) {
|
|
308
|
+
const entry = loadFile(file, cached?.files.get(file.relativePath), windowLines);
|
|
309
|
+
if (!entry) continue;
|
|
310
|
+
files.set(file.relativePath, entry);
|
|
311
|
+
signatureParts.push(`${file.relativePath}\0${entry.signature}`);
|
|
312
|
+
}
|
|
313
|
+
const signature = signatureParts.join('\n');
|
|
314
|
+
|
|
315
|
+
// Most recently used last, so the first key is the one to evict
|
|
316
|
+
projectCache.delete(key);
|
|
317
|
+
if (cached && cached.signature === signature) {
|
|
318
|
+
projectCache.set(key, cached);
|
|
319
|
+
return { ...cached, reused: true };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const index = new BM25Index();
|
|
323
|
+
for (const entry of files.values()) {
|
|
324
|
+
const pathTerms = tokenize(entry.relativePath);
|
|
325
|
+
const lines = entry.content.split('\n');
|
|
326
|
+
for (const chunk of entry.chunks) {
|
|
327
|
+
// Index the redacted text, so the ranking cannot be used to probe secret values
|
|
328
|
+
const text = redactSecrets(lines.slice(chunk.startLine - 1, chunk.endLine).join('\n')).text;
|
|
329
|
+
const nameTerms = chunk.name ? tokenize(chunk.name) : [];
|
|
330
|
+
// The name counts three times and the file path once on top of the text
|
|
331
|
+
index.add({ entry, chunk }, [
|
|
332
|
+
...tokenize(text),
|
|
333
|
+
...nameTerms,
|
|
334
|
+
...nameTerms,
|
|
335
|
+
...nameTerms,
|
|
336
|
+
...pathTerms,
|
|
337
|
+
]);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const project = { root: resolvedRoot, files, index, signature };
|
|
342
|
+
projectCache.set(key, project);
|
|
343
|
+
while (projectCache.size > MAX_CACHED_PROJECTS) {
|
|
344
|
+
projectCache.delete(projectCache.keys().next().value);
|
|
345
|
+
}
|
|
346
|
+
return { ...project, reused: false };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Cut text to its first lines that fit in a token budget
|
|
351
|
+
* @param {string} text
|
|
352
|
+
* @param {number} budget - Tokens
|
|
353
|
+
* @param {string} filePath - For the token estimate when tiktoken is missing
|
|
354
|
+
* @returns {string} The fitting prefix ('' when not even one line fits)
|
|
355
|
+
*/
|
|
356
|
+
export function fitToTokenBudget(text, budget, filePath) {
|
|
357
|
+
const lines = text.split('\n');
|
|
358
|
+
let low = 0;
|
|
359
|
+
let high = lines.length;
|
|
360
|
+
// Largest line count whose text fits
|
|
361
|
+
while (low < high) {
|
|
362
|
+
const mid = (low + high + 1) >> 1;
|
|
363
|
+
if (TokenUtils.calculate(lines.slice(0, mid).join('\n'), filePath) <= budget) low = mid;
|
|
364
|
+
else high = mid - 1;
|
|
365
|
+
}
|
|
366
|
+
return lines.slice(0, low).join('\n');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Ranked search over a project.
|
|
371
|
+
*
|
|
372
|
+
* @param {string} root - Project directory
|
|
373
|
+
* @param {string} query - Words or identifiers to look for
|
|
374
|
+
* @param {Object} [options]
|
|
375
|
+
* @param {number} [options.limit=10] - Maximum number of chunks
|
|
376
|
+
* @param {number} [options.maxTokens=4000] - Token budget for the chunk texts; chunks past
|
|
377
|
+
* it are cut (`truncated: true`) or listed without text
|
|
378
|
+
* @param {boolean} [options.redact=true] - Redact secrets in the returned text
|
|
379
|
+
* @param {number} [options.windowLines=40] - Window size for code outside methods
|
|
380
|
+
* @returns {{query: string, root: string, files: number, chunks: number, tokens: number,
|
|
381
|
+
* results: Array<{file: string, name: string|null, kind: string, parent?: string,
|
|
382
|
+
* startLine: number, endLine: number, score: number, tokens: number, text: string|null,
|
|
383
|
+
* truncated?: boolean}>}}
|
|
384
|
+
*/
|
|
385
|
+
export function searchProject(
|
|
386
|
+
root,
|
|
387
|
+
query,
|
|
388
|
+
{
|
|
389
|
+
limit = DEFAULT_LIMIT,
|
|
390
|
+
maxTokens = DEFAULT_MAX_TOKENS,
|
|
391
|
+
redact = true,
|
|
392
|
+
windowLines = WINDOW_LINES,
|
|
393
|
+
} = {}
|
|
394
|
+
) {
|
|
395
|
+
if (typeof query !== 'string' || !query.trim()) {
|
|
396
|
+
throw new Error('The search query is empty');
|
|
397
|
+
}
|
|
398
|
+
const project = getProjectIndex(root, { windowLines });
|
|
399
|
+
const hits = project.index.search(query, { limit });
|
|
400
|
+
|
|
401
|
+
let used = 0;
|
|
402
|
+
const results = hits.map(({ doc: { entry, chunk }, score }) => {
|
|
403
|
+
const raw = sliceLines(entry.content, chunk.startLine, chunk.endLine);
|
|
404
|
+
let text = redact ? redactSecrets(raw).text : raw;
|
|
405
|
+
let tokens = TokenUtils.calculate(text, entry.path);
|
|
406
|
+
const result = {
|
|
407
|
+
file: entry.relativePath,
|
|
408
|
+
name: chunk.name,
|
|
409
|
+
kind: chunk.kind,
|
|
410
|
+
...(chunk.parent ? { parent: chunk.parent } : {}),
|
|
411
|
+
startLine: chunk.startLine,
|
|
412
|
+
endLine: chunk.endLine,
|
|
413
|
+
score: Math.round(score * 10000) / 10000,
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
if (used + tokens > maxTokens) {
|
|
417
|
+
text = fitToTokenBudget(text, maxTokens - used, entry.path);
|
|
418
|
+
tokens = text ? TokenUtils.calculate(text, entry.path) : 0;
|
|
419
|
+
result.truncated = true;
|
|
420
|
+
}
|
|
421
|
+
used += tokens;
|
|
422
|
+
return { ...result, tokens, text: text || null };
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
query,
|
|
427
|
+
root: project.root,
|
|
428
|
+
files: project.files.size,
|
|
429
|
+
chunks: project.index.size,
|
|
430
|
+
tokens: used,
|
|
431
|
+
results,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code units of a source file: methods/functions (from the existing method analyzers) and
|
|
3
|
+
* class-like declarations, each with the line range of its source.
|
|
4
|
+
*
|
|
5
|
+
* Shared by the ranked search (lib/search/bm25.js) and symbol lookup (lib/search/symbols.js).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import MethodAnalyzer from '../analyzers/method-analyzer.js';
|
|
10
|
+
|
|
11
|
+
/** Extensions the method analyzers understand; other files have no code units */
|
|
12
|
+
export const CODE_EXTENSIONS = new Set([
|
|
13
|
+
'.js',
|
|
14
|
+
'.jsx',
|
|
15
|
+
'.mjs',
|
|
16
|
+
'.cjs',
|
|
17
|
+
'.ts',
|
|
18
|
+
'.tsx',
|
|
19
|
+
'.mts',
|
|
20
|
+
'.cts',
|
|
21
|
+
'.rs',
|
|
22
|
+
'.go',
|
|
23
|
+
'.java',
|
|
24
|
+
'.cs',
|
|
25
|
+
'.py',
|
|
26
|
+
'.php',
|
|
27
|
+
'.rb',
|
|
28
|
+
'.kt',
|
|
29
|
+
'.kts',
|
|
30
|
+
'.swift',
|
|
31
|
+
'.c',
|
|
32
|
+
'.cpp',
|
|
33
|
+
'.cc',
|
|
34
|
+
'.h',
|
|
35
|
+
'.hpp',
|
|
36
|
+
'.scala',
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const MAX_AVERAGE_LINE_LENGTH = 250;
|
|
40
|
+
|
|
41
|
+
// Modifiers that may precede a class-like keyword in the brace languages
|
|
42
|
+
const MODIFIERS =
|
|
43
|
+
'(?:(?:export|default|declare|public|private|protected|internal|static|abstract|final|sealed|' +
|
|
44
|
+
'partial|open|data|inner|value|annotation|readonly|override|case|implicit|lazy|unsafe|' +
|
|
45
|
+
'pub(?:\\([^)]*\\))?|fileprivate)\\s+)*';
|
|
46
|
+
|
|
47
|
+
const CLASS_PATTERNS = {
|
|
48
|
+
brace: new RegExp(
|
|
49
|
+
`^[ \\t]*${MODIFIERS}(class|interface|enum(?:\\s+(?:class|struct))?|struct|record|trait|object|protocol|extension|union)\\s+([A-Za-z_$][\\w$]*)`,
|
|
50
|
+
'gm'
|
|
51
|
+
),
|
|
52
|
+
rustImpl:
|
|
53
|
+
/^[ \t]*(?:unsafe\s+)?impl(?:\s*<[^>]*>)?\s+(?:[\w:]+(?:<[^>]*>)?\s+for\s+)?([A-Za-z_]\w*)/gm,
|
|
54
|
+
goType: /^type\s+([A-Za-z_]\w*)\s+(struct|interface)\b/gm,
|
|
55
|
+
python: /^[ \t]*class\s+([A-Za-z_]\w*)/gm,
|
|
56
|
+
ruby: /^[ \t]*(class|module)\s+([A-Z]\w*(?:::\w+)*)/gm,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const analyzer = new MethodAnalyzer();
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Function mapping a string index to its 1-based line number
|
|
63
|
+
* @param {string} content
|
|
64
|
+
* @returns {(index: number) => number}
|
|
65
|
+
*/
|
|
66
|
+
function lineLocator(content) {
|
|
67
|
+
const breaks = [];
|
|
68
|
+
for (let i = content.indexOf('\n'); i !== -1; i = content.indexOf('\n', i + 1)) breaks.push(i);
|
|
69
|
+
return (index) => {
|
|
70
|
+
// Number of line breaks before `index`
|
|
71
|
+
let low = 0;
|
|
72
|
+
let high = breaks.length;
|
|
73
|
+
while (low < high) {
|
|
74
|
+
const mid = (low + high) >> 1;
|
|
75
|
+
if (breaks[mid] < index) low = mid + 1;
|
|
76
|
+
else high = mid;
|
|
77
|
+
}
|
|
78
|
+
return low + 1;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Class-like declarations (classes, interfaces, structs, enums, traits, modules, impl blocks)
|
|
84
|
+
* @param {string} content
|
|
85
|
+
* @param {string} ext - Lower-case file extension
|
|
86
|
+
* @returns {Array<{name: string, kind: string, line: number}>}
|
|
87
|
+
*/
|
|
88
|
+
function findClassDeclarations(content, ext) {
|
|
89
|
+
const found = [];
|
|
90
|
+
const lineAt = lineLocator(content);
|
|
91
|
+
const collect = (regex, pick) => {
|
|
92
|
+
for (const match of content.matchAll(regex)) {
|
|
93
|
+
const { name, kind } = pick(match);
|
|
94
|
+
found.push({ name, kind, line: lineAt(match.index + match[0].lastIndexOf(name)) });
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (ext === '.py') {
|
|
99
|
+
collect(CLASS_PATTERNS.python, (m) => ({ name: m[1], kind: 'class' }));
|
|
100
|
+
} else if (ext === '.rb') {
|
|
101
|
+
collect(CLASS_PATTERNS.ruby, (m) => ({ name: m[2], kind: m[1] }));
|
|
102
|
+
} else if (ext === '.go') {
|
|
103
|
+
collect(CLASS_PATTERNS.goType, (m) => ({ name: m[1], kind: m[2] }));
|
|
104
|
+
} else {
|
|
105
|
+
collect(CLASS_PATTERNS.brace, (m) => ({ name: m[2], kind: m[1].replace(/\s+/g, ' ') }));
|
|
106
|
+
if (ext === '.rs') {
|
|
107
|
+
collect(CLASS_PATTERNS.rustImpl, (m) => ({ name: m[1], kind: 'impl' }));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return found;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Methods, functions and class-like declarations of a file with their line ranges.
|
|
115
|
+
*
|
|
116
|
+
* Method ranges come from MethodAnalyzer.extractMethodSources(); class ranges use the same
|
|
117
|
+
* block rules (braces, Python indentation, Ruby `end`). Units are sorted by start line.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} content - File content
|
|
120
|
+
* @param {string} filePath - Used for the language (extension)
|
|
121
|
+
* @returns {Array<{name: string, kind: string, startLine: number, endLine: number,
|
|
122
|
+
* container?: boolean, parent?: string}>} `container` marks class-like declarations;
|
|
123
|
+
* `parent` names the class-like declaration a method lies in
|
|
124
|
+
*/
|
|
125
|
+
export function extractCodeUnits(content, filePath) {
|
|
126
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
127
|
+
if (!CODE_EXTENSIONS.has(ext)) return [];
|
|
128
|
+
|
|
129
|
+
const lines = content.split('\n');
|
|
130
|
+
// Minified or generated code: the regex analyzers crawl over very long lines and find
|
|
131
|
+
// nothing useful, so such files are searched by line windows only
|
|
132
|
+
if (content.length / lines.length > MAX_AVERAGE_LINE_LENGTH) return [];
|
|
133
|
+
let methods = [];
|
|
134
|
+
try {
|
|
135
|
+
methods = analyzer.extractMethods(content, filePath);
|
|
136
|
+
} catch (_error) {
|
|
137
|
+
// A regex analyzer failing on odd input leaves the file to the line windows
|
|
138
|
+
}
|
|
139
|
+
const methodSources = analyzer.extractMethodSources(content, methods, filePath);
|
|
140
|
+
const units = methods.map((method, i) => ({
|
|
141
|
+
name: method.name,
|
|
142
|
+
kind: method.type || 'function',
|
|
143
|
+
startLine: method.line,
|
|
144
|
+
endLine: method.line + methodSources[i].split('\n').length - 1,
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
const classes = findClassDeclarations(content, ext);
|
|
148
|
+
const starts = [...new Set([...units, ...classes].map((u) => u.startLine ?? u.line))].sort(
|
|
149
|
+
(a, b) => a - b
|
|
150
|
+
);
|
|
151
|
+
for (const decl of classes) {
|
|
152
|
+
const start = decl.line - 1;
|
|
153
|
+
let end;
|
|
154
|
+
if (ext === '.py') {
|
|
155
|
+
end = analyzer.findIndentedBlockEnd(lines, start);
|
|
156
|
+
} else if (ext === '.rb') {
|
|
157
|
+
end = analyzer.findRubyBlockEnd(lines, start);
|
|
158
|
+
} else if (ext === '.go') {
|
|
159
|
+
end = analyzer.findBraceBlockEnd(lines, start, lines.length);
|
|
160
|
+
} else {
|
|
161
|
+
// A declaration without a body (`class Foo;`, `case class Foo(x: Int)`) must not run
|
|
162
|
+
// into the body of the next unit
|
|
163
|
+
const next = starts.find((line) => line > decl.line);
|
|
164
|
+
end = analyzer.findBraceBlockEnd(lines, start, next ? next - 1 : lines.length);
|
|
165
|
+
}
|
|
166
|
+
units.push({
|
|
167
|
+
name: decl.name,
|
|
168
|
+
kind: decl.kind,
|
|
169
|
+
startLine: decl.line,
|
|
170
|
+
endLine: end + 1,
|
|
171
|
+
container: true,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
units.sort((a, b) => a.startLine - b.startLine || b.endLine - a.endLine);
|
|
176
|
+
|
|
177
|
+
// Go methods belong to their receiver type: `func (s *Server) Start()`
|
|
178
|
+
if (ext === '.go') {
|
|
179
|
+
for (const unit of units) {
|
|
180
|
+
const receiver = /^\s*func\s*\(\s*(?:\w+\s+)?\*?\s*([A-Za-z_]\w*)/.exec(
|
|
181
|
+
lines[unit.startLine - 1] ?? ''
|
|
182
|
+
);
|
|
183
|
+
if (receiver && !unit.container) unit.parent = receiver[1];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Functions inside a class-like block are its methods
|
|
188
|
+
const containers = units.filter((unit) => unit.container);
|
|
189
|
+
for (const unit of units) {
|
|
190
|
+
if (unit.container) continue;
|
|
191
|
+
const parent = containers
|
|
192
|
+
.filter((c) => c.startLine < unit.startLine && c.endLine >= unit.endLine)
|
|
193
|
+
.at(-1);
|
|
194
|
+
if (parent) {
|
|
195
|
+
unit.parent = parent.name;
|
|
196
|
+
if (unit.kind === 'function') unit.kind = 'method';
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return units;
|
|
201
|
+
}
|