smart-context-mcp 1.0.4 → 1.2.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/README.md +196 -586
- package/package.json +11 -7
- package/scripts/init-clients.js +56 -27
- package/scripts/report-metrics.js +5 -0
- package/scripts/report-workflow-metrics.js +255 -0
- package/src/analytics/adoption.js +197 -0
- package/src/cache-warming.js +131 -0
- package/src/context-patterns.js +192 -0
- package/src/cross-project.js +343 -0
- package/src/diff-analysis.js +291 -0
- package/src/git-blame.js +324 -0
- package/src/index.js +54 -5
- package/src/metrics.js +6 -1
- package/src/server.js +199 -13
- package/src/storage/sqlite.js +50 -1
- package/src/streaming.js +152 -0
- package/src/tools/smart-context.js +115 -6
- package/src/tools/smart-metrics.js +7 -0
- package/src/tools/smart-read-batch.js +9 -0
- package/src/tools/smart-read.js +21 -1
- package/src/tools/smart-shell.js +33 -9
- package/src/tools/smart-turn.js +1 -0
- package/src/workflow-tracker-stub.js +53 -0
- package/src/workflow-tracker.js +410 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache warming for intelligent index preloading.
|
|
3
|
+
*
|
|
4
|
+
* Analyzes usage patterns and preloads frequently accessed files into memory
|
|
5
|
+
* to reduce cold-start latency on first queries.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { loadIndex } from './index.js';
|
|
11
|
+
import { withStateDb, getStateDbPath } from './storage/sqlite.js';
|
|
12
|
+
import { projectRoot } from './utils/paths.js';
|
|
13
|
+
|
|
14
|
+
const isCacheWarmingEnabled = () => process.env.DEVCTX_CACHE_WARMING !== 'false';
|
|
15
|
+
const WARM_TOP_N_FILES = parseInt(process.env.DEVCTX_WARM_FILES || '50', 10);
|
|
16
|
+
const MIN_ACCESS_COUNT = 3;
|
|
17
|
+
|
|
18
|
+
export const getFrequentlyAccessedFiles = async (root = projectRoot, limit = WARM_TOP_N_FILES) => {
|
|
19
|
+
if (!isCacheWarmingEnabled()) return [];
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const dbPath = getStateDbPath(root);
|
|
23
|
+
if (!fs.existsSync(dbPath)) return [];
|
|
24
|
+
|
|
25
|
+
const files = await withStateDb(async (db) => {
|
|
26
|
+
const rows = db.prepare(`
|
|
27
|
+
SELECT file_path, COUNT(*) as access_count
|
|
28
|
+
FROM context_access
|
|
29
|
+
WHERE timestamp > datetime('now', '-30 days')
|
|
30
|
+
GROUP BY file_path
|
|
31
|
+
HAVING access_count >= ?
|
|
32
|
+
ORDER BY access_count DESC, MAX(timestamp) DESC
|
|
33
|
+
LIMIT ?
|
|
34
|
+
`).all(MIN_ACCESS_COUNT, limit);
|
|
35
|
+
|
|
36
|
+
return rows.map(r => r.file_path);
|
|
37
|
+
}, { filePath: dbPath });
|
|
38
|
+
|
|
39
|
+
return files;
|
|
40
|
+
} catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const warmCache = async (root = projectRoot, progress = null) => {
|
|
46
|
+
if (!isCacheWarmingEnabled()) {
|
|
47
|
+
return { warmed: 0, skipped: 0, reason: 'disabled' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const index = loadIndex(root);
|
|
51
|
+
if (!index) {
|
|
52
|
+
return { warmed: 0, skipped: 0, reason: 'no_index' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const frequentFiles = await getFrequentlyAccessedFiles(root);
|
|
56
|
+
if (frequentFiles.length === 0) {
|
|
57
|
+
return { warmed: 0, skipped: 0, reason: 'no_frequent_files' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let warmed = 0;
|
|
61
|
+
let skipped = 0;
|
|
62
|
+
|
|
63
|
+
for (let i = 0; i < frequentFiles.length; i++) {
|
|
64
|
+
const relPath = frequentFiles[i];
|
|
65
|
+
const absPath = path.join(root, relPath);
|
|
66
|
+
|
|
67
|
+
if (progress && i % 10 === 0) {
|
|
68
|
+
progress.report({
|
|
69
|
+
phase: 'warming',
|
|
70
|
+
processed: i,
|
|
71
|
+
total: frequentFiles.length,
|
|
72
|
+
percentage: Math.round((i / frequentFiles.length) * 100),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
if (!fs.existsSync(absPath)) {
|
|
78
|
+
skipped++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const stats = fs.statSync(absPath);
|
|
83
|
+
if (stats.size > 1024 * 1024) {
|
|
84
|
+
skipped++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
fs.readFileSync(absPath, 'utf8');
|
|
89
|
+
warmed++;
|
|
90
|
+
} catch {
|
|
91
|
+
skipped++;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (progress) {
|
|
96
|
+
progress.report({
|
|
97
|
+
phase: 'warming',
|
|
98
|
+
processed: frequentFiles.length,
|
|
99
|
+
total: frequentFiles.length,
|
|
100
|
+
percentage: 100,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { warmed, skipped, totalCandidates: frequentFiles.length };
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const shouldWarmCache = async (root = projectRoot) => {
|
|
108
|
+
if (!isCacheWarmingEnabled()) return false;
|
|
109
|
+
|
|
110
|
+
const index = loadIndex(root);
|
|
111
|
+
if (!index) return false;
|
|
112
|
+
|
|
113
|
+
const frequentFiles = await getFrequentlyAccessedFiles(root, 10);
|
|
114
|
+
return frequentFiles.length >= 5;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const getCacheStats = async (root = projectRoot) => {
|
|
118
|
+
const frequentFiles = await getFrequentlyAccessedFiles(root, 100);
|
|
119
|
+
|
|
120
|
+
const byExtension = {};
|
|
121
|
+
for (const file of frequentFiles) {
|
|
122
|
+
const ext = path.extname(file) || 'no-ext';
|
|
123
|
+
byExtension[ext] = (byExtension[ext] || 0) + 1;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
totalFrequentFiles: frequentFiles.length,
|
|
128
|
+
byExtension,
|
|
129
|
+
topFiles: frequentFiles.slice(0, 10),
|
|
130
|
+
};
|
|
131
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { withStateDb, withStateDbSnapshot } from './storage/sqlite.js';
|
|
2
|
+
|
|
3
|
+
const PATTERN_CONFIDENCE_THRESHOLD = 0.6;
|
|
4
|
+
const MIN_PATTERN_OCCURRENCES = 3;
|
|
5
|
+
const MAX_PREDICTED_FILES = 8;
|
|
6
|
+
const PATTERN_DECAY_DAYS = 30;
|
|
7
|
+
|
|
8
|
+
const initPatternTables = (db) => {
|
|
9
|
+
db.exec(`
|
|
10
|
+
CREATE TABLE IF NOT EXISTS context_patterns (
|
|
11
|
+
pattern_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
12
|
+
task_signature TEXT NOT NULL,
|
|
13
|
+
intent TEXT,
|
|
14
|
+
occurrences INTEGER DEFAULT 1,
|
|
15
|
+
last_seen_at TEXT NOT NULL,
|
|
16
|
+
created_at TEXT NOT NULL,
|
|
17
|
+
UNIQUE(task_signature, intent)
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
CREATE TABLE IF NOT EXISTS pattern_files (
|
|
21
|
+
pattern_id INTEGER NOT NULL,
|
|
22
|
+
file_path TEXT NOT NULL,
|
|
23
|
+
access_order INTEGER NOT NULL,
|
|
24
|
+
access_count INTEGER DEFAULT 1,
|
|
25
|
+
avg_relevance REAL DEFAULT 1.0,
|
|
26
|
+
last_accessed_at TEXT NOT NULL,
|
|
27
|
+
FOREIGN KEY(pattern_id) REFERENCES context_patterns(pattern_id) ON DELETE CASCADE,
|
|
28
|
+
PRIMARY KEY(pattern_id, file_path)
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_pattern_signature ON context_patterns(task_signature);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_pattern_files_lookup ON pattern_files(pattern_id, avg_relevance DESC);
|
|
33
|
+
`);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const normalizeTaskSignature = (task) => {
|
|
37
|
+
return task
|
|
38
|
+
.toLowerCase()
|
|
39
|
+
.replace(/[^a-z0-9\s]/g, ' ')
|
|
40
|
+
.replace(/\s+/g, ' ')
|
|
41
|
+
.trim()
|
|
42
|
+
.split(' ')
|
|
43
|
+
.filter(word => word.length > 2)
|
|
44
|
+
.slice(0, 8)
|
|
45
|
+
.join(' ');
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const computeTaskSimilarity = (task1, task2) => {
|
|
49
|
+
const words1 = new Set(task1.split(' '));
|
|
50
|
+
const words2 = new Set(task2.split(' '));
|
|
51
|
+
const intersection = [...words1].filter(w => words2.has(w)).length;
|
|
52
|
+
const union = new Set([...words1, ...words2]).size;
|
|
53
|
+
return union > 0 ? intersection / union : 0;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const recordContextAccess = async ({ task, intent, files }) => {
|
|
57
|
+
if (!task || !Array.isArray(files) || files.length === 0) return;
|
|
58
|
+
|
|
59
|
+
const signature = normalizeTaskSignature(task);
|
|
60
|
+
if (!signature) return;
|
|
61
|
+
|
|
62
|
+
return withStateDb((db) => {
|
|
63
|
+
initPatternTables(db);
|
|
64
|
+
|
|
65
|
+
const now = new Date().toISOString();
|
|
66
|
+
|
|
67
|
+
db.exec('BEGIN');
|
|
68
|
+
try {
|
|
69
|
+
const existing = db.prepare(
|
|
70
|
+
'SELECT pattern_id, occurrences FROM context_patterns WHERE task_signature = ? AND intent = ?'
|
|
71
|
+
).get(signature, intent || 'explore');
|
|
72
|
+
|
|
73
|
+
let patternId;
|
|
74
|
+
if (existing) {
|
|
75
|
+
db.prepare(
|
|
76
|
+
'UPDATE context_patterns SET occurrences = occurrences + 1, last_seen_at = ? WHERE pattern_id = ?'
|
|
77
|
+
).run(now, existing.pattern_id);
|
|
78
|
+
patternId = existing.pattern_id;
|
|
79
|
+
} else {
|
|
80
|
+
const result = db.prepare(
|
|
81
|
+
'INSERT INTO context_patterns (task_signature, intent, last_seen_at, created_at) VALUES (?, ?, ?, ?)'
|
|
82
|
+
).run(signature, intent || 'explore', now, now);
|
|
83
|
+
patternId = result.lastInsertRowid;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < files.length; i++) {
|
|
87
|
+
const file = files[i];
|
|
88
|
+
const existingFile = db.prepare(
|
|
89
|
+
'SELECT access_count, avg_relevance FROM pattern_files WHERE pattern_id = ? AND file_path = ?'
|
|
90
|
+
).get(patternId, file.path);
|
|
91
|
+
|
|
92
|
+
if (existingFile) {
|
|
93
|
+
const newCount = existingFile.access_count + 1;
|
|
94
|
+
const newRelevance = (existingFile.avg_relevance * existingFile.access_count + (file.relevance || 1.0)) / newCount;
|
|
95
|
+
|
|
96
|
+
db.prepare(
|
|
97
|
+
'UPDATE pattern_files SET access_count = ?, avg_relevance = ?, last_accessed_at = ? WHERE pattern_id = ? AND file_path = ?'
|
|
98
|
+
).run(newCount, newRelevance, now, patternId, file.path);
|
|
99
|
+
} else {
|
|
100
|
+
db.prepare(
|
|
101
|
+
'INSERT INTO pattern_files (pattern_id, file_path, access_order, avg_relevance, last_accessed_at) VALUES (?, ?, ?, ?, ?)'
|
|
102
|
+
).run(patternId, file.path, i, file.relevance || 1.0, now);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
db.exec('COMMIT');
|
|
107
|
+
} catch (error) {
|
|
108
|
+
db.exec('ROLLBACK');
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const predictContextFiles = async ({ task, intent, maxFiles = MAX_PREDICTED_FILES }) => {
|
|
115
|
+
const signature = normalizeTaskSignature(task);
|
|
116
|
+
if (!signature) return { predicted: [], confidence: 0, matchedPattern: null };
|
|
117
|
+
|
|
118
|
+
return withStateDbSnapshot((db) => {
|
|
119
|
+
initPatternTables(db);
|
|
120
|
+
|
|
121
|
+
const cutoffDate = new Date(Date.now() - PATTERN_DECAY_DAYS * 24 * 60 * 60 * 1000).toISOString();
|
|
122
|
+
|
|
123
|
+
const patterns = db.prepare(`
|
|
124
|
+
SELECT pattern_id, task_signature, intent, occurrences, last_seen_at
|
|
125
|
+
FROM context_patterns
|
|
126
|
+
WHERE last_seen_at > ?
|
|
127
|
+
ORDER BY occurrences DESC
|
|
128
|
+
LIMIT 20
|
|
129
|
+
`).all(cutoffDate);
|
|
130
|
+
|
|
131
|
+
let bestMatch = null;
|
|
132
|
+
let bestScore = 0;
|
|
133
|
+
|
|
134
|
+
for (const pattern of patterns) {
|
|
135
|
+
if (intent && pattern.intent !== intent) continue;
|
|
136
|
+
|
|
137
|
+
const similarity = computeTaskSimilarity(signature, pattern.task_signature);
|
|
138
|
+
const recencyBonus = pattern.occurrences >= MIN_PATTERN_OCCURRENCES ? 0.1 : 0;
|
|
139
|
+
const score = similarity + recencyBonus;
|
|
140
|
+
|
|
141
|
+
if (score > bestScore && score >= PATTERN_CONFIDENCE_THRESHOLD) {
|
|
142
|
+
bestScore = score;
|
|
143
|
+
bestMatch = pattern;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!bestMatch) {
|
|
148
|
+
return { predicted: [], confidence: 0, matchedPattern: null };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const files = db.prepare(`
|
|
152
|
+
SELECT file_path, access_order, access_count, avg_relevance
|
|
153
|
+
FROM pattern_files
|
|
154
|
+
WHERE pattern_id = ?
|
|
155
|
+
ORDER BY avg_relevance DESC, access_count DESC, access_order ASC
|
|
156
|
+
LIMIT ?
|
|
157
|
+
`).all(bestMatch.pattern_id, maxFiles);
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
predicted: files.map(f => ({
|
|
161
|
+
path: f.file_path,
|
|
162
|
+
confidence: f.avg_relevance,
|
|
163
|
+
accessCount: f.access_count,
|
|
164
|
+
order: f.access_order
|
|
165
|
+
})),
|
|
166
|
+
confidence: bestScore,
|
|
167
|
+
matchedPattern: {
|
|
168
|
+
signature: bestMatch.task_signature,
|
|
169
|
+
intent: bestMatch.intent,
|
|
170
|
+
occurrences: bestMatch.occurrences
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
}, {});
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export const cleanupStalePatterns = async ({ retentionDays = PATTERN_DECAY_DAYS } = {}) => {
|
|
177
|
+
return withStateDb((db) => {
|
|
178
|
+
initPatternTables(db);
|
|
179
|
+
|
|
180
|
+
const cutoffDate = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
|
|
181
|
+
|
|
182
|
+
const result = db.prepare(
|
|
183
|
+
'DELETE FROM context_patterns WHERE last_seen_at < ?'
|
|
184
|
+
).run(cutoffDate);
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
action: 'cleanup_patterns',
|
|
188
|
+
deletedPatterns: result.changes,
|
|
189
|
+
retentionDays
|
|
190
|
+
};
|
|
191
|
+
});
|
|
192
|
+
};
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-project context for monorepos and related projects.
|
|
3
|
+
*
|
|
4
|
+
* Enables context sharing across multiple related codebases.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { loadIndex } from './index.js';
|
|
10
|
+
import { smartSearch } from './tools/smart-search.js';
|
|
11
|
+
import { smartRead } from './tools/smart-read.js';
|
|
12
|
+
import { projectRoot, setProjectRoot } from './utils/paths.js';
|
|
13
|
+
|
|
14
|
+
const CROSS_PROJECT_CONFIG_FILE = '.devctx-projects.json';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Load cross-project configuration.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} root - Project root
|
|
20
|
+
* @returns {object|null} Configuration or null if not found
|
|
21
|
+
*/
|
|
22
|
+
export const loadCrossProjectConfig = (root = projectRoot) => {
|
|
23
|
+
const configPath = path.join(root, CROSS_PROJECT_CONFIG_FILE);
|
|
24
|
+
|
|
25
|
+
if (!fs.existsSync(configPath)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
31
|
+
return JSON.parse(content);
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Discover related projects from config.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} root - Project root
|
|
41
|
+
* @returns {Array<object>} Array of { name, path, type, description }
|
|
42
|
+
*/
|
|
43
|
+
export const discoverRelatedProjects = (root = projectRoot) => {
|
|
44
|
+
const config = loadCrossProjectConfig(root);
|
|
45
|
+
if (!config?.projects) return [];
|
|
46
|
+
|
|
47
|
+
const projects = [];
|
|
48
|
+
|
|
49
|
+
for (const project of config.projects) {
|
|
50
|
+
const projectPath = path.isAbsolute(project.path)
|
|
51
|
+
? project.path
|
|
52
|
+
: path.resolve(root, project.path);
|
|
53
|
+
|
|
54
|
+
if (!fs.existsSync(projectPath)) continue;
|
|
55
|
+
|
|
56
|
+
const indexPath = path.join(projectPath, '.devctx/index.json');
|
|
57
|
+
const hasIndex = fs.existsSync(indexPath);
|
|
58
|
+
|
|
59
|
+
projects.push({
|
|
60
|
+
name: project.name,
|
|
61
|
+
path: projectPath,
|
|
62
|
+
type: project.type || 'related',
|
|
63
|
+
description: project.description || '',
|
|
64
|
+
hasIndex,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return projects;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Search across multiple related projects.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} query - Search query
|
|
75
|
+
* @param {object} options - Search options
|
|
76
|
+
* @returns {Promise<Array>} Results from all projects
|
|
77
|
+
*/
|
|
78
|
+
export const searchAcrossProjects = async (query, options = {}) => {
|
|
79
|
+
const {
|
|
80
|
+
root = projectRoot,
|
|
81
|
+
intent = 'implementation',
|
|
82
|
+
maxResultsPerProject = 5,
|
|
83
|
+
includeProjects = null,
|
|
84
|
+
excludeProjects = null,
|
|
85
|
+
} = options;
|
|
86
|
+
|
|
87
|
+
const relatedProjects = discoverRelatedProjects(root);
|
|
88
|
+
|
|
89
|
+
const projectsToSearch = relatedProjects.filter(p => {
|
|
90
|
+
if (!p.hasIndex) return false;
|
|
91
|
+
if (includeProjects && !includeProjects.includes(p.name)) return false;
|
|
92
|
+
if (excludeProjects && excludeProjects.includes(p.name)) return false;
|
|
93
|
+
return true;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const results = [];
|
|
97
|
+
|
|
98
|
+
for (const project of projectsToSearch) {
|
|
99
|
+
try {
|
|
100
|
+
const searchResult = await smartSearch({
|
|
101
|
+
query,
|
|
102
|
+
cwd: project.path,
|
|
103
|
+
intent,
|
|
104
|
+
maxResults: maxResultsPerProject,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (searchResult.results && searchResult.results.length > 0) {
|
|
108
|
+
results.push({
|
|
109
|
+
project: project.name,
|
|
110
|
+
projectPath: project.path,
|
|
111
|
+
projectType: project.type,
|
|
112
|
+
matches: searchResult.results.length,
|
|
113
|
+
results: searchResult.results.map(r => ({
|
|
114
|
+
...r,
|
|
115
|
+
projectName: project.name,
|
|
116
|
+
absolutePath: path.join(project.path, r.file),
|
|
117
|
+
})),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return results;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Read files from multiple projects.
|
|
130
|
+
*
|
|
131
|
+
* @param {Array<object>} fileRefs - Array of { project, file, mode }
|
|
132
|
+
* @param {string} root - Project root
|
|
133
|
+
* @returns {Promise<Array>} Read results
|
|
134
|
+
*/
|
|
135
|
+
export const readAcrossProjects = async (fileRefs, root = projectRoot) => {
|
|
136
|
+
const relatedProjects = discoverRelatedProjects(root);
|
|
137
|
+
const projectMap = new Map(relatedProjects.map(p => [p.name, p]));
|
|
138
|
+
|
|
139
|
+
const originalRoot = projectRoot;
|
|
140
|
+
const results = [];
|
|
141
|
+
|
|
142
|
+
for (const ref of fileRefs) {
|
|
143
|
+
const project = projectMap.get(ref.project);
|
|
144
|
+
if (!project) {
|
|
145
|
+
results.push({
|
|
146
|
+
project: ref.project,
|
|
147
|
+
file: ref.file,
|
|
148
|
+
error: 'Project not found',
|
|
149
|
+
});
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
setProjectRoot(project.path);
|
|
155
|
+
|
|
156
|
+
const readResult = await smartRead({
|
|
157
|
+
filePath: ref.file,
|
|
158
|
+
mode: ref.mode || 'outline',
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
results.push({
|
|
162
|
+
project: ref.project,
|
|
163
|
+
projectPath: project.path,
|
|
164
|
+
file: ref.file,
|
|
165
|
+
mode: readResult.mode,
|
|
166
|
+
content: readResult.content,
|
|
167
|
+
parser: readResult.parser,
|
|
168
|
+
});
|
|
169
|
+
} catch (err) {
|
|
170
|
+
results.push({
|
|
171
|
+
project: ref.project,
|
|
172
|
+
file: ref.file,
|
|
173
|
+
error: err.message,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
setProjectRoot(originalRoot);
|
|
179
|
+
return results;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Find symbol definitions across projects.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} symbolName - Symbol to find
|
|
186
|
+
* @param {string} root - Project root
|
|
187
|
+
* @returns {Promise<Array>} Symbol locations across projects
|
|
188
|
+
*/
|
|
189
|
+
export const findSymbolAcrossProjects = async (symbolName, root = projectRoot) => {
|
|
190
|
+
const relatedProjects = discoverRelatedProjects(root).filter(p => p.hasIndex);
|
|
191
|
+
const results = [];
|
|
192
|
+
|
|
193
|
+
for (const project of relatedProjects) {
|
|
194
|
+
try {
|
|
195
|
+
const index = loadIndex(project.path);
|
|
196
|
+
if (!index?.files) continue;
|
|
197
|
+
|
|
198
|
+
for (const [filePath, fileInfo] of Object.entries(index.files)) {
|
|
199
|
+
if (!fileInfo.symbols) continue;
|
|
200
|
+
|
|
201
|
+
const matchingSymbols = fileInfo.symbols.filter(s =>
|
|
202
|
+
s.name === symbolName || s.name.includes(symbolName)
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
for (const symbol of matchingSymbols) {
|
|
206
|
+
results.push({
|
|
207
|
+
project: project.name,
|
|
208
|
+
projectPath: project.path,
|
|
209
|
+
projectType: project.type,
|
|
210
|
+
file: filePath,
|
|
211
|
+
symbol: symbol.name,
|
|
212
|
+
kind: symbol.kind,
|
|
213
|
+
line: symbol.line,
|
|
214
|
+
signature: symbol.signature,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return results;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Get dependency graph across projects.
|
|
228
|
+
*
|
|
229
|
+
* @param {string} root - Project root
|
|
230
|
+
* @returns {object} Cross-project dependency graph
|
|
231
|
+
*/
|
|
232
|
+
export const getCrossProjectDependencies = (root = projectRoot) => {
|
|
233
|
+
const relatedProjects = discoverRelatedProjects(root).filter(p => p.hasIndex);
|
|
234
|
+
const dependencies = {
|
|
235
|
+
projects: [],
|
|
236
|
+
edges: [],
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
for (const project of relatedProjects) {
|
|
240
|
+
dependencies.projects.push({
|
|
241
|
+
name: project.name,
|
|
242
|
+
path: project.path,
|
|
243
|
+
type: project.type,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
const index = loadIndex(project.path);
|
|
248
|
+
if (!index?.graph?.edges) continue;
|
|
249
|
+
|
|
250
|
+
for (const edge of index.graph.edges) {
|
|
251
|
+
if (edge.kind !== 'import') continue;
|
|
252
|
+
|
|
253
|
+
const fromAbs = path.join(project.path, edge.from);
|
|
254
|
+
const toAbs = path.join(project.path, edge.to);
|
|
255
|
+
|
|
256
|
+
const toProject = relatedProjects.find(p =>
|
|
257
|
+
toAbs.startsWith(p.path) && p.path !== project.path
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
if (toProject) {
|
|
261
|
+
const toRel = path.relative(toProject.path, toAbs);
|
|
262
|
+
dependencies.edges.push({
|
|
263
|
+
from: project.name,
|
|
264
|
+
fromFile: edge.from,
|
|
265
|
+
to: toProject.name,
|
|
266
|
+
toFile: toRel,
|
|
267
|
+
kind: 'cross-project-import',
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} catch {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return dependencies;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Get statistics about cross-project usage.
|
|
281
|
+
*
|
|
282
|
+
* @param {string} root - Project root
|
|
283
|
+
* @returns {object} Usage statistics
|
|
284
|
+
*/
|
|
285
|
+
export const getCrossProjectStats = (root = projectRoot) => {
|
|
286
|
+
const relatedProjects = discoverRelatedProjects(root);
|
|
287
|
+
const deps = getCrossProjectDependencies(root);
|
|
288
|
+
|
|
289
|
+
const stats = {
|
|
290
|
+
totalProjects: relatedProjects.length,
|
|
291
|
+
indexedProjects: relatedProjects.filter(p => p.hasIndex).length,
|
|
292
|
+
projectTypes: {},
|
|
293
|
+
crossProjectImports: deps.edges.length,
|
|
294
|
+
importsByProject: {},
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
for (const project of relatedProjects) {
|
|
298
|
+
const type = project.type || 'related';
|
|
299
|
+
stats.projectTypes[type] = (stats.projectTypes[type] || 0) + 1;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
for (const edge of deps.edges) {
|
|
303
|
+
stats.importsByProject[edge.from] = (stats.importsByProject[edge.from] || 0) + 1;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return stats;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Create a sample cross-project configuration.
|
|
311
|
+
*
|
|
312
|
+
* @param {string} root - Project root
|
|
313
|
+
* @returns {object} Sample configuration
|
|
314
|
+
*/
|
|
315
|
+
export const createSampleConfig = (root = projectRoot) => {
|
|
316
|
+
return {
|
|
317
|
+
version: '1.0',
|
|
318
|
+
projects: [
|
|
319
|
+
{
|
|
320
|
+
name: 'main-app',
|
|
321
|
+
path: '.',
|
|
322
|
+
type: 'main',
|
|
323
|
+
description: 'Main application',
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
name: 'shared-lib',
|
|
327
|
+
path: '../shared-lib',
|
|
328
|
+
type: 'library',
|
|
329
|
+
description: 'Shared utilities library',
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
name: 'api-service',
|
|
333
|
+
path: '../api-service',
|
|
334
|
+
type: 'service',
|
|
335
|
+
description: 'Backend API service',
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
searchDefaults: {
|
|
339
|
+
maxResultsPerProject: 5,
|
|
340
|
+
includeTypes: ['main', 'library', 'service'],
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
};
|