magector 2.4.1 → 2.5.1

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 CHANGED
@@ -415,7 +415,8 @@ All search tools return structured JSON:
415
415
  |------|-------------|
416
416
  | `magento_find_config` | Find XML configuration (di.xml, events.xml, routes.xml, system.xml, webapi.xml, module.xml, layout) |
417
417
  | `magento_find_template` | Find PHTML template files for frontend or admin rendering |
418
- | `magento_find_plugin` | Find interceptor plugins (before/after/around methods) and di.xml declarations |
418
+ | `magento_find_plugin` | Find interceptor plugins (before/after/around methods) and di.xml declarations. Resolves plugin PHP files and extracts interceptor method signatures **(v2.5)** |
419
+ | `magento_find_fieldset` | Find fieldset.xml definitions controlling data copy between entities (order→quote, quote→order). Shows fields per aspect (to_order, to_edit) **(v2.5)** |
419
420
  | `magento_find_observer` | Find event observers and events.xml declarations |
420
421
  | `magento_find_preference` | Find DI preference overrides -- which class implements an interface |
421
422
  | `magento_find_controller` | Find MVC controllers by frontend or admin route path |
@@ -429,7 +430,8 @@ All search tools return structured JSON:
429
430
 
430
431
  | Tool | Description |
431
432
  |------|-------------|
432
- | `magento_trace_flow` | Trace execution flow from an entry point (route, API, GraphQL, event, cron) -- maps controller → plugins → observers → templates in one call |
433
+ | `magento_trace_flow` | Trace execution flow from an entry point (route, API, GraphQL, event, cron) -- maps controller → plugins → observers → templates with code snippets **(v2.5)** |
434
+ | `magento_trace_shipping_chain` | Trace the complete shipping rate chain: carriers → collectRates plugins → rate modifiers → totals collectors → fieldset mappings **(v2.5)** |
433
435
  | `magento_trace_dependency` | Trace DI graph for a class/interface -- preferences, plugins, virtualTypes, argument overrides (parses all di.xml, no index needed) |
434
436
  | `magento_find_event_flow` | Trace complete event chain: dispatchers → observers → handler PHP classes (parses events.xml + vector search) |
435
437
  | `magento_find_event_dispatchers` | Find all PHP locations where a specific event is dispatched -- exact grep matching with method context and surrounding code **(v2.3)** |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magector",
3
- "version": "2.4.1",
3
+ "version": "2.5.1",
4
4
  "description": "Semantic code search for Magento 2 — index, search, MCP server",
5
5
  "type": "module",
6
6
  "main": "src/mcp-server.js",
@@ -33,10 +33,10 @@
33
33
  "ruvector": "^0.1.96"
34
34
  },
35
35
  "optionalDependencies": {
36
- "@magector/cli-darwin-arm64": "2.4.1",
37
- "@magector/cli-linux-x64": "2.4.1",
38
- "@magector/cli-linux-arm64": "2.4.1",
39
- "@magector/cli-win32-x64": "2.4.1"
36
+ "@magector/cli-darwin-arm64": "2.5.1",
37
+ "@magector/cli-linux-x64": "2.5.1",
38
+ "@magector/cli-linux-arm64": "2.5.1",
39
+ "@magector/cli-win32-x64": "2.5.1"
40
40
  },
