magector 2.5.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +5 -5
  2. package/src/mcp-server.js +254 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magector",
3
- "version": "2.5.0",
3
+ "version": "2.5.1",
4
4
  "description": "Semantic code search for Magento 2 — index, search, MCP server",
5
5
  "type": "module",
6
6
  "main": "src/mcp-server.js",
@@ -33,10 +33,10 @@
33
33
  "ruvector": "^0.1.96"
34
34
  },
35
35
  "optionalDependencies": {
36
- "@magector/cli-darwin-arm64": "2.5.0",
37
- "@magector/cli-linux-x64": "2.5.0",
38
- "@magector/cli-linux-arm64": "2.5.0",
39
- "@magector/cli-win32-x64": "2.5.0"
36
+ "@magector/cli-darwin-arm64": "2.5.1",
37
+ "@magector/cli-linux-x64": "2.5.1",
38
+ "@magector/cli-linux-arm64": "2.5.1",
39
+ "@magector/cli-win32-x64": "2.5.1"
40
40
  },
41
41
  "keywords": [
42
42
  "magento",
package/src/mcp-server.js CHANGED
@@ -2176,6 +2176,7 @@ async function analyzeImpact(className) {
2176
2176
  diXmlReferences: [],
2177
2177
  instantiations: [],
2178
2178
  typeHints: [],
2179
+ runtimeCallers: [],
2179
2180
  total: 0
2180
2181
  };
2181
2182
 
@@ -2194,6 +2195,7 @@ async function analyzeImpact(className) {
2194
2195
  ];
2195
2196
 
2196
2197
  // Check PHP files for direct references
2198
+ const escapedShort = shortName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2197
2199
  for (const r of relatedPaths.slice(0, 40)) {
2198
2200
  const absPath = path.join(root, r.path);
2199
2201
  if (!existsSync(absPath) || !r.path.endsWith('.php')) continue;
@@ -2208,13 +2210,38 @@ async function analyzeImpact(className) {
2208
2210
  references.instantiations.push({ file: r.path, className: r.className });
2209
2211
  }
2210
2212
  if (content.includes(`@var ${shortName}`) || content.includes(`@param ${shortName}`) ||
2211
- content.match(new RegExp(`:\\s*${shortName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`))) {
2213
+ content.match(new RegExp(`:\\s*${escapedShort}\\b`))) {
2212
2214
  references.typeHints.push({ file: r.path, className: r.className });
2213
2215
  }
2216
+
2217
+ // Runtime callers: find $this->property->method() where property is typed as this class
2218
+ if (hasUse) {
2219
+ const ctorMatch = content.match(/function\s+__construct\s*\(([\s\S]*?)\)\s*[{:]/);
2220
+ if (ctorMatch) {
2221
+ // Find constructor params typed as the target class
2222
+ const paramRegex = new RegExp(`(?:${escapedShort}|${className.replace(/\\/g, '\\\\')})\\s+\\$(\\w+)`, 'g');
2223
+ let pm;
2224
+ while ((pm = paramRegex.exec(ctorMatch[1])) !== null) {
2225
+ const propName = pm[1];
2226
+ // Find all calls to this property in the file
2227
+ const callRegex = new RegExp(`\\$this->${propName}->(\\w+)\\s*\\(`, 'g');
2228
+ let cm;
2229
+ while ((cm = callRegex.exec(content)) !== null) {
2230
+ references.runtimeCallers.push({
2231
+ file: r.path,
2232
+ callerClass: r.className,
2233
+ property: propName,
2234
+ calledMethod: cm[1]
2235
+ });
2236
+ }
2237
+ }
2238
+ }
2239
+ }
2214
2240
  }
2215
2241
 
2216
2242
  references.total = references.useStatements.length + references.diXmlReferences.length +
2217
- references.instantiations.length + references.typeHints.length;
2243
+ references.instantiations.length + references.typeHints.length +
2244
+ references.runtimeCallers.length;
2218
2245
 
2219
2246
  return references;
2220
2247
  }
@@ -2904,6 +2931,25 @@ async function traceCallChain(startClass, startMethod, maxDepth = 3) {
2904
2931
  };
2905
2932
 
2906
2933
  const classFileMap = new Map();
2934
+ const parentClassCache = new Map();
2935
+
2936
+ // Resolve parent class from extends declaration in PHP file content
2937
+ function resolveParentFromContent(content) {
2938
+ const extendsMatch = content.match(/class\s+\w+\s+extends\s+([\w\\]+)/);
2939
+ if (!extendsMatch) return null;
2940
+ const parent = extendsMatch[1];
2941
+ // If it's a short name, resolve using use statements
2942
+ if (!parent.includes('\\')) {
2943
+ const useMatch = content.match(new RegExp(`use\\s+([\\w\\\\]+\\\\${parent})\\s*;`));
2944
+ if (useMatch) return useMatch[1];
2945
+ // Check namespace-relative
2946
+ const nsMatch = content.match(/namespace\s+([\w\\]+)/);
2947
+ if (nsMatch) return `${nsMatch[1]}\\${parent}`;
2948
+ return parent;
2949
+ }
2950
+ // Leading backslash = fully qualified
2951
+ return parent.replace(/^\\/, '');
2952
+ }
2907
2953
 
2908
2954
  async function resolveClassFile(className) {
2909
2955
  const shortName = className.split('\\').pop();
@@ -3009,14 +3055,46 @@ async function traceCallChain(startClass, startMethod, maxDepth = 3) {
3009
3055
  const relativePath = filePath.replace(root + '/', '');
3010
3056
 
3011
3057
  // Extract method body (brace counting)
3058
+ const escapedMethod = methodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3012
3059
  const methodRegex = new RegExp(
3013
- `function\\s+${methodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\([^)]*\\)[^{]*\\{`
3060
+ `function\\s+${escapedMethod}\\s*\\([^)]*\\)[^{]*\\{`
3014
3061
  );
3015
- const methodStart = content.search(methodRegex);
3062
+ let methodStart = content.search(methodRegex);
3063
+
3064
+ // If method not found in this class, walk up the inheritance chain
3065
+ let resolvedClass = className;
3066
+ let resolvedContent = content;
3067
+ let resolvedFilePath = filePath;
3016
3068
  if (methodStart === -1) {
3017
- result.chain.push({ depth, class: className, method: methodName, file: relativePath, status: 'method_not_found' });
3018
- return;
3069
+ let currentContent = content;
3070
+ let found = false;
3071
+ const visited = new Set([className]);
3072
+ for (let i = 0; i < 10; i++) { // max 10 parent levels
3073
+ const parentFqcn = resolveParentFromContent(currentContent);
3074
+ if (!parentFqcn || visited.has(parentFqcn)) break;
3075
+ visited.add(parentFqcn);
3076
+ const parentFile = await resolveClassFile(parentFqcn);
3077
+ if (!parentFile) break;
3078
+ let parentContent;
3079
+ try { parentContent = readFileSync(parentFile, 'utf-8'); } catch { break; }
3080
+ const parentMethodStart = parentContent.search(methodRegex);
3081
+ if (parentMethodStart !== -1) {
3082
+ resolvedClass = parentFqcn;
3083
+ resolvedContent = parentContent;
3084
+ resolvedFilePath = parentFile;
3085
+ methodStart = parentMethodStart;
3086
+ found = true;
3087
+ break;
3088
+ }
3089
+ currentContent = parentContent;
3090
+ }
3091
+ if (!found) {
3092
+ result.chain.push({ depth, class: className, method: methodName, file: relativePath, status: 'method_not_found' });
3093
+ return;
3094
+ }
3019
3095
  }
3096
+ content = resolvedContent;
3097
+ const resolvedRelPath = resolvedFilePath.replace(root + '/', '');
3020
3098
 
3021
3099
  let braceCount = 0;
3022
3100
  let bodyStart = content.indexOf('{', methodStart);
@@ -3028,8 +3106,11 @@ async function traceCallChain(startClass, startMethod, maxDepth = 3) {
3028
3106
  }
3029
3107
  const methodBody = content.slice(bodyStart, bodyEnd + 1);
3030
3108
 
3109
+ // Show where method was actually found if inherited
3110
+ const inheritedFrom = (resolvedClass !== className) ? resolvedClass : null;
3031
3111
  const chainEntry = {
3032
3112
  depth, class: className, method: methodName, file: relativePath,
3113
+ ...(inheritedFrom ? { inheritedFrom, resolvedFile: resolvedRelPath } : {}),
3033
3114
  calls: [], dispatches: []
3034
3115
  };
3035
3116
 
@@ -3049,13 +3130,20 @@ async function traceCallChain(startClass, startMethod, maxDepth = 3) {
3049
3130
  while ((dc = depCallRegex.exec(methodBody)) !== null) {
3050
3131
  const property = dc[1];
3051
3132
  const calledMethod = dc[2];
3052
- // Resolve property type from constructor
3053
- const ctorMatch = content.match(/function\s+__construct\s*\(([\s\S]*?)\)\s*[{:]/);
3133
+ // Resolve property type from constructor — check the original class first, then the resolved (parent) class
3054
3134
  let resolvedType = null;
3055
- if (ctorMatch) {
3056
- const paramRegex = new RegExp(`([\\w\\\\]+)\\s+\\$${property}\\b`);
3057
- const pm = ctorMatch[1].match(paramRegex);
3058
- if (pm) resolvedType = pm[1];
3135
+ let originalContent = content;
3136
+ if (resolvedClass !== className) {
3137
+ try { originalContent = readFileSync(filePath, 'utf-8'); } catch { originalContent = content; }
3138
+ }
3139
+ const contentSources = (resolvedClass !== className) ? [originalContent, content] : [content];
3140
+ for (const src of contentSources) {
3141
+ const ctorMatch = src.match(/function\s+__construct\s*\(([\s\S]*?)\)\s*[{:]/);
3142
+ if (ctorMatch) {
3143
+ const paramRegex = new RegExp(`([\\w\\\\]+)\\s+\\$${property}\\b`);
3144
+ const pm = ctorMatch[1].match(paramRegex);
3145
+ if (pm) { resolvedType = pm[1]; break; }
3146
+ }
3059
3147
  }
3060
3148
  chainEntry.calls.push({ type: 'dependency', property, method: calledMethod, typeHint: resolvedType || null });
3061
3149
  }
@@ -3144,6 +3232,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
3144
3232
  description: 'Enable query expansion with Magento domain synonyms for better recall (default: true)',
3145
3233
  default: true
3146
3234
  },
3235
+ precise: {
3236
+ type: 'boolean',
3237
+ description: 'Precise mode for debugging: disables query expansion AND applies strict post-filtering — only returns results where the file content contains at least one query keyword. Use for specific debugging queries like "gift card subtotal infinite loop". Default: false.',
3238
+ default: false
3239
+ },
3147
3240
  },
3148
3241
  required: ['query']
3149
3242
  }
@@ -3759,6 +3852,29 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
3759
3852
  required: ['eventName']
3760
3853
  }
3761
3854
  },
3855
+ {
3856
+ name: 'magento_batch',
3857
+ description: 'Execute multiple Magector tool calls in a single request to reduce MCP round-trip overhead. Each query runs in parallel and returns combined results. Use this when you need 2+ independent lookups (e.g., find a class AND its plugins AND its observers in one call instead of three).',
3858
+ inputSchema: {
3859
+ type: 'object',
3860
+ properties: {
3861
+ queries: {
3862
+ type: 'array',
3863
+ description: 'Array of tool calls to execute. Each entry has a "tool" name and "args" object.',
3864
+ items: {
3865
+ type: 'object',
3866
+ properties: {
3867
+ tool: { type: 'string', description: 'Magector tool name (e.g., "magento_find_class", "magento_find_plugin")' },
3868
+ args: { type: 'object', description: 'Arguments for the tool call' }
3869
+ },
3870
+ required: ['tool', 'args']
3871
+ },
3872
+ maxItems: 10
3873
+ }
3874
+ },
3875
+ required: ['queries']
3876
+ }
3877
+ },
3762
3878
  ]
3763
3879
  }));
