magector 1.7.1 → 2.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/src/mcp-server.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  } from '@modelcontextprotocol/sdk/types.js';
17
17
  import { execFileSync, spawn } from 'child_process';
18
18
  import { createInterface } from 'readline';
19
- import { existsSync, statSync, unlinkSync, appendFileSync, writeFileSync, readFileSync, mkdirSync } from 'fs';
19
+ import { existsSync, statSync, unlinkSync, copyFileSync, renameSync, appendFileSync, writeFileSync, readFileSync, mkdirSync } from 'fs';
20
20
  import { stat } from 'fs/promises';
21
21
  import { glob } from 'glob';
22
22
  import path from 'path';
@@ -217,8 +217,9 @@ function checkDbFormat() {
217
217
  }
218
218
 
219
219
  /**
220
- * Start a background re-index process. Logs to .magector/magector.log.
221
- * MCP tools return an informative error while this is running.
220
+ * Start a background re-index process that builds to a temporary path.
221
+ * The old DB remains in place so search tools keep working during rebuild.
222
+ * On completion, the new index is swapped in atomically (old → .bak, new → current).
222
223
  */
223
224
  function startBackgroundReindex() {
224
225
  if (reindexInProgress) return;
@@ -229,30 +230,25 @@ function startBackgroundReindex() {
229
230
  return;
230
231
  }
231
232
 
232
- reindexInProgress = true;
233
+ const tempDbPath = config.dbPath + '.new';
233
234
 
234
- // Do NOT delete existing DB the new indexer saves incrementally,
235
- // so a partial index is better than no index. The Rust binary handles
236
- // format incompatibility internally by clearing and rebuilding.
237
- const hadExistingDb = existsSync(config.dbPath);
238
- if (hadExistingDb) {
239
- try {
240
- const fstat = statSync(config.dbPath);
241
- logToFile('WARN', `Existing DB (${(fstat.size / 1024).toFixed(0)} KB) will be overwritten by re-index.`);
242
- } catch {}
243
- try { unlinkSync(config.dbPath); } catch {}
235
+ // Clean up leftover temp DB from a previous failed reindex
236
+ if (existsSync(tempDbPath)) {
237
+ try { unlinkSync(tempDbPath); } catch {}
244
238
  }
245
239
 
246
- logToFile('WARN', `Database format incompatible. Starting background re-index.`);
240
+ reindexInProgress = true;
241
+
242
+ const hadExistingDb = existsSync(config.dbPath);
243
+ logToFile('WARN', `Starting background re-index to temp path. Old DB ${hadExistingDb ? 'preserved for queries' : 'not found'}.`);
247
244
  console.error(`Database format incompatible. Starting background re-index (log: ${LOG_PATH})`);
248
245
 
249
246
  const reindexArgs = [
250
247
  'index',
251
248
  '-m', config.magentoRoot,
252
- '-d', config.dbPath,
249
+ '-d', tempDbPath,
253
250
  '-c', config.modelCache
254
251
  ];
255
- // Pass thread limit if configured
256
252
  const threads = process.env.MAGECTOR_THREADS;
257
253
  if (threads) {
258
254
  reindexArgs.push('--threads', threads);
@@ -267,7 +263,6 @@ function startBackgroundReindex() {
267
263
  env: rustEnv,
268
264
  });
269
265
 
270
- // Pipe reindex stdout/stderr to log file (strip ANSI codes)
271
266
  reindexProcess.stdout.on('data', (d) => {
272
267
  const text = d.toString().replace(/\x1b\[[0-9;]*m/g, '').trim();
273
268
  if (text) logToFile('INDEX', text);
@@ -281,12 +276,27 @@ function startBackgroundReindex() {
281
276
  reindexInProgress = false;
282
277
  reindexProcess = null;
283
278
  if (code === 0) {
279
+ // Atomic swap: old → .bak, new → current
280
+ try {
281
+ if (existsSync(config.dbPath)) {
282
+ const backupPath = config.dbPath + '.bak';
283
+ if (existsSync(backupPath)) { try { unlinkSync(backupPath); } catch {} }
284
+ renameSync(config.dbPath, backupPath);
285
+ logToFile('INFO', 'Old DB moved to .bak');
286
+ }
287
+ renameSync(tempDbPath, config.dbPath);
288
+ logToFile('INFO', 'New index swapped into place.');
289
+ } catch (e) {
290
+ logToFile('ERR', `Failed to swap index: ${e.message}`);
291
+ }
284
292
  logToFile('INFO', 'Background re-index completed. Restarting serve process.');
285
293
  console.error('Background re-index completed. Restarting serve process.');
286
294
  if (serveProcess) serveProcess.kill();
287
295
  searchCache.clear();
288
296
  startServeProcess();
289
297
  } else {
298
+ // Clean up failed temp DB
299
+ try { if (existsSync(tempDbPath)) unlinkSync(tempDbPath); } catch {}
290
300
  logToFile('ERR', `Background re-index failed (exit code ${code})`);
291
301
  console.error(`Background re-index failed (exit code ${code}). Check ${LOG_PATH}`);
292
302
  }
@@ -552,13 +562,21 @@ function rustIndex(magentoRoot) {
552
562
  if (existsSync(descDbPath)) {
553
563
  indexArgs.push('--descriptions-db', descDbPath);
554
564
  }
555
- const indexTimeout = parseInt(process.env.MAGECTOR_INDEX_TIMEOUT, 10) || 1800000;
565
+ // Default 4 hours, matching cli.js/init.js. Previously 1800000 (30 min),
566
+ // which was too short for ~80K-file enterprise Magento installs under CPU
567
+ // constraint and caused silent re-index loops via the MCP auto-index path.
568
+ const indexTimeout = parseInt(process.env.MAGECTOR_INDEX_TIMEOUT, 10) || 14400000;
556
569
  try {
557
570
  const result = execFileSync(config.rustBinary, indexArgs, { encoding: 'utf-8', timeout: indexTimeout, stdio: ['pipe', 'pipe', 'pipe'], env: rustEnv });
558
571
  return result;
559
572
  } catch (err) {
560
573
  if (err.message && err.message.includes('ETIMEDOUT')) {
561
- throw new Error(`Indexing timed out after ${indexTimeout / 1000}s. Set MAGECTOR_INDEX_TIMEOUT=3600000 (or higher) in your environment to increase the limit.`);
574
+ throw new Error(
575
+ `Indexing timed out after ${indexTimeout / 1000}s. Partial progress was saved ` +
576
+ `to disk — the next indexing run will auto-resume from the last checkpoint. ` +
577
+ `To raise the timeout further, set MAGECTOR_INDEX_TIMEOUT (milliseconds) in the ` +
578
+ `MCP server env, e.g. MAGECTOR_INDEX_TIMEOUT=28800000 for 8 hours.`
579
+ );
562
580
  }
563
581
  throw err;
564
582
  }
@@ -993,12 +1011,738 @@ function formatSearchResults(results) {
993
1011
  return JSON.stringify({ results: formatted, count: formatted.length });
994
1012
  }
995
1013
 
1014
+ // ─── DI Dependency Tracing ─────────────────────────────────────
1015
+
1016
+ /**
1017
+ * Parse all di.xml files to build a dependency graph for a given class/interface.
1018
+ * Returns preferences, plugins, virtualTypes, and argument overrides.
1019
+ */
1020
+ async function traceDependency(className, direction = 'both') {
1021
+ const root = config.magentoRoot;
1022
+ const diFiles = await glob('**/etc/**/di.xml', { cwd: root, absolute: true, nodir: true });
1023
+ const classLower = className.toLowerCase();
1024
+ const classShort = className.split('\\').pop().toLowerCase();
1025
+
1026
+ const result = {
1027
+ className,
1028
+ preferences: [],
1029
+ plugins: [],
1030
+ virtualTypes: [],
1031
+ argumentOverrides: [],
1032
+ totalDiFiles: diFiles.length
1033
+ };
1034
+
1035
+ for (const diFile of diFiles) {
1036
+ let content;
1037
+ try {
1038
+ content = readFileSync(diFile, 'utf-8');
1039
+ } catch { continue; }
1040
+
1041
+ const relativePath = diFile.replace(root + '/', '');
1042
+
1043
+ if (direction === 'resolve' || direction === 'both') {
1044
+ // Find preferences: <preference for="ClassName" type="Implementation"/>
1045
+ const prefRegex = /<preference\s+for="([^"]+)"\s+type="([^"]+)"\s*\/?>/g;
1046
+ let match;
1047
+ while ((match = prefRegex.exec(content)) !== null) {
1048
+ const forClass = match[1];
1049
+ if (forClass.toLowerCase().includes(classLower) || forClass.toLowerCase().includes(classShort)) {
1050
+ result.preferences.push({
1051
+ for: forClass,
1052
+ type: match[2],
1053
+ file: relativePath
1054
+ });
1055
+ }
1056
+ }
1057
+
1058
+ // Find virtualTypes: <virtualType name="..." type="ClassName"/>
1059
+ const vtRegex = /<virtualType\s+name="([^"]+)"[^>]*type="([^"]+)"[^>]*\/?>/g;
1060
+ while ((match = vtRegex.exec(content)) !== null) {
1061
+ const vtType = match[2];
1062
+ if (vtType.toLowerCase().includes(classLower) || vtType.toLowerCase().includes(classShort)) {
1063
+ result.virtualTypes.push({
1064
+ name: match[1],
1065
+ type: vtType,
1066
+ file: relativePath
1067
+ });
1068
+ }
1069
+ }
1070
+ }
1071
+
1072
+ if (direction === 'dependents' || direction === 'both') {
1073
+ // Find plugins targeting this class: <type name="ClassName"><plugin ... type="PluginClass"/></type>
1074
+ const typeBlockRegex = /<type\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/type>/g;
1075
+ let typeMatch;
1076
+ while ((typeMatch = typeBlockRegex.exec(content)) !== null) {
1077
+ const typeName = typeMatch[1];
1078
+ const typeBlock = typeMatch[2];
1079
+ if (typeName.toLowerCase().includes(classLower) || typeName.toLowerCase().includes(classShort)) {
1080
+ const pluginRegex = /<plugin\s+name="([^"]+)"[^>]*type="([^"]+)"[^>]*\/?>/g;
1081
+ let pMatch;
1082
+ while ((pMatch = pluginRegex.exec(typeBlock)) !== null) {
1083
+ result.plugins.push({
1084
+ target: typeName,
1085
+ pluginName: pMatch[1],
1086
+ pluginClass: pMatch[2],
1087
+ file: relativePath
1088
+ });
1089
+ }
1090
+
1091
+ // Find argument overrides
1092
+ const argRegex = /<argument\s+name="([^"]+)"[^>]*xsi:type="([^"]+)"[^>]*>([^<]*)<\/argument>/g;
1093
+ let aMatch;
1094
+ while ((aMatch = argRegex.exec(typeBlock)) !== null) {
1095
+ result.argumentOverrides.push({
1096
+ target: typeName,
1097
+ argumentName: aMatch[1],
1098
+ argumentType: aMatch[2],
1099
+ value: aMatch[3].trim().slice(0, 200),
1100
+ file: relativePath
1101
+ });
1102
+ }
1103
+ }
1104
+ }
1105
+ }
1106
+ }
1107
+
1108
+ return result;
1109
+ }
1110
+
1111
+ // ─── Error Message Parser ─────────────────────────────────────
1112
+
1113
+ const ERROR_PATTERNS = [
1114
+ {
1115
+ pattern: /Cannot instantiate interface\s+([\w\\]+)/i,
1116
+ type: 'missing_preference',
1117
+ extract: (m) => ({ interface: m[1] }),
1118
+ suggestion: (ctx) => `Interface ${ctx.interface} has no di.xml preference. Add <preference for="${ctx.interface}" type="ConcreteClass"/> in etc/di.xml`
1119
+ },
1120
+ {
1121
+ pattern: /Class\s+([\w\\]+)\s+does not exist/i,
1122
+ type: 'missing_class',
1123
+ extract: (m) => ({ class: m[1] }),
1124
+ suggestion: (ctx) => `Class ${ctx.class} not found. Check namespace, autoload (composer dump-autoload), and module registration.`
1125
+ },
1126
+ {
1127
+ pattern: /Plugin class\s+([\w\\]+)\s+doesn't exist/i,
1128
+ type: 'missing_plugin',
1129
+ extract: (m) => ({ plugin: m[1] }),
1130
+ suggestion: (ctx) => `Plugin class ${ctx.plugin} declared in di.xml but PHP file missing. Check namespace and file path.`
1131
+ },
1132
+ {
1133
+ pattern: /Area code is not set/i,
1134
+ type: 'area_code_not_set',
1135
+ extract: () => ({}),
1136
+ suggestion: () => 'Area code must be set before object manager usage. In CLI commands, use $state->setAreaCode(Area::AREA_GLOBAL) in execute().'
1137
+ },
1138
+ {
1139
+ pattern: /Circular dependency:\s*([\w\\,\s]+)/i,
1140
+ type: 'circular_dependency',
1141
+ extract: (m) => ({ classes: m[1] }),
1142
+ suggestion: (ctx) => `Circular DI dependency between: ${ctx.classes}. Break the cycle with Proxy class or Factory.`
1143
+ },
1144
+ {
1145
+ pattern: /(\d+)\s+plugins\s+.*sortOrder.*conflict/i,
1146
+ type: 'plugin_sort_conflict',
1147
+ extract: (m) => ({ count: m[1] }),
1148
+ suggestion: (ctx) => `${ctx.count} plugins have conflicting sortOrder values. Check di.xml for duplicate sortOrder on the same type.`
1149
+ },
1150
+ {
1151
+ pattern: /Undefined index:\s+(\w+)\s+in\s+([\w\/\-.]+\.phtml)/i,
1152
+ type: 'template_undefined_index',
1153
+ extract: (m) => ({ index: m[1], template: m[2] }),
1154
+ suggestion: (ctx) => `Template ${ctx.template} accesses undefined variable '${ctx.index}'. Check the Block class getData() or assign in layout XML.`
1155
+ },
1156
+ {
1157
+ pattern: /SQLSTATE\[(\w+)\].*Table '[\w.]*\.(\w+)' doesn't exist/i,
1158
+ type: 'missing_table',
1159
+ extract: (m) => ({ sqlState: m[1], table: m[2] }),
1160
+ suggestion: (ctx) => `Table '${ctx.table}' missing. Run setup:upgrade or check db_schema.xml. May need to regenerate declarative schema whitelist.`
1161
+ },
1162
+ {
1163
+ pattern: /There are no commands defined in the "(\w+(?::\w+)*)" namespace/i,
1164
+ type: 'missing_cli_command',
1165
+ extract: (m) => ({ namespace: m[1] }),
1166
+ suggestion: (ctx) => `CLI command namespace '${ctx.namespace}' not registered. Check module's etc/di.xml for Magento\\Framework\\Console\\CommandListInterface argument.`
1167
+ },
1168
+ {
1169
+ pattern: /Invalid method\s+([\w\\]+)::(before|after|around)(\w+)/i,
1170
+ type: 'invalid_plugin_method',
1171
+ extract: (m) => ({ class: m[1], type: m[2], method: m[3] }),
1172
+ suggestion: (ctx) => `Plugin method ${ctx.type}${ctx.method} in ${ctx.class} doesn't match any public method on the target class. Check method name spelling.`
1173
+ }
1174
+ ];
1175
+
1176
+ /**
1177
+ * Parse an error message and find relevant source files.
1178
+ */
1179
+ async function parseError(errorText) {
1180
+ const result = {
1181
+ errorType: 'unknown',
1182
+ parsed: {},
1183
+ suggestion: null,
1184
+ relatedFiles: [],
1185
+ stackTrace: []
1186
+ };
1187
+
1188
+ // Try to match against known patterns
1189
+ for (const errPattern of ERROR_PATTERNS) {
1190
+ const match = errorText.match(errPattern.pattern);
1191
+ if (match) {
1192
+ result.errorType = errPattern.type;
1193
+ result.parsed = errPattern.extract(match);
1194
+ result.suggestion = errPattern.suggestion(result.parsed);
1195
+ break;
1196
+ }
1197
+ }
1198
+
1199
+ // Extract class names from error text for file search
1200
+ const classNames = [...errorText.matchAll(/([\w\\]{2,}(?:\\[\w]+)+)/g)].map(m => m[1]);
1201
+ const uniqueClasses = [...new Set(classNames)];
1202
+
1203
+ // Extract stack trace file paths
1204
+ const stackFiles = [...errorText.matchAll(/#\d+\s+([\w\/\-.]+\.php)(?:\((\d+)\))?/g)];
1205
+ result.stackTrace = stackFiles.map(m => ({
1206
+ file: m[1],
1207
+ line: m[2] ? parseInt(m[2]) : null
1208
+ }));
1209
+
1210
+ // Search for related files using semantic search and class names
1211
+ for (const cls of uniqueClasses.slice(0, 5)) {
1212
+ const shortName = cls.split('\\').pop();
1213
+ try {
1214
+ const raw = await rustSearchAsync(shortName, 5);
1215
+ const results = raw.map(normalizeResult).filter(r =>
1216
+ r.className?.includes(shortName) || r.path?.toLowerCase().includes(shortName.toLowerCase())
1217
+ );
1218
+ for (const r of results.slice(0, 3)) {
1219
+ if (!result.relatedFiles.some(f => f.path === r.path)) {
1220
+ result.relatedFiles.push({ path: r.path, className: r.className, score: r.score });
1221
+ }
1222
+ }
1223
+ } catch { /* search may fail if index not ready */ }
1224
+ }
1225
+
1226
+ // For missing_preference errors, also trace DI
1227
+ if (result.errorType === 'missing_preference' && result.parsed.interface) {
1228
+ try {
1229
+ const diTrace = await traceDependency(result.parsed.interface, 'resolve');
1230
+ if (diTrace.preferences.length > 0) {
1231
+ result.existingPreferences = diTrace.preferences;
1232
+ result.suggestion += ` Found ${diTrace.preferences.length} existing preference(s) — check area-specific di.xml overrides.`;
1233
+ }
1234
+ } catch { /* non-fatal */ }
1235
+ }
1236
+
1237
+ return result;
1238
+ }
1239
+
1240
+ // ─── Performance Profiling ─────────────────────────────────────
1241
+
1242
+ const SUBSYSTEM_QUERIES = {
1243
+ checkout_totals: {
1244
+ searches: [
1245
+ 'quote totals collector plugin',
1246
+ 'TotalsCollector TotalsCollectorList',
1247
+ 'checkout totals calculation',
1248
+ 'quote address collect totals'
1249
+ ],
1250
+ pathFilters: ['/Model/Quote/', '/Plugin/', 'totals', 'collector'],
1251
+ eventPatterns: ['sales_quote_collect_totals']
1252
+ },
1253
+ order_place: {
1254
+ searches: [
1255
+ 'order place submit plugin',
1256
+ 'sales order place after observer',
1257
+ 'order management submit',
1258
+ 'payment place order'
1259
+ ],
1260
+ pathFilters: ['/Order/', '/Plugin/', '/Observer/', 'payment'],
1261
+ eventPatterns: ['sales_order_place_after', 'checkout_submit_all_after']
1262
+ },
1263
+ product_save: {
1264
+ searches: [
1265
+ 'product save plugin afterSave',
1266
+ 'catalog product save observer',
1267
+ 'product repository save',
1268
+ 'product save reindex'
1269
+ ],
1270
+ pathFilters: ['/Product/', '/Plugin/', '/Observer/', 'catalog'],
1271
+ eventPatterns: ['catalog_product_save_after', 'catalog_product_save_before']
1272
+ },
1273
+ cart_add: {
1274
+ searches: [
1275
+ 'add product to cart quote plugin',
1276
+ 'cart add product stock validation',
1277
+ 'quote addProduct beforeAddProduct',
1278
+ 'checkout cart add observer'
1279
+ ],
1280
+ pathFilters: ['/Cart/', '/Quote/', '/Plugin/', 'inventory', 'stock'],
1281
+ eventPatterns: ['checkout_cart_add_product_complete', 'checkout_cart_product_add_after']
1282
+ },
1283
+ customer_login: {
1284
+ searches: [
1285
+ 'customer login authenticate plugin',
1286
+ 'customer session login observer',
1287
+ 'customer account authenticate',
1288
+ 'customer login after event'
1289
+ ],
1290
+ pathFilters: ['/Customer/', '/Plugin/', '/Observer/', 'session', 'auth'],
1291
+ eventPatterns: ['customer_login', 'customer_session_init']
1292
+ },
1293
+ catalog_reindex: {
1294
+ searches: [
1295
+ 'catalog product indexer reindex',
1296
+ 'flat table indexer catalog',
1297
+ 'price indexer product',
1298
+ 'category product indexer'
1299
+ ],
1300
+ pathFilters: ['/Indexer/', '/Model/', 'catalog', 'price', 'flat'],
1301
+ eventPatterns: ['catalog_product_reindex', 'catalogsearch_reset_search_result']
1302
+ }
1303
+ };
1304
+
1305
+ /**
1306
+ * Profile a Magento subsystem for performance bottlenecks.
1307
+ */
1308
+ async function profilePerformance(subsystem, threshold = 0) {
1309
+ const configSub = SUBSYSTEM_QUERIES[subsystem];
1310
+ if (!configSub) {
1311
+ // Fallback: use subsystem name as search query
1312
+ const fallbackSearches = [subsystem.replace(/_/g, ' ') + ' plugin', subsystem.replace(/_/g, ' ') + ' observer'];
1313
+ const raw = [];
1314
+ for (const q of fallbackSearches) {
1315
+ try {
1316
+ const r = await rustSearchAsync(q, 20);
1317
+ raw.push(...r);
1318
+ } catch { /* ignore */ }
1319
+ }
1320
+ const results = raw.map(normalizeResult);
1321
+ const unique = [];
1322
+ const seen = new Set();
1323
+ for (const r of results) {
1324
+ if (r.path && !seen.has(r.path)) {
1325
+ seen.add(r.path);
1326
+ unique.push(r);
1327
+ }
1328
+ }
1329
+ return { subsystem, files: unique.slice(0, 20), plugins: [], observers: [], collectors: [] };
1330
+ }
1331
+
1332
+ // Run all searches in parallel
1333
+ const searchPromises = configSub.searches.map(q => rustSearchAsync(q, 20).catch(() => []));
1334
+ const eventPromises = (configSub.eventPatterns || []).map(e =>
1335
+ rustSearchAsync(`event ${e} observer`, 15).catch(() => [])
1336
+ );
1337
+ const allResults = await Promise.all([...searchPromises, ...eventPromises]);
1338
+ const rawAll = allResults.flat();
1339
+
1340
+ // Normalize and deduplicate
1341
+ const seen = new Set();
1342
+ const allFiles = [];
1343
+ for (const r of rawAll) {
1344
+ const n = normalizeResult(r);
1345
+ if (n.path && !seen.has(n.path)) {
1346
+ seen.add(n.path);
1347
+ allFiles.push(n);
1348
+ }
1349
+ }
1350
+
1351
+ // Categorize
1352
+ const plugins = allFiles.filter(r => r.isPlugin || r.path?.includes('/Plugin/'));
1353
+ const observers = allFiles.filter(r => r.isObserver || r.path?.includes('/Observer/') || r.path?.includes('events.xml'));
1354
+ const collectors = allFiles.filter(r =>
1355
+ configSub.pathFilters.some(f => r.path?.includes(f))
1356
+ );
1357
+
1358
+ // Try to get complexity for PHP files
1359
+ const phpFiles = allFiles.filter(r => r.path?.endsWith('.php')).slice(0, 30);
1360
+ let complexityData = [];
1361
+ if (phpFiles.length > 0) {
1362
+ try {
1363
+ const absPaths = phpFiles
1364
+ .map(r => path.join(config.magentoRoot, r.path))
1365
+ .filter(p => existsSync(p));
1366
+ if (absPaths.length > 0) {
1367
+ complexityData = await analyzeComplexity(absPaths);
1368
+ }
1369
+ } catch { /* non-fatal */ }
1370
+ }
1371
+
1372
+ // Merge complexity into results
1373
+ const filesWithComplexity = allFiles.map(r => {
1374
+ const absPath = path.join(config.magentoRoot, r.path || '');
1375
+ const cx = complexityData.find(c => c.file === absPath);
1376
+ return {
1377
+ ...r,
1378
+ complexity: cx?.cyclomaticComplexity || 0,
1379
+ complexityRating: cx?.rating || 'unknown',
1380
+ functions: cx?.functions || 0,
1381
+ lines: cx?.lines || 0
1382
+ };
1383
+ });
1384
+
1385
+ // Sort by complexity descending, filter by threshold
1386
+ const sorted = filesWithComplexity
1387
+ .filter(r => r.complexity >= threshold)
1388
+ .sort((a, b) => b.complexity - a.complexity);
1389
+
1390
+ return {
1391
+ subsystem,
1392
+ totalFiles: allFiles.length,
1393
+ files: sorted.slice(0, 30),
1394
+ plugins: plugins.slice(0, 15),
1395
+ observers: observers.slice(0, 15),
1396
+ collectors: collectors.slice(0, 15)
1397
+ };
1398
+ }
1399
+
1400
+ // ─── BM25 Text Scoring ──────────────────────────────────────────
1401
+ // Simple BM25 scoring to complement vector similarity on exact class/method names.
1402
+
1403
+ function bm25Score(queryTerms, text, k1 = 1.2, b = 0.75) {
1404
+ if (!text || !queryTerms.length) return 0;
1405
+ const words = text.toLowerCase().split(/[\W_]+/).filter(Boolean);
1406
+ const docLen = words.length;
1407
+ const avgDocLen = 200;
1408
+ let score = 0;
1409
+ for (const term of queryTerms) {
1410
+ const termLower = term.toLowerCase();
1411
+ const tf = words.filter(w => w === termLower).length;
1412
+ if (tf === 0) continue;
1413
+ const numerator = tf * (k1 + 1);
1414
+ const denominator = tf + k1 * (1 - b + b * (docLen / avgDocLen));
1415
+ score += numerator / denominator;
1416
+ }
1417
+ return score;
1418
+ }
1419
+
1420
+ /**
1421
+ * Hybrid rerank: combine vector score with BM25 text score.
1422
+ * @param {Array} results - normalized search results
1423
+ * @param {string} query - original query string
1424
+ * @param {number} bm25Weight - weight for BM25 component (default 0.3)
1425
+ */
1426
+ function hybridRerank(results, query, bm25Weight = 0.3) {
1427
+ if (!results.length || !query) return results;
1428
+ const queryTerms = query.toLowerCase().split(/[\s_\\]+/).filter(t => t.length > 2);
1429
+ if (!queryTerms.length) return results;
1430
+
1431
+ return results.map(r => {
1432
+ const textScore = bm25Score(queryTerms, (r.searchText || '') + ' ' + (r.className || '') + ' ' + (r.path || ''));
1433
+ // Normalize BM25 score relative to max possible
1434
+ const bonus = Math.min(textScore * bm25Weight, 1.0);
1435
+ return { ...r, score: (r.score || 0) + bonus, bm25: textScore };
1436
+ }).sort((a, b) => b.score - a.score);
1437
+ }
1438
+
1439
+ // ─── Query Expansion ────────────────────────────────────────────
1440
+ // Expand queries with Magento domain synonyms for better recall.
1441
+
1442
+ const MAGENTO_SYNONYMS = {
1443
+ plugin: ['interceptor', 'around method', 'before after'],
1444
+ preference: ['di override', 'rewrite', 'di.xml for type'],
1445
+ observer: ['event listener', 'event handler', 'events.xml'],
1446
+ model: ['entity', 'resource model'],
1447
+ block: ['view block', 'template block'],
1448
+ controller: ['action class', 'execute method', 'route handler'],
1449
+ cron: ['scheduled task', 'crontab.xml'],
1450
+ api: ['rest endpoint', 'webapi', 'service contract'],
1451
+ layout: ['xml layout', 'handle', 'container', 'reference block'],
1452
+ checkout: ['cart', 'quote', 'totals collection'],
1453
+ order: ['sales order', 'order placement', 'order submit'],
1454
+ product: ['catalog product', 'product entity'],
1455
+ customer: ['customer entity', 'customer account'],
1456
+ indexer: ['reindex', 'flat table', 'price index'],
1457
+ payment: ['payment method', 'payment gateway', 'payment information'],
1458
+ shipping: ['carrier', 'shipping method', 'delivery'],
1459
+ stock: ['inventory', 'salable quantity', 'source item'],
1460
+ };
1461
+
1462
+ function expandQuery(query) {
1463
+ const terms = query.toLowerCase().split(/\s+/);
1464
+ const expanded = [query]; // keep original query as-is
1465
+ for (const term of terms) {
1466
+ const synonyms = MAGENTO_SYNONYMS[term];
1467
+ if (synonyms) {
1468
+ expanded.push(...synonyms);
1469
+ }
1470
+ }
1471
+ return expanded.join(' ');
1472
+ }
1473
+
1474
+ // ─── Module Filtering ───────────────────────────────────────────
1475
+ // Filter search results by vendor/module namespace pattern.
1476
+
1477
+ function filterByModule(results, moduleFilter) {
1478
+ if (!moduleFilter) return results;
1479
+ const patterns = Array.isArray(moduleFilter) ? moduleFilter : [moduleFilter];
1480
+ return results.filter(r => {
1481
+ const mod = r.module || '';
1482
+ const filePath = r.path || '';
1483
+ return patterns.some(pat => {
1484
+ if (pat.includes('*')) {
1485
+ const regex = new RegExp('^' + pat.replace(/\*/g, '.*') + '$', 'i');
1486
+ return regex.test(mod) || regex.test(filePath);
1487
+ }
1488
+ return mod.toLowerCase().includes(pat.toLowerCase()) ||
1489
+ filePath.toLowerCase().includes(pat.toLowerCase());
1490
+ });
1491
+ });
1492
+ }
1493
+
1494
+ // ─── Layout XML Search ──────────────────────────────────────────
1495
+
1496
+ async function findLayout(query, handle) {
1497
+ const root = config.magentoRoot;
1498
+ const layoutFiles = await glob('**/view/**/layout/**/*.xml', { cwd: root, absolute: true, nodir: true });
1499
+ const queryLower = (query || '').toLowerCase();
1500
+ const handleLower = (handle || '').toLowerCase();
1501
+
1502
+ const results = [];
1503
+
1504
+ for (const file of layoutFiles) {
1505
+ let content;
1506
+ try { content = readFileSync(file, 'utf-8'); } catch { continue; }
1507
+ const relativePath = file.replace(root + '/', '');
1508
+ const fileName = path.basename(file, '.xml');
1509
+
1510
+ const handleMatch = handleLower && fileName.toLowerCase().includes(handleLower);
1511
+ const contentMatch = queryLower && content.toLowerCase().includes(queryLower);
1512
+
1513
+ if (!handleMatch && !contentMatch) continue;
1514
+
1515
+ const blocks = [];
1516
+ const blockRegex = /<block\s+[^>]*(?:class|name)="([^"]+)"[^>]*/g;
1517
+ let m;
1518
+ while ((m = blockRegex.exec(content)) !== null) blocks.push(m[1]);
1519
+
1520
+ const containers = [];
1521
+ const containerRegex = /<(?:container|referenceContainer)\s+[^>]*name="([^"]+)"[^>]*/g;
1522
+ while ((m = containerRegex.exec(content)) !== null) containers.push(m[1]);
1523
+
1524
+ const refBlocks = [];
1525
+ const refBlockRegex = /<referenceBlock\s+[^>]*name="([^"]+)"[^>]*/g;
1526
+ while ((m = refBlockRegex.exec(content)) !== null) refBlocks.push(m[1]);
1527
+
1528
+ results.push({
1529
+ path: relativePath,
1530
+ handle: fileName,
1531
+ blocks: [...new Set(blocks)],
1532
+ containers: [...new Set(containers)],
1533
+ referenceBlocks: [...new Set(refBlocks)],
1534
+ score: (handleMatch ? 1.0 : 0) + (contentMatch ? 0.5 : 0)
1535
+ });
1536
+ }
1537
+
1538
+ return results.sort((a, b) => b.score - a.score);
1539
+ }
1540
+
1541
+ // ─── Impact Analysis ────────────────────────────────────────────
1542
+
1543
+ async function analyzeImpact(className) {
1544
+ const root = config.magentoRoot;
1545
+ const shortName = className.split('\\').pop();
1546
+
1547
+ const references = {
1548
+ className,
1549
+ useStatements: [],
1550
+ diXmlReferences: [],
1551
+ instantiations: [],
1552
+ typeHints: [],
1553
+ total: 0
1554
+ };
1555
+
1556
+ // Use vector search to find candidate files (much faster than globbing all PHP)
1557
+ const rawSearch = await rustSearchAsync(`${shortName} ${className}`, 50).catch(() => []);
1558
+ const raw = Array.isArray(rawSearch) ? rawSearch : [];
1559
+ const relatedPaths = raw.map(r => normalizeResult(r)).filter(r => r.path);
1560
+
1561
+ // Check DI references via xml parsing
1562
+ const diTrace = await traceDependency(className, 'both');
1563
+ references.diXmlReferences = [
1564
+ ...diTrace.preferences.map(p => ({ type: 'preference', file: p.file, detail: `${p.for} → ${p.type}` })),
1565
+ ...diTrace.plugins.map(p => ({ type: 'plugin', file: p.file, detail: `${p.pluginName}: ${p.pluginClass}` })),
1566
+ ...diTrace.virtualTypes.map(v => ({ type: 'virtualType', file: v.file, detail: `${v.name} extends ${v.type}` })),
1567
+ ...diTrace.argumentOverrides.map(a => ({ type: 'argument', file: a.file, detail: `${a.target}.${a.argumentName}` }))
1568
+ ];
1569
+
1570
+ // Check PHP files for direct references
1571
+ for (const r of relatedPaths.slice(0, 40)) {
1572
+ const absPath = path.join(root, r.path);
1573
+ if (!existsSync(absPath) || !r.path.endsWith('.php')) continue;
1574
+ let content;
1575
+ try { content = readFileSync(absPath, 'utf-8'); } catch { continue; }
1576
+
1577
+ const hasUse = content.includes(`use ${className}`) || content.includes(`\\${className}`);
1578
+ if (hasUse) {
1579
+ references.useStatements.push({ file: r.path, className: r.className });
1580
+ }
1581
+ if (content.includes(`new ${shortName}(`) || content.includes(`new \\${className}(`)) {
1582
+ references.instantiations.push({ file: r.path, className: r.className });
1583
+ }
1584
+ if (content.includes(`@var ${shortName}`) || content.includes(`@param ${shortName}`) ||
1585
+ content.match(new RegExp(`:\\s*${shortName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`))) {
1586
+ references.typeHints.push({ file: r.path, className: r.className });
1587
+ }
1588
+ }
1589
+
1590
+ references.total = references.useStatements.length + references.diXmlReferences.length +
1591
+ references.instantiations.length + references.typeHints.length;
1592
+
1593
+ return references;
1594
+ }
1595
+
1596
+ // ─── Event Flow Tracing ─────────────────────────────────────────
1597
+
1598
+ async function traceEventFlow(eventName) {
1599
+ const root = config.magentoRoot;
1600
+
1601
+ const result = {
1602
+ eventName,
1603
+ dispatchers: [],
1604
+ observers: [],
1605
+ observerDetails: []
1606
+ };
1607
+
1608
+ // 1. Parse all events.xml for observer declarations
1609
+ const eventsFiles = await glob('**/etc/**/events.xml', { cwd: root, absolute: true, nodir: true });
1610
+ const escapedEvent = eventName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1611
+
1612
+ for (const file of eventsFiles) {
1613
+ let content;
1614
+ try { content = readFileSync(file, 'utf-8'); } catch { continue; }
1615
+ const relativePath = file.replace(root + '/', '');
1616
+
1617
+ const eventBlockRegex = new RegExp(`<event\\s+name="${escapedEvent}"[^>]*>([\\s\\S]*?)<\\/event>`, 'g');
1618
+ let eventMatch;
1619
+ while ((eventMatch = eventBlockRegex.exec(content)) !== null) {
1620
+ const block = eventMatch[1];
1621
+ const obsRegex = /<observer\s+[^>]*name="([^"]+)"[^>]*instance="([^"]+)"[^>]*(?:method="([^"]+)")?[^>]*\/?>/g;
1622
+ let obsMatch;
1623
+ while ((obsMatch = obsRegex.exec(block)) !== null) {
1624
+ result.observers.push({
1625
+ name: obsMatch[1],
1626
+ instance: obsMatch[2],
1627
+ method: obsMatch[3] || 'execute',
1628
+ file: relativePath
1629
+ });
1630
+ }
1631
+ }
1632
+ }
1633
+
1634
+ // 2. Find dispatch() calls via vector search
1635
+ try {
1636
+ const dispatchSearch = await rustSearchAsync(`dispatch ${eventName} eventManager`, 20);
1637
+ const dispatchRaw = Array.isArray(dispatchSearch) ? dispatchSearch : [];
1638
+ const dispatchers = dispatchRaw.map(normalizeResult).filter(r =>
1639
+ r.searchText?.includes(eventName) || r.path?.endsWith('.php')
1640
+ );
1641
+ result.dispatchers = dispatchers.slice(0, 10).map(r => ({
1642
+ path: r.path,
1643
+ className: r.className,
1644
+ snippet: (r.searchText || '').slice(0, 200)
1645
+ }));
1646
+ } catch { /* index may not be ready */ }
1647
+
1648
+ // 3. Resolve observer PHP classes
1649
+ for (const obs of result.observers.slice(0, 10)) {
1650
+ const shortName = obs.instance.split('\\').pop();
1651
+ try {
1652
+ const obsSearch = await rustSearchAsync(shortName, 5);
1653
+ const obsRaw = Array.isArray(obsSearch) ? obsSearch : [];
1654
+ const matches = obsRaw.map(normalizeResult).filter(r =>
1655
+ r.className?.includes(shortName)
1656
+ );
1657
+ if (matches[0]) {
1658
+ result.observerDetails.push({
1659
+ name: obs.name,
1660
+ instance: obs.instance,
1661
+ path: matches[0].path,
1662
+ methods: matches[0].methods || []
1663
+ });
1664
+ }
1665
+ } catch { /* non-fatal */ }
1666
+ }
1667
+
1668
+ return result;
1669
+ }
1670
+
1671
+ // ─── Test Finder ────────────────────────────────────────────────
1672
+
1673
+ async function findTests(className, methodName) {
1674
+ const root = config.magentoRoot;
1675
+ const shortName = className.split('\\').pop();
1676
+
1677
+ const result = {
1678
+ className,
1679
+ tests: []
1680
+ };
1681
+
1682
+ // Find test files by name pattern
1683
+ const testPatterns = [
1684
+ `**/*${shortName}Test.php`,
1685
+ `**/*${shortName}*Test.php`,
1686
+ `**/Test/**/*${shortName}*.php`,
1687
+ ];
1688
+
1689
+ const testFiles = new Set();
1690
+ for (const pattern of testPatterns) {
1691
+ try {
1692
+ const found = await glob(pattern, { cwd: root, absolute: true, nodir: true });
1693
+ for (const f of found) testFiles.add(f);
1694
+ } catch { /* glob failure non-fatal */ }
1695
+ }
1696
+
1697
+ // Also search via vector search
1698
+ try {
1699
+ const query = methodName
1700
+ ? `test ${shortName} ${methodName} PHPUnit`
1701
+ : `test ${shortName} PHPUnit`;
1702
+ const rawSearch = await rustSearchAsync(query, 20);
1703
+ const rawArr = Array.isArray(rawSearch) ? rawSearch : [];
1704
+ for (const r of rawArr.map(normalizeResult)) {
1705
+ if (r.path?.toLowerCase().includes('test')) {
1706
+ testFiles.add(path.join(root, r.path));
1707
+ }
1708
+ }
1709
+ } catch { /* index may not be ready */ }
1710
+
1711
+ // Read each test file and extract info
1712
+ for (const file of [...testFiles].slice(0, 20)) {
1713
+ let content;
1714
+ try { content = readFileSync(file, 'utf-8'); } catch { continue; }
1715
+ const relativePath = file.replace(root + '/', '');
1716
+
1717
+ if (!content.includes(shortName)) continue;
1718
+
1719
+ const testMethods = [];
1720
+ const methodRegex = /(?:public\s+)?function\s+(test\w+)\s*\(/g;
1721
+ let m;
1722
+ while ((m = methodRegex.exec(content)) !== null) testMethods.push(m[1]);
1723
+
1724
+ const coversAnnotation = content.includes('@covers') && content.includes(shortName);
1725
+ const hasMock = content.includes('getMock') || content.includes('createMock') ||
1726
+ content.includes('MockObject');
1727
+
1728
+ result.tests.push({
1729
+ path: relativePath,
1730
+ testMethods,
1731
+ coversAnnotation,
1732
+ hasMock,
1733
+ referencesClass: content.includes(shortName)
1734
+ });
1735
+ }
1736
+
1737
+ return result;
1738
+ }
1739
+
996
1740
  // ─── MCP Server ─────────────────────────────────────────────────
997
1741
 
998
1742
  const server = new Server(
999
1743
  {
1000
1744
  name: 'magector',
1001
- version: '1.0.0'
1745
+ version: '2.1.0'
1002
1746
  },
1003
1747
  {
1004
1748
  capabilities: {
@@ -1025,6 +1769,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1025
1769
  description: 'Maximum results to return (default: 10, max: 100)',
1026
1770
  default: 10
1027
1771
  },
1772
+ moduleFilter: {
1773
+ type: 'string',
1774
+ description: 'Filter results by vendor/module pattern. Supports wildcards. Examples: "Vendor_*" to show only custom modules, "Magento_Catalog" for specific module. Omit to search all modules.'
1775
+ },
1776
+ expand: {
1777
+ type: 'boolean',
1778
+ description: 'Enable query expansion with Magento domain synonyms for better recall (default: true)',
1779
+ default: true
1780
+ },
1028
1781
  },
1029
1782
  required: ['query']
1030
1783
  }
@@ -1362,6 +2115,122 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1362
2115
  required: ['entryPoint']
1363
2116
  }
1364
2117
  },
2118
+ {
2119
+ name: 'magento_trace_dependency',
2120
+ description: 'Trace dependency injection graph for a PHP class or interface. Parses di.xml files across all modules to find: preferences (interface→implementation), plugins (interceptors), virtualTypes, and constructor argument overrides. Use this to understand how Magento resolves a class at runtime — especially useful for "Cannot instantiate interface" errors.',
2121
+ inputSchema: {
2122
+ type: 'object',
2123
+ properties: {
2124
+ className: {
2125
+ type: 'string',
2126
+ description: 'Full or partial PHP class/interface name to trace. Examples: "ProductRepositoryInterface", "CartManagementInterface", "LoggerInterface", "StoreManagerInterface"'
2127
+ },
2128
+ direction: {
2129
+ type: 'string',
2130
+ enum: ['resolve', 'dependents', 'both'],
2131
+ description: '"resolve" finds what implements/replaces this class (preferences, virtualTypes). "dependents" finds what depends on this class (plugins, type arguments). "both" does both. Default: both.',
2132
+ default: 'both'
2133
+ }
2134
+ },
2135
+ required: ['className']
2136
+ }
2137
+ },
2138
+ {
2139
+ name: 'magento_error_parser',
2140
+ description: 'Parse a Magento error message or stack trace and map it to relevant source files and root causes. Understands common Magento error patterns: DI instantiation failures, missing class/interface, plugin sort conflicts, area code not set, undefined index in templates, and more. Provides actionable file paths and fix suggestions.',
2141
+ inputSchema: {
2142
+ type: 'object',
2143
+ properties: {
2144
+ error: {
2145
+ type: 'string',
2146
+ description: 'Full error message, exception message, or stack trace from Magento. Can include PHP fatal errors, uncaught exceptions, or log entries.'
2147
+ }
2148
+ },
2149
+ required: ['error']
2150
+ }
2151
+ },
2152
+ {
2153
+ name: 'magento_performance_profile',
2154
+ description: 'Profile a Magento subsystem for performance bottlenecks. Scans for all plugins, observers, and collectors registered on a critical path (e.g., checkout totals, order placement, product save). Returns files sorted by complexity score to identify likely performance hotspots.',
2155
+ inputSchema: {
2156
+ type: 'object',
2157
+ properties: {
2158
+ subsystem: {
2159
+ type: 'string',
2160
+ description: 'Magento subsystem to profile. Examples: "checkout_totals", "order_place", "product_save", "cart_add", "customer_login", "catalog_reindex"'
2161
+ },
2162
+ threshold: {
2163
+ type: 'number',
2164
+ description: 'Minimum complexity score to include in results (default: 0). Set higher (e.g., 5) to focus on complex files only.',
2165
+ default: 0
2166
+ }
2167
+ },
2168
+ required: ['subsystem']
2169
+ }
2170
+ },
2171
+ {
2172
+ name: 'magento_find_layout',
2173
+ description: 'Find layout XML files — handles, blocks, containers, and referenceBlock/referenceContainer declarations. Parses view/*/layout/*.xml files across all modules. Use to understand page structure and block assignments.',
2174
+ inputSchema: {
2175
+ type: 'object',
2176
+ properties: {
2177
+ query: {
2178
+ type: 'string',
2179
+ description: 'Search term to find in layout XML content. Examples: "product.info", "checkout.cart", "minicart", "breadcrumbs", "page.main.title"'
2180
+ },
2181
+ handle: {
2182
+ type: 'string',
2183
+ description: 'Layout handle name (file name without .xml). Examples: "catalog_product_view", "checkout_index_index", "default", "customer_account"'
2184
+ }
2185
+ }
2186
+ }
2187
+ },
2188
+ {
2189
+ name: 'magento_impact_analysis',
2190
+ description: 'Analyze the impact of changing a PHP class — finds all files that reference it via use statements, DI configuration, instantiation, and type hints. Combines DI XML tracing with PHP source analysis to map cross-module dependencies.',
2191
+ inputSchema: {
2192
+ type: 'object',
2193
+ properties: {
2194
+ className: {
2195
+ type: 'string',
2196
+ description: 'Full or partial PHP class/interface name. Examples: "ProductRepository", "CartManagementInterface", "StoreManagerInterface"'
2197
+ }
2198
+ },
2199
+ required: ['className']
2200
+ }
2201
+ },
2202
+ {
2203
+ name: 'magento_find_event_flow',
2204
+ description: 'Trace complete event flow chain: find where an event is dispatched, list all observers registered in events.xml, and resolve observer PHP classes. Shows the full dispatch → observer → handler chain for any Magento event.',
2205
+ inputSchema: {
2206
+ type: 'object',
2207
+ properties: {
2208
+ eventName: {
2209
+ type: 'string',
2210
+ description: 'Magento event name. Examples: "sales_order_place_after", "checkout_cart_add_product_complete", "catalog_product_save_after", "customer_login", "controller_action_predispatch"'
2211
+ }
2212
+ },
2213
+ required: ['eventName']
2214
+ }
2215
+ },
2216
+ {
2217
+ name: 'magento_find_test',
2218
+ description: 'Find PHPUnit test files for a given PHP class or method. Searches Test/ directories for test classes, @covers annotations, mock references, and class name matches. Helps identify test coverage for refactoring.',
2219
+ inputSchema: {
2220
+ type: 'object',
2221
+ properties: {
2222
+ className: {
2223
+ type: 'string',
2224
+ description: 'PHP class name to find tests for. Examples: "ProductRepository", "CartManagement", "OrderService"'
2225
+ },
2226
+ methodName: {
2227
+ type: 'string',
2228
+ description: 'Optional method name to narrow test search. Examples: "save", "getById", "execute"'
2229
+ }
2230
+ },
2231
+ required: ['className']
2232
+ }
2233
+ },
1365
2234
  ]