41
41
  "keywords": [
42
42
  "magento",
package/src/mcp-server.js CHANGED
@@ -965,6 +965,228 @@ function normalizeResult(r) {
965
965
  * @param {Object} boosts - e.g. { fileType: 'xml', pathContains: 'di.xml', isPlugin: true }
966
966
  * @param {number} weight - boost multiplier (default 0.3 = 30% score bump per match)
967
967
  */
968
+ /**
969
+ * Extract before/after/around plugin method signatures from a PHP file.
970
+ * Returns array of { name, type, targetMethod, signature } objects.
971
+ */
972
+ function extractPluginMethods(filePath) {
973
+ const absPath = filePath.startsWith('/') ? filePath : path.join(config.magentoRoot, filePath);
974
+ let content;
975
+ try { content = readFileSync(absPath, 'utf-8'); } catch { return []; }
976
+ const methods = [];
977
+ const methodRegex = /^\s*public\s+function\s+((?:before|after|around)([A-Z]\w*))\s*\(([^)]*)\)/gm;
978
+ let match;
979
+ while ((match = methodRegex.exec(content)) !== null) {
980
+ const name = match[1];
981
+ const targetMethod = match[2].charAt(0).toLowerCase() + match[2].slice(1);
982
+ let type = 'around';
983
+ if (name.startsWith('before')) type = 'before';
984
+ else if (name.startsWith('after')) type = 'after';
985
+ methods.push({ name, type, targetMethod, signature: match[0].trim() });
986
+ }
987
+ return methods;
988
+ }
989
+
990
+ /**
991
+ * Read a short code snippet around a method definition from a PHP file.
992
+ * Returns up to `maxLines` lines starting from the method signature.
993
+ */
994
+ function readMethodSnippet(filePath, methodName, maxLines = 15) {
995
+ const absPath = filePath.startsWith('/') ? filePath : path.join(config.magentoRoot, filePath);
996
+ let content;
997
+ try { content = readFileSync(absPath, 'utf-8'); } catch { return null; }
998
+ const lines = content.split('\n');
999
+ for (let i = 0; i < lines.length; i++) {
1000
+ if (lines[i].includes(`function ${methodName}(`)) {
1001
+ return lines.slice(i, i + maxLines).join('\n');
1002
+ }
1003
+ }
1004
+ return null;
1005
+ }
1006
+
1007
+ /**
1008
+ * Parse all fieldset.xml files in the Magento root.
1009
+ * Returns array of { file, scope, fieldset, fields: [{ field, aspect }] }.
1010
+ */
1011
+ async function parseFieldsetXml(filterFieldset, filterAspect) {
1012
+ const root = config.magentoRoot;
1013
+ const fieldsetFiles = await glob('**/etc/fieldset.xml', { cwd: root, absolute: true, nodir: true });
1014
+ const results = [];
1015
+ for (const fsFile of fieldsetFiles) {
1016
+ let content;
1017
+ try { content = readFileSync(fsFile, 'utf-8'); } catch { continue; }
1018
+ const relPath = fsFile.replace(root + '/', '');
1019
+ // Parse <scope id="..."> blocks
1020
+ const scopeRegex = /<scope\s+id="([^"]+)">([\s\S]*?)<\/scope>/g;
1021
+ let scopeMatch;
1022
+ while ((scopeMatch = scopeRegex.exec(content)) !== null) {
1023
+ const scopeName = scopeMatch[1];
1024
+ const scopeBlock = scopeMatch[2];
1025
+ const innerFsRegex = /<fieldset\s+id="([^"]+)">([\s\S]*?)<\/fieldset>/g;
1026
+ let innerMatch;
1027
+ while ((innerMatch = innerFsRegex.exec(scopeBlock)) !== null) {
1028
+ const innerFieldsetId = innerMatch[1];
1029
+ if (filterFieldset && !innerFieldsetId.toLowerCase().includes(filterFieldset.toLowerCase()) && !scopeName.toLowerCase().includes(filterFieldset.toLowerCase())) continue;
1030
+ const innerBlock = innerMatch[2];
1031
+ const fieldRegex = /<field\s+name="([^"]+)">([\s\S]*?)<\/field>/g;
1032
+ let fieldMatch;
1033
+ const fields = [];
1034
+ while ((fieldMatch = fieldRegex.exec(innerBlock)) !== null) {
1035
+ const fieldName = fieldMatch[1];
1036
+ const fieldBlock = fieldMatch[2];
1037
+ const aspectRegex = /<aspect\s+name="([^"]+)"\s*(?:\/>|>[^<]*<\/aspect>)/g;
1038
+ let aspectMatch;
1039
+ while ((aspectMatch = aspectRegex.exec(fieldBlock)) !== null) {
1040
+ if (filterAspect && !aspectMatch[1].toLowerCase().includes(filterAspect.toLowerCase())) continue;
1041
+ fields.push({ field: fieldName, aspect: aspectMatch[1] });
1042
+ }
1043
+ }
1044
+ if (fields.length > 0) {
1045
+ results.push({ file: relPath, scope: scopeName, fieldset: innerFieldsetId, fields });
1046
+ }
1047
+ }
1048
+ }
1049
+ // Also handle flat fieldset format: <fieldset id="..."> without scope wrapper
1050
+ const flatFsRegex = /<fieldset\s+id="([^"]+)">([\s\S]*?)<\/fieldset>/g;
1051
+ let flatMatch;
1052
+ while ((flatMatch = flatFsRegex.exec(content)) !== null) {
1053
+ const fieldsetId = flatMatch[1];
1054
+ if (filterFieldset && !fieldsetId.toLowerCase().includes(filterFieldset.toLowerCase())) continue;
1055
+ const block = flatMatch[2];
1056
+ if (block.includes('<fieldset')) continue; // skip nested (handled above)
1057
+ const fieldRegex = /<field\s+name="([^"]+)">([\s\S]*?)<\/field>/g;
1058
+ let fieldMatch;
1059
+ const fields = [];
1060
+ while ((fieldMatch = fieldRegex.exec(block)) !== null) {
1061
+ const fieldName = fieldMatch[1];
1062
+ const fieldBlock = fieldMatch[2];
1063
+ const aspectRegex = /<aspect\s+name="([^"]+)"\s*(?:\/>|>[^<]*<\/aspect>)/g;
1064
+ let aspectMatch;
1065
+ while ((aspectMatch = aspectRegex.exec(fieldBlock)) !== null) {
1066
+ if (filterAspect && !aspectMatch[1].toLowerCase().includes(filterAspect.toLowerCase())) continue;
1067
+ fields.push({ field: fieldName, aspect: aspectMatch[1] });
1068
+ }
1069
+ }
1070
+ if (fields.length > 0) {
1071
+ results.push({ file: relPath, scope: null, fieldset: fieldsetId, fields });
1072
+ }
1073
+ }
1074
+ }
1075
+ return results;
1076
+ }
1077
+
1078
+ /**
1079
+ * Resolve a PHP class name to a file path by converting namespace to path.
1080
+ */
1081
+ function findClassFile(root, className) {
1082
+ if (!className) return '';
1083
+ const parts = className.replace(/\\\\/g, '\\').split('\\');
1084
+ if (parts.length < 3) return '';
1085
+ const vendor = parts[0].toLowerCase();
1086
+ const moduleParts = parts[1].replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '').split('-');
1087
+ const candidates = [
1088
+ path.join(root, 'vendor', vendor, parts.slice(1).join('/').replace(/\\/g, '/') + '.php'),
1089
+ path.join(root, 'vendor', vendor, moduleParts.join('-'), parts.slice(2).join('/') + '.php'),
1090
+ path.join(root, 'app/code', parts.join('/') + '.php'),
1091
+ ];
1092
+ for (const candidate of candidates) {
1093
+ if (existsSync(candidate)) return candidate;
1094
+ }
1095
+ // Glob fallback — search for the class filename
1096
+ const fileName = parts[parts.length - 1] + '.php';
1097
+ try {
1098
+ const matches = glob.sync(`**/${fileName}`, { cwd: root, absolute: true, nodir: true, ignore: ['**/Test/**', '**/test/**'] });
1099
+ for (const m of matches) {
1100
+ const content = readFileSync(m, 'utf-8').slice(0, 500);
1101
+ if (content.includes(parts[parts.length - 1])) return m;
1102
+ }
1103
+ } catch {}
1104
+ return '';
1105
+ }
1106
+
1107
+ /**
1108
+ * Trace the shipping rate modification chain:
1109
+ * carriers → plugins on collectRates → ShippingRateModifier pool → totals collector
1110
+ */
1111
+ async function traceShippingChain(carrierOrMethod) {
1112
+ const root = config.magentoRoot;
1113
+ const chain = { carriers: [], collectRatesPlugins: [], rateModifiers: [], totalsCollectors: [], fieldsets: [] };
1114
+
1115
+ // 1. Find carrier classes
1116
+ const carrierQuery = carrierOrMethod
1117
+ ? `carrier shipping ${carrierOrMethod} collectRates`
1118
+ : 'carrier shipping collectRates AbstractCarrier';
1119
+ const carrierRaw = await safeSearch(carrierQuery, 30);
1120
+ const carriers = carrierRaw.map(normalizeResult).filter(r =>
1121
+ r.path?.includes('/Carrier/') || r.path?.includes('/Model/Carrier')
1122
+ );
1123
+ chain.carriers = carriers.slice(0, 10).map(r => ({
1124
+ path: r.path, className: r.className || null, methods: r.methods || []
1125
+ }));
1126
+
1127
+ // 2. Find plugins on AbstractCarrierInterface::collectRates
1128
+ const diFiles = await glob('**/etc/**/di.xml', { cwd: root, absolute: true, nodir: true });
1129
+ for (const diFile of diFiles) {
1130
+ let content;
1131
+ try { content = readFileSync(diFile, 'utf-8'); } catch { continue; }
1132
+ const carrierTargets = ['AbstractCarrier', 'CarrierInterface', 'AbstractCarrierInterface', 'AbstractCarrierOnline'];
1133
+ for (const target of carrierTargets) {
1134
+ if (!content.includes(target)) continue;
1135
+ const typeBlockRegex = /<type\s+name="([^"]*)"[^>]*>([\s\S]*?)<\/type>/g;
1136
+ let tm;
1137
+ while ((tm = typeBlockRegex.exec(content)) !== null) {
1138
+ if (!tm[1].includes(target)) continue;
1139
+ const block = tm[2];
1140
+ const pluginRegex = /<plugin\s+([^/>]*)\/?>/g;
1141
+ let pm;
1142
+ while ((pm = pluginRegex.exec(block)) !== null) {
1143
+ const attrs = {};
1144
+ const attrRe = /(\w+)="([^"]*)"/g;
1145
+ let am;
1146
+ while ((am = attrRe.exec(pm[1])) !== null) attrs[am[1]] = am[2];
1147
+ let area = 'global';
1148
+ const relPath = diFile.replace(root + '/', '');
1149
+ if (relPath.includes('/etc/adminhtml/')) area = 'adminhtml';
1150
+ else if (relPath.includes('/etc/frontend/')) area = 'frontend';
1151
+ const pluginFile = attrs.type ? findClassFile(root, attrs.type) : '';
1152
+ const pluginMethods = pluginFile ? extractPluginMethods(pluginFile) : [];
1153
+ chain.collectRatesPlugins.push({
1154
+ target: tm[1], pluginName: attrs.name || '', pluginClass: attrs.type || '',
1155
+ disabled: attrs.disabled === 'true', sortOrder: attrs.sortOrder || null,
1156
+ area, file: relPath, methods: pluginMethods
1157
+ });
1158
+ }
1159
+ }
1160
+ }
1161
+ }
1162
+
1163
+ // 3. Find ShippingRateModifier implementations
1164
+ const modifierRaw = await safeSearch('ShippingRateModifier shipping rate modifier', 20);
1165
+ const modifiers = modifierRaw.map(normalizeResult).filter(r =>
1166
+ r.path?.includes('Modifier') || r.path?.includes('modifier') ||
1167
+ r.className?.includes('Modifier')
1168
+ );
1169
+ chain.rateModifiers = modifiers.slice(0, 10).map(r => ({
1170
+ path: r.path, className: r.className || null, methods: r.methods || []
1171
+ }));
1172
+
1173
+ // 4. Find shipping totals collectors
1174
+ const totalsRaw = await safeSearch('shipping total collector quote address', 20);
1175
+ const totals = totalsRaw.map(normalizeResult).filter(r =>
1176
+ r.path?.includes('/Total/') || r.path?.includes('Shipping') ||
1177
+ r.className?.includes('Shipping')
1178
+ );
1179
+ chain.totalsCollectors = totals.slice(0, 5).map(r => ({
1180
+ path: r.path, className: r.className || null, methods: r.methods || []
1181
+ }));
1182
+
1183
+ // 5. Find relevant fieldsets
1184
+ const fieldsets = await parseFieldsetXml('sales_convert', null);
1185
+ chain.fieldsets = fieldsets;
1186
+
1187
+ return chain;
1188
+ }
1189
+
968
1190
  function rerank(results, boosts = {}, weight = 0.3) {
969
1191
  if (!boosts || Object.keys(boosts).length === 0) return results;
970
1192
 
@@ -1027,6 +1249,9 @@ async function traceRoute(entryPoint, depth) {
1027
1249
  const best = ranked[0];
1028
1250
  if (best) {
1029
1251
  trace.controller = { path: best.path, className: best.className || null, methods: best.methods || [] };
1252
+ // Add code snippet for the controller's execute() method
1253
+ const execSnippet = readMethodSnippet(best.path, 'execute');
1254
+ if (execSnippet) trace.controller.codeSnippet = execSnippet;
1030
1255
  }
1031
1256
 
1032
1257
  const routeConfigs = routeRaw.map(normalizeResult).filter(r => r.path?.includes('routes.xml'));
@@ -1039,7 +1264,13 @@ async function traceRoute(entryPoint, depth) {
1039
1264
  const pluginRaw = await safeSearch(`plugin interceptor ${best.className}`, 20);
1040
1265
  const plugins = pluginRaw.map(normalizeResult).filter(r => r.isPlugin || r.path?.includes('/Plugin/') || r.path?.includes('di.xml'));
1041
1266
  if (plugins.length > 0) {
1042
- trace.plugins = plugins.slice(0, 10).map(r => ({ path: r.path, className: r.className || null, methods: r.methods || [] }));
1267
+ trace.plugins = plugins.slice(0, 10).map(r => {
1268
+ const entry = { path: r.path, className: r.className || null, methods: r.methods || [] };
1269
+ if (r.path && !r.path.endsWith('.xml')) {
1270
+ entry.pluginMethods = extractPluginMethods(r.path);
1271
+ }
1272
+ return entry;
1273
+ });
1043
1274
  }
1044
1275
  }
1045
1276
 
@@ -1075,6 +1306,13 @@ async function traceRoute(entryPoint, depth) {
1075
1306
  if (templates.length > 0) {
1076
1307
  trace.templates = templates.slice(0, 10).map(r => ({ path: r.path }));
1077
1308
  }
1309
+
1310
+ // Fieldset discovery — look for fieldsets related to the route domain
1311
+ const domain = parts[0]; // e.g., "sales", "checkout", "catalog"
1312
+ const fieldsets = await parseFieldsetXml(domain, null);
1313
+ if (fieldsets.length > 0) {
1314
+ trace.fieldsets = fieldsets;
1315
+ }
1078
1316
  }
1079
1317
 
1080
1318
  return trace;
@@ -1103,6 +1341,12 @@ async function traceApi(entryPoint, depth) {
1103
1341
  const svcs = svcRaw.map(normalizeResult).filter(r => r.className?.includes(serviceShortName));
1104
1342
  if (svcs.length > 0) {
1105
1343
  trace.serviceClass = { path: svcs[0].path, className: svcs[0].className || serviceClassName, methods: svcs[0].methods || [] };
1344
+ // Try to add code snippet for the main service method
1345
+ const methodMatch = (webapis[0]?.searchText || '').match(/method="([^"]+)"/);
1346
+ if (methodMatch) {
1347
+ const svcSnippet = readMethodSnippet(svcs[0].path, methodMatch[1]);
1348
+ if (svcSnippet) trace.serviceClass.codeSnippet = svcSnippet;
1349
+ }
1106
1350
  }
1107
1351
  }
1108
1352
 
@@ -1168,7 +1412,15 @@ async function traceEvent(entryPoint, depth) {
1168
1412
  const obsRaw = await safeSearch(`event ${entryPoint} observer`, 30);
1169
1413
  const observers = obsRaw.map(normalizeResult).filter(r => r.isObserver || r.path?.includes('/Observer/') || r.path?.includes('events.xml'));
1170
1414
  if (observers.length > 0) {
1171
- trace.observers = observers.slice(0, 15).map(r => ({ eventName: entryPoint, path: r.path, className: r.className || null }));
1415
+ trace.observers = observers.slice(0, 15).map(r => {
1416
+ const entry = { eventName: entryPoint, path: r.path, className: r.className || null };
1417
+ // Add execute() snippet for observer PHP classes
1418
+ if (r.path && r.path.endsWith('.php')) {
1419
+ const snippet = readMethodSnippet(r.path, 'execute');
1420
+ if (snippet) entry.codeSnippet = snippet;
1421
+ }
1422
+ return entry;
1423
+ });
1172
1424
  }
1173
1425
 
1174
1426
  if (depth === 'deep') {
@@ -1180,6 +1432,13 @@ async function traceEvent(entryPoint, depth) {
1180
1432
  if (origins.length > 0) {
1181
1433
  trace.origin = { path: origins[0].path, className: origins[0].className || null, methods: origins[0].methods || [] };
1182
1434
  }
1435
+
1436
+ // Fieldset discovery — look for fieldsets related to the event domain
1437
+ const domain = entryPoint.split('_').slice(0, 2).join('_'); // e.g., "sales_convert", "sales_order"
1438
+ const fieldsets = await parseFieldsetXml(domain, null);
1439
+ if (fieldsets.length > 0) {
1440
+ trace.fieldsets = fieldsets;
1441
+ }
1183
1442
  }
1184
1443
 
1185
1444
  return trace;
@@ -1202,6 +1461,8 @@ async function traceCron(entryPoint, depth) {
1202
1461
  const handlers = handlerRaw.map(normalizeResult).filter(r => r.path?.includes('/Cron/'));
1203
1462
  if (handlers.length > 0) {
1204
1463
  trace.handler = { path: handlers[0].path, className: handlers[0].className || null, methods: handlers[0].methods || [] };
1464
+ const cronSnippet = readMethodSnippet(handlers[0].path, 'execute');
1465
+ if (cronSnippet) trace.handler.codeSnippet = cronSnippet;
1205
1466
  }
1206
1467
 
1207
1468
  if (depth === 'deep' && handlers[0]?.className) {
@@ -1307,6 +1568,32 @@ function formatSearchResults(results) {
1307
1568
  : r.searchText;
1308
1569
  }
1309
1570
 
1571
+ // Code preview — read actual source lines for PHP files with known class/method
1572
+ if (r.path && (r.path.endsWith('.php') || r.path.endsWith('.phtml'))) {
1573
+ if (r.methodName) {
1574
+ const preview = readMethodSnippet(r.path, r.methodName, 10);
1575
+ if (preview) entry.codePreview = preview;
1576
+ } else if (r.className) {
1577
+ // Show class declaration + first few lines
1578
+ const classShort = r.className.split('\\').pop();
1579
+ const preview = readMethodSnippet(r.path, classShort ? `class ${classShort}` : null, 10);
1580
+ if (!preview) {
1581
+ // Fallback: read file header (namespace + class line)
1582
+ const absP = r.path.startsWith('/') ? r.path : path.join(config.magentoRoot, r.path);
1583
+ try {
1584
+ const content = readFileSync(absP, 'utf-8');
1585
+ const lines = content.split('\n');
1586
+ const classLine = lines.findIndex(l => /^\s*(abstract\s+|final\s+)?class\s+/.test(l));
1587
+ if (classLine >= 0) {
1588
+ entry.codePreview = lines.slice(Math.max(0, classLine - 2), classLine + 8).join('\n');
1589
+ }
1590
+ } catch {}
1591
+ } else {
1592
+ entry.codePreview = preview;
1593
+ }
1594
+ }
1595
+ }
1596
+
1310
1597
  return entry;
1311
1598
  });
1312
1599
 
@@ -1889,6 +2176,7 @@ async function analyzeImpact(className) {
1889
2176
  diXmlReferences: [],
1890
2177
  instantiations: [],
1891
2178
  typeHints: [],
2179
+ runtimeCallers: [],
1892
2180
  total: 0
1893
2181
  };
1894
2182
 
@@ -1907,6 +2195,7 @@ async function analyzeImpact(className) {
1907
2195
  ];
1908
2196
 
1909
2197
  // Check PHP files for direct references
2198
+ const escapedShort = shortName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1910
2199
  for (const r of relatedPaths.slice(0, 40)) {
1911
2200
  const absPath = path.join(root, r.path);
1912
2201
  if (!existsSync(absPath) || !r.path.endsWith('.php')) continue;
@@ -1921,13 +2210,38 @@ async function analyzeImpact(className) {
1921
2210
  references.instantiations.push({ file: r.path, className: r.className });
1922
2211
  }
1923
2212
  if (content.includes(`@var ${shortName}`) || content.includes(`@param ${shortName}`) ||
1924
- content.match(new RegExp(`:\\s*${shortName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`))) {
2213
+ content.match(new RegExp(`:\\s*${escapedShort}\\b`))) {
1925
2214
  references.typeHints.push({ file: r.path, className: r.className });
1926
2215
  }
2216
+
2217
+ // Runtime callers: find $this->property->method() where property is typed as this class
2218
+ if (hasUse) {
2219
+ const ctorMatch = content.match(/function\s+__construct\s*\(([\s\S]*?)\)\s*[{:]/);
2220
+ if (ctorMatch) {
2221
+ // Find constructor params typed as the target class
2222
+ const paramRegex = new RegExp(`(?:${escapedShort}|${className.replace(/\\/g, '\\\\')})\\s+\\$(\\w+)`, 'g');
2223
+ let pm;
2224
+ while ((pm = paramRegex.exec(ctorMatch[1])) !== null) {
2225
+ const propName = pm[1];
2226
+ // Find all calls to this property in the file
2227
+ const callRegex = new RegExp(`\\$this->${propName}->(\\w+)\\s*\\(`, 'g');
2228
+ let cm;
2229
+ while ((cm = callRegex.exec(content)) !== null) {
2230
+ references.runtimeCallers.push({
2231
+ file: r.path,
2232
+ callerClass: r.className,
2233
+ property: propName,
2234
+ calledMethod: cm[1]
2235
+ });
2236
+ }
2237
+ }
2238
+ }
2239
+ }
1927
2240
  }
1928
2241
 
1929
2242
  references.total = references.useStatements.length + references.diXmlReferences.length +
1930
- references.instantiations.length + references.typeHints.length;
2243
+ references.instantiations.length + references.typeHints.length +
2244
+ references.runtimeCallers.length;
1931
2245
 
1932
2246
  return references;
1933
2247
  }
@@ -2617,6 +2931,25 @@ async function traceCallChain(startClass, startMethod, maxDepth = 3) {
2617
2931
  };
2618
2932
 
2619
2933
  const classFileMap = new Map();
2934
+ const parentClassCache = new Map();
2935
+
2936
+ // Resolve parent class from extends declaration in PHP file content
2937
+ function resolveParentFromContent(content) {
2938
+ const extendsMatch = content.match(/class\s+\w+\s+extends\s+([\w\\]+)/);
2939
+ if (!extendsMatch) return null;
2940
+ const parent = extendsMatch[1];
2941
+ // If it's a short name, resolve using use statements
2942
+ if (!parent.includes('\\')) {
2943
+ const useMatch = content.match(new RegExp(`use\\s+([\\w\\\\]+\\\\${parent})\\s*;`));
2944
+ if (useMatch) return useMatch[1];
2945
+ // Check namespace-relative
2946
+ const nsMatch = content.match(/namespace\s+([\w\\]+)/);
2947
+ if (nsMatch) return `${nsMatch[1]}\\${parent}`;
2948
+ return parent;
2949
+ }
2950
+ // Leading backslash = fully qualified
2951
+ return parent.replace(/^\\/, '');
2952
+ }
2620
2953
 
2621
2954
  async function resolveClassFile(className) {
2622
2955
  const shortName = className.split('\\').pop();
@@ -2722,14 +3055,46 @@ async function traceCallChain(startClass, startMethod, maxDepth = 3) {
2722
3055
  const relativePath = filePath.replace(root + '/', '');
2723
3056
 
2724
3057
  // Extract method body (brace counting)
3058
+ const escapedMethod = methodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2725
3059
  const methodRegex = new RegExp(
2726
- `function\\s+${methodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\([^)]*\\)[^{]*\\{`
3060
+ `function\\s+${escapedMethod}\\s*\\([^)]*\\)[^{]*\\{`
2727
3061
  );
2728
- const methodStart = content.search(methodRegex);
3062
+ let methodStart = content.search(methodRegex);
3063
+
3064
+ // If method not found in this class, walk up the inheritance chain
3065
+ let resolvedClass = className;
3066
+ let resolvedContent = content;
3067
+ let resolvedFilePath = filePath;
2729
3068
  if (methodStart === -1) {
2730
- result.chain.push({ depth, class: className, method: methodName, file: relativePath, status: 'method_not_found' });
2731
- return;
3069
+ let currentContent = content;
3070
+ let found = false;
3071
+ const visited = new Set([className]);
3072
+ for (let i = 0; i < 10; i++) { // max 10 parent levels
3073
+ const parentFqcn = resolveParentFromContent(currentContent);
3074
+ if (!parentFqcn || visited.has(parentFqcn)) break;
3075
+ visited.add(parentFqcn);
3076
+ const parentFile = await resolveClassFile(parentFqcn);
3077
+ if (!parentFile) break;
3078
+ let parentContent;
3079
+ try { parentContent = readFileSync(parentFile, 'utf-8'); } catch { break; }
3080
+ const parentMethodStart = parentContent.search(methodRegex);
3081
+ if (parentMethodStart !== -1) {
3082
+ resolvedClass = parentFqcn;
3083
+ resolvedContent = parentContent;
3084
+ resolvedFilePath = parentFile;
3085
+ methodStart = parentMethodStart;
3086
+ found = true;
3087
+ break;
3088
+ }
3089
+ currentContent = parentContent;
3090
+ }
3091
+ if (!found) {
3092
+ result.chain.push({ depth, class: className, method: methodName, file: relativePath, status: 'method_not_found' });
3093
+ return;
3094
+ }
2732
3095
  }
3096
+ content = resolvedContent;
3097
+ const resolvedRelPath = resolvedFilePath.replace(root + '/', '');
2733
3098
 
2734
3099
  let braceCount = 0;
2735
3100
  let bodyStart = content.indexOf('{', methodStart);
@@ -2741,8 +3106,11 @@ async function traceCallChain(startClass, startMethod, maxDepth = 3) {
2741
3106
  }
2742
3107
  const methodBody = content.slice(bodyStart, bodyEnd + 1);
2743
3108
 
3109
+ // Show where method was actually found if inherited
3110
+ const inheritedFrom = (resolvedClass !== className) ? resolvedClass : null;
2744
3111
  const chainEntry = {
2745
3112
  depth, class: className, method: methodName, file: relativePath,
3113
+ ...(inheritedFrom ? { inheritedFrom, resolvedFile: resolvedRelPath } : {}),
2746
3114
  calls: [], dispatches: []
2747
3115
  };
2748
3116
 
@@ -2762,13 +3130,20 @@ async function traceCallChain(startClass, startMethod, maxDepth = 3) {
2762
3130
  while ((dc = depCallRegex.exec(methodBody)) !== null) {
2763
3131
  const property = dc[1];
2764
3132
  const calledMethod = dc[2];
2765
- // Resolve property type from constructor
2766
- const ctorMatch = content.match(/function\s+__construct\s*\(([\s\S]*?)\)\s*[{:]/);
3133
+ // Resolve property type from constructor — check the original class first, then the resolved (parent) class
2767
3134
  let resolvedType = null;
2768
- if (ctorMatch) {
2769
- const paramRegex = new RegExp(`([\\w\\\\]+)\\s+\\$${property}\\b`);
2770
- const pm = ctorMatch[1].match(paramRegex);
2771
- if (pm) resolvedType = pm[1];
3135
+ let originalContent = content;
3136
+ if (resolvedClass !== className) {
3137
+ try { originalContent = readFileSync(filePath, 'utf-8'); } catch { originalContent = content; }
3138
+ }
3139
+ const contentSources = (resolvedClass !== className) ? [originalContent, content] : [content];
3140
+ for (const src of contentSources) {
3141
+ const ctorMatch = src.match(/function\s+__construct\s*\(([\s\S]*?)\)\s*[{:]/);
3142
+ if (ctorMatch) {
3143
+ const paramRegex = new RegExp(`([\\w\\\\]+)\\s+\\$${property}\\b`);
3144
+ const pm = ctorMatch[1].match(paramRegex);
3145
+ if (pm) { resolvedType = pm[1]; break; }
3146
+ }
2772
3147
  }
2773
3148
  chainEntry.calls.push({ type: 'dependency', property, method: calledMethod, typeHint: resolvedType || null });
2774
3149
  }
@@ -2857,6 +3232,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2857
3232
  description: 'Enable query expansion with Magento domain synonyms for better recall (default: true)',
2858
3233
  default: true
2859
3234
  },
3235
+ precise: {
3236
+ type: 'boolean',
3237
+ description: 'Precise mode for debugging: disables query expansion AND applies strict post-filtering — only returns results where the file content contains at least one query keyword. Use for specific debugging queries like "gift card subtotal infinite loop". Default: false.',
3238
+ default: false
3239
+ },
2860
3240
  },
2861
3241
  required: ['query']
2862
3242
  }
@@ -3199,6 +3579,36 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
3199
3579
  }
3200
3580
  }
3201
3581
  },
3582
+ {
3583
+ name: 'magento_find_fieldset',
3584
+ description: 'Find fieldset.xml definitions that control how data is copied between Magento entities (e.g., order→quote, quote→order). Shows which fields are copied for each aspect (to_order, to_edit, to_quote). Essential for understanding data conversion flows like reorder, order edit, and checkout.',
3585
+ inputSchema: {
3586
+ type: 'object',
3587
+ properties: {
3588
+ fieldset: {
3589
+ type: 'string',
3590
+ description: 'Fieldset name or partial match. Examples: "sales_copy_order", "sales_convert_quote", "sales_convert_order", "customer_account"'
3591
+ },
3592
+ aspect: {
3593
+ type: 'string',
3594
+ description: 'Aspect name filter. Examples: "to_order", "to_edit", "to_quote", "to_customer"'
3595
+ }
3596
+ }
3597
+ }
3598
+ },
3599
+ {
3600
+ name: 'magento_trace_shipping_chain',
3601
+ description: 'Trace the complete shipping rate calculation chain: carrier classes → plugins on collectRates() → ShippingRateModifier pool → totals collectors → fieldset copy mappings. Use this to understand how shipping prices are calculated, modified, and propagated during checkout, reorder, or order edit.',
3602
+ inputSchema: {
3603
+ type: 'object',
3604
+ properties: {
3605
+ carrier: {
3606
+ type: 'string',
3607
+ description: 'Optional carrier or shipping method to focus on. Examples: "flatrate", "freeshipping", "tablerate", "innoship", "home_delivery", "pickup"'
3608
+ }
3609
+ }
3610
+ }
3611
+ },
3202
3612
  {
3203
3613
  name: 'magento_trace_flow',
3204
3614
  description: 'Trace Magento execution flow from an entry point (route, API endpoint, GraphQL mutation, event, or cron job). Chains multiple searches to map controller → plugins → observers → templates for a given request path. Use this to understand how a request is processed end-to-end.',
@@ -3442,6 +3852,29 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
3442
3852
  required: ['eventName']
3443
3853
  }
3444
3854
  },
3855
+ {
3856
+ name: 'magento_batch',
3857
+ description: 'Execute multiple Magector tool calls in a single request to reduce MCP round-trip overhead. Each query runs in parallel and returns combined results. Use this when you need 2+ independent lookups (e.g., find a class AND its plugins AND its observers in one call instead of three).',
3858
+ inputSchema: {
3859
+ type: 'object',
3860
+ properties: {
3861
+ queries: {
3862
+ type: 'array',
3863
+ description: 'Array of tool calls to execute. Each entry has a "tool" name and "args" object.',
3864
+ items: {
3865
+ type: 'object',
3866
+ properties: {
3867
+ tool: { type: 'string', description: 'Magector tool name (e.g., "magento_find_class", "magento_find_plugin")' },
3868
+ args: { type: 'object', description: 'Arguments for the tool call' }
3869
+ },
3870
+ required: ['tool', 'args']
3871
+ },
3872
+ maxItems: 10
3873
+ }
3874
+ },
3875
+ required: ['queries']
3876
+ }
3877
+ },
3445
3878
  ]
3446
3879
  }));
