magector 2.2.4 → 2.3.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.
Files changed (2) hide show
  1. package/package.json +6 -12
  2. package/src/mcp-server.js +1196 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magector",
3
- "version": "2.2.4",
3
+ "version": "2.3.0",
4
4
  "description": "Semantic code search for Magento 2 — index, search, MCP server",
5
5
  "type": "module",
6
6
  "main": "src/mcp-server.js",
@@ -23,13 +23,7 @@
23
23
  "test": "node tests/unit.test.js && node tests/mcp-server.test.js",
24
24
  "test:unit": "node tests/unit.test.js",
25
25
  "test:integration": "node tests/mcp-server.test.js",
26
- "test:no-index": "node tests/mcp-server.test.js --no-index",
27
- "test:accuracy": "node tests/mcp-accuracy.test.js",
28
- "test:accuracy:verbose": "node tests/mcp-accuracy.test.js --verbose",
29
- "test:sona-eval": "node tests/mcp-sona-eval.test.js",
30
- "test:sona-eval:verbose": "node tests/mcp-sona-eval.test.js --verbose",
31
- "test:describe-eval": "node tests/describe-benefit-eval.test.js",
32
- "test:describe-eval:verbose": "node tests/describe-benefit-eval.test.js --verbose"
26
+ "test:no-index": "node tests/mcp-server.test.js --no-index"
33
27
  },
34
28
  "dependencies": {
35
29
  "@modelcontextprotocol/sdk": "^1.0.0",
@@ -39,10 +33,10 @@
39
33
  "ruvector": "^0.1.96"
40
34
  },
41
35
  "optionalDependencies": {
42
- "@magector/cli-darwin-arm64": "2.2.4",
43
- "@magector/cli-linux-x64": "2.2.4",
44
- "@magector/cli-linux-arm64": "2.2.4",
45
- "@magector/cli-win32-x64": "2.2.4"
36
+ "@magector/cli-darwin-arm64": "2.3.0",
37
+ "@magector/cli-linux-x64": "2.3.0",
38
+ "@magector/cli-linux-arm64": "2.3.0",
39
+ "@magector/cli-win32-x64": "2.3.0"
46
40
  },