3764
3880
 
@@ -3805,12 +3921,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3805
3921
  try {
3806
3922
  switch (name) {
3807
3923
  case 'magento_search': {
3808
- const searchQuery = args.expand !== false ? expandQuery(args.query) : args.query;
3809
- const raw = await rustSearchAsync(searchQuery, Math.max(args.limit || 10, 30));
3924
+ const precise = args.precise === true;
3925
+ const searchQuery = (args.expand !== false && !precise) ? expandQuery(args.query) : args.query;
3926
+ const raw = await rustSearchAsync(searchQuery, Math.max(args.limit || 10, precise ? 60 : 30));
3810
3927
  const arr = Array.isArray(raw) ? raw : [];
3811
3928
  let results = arr.map(normalizeResult);
3812
3929
  // Hybrid BM25 rerank for better exact-match handling
3813
3930
  results = hybridRerank(results, args.query);
3931
+ // Precise mode: strict post-filter — result must contain at least one significant query keyword
3932
+ if (precise) {
3933
+ const queryTerms = args.query.toLowerCase().split(/\s+/).filter(t => t.length > 2);
3934
+ results = results.filter(r => {
3935
+ const haystack = [r.searchText, r.className, r.methodName, r.path, ...(r.methods || [])]
3936
+ .filter(Boolean).join(' ').toLowerCase();
3937
+ return queryTerms.some(term => haystack.includes(term));
3938
+ });
3939
+ }
3814
3940
  // Apply module filter if specified
3815
3941
  if (args.moduleFilter) {
3816
3942
  results = filterByModule(results, args.moduleFilter);
@@ -4850,6 +4976,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4850
4976
  text += '\n';
4851
4977
  }
4852
4978
 
4979
+ if (impact.runtimeCallers.length > 0) {
4980
+ text += `### Runtime Callers (${impact.runtimeCallers.length})\n`;
4981
+ text += '_Classes that inject this class and call its methods at runtime:_\n';
4982
+ // Group by caller class for readability
4983
+ const grouped = new Map();
4984
+ for (const c of impact.runtimeCallers) {
4985
+ const key = c.callerClass || c.file;
4986
+ if (!grouped.has(key)) grouped.set(key, { file: c.file, methods: new Set() });
4987
+ grouped.get(key).methods.add(`$this->${c.property}->${c.calledMethod}()`);
4988
+ }
4989
+ for (const [cls, info] of grouped) {
4990
+ text += `- **\`${cls}\`** (\`${info.file}\`)\n`;
4991
+ for (const m of info.methods) {
4992
+ text += ` - \`${m}\`\n`;
4993
+ }
4994
+ }
4995
+ text += '\n';
4996
+ }
4997
+
4853
4998
  if (impact.total === 0) {
4854
4999
  text += '_No references found. The class may be referenced under a different name or alias._\n';
4855
5000
  }
@@ -5057,6 +5202,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5057
5202
  const status = entry.status ? ` [${entry.status}]` : '';
5058
5203
  text += `${indent}- **${entry.class}::${entry.method}**${status}`;
5059
5204
  if (entry.file) text += ` (${entry.file})`;
5205
+ if (entry.inheritedFrom) text += `\n${indent} _inherited from_ \`${entry.inheritedFrom}\` (${entry.resolvedFile})`;
5060
5206
  text += '\n';
5061
5207
 
5062
5208
  if (entry.calls) {
@@ -5170,6 +5316,100 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5170
5316
  return { content: [{ type: 'text', text }] };
5171
5317
  }
5172
5318
 
5319
+ case 'magento_batch': {
5320
+ const queries = args.queries || [];
5321
+ if (queries.length === 0) {
5322
+ return { content: [{ type: 'text', text: 'No queries provided.' }] };
5323
+ }
5324
+ if (queries.length > 10) {
5325
+ return { content: [{ type: 'text', text: 'Maximum 10 queries per batch.' }], isError: true };
5326
+ }
5327
+ // Run batch queries in parallel using existing standalone functions
5328
+ const batchResults = await Promise.all(queries.map(async (q, idx) => {
5329
+ try {
5330
+ const a = q.args || {};
5331
+ let text = '';
5332
+ switch (q.tool) {
5333
+ case 'magento_find_class': {
5334
+ const ns = a.namespace || '';
5335
+ const qr = `${a.className} ${ns}`.trim();
5336
+ const raw = await rustSearchAsync(qr, 30);
5337
+ const cl = a.className.toLowerCase();
5338
+ const res = raw.map(normalizeResult).filter(r =>
5339
+ r.className?.toLowerCase().includes(cl) || r.path?.toLowerCase().includes(cl.replace(/\\/g, '/'))
5340
+ );
5341
+ text = formatSearchResults(res.slice(0, 5));
5342
+ break;
5343
+ }
5344
+ case 'magento_find_plugin': {
5345
+ const qr = `plugin interceptor ${a.targetClass || ''} ${a.targetMethod || ''}`.trim();
5346
+ const raw = await rustSearchAsync(qr, 30);
5347
+ const res = raw.map(normalizeResult).filter(r =>
5348
+ r.magentoType === 'plugin' || r.path?.toLowerCase().includes('plugin')
5349
+ );
5350
+ text = formatSearchResults(res.slice(0, 10));
5351
+ break;
5352
+ }
5353
+ case 'magento_find_observer': {
5354
+ const flow = await traceEventFlow(a.eventName);
5355
+ text = `Observers: ${flow.observers.length}\n`;
5356
+ for (const o of flow.observers.slice(0, 10)) {
5357
+ text += `- ${o.name}: ${o.instance}::${o.method} (${o.file})\n`;
5358
+ }
5359
+ break;
5360
+ }
5361
+ case 'magento_trace_dependency': {
5362
+ const dep = await traceDependency(a.className, a.direction || 'both');
5363
+ text = `Preferences: ${dep.preferences.length}, Plugins: ${dep.plugins.length}, VirtualTypes: ${dep.virtualTypes.length}, Args: ${dep.argumentOverrides.length}\n`;
5364
+ for (const p of dep.plugins.slice(0, 5)) text += `- plugin: ${p.pluginName} → ${p.pluginClass}\n`;
5365
+ for (const p of dep.preferences.slice(0, 5)) text += `- pref: ${p.for} → ${p.type}\n`;
5366
+ break;
5367
+ }
5368
+ case 'magento_impact_analysis': {
5369
+ const imp = await analyzeImpact(a.className);
5370
+ text = `Total: ${imp.total} (use: ${imp.useStatements.length}, di: ${imp.diXmlReferences.length}, new: ${imp.instantiations.length}, type: ${imp.typeHints.length}, runtime: ${imp.runtimeCallers.length})\n`;
5371
+ for (const c of imp.runtimeCallers.slice(0, 5)) text += `- ${c.callerClass}: $this->${c.property}->${c.calledMethod}()\n`;
5372
+ break;
5373
+ }
5374
+ case 'magento_search': {
5375
+ const precise = a.precise === true;
5376
+ const sq = (a.expand !== false && !precise) ? expandQuery(a.query) : a.query;
5377
+ const raw = await rustSearchAsync(sq, 30);
5378
+ let res = raw.map(normalizeResult);
5379
+ res = hybridRerank(res, a.query);
5380
+ text = formatSearchResults(res.slice(0, a.limit || 5));
5381
+ break;
5382
+ }
5383
+ case 'magento_find_callers': {
5384
+ const callers = await findCallers(a.methodName, a.className);
5385
+ text = `Callers: ${callers.callers?.length || 0}\n`;
5386
+ for (const c of (callers.callers || []).slice(0, 10)) {
5387
+ text += `- ${c.class}::${c.method} (${c.path}:${c.line})\n`;
5388
+ }
5389
+ break;
5390
+ }
5391
+ case 'magento_find_event_flow': {
5392
+ const flow = await traceEventFlow(a.eventName);
5393
+ text = `Dispatchers: ${flow.dispatchers.length}, Observers: ${flow.observers.length}\n`;
5394
+ for (const o of flow.observers.slice(0, 10)) text += `- ${o.name}: ${o.instance} (${o.file})\n`;
5395
+ break;
5396
+ }
5397
+ default:
5398
+ text = `Unsupported batch tool: ${q.tool}`;
5399
+ }
5400
+ return { idx, tool: q.tool, text };
5401
+ } catch (err) {
5402
+ return { idx, tool: q.tool, text: `Error: ${err.message}` };
5403
+ }
5404
+ }));
5405
+
5406
+ let text = `## Batch Results (${batchResults.length} queries)\n\n`;
5407
+ for (const br of batchResults) {
5408
+ text += `---\n### [${br.idx + 1}] ${br.tool}\n\n${br.text}\n\n`;
5409
+ }
5410
+ return { content: [{ type: 'text', text }] };
5411
+ }
5412
+
5173
5413
  default:
5174
5414
  return {
5175
5415
  content: [{