magector 2.2.4 → 2.2.6

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 +5 -5
  2. package/src/mcp-server.js +789 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magector",
3
- "version": "2.2.4",
3
+ "version": "2.2.6",
4
4
  "description": "Semantic code search for Magento 2 — index, search, MCP server",
5
5
  "type": "module",
6
6
  "main": "src/mcp-server.js",
@@ -39,10 +39,10 @@
39
39
  "ruvector": "^0.1.96"
40
40
  },
41
41
  "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"
42
+ "@magector/cli-darwin-arm64": "2.2.6",
43
+ "@magector/cli-linux-x64": "2.2.6",
44
+ "@magector/cli-linux-arm64": "2.2.6",
45
+ "@magector/cli-win32-x64": "2.2.6"
46
46
  },
47
47
  "keywords": [
48
48
  "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,505 @@ 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 Call Chain ───────────────────────────────────────────
2366
+ // Trace internal method execution: method calls, dispatched events, observers
2367
+
2368
+ async function traceCallChain(startClass, startMethod, maxDepth = 3) {
2369
+ const root = config.magentoRoot;
2370
+
2371
+ const result = {
2372
+ entryPoint: `${startClass}::${startMethod}`,
2373
+ chain: [],
2374
+ events: [],
2375
+ observers: [],
2376
+ depth: 0
2377
+ };
2378
+
2379
+ const classFileMap = new Map();
2380
+
2381
+ async function resolveClassFile(className) {
2382
+ const shortName = className.split('\\').pop();
2383
+ if (classFileMap.has(className)) return classFileMap.get(className);
2384
+
2385
+ // Try common Magento path patterns
2386
+ const nsPath = className.replace(/\\/g, '/') + '.php';
2387
+ const candidates = [
2388
+ `app/code/${nsPath}`,
2389
+ `vendor/${nsPath}`
2390
+ ];
2391
+
2392
+ for (const c of candidates) {
2393
+ const full = path.join(root, c);
2394
+ if (existsSync(full)) {
2395
+ classFileMap.set(className, full);
2396
+ return full;
2397
+ }
2398
+ }
2399
+
2400
+ // Fallback: glob for the class file
2401
+ const matches = await glob(`**/${shortName}.php`, {
2402
+ cwd: root, absolute: true, nodir: true,
2403
+ ignore: ['**/test/**', '**/tests/**', '**/Test/**', '**/Tests/**']
2404
+ });
2405
+
2406
+ for (const match of matches) {
2407
+ let content;
2408
+ try { content = readFileSync(match, 'utf-8'); } catch { continue; }
2409
+ const nsMatch = content.match(/namespace\s+([\w\\]+)/);
2410
+ const classMatch = content.match(/(?:class|abstract\s+class|trait)\s+(\w+)/);
2411
+ if (classMatch && classMatch[1] === shortName) {
2412
+ const fqcn = nsMatch ? `${nsMatch[1]}\\${classMatch[1]}` : classMatch[1];
2413
+ classFileMap.set(fqcn, match);
2414
+ if (fqcn === className || classMatch[1] === shortName) {
2415
+ classFileMap.set(className, match);
2416
+ return match;
2417
+ }
2418
+ }
2419
+ }
2420
+
2421
+ classFileMap.set(className, null);
2422
+ return null;
2423
+ }
2424
+
2425
+ // Load events.xml index
2426
+ const eventObserverMap = new Map();
2427
+ const eventFiles = await glob('**/etc/**/events.xml', { cwd: root, absolute: true, nodir: true });
2428
+ for (const evFile of eventFiles) {
2429
+ let content;
2430
+ try { content = readFileSync(evFile, 'utf-8'); } catch { continue; }
2431
+ const relativePath = evFile.replace(root + '/', '');
2432
+
2433
+ const eventRegex = /<event\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/event>/g;
2434
+ let em;
2435
+ while ((em = eventRegex.exec(content)) !== null) {
2436
+ const eventName = em[1];
2437
+ const eventBlock = em[2];
2438
+ const obsRegex = /<observer\s+[^>]*name="([^"]+)"[^>]*instance="([^"]+)"[^>]*/g;
2439
+ let om;
2440
+ while ((om = obsRegex.exec(eventBlock)) !== null) {
2441
+ if (!eventObserverMap.has(eventName)) eventObserverMap.set(eventName, []);
2442
+ eventObserverMap.get(eventName).push({
2443
+ name: om[1], class: om[2], file: relativePath
2444
+ });
2445
+ }
2446
+ }
2447
+ }
2448
+
2449
+ // Resolve DI preference for an interface
2450
+ const prefCache = new Map();
2451
+ async function resolvePreference(interfaceName) {
2452
+ if (prefCache.has(interfaceName)) return prefCache.get(interfaceName);
2453
+ const shortName = interfaceName.split('\\').pop();
2454
+ const diXmlFiles = await glob('**/etc/di.xml', { cwd: root, absolute: true, nodir: true });
2455
+ for (const diFile of diXmlFiles) {
2456
+ let content;
2457
+ try { content = readFileSync(diFile, 'utf-8'); } catch { continue; }
2458
+ const prefRegex = /<preference\s+for="([^"]+)"\s+type="([^"]+)"\s*\/?>/g;
2459
+ let m;
2460
+ while ((m = prefRegex.exec(content)) !== null) {
2461
+ if (m[1] === interfaceName || m[1].endsWith('\\' + shortName)) {
2462
+ prefCache.set(interfaceName, m[2]);
2463
+ return m[2];
2464
+ }
2465
+ }
2466
+ }
2467
+ prefCache.set(interfaceName, null);
2468
+ return null;
2469
+ }
2470
+
2471
+ async function traceMethod(className, methodName, depth) {
2472
+ if (depth > maxDepth) return;
2473
+
2474
+ const filePath = await resolveClassFile(className);
2475
+ if (!filePath) {
2476
+ result.chain.push({ depth, class: className, method: methodName, status: 'unresolved' });
2477
+ return;
2478
+ }
2479
+
2480
+ let content;
2481
+ try { content = readFileSync(filePath, 'utf-8'); } catch { return; }
2482
+ const relativePath = filePath.replace(root + '/', '');
2483
+
2484
+ // Extract method body (brace counting)
2485
+ const methodRegex = new RegExp(
2486
+ `function\\s+${methodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\([^)]*\\)[^{]*\\{`
2487
+ );
2488
+ const methodStart = content.search(methodRegex);
2489
+ if (methodStart === -1) {
2490
+ result.chain.push({ depth, class: className, method: methodName, file: relativePath, status: 'method_not_found' });
2491
+ return;
2492
+ }
2493
+
2494
+ let braceCount = 0;
2495
+ let bodyStart = content.indexOf('{', methodStart);
2496
+ let bodyEnd = bodyStart;
2497
+ for (let i = bodyStart; i < content.length; i++) {
2498
+ if (content[i] === '{') braceCount++;
2499
+ if (content[i] === '}') braceCount--;
2500
+ if (braceCount === 0) { bodyEnd = i; break; }
2501
+ }
2502
+ const methodBody = content.slice(bodyStart, bodyEnd + 1);
2503
+
2504
+ const chainEntry = {
2505
+ depth, class: className, method: methodName, file: relativePath,
2506
+ calls: [], dispatches: []
2507
+ };
2508
+
2509
+ // $this->method( calls
2510
+ const selfCallRegex = /\$this->(\w+)\s*\(/g;
2511
+ let sc;
2512
+ while ((sc = selfCallRegex.exec(methodBody)) !== null) {
2513
+ const calledMethod = sc[1];
2514
+ if (calledMethod !== methodName && calledMethod !== '__construct') {
2515
+ chainEntry.calls.push({ type: 'self', method: calledMethod });
2516
+ }
2517
+ }
2518
+
2519
+ // $this->dependency->method( calls
2520
+ const depCallRegex = /\$this->(\w+)->(\w+)\s*\(/g;
2521
+ let dc;
2522
+ while ((dc = depCallRegex.exec(methodBody)) !== null) {
2523
+ const property = dc[1];
2524
+ const calledMethod = dc[2];
2525
+ // Resolve property type from constructor
2526
+ const ctorMatch = content.match(/function\s+__construct\s*\(([\s\S]*?)\)\s*[{:]/);
2527
+ let resolvedType = null;
2528
+ if (ctorMatch) {
2529
+ const paramRegex = new RegExp(`([\\w\\\\]+)\\s+\\$${property}\\b`);
2530
+ const pm = ctorMatch[1].match(paramRegex);
2531
+ if (pm) resolvedType = pm[1];
2532
+ }
2533
+ chainEntry.calls.push({ type: 'dependency', property, method: calledMethod, typeHint: resolvedType || null });
2534
+ }
2535
+
2536
+ // eventManager->dispatch calls
2537
+ const dispatchRegex = /(?:eventManager|_eventManager)->dispatch\s*\(\s*['"]([^'"]+)['"]/g;
2538
+ let dm;
2539
+ while ((dm = dispatchRegex.exec(methodBody)) !== null) {
2540
+ const eventName = dm[1];
2541
+ chainEntry.dispatches.push(eventName);
2542
+ const observers = eventObserverMap.get(eventName) || [];
2543
+ result.events.push({ event: eventName, dispatchedIn: `${className}::${methodName}`, observers });
2544
+ for (const obs of observers) {
2545
+ result.observers.push({ event: eventName, ...obs });
2546
+ }
2547
+ }
2548
+
2549
+ result.chain.push(chainEntry);
2550
+ result.depth = Math.max(result.depth, depth);
2551
+
2552
+ // Recurse into $this-> calls
2553
+ for (const call of chainEntry.calls) {
2554
+ if (call.type === 'self') {
2555
+ await traceMethod(className, call.method, depth + 1);
2556
+ }
2557
+ }
2558
+
2559
+ // Recurse into dependency calls (within depth limit)
2560
+ if (depth < maxDepth - 1) {
2561
+ for (const call of chainEntry.calls) {
2562
+ if (call.type === 'dependency' && call.typeHint) {
2563
+ const impl = await resolvePreference(call.typeHint);
2564
+ const targetClass = impl || call.typeHint;
2565
+ await traceMethod(targetClass, call.method, depth + 1);
2566
+ }
2567
+ }
2568
+ }
2569
+ }
2570
+
2571
+ await traceMethod(startClass, startMethod, 0);
2572
+
2573
+ return result;
2574
+ }
2575
+
2040
2576
  // ─── MCP Server ─────────────────────────────────────────────────
2041
2577
 
2042
2578
  const server = new Server(
@@ -2071,7 +2607,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2071
2607
  },
2072
2608
  moduleFilter: {
2073
2609
  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.'
2610
+ description: 'Filter results by vendor/module pattern. Supports wildcards and vendor prefix matching. Uses "/" or "_" interchangeably as separator. Examples: "Vendor_*" or "Vendor/*" to show modules from that vendor (also matches vendor-extended names like "Vendor-Extra_*"), "Magento_Catalog" for specific module. Omit to search all modules.'
2075
2611
  },
2076
2612
  expand: {
2077
2613
  type: 'boolean',
@@ -2531,6 +3067,75 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2531
3067
  required: ['className']
2532
3068
  }
2533
3069
  },
3070
+ {
3071
+ name: 'magento_find_implementors',
3072
+ 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.',
3073
+ inputSchema: {
3074
+ type: 'object',
3075
+ properties: {
3076
+ interfaceName: {
3077
+ type: 'string',
3078
+ description: 'Full or short PHP interface name. Examples: "OrderRepositoryInterface", "Magento\\Sales\\Api\\OrderRepositoryInterface", "ChildOrderValidatorInterface"'
3079
+ }
3080
+ },
3081
+ required: ['interfaceName']
3082
+ }
3083
+ },
3084
+ {
3085
+ name: 'magento_find_callers',
3086
+ 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.',
3087
+ inputSchema: {
3088
+ type: 'object',
3089
+ properties: {
3090
+ methodName: {
3091
+ type: 'string',
3092
+ description: 'Method name to find callers for. Examples: "execute", "save", "collectTotals", "copySalesRuleIdsFromParentToDrmaxQuote"'
3093
+ },
3094
+ className: {
3095
+ type: 'string',
3096
+ description: 'Optional: class that owns the method — narrows results to files that reference this class. Examples: "SalesRuleManagement", "CartRepository"'
3097
+ }
3098
+ },
3099
+ required: ['methodName']
3100
+ }
3101
+ },
3102
+ {
3103
+ name: 'magento_find_di_wiring',
3104
+ 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.',
3105
+ inputSchema: {
3106
+ type: 'object',
3107
+ properties: {
3108
+ className: {
3109
+ type: 'string',
3110
+ description: 'Full or short PHP class/interface name. Examples: "ChildOrderValidatorChain", "Magento\\SalesRule\\Model\\Rule", "CartManagementInterface"'
3111
+ }
3112
+ },
3113
+ required: ['className']
3114
+ }
3115
+ },
3116
+ {
3117
+ name: 'magento_trace_call_chain',
3118
+ 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.',
3119
+ inputSchema: {
3120
+ type: 'object',
3121
+ properties: {
3122
+ className: {
3123
+ type: 'string',
3124
+ description: 'Full PHP class name (FQCN) to start tracing from. Examples: "DrmaxMarketplace\\OrderSplit\\Model\\CreateOrder\\CreateChildOrder", "Magento\\Quote\\Model\\QuoteManagement"'
3125
+ },
3126
+ methodName: {
3127
+ type: 'string',
3128
+ description: 'Method name to start tracing. Examples: "execute", "submit", "collectTotals"'
3129
+ },
3130
+ maxDepth: {
3131
+ type: 'number',
3132
+ description: 'Maximum recursion depth for tracing (default: 3). Higher values trace deeper but take longer.',
3133
+ default: 3
3134
+ }
3135
+ },
3136
+ required: ['className', 'methodName']
3137
+ }
3138
+ },
2534
3139
  ]
2535
3140
  }));