1366
2235
  }));
1367
2236
 
@@ -1370,13 +2239,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1370
2239
  const reqStart = Date.now();
1371
2240
  logToFile('REQ', `${name}(${JSON.stringify(args || {})})`);
1372
2241
 
1373
- // Block search tools while re-indexing is in progress
1374
- if (reindexInProgress && name !== 'magento_stats' && name !== 'magento_analyze_diff' && name !== 'magento_complexity') {
1375
- logToFile('REQ', `${name} → blocked (re-indexing in progress)`);
2242
+ // Block search tools only when re-indexing AND no usable old DB exists.
2243
+ // If old DB is preserved, searches keep running against it during rebuild.
2244
+ const indexFreeTools = ['magento_stats', 'magento_analyze_diff', 'magento_complexity',
2245
+ 'magento_trace_dependency', 'magento_error_parser', 'magento_find_layout',
2246
+ 'magento_impact_analysis', 'magento_find_event_flow', 'magento_find_test'];
2247
+ const hasUsableDb = existsSync(config.dbPath) && (() => { try { return statSync(config.dbPath).size > 100; } catch { return false; } })();
2248
+ if (reindexInProgress && !hasUsableDb && !indexFreeTools.includes(name)) {
2249
+ logToFile('REQ', `${name} → blocked (re-indexing, no usable DB)`);
1376
2250
  return {
1377
2251
  content: [{
1378
2252
  type: 'text',
1379
- text: 'Re-indexing in progress. The database format was incompatible and is being rebuilt automatically. Check .magector/magector.log for progress. Search tools will be available once re-indexing completes.'
2253
+ text: 'Re-indexing in progress and no previous index available. Check .magector/magector.log for progress. Search tools will be available once re-indexing completes.'
1380
2254
  }],
1381
2255
  isError: true,
1382
2256
  };
@@ -1390,15 +2264,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1390
2264
  try {
1391
2265
  switch (name) {
1392
2266
  case 'magento_search': {
1393
- const raw = await rustSearchAsync(args.query, args.limit || 10);
2267
+ const searchQuery = args.expand !== false ? expandQuery(args.query) : args.query;
2268
+ const raw = await rustSearchAsync(searchQuery, Math.max(args.limit || 10, 30));
1394
2269
  const arr = Array.isArray(raw) ? raw : [];
1395
- const results = arr.map(normalizeResult);
2270
+ let results = arr.map(normalizeResult);
2271
+ // Hybrid BM25 rerank for better exact-match handling
2272
+ results = hybridRerank(results, args.query);
2273
+ // Apply module filter if specified
2274
+ if (args.moduleFilter) {
2275
+ results = filterByModule(results, args.moduleFilter);
2276
+ }
1396
2277
  // SONA: record search with results for follow-up tracking
1397
2278
  sessionTracker.recordToolCall(name, args || {}, arr);
1398
2279
  return {
1399
2280
  content: [{
1400
2281
  type: 'text',
1401
- text: formatSearchResults(results)
2282
+ text: formatSearchResults(results.slice(0, args.limit || 10))
1402
2283
  }]
1403
2284
  };
1404
2285
  }
@@ -1926,7 +2807,262 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1926
2807
  };
1927
2808
  }
1928
2809
 
2810
+ case 'magento_trace_dependency': {
2811
+ const diResult = await traceDependency(args.className, args.direction || 'both');
1929
2812
 
2813
+ let text = `## DI Dependency Trace: ${args.className}\n\n`;
2814
+ text += `Scanned ${diResult.totalDiFiles} di.xml files.\n\n`;
2815
+
2816
+ if (diResult.preferences.length > 0) {
2817
+ text += `### Preferences (${diResult.preferences.length})\n`;
2818
+ for (const p of diResult.preferences) {
2819
+ text += `- \`${p.for}\` → \`${p.type}\` (${p.file})\n`;
2820
+ }
2821
+ text += '\n';
2822
+ }
2823
+
2824
+ if (diResult.plugins.length > 0) {
2825
+ text += `### Plugins (${diResult.plugins.length})\n`;
2826
+ for (const p of diResult.plugins) {
2827
+ text += `- \`${p.pluginName}\`: \`${p.pluginClass}\` on \`${p.target}\` (${p.file})\n`;
2828
+ }
2829
+ text += '\n';
2830
+ }
2831
+
2832
+ if (diResult.virtualTypes.length > 0) {
2833
+ text += `### Virtual Types (${diResult.virtualTypes.length})\n`;
2834
+ for (const v of diResult.virtualTypes) {
2835
+ text += `- \`${v.name}\` extends \`${v.type}\` (${v.file})\n`;
2836
+ }
2837
+ text += '\n';
2838
+ }
2839
+
2840
+ if (diResult.argumentOverrides.length > 0) {
2841
+ text += `### Argument Overrides (${diResult.argumentOverrides.length})\n`;
2842
+ for (const a of diResult.argumentOverrides) {
2843
+ text += `- \`${a.target}\`.${a.argumentName} (${a.argumentType}) = ${a.value} (${a.file})\n`;
2844
+ }
2845
+ text += '\n';
2846
+ }
2847
+
2848
+ if (diResult.preferences.length === 0 && diResult.plugins.length === 0 &&
2849
+ diResult.virtualTypes.length === 0 && diResult.argumentOverrides.length === 0) {
2850
+ text += '_No DI configuration found for this class. It may use default Magento auto-resolution or be configured under a different name._\n';
2851
+ }
2852
+
2853
+ return { content: [{ type: 'text', text }] };
2854
+ }
2855
+
2856
+ case 'magento_error_parser': {
2857
+ const errorResult = await parseError(args.error);
2858
+
2859
+ let text = `## Error Analysis\n\n`;
2860
+ text += `**Type:** ${errorResult.errorType}\n`;
2861
+ if (errorResult.suggestion) {
2862
+ text += `**Suggestion:** ${errorResult.suggestion}\n`;
2863
+ }
2864
+ text += '\n';
2865
+
2866
+ if (Object.keys(errorResult.parsed).length > 0) {
2867
+ text += `### Parsed Details\n`;
2868
+ for (const [key, value] of Object.entries(errorResult.parsed)) {
2869
+ text += `- **${key}:** \`${value}\`\n`;
2870
+ }
2871
+ text += '\n';
2872
+ }
2873
+
2874
+ if (errorResult.existingPreferences?.length > 0) {
2875
+ text += `### Existing Preferences\n`;
2876
+ for (const p of errorResult.existingPreferences) {
2877
+ text += `- \`${p.for}\` → \`${p.type}\` (${p.file})\n`;
2878
+ }
2879
+ text += '\n';
2880
+ }
2881
+
2882
+ if (errorResult.relatedFiles.length > 0) {
2883
+ text += `### Related Files\n`;
2884
+ for (const f of errorResult.relatedFiles) {
2885
+ text += `- \`${f.path}\`${f.className ? ` (${f.className})` : ''}\n`;
2886
+ }
2887
+ text += '\n';
2888
+ }
2889
+
2890
+ if (errorResult.stackTrace.length > 0) {
2891
+ text += `### Stack Trace Files\n`;
2892
+ for (const s of errorResult.stackTrace) {
2893
+ text += `- ${s.file}${s.line ? `:${s.line}` : ''}\n`;
2894
+ }
2895
+ text += '\n';
2896
+ }
2897
+
2898
+ return { content: [{ type: 'text', text }] };
2899
+ }
2900
+
2901
+ case 'magento_performance_profile': {
2902
+ const profileResult = await profilePerformance(args.subsystem, args.threshold || 0);
2903
+
2904
+ let text = `## Performance Profile: ${profileResult.subsystem}\n\n`;
2905
+ text += `Found ${profileResult.totalFiles || profileResult.files?.length || 0} related files.\n\n`;
2906
+
2907
+ const knownSubsystems = Object.keys(SUBSYSTEM_QUERIES);
2908
+ if (!SUBSYSTEM_QUERIES[args.subsystem]) {
2909
+ text += `_Custom subsystem — used generic search. Known subsystems: ${knownSubsystems.join(', ')}_\n\n`;
2910
+ }
2911
+
2912
+ if (profileResult.plugins?.length > 0) {
2913
+ text += `### Plugins (${profileResult.plugins.length})\n`;
2914
+ for (const p of profileResult.plugins) {
2915
+ text += `- \`${p.path}\`${p.className ? ` (${p.className})` : ''}\n`;
2916
+ }
2917
+ text += '\n';
2918
+ }
2919
+
2920
+ if (profileResult.observers?.length > 0) {
2921
+ text += `### Observers (${profileResult.observers.length})\n`;
2922
+ for (const o of profileResult.observers) {
2923
+ text += `- \`${o.path}\`${o.className ? ` (${o.className})` : ''}\n`;
2924
+ }
2925
+ text += '\n';
2926
+ }
2927
+
2928
+ if (profileResult.files?.length > 0) {
2929
+ text += `### Files by Complexity\n`;
2930
+ text += '| File | Complexity | Rating | Functions | Lines |\n';
2931
+ text += '|------|-----------|--------|-----------|-------|\n';
2932
+ for (const f of profileResult.files.filter(f => f.complexity > 0).slice(0, 20)) {
2933
+ text += `| ${f.path} | ${f.complexity} | ${f.complexityRating} | ${f.functions} | ${f.lines} |\n`;
2934
+ }
2935
+ text += '\n';
2936
+ }
2937
+
2938
+ return { content: [{ type: 'text', text }] };
2939
+ }
2940
+
2941
+ case 'magento_find_layout': {
2942
+ if (!args.query && !args.handle) {
2943
+ return { content: [{ type: 'text', text: 'Provide at least one of: query or handle.' }], isError: true };
2944
+ }
2945
+ const layouts = await findLayout(args.query, args.handle);
2946
+ if (layouts.length === 0) {
2947
+ return { content: [{ type: 'text', text: 'No layout XML files found matching the query.' }] };
2948
+ }
2949
+ let text = `## Layout XML Results (${layouts.length} files)\n\n`;
2950
+ for (const l of layouts.slice(0, 20)) {
2951
+ text += `### ${l.handle} — \`${l.path}\`\n`;
2952
+ if (l.blocks.length > 0) text += ` Blocks: ${l.blocks.slice(0, 10).map(b => '`' + b + '`').join(', ')}\n`;
2953
+ if (l.containers.length > 0) text += ` Containers: ${l.containers.slice(0, 10).map(c => '`' + c + '`').join(', ')}\n`;
2954
+ if (l.referenceBlocks.length > 0) text += ` Ref blocks: ${l.referenceBlocks.slice(0, 10).map(b => '`' + b + '`').join(', ')}\n`;
2955
+ text += '\n';
2956
+ }
2957
+ return { content: [{ type: 'text', text }] };
2958
+ }
2959
+
2960
+ case 'magento_impact_analysis': {
2961
+ const impact = await analyzeImpact(args.className);
2962
+
2963
+ let text = `## Impact Analysis: ${impact.className}\n\n`;
2964
+ text += `**Total references:** ${impact.total}\n\n`;
2965
+
2966
+ if (impact.useStatements.length > 0) {
2967
+ text += `### Use Statements (${impact.useStatements.length})\n`;
2968
+ for (const u of impact.useStatements) {
2969
+ text += `- \`${u.file}\`${u.className ? ` (${u.className})` : ''}\n`;
2970
+ }
2971
+ text += '\n';
2972
+ }
2973
+
2974
+ if (impact.diXmlReferences.length > 0) {
2975
+ text += `### DI XML References (${impact.diXmlReferences.length})\n`;
2976
+ for (const d of impact.diXmlReferences) {
2977
+ text += `- [${d.type}] ${d.detail} (\`${d.file}\`)\n`;
2978
+ }
2979
+ text += '\n';
2980
+ }
2981
+
2982
+ if (impact.instantiations.length > 0) {
2983
+ text += `### Direct Instantiations (${impact.instantiations.length})\n`;
2984
+ for (const i of impact.instantiations) {
2985
+ text += `- \`${i.file}\`${i.className ? ` (${i.className})` : ''}\n`;
2986
+ }
2987
+ text += '\n';
2988
+ }
2989
+
2990
+ if (impact.typeHints.length > 0) {
2991
+ text += `### Type Hints / PHPDoc (${impact.typeHints.length})\n`;
2992
+ for (const t of impact.typeHints) {
2993
+ text += `- \`${t.file}\`${t.className ? ` (${t.className})` : ''}\n`;
2994
+ }
2995
+ text += '\n';
2996
+ }
2997
+
2998
+ if (impact.total === 0) {
2999
+ text += '_No references found. The class may be referenced under a different name or alias._\n';
3000
+ }
3001
+
3002
+ return { content: [{ type: 'text', text }] };
3003
+ }
3004
+
3005
+ case 'magento_find_event_flow': {
3006
+ const flow = await traceEventFlow(args.eventName);
3007
+
3008
+ let text = `## Event Flow: ${flow.eventName}\n\n`;
3009
+
3010
+ if (flow.dispatchers.length > 0) {
3011
+ text += `### Dispatchers (${flow.dispatchers.length})\n`;
3012
+ for (const d of flow.dispatchers) {
3013
+ text += `- \`${d.path}\`${d.className ? ` (${d.className})` : ''}\n`;
3014
+ if (d.snippet) text += ` _${d.snippet.slice(0, 150)}_\n`;
3015
+ }
3016
+ text += '\n';
3017
+ }
3018
+
3019
+ if (flow.observers.length > 0) {
3020
+ text += `### Observers (${flow.observers.length})\n`;
3021
+ for (const o of flow.observers) {
3022
+ text += `- **${o.name}**: \`${o.instance}::${o.method}\` (${o.file})\n`;
3023
+ }
3024
+ text += '\n';
3025
+ }
3026
+
3027
+ if (flow.observerDetails.length > 0) {
3028
+ text += `### Observer Class Details\n`;
3029
+ for (const od of flow.observerDetails) {
3030
+ text += `- **${od.name}** → \`${od.path}\``;
3031
+ if (od.methods.length > 0) text += ` [methods: ${od.methods.join(', ')}]`;
3032
+ text += '\n';
3033
+ }
3034
+ text += '\n';
3035
+ }
3036
+
3037
+ if (flow.observers.length === 0 && flow.dispatchers.length === 0) {
3038
+ text += '_No observers or dispatchers found for this event._\n';
3039
+ }
3040
+
3041
+ return { content: [{ type: 'text', text }] };
3042
+ }
3043
+
3044
+ case 'magento_find_test': {
3045
+ const testResult = await findTests(args.className, args.methodName);
3046
+
3047
+ let text = `## Tests for: ${testResult.className}\n\n`;
3048
+
3049
+ if (testResult.tests.length === 0) {
3050
+ text += '_No test files found for this class._\n';
3051
+ } else {
3052
+ text += `Found ${testResult.tests.length} test file(s).\n\n`;
3053
+ for (const t of testResult.tests) {
3054
+ text += `### \`${t.path}\`\n`;
3055
+ if (t.coversAnnotation) text += ' Has @covers annotation\n';
3056
+ if (t.hasMock) text += ' Uses mocking\n';
3057
+ if (t.testMethods.length > 0) {
3058
+ text += ` Test methods: ${t.testMethods.slice(0, 15).join(', ')}\n`;
3059
+ }
3060
+ text += '\n';
3061
+ }
3062
+ }
3063
+
3064
+ return { content: [{ type: 'text', text }] };
3065
+ }
1930
3066
 
1931
3067
  default:
1932
3068
  return {
@@ -2006,11 +3142,12 @@ async function main() {
2006
3142
  startBackgroundReindex();
2007
3143
  }
2008
3144
 
2009
- // Try to start persistent Rust serve process for fast queries
2010
- if (!reindexInProgress) {
3145
+ // Start persistent Rust serve process for fast queries.
3146
+ // During reindex, start if old DB exists so searches keep working.
3147
+ const canStartServe = !reindexInProgress || (existsSync(config.dbPath) && (() => { try { return statSync(config.dbPath).size > 100; } catch { return false; } })());
3148
+ if (canStartServe) {
2011
3149
  try {
2012
3150
  startServeProcess();
2013
- // Wait for the serve process to load ONNX model + HNSW index (up to 15s)
2014
3151
  if (serveReadyPromise) {
2015
3152
  const ready = await Promise.race([
2016
3153
  serveReadyPromise,