3447
3880
 
@@ -3488,12 +3921,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3488
3921
  try {
3489
3922
  switch (name) {
3490
3923
  case 'magento_search': {
3491
- const searchQuery = args.expand !== false ? expandQuery(args.query) : args.query;
3492
- const raw = await rustSearchAsync(searchQuery, Math.max(args.limit || 10, 30));
3924
+ const precise = args.precise === true;
3925
+ const searchQuery = (args.expand !== false && !precise) ? expandQuery(args.query) : args.query;
3926
+ const raw = await rustSearchAsync(searchQuery, Math.max(args.limit || 10, precise ? 60 : 30));
3493
3927
  const arr = Array.isArray(raw) ? raw : [];
3494
3928
  let results = arr.map(normalizeResult);
3495
3929
  // Hybrid BM25 rerank for better exact-match handling
3496
3930
  results = hybridRerank(results, args.query);
3931
+ // Precise mode: strict post-filter — result must contain at least one significant query keyword
3932
+ if (precise) {
3933
+ const queryTerms = args.query.toLowerCase().split(/\s+/).filter(t => t.length > 2);
3934
+ results = results.filter(r => {
3935
+ const haystack = [r.searchText, r.className, r.methodName, r.path, ...(r.methods || [])]
3936
+ .filter(Boolean).join(' ').toLowerCase();
3937
+ return queryTerms.some(term => haystack.includes(term));
3938
+ });
3939
+ }
3497
3940
  // Apply module filter if specified
3498
3941
  if (args.moduleFilter) {
3499
3942
  results = filterByModule(results, args.moduleFilter);
@@ -3696,6 +4139,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3696
4139
  }
3697
4140
  }
3698
4141
 
4142
+ // Resolve plugin methods for DI registrations
4143
+ const fpRoot = args.targetClass ? config.magentoRoot : null;
4144
+ for (const reg of diRegistrations) {
4145
+ if (reg.pluginClass && fpRoot) {
4146
+ const pluginFile = findClassFile(fpRoot, reg.pluginClass);
4147
+ if (pluginFile) {
4148
+ reg.methods = extractPluginMethods(pluginFile);
4149
+ reg.resolvedFile = pluginFile.replace(fpRoot + '/', '');
4150
+ }
4151
+ }
4152
+ }
4153
+
3699
4154
  let text = formatSearchResults(enrichedResults);
3700
4155
  if (diRegistrations.length > 0) {
3701
4156
  text += `\n\n### DI Plugin Registrations for ${args.targetClass} (${diRegistrations.length})\n`;
@@ -3703,6 +4158,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3703
4158
  const disabledTag = reg.disabled ? ' **[DISABLED]**' : '';
3704
4159
  const sortTag = reg.sortOrder ? ` (sortOrder: ${reg.sortOrder})` : '';
3705
4160
  text += `- **${reg.pluginName}** → \`${reg.pluginClass}\` [${reg.area}]${sortTag}${disabledTag} (${reg.file})\n`;
4161
+ if (reg.resolvedFile) {
4162
+ text += ` PHP: \`${reg.resolvedFile}\`\n`;
4163
+ }
4164
+ if (reg.methods?.length > 0) {
4165
+ for (const m of reg.methods) {
4166
+ text += ` - \`${m.type}\` **${m.targetMethod}** → \`${m.name}()\`\n`;
4167
+ }
4168
+ }
3706
4169
  }
3707
4170
  }
3708
4171
 
@@ -4222,6 +4685,93 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4222
4685
  }
