magector 2.5.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +5 -5
  2. package/src/mcp-server.js +174 -35
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magector",
3
- "version": "2.5.1",
3
+ "version": "2.6.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",
@@ -33,10 +33,10 @@
33
33
  "ruvector": "^0.1.96"
34
34
  },
35
35
  "optionalDependencies": {
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"
36
+ "@magector/cli-darwin-arm64": "2.6.0",
37
+ "@magector/cli-linux-x64": "2.6.0",
38
+ "@magector/cli-linux-arm64": "2.6.0",
39
+ "@magector/cli-win32-x64": "2.6.0"
40
40
  },
41
41
  "keywords": [
42
42
  "magento",
package/src/mcp-server.js CHANGED
@@ -1004,6 +1004,41 @@ function readMethodSnippet(filePath, methodName, maxLines = 15) {
1004
1004
  return null;
1005
1005
  }
1006
1006
 
1007
+ /**
1008
+ * Read the FULL method body using brace-counting to find the closing brace.
1009
+ * Unlike readMethodSnippet (fixed N lines), this extracts the complete method
1010
+ * regardless of length, up to a safety limit.
1011
+ * @param {string} filePath - absolute or relative PHP file path
1012
+ * @param {string} methodName - method name to find
1013
+ * @param {number} maxLines - safety limit to prevent reading huge methods (default: 60)
1014
+ * @returns {string|null} complete method source or null if not found
1015
+ */
1016
+ function readFullMethodBody(filePath, methodName, maxLines = 60) {
1017
+ const absPath = filePath.startsWith('/') ? filePath : path.join(config.magentoRoot, filePath);
1018
+ let content;
1019
+ try { content = readFileSync(absPath, 'utf-8'); } catch { return null; }
1020
+ const lines = content.split('\n');
1021
+ for (let i = 0; i < lines.length; i++) {
1022
+ if (lines[i].includes(`function ${methodName}(`)) {
1023
+ // Use brace counting to find the complete method body
1024
+ let braceCount = 0;
1025
+ let started = false;
1026
+ for (let j = i; j < lines.length && j < i + maxLines; j++) {
1027
+ for (const ch of lines[j]) {
1028
+ if (ch === '{') { braceCount++; started = true; }
1029
+ if (ch === '}') braceCount--;
1030
+ }
1031
+ if (started && braceCount <= 0) {
1032
+ return lines.slice(i, j + 1).join('\n');
1033
+ }
1034
+ }
1035
+ // Safety: if closing brace not found within maxLines, return what we have
1036
+ return lines.slice(i, Math.min(i + maxLines, lines.length)).join('\n');
1037
+ }
1038
+ }
1039
+ return null;
1040
+ }
1041
+
1007
1042
  /**
1008
1043
  * Parse all fieldset.xml files in the Magento root.
1009
1044
  * Returns array of { file, scope, fieldset, fields: [{ field, aspect }] }.
@@ -1561,15 +1596,23 @@ function formatSearchResults(results) {
1561
1596
  if (r.isBlock) badges.push('block');
1562
1597
  if (badges.length > 0) entry.badges = badges;
1563
1598
 
1599
+ // Only include verbose content (snippet, codePreview) for top-ranked results
1600
+ // to reduce token consumption — lower-ranked results just show metadata
1601
+ const isTopRanked = i < 3;
1602
+
1564
1603
  // Snippet — first 300 chars of indexed content for quick assessment
1565
- if (r.searchText) {
1604
+ if (isTopRanked && r.searchText) {
1566
1605
  entry.snippet = r.searchText.length > 300
1567
1606
  ? r.searchText.slice(0, 300) + '...'
1568
1607
  : r.searchText;
1569
1608
  }
1570
1609
 
1610
+ // Full method body — attached by magento_find_method for complete understanding
1611
+ if (r.fullMethodBody) {
1612
+ entry.codePreview = r.fullMethodBody;
1613
+ }
1571
1614
  // Code preview — read actual source lines for PHP files with known class/method
1572
- if (r.path && (r.path.endsWith('.php') || r.path.endsWith('.phtml'))) {
1615
+ else if (isTopRanked && r.path && (r.path.endsWith('.php') || r.path.endsWith('.phtml'))) {
1573
1616
  if (r.methodName) {
1574
1617
  const preview = readMethodSnippet(r.path, r.methodName, 10);
1575
1618
  if (preview) entry.codePreview = preview;
@@ -1600,6 +1643,47 @@ function formatSearchResults(results) {
1600
1643
  return JSON.stringify({ results: formatted, count: formatted.length });
1601
1644
  }
1602
1645
 
1646
+ // ─── DI XML Session Cache ─────────────────────────────────────
1647
+
1648
+ /**
1649
+ * Session-level cache for parsed di.xml file contents.
1650
+ * Avoids re-reading and re-globbing di.xml files across multiple tool calls
1651
+ * (findDiWiring, traceDependency, magento_find_plugin all scan di.xml).
1652
+ */
1653
+ const diXmlCache = {
1654
+ /** @type {Map<string, string>} path → file content */
1655
+ files: new Map(),
1656
+ /** @type {string[]|null} cached list of all di.xml absolute paths */
1657
+ paths: null,
1658
+ /** @type {string|null} root used for caching (invalidate if root changes) */
1659
+ root: null
1660
+ };
1661
+
1662
+ /**
1663
+ * Get all di.xml file paths and their contents, using session cache.
1664
+ * @param {string} root - Magento root path
1665
+ * @returns {Promise<Array<{absPath: string, relPath: string, content: string}>>}
1666
+ */
1667
+ async function getDiXmlFiles(root) {
1668
+ if (diXmlCache.root !== root || !diXmlCache.paths) {
1669
+ diXmlCache.root = root;
1670
+ diXmlCache.paths = await glob('**/etc/**/di.xml', { cwd: root, absolute: true, nodir: true });
1671
+ diXmlCache.files.clear();
1672
+ }
1673
+ const results = [];
1674
+ for (const absPath of diXmlCache.paths) {
1675
+ let content = diXmlCache.files.get(absPath);
1676
+ if (content === undefined) {
1677
+ try { content = readFileSync(absPath, 'utf-8'); } catch { content = null; }
1678
+ diXmlCache.files.set(absPath, content);
1679
+ }
1680
+ if (content !== null) {
1681
+ results.push({ absPath, relPath: absPath.replace(root + '/', ''), content });
1682
+ }
1683
+ }
1684
+ return results;
1685
+ }
1686
+
1603
1687
  // ─── DI Dependency Tracing ─────────────────────────────────────
1604
1688
 
1605
1689
  /**
@@ -1608,7 +1692,7 @@ function formatSearchResults(results) {
1608
1692
  */
1609
1693
  async function traceDependency(className, direction = 'both') {
1610
1694
  const root = config.magentoRoot;
1611
- const diFiles = await glob('**/etc/**/di.xml', { cwd: root, absolute: true, nodir: true });
1695
+ const diFiles = await getDiXmlFiles(root);
1612
1696
  const classLower = className.toLowerCase();
1613
1697
  const classShort = className.split('\\').pop().toLowerCase();
1614
1698
 
@@ -1621,13 +1705,7 @@ async function traceDependency(className, direction = 'both') {
1621
1705
  totalDiFiles: diFiles.length
1622
1706
  };
1623
1707
 
1624
- for (const diFile of diFiles) {
1625
- let content;
1626
- try {
1627
- content = readFileSync(diFile, 'utf-8');
1628
- } catch { continue; }
1629
-
1630
- const relativePath = diFile.replace(root + '/', '');
1708
+ for (const { content, relPath: relativePath } of diFiles) {
1631
1709
 
1632
1710
  if (direction === 'resolve' || direction === 'both') {
1633
1711
  // Find preferences: <preference for="ClassName" type="Implementation"/>
@@ -2553,6 +2631,21 @@ async function findDiWiring(className) {
2553
2631
  const root = config.magentoRoot;
2554
2632
  const shortName = className.split('\\').pop();
2555
2633
  const shortLower = shortName.toLowerCase();
2634
+ // When a FQCN is provided (contains \), use it for precise matching
2635
+ const hasFqcn = className.includes('\\');
2636
+ // Normalize FQCN for XML matching: di.xml uses backslash-escaped names
2637
+ const fqcnNormalized = hasFqcn ? className.replace(/\\\\/g, '\\') : null;
2638
+ const fqcnLower = fqcnNormalized ? fqcnNormalized.toLowerCase() : null;
2639
+
2640
+ // Helper: check if a di.xml class name matches the requested class.
2641
+ // When FQCN is available, require full namespace match; otherwise fall back to short name.
2642
+ function matchesClass(xmlClassName) {
2643
+ const xmlLower = xmlClassName.toLowerCase().replace(/\\\\/g, '\\');
2644
+ if (fqcnLower) {
2645
+ return xmlLower === fqcnLower || xmlLower.endsWith('\\' + fqcnLower);
2646
+ }
2647
+ return xmlLower.includes(shortLower);
2648
+ }
2556
2649
 
2557
2650
  const result = {
2558
2651
  className,
@@ -2564,24 +2657,20 @@ async function findDiWiring(className) {
2564
2657
  totalDiFiles: 0
2565
2658
  };
2566
2659
 
2567
- const diFiles = await glob('**/etc/**/di.xml', { cwd: root, absolute: true, nodir: true });
2660
+ const diFiles = await getDiXmlFiles(root);
2568
2661
  result.totalDiFiles = diFiles.length;
2569
2662
 
2570
- for (const diFile of diFiles) {
2571
- let content;
2572
- try { content = readFileSync(diFile, 'utf-8'); } catch { continue; }
2573
- const relativePath = diFile.replace(root + '/', '');
2663
+ for (const { content, relPath: relativePath } of diFiles) {
2574
2664
  const contentLower = content.toLowerCase();
2575
2665
 
2666
+ // Quick pre-filter: skip files that don't contain the short name at all
2576
2667
  if (!contentLower.includes(shortLower)) continue;
2577
2668
 
2578
2669
  // 1. Preferences where this class is the "for" or "type"
2579
2670
  const prefRegex = /<preference\s+for="([^"]+)"\s+type="([^"]+)"\s*\/?>/g;
2580
2671
  let m;
2581
2672
  while ((m = prefRegex.exec(content)) !== null) {
2582
- const forLower = m[1].toLowerCase();
2583
- const typeLower = m[2].toLowerCase();
2584
- if (forLower.includes(shortLower) || typeLower.includes(shortLower)) {
2673
+ if (matchesClass(m[1]) || matchesClass(m[2])) {
2585
2674
  result.preferences.push({ for: m[1], type: m[2], file: relativePath });
2586
2675
  }
2587
2676
  }
@@ -2591,10 +2680,9 @@ async function findDiWiring(className) {
2591
2680
  let typeMatch;
2592
2681
  while ((typeMatch = typeBlockRegex.exec(content)) !== null) {
2593
2682
  const typeName = typeMatch[1];
2594
- const typeNameLower = typeName.toLowerCase();
2595
2683
  const typeBlock = typeMatch[2];
2596
2684
 
2597
- if (!typeNameLower.includes(shortLower)) continue;
2685
+ if (!matchesClass(typeName)) continue;
2598
2686
 
2599
2687
  // Plugins
2600
2688
  const pluginRegex = /<plugin\s+name="([^"]+)"[^>]*type="([^"]+)"[^>]*/g;
@@ -2641,7 +2729,7 @@ async function findDiWiring(className) {
2641
2729
  const vtRegex = /<virtualType\s+name="([^"]+)"[^>]*type="([^"]+)"[^>]*(?:\/>|>([\s\S]*?)<\/virtualType>)/g;
2642
2730
  while ((m = vtRegex.exec(content)) !== null) {
2643
2731
  const vtType = m[2];
2644
- if (!vtType.toLowerCase().includes(shortLower)) continue;
2732
+ if (!matchesClass(vtType)) continue;
2645
2733
  const vtEntry = { name: m[1], type: vtType, file: relativePath };
2646
2734
  if (m[3]) {
2647
2735
  const argRegex = /<argument\s+name="([^"]+)"[^>]*xsi:type="([^"]+)"[^>]*>([^<]*)<\/argument>/g;
@@ -2657,6 +2745,7 @@ async function findDiWiring(className) {
2657
2745
  }
2658
2746
 
2659
2747
  // 4. Constructor arguments from PHP class
2748
+ // When FQCN is provided, verify namespace matches to avoid class name collisions
2660
2749
  const phpFiles = await glob(`**/${shortName}.php`, { cwd: root, absolute: true, nodir: true });
2661
2750
  for (const phpFile of phpFiles) {
2662
2751
  let content;
@@ -2664,6 +2753,17 @@ async function findDiWiring(className) {
2664
2753
  const classMatch = content.match(/(?:class|abstract\s+class)\s+(\w+)/);
2665
2754
  if (!classMatch || classMatch[1] !== shortName) continue;
2666
2755
 
2756
+ // FQCN namespace verification: when a full class name was provided,
2757
+ // check that the PHP file's namespace matches to avoid collisions
2758
+ // (e.g., two different ViewPlugin classes in different modules)
2759
+ if (fqcnNormalized) {
2760
+ const nsMatch = content.match(/namespace\s+([\w\\]+)\s*;/);
2761
+ if (nsMatch) {
2762
+ const fileFqcn = (nsMatch[1] + '\\' + shortName).toLowerCase();
2763
+ if (fileFqcn !== fqcnLower) continue; // Wrong class, skip
2764
+ }
2765
+ }
2766
+
2667
2767
  const ctorMatch = content.match(/function\s+__construct\s*\(([\s\S]*?)\)\s*[{:]/);
2668
2768
  if (ctorMatch) {
2669
2769
  const paramRegex = /(?:([\w\\]+)\s+)?(\$\w+)/g;
@@ -2672,6 +2772,7 @@ async function findDiWiring(className) {
2672
2772
  result.constructorArguments.push({ typeHint: pm[1] || null, variable: pm[2] });
2673
2773
  }
2674
2774
  }
2775
+ result.constructorSourceFile = phpFile.replace(root + '/', '');
2675
2776
  break;
2676
2777
  }
2677
2778
 
@@ -3946,7 +4047,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3946
4047
  return {
3947
4048
  content: [{
3948
4049
  type: 'text',
3949
- text: formatSearchResults(results.slice(0, args.limit || 10))
4050
+ text: formatSearchResults(results.slice(0, args.limit || 5))
3950
4051
  }]
3951
4052
  };
3952
4053
  }
@@ -3963,7 +4064,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3963
4064
  return {
3964
4065
  content: [{
3965
4066
  type: 'text',
3966
- text: formatSearchResults(results.slice(0, 5))
4067
+ text: formatSearchResults(results.slice(0, 3))
3967
4068
  }]
3968
4069
  };
3969
4070
  }
@@ -3992,10 +4093,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3992
4093
  if (r.methodName?.toLowerCase() === methodLower) bonus += 0.3;
3993
4094
  return { ...r, score: (r.score || 0) + bonus };
3994
4095
  }).sort((a, b) => b.score - a.score);
4096
+ // Attach full method body to each result for complete understanding
4097
+ const sliced = results.slice(0, 5);
4098
+ for (const r of sliced) {
4099
+ if (r.path && r.path.endsWith('.php')) {
4100
+ const body = readFullMethodBody(r.path, args.methodName);
4101
+ if (body) r.fullMethodBody = body;
4102
+ }
4103
+ }
3995
4104
  return {
3996
4105
  content: [{
3997
4106
  type: 'text',
3998
- text: formatSearchResults(results.slice(0, 10))
4107
+ text: formatSearchResults(sliced)
3999
4108
  }]
4000
4109
  };
4001
4110
  }
@@ -4093,18 +4202,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4093
4202
  return { ...r, diArea };
4094
4203
  });
4095
4204
 
4096
- // If targetClass provided, also scan di.xml for explicit registrations
4205
+ // If targetClass provided, also scan di.xml for explicit registrations (using session cache)
4097
4206
  let diRegistrations = [];
4098
4207
  if (args.targetClass) {
4099
4208
  const fpRoot = config.magentoRoot;
4100
- const diFiles = await glob('**/etc/**/di.xml', { cwd: fpRoot, absolute: true, nodir: true });
4209
+ const diFiles = await getDiXmlFiles(fpRoot);
4101
4210
  // Normalize target class for matching (both \ and \\)
4102
4211
  const normalizedTarget = args.targetClass.replace(/\\\\/g, '\\');
4103
- for (const diFile of diFiles) {
4104
- let content;
4105
- try { content = readFileSync(diFile, 'utf-8'); } catch { continue; }
4212
+ for (const { content, relPath } of diFiles) {
4106
4213
  if (!content.includes(normalizedTarget)) continue;
4107
- const relPath = diFile.replace(fpRoot + '/', '');
4108
4214
  // Find plugin registrations for this target
4109
4215
  const typeBlockRegex = /<type\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/type>/g;
4110
4216
  let tm;
@@ -4139,14 +4245,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4139
4245
  }
4140
4246
  }
4141
4247
 
4142
- // Resolve plugin methods for DI registrations
4143
- const fpRoot = args.targetClass ? config.magentoRoot : null;
4248
+ // Resolve plugin methods + method bodies for DI registrations
4249
+ const fpRoot2 = args.targetClass ? config.magentoRoot : null;
4144
4250
  for (const reg of diRegistrations) {
4145
- if (reg.pluginClass && fpRoot) {
4146
- const pluginFile = findClassFile(fpRoot, reg.pluginClass);
4251
+ if (reg.pluginClass && fpRoot2) {
4252
+ const pluginFile = findClassFile(fpRoot2, reg.pluginClass);
4147
4253
  if (pluginFile) {
4148
4254
  reg.methods = extractPluginMethods(pluginFile);
4149
- reg.resolvedFile = pluginFile.replace(fpRoot + '/', '');
4255
+ reg.resolvedFile = pluginFile.replace(fpRoot2 + '/', '');
4256
+ // Read full method bodies so the agent sees actual code without follow-up calls
4257
+ for (const m of reg.methods) {
4258
+ const body = readFullMethodBody(pluginFile, m.name);
4259
+ if (body) m.body = body;
4260
+ }
4150
4261
  }
4151
4262
  }
4152
4263
  }
@@ -4164,6 +4275,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4164
4275
  if (reg.methods?.length > 0) {
4165
4276
  for (const m of reg.methods) {
4166
4277
  text += ` - \`${m.type}\` **${m.targetMethod}** → \`${m.name}()\`\n`;
4278
+ if (m.body) {
4279
+ const indentedBody = m.body.split('\n').join('\n ');
4280
+ text += ' ' + '```php\n ' + indentedBody + '\n ' + '```\n';
4281
+ }
4167
4282
  }
4168
4283
  }
4169
4284
  }
@@ -5394,6 +5509,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5394
5509
  for (const o of flow.observers.slice(0, 10)) text += `- ${o.name}: ${o.instance} (${o.file})\n`;
5395
5510
  break;
5396
5511
  }
5512
+ case 'magento_find_di_wiring': {
5513
+ const wiring = await findDiWiring(a.className);
5514
+ text = `Prefs: ${wiring.preferences.length}, Plugins: ${wiring.plugins.length}, VTs: ${wiring.virtualTypes.length}, Ctor: ${wiring.constructorArguments.length}\n`;
5515
+ for (const p of wiring.plugins.slice(0, 5)) text += `- plugin: ${p.pluginName} → ${p.pluginClass}\n`;
5516
+ for (const c of wiring.constructorArguments.slice(0, 10)) text += `- ctor: ${c.typeHint || '?'} ${c.variable}\n`;
5517
+ if (wiring.constructorSourceFile) text += `Source: ${wiring.constructorSourceFile}\n`;
5518
+ break;
5519
+ }
5520
+ case 'magento_find_method': {
5521
+ const qr = `method ${a.methodName} function ${a.className || ''}`.trim();
5522
+ const raw = await rustSearchAsync(qr, 50);
5523
+ const ml = a.methodName.toLowerCase();
5524
+ let res = raw.map(normalizeResult).filter(r =>
5525
+ r.methodName?.toLowerCase() === ml || r.methods?.some(m => m.toLowerCase() === ml)
5526
+ );
5527
+ for (const r of res.slice(0, 5)) {
5528
+ if (r.path?.endsWith('.php')) {
5529
+ const body = readFullMethodBody(r.path, a.methodName);
5530
+ if (body) r.fullMethodBody = body;
5531
+ }
5532
+ }
5533
+ text = formatSearchResults(res.slice(0, 5));
5534
+ break;
5535
+ }
5397
5536
  default:
5398
5537
  text = `Unsupported batch tool: ${q.tool}`;
5399
5538
  }