47
41
  "keywords": [
48
42
  "magento",
package/src/mcp-server.js CHANGED
@@ -1773,6 +1773,11 @@ function expandQuery(query) {
1773
1773
 
1774
1774
  // ─── Module Filtering ───────────────────────────────────────────
1775
1775
  // Filter search results by vendor/module namespace pattern.
1776
+ // Module values in the index use composer format: "vendor_package"
1777
+ // (e.g. "some-vendor_module-name"). Users may write patterns with
1778
+ // "/" or "_" as separator, and may use a vendor prefix that matches
1779
+ // only the start of the full vendor name (e.g. "acme/*" matching
1780
+ // "acme-extensions_module-foo").
1776
1781
 
1777
1782
  function filterByModule(results, moduleFilter) {
1778
1783
  if (!moduleFilter) return results;
@@ -1782,11 +1787,43 @@ function filterByModule(results, moduleFilter) {
1782
1787
  const filePath = r.path || '';
1783
1788
  return patterns.some(pat => {
1784
1789
  if (pat.includes('*')) {
1785
- const regex = new RegExp('^' + pat.replace(/\*/g, '.*') + '$', 'i');
1786
- return regex.test(mod) || regex.test(filePath);
1790
+ // 1. Exact regex match treat / and _ as interchangeable separators
1791
+ const normalized = pat.replace(/[/_]/g, '[/_]');
1792
+ const regex = new RegExp('^' + normalized.replace(/\*/g, '.*') + '$', 'i');
1793
+ if (regex.test(mod) || regex.test(filePath)) return true;
1794
+
1795
+ // 2. Vendor prefix match: "vendor/*" should also match "vendor-extended_*"
1796
+ // Extract the vendor part (everything before the first separator or wildcard)
1797
+ const vendorPrefix = pat.split(/[/*_]/)[0];
1798
+ if (vendorPrefix) {
1799
+ const pfx = vendorPrefix.toLowerCase();
1800
+ // Module field starts with the vendor prefix
1801
+ if (mod.toLowerCase().startsWith(pfx)) return true;
1802
+ // File path contains vendor/<prefix> (covers vendor/ directory)
1803
+ if (filePath.toLowerCase().includes('vendor/' + pfx) ||
1804
+ filePath.toLowerCase().includes('app/code/' + pfx)) return true;
1805
+ }
1806
+ return false;
1807
+ }
1808
+ // Non-wildcard: substring match on module and path, with separator normalization
1809
+ const patLower = pat.toLowerCase();
1810
+ if (mod.toLowerCase().includes(patLower) || filePath.toLowerCase().includes(patLower)) return true;
1811
+ // Structured match: split "Vendor_Module" into parts and match against
1812
+ // index format "vendor_module-name" (composer packages use "module-" prefix)
1813
+ const patParts = patLower.split(/[/_]/);
1814
+ if (patParts.length >= 2) {
1815
+ const modLower = mod.toLowerCase();
1816
+ const modParts = modLower.split(/[/_]/);
1817
+ if (modParts.length >= 2) {
1818
+ const vendorMatch = modParts[0].startsWith(patParts[0]) || patParts[0].startsWith(modParts[0]);
1819
+ const modulePart = modParts.slice(1).join('-');
1820
+ const patModule = patParts.slice(1).join('-');
1821
+ // "module-catalog" contains "catalog", or "catalog" matches after stripping "module-"
1822
+ const moduleMatch = modulePart.includes(patModule) || modulePart.replace(/^module-/, '').includes(patModule);
1823
+ if (vendorMatch && moduleMatch) return true;
1824
+ }
1787
1825
  }
1788
- return mod.toLowerCase().includes(pat.toLowerCase()) ||
1789
- filePath.toLowerCase().includes(pat.toLowerCase());
1826
+ return false;
1790
1827
  });
1791
1828
  });
1792
1829
  }
@@ -2037,6 +2074,743 @@ async function findTests(className, methodName) {
2037
2074
  return result;
2038
2075
  }
2039
2076
 
2077
+ // ─── Find Implementors ──────────────────────────────────────────
2078
+ // Find all classes implementing a given interface: PHP `implements` + DI preferences
2079
+
2080
+ async function findImplementors(interfaceName) {
2081
+ const root = config.magentoRoot;
2082
+ const shortName = interfaceName.split('\\').pop();
2083
+
2084
+ const result = {
2085
+ interface: interfaceName,
2086
+ implementors: [],
2087
+ diPreferences: []
2088
+ };
2089
+
2090
+ // 1. Search DI preferences for this interface
2091
+ const diFiles = await glob('**/etc/**/di.xml', { cwd: root, absolute: true, nodir: true });
2092
+ for (const diFile of diFiles) {
2093
+ let content;
2094
+ try { content = readFileSync(diFile, 'utf-8'); } catch { continue; }
2095
+ const relativePath = diFile.replace(root + '/', '');
2096
+
2097
+ const prefRegex = /<preference\s+for="([^"]+)"\s+type="([^"]+)"\s*\/?>/g;
2098
+ let m;
2099
+ while ((m = prefRegex.exec(content)) !== null) {
2100
+ const forClass = m[1];
2101
+ if (forClass === interfaceName || forClass.endsWith('\\' + shortName) ||
2102
+ forClass.toLowerCase() === interfaceName.toLowerCase()) {
2103
+ result.diPreferences.push({ for: forClass, type: m[2], file: relativePath });
2104
+ }
2105
+ }
2106
+ }
2107
+
2108
+ // 2. Grep PHP files for `implements ...InterfaceName`
2109
+ const phpFiles = await glob('**/*.php', {
2110
+ cwd: root, absolute: true, nodir: true,
2111
+ ignore: ['**/test/**', '**/tests/**', '**/Test/**', '**/Tests/**']
2112
+ });
2113
+
2114
+ const implementsRegex = new RegExp(
2115
+ `implements\\s+[^{]*\\b${shortName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i'
2116
+ );
2117
+
2118
+ for (const phpFile of phpFiles) {
2119
+ let content;
2120
+ try { content = readFileSync(phpFile, 'utf-8'); } catch { continue; }
2121
+ if (!implementsRegex.test(content)) continue;
2122
+
2123
+ const relativePath = phpFile.replace(root + '/', '');
2124
+ const classMatch = content.match(/(?:class|abstract\s+class)\s+(\w+)/);
2125
+ const nsMatch = content.match(/namespace\s+([\w\\]+)/);
2126
+ const className = classMatch ? classMatch[1] : path.basename(phpFile, '.php');
2127
+ const fqcn = nsMatch ? `${nsMatch[1]}\\${className}` : className;
2128
+
2129
+ // Verify it references the right interface (not just a name collision)
2130
+ const useStatements = content.match(/use\s+([\w\\]+);/g) || [];
2131
+ const usesFullInterface = useStatements.some(u =>
2132
+ u.includes(interfaceName) || u.endsWith(shortName + ';')
2133
+ );
2134
+
2135
+ if (usesFullInterface || content.includes(interfaceName)) {
2136
+ result.implementors.push({ class: fqcn, file: relativePath });
2137
+ } else {
2138
+ result.implementors.push({ class: fqcn, file: relativePath, matchType: 'shortName' });
2139
+ }
2140
+ }
2141
+
2142
+ return result;
2143
+ }
2144
+
2145
+ // ─── Find Callers ───────────────────────────────────────────────
2146
+ // Find all call sites of a method across PHP and XML files
2147
+
2148
+ async function findCallers(methodName, className) {
2149
+ const root = config.magentoRoot;
2150
+ const shortClass = className ? className.split('\\').pop() : null;
2151
+
2152
+ const result = {
2153
+ method: methodName,
2154
+ className: className || null,
2155
+ phpCallers: [],
2156
+ xmlReferences: [],
2157
+ totalScanned: 0
2158
+ };
2159
+
2160
+ // 1. PHP call sites: ->methodName( and ::methodName(
2161
+ const phpFiles = await glob('**/*.php', { cwd: root, absolute: true, nodir: true });
2162
+ result.totalScanned = phpFiles.length;
2163
+
2164
+ const methodRegex = new RegExp(
2165
+ `(?:->|::)${methodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\(`, 'g'
2166
+ );
2167
+
2168
+ for (const phpFile of phpFiles) {
2169
+ let content;
2170
+ try { content = readFileSync(phpFile, 'utf-8'); } catch { continue; }
2171
+ if (!methodRegex.test(content)) continue;
2172
+ methodRegex.lastIndex = 0;
2173
+
2174
+ const relativePath = phpFile.replace(root + '/', '');
2175
+ const lines = content.split('\n');
2176
+ const callSites = [];
2177
+
2178
+ for (let i = 0; i < lines.length; i++) {
2179
+ if (methodRegex.test(lines[i])) {
2180
+ if (shortClass) {
2181
+ const usesClass = content.includes(shortClass);
2182
+ if (!usesClass) continue;
2183
+ }
2184
+ callSites.push({ line: i + 1, snippet: lines[i].trim().slice(0, 150) });
2185
+ }
2186
+ methodRegex.lastIndex = 0;
2187
+ }
2188
+
2189
+ if (callSites.length > 0) {
2190
+ const nsMatch = content.match(/namespace\s+([\w\\]+)/);
2191
+ const classMatch = content.match(/(?:class|trait)\s+(\w+)/);
2192
+ const callerClass = nsMatch && classMatch ? `${nsMatch[1]}\\${classMatch[1]}` : null;
2193
+
2194
+ result.phpCallers.push({
2195
+ file: relativePath, callerClass,
2196
+ calls: callSites.slice(0, 5)
2197
+ });
2198
+ }
2199
+ }
2200
+
2201
+ // 2. XML references
2202
+ const xmlFiles = await glob('**/etc/**/*.xml', { cwd: root, absolute: true, nodir: true });
2203
+ for (const xmlFile of xmlFiles) {
2204
+ let content;
2205
+ try { content = readFileSync(xmlFile, 'utf-8'); } catch { continue; }
2206
+ if (!content.includes(methodName)) continue;
2207
+
2208
+ const relativePath = xmlFile.replace(root + '/', '');
2209
+ const lines = content.split('\n');
2210
+ const refs = [];
2211
+ for (let i = 0; i < lines.length; i++) {
2212
+ if (lines[i].includes(methodName)) {
2213
+ refs.push({ line: i + 1, snippet: lines[i].trim().slice(0, 150) });
2214
+ }
2215
+ }
2216
+ if (refs.length > 0) {
2217
+ result.xmlReferences.push({ file: relativePath, refs: refs.slice(0, 5) });
2218
+ }
2219
+ }
2220
+
2221
+ // Sort: files referencing the class get priority
2222
+ if (shortClass) {
2223
+ result.phpCallers.sort((a, b) => {
2224
+ const aHas = a.callerClass?.includes(shortClass) ? 0 : 1;
2225
+ const bHas = b.callerClass?.includes(shortClass) ? 0 : 1;
2226
+ return aHas - bHas;
2227
+ });
2228
+ }
2229
+
2230
+ return result;
2231
+ }
2232
+
2233
+ // ─── Find DI Wiring ─────────────────────────────────────────────
2234
+ // Complete DI picture: preferences, plugins, constructor args, virtual types
2235
+
2236
+ async function findDiWiring(className) {
2237
+ const root = config.magentoRoot;
2238
+ const shortName = className.split('\\').pop();
2239
+ const shortLower = shortName.toLowerCase();
2240
+
2241
+ const result = {
2242
+ className,
2243
+ preferences: [],
2244
+ plugins: [],
2245
+ constructorArguments: [],
2246
+ virtualTypes: [],
2247
+ typeArguments: [],
2248
+ totalDiFiles: 0
2249
+ };
2250
+
2251
+ const diFiles = await glob('**/etc/**/di.xml', { cwd: root, absolute: true, nodir: true });
2252
+ result.totalDiFiles = diFiles.length;
2253
+
2254
+ for (const diFile of diFiles) {
2255
+ let content;
2256
+ try { content = readFileSync(diFile, 'utf-8'); } catch { continue; }
2257
+ const relativePath = diFile.replace(root + '/', '');
2258
+ const contentLower = content.toLowerCase();
2259
+
2260
+ if (!contentLower.includes(shortLower)) continue;
2261
+
2262
+ // 1. Preferences where this class is the "for" or "type"
2263
+ const prefRegex = /<preference\s+for="([^"]+)"\s+type="([^"]+)"\s*\/?>/g;
2264
+ let m;
2265
+ while ((m = prefRegex.exec(content)) !== null) {
2266
+ const forLower = m[1].toLowerCase();
2267
+ const typeLower = m[2].toLowerCase();
2268
+ if (forLower.includes(shortLower) || typeLower.includes(shortLower)) {
2269
+ result.preferences.push({ for: m[1], type: m[2], file: relativePath });
2270
+ }
2271
+ }
2272
+
2273
+ // 2. Plugins and type arguments
2274
+ const typeBlockRegex = /<type\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/type>/g;
2275
+ let typeMatch;
2276
+ while ((typeMatch = typeBlockRegex.exec(content)) !== null) {
2277
+ const typeName = typeMatch[1];
2278
+ const typeNameLower = typeName.toLowerCase();
2279
+ const typeBlock = typeMatch[2];
2280
+
2281
+ if (!typeNameLower.includes(shortLower)) continue;
2282
+
2283
+ // Plugins
2284
+ const pluginRegex = /<plugin\s+name="([^"]+)"[^>]*type="([^"]+)"[^>]*/g;
2285
+ let pMatch;
2286
+ while ((pMatch = pluginRegex.exec(typeBlock)) !== null) {
2287
+ result.plugins.push({
2288
+ target: typeName, pluginName: pMatch[1],
2289
+ pluginClass: pMatch[2], file: relativePath
2290
+ });
2291
+ }
2292
+
2293
+ // Simple arguments
2294
+ const argSimpleRegex = /<argument\s+name="([^"]+)"[^>]*xsi:type="([^"]+)"[^>]*>([^<]*)<\/argument>/g;
2295
+ let aMatch;
2296
+ while ((aMatch = argSimpleRegex.exec(typeBlock)) !== null) {
2297
+ result.typeArguments.push({
2298
+ target: typeName, name: aMatch[1],
2299
+ type: aMatch[2], value: aMatch[3].trim().slice(0, 200), file: relativePath
2300
+ });
2301
+ }
2302
+
2303
+ // Array arguments
2304
+ const argArrayRegex = /<argument\s+name="([^"]+)"[^>]*xsi:type="array"[^>]*>([\s\S]*?)<\/argument>/g;
2305
+ let arrMatch;
2306
+ while ((arrMatch = argArrayRegex.exec(typeBlock)) !== null) {
2307
+ const argName = arrMatch[1];
2308
+ const argBlock = arrMatch[2];
2309
+ const itemRegex = /<item\s+name="([^"]+)"[^>]*xsi:type="([^"]+)"[^>]*>([^<]*)<\/item>/g;
2310
+ let iMatch;
2311
+ const items = [];
2312
+ while ((iMatch = itemRegex.exec(argBlock)) !== null) {
2313
+ items.push({ name: iMatch[1], type: iMatch[2], value: iMatch[3].trim().slice(0, 200) });
2314
+ }
2315
+ if (items.length > 0) {
2316
+ result.typeArguments.push({
2317
+ target: typeName, name: argName,
2318
+ type: 'array', items, file: relativePath
2319
+ });
2320
+ }
2321
+ }
2322
+ }
2323
+
2324
+ // 3. Virtual types extending this class
2325
+ const vtRegex = /<virtualType\s+name="([^"]+)"[^>]*type="([^"]+)"[^>]*(?:\/>|>([\s\S]*?)<\/virtualType>)/g;
2326
+ while ((m = vtRegex.exec(content)) !== null) {
2327
+ const vtType = m[2];
2328
+ if (!vtType.toLowerCase().includes(shortLower)) continue;
2329
+ const vtEntry = { name: m[1], type: vtType, file: relativePath };
2330
+ if (m[3]) {
2331
+ const argRegex = /<argument\s+name="([^"]+)"[^>]*xsi:type="([^"]+)"[^>]*>([^<]*)<\/argument>/g;
2332
+ const vtArgs = [];
2333
+ let vam;
2334
+ while ((vam = argRegex.exec(m[3])) !== null) {
2335
+ vtArgs.push({ name: vam[1], type: vam[2], value: vam[3].trim().slice(0, 200) });
2336
+ }
2337
+ if (vtArgs.length > 0) vtEntry.arguments = vtArgs;
2338
+ }
2339
+ result.virtualTypes.push(vtEntry);
2340
+ }
2341
+ }
2342
+
2343
+ // 4. Constructor arguments from PHP class
2344
+ const phpFiles = await glob(`**/${shortName}.php`, { cwd: root, absolute: true, nodir: true });
2345
+ for (const phpFile of phpFiles) {
2346
+ let content;
2347
+ try { content = readFileSync(phpFile, 'utf-8'); } catch { continue; }
2348
+ const classMatch = content.match(/(?:class|abstract\s+class)\s+(\w+)/);
2349
+ if (!classMatch || classMatch[1] !== shortName) continue;
2350
+
2351
+ const ctorMatch = content.match(/function\s+__construct\s*\(([\s\S]*?)\)\s*[{:]/);
2352
+ if (ctorMatch) {
2353
+ const paramRegex = /(?:([\w\\]+)\s+)?(\$\w+)/g;
2354
+ let pm;
2355
+ while ((pm = paramRegex.exec(ctorMatch[1])) !== null) {
2356
+ result.constructorArguments.push({ typeHint: pm[1] || null, variable: pm[2] });
2357
+ }
2358
+ }
2359
+ break;
2360
+ }
2361
+
2362
+ return result;
2363
+ }
2364
+
2365
+ // ─── Trace Data Flow ───────────────────────────────────────────
2366
+ // Find all setters and getters for a data attribute across the codebase
2367
+
2368
+ async function traceDataFlow(attributeKey, modelClass) {
2369
+ const root = config.magentoRoot;
2370
+
2371
+ // Convert snake_case to PascalCase for magic method names
2372
+ const pascal = attributeKey.split('_').map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('');
2373
+ const setterMethod = `set${pascal}`;
2374
+ const getterMethod = `get${pascal}`;
2375
+
2376
+ const result = {
2377
+ attributeKey,
2378
+ setterMethod,
2379
+ getterMethod,
2380
+ setters: [],
2381
+ getters: [],
2382
+ xmlReferences: []
2383
+ };
2384
+
2385
+ // 1. Search PHP files for setters/getters
2386
+ const phpFiles = await glob('**/*.php', {
2387
+ cwd: root, absolute: true, nodir: true,
2388
+ ignore: ['**/test/**', '**/tests/**', '**/Test/**', '**/Tests/**']
2389
+ });
2390
+
2391
+ const setterRegex = new RegExp(
2392
+ `(?:->|::)${setterMethod}\\s*\\(|` +
2393
+ `setData\\s*\\(\\s*['"]${attributeKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]`
2394
+ );
2395
+ const getterRegex = new RegExp(
2396
+ `(?:->|::)${getterMethod}\\s*\\(|` +
2397
+ `getData\\s*\\(\\s*['"]${attributeKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]`
2398
+ );
2399
+ // Also match addData calls that could set this attribute
2400
+ const addDataRegex = new RegExp(
2401
+ `addData\\s*\\(.*${attributeKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`
2402
+ );
2403
+ // Match constant definitions for this key
2404
+ const constRegex = new RegExp(
2405
+ `(?:const\\s+\\w+\\s*=\\s*['"]${attributeKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"])`
2406
+ );
2407
+
2408
+ const shortModel = modelClass ? modelClass.split('\\').pop() : null;
2409
+
2410
+ for (const phpFile of phpFiles) {
2411
+ let content;
2412
+ try { content = readFileSync(phpFile, 'utf-8'); } catch { continue; }
2413
+
2414
+ // Quick pre-check
2415
+ if (!content.includes(setterMethod) && !content.includes(getterMethod) &&
2416
+ !content.includes(attributeKey)) continue;
2417
+
2418
+ // If modelClass specified, prioritize files that reference it
2419
+ const refsModel = !shortModel || content.includes(shortModel);
2420
+
2421
+ const relativePath = phpFile.replace(root + '/', '');
2422
+ const classMatch = content.match(/(?:class|abstract\s+class|trait)\s+(\w+)/);
2423
+ const nsMatch = content.match(/namespace\s+([\w\\]+)/);
2424
+ const className = classMatch ? classMatch[1] : path.basename(phpFile, '.php');
2425
+ const fqcn = nsMatch ? `${nsMatch[1]}\\${className}` : className;
2426
+ const lines = content.split('\n');
2427
+
2428
+ // Determine area from path
2429
+ const area = relativePath.includes('/frontend/') ? 'frontend'
2430
+ : relativePath.includes('/adminhtml/') ? 'adminhtml'
2431
+ : relativePath.includes('/graphql/') ? 'graphql' : 'global';
2432
+
2433
+ // Find setter lines
2434
+ for (let i = 0; i < lines.length; i++) {
2435
+ const line = lines[i];
2436
+ if (setterRegex.test(line) || (addDataRegex.test(line) && line.includes(attributeKey))) {
2437
+ // Find enclosing method
2438
+ let methodName = null;
2439
+ for (let j = i; j >= Math.max(0, i - 30); j--) {
2440
+ const mMatch = lines[j].match(/(?:public|protected|private|static)\s+function\s+(\w+)/);
2441
+ if (mMatch) { methodName = mMatch[1]; break; }
2442
+ }
2443
+ result.setters.push({
2444
+ path: relativePath,
2445
+ class: fqcn,
2446
+ method: methodName,
2447
+ line: i + 1,
2448
+ snippet: line.trim().slice(0, 200),
2449
+ referencesModel: refsModel,
2450
+ area
2451
+ });
2452
+ break; // one entry per file for setters
2453
+ }
2454
+ }
2455
+
2456
+ // Find getter lines
2457
+ for (let i = 0; i < lines.length; i++) {
2458
+ const line = lines[i];
2459
+ if (getterRegex.test(line)) {
2460
+ let methodName = null;
2461
+ for (let j = i; j >= Math.max(0, i - 30); j--) {
2462
+ const mMatch = lines[j].match(/(?:public|protected|private|static)\s+function\s+(\w+)/);
2463
+ if (mMatch) { methodName = mMatch[1]; break; }
2464
+ }
2465
+ result.getters.push({
2466
+ path: relativePath,
2467
+ class: fqcn,
2468
+ method: methodName,
2469
+ line: i + 1,
2470
+ snippet: line.trim().slice(0, 200),
2471
+ referencesModel: refsModel,
2472
+ area
2473
+ });
2474
+ break; // one entry per file for getters
2475
+ }
2476
+ }
2477
+
2478
+ // Find constant definitions
2479
+ if (constRegex.test(content)) {
2480
+ const constLine = lines.findIndex(l => constRegex.test(l));
2481
+ if (constLine >= 0) {
2482
+ result.setters.push({
2483
+ path: relativePath,
2484
+ class: fqcn,
2485
+ method: null,
2486
+ line: constLine + 1,
2487
+ snippet: lines[constLine].trim().slice(0, 200),
2488
+ type: 'constant',
2489
+ area
2490
+ });
2491
+ }
2492
+ }
2493
+ }
2494
+
2495
+ // 2. Search XML files (sales.xml, extension_attributes.xml) for attribute references
2496
+ const xmlFiles = await glob('**/etc/**/*.xml', { cwd: root, absolute: true, nodir: true });
2497
+ const escapedKey = attributeKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2498
+ const xmlRegex = new RegExp(escapedKey);
2499
+
2500
+ for (const xmlFile of xmlFiles) {
2501
+ let content;
2502
+ try { content = readFileSync(xmlFile, 'utf-8'); } catch { continue; }
2503
+ if (!xmlRegex.test(content)) continue;
2504
+
2505
+ const relativePath = xmlFile.replace(root + '/', '');
2506
+ const lines = content.split('\n');
2507
+ for (let i = 0; i < lines.length; i++) {
2508
+ if (xmlRegex.test(lines[i])) {
2509
+ result.xmlReferences.push({
2510
+ path: relativePath,
2511
+ line: i + 1,
2512
+ snippet: lines[i].trim().slice(0, 200)
2513
+ });
2514
+ break;
2515
+ }
2516
+ }
2517
+ }
2518
+
2519
+ // Sort: files referencing the model get priority
2520
+ if (shortModel) {
2521
+ result.setters.sort((a, b) => (b.referencesModel ? 1 : 0) - (a.referencesModel ? 1 : 0));
2522
+ result.getters.sort((a, b) => (b.referencesModel ? 1 : 0) - (a.referencesModel ? 1 : 0));
2523
+ }
2524
+
2525
+ return result;
2526
+ }
2527
+
2528
+ // ─── Find Event Dispatchers ────────────────────────────────────
2529
+ // Find all PHP locations where a specific Magento event is dispatched
2530
+
2531
+ async function findEventDispatchers(eventName) {
2532
+ const root = config.magentoRoot;
2533
+
2534
+ const result = {
2535
+ eventName,
2536
+ dispatchers: [],
2537
+ observerCount: 0
2538
+ };
2539
+
2540
+ const escaped = eventName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2541
+ // Match eventManager->dispatch('event_name' and similar patterns
2542
+ const dispatchRegex = new RegExp(
2543
+ `dispatch\\s*\\(\\s*['"]${escaped}['"]`
2544
+ );
2545
+
2546
+ // 1. Grep PHP files for exact dispatch calls
2547
+ const phpFiles = await glob('**/*.php', {
2548
+ cwd: root, absolute: true, nodir: true,
2549
+ ignore: ['**/test/**', '**/tests/**', '**/Test/**', '**/Tests/**']
2550
+ });
2551
+
2552
+ for (const phpFile of phpFiles) {
2553
+ let content;
2554
+ try { content = readFileSync(phpFile, 'utf-8'); } catch { continue; }
2555
+ if (!content.includes(eventName)) continue;
2556
+
2557
+ const relativePath = phpFile.replace(root + '/', '');
2558
+ const lines = content.split('\n');
2559
+ const classMatch = content.match(/(?:class|abstract\s+class|trait)\s+(\w+)/);
2560
+ const nsMatch = content.match(/namespace\s+([\w\\]+)/);
2561
+ const className = classMatch ? classMatch[1] : path.basename(phpFile, '.php');
2562
+ const fqcn = nsMatch ? `${nsMatch[1]}\\${className}` : className;
2563
+
2564
+ for (let i = 0; i < lines.length; i++) {
2565
+ if (dispatchRegex.test(lines[i])) {
2566
+ // Find enclosing method
2567
+ let methodName = null;
2568
+ for (let j = i; j >= Math.max(0, i - 30); j--) {
2569
+ const mMatch = lines[j].match(/(?:public|protected|private|static)\s+function\s+(\w+)/);
2570
+ if (mMatch) { methodName = mMatch[1]; break; }
2571
+ }
2572
+
2573
+ // Get surrounding context (2 lines before and after)
2574
+ const ctxStart = Math.max(0, i - 2);
2575
+ const ctxEnd = Math.min(lines.length - 1, i + 2);
2576
+ const context = lines.slice(ctxStart, ctxEnd + 1).map(l => l.trimEnd()).join('\n');
2577
+
2578
+ result.dispatchers.push({
2579
+ path: relativePath,
2580
+ class: fqcn,
2581
+ method: methodName,
2582
+ line: i + 1,
2583
+ snippet: lines[i].trim().slice(0, 200),
2584
+ context: context.slice(0, 500)
2585
+ });
2586
+ }
2587
+ }
2588
+ }
2589
+
2590
+ // 2. Count registered observers for context
2591
+ const eventsFiles = await glob('**/etc/**/events.xml', { cwd: root, absolute: true, nodir: true });
2592
+ for (const file of eventsFiles) {
2593
+ let content;
2594
+ try { content = readFileSync(file, 'utf-8'); } catch { continue; }
2595
+ if (!content.includes(eventName)) continue;
2596
+ const obsMatches = content.match(/<observer\s+/g);
2597
+ if (obsMatches) result.observerCount += obsMatches.length;
2598
+ }
2599
+
2600
+ return result;
2601
+ }
2602
+
2603
+ // ─── Trace Call Chain ───────────────────────────────────────────
2604
+ // Trace internal method execution: method calls, dispatched events, observers
2605
+
2606
+ async function traceCallChain(startClass, startMethod, maxDepth = 3) {
2607
+ const root = config.magentoRoot;
2608
+
2609
+ const result = {
2610
+ entryPoint: `${startClass}::${startMethod}`,
2611
+ chain: [],
2612
+ events: [],
2613
+ observers: [],
2614
+ depth: 0
2615
+ };
2616
+
2617
+ const classFileMap = new Map();
2618
+
2619
+ async function resolveClassFile(className) {
2620
+ const shortName = className.split('\\').pop();
2621
+ if (classFileMap.has(className)) return classFileMap.get(className);
2622
+
2623
+ // Try common Magento path patterns
2624
+ const nsPath = className.replace(/\\/g, '/') + '.php';
2625
+ const candidates = [
2626
+ `app/code/${nsPath}`,
2627
+ `vendor/${nsPath}`
2628
+ ];
2629
+
2630
+ for (const c of candidates) {
2631
+ const full = path.join(root, c);
2632
+ if (existsSync(full)) {
2633
+ classFileMap.set(className, full);
2634
+ return full;
2635
+ }
2636
+ }
2637
+
2638
+ // Fallback: glob for the class file
2639
+ const matches = await glob(`**/${shortName}.php`, {
2640
+ cwd: root, absolute: true, nodir: true,
2641
+ ignore: ['**/test/**', '**/tests/**', '**/Test/**', '**/Tests/**']
2642
+ });
2643
+
2644
+ for (const match of matches) {
2645
+ let content;
2646
+ try { content = readFileSync(match, 'utf-8'); } catch { continue; }
2647
+ const nsMatch = content.match(/namespace\s+([\w\\]+)/);
2648
+ const classMatch = content.match(/(?:class|abstract\s+class|trait)\s+(\w+)/);
2649
+ if (classMatch && classMatch[1] === shortName) {
2650
+ const fqcn = nsMatch ? `${nsMatch[1]}\\${classMatch[1]}` : classMatch[1];
2651
+ classFileMap.set(fqcn, match);
2652
+ if (fqcn === className || classMatch[1] === shortName) {
2653
+ classFileMap.set(className, match);
2654
+ return match;
2655
+ }
2656
+ }
2657
+ }
2658
+
2659
+ classFileMap.set(className, null);
2660
+ return null;
2661
+ }
2662
+
2663
+ // Load events.xml index
2664
+ const eventObserverMap = new Map();
2665
+ const eventFiles = await glob('**/etc/**/events.xml', { cwd: root, absolute: true, nodir: true });
2666
+ for (const evFile of eventFiles) {
2667
+ let content;
2668
+ try { content = readFileSync(evFile, 'utf-8'); } catch { continue; }
2669
+ const relativePath = evFile.replace(root + '/', '');
2670
+
2671
+ const eventRegex = /<event\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/event>/g;
2672
+ let em;
2673
+ while ((em = eventRegex.exec(content)) !== null) {
2674
+ const eventName = em[1];
2675
+ const eventBlock = em[2];
2676
+ const obsRegex = /<observer\s+[^>]*name="([^"]+)"[^>]*instance="([^"]+)"[^>]*/g;
2677
+ let om;
2678
+ while ((om = obsRegex.exec(eventBlock)) !== null) {
2679
+ if (!eventObserverMap.has(eventName)) eventObserverMap.set(eventName, []);
2680
+ eventObserverMap.get(eventName).push({
2681
+ name: om[1], class: om[2], file: relativePath
2682
+ });
2683
+ }
2684
+ }
2685
+ }
2686
+
2687
+ // Resolve DI preference for an interface
2688
+ const prefCache = new Map();
2689
+ async function resolvePreference(interfaceName) {
2690
+ if (prefCache.has(interfaceName)) return prefCache.get(interfaceName);
2691
+ const shortName = interfaceName.split('\\').pop();
2692
+ const diXmlFiles = await glob('**/etc/di.xml', { cwd: root, absolute: true, nodir: true });
2693
+ for (const diFile of diXmlFiles) {
2694
+ let content;
2695
+ try { content = readFileSync(diFile, 'utf-8'); } catch { continue; }
2696
+ const prefRegex = /<preference\s+for="([^"]+)"\s+type="([^"]+)"\s*\/?>/g;
2697
+ let m;
2698
+ while ((m = prefRegex.exec(content)) !== null) {
2699
+ if (m[1] === interfaceName || m[1].endsWith('\\' + shortName)) {
2700
+ prefCache.set(interfaceName, m[2]);
2701
+ return m[2];
2702
+ }
2703
+ }
2704
+ }
2705
+ prefCache.set(interfaceName, null);
2706
+ return null;
2707
+ }
2708
+
2709
+ async function traceMethod(className, methodName, depth) {
2710
+ if (depth > maxDepth) return;
2711
+
2712
+ const filePath = await resolveClassFile(className);
2713
+ if (!filePath) {
2714
+ result.chain.push({ depth, class: className, method: methodName, status: 'unresolved' });
2715
+ return;
2716
+ }
2717
+
2718
+ let content;
2719
+ try { content = readFileSync(filePath, 'utf-8'); } catch { return; }
2720
+ const relativePath = filePath.replace(root + '/', '');
2721
+
2722
+ // Extract method body (brace counting)
2723
+ const methodRegex = new RegExp(
2724
+ `function\\s+${methodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\([^)]*\\)[^{]*\\{`
2725
+ );
2726
+ const methodStart = content.search(methodRegex);
2727
+ if (methodStart === -1) {
2728
+ result.chain.push({ depth, class: className, method: methodName, file: relativePath, status: 'method_not_found' });
2729
+ return;
2730
+ }
2731
+
2732
+ let braceCount = 0;
2733
+ let bodyStart = content.indexOf('{', methodStart);
2734
+ let bodyEnd = bodyStart;
2735
+ for (let i = bodyStart; i < content.length; i++) {
2736
+ if (content[i] === '{') braceCount++;
2737
+ if (content[i] === '}') braceCount--;
2738
+ if (braceCount === 0) { bodyEnd = i; break; }
2739
+ }
2740
+ const methodBody = content.slice(bodyStart, bodyEnd + 1);
2741
+
2742
+ const chainEntry = {
2743
+ depth, class: className, method: methodName, file: relativePath,
2744
+ calls: [], dispatches: []
2745
+ };
2746
+
2747
+ // $this->method( calls
2748
+ const selfCallRegex = /\$this->(\w+)\s*\(/g;
2749
+ let sc;
2750
+ while ((sc = selfCallRegex.exec(methodBody)) !== null) {
2751
+ const calledMethod = sc[1];
2752
+ if (calledMethod !== methodName && calledMethod !== '__construct') {
2753
+ chainEntry.calls.push({ type: 'self', method: calledMethod });
2754
+ }
2755
+ }
2756
+
2757
+ // $this->dependency->method( calls
2758
+ const depCallRegex = /\$this->(\w+)->(\w+)\s*\(/g;
2759
+ let dc;
2760
+ while ((dc = depCallRegex.exec(methodBody)) !== null) {
2761
+ const property = dc[1];
2762
+ const calledMethod = dc[2];
2763
+ // Resolve property type from constructor
2764
+ const ctorMatch = content.match(/function\s+__construct\s*\(([\s\S]*?)\)\s*[{:]/);
2765
+ let resolvedType = null;
2766
+ if (ctorMatch) {
2767
+ const paramRegex = new RegExp(`([\\w\\\\]+)\\s+\\$${property}\\b`);
2768
+ const pm = ctorMatch[1].match(paramRegex);
2769
+ if (pm) resolvedType = pm[1];
2770
+ }
2771
+ chainEntry.calls.push({ type: 'dependency', property, method: calledMethod, typeHint: resolvedType || null });
2772
+ }
2773
+
2774
+ // eventManager->dispatch calls
2775
+ const dispatchRegex = /(?:eventManager|_eventManager)->dispatch\s*\(\s*['"]([^'"]+)['"]/g;
2776
+ let dm;
2777
+ while ((dm = dispatchRegex.exec(methodBody)) !== null) {
2778
+ const eventName = dm[1];
2779
+ chainEntry.dispatches.push(eventName);
2780
+ const observers = eventObserverMap.get(eventName) || [];
2781
+ result.events.push({ event: eventName, dispatchedIn: `${className}::${methodName}`, observers });
2782
+ for (const obs of observers) {
2783
+ result.observers.push({ event: eventName, ...obs });
2784
+ }
2785
+ }
2786
+
2787
+ result.chain.push(chainEntry);
2788
+ result.depth = Math.max(result.depth, depth);
2789
+
2790
+ // Recurse into $this-> calls
2791
+ for (const call of chainEntry.calls) {
2792
+ if (call.type === 'self') {
2793
+ await traceMethod(className, call.method, depth + 1);
2794
+ }
2795
+ }
2796
+
2797
+ // Recurse into dependency calls (within depth limit)
2798
+ if (depth < maxDepth - 1) {
2799
+ for (const call of chainEntry.calls) {
2800
+ if (call.type === 'dependency' && call.typeHint) {
2801
+ const impl = await resolvePreference(call.typeHint);
2802
+ const targetClass = impl || call.typeHint;
2803
+ await traceMethod(targetClass, call.method, depth + 1);
2804
+ }
2805
+ }
2806
+ }
2807
+ }
2808
+
2809
+ await traceMethod(startClass, startMethod, 0);
2810
+
2811
+ return result;
2812
+ }
2813
+
2040
2814
  // ─── MCP Server ─────────────────────────────────────────────────
2041
2815
 
2042
2816
  const server = new Server(
@@ -2070,8 +2844,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2070
2844
  default: 10
2071
2845
  },
2072
2846
  moduleFilter: {
2073
- type: 'string',
2074
- 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.'
2847
+ oneOf: [
2848
+ { type: 'string' },
2849
+ { type: 'array', items: { type: 'string' } }
2850
+ ],
2851
+ description: 'Filter results by vendor/module pattern(s). Accepts a single string or array of strings. Supports wildcards and vendor prefix matching. Uses "/" or "_" interchangeably as separator. Examples: "Vendor_*", ["drmax_paymentrestrictions", "drmax_module-free-shipping"], "Magento_Catalog".'
2075
2852
  },
2076
2853
  expand: {
2077
2854
  type: 'boolean',
@@ -2531,6 +3308,107 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2531
3308
  required: ['className']
2532
3309
  }
2533
3310
  },
3311
+ {
3312
+ name: 'magento_find_implementors',
3313
+ description: 'Find all classes that implement a given PHP interface. Scans PHP files for `implements` keyword and di.xml for `<preference>` declarations. Use this to discover all concrete implementations of an interface across the codebase.',
3314
+ inputSchema: {
3315
+ type: 'object',
3316
+ properties: {
3317
+ interfaceName: {
3318
+ type: 'string',
3319
+ description: 'Full or short PHP interface name. Examples: "OrderRepositoryInterface", "Magento\\Sales\\Api\\OrderRepositoryInterface", "ChildOrderValidatorInterface"'
3320
+ }
3321
+ },
3322
+ required: ['interfaceName']
3323
+ }
3324
+ },
3325
+ {
3326
+ name: 'magento_find_callers',
3327
+ description: 'Find all call sites of a PHP method across the codebase. Searches for ->method() and ::method() patterns in PHP files and method references in XML config files. Use this to understand where a method is used and trace data flow.',
3328
+ inputSchema: {
3329
+ type: 'object',
3330
+ properties: {
3331
+ methodName: {
3332
+ type: 'string',
3333
+ description: 'Method name to find callers for. Examples: "execute", "save", "collectTotals", "copySalesRuleIdsFromParentToDrmaxQuote"'
3334
+ },
3335
+ className: {
3336
+ type: 'string',
3337
+ description: 'Optional: class that owns the method — narrows results to files that reference this class. Examples: "SalesRuleManagement", "CartRepository"'
3338
+ }
3339
+ },
3340
+ required: ['methodName']
3341
+ }
3342
+ },
3343
+ {
3344
+ name: 'magento_find_di_wiring',
3345
+ description: 'Get the complete DI wiring picture for a PHP class: preferences (interface→implementation), plugins (interceptors), constructor arguments from di.xml, virtual types, and argument overrides. Also extracts the PHP constructor signature. Use this to understand how a class is configured and extended across all modules.',
3346
+ inputSchema: {
3347
+ type: 'object',
3348
+ properties: {
3349
+ className: {
3350
+ type: 'string',
3351
+ description: 'Full or short PHP class/interface name. Examples: "ChildOrderValidatorChain", "Magento\\SalesRule\\Model\\Rule", "CartManagementInterface"'
3352
+ }
3353
+ },
3354
+ required: ['className']
3355
+ }
3356
+ },
3357
+ {
3358
+ name: 'magento_trace_call_chain',
3359
+ description: 'Trace the internal method call chain starting from a specific class::method. Follows $this->method() calls (same class), $this->dependency->method() calls (resolves DI types), and eventManager->dispatch() calls (maps to observers from events.xml). Returns a call tree showing the execution path through the code.',
3360
+ inputSchema: {
3361
+ type: 'object',
3362
+ properties: {
3363
+ className: {
3364
+ type: 'string',
3365
+ description: 'Full PHP class name (FQCN) to start tracing from. Examples: "DrmaxMarketplace\\OrderSplit\\Model\\CreateOrder\\CreateChildOrder", "Magento\\Quote\\Model\\QuoteManagement"'
3366
+ },
3367
+ methodName: {
3368
+ type: 'string',
3369
+ description: 'Method name to start tracing. Examples: "execute", "submit", "collectTotals"'
3370
+ },
3371
+ maxDepth: {
3372
+ type: 'number',
3373
+ description: 'Maximum recursion depth for tracing (default: 3). Higher values trace deeper but take longer.',
3374
+ default: 3
3375
+ }
3376
+ },
3377
+ required: ['className', 'methodName']
3378
+ }
3379
+ },
3380
+ {
3381
+ name: 'magento_trace_data_flow',
3382
+ description: 'Trace how a data attribute flows through the Magento codebase: find all PHP files that set (via magic setter, setData, addData) and get (via magic getter, getData) a specific attribute key. Shows which classes write vs read the attribute, in which methods, and whether XML configs reference it. Use this to understand data dependencies — e.g., who sets drmax_discounted_price_incl_tax on Quote\\Address and who reads it.',
3383
+ inputSchema: {
3384
+ type: 'object',
3385
+ properties: {
3386
+ attributeKey: {
3387
+ type: 'string',
3388
+ description: 'The snake_case data attribute key to trace. Examples: "drmax_discounted_price_incl_tax", "base_grand_total", "drmax_free_shipping_price", "subtotal_with_discount"'
3389
+ },
3390
+ modelClass: {
3391
+ type: 'string',
3392
+ description: 'Optional: model class name to prioritize results that reference this class. Examples: "Quote\\Address", "Order", "Product"'
3393
+ }
3394
+ },
3395
+ required: ['attributeKey']
3396
+ }
3397
+ },
3398
+ {
3399
+ name: 'magento_find_event_dispatchers',
3400
+ description: 'Find all PHP locations where a specific Magento event is dispatched via eventManager->dispatch(). Unlike magento_find_event_flow (which shows the full chain: dispatchers+observers+handlers), this tool focuses exclusively on finding WHERE an event is triggered — with exact grep matching, method context, and surrounding code. Use this to answer "does class X dispatch event Y?" or "who triggers this event?".',
3401
+ inputSchema: {
3402
+ type: 'object',
3403
+ properties: {
3404
+ eventName: {
3405
+ type: 'string',
3406
+ description: 'Magento event name to find dispatchers for. Examples: "sales_order_place_after", "drmax_discount_rule_validation_before", "checkout_cart_add_product_complete"'
3407
+ }
3408
+ },
3409
+ required: ['eventName']
3410
+ }
3411
+ },
2534
3412
  ]
2535
3413
  }));
2536
3414
 
@@ -2542,7 +3420,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2542
3420
  // ── Warmup guard: index compatibility check or serve process still loading ──
2543
3421
  const indexFreeTools = ['magento_stats', 'magento_analyze_diff', 'magento_complexity',
2544
3422
  'magento_trace_dependency', 'magento_error_parser', 'magento_find_layout',
2545
- 'magento_impact_analysis', 'magento_find_event_flow', 'magento_find_test'];
3423
+ 'magento_impact_analysis', 'magento_find_event_flow', 'magento_find_test',
3424
+ 'magento_trace_data_flow', 'magento_find_event_dispatchers'];
2546
3425
  if (warmupInProgress && !indexFreeTools.includes(name)) {
2547
3426
  logToFile('REQ', `${name} → blocked (warmup: loading index)`);
2548
3427
  return {
@@ -2725,12 +3604,65 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2725
3604
  );
2726
3605
  results = rerank(results, { isPlugin: true, pathContains: ['/Plugin/', 'di.xml'] });
2727
3606
 
2728
- return {
2729
- content: [{
2730
- type: 'text',
2731
- text: formatSearchResults(results.slice(0, 15))
2732
- }]
2733
- };
3607
+ // Enrich results with di.xml registration area (frontend/adminhtml/global)
3608
+ const enrichedResults = results.slice(0, 15).map(r => {
3609
+ if (!r.path) return r;
3610
+ let diArea = 'global';
3611
+ if (r.path.includes('/etc/adminhtml/')) diArea = 'adminhtml';
3612
+ else if (r.path.includes('/etc/frontend/')) diArea = 'frontend';
3613
+ else if (r.path.includes('/etc/graphql/')) diArea = 'graphql';
3614
+ else if (r.path.includes('/etc/webapi_rest/')) diArea = 'webapi_rest';
3615
+ else if (r.path.includes('/etc/webapi_soap/')) diArea = 'webapi_soap';
3616
+ else if (r.path.includes('/etc/crontab/')) diArea = 'crontab';
3617
+ return { ...r, diArea };
3618
+ });
3619
+
3620
+ // If targetClass provided, also scan di.xml for explicit registrations
3621
+ let diRegistrations = [];
3622
+ if (args.targetClass) {
3623
+ const fpRoot = config.magentoRoot;
3624
+ const diFiles = await glob('**/etc/**/di.xml', { cwd: fpRoot, absolute: true, nodir: true });
3625
+ const shortTarget = args.targetClass.split('\\').pop();
3626
+ for (const diFile of diFiles) {
3627
+ let content;
3628
+ try { content = readFileSync(diFile, 'utf-8'); } catch { continue; }
3629
+ if (!content.includes(shortTarget)) continue;
3630
+ const relPath = diFile.replace(fpRoot + '/', '');
3631
+ // Find plugin registrations for this target
3632
+ const typeBlockRegex = /<type\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/type>/g;
3633
+ let tm;
3634
+ while ((tm = typeBlockRegex.exec(content)) !== null) {
3635
+ const typeName = tm[1];
3636
+ if (!typeName.includes(shortTarget)) continue;
3637
+ const block = tm[2];
3638
+ const pluginRegex = /<plugin\s+name="([^"]+)"[^>]*type="([^"]+)"[^>]*/g;
3639
+ let pm;
3640
+ while ((pm = pluginRegex.exec(block)) !== null) {
3641
+ let area = 'global';
3642
+ if (relPath.includes('/etc/adminhtml/')) area = 'adminhtml';
3643
+ else if (relPath.includes('/etc/frontend/')) area = 'frontend';
3644
+ else if (relPath.includes('/etc/graphql/')) area = 'graphql';
3645
+ diRegistrations.push({
3646
+ target: typeName,
3647
+ pluginName: pm[1],
3648
+ pluginClass: pm[2],
3649
+ area,
3650
+ file: relPath
3651
+ });
3652
+ }
3653
+ }
3654
+ }
3655
+ }
3656
+
3657
+ let text = formatSearchResults(enrichedResults);
3658
+ if (diRegistrations.length > 0) {
3659
+ text += `\n\n### DI Plugin Registrations for ${args.targetClass} (${diRegistrations.length})\n`;
3660
+ for (const reg of diRegistrations) {
3661
+ text += `- **${reg.pluginName}** → \`${reg.pluginClass}\` [${reg.area}] (${reg.file})\n`;
3662
+ }
3663
+ }
3664
+
3665
+ return { content: [{ type: 'text', text }] };
2734
3666
  }
2735
3667
 
2736
3668
  case 'magento_find_observer': {
@@ -3376,6 +4308,257 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3376
4308
  return { content: [{ type: 'text', text }] };
3377
4309
  }
3378
4310
 
4311
+ case 'magento_find_implementors': {
4312
+ const implResult = await findImplementors(args.interfaceName);
4313
+
4314
+ let text = `## Implementors of: ${implResult.interface}\n\n`;
4315
+
4316
+ if (implResult.diPreferences.length > 0) {
4317
+ text += `### DI Preferences (${implResult.diPreferences.length})\n`;
4318
+ for (const p of implResult.diPreferences) {
4319
+ text += `- \`${p.for}\` → \`${p.type}\` (${p.file})\n`;
4320
+ }
4321
+ text += '\n';
4322
+ }
4323
+
4324
+ if (implResult.implementors.length > 0) {
4325
+ text += `### PHP Implementors (${implResult.implementors.length})\n`;
4326
+ for (const impl of implResult.implementors) {
4327
+ const suffix = impl.matchType === 'shortName' ? ' _(short name match)_' : '';
4328
+ text += `- \`${impl.class}\` (${impl.file})${suffix}\n`;
4329
+ }
4330
+ text += '\n';
4331
+ }
4332
+
4333
+ if (implResult.diPreferences.length === 0 && implResult.implementors.length === 0) {
4334
+ text += '_No implementors found._\n';
4335
+ }
4336
+
4337
+ return { content: [{ type: 'text', text }] };
4338
+ }
4339
+
4340
+ case 'magento_find_callers': {
4341
+ const callResult = await findCallers(args.methodName, args.className);
4342
+
4343
+ let text = `## Callers of: ${args.methodName}${args.className ? ` (${args.className})` : ''}\n\n`;
4344
+ text += `Scanned ${callResult.totalScanned} PHP files.\n\n`;
4345
+
4346
+ if (callResult.phpCallers.length > 0) {
4347
+ text += `### PHP Call Sites (${callResult.phpCallers.length})\n`;
4348
+ for (const caller of callResult.phpCallers.slice(0, 30)) {
4349
+ text += `- **${caller.file}**${caller.callerClass ? ` (\`${caller.callerClass}\`)` : ''}\n`;
4350
+ for (const c of caller.calls) {
4351
+ text += ` - L${c.line}: \`${c.snippet}\`\n`;
4352
+ }
4353
+ }
4354
+ text += '\n';
4355
+ }
4356
+
4357
+ if (callResult.xmlReferences.length > 0) {
4358
+ text += `### XML References (${callResult.xmlReferences.length})\n`;
4359
+ for (const ref of callResult.xmlReferences.slice(0, 15)) {
4360
+ text += `- **${ref.file}**\n`;
4361
+ for (const r of ref.refs) {
4362
+ text += ` - L${r.line}: \`${r.snippet}\`\n`;
4363
+ }
4364
+ }
4365
+ text += '\n';
4366
+ }
4367
+
4368
+ if (callResult.phpCallers.length === 0 && callResult.xmlReferences.length === 0) {
4369
+ text += '_No callers found._\n';
4370
+ }
4371
+
4372
+ return { content: [{ type: 'text', text }] };
4373
+ }
4374
+
4375
+ case 'magento_find_di_wiring': {
4376
+ const diResult = await findDiWiring(args.className);
4377
+
4378
+ let text = `## DI Wiring: ${diResult.className}\n\n`;
4379
+ text += `Scanned ${diResult.totalDiFiles} di.xml files.\n\n`;
4380
+
4381
+ if (diResult.constructorArguments.length > 0) {
4382
+ text += `### Constructor Signature\n`;
4383
+ for (const arg of diResult.constructorArguments) {
4384
+ text += `- ${arg.typeHint || 'mixed'} ${arg.variable}\n`;
4385
+ }
4386
+ text += '\n';
4387
+ }
4388
+
4389
+ if (diResult.preferences.length > 0) {
4390
+ text += `### Preferences (${diResult.preferences.length})\n`;
4391
+ for (const p of diResult.preferences) {
4392
+ text += `- \`${p.for}\` → \`${p.type}\` (${p.file})\n`;
4393
+ }
4394
+ text += '\n';
4395
+ }
4396
+
4397
+ if (diResult.plugins.length > 0) {
4398
+ text += `### Plugins (${diResult.plugins.length})\n`;
4399
+ for (const p of diResult.plugins) {
4400
+ text += `- \`${p.pluginName}\`: \`${p.pluginClass}\` on \`${p.target}\` (${p.file})\n`;
4401
+ }
4402
+ text += '\n';
4403
+ }
4404
+
4405
+ if (diResult.typeArguments.length > 0) {
4406
+ text += `### DI Arguments (${diResult.typeArguments.length})\n`;
4407
+ for (const a of diResult.typeArguments) {
4408
+ if (a.type === 'array' && a.items) {
4409
+ text += `- \`${a.target}\`.${a.name} (array, ${a.items.length} items) (${a.file})\n`;
4410
+ for (const item of a.items.slice(0, 10)) {
4411
+ text += ` - ${item.name}: \`${item.value}\`\n`;
4412
+ }
4413
+ } else {
4414
+ text += `- \`${a.target}\`.${a.name} (${a.type}) = \`${a.value}\` (${a.file})\n`;
4415
+ }
4416
+ }
4417
+ text += '\n';
4418
+ }
4419
+
4420
+ if (diResult.virtualTypes.length > 0) {
4421
+ text += `### Virtual Types (${diResult.virtualTypes.length})\n`;
4422
+ for (const v of diResult.virtualTypes) {
4423
+ text += `- \`${v.name}\` extends \`${v.type}\` (${v.file})\n`;
4424
+ if (v.arguments) {
4425
+ for (const a of v.arguments) {
4426
+ text += ` - ${a.name}: \`${a.value}\`\n`;
4427
+ }
4428
+ }
4429
+ }
4430
+ text += '\n';
4431
+ }
4432
+
4433
+ return { content: [{ type: 'text', text }] };
4434
+ }
4435
+
4436
+ case 'magento_trace_call_chain': {
4437
+ const traceResult = await traceCallChain(args.className, args.methodName, args.maxDepth || 3);
4438
+
4439
+ let text = `## Call Chain: ${traceResult.entryPoint}\n\n`;
4440
+ text += `Max depth reached: ${traceResult.depth}\n\n`;
4441
+
4442
+ if (traceResult.chain.length > 0) {
4443
+ text += `### Execution Chain (${traceResult.chain.length} methods)\n`;
4444
+ for (const entry of traceResult.chain) {
4445
+ const indent = ' '.repeat(entry.depth);
4446
+ const status = entry.status ? ` [${entry.status}]` : '';
4447
+ text += `${indent}- **${entry.class}::${entry.method}**${status}`;
4448
+ if (entry.file) text += ` (${entry.file})`;
4449
+ text += '\n';
4450
+
4451
+ if (entry.calls) {
4452
+ for (const call of entry.calls) {
4453
+ if (call.type === 'self') {
4454
+ text += `${indent} → \`$this->${call.method}()\`\n`;
4455
+ } else {
4456
+ text += `${indent} → \`$this->${call.property}->${call.method}()\``;
4457
+ if (call.typeHint) text += ` [${call.typeHint}]`;
4458
+ text += '\n';
4459
+ }
4460
+ }
4461
+ }
4462
+
4463
+ if (entry.dispatches) {
4464
+ for (const evt of entry.dispatches) {
4465
+ text += `${indent} ⚡ dispatch(\`${evt}\`)\n`;
4466
+ }
4467
+ }
4468
+ }
4469
+ text += '\n';
4470
+ }
4471
+
4472
+ if (traceResult.events.length > 0) {
4473
+ text += `### Events Dispatched (${traceResult.events.length})\n`;
4474
+ for (const evt of traceResult.events) {
4475
+ text += `- **${evt.event}** (from ${evt.dispatchedIn})\n`;
4476
+ if (evt.observers.length > 0) {
4477
+ for (const obs of evt.observers) {
4478
+ text += ` - \`${obs.name}\`: \`${obs.class}\` (${obs.file})\n`;
4479
+ }
4480
+ } else {
4481
+ text += ' - _no observers registered_\n';
4482
+ }
4483
+ }
4484
+ text += '\n';
4485
+ }
4486
+
4487
+ return { content: [{ type: 'text', text }] };
4488
+ }
4489
+
4490
+ case 'magento_trace_data_flow': {
4491
+ const flowResult = await traceDataFlow(args.attributeKey, args.modelClass);
4492
+
4493
+ let text = `## Data Flow: \`${flowResult.attributeKey}\`\n`;
4494
+ text += `Magic setter: \`${flowResult.setterMethod}()\` · Magic getter: \`${flowResult.getterMethod}()\`\n\n`;
4495
+
4496
+ if (flowResult.setters.length > 0) {
4497
+ text += `### Setters (${flowResult.setters.length})\n`;
4498
+ for (const s of flowResult.setters) {
4499
+ const typeTag = s.type === 'constant' ? ' [const]' : '';
4500
+ const methodTag = s.method ? `::${s.method}` : '';
4501
+ text += `- \`${s.class}${methodTag}\`${typeTag} (${s.path}:${s.line})\n`;
4502
+ if (s.snippet) text += ` \`${s.snippet}\`\n`;
4503
+ }
4504
+ text += '\n';
4505
+ } else {
4506
+ text += '### Setters\n_No setters found._\n\n';
4507
+ }
4508
+
4509
+ if (flowResult.getters.length > 0) {
4510
+ text += `### Getters (${flowResult.getters.length})\n`;
4511
+ for (const g of flowResult.getters) {
4512
+ const methodTag = g.method ? `::${g.method}` : '';
4513
+ text += `- \`${g.class}${methodTag}\` (${g.path}:${g.line})\n`;
4514
+ if (g.snippet) text += ` \`${g.snippet}\`\n`;
4515
+ }
4516
+ text += '\n';
4517
+ } else {
4518
+ text += '### Getters\n_No getters found._\n\n';
4519
+ }
4520
+
4521
+ if (flowResult.xmlReferences.length > 0) {
4522
+ text += `### XML References (${flowResult.xmlReferences.length})\n`;
4523
+ for (const x of flowResult.xmlReferences) {
4524
+ text += `- ${x.path}:${x.line}\n \`${x.snippet}\`\n`;
4525
+ }
4526
+ }
4527
+
4528
+ return { content: [{ type: 'text', text }] };
4529
+ }
4530
+
4531
+ case 'magento_find_event_dispatchers': {
4532
+ const dispResult = await findEventDispatchers(args.eventName);
4533
+
4534
+ let text = `## Event Dispatchers: \`${dispResult.eventName}\`\n\n`;
4535
+
4536
+ if (dispResult.dispatchers.length > 0) {
4537
+ text += `Found ${dispResult.dispatchers.length} dispatch location(s)`;
4538
+ if (dispResult.observerCount > 0) {
4539
+ text += ` (${dispResult.observerCount} observer(s) registered)`;
4540
+ }
4541
+ text += '.\n\n';
4542
+
4543
+ for (const d of dispResult.dispatchers) {
4544
+ const methodTag = d.method ? `::${d.method}` : '';
4545
+ text += `### \`${d.class}${methodTag}\`\n`;
4546
+ text += `**File:** ${d.path}:${d.line}\n`;
4547
+ if (d.context) {
4548
+ text += '```php\n' + d.context + '\n```\n';
4549
+ }
4550
+ text += '\n';
4551
+ }
4552
+ } else {
4553
+ text += '_No dispatch() calls found for this event._\n';
4554
+ if (dispResult.observerCount > 0) {
4555
+ text += `\nNote: ${dispResult.observerCount} observer(s) are registered for this event — it may be dispatched by Magento core or a module not in the scanned path.\n`;
4556
+ }
4557
+ }
4558
+
4559
+ return { content: [{ type: 'text', text }] };
4560
+ }
4561
+
3379
4562
  default:
3380
4563
  return {
3381
4564
  content: [{