4223
4686
  }
4224
4687
 
4688
+ case 'magento_find_fieldset': {
4689
+ const fieldsets = await parseFieldsetXml(args.fieldset || null, args.aspect || null);
4690
+ if (fieldsets.length === 0) {
4691
+ return { content: [{ type: 'text', text: 'No fieldset.xml definitions found matching the criteria.' }] };
4692
+ }
4693
+ let text = `## Fieldset Definitions (${fieldsets.length} matches)\n\n`;
4694
+ for (const fs of fieldsets) {
4695
+ text += `### ${fs.scope ? `${fs.scope} / ` : ''}${fs.fieldset}\n`;
4696
+ text += `File: \`${fs.file}\`\n`;
4697
+ // Group fields by aspect
4698
+ const byAspect = {};
4699
+ for (const f of fs.fields) {
4700
+ if (!byAspect[f.aspect]) byAspect[f.aspect] = [];
4701
+ byAspect[f.aspect].push(f.field);
4702
+ }
4703
+ for (const [aspect, fields] of Object.entries(byAspect)) {
4704
+ text += `- **${aspect}**: ${fields.map(f => `\`${f}\``).join(', ')}\n`;
4705
+ }
4706
+ text += '\n';
4707
+ }
4708
+ return { content: [{ type: 'text', text }] };
4709
+ }
4710
+
4711
+ case 'magento_trace_shipping_chain': {
4712
+ const chain = await traceShippingChain(args.carrier || null);
4713
+ let text = `## Shipping Rate Chain\n\n`;
4714
+
4715
+ if (chain.carriers.length > 0) {
4716
+ text += `### Carriers (${chain.carriers.length})\n`;
4717
+ for (const c of chain.carriers) {
4718
+ text += `- \`${c.className || c.path}\` (${c.path})\n`;
4719
+ if (c.methods?.length > 0) text += ` Methods: ${c.methods.join(', ')}\n`;
4720
+ }
4721
+ text += '\n';
4722
+ }
4723
+
4724
+ if (chain.collectRatesPlugins.length > 0) {
4725
+ text += `### Plugins on collectRates (${chain.collectRatesPlugins.length})\n`;
4726
+ for (const p of chain.collectRatesPlugins) {
4727
+ const disabledTag = p.disabled ? ' **[DISABLED]**' : '';
4728
+ const sortTag = p.sortOrder ? ` (sortOrder: ${p.sortOrder})` : '';
4729
+ text += `- **${p.pluginName}** → \`${p.pluginClass}\` [${p.area}]${sortTag}${disabledTag}\n`;
4730
+ text += ` Target: \`${p.target}\` | DI: \`${p.file}\`\n`;
4731
+ if (p.methods?.length > 0) {
4732
+ for (const m of p.methods) {
4733
+ text += ` - \`${m.type}\` **${m.targetMethod}** → \`${m.name}()\`\n`;
4734
+ }
4735
+ }
4736
+ }
4737
+ text += '\n';
4738
+ }
4739
+
4740
+ if (chain.rateModifiers.length > 0) {
4741
+ text += `### Shipping Rate Modifiers (${chain.rateModifiers.length})\n`;
4742
+ for (const m of chain.rateModifiers) {
4743
+ text += `- \`${m.className || m.path}\` (${m.path})\n`;
4744
+ }
4745
+ text += '\n';
4746
+ }
4747
+
4748
+ if (chain.totalsCollectors.length > 0) {
4749
+ text += `### Totals Collectors (${chain.totalsCollectors.length})\n`;
4750
+ for (const t of chain.totalsCollectors) {
4751
+ text += `- \`${t.className || t.path}\` (${t.path})\n`;
4752
+ }
4753
+ text += '\n';
4754
+ }
4755
+
4756
+ if (chain.fieldsets.length > 0) {
4757
+ text += `### Related Fieldsets (${chain.fieldsets.length})\n`;
4758
+ for (const fs of chain.fieldsets) {
4759
+ const byAspect = {};
4760
+ for (const f of fs.fields) {
4761
+ if (!byAspect[f.aspect]) byAspect[f.aspect] = [];
4762
+ byAspect[f.aspect].push(f.field);
4763
+ }
4764
+ text += `- **${fs.scope || fs.fieldset}** (${fs.file})\n`;
4765
+ for (const [aspect, fields] of Object.entries(byAspect)) {
4766
+ text += ` - ${aspect}: ${fields.map(f => `\`${f}\``).join(', ')}\n`;
4767
+ }
4768
+ }
4769
+ text += '\n';
4770
+ }
4771
+
4772
+ return { content: [{ type: 'text', text }] };
4773
+ }
4774
+
4225
4775
  case 'magento_trace_flow': {
4226
4776
  const entryPoint = args.entryPoint;
4227
4777
  const entryType = args.entryType || 'auto';
@@ -4426,6 +4976,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4426
4976
  text += '\n';
4427
4977
  }
4428
4978
 
4979
+ if (impact.runtimeCallers.length > 0) {
4980
+ text += `### Runtime Callers (${impact.runtimeCallers.length})\n`;
4981
+ text += '_Classes that inject this class and call its methods at runtime:_\n';
4982
+ // Group by caller class for readability
4983
+ const grouped = new Map();
4984
+ for (const c of impact.runtimeCallers) {
4985
+ const key = c.callerClass || c.file;
4986
+ if (!grouped.has(key)) grouped.set(key, { file: c.file, methods: new Set() });
4987
+ grouped.get(key).methods.add(`$this->${c.property}->${c.calledMethod}()`);
4988
+ }
4989
+ for (const [cls, info] of grouped) {
4990
+ text += `- **\`${cls}\`** (\`${info.file}\`)\n`;
4991
+ for (const m of info.methods) {
4992
+ text += ` - \`${m}\`\n`;
4993
+ }
4994
+ }
4995
+ text += '\n';
4996
+ }
4997
+
4429
4998
  if (impact.total === 0) {
4430
4999
  text += '_No references found. The class may be referenced under a different name or alias._\n';
4431
5000
  }
@@ -4633,6 +5202,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4633
5202
  const status = entry.status ? ` [${entry.status}]` : '';
4634
5203
  text += `${indent}- **${entry.class}::${entry.method}**${status}`;
4635
5204
  if (entry.file) text += ` (${entry.file})`;
5205
+ if (entry.inheritedFrom) text += `\n${indent} _inherited from_ \`${entry.inheritedFrom}\` (${entry.resolvedFile})`;
4636
5206
  text += '\n';
4637
5207
 
4638
5208
  if (entry.calls) {
@@ -4746,6 +5316,100 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4746
5316
  return { content: [{ type: 'text', text }] };
4747
5317
  }
4748
5318
 
5319
+ case 'magento_batch': {
5320
+ const queries = args.queries || [];
5321
+ if (queries.length === 0) {
5322
+ return { content: [{ type: 'text', text: 'No queries provided.' }] };
5323
+ }
5324
+ if (queries.length > 10) {
5325
+ return { content: [{ type: 'text', text: 'Maximum 10 queries per batch.' }], isError: true };
5326
+ }
5327
+ // Run batch queries in parallel using existing standalone functions
5328
+ const batchResults = await Promise.all(queries.map(async (q, idx) => {
5329
+ try {
5330
+ const a = q.args || {};
5331
+ let text = '';
5332
+ switch (q.tool) {
5333
+ case 'magento_find_class': {
5334
+ const ns = a.namespace || '';
5335
+ const qr = `${a.className} ${ns}`.trim();
5336
+ const raw = await rustSearchAsync(qr, 30);
5337
+ const cl = a.className.toLowerCase();
5338
+ const res = raw.map(normalizeResult).filter(r =>
5339
+ r.className?.toLowerCase().includes(cl) || r.path?.toLowerCase().includes(cl.replace(/\\/g, '/'))
5340
+ );
5341
+ text = formatSearchResults(res.slice(0, 5));
5342
+ break;
5343
+ }
5344
+ case 'magento_find_plugin': {
5345
+ const qr = `plugin interceptor ${a.targetClass || ''} ${a.targetMethod || ''}`.trim();
5346
+ const raw = await rustSearchAsync(qr, 30);
5347
+ const res = raw.map(normalizeResult).filter(r =>
5348
+ r.magentoType === 'plugin' || r.path?.toLowerCase().includes('plugin')
5349
+ );
5350
+ text = formatSearchResults(res.slice(0, 10));
5351
+ break;
5352
+ }
5353
+ case 'magento_find_observer': {
5354
+ const flow = await traceEventFlow(a.eventName);
5355
+ text = `Observers: ${flow.observers.length}\n`;
5356
+ for (const o of flow.observers.slice(0, 10)) {
5357
+ text += `- ${o.name}: ${o.instance}::${o.method} (${o.file})\n`;
5358
+ }
5359
+ break;
5360
+ }
5361
+ case 'magento_trace_dependency': {
5362
+ const dep = await traceDependency(a.className, a.direction || 'both');
5363
+ text = `Preferences: ${dep.preferences.length}, Plugins: ${dep.plugins.length}, VirtualTypes: ${dep.virtualTypes.length}, Args: ${dep.argumentOverrides.length}\n`;
5364
+ for (const p of dep.plugins.slice(0, 5)) text += `- plugin: ${p.pluginName} → ${p.pluginClass}\n`;
5365
+ for (const p of dep.preferences.slice(0, 5)) text += `- pref: ${p.for} → ${p.type}\n`;
5366
+ break;
5367
+ }
5368
+ case 'magento_impact_analysis': {
5369
+ const imp = await analyzeImpact(a.className);
5370
+ text = `Total: ${imp.total} (use: ${imp.useStatements.length}, di: ${imp.diXmlReferences.length}, new: ${imp.instantiations.length}, type: ${imp.typeHints.length}, runtime: ${imp.runtimeCallers.length})\n`;
5371
+ for (const c of imp.runtimeCallers.slice(0, 5)) text += `- ${c.callerClass}: $this->${c.property}->${c.calledMethod}()\n`;
5372
+ break;
5373
+ }
5374
+ case 'magento_search': {
5375
+ const precise = a.precise === true;
5376
+ const sq = (a.expand !== false && !precise) ? expandQuery(a.query) : a.query;
5377
+ const raw = await rustSearchAsync(sq, 30);
5378
+ let res = raw.map(normalizeResult);
5379
+ res = hybridRerank(res, a.query);
5380
+ text = formatSearchResults(res.slice(0, a.limit || 5));
5381
+ break;
5382
+ }
5383
+ case 'magento_find_callers': {
5384
+ const callers = await findCallers(a.methodName, a.className);
5385
+ text = `Callers: ${callers.callers?.length || 0}\n`;
5386
+ for (const c of (callers.callers || []).slice(0, 10)) {
5387
+ text += `- ${c.class}::${c.method} (${c.path}:${c.line})\n`;
5388
+ }
5389
+ break;
5390
+ }
5391
+ case 'magento_find_event_flow': {
5392
+ const flow = await traceEventFlow(a.eventName);
5393
+ text = `Dispatchers: ${flow.dispatchers.length}, Observers: ${flow.observers.length}\n`;
5394
+ for (const o of flow.observers.slice(0, 10)) text += `- ${o.name}: ${o.instance} (${o.file})\n`;
5395
+ break;
5396
+ }
5397
+ default:
5398
+ text = `Unsupported batch tool: ${q.tool}`;
5399
+ }
5400
+ return { idx, tool: q.tool, text };
5401
+ } catch (err) {
5402
+ return { idx, tool: q.tool, text: `Error: ${err.message}` };
5403
+ }
5404
+ }));
5405
+
5406
+ let text = `## Batch Results (${batchResults.length} queries)\n\n`;
5407
+ for (const br of batchResults) {
5408
+ text += `---\n### [${br.idx + 1}] ${br.tool}\n\n${br.text}\n\n`;
5409
+ }
5410
+ return { content: [{ type: 'text', text }] };
5411
+ }
5412
+
4749
5413
  default:
4750
5414
  return {
4751
5415
  content: [{