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.
- package/package.json +6 -12
- package/src/mcp-server.js +1196 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magector",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
43
|
-
"@magector/cli-linux-x64": "2.
|
|
44
|
-
"@magector/cli-linux-arm64": "2.
|
|
45
|
-
"@magector/cli-win32-x64": "2.
|
|
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
|
-
|
|
1786
|
-
|
|
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
|
|
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
|
-
|
|
2074
|
-
|
|
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
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
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: [{
|