magector 2.5.2 → 2.6.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 +167 -47
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magector",
3
- "version": "2.5.2",
3
+ "version": "2.6.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.2",
37
- "@magector/cli-linux-x64": "2.5.2",
38
- "@magector/cli-linux-arm64": "2.5.2",
39
- "@magector/cli-win32-x64": "2.5.2"
36
+ "@magector/cli-darwin-arm64": "2.6.1",
37
+ "@magector/cli-linux-x64": "2.6.1",
38
+ "@magector/cli-linux-arm64": "2.6.1",
39
+ "@magector/cli-win32-x64": "2.6.1"
40
40
  },
41
41
  "keywords": [
42
42
  "magento",
package/src/mcp-server.js CHANGED
@@ -202,7 +202,16 @@ function releasePrimaryLock() {
202
202
  * Write the serve process PID to disk so future instances can clean up orphans.
203
203
  */
204
204
  function writePidFile(pid) {
205
- try { writeFileSync(PID_PATH, String(pid)); } catch {}
205
+ try { writeFileSync(PID_PATH, `${pid}\n${__pkg.version}`); } catch {}
206
+ }
207
+
208
+ function getServePidVersion() {
209
+ try {
210
+ if (!existsSync(PID_PATH)) return null;
211
+ const content = readFileSync(PID_PATH, 'utf-8').trim();
212
+ const lines = content.split('\n');
213
+ return lines[1] || null;
214
+ } catch { return null; }
206
215
  }
207
216
 
208
217
  function removePidFile() {
@@ -809,20 +818,29 @@ async function rustSearchAsync(query, limit = 10) {
809
818
  if (queryFn) {
810
819
  try {
811
820
  const resp = await queryFn('search', { query, limit });
812
- if (resp.ok && Array.isArray(resp.data)) {
821
+ if (resp.ok && Array.isArray(resp.data) && resp.data.length > 0) {
813
822
  cacheSet(cacheKey, resp.data);
814
823
  return resp.data;
815
824
  }
825
+ // Serve returned empty results — fall through to execFileSync
826
+ // This catches stale serve processes with wrong/empty index
827
+ if (resp.ok && Array.isArray(resp.data) && resp.data.length === 0) {
828
+ logToFile('WARN', `Serve returned 0 results for "${query}" — trying execFileSync fallback`);
829
+ }
816
830
  } catch (err) {
817
831
  logToFile('WARN', `Serve query failed, falling back to execFileSync: ${err.message}`);
818
832
  }
819
833
  }
820
834
 
821
- // Fallback: cold-start execFileSync
835
+ // Fallback: cold-start execFileSync (always works if CLI works)
822
836
  logToFile('INFO', `Using execFileSync fallback for search: "${query}"`);
823
837
  try {
824
838
  const result = rustSearchSync(query, limit);
825
- return Array.isArray(result) ? result : [];
839
+ const arr = Array.isArray(result) ? result : [];
840
+ if (arr.length > 0) {
841
+ cacheSet(cacheKey, arr);
842
+ }
843
+ return arr;
826
844
  } catch (err) {
827
845
  logToFile('WARN', `execFileSync fallback failed: ${err.message}`);
828
846
  return [];
@@ -1596,8 +1614,12 @@ function formatSearchResults(results) {
1596
1614
  if (r.isBlock) badges.push('block');
1597
1615
  if (badges.length > 0) entry.badges = badges;
1598
1616
 
1617
+ // Only include verbose content (snippet, codePreview) for top-ranked results
1618
+ // to reduce token consumption — lower-ranked results just show metadata
1619
+ const isTopRanked = i < 3;
1620
+
1599
1621
  // Snippet — first 300 chars of indexed content for quick assessment
1600
- if (r.searchText) {
1622
+ if (isTopRanked && r.searchText) {
1601
1623
  entry.snippet = r.searchText.length > 300
1602
1624
  ? r.searchText.slice(0, 300) + '...'
1603
1625
  : r.searchText;
@@ -1608,7 +1630,7 @@ function formatSearchResults(results) {
1608
1630
  entry.codePreview = r.fullMethodBody;
1609
1631
  }
1610
1632
  // Code preview — read actual source lines for PHP files with known class/method
1611
- else if (r.path && (r.path.endsWith('.php') || r.path.endsWith('.phtml'))) {
1633
+ else if (isTopRanked && r.path && (r.path.endsWith('.php') || r.path.endsWith('.phtml'))) {
1612
1634
  if (r.methodName) {
1613
1635
  const preview = readMethodSnippet(r.path, r.methodName, 10);
1614
1636
  if (preview) entry.codePreview = preview;
@@ -1639,6 +1661,47 @@ function formatSearchResults(results) {
1639
1661
  return JSON.stringify({ results: formatted, count: formatted.length });
1640
1662
  }
1641
1663
 
1664
+ // ─── DI XML Session Cache ─────────────────────────────────────
1665
+
1666
+ /**
1667
+ * Session-level cache for parsed di.xml file contents.
1668
+ * Avoids re-reading and re-globbing di.xml files across multiple tool calls
1669
+ * (findDiWiring, traceDependency, magento_find_plugin all scan di.xml).
1670
+ */
1671
+ const diXmlCache = {
1672
+ /** @type {Map<string, string>} path → file content */
1673
+ files: new Map(),
1674
+ /** @type {string[]|null} cached list of all di.xml absolute paths */
1675
+ paths: null,
1676
+ /** @type {string|null} root used for caching (invalidate if root changes) */
1677
+ root: null
1678
+ };
1679
+
1680
+ /**
1681
+ * Get all di.xml file paths and their contents, using session cache.
1682
+ * @param {string} root - Magento root path
1683
+ * @returns {Promise<Array<{absPath: string, relPath: string, content: string}>>}
1684
+ */
1685
+ async function getDiXmlFiles(root) {
1686
+ if (diXmlCache.root !== root || !diXmlCache.paths) {
1687
+ diXmlCache.root = root;
1688
+ diXmlCache.paths = await glob('**/etc/**/di.xml', { cwd: root, absolute: true, nodir: true });
1689
+ diXmlCache.files.clear();
1690
+ }
1691
+ const results = [];
1692
+ for (const absPath of diXmlCache.paths) {
1693
+ let content = diXmlCache.files.get(absPath);
1694
+ if (content === undefined) {
1695
+ try { content = readFileSync(absPath, 'utf-8'); } catch { content = null; }
1696
+ diXmlCache.files.set(absPath, content);
1697
+ }
1698
+ if (content !== null) {
1699
+ results.push({ absPath, relPath: absPath.replace(root + '/', ''), content });
1700
+ }
1701
+ }
1702
+ return results;
1703
+ }
1704
+
1642
1705
  // ─── DI Dependency Tracing ─────────────────────────────────────
1643
1706
 
1644
1707
  /**
@@ -1647,7 +1710,7 @@ function formatSearchResults(results) {
1647
1710
  */
1648
1711
  async function traceDependency(className, direction = 'both') {
1649
1712
  const root = config.magentoRoot;
1650
- const diFiles = await glob('**/etc/**/di.xml', { cwd: root, absolute: true, nodir: true });
1713
+ const diFiles = await getDiXmlFiles(root);
1651
1714
  const classLower = className.toLowerCase();
1652
1715
  const classShort = className.split('\\').pop().toLowerCase();
1653
1716
 
@@ -1660,13 +1723,7 @@ async function traceDependency(className, direction = 'both') {
1660
1723
  totalDiFiles: diFiles.length
1661
1724
  };
1662
1725
 
1663
- for (const diFile of diFiles) {
1664
- let content;
1665
- try {
1666
- content = readFileSync(diFile, 'utf-8');
1667
- } catch { continue; }
1668
-
1669
- const relativePath = diFile.replace(root + '/', '');
1726
+ for (const { content, relPath: relativePath } of diFiles) {
1670
1727
 
1671
1728
  if (direction === 'resolve' || direction === 'both') {
1672
1729
  // Find preferences: <preference for="ClassName" type="Implementation"/>
@@ -2618,13 +2675,10 @@ async function findDiWiring(className) {
2618
2675
  totalDiFiles: 0
2619
2676
  };
2620
2677
 
2621
- const diFiles = await glob('**/etc/**/di.xml', { cwd: root, absolute: true, nodir: true });
2678
+ const diFiles = await getDiXmlFiles(root);
2622
2679
  result.totalDiFiles = diFiles.length;
2623
2680
 
2624
- for (const diFile of diFiles) {
2625
- let content;
2626
- try { content = readFileSync(diFile, 'utf-8'); } catch { continue; }
2627
- const relativePath = diFile.replace(root + '/', '');
2681
+ for (const { content, relPath: relativePath } of diFiles) {
2628
2682
  const contentLower = content.toLowerCase();
2629
2683
 
2630
2684
  // Quick pre-filter: skip files that don't contain the short name at all
@@ -4011,7 +4065,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4011
4065
  return {
4012
4066
  content: [{
4013
4067
  type: 'text',
4014
- text: formatSearchResults(results.slice(0, args.limit || 10))
4068
+ text: formatSearchResults(results.slice(0, args.limit || 5))
4015
4069
  }]
4016
4070
  };
4017
4071
  }
@@ -4028,7 +4082,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4028
4082
  return {
4029
4083
  content: [{
4030
4084
  type: 'text',
4031
- text: formatSearchResults(results.slice(0, 5))
4085
+ text: formatSearchResults(results.slice(0, 3))
4032
4086
  }]
4033
4087
  };
4034
4088
  }
@@ -4058,7 +4112,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4058
4112
  return { ...r, score: (r.score || 0) + bonus };
4059
4113
  }).sort((a, b) => b.score - a.score);
4060
4114
  // Attach full method body to each result for complete understanding
4061
- const sliced = results.slice(0, 10);
4115
+ const sliced = results.slice(0, 5);
4062
4116
  for (const r of sliced) {
4063
4117
  if (r.path && r.path.endsWith('.php')) {
4064
4118
  const body = readFullMethodBody(r.path, args.methodName);
@@ -4166,24 +4220,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4166
4220
  return { ...r, diArea };
4167
4221
  });
4168
4222
 
4169
- // If targetClass provided, also scan di.xml for explicit registrations
4223
+ // If targetClass provided, also scan di.xml for explicit registrations (using session cache)
4170
4224
  let diRegistrations = [];
4171
4225
  if (args.targetClass) {
4172
4226
  const fpRoot = config.magentoRoot;
4173
- const diFiles = await glob('**/etc/**/di.xml', { cwd: fpRoot, absolute: true, nodir: true });
4227
+ const diFiles = await getDiXmlFiles(fpRoot);
4174
4228
  // Normalize target class for matching (both \ and \\)
4175
4229
  const normalizedTarget = args.targetClass.replace(/\\\\/g, '\\');
4176
- for (const diFile of diFiles) {
4177
- let content;
4178
- try { content = readFileSync(diFile, 'utf-8'); } catch { continue; }
4179
- if (!content.includes(normalizedTarget)) continue;
4180
- const relPath = diFile.replace(fpRoot + '/', '');
4230
+ const isFqcn = normalizedTarget.includes('\\');
4231
+ const shortTarget = normalizedTarget.split('\\').pop().toLowerCase();
4232
+ for (const { content, relPath } of diFiles) {
4233
+ if (!content.includes(isFqcn ? normalizedTarget : args.targetClass)) continue;
4181
4234
  // Find plugin registrations for this target
4182
4235
  const typeBlockRegex = /<type\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/type>/g;
4183
4236
  let tm;
4184
4237
  while ((tm = typeBlockRegex.exec(content)) !== null) {
4185
4238
  const typeName = tm[1].replace(/\\\\/g, '\\');
4186
- if (typeName !== normalizedTarget) continue;
4239
+ // FQCN: exact match. Short name: match if type ends with the short name
4240
+ const typeMatches = isFqcn
4241
+ ? typeName === normalizedTarget
4242
+ : typeName.split('\\').pop().toLowerCase() === shortTarget;
4243
+ if (!typeMatches) continue;
4187
4244
  const block = tm[2];
4188
4245
  const pluginRegex = /<plugin\s+([^/>]*)\/?>/g;
4189
4246
  let pm;
@@ -4212,14 +4269,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4212
4269
  }
4213
4270
  }
4214
4271
 
4215
- // Resolve plugin methods for DI registrations
4216
- const fpRoot = args.targetClass ? config.magentoRoot : null;
4272
+ // Resolve plugin methods + method bodies for DI registrations
4273
+ const fpRoot2 = args.targetClass ? config.magentoRoot : null;
4217
4274
  for (const reg of diRegistrations) {
4218
- if (reg.pluginClass && fpRoot) {
4219
- const pluginFile = findClassFile(fpRoot, reg.pluginClass);
4275
+ if (reg.pluginClass && fpRoot2) {
4276
+ const pluginFile = findClassFile(fpRoot2, reg.pluginClass);
4220
4277
  if (pluginFile) {
4221
4278
  reg.methods = extractPluginMethods(pluginFile);
4222
- reg.resolvedFile = pluginFile.replace(fpRoot + '/', '');
4279
+ reg.resolvedFile = pluginFile.replace(fpRoot2 + '/', '');
4280
+ // Read full method bodies so the agent sees actual code without follow-up calls
4281
+ for (const m of reg.methods) {
4282
+ const body = readFullMethodBody(pluginFile, m.name);
4283
+ if (body) m.body = body;
4284
+ }
4223
4285
  }
4224
4286
  }
4225
4287
  }
@@ -4237,6 +4299,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4237
4299
  if (reg.methods?.length > 0) {
4238
4300
  for (const m of reg.methods) {
4239
4301
  text += ` - \`${m.type}\` **${m.targetMethod}** → \`${m.name}()\`\n`;
4302
+ if (m.body) {
4303
+ const indentedBody = m.body.split('\n').join('\n ');
4304
+ text += ' ' + '```php\n ' + indentedBody + '\n ' + '```\n';
4305
+ }
4240
4306
  }
4241
4307
  }
4242
4308
  }
@@ -4246,19 +4312,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4246
4312
  }
4247
4313
 
4248
4314
  case 'magento_find_observer': {
4249
- const query = `event ${args.eventName} observer`;
4250
- const raw = await rustSearchAsync(query, 30);
4251
- let results = raw.map(normalizeResult).filter(r =>
4252
- r.isObserver || r.path?.includes('/Observer/') || r.path?.includes('events.xml')
4253
- );
4254
- results = rerank(results, { isObserver: true, pathContains: ['events.xml', '/Observer/'] });
4315
+ // Primary: parse events.xml for exact event name match (structural, not semantic)
4316
+ const eventFlow = await traceEventFlow(args.eventName);
4317
+ let text = '';
4318
+
4319
+ if (eventFlow.observers.length > 0) {
4320
+ text += `### Observers for \`${args.eventName}\` (${eventFlow.observers.length})\n\n`;
4321
+ for (const obs of eventFlow.observers) {
4322
+ text += `- **${obs.name}** → \`${obs.instance}::${obs.method}()\` (${obs.file})\n`;
4323
+ }
4324
+ if (eventFlow.observerDetails.length > 0) {
4325
+ text += `\n### Observer PHP Files\n`;
4326
+ for (const det of eventFlow.observerDetails) {
4327
+ text += `- \`${det.instance}\` → ${det.path}\n`;
4328
+ }
4329
+ }
4330
+ }
4255
4331
 
4256
- return {
4257
- content: [{
4258
- type: 'text',
4259
- text: formatSearchResults(results.slice(0, 15))
4260
- }]
4261
- };
4332
+ // Fallback: semantic search if events.xml parsing found nothing
4333
+ if (eventFlow.observers.length === 0) {
4334
+ const query = `event ${args.eventName} observer`;
4335
+ const raw = await rustSearchAsync(query, 30);
4336
+ let results = raw.map(normalizeResult).filter(r =>
4337
+ r.isObserver || r.path?.includes('/Observer/') || r.path?.includes('events.xml')
4338
+ );
4339
+ results = rerank(results, { isObserver: true, pathContains: ['events.xml', '/Observer/'] });
4340
+ text = formatSearchResults(results.slice(0, 15));
4341
+ }
4342
+
4343
+ return { content: [{ type: 'text', text }] };
4262
4344
  }
4263
4345
 
4264
4346
  case 'magento_find_preference': {
@@ -5491,6 +5573,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5491
5573
  text = formatSearchResults(res.slice(0, 5));
5492
5574
  break;
5493
5575
  }
5576
+ case 'magento_module_structure': {
5577
+ const raw = await rustSearchAsync(a.moduleName, 200);
5578
+ const modulePath = a.moduleName.replace('_', '/') + '/';
5579
+ const parts = a.moduleName.split('_');
5580
+ const vendorPath = parts.length === 2 ? `module-${parts[1].toLowerCase()}/` : '';
5581
+ const res = raw.map(normalizeResult).filter(r => {
5582
+ const p = r.path || '';
5583
+ const mod = r.module || '';
5584
+ return mod === a.moduleName || p.includes(modulePath) || (vendorPath && p.toLowerCase().includes(vendorPath));
5585
+ });
5586
+ text = `Module: ${a.moduleName} (${res.length} files)\n`;
5587
+ const cats = { controllers: '/Controller/', models: '/Model/', plugins: '/Plugin/', observers: '/Observer/', api: '/Api/' };
5588
+ for (const [cat, pattern] of Object.entries(cats)) {
5589
+ const matches = res.filter(r => r.path?.includes(pattern));
5590
+ if (matches.length > 0) {
5591
+ text += `${cat}: ${matches.length} (${matches.slice(0, 3).map(r => r.className || r.path?.split('/').pop()).join(', ')})\n`;
5592
+ }
5593
+ }
5594
+ break;
5595
+ }
5596
+ case 'magento_find_observer': {
5597
+ const flow = await traceEventFlow(a.eventName);
5598
+ text = `Observers: ${flow.observers.length}\n`;
5599
+ for (const o of flow.observers.slice(0, 10)) {
5600
+ text += `- ${o.name}: ${o.instance}::${o.method}() (${o.file})\n`;
5601
+ }
5602
+ break;
5603
+ }
5494
5604
  default:
5495
5605
  text = `Unsupported batch tool: ${q.tool}`;
5496
5606
  }
@@ -5583,6 +5693,16 @@ async function main() {
5583
5693
  try {
5584
5694
  let role = 'secondary';
5585
5695
 
5696
+ // Kill stale serve process if version mismatch (e.g., user upgraded Magector)
5697
+ const staleVersion = getServePidVersion();
5698
+ if (staleVersion && staleVersion !== __pkg.version) {
5699
+ logToFile('WARN', `Serve process version mismatch: ${staleVersion} vs ${__pkg.version} — killing stale process`);
5700
+ console.error(`Killing stale serve process (version ${staleVersion}, current ${__pkg.version})`);
5701
+ killStaleServeProcess();
5702
+ // Remove stale socket so we don't connect to it
5703
+ try { if (existsSync(SOCK_PATH)) unlinkSync(SOCK_PATH); } catch {}
5704
+ }
5705
+
5586
5706
  const connected = await tryConnectSocket();
5587
5707
  if (connected) {
5588
5708
  logToFile('INFO', 'Joined existing serve process via socket (secondary)');