2536
3141
 
@@ -3376,6 +3981,185 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3376
3981
  return { content: [{ type: 'text', text }] };
3377
3982
  }
3378
3983
 
3984
+ case 'magento_find_implementors': {
3985
+ const implResult = await findImplementors(args.interfaceName);
3986
+
3987
+ let text = `## Implementors of: ${implResult.interface}\n\n`;
3988
+
3989
+ if (implResult.diPreferences.length > 0) {
3990
+ text += `### DI Preferences (${implResult.diPreferences.length})\n`;
3991
+ for (const p of implResult.diPreferences) {
3992
+ text += `- \`${p.for}\` → \`${p.type}\` (${p.file})\n`;
3993
+ }
3994
+ text += '\n';
3995
+ }
3996
+
3997
+ if (implResult.implementors.length > 0) {
3998
+ text += `### PHP Implementors (${implResult.implementors.length})\n`;
3999
+ for (const impl of implResult.implementors) {
4000
+ const suffix = impl.matchType === 'shortName' ? ' _(short name match)_' : '';
4001
+ text += `- \`${impl.class}\` (${impl.file})${suffix}\n`;
4002
+ }
4003
+ text += '\n';
4004
+ }
4005
+
4006
+ if (implResult.diPreferences.length === 0 && implResult.implementors.length === 0) {
4007
+ text += '_No implementors found._\n';
4008
+ }
4009
+
4010
+ return { content: [{ type: 'text', text }] };
4011
+ }
4012
+
4013
+ case 'magento_find_callers': {
4014
+ const callResult = await findCallers(args.methodName, args.className);
4015
+
4016
+ let text = `## Callers of: ${args.methodName}${args.className ? ` (${args.className})` : ''}\n\n`;
4017
+ text += `Scanned ${callResult.totalScanned} PHP files.\n\n`;
4018
+
4019
+ if (callResult.phpCallers.length > 0) {
4020
+ text += `### PHP Call Sites (${callResult.phpCallers.length})\n`;
4021
+ for (const caller of callResult.phpCallers.slice(0, 30)) {
4022
+ text += `- **${caller.file}**${caller.callerClass ? ` (\`${caller.callerClass}\`)` : ''}\n`;
4023
+ for (const c of caller.calls) {
4024
+ text += ` - L${c.line}: \`${c.snippet}\`\n`;
4025
+ }
4026
+ }
4027
+ text += '\n';
4028
+ }
4029
+
4030
+ if (callResult.xmlReferences.length > 0) {
4031
+ text += `### XML References (${callResult.xmlReferences.length})\n`;
4032
+ for (const ref of callResult.xmlReferences.slice(0, 15)) {
4033
+ text += `- **${ref.file}**\n`;
4034
+ for (const r of ref.refs) {
4035
+ text += ` - L${r.line}: \`${r.snippet}\`\n`;
4036
+ }
4037
+ }
4038
+ text += '\n';
4039
+ }
4040
+
4041
+ if (callResult.phpCallers.length === 0 && callResult.xmlReferences.length === 0) {
4042
+ text += '_No callers found._\n';
4043
+ }
4044
+
4045
+ return { content: [{ type: 'text', text }] };
4046
+ }
4047
+
4048
+ case 'magento_find_di_wiring': {
4049
+ const diResult = await findDiWiring(args.className);
4050
+
4051
+ let text = `## DI Wiring: ${diResult.className}\n\n`;
4052
+ text += `Scanned ${diResult.totalDiFiles} di.xml files.\n\n`;
4053
+
4054
+ if (diResult.constructorArguments.length > 0) {
4055
+ text += `### Constructor Signature\n`;
4056
+ for (const arg of diResult.constructorArguments) {
4057
+ text += `- ${arg.typeHint || 'mixed'} ${arg.variable}\n`;
4058
+ }
4059
+ text += '\n';
4060
+ }
4061
+
4062
+ if (diResult.preferences.length > 0) {
4063
+ text += `### Preferences (${diResult.preferences.length})\n`;
4064
+ for (const p of diResult.preferences) {
4065
+ text += `- \`${p.for}\` → \`${p.type}\` (${p.file})\n`;
4066
+ }
4067
+ text += '\n';
4068
+ }
4069
+
4070
+ if (diResult.plugins.length > 0) {
4071
+ text += `### Plugins (${diResult.plugins.length})\n`;
4072
+ for (const p of diResult.plugins) {
4073
+ text += `- \`${p.pluginName}\`: \`${p.pluginClass}\` on \`${p.target}\` (${p.file})\n`;
4074
+ }
4075
+ text += '\n';
4076
+ }
4077
+
4078
+ if (diResult.typeArguments.length > 0) {
4079
+ text += `### DI Arguments (${diResult.typeArguments.length})\n`;
4080
+ for (const a of diResult.typeArguments) {
4081
+ if (a.type === 'array' && a.items) {
4082
+ text += `- \`${a.target}\`.${a.name} (array, ${a.items.length} items) (${a.file})\n`;
4083
+ for (const item of a.items.slice(0, 10)) {
4084
+ text += ` - ${item.name}: \`${item.value}\`\n`;
4085
+ }
4086
+ } else {
4087
+ text += `- \`${a.target}\`.${a.name} (${a.type}) = \`${a.value}\` (${a.file})\n`;
4088
+ }
4089
+ }
4090
+ text += '\n';
4091
+ }
4092
+
4093
+ if (diResult.virtualTypes.length > 0) {
4094
+ text += `### Virtual Types (${diResult.virtualTypes.length})\n`;
4095
+ for (const v of diResult.virtualTypes) {
4096
+ text += `- \`${v.name}\` extends \`${v.type}\` (${v.file})\n`;
4097
+ if (v.arguments) {
4098
+ for (const a of v.arguments) {
4099
+ text += ` - ${a.name}: \`${a.value}\`\n`;
4100
+ }
4101
+ }
4102
+ }
4103
+ text += '\n';
4104
+ }
4105
+
4106
+ return { content: [{ type: 'text', text }] };
4107
+ }
4108
+
4109
+ case 'magento_trace_call_chain': {
4110
+ const traceResult = await traceCallChain(args.className, args.methodName, args.maxDepth || 3);
4111
+
4112
+ let text = `## Call Chain: ${traceResult.entryPoint}\n\n`;
4113
+ text += `Max depth reached: ${traceResult.depth}\n\n`;
4114
+
4115
+ if (traceResult.chain.length > 0) {
4116
+ text += `### Execution Chain (${traceResult.chain.length} methods)\n`;
4117
+ for (const entry of traceResult.chain) {
4118
+ const indent = ' '.repeat(entry.depth);
4119
+ const status = entry.status ? ` [${entry.status}]` : '';
4120
+ text += `${indent}- **${entry.class}::${entry.method}**${status}`;
4121
+ if (entry.file) text += ` (${entry.file})`;
4122
+ text += '\n';
4123
+
4124
+ if (entry.calls) {
4125
+ for (const call of entry.calls) {
4126
+ if (call.type === 'self') {
4127
+ text += `${indent} → \`$this->${call.method}()\`\n`;
4128
+ } else {
4129
+ text += `${indent} → \`$this->${call.property}->${call.method}()\``;
4130
+ if (call.typeHint) text += ` [${call.typeHint}]`;
4131
+ text += '\n';
4132
+ }
4133
+ }
4134
+ }
4135
+
4136
+ if (entry.dispatches) {
4137
+ for (const evt of entry.dispatches) {
4138
+ text += `${indent} ⚡ dispatch(\`${evt}\`)\n`;
4139
+ }
4140
+ }
4141
+ }
4142
+ text += '\n';
4143
+ }
4144
+
4145
+ if (traceResult.events.length > 0) {
4146
+ text += `### Events Dispatched (${traceResult.events.length})\n`;
4147
+ for (const evt of traceResult.events) {
4148
+ text += `- **${evt.event}** (from ${evt.dispatchedIn})\n`;
4149
+ if (evt.observers.length > 0) {
4150
+ for (const obs of evt.observers) {
4151
+ text += ` - \`${obs.name}\`: \`${obs.class}\` (${obs.file})\n`;
4152
+ }
4153
+ } else {
4154
+ text += ' - _no observers registered_\n';
4155
+ }
4156
+ }
4157
+ text += '\n';
4158
+ }
4159
+
4160
+ return { content: [{ type: 'text', text }] };
4161
+ }
4162
+
3379
4163
  default:
3380
4164
  return {
3381
4165
  content: [{