magector 2.7.0 → 2.8.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 +158 -54
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magector",
3
- "version": "2.7.0",
3
+ "version": "2.8.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.7.0",
37
- "@magector/cli-linux-x64": "2.7.0",
38
- "@magector/cli-linux-arm64": "2.7.0",
39
- "@magector/cli-win32-x64": "2.7.0"
36
+ "@magector/cli-darwin-arm64": "2.8.0",
37
+ "@magector/cli-linux-x64": "2.8.0",
38
+ "@magector/cli-linux-arm64": "2.8.0",
39
+ "@magector/cli-win32-x64": "2.8.0"
40
40
  },
41
41
  "keywords": [
42
42
  "magento",
package/src/mcp-server.js CHANGED
@@ -2303,6 +2303,33 @@ async function analyzeImpact(className) {
2303
2303
  ...diTrace.virtualTypes.map(v => ({ type: 'virtualType', file: v.file, detail: `${v.name} extends ${v.type}` })),
2304
2304
  ...diTrace.argumentOverrides.map(a => ({ type: 'argument', file: a.file, detail: `${a.target}.${a.argumentName}` }))
2305
2305
  ];
2306
+ // Also find where this class is used AS a plugin/preference/virtualType implementation
2307
+ if (references.diXmlReferences.length === 0 && root) {
2308
+ const diFiles = await getDiXmlFiles(root);
2309
+ for (const { content, relPath } of diFiles) {
2310
+ if (!content.toLowerCase().includes(shortName.toLowerCase())) continue;
2311
+ // Check if class appears as plugin type
2312
+ const pluginTypeRegex = /<plugin\s+[^>]*type="([^"]+)"[^>]*\/?>/g;
2313
+ let pm;
2314
+ while ((pm = pluginTypeRegex.exec(content)) !== null) {
2315
+ if (pm[1].toLowerCase().includes(shortName.toLowerCase())) {
2316
+ // Find parent <type> to know the target
2317
+ const beforePlugin = content.slice(0, pm.index);
2318
+ const typeMatch = beforePlugin.match(/<type\s+name="([^"]+)"[^>]*>\s*$/m);
2319
+ const target = typeMatch ? typeMatch[1] : 'unknown';
2320
+ references.diXmlReferences.push({ type: 'registered-as-plugin', file: relPath, detail: `plugin on ${target} → ${pm[1]}` });
2321
+ }
2322
+ }
2323
+ // Check if class appears as preference type
2324
+ const prefRegex = /<preference\s+for="([^"]+)"\s+type="([^"]+)"\s*\/?>/g;
2325
+ let prm;
2326
+ while ((prm = prefRegex.exec(content)) !== null) {
2327
+ if (prm[2].toLowerCase().includes(shortName.toLowerCase())) {
2328
+ references.diXmlReferences.push({ type: 'registered-as-preference', file: relPath, detail: `preference for ${prm[1]} → ${prm[2]}` });
2329
+ }
2330
+ }
2331
+ }
2332
+ }
2306
2333
 
2307
2334
  // Check PHP files for direct references
2308
2335
  const escapedShort = shortName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -4016,11 +4043,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4016
4043
  const reqStart = Date.now();
4017
4044
  logToFile('REQ', `${name}(${JSON.stringify(args || {})})`);
4018
4045
 
4019
- // ── Warmup guard: index compatibility check or serve process still loading ──
4046
+ // ── Warmup guard: only block if no DB exists at all (nothing to search) ──
4047
+ // Tools with filesystem fallback work without the serve process, so we
4048
+ // don't block them during warmup. Only block if the DB doesn't exist.
4020
4049
  const indexFreeTools = ['magento_stats', 'magento_analyze_diff', 'magento_complexity',
4021
4050
  'magento_trace_dependency', 'magento_error_parser', 'magento_find_layout',
4022
4051
  'magento_impact_analysis', 'magento_find_event_flow', 'magento_find_test',
4023
- 'magento_trace_data_flow', 'magento_find_event_dispatchers'];
4052
+ 'magento_trace_data_flow', 'magento_find_event_dispatchers',
4053
+ // These tools have filesystem/di.xml fallbacks — work without serve process
4054
+ 'magento_find_class', 'magento_find_method', 'magento_find_plugin',
4055
+ 'magento_find_observer', 'magento_find_di_wiring', 'magento_module_structure',
4056
+ 'magento_batch', 'magento_find_config', 'magento_find_callers'];
4024
4057
  if (warmupInProgress && !indexFreeTools.includes(name)) {
4025
4058
  logToFile('REQ', `${name} → blocked (warmup: loading index)`);
4026
4059
  return {
@@ -5640,18 +5673,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5640
5673
  const res = raw.map(normalizeResult).filter(r =>
5641
5674
  r.magentoType === 'plugin' || r.path?.toLowerCase().includes('plugin')
5642
5675
  );
5643
- text = formatSearchResults(res.slice(0, 5));
5644
- // DI registrations + method bodies (same as standalone find_plugin)
5676
+ text = formatSearchResults(res.slice(0, 3));
5677
+ // DI registrations + method bodies (compact: only targetMethod bodies)
5645
5678
  if (a.targetClass) {
5646
5679
  const diFiles = await getDiXmlFiles(config.magentoRoot);
5647
5680
  const normalizedTarget = a.targetClass.replace(/\\\\/g, '\\');
5648
5681
  const isFqcn = normalizedTarget.includes('\\');
5649
5682
  const shortTarget = normalizedTarget.split('\\').pop().toLowerCase();
5683
+ let regCount = 0;
5684
+ text += '\n\n### DI Registrations\n';
5650
5685
  for (const { content: diContent, relPath } of diFiles) {
5686
+ if (regCount >= 8) break;
5651
5687
  if (!diContent.includes(isFqcn ? normalizedTarget : a.targetClass)) continue;
5652
5688
  const typeBlockRegex = /<type\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/type>/g;
5653
5689
  let tm;
5654
5690
  while ((tm = typeBlockRegex.exec(diContent)) !== null) {
5691
+ if (regCount >= 8) break;
5655
5692
  const typeName = tm[1].replace(/\\\\/g, '\\');
5656
5693
  const typeMatches = isFqcn ? typeName === normalizedTarget : typeName.split('\\').pop().toLowerCase() === shortTarget;
5657
5694
  if (!typeMatches) continue;
@@ -5659,22 +5696,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5659
5696
  const pluginRegex = /<plugin\s+([^/>]*)\/?>/g;
5660
5697
  let pm;
5661
5698
  while ((pm = pluginRegex.exec(block)) !== null) {
5699
+ if (regCount >= 8) break;
5662
5700
  const attrs = {};
5663
5701
  const localAttrRe = /(\w+)="([^"]*)"/g;
5664
- let am;
5665
- while ((am = localAttrRe.exec(pm[1])) !== null) attrs[am[1]] = am[2];
5666
- text += `\n- **${attrs.name || '?'}** \`${attrs.type || '?'}\` (${relPath})`;
5667
- if (attrs.type) {
5702
+ let am2;
5703
+ while ((am2 = localAttrRe.exec(pm[1])) !== null) attrs[am2[1]] = am2[2];
5704
+ const disabled = attrs.disabled === 'true' ? ' [DISABLED]' : '';
5705
+ text += `- **${attrs.name || '?'}** → \`${attrs.type || '?'}\`${disabled} (${relPath})\n`;
5706
+ // Only read method bodies for plugins that intercept the targetMethod
5707
+ if (attrs.type && a.targetMethod) {
5668
5708
  const pFile = findClassFile(config.magentoRoot, attrs.type);
5669
5709
  if (pFile) {
5670
5710
  const methods = extractPluginMethods(pFile);
5671
- for (const m of methods) {
5711
+ const relevant = methods.filter(m => m.targetMethod === a.targetMethod);
5712
+ for (const m of relevant) {
5672
5713
  const body = readFullMethodBody(pFile, m.name);
5673
- text += `\n - \`${m.type}\` **${m.targetMethod}** → \`${m.name}()\``;
5674
- if (body) text += '\n ' + '```php\n ' + body.split('\n').join('\n ') + '\n ' + '```';
5714
+ text += ` - \`${m.type}\` **${m.targetMethod}** → \`${m.name}()\`\n`;
5715
+ if (body) text += ' ' + '```php\n ' + body.split('\n').join('\n ') + '\n ' + '```\n';
5675
5716
  }
5676
5717
  }
5677
5718
  }
5719
+ regCount++;
5678
5720
  }
5679
5721
  }
5680
5722
  }
@@ -5740,9 +5782,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5740
5782
  let res = raw.map(normalizeResult).filter(r =>
5741
5783
  r.methodName?.toLowerCase() === ml || r.methods?.some(m => m.toLowerCase() === ml)
5742
5784
  );
5785
+ // Filesystem fallback: grep -rl for method signature
5786
+ if (res.length === 0 && config.magentoRoot) {
5787
+ const methodSig = `function ${a.methodName}(`;
5788
+ const classShort = a.className ? a.className.split('\\').pop() : null;
5789
+ try {
5790
+ let files = [];
5791
+ if (classShort) {
5792
+ files = await glob(`**/${classShort}.php`, { cwd: config.magentoRoot, absolute: false, nodir: true });
5793
+ } else {
5794
+ const grepResult = execFileSync('grep', ['-rl', '--include=*.php', methodSig, '.'],
5795
+ { cwd: config.magentoRoot, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] });
5796
+ files = grepResult.trim().split('\n').filter(Boolean).map(f => f.replace(/^\.\//, ''));
5797
+ }
5798
+ for (const f of files.slice(0, 10)) {
5799
+ const absP = f.startsWith('/') ? f : path.join(config.magentoRoot, f);
5800
+ let content;
5801
+ try { content = readFileSync(absP, 'utf-8'); } catch { continue; }
5802
+ if (!content.includes(methodSig)) continue;
5803
+ let cn = null;
5804
+ const nsM = content.match(/namespace\s+([\w\\]+)/);
5805
+ const clM = content.match(/class\s+(\w+)/);
5806
+ if (nsM && clM) cn = nsM[1] + '\\' + clM[1];
5807
+ const body = readFullMethodBody(absP, a.methodName);
5808
+ res.push({ path: f, className: cn, methodName: a.methodName, score: 0.5, fullMethodBody: body || undefined });
5809
+ if (res.length >= 5) break;
5810
+ }
5811
+ } catch {}
5812
+ }
5743
5813
  for (const r of res.slice(0, 5)) {
5744
- if (r.path?.endsWith('.php')) {
5745
- const body = readFullMethodBody(r.path, a.methodName);
5814
+ if (r.path?.endsWith('.php') && !r.fullMethodBody) {
5815
+ const body = readFullMethodBody(path.join(config.magentoRoot, r.path), a.methodName);
5746
5816
  if (body) r.fullMethodBody = body;
5747
5817
  }
5748
5818
  }
@@ -5752,13 +5822,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5752
5822
  case 'magento_module_structure': {
5753
5823
  const raw = await rustSearchAsync(a.moduleName, 200);
5754
5824
  const modulePath = a.moduleName.replace('_', '/') + '/';
5755
- const parts = a.moduleName.split('_');
5756
- const vendorPath = parts.length === 2 ? `module-${parts[1].toLowerCase()}/` : '';
5757
- const res = raw.map(normalizeResult).filter(r => {
5825
+ const mParts = a.moduleName.split('_');
5826
+ // Hyphenate camelCase for vendor path: OrderSplit order-split
5827
+ const vendorPath = mParts.length === 2
5828
+ ? `module-${mParts[1].replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()}/`
5829
+ : '';
5830
+ let res = raw.map(normalizeResult).filter(r => {
5758
5831
  const p = r.path || '';
5759
5832
  const mod = r.module || '';
5760
5833
  return mod === a.moduleName || p.includes(modulePath) || (vendorPath && p.toLowerCase().includes(vendorPath));
5761
5834
  });
5835
+ // Filesystem fallback
5836
+ if (res.length === 0 && config.magentoRoot && vendorPath) {
5837
+ try {
5838
+ const vendorGlob = `**/${vendorPath}**/*.{php,xml,phtml}`;
5839
+ const files = await glob(vendorGlob, { cwd: config.magentoRoot, absolute: false, nodir: true });
5840
+ for (const f of files.slice(0, 100)) {
5841
+ const entry = { path: f, score: 0.5 };
5842
+ if (f.includes('/Controller/')) entry.isController = true;
5843
+ if (f.includes('/Model/')) entry.isModel = true;
5844
+ if (f.includes('/Plugin/')) entry.isPlugin = true;
5845
+ if (f.includes('/Observer/')) entry.isObserver = true;
5846
+ if (f.endsWith('.xml')) entry.type = 'xml';
5847
+ const phpMatch = f.match(/\/([A-Z]\w+)\.php$/);
5848
+ if (phpMatch) entry.className = phpMatch[1];
5849
+ res.push(entry);
5850
+ }
5851
+ } catch {}
5852
+ }
5762
5853
  text = `Module: ${a.moduleName} (${res.length} files)\n`;
5763
5854
  const cats = { controllers: '/Controller/', models: '/Model/', plugins: '/Plugin/', observers: '/Observer/', api: '/Api/' };
5764
5855
  for (const [cat, pattern] of Object.entries(cats)) {
@@ -5875,8 +5966,9 @@ async function main() {
5875
5966
  logToFile('WARN', `Serve process version mismatch: ${staleVersion} vs ${__pkg.version} — killing stale process`);
5876
5967
  console.error(`Killing stale serve process (version ${staleVersion}, current ${__pkg.version})`);
5877
5968
  killStaleServeProcess();
5878
- // Remove stale socket so we don't connect to it
5969
+ // Remove stale socket and lock so we don't connect to dead process
5879
5970
  try { if (existsSync(SOCK_PATH)) unlinkSync(SOCK_PATH); } catch {}
5971
+ releasePrimaryLock();
5880
5972
  }
5881
5973
 
5882
5974
  const connected = await tryConnectSocket();
@@ -5887,47 +5979,53 @@ async function main() {
5887
5979
  role = 'primary';
5888
5980
  logToFile('INFO', 'Acquired primary lock — this instance owns the serve process');
5889
5981
 
5890
- // Check DB format (uses cache instant if already validated)
5891
- if (existsSync(config.dbPath)) {
5892
- if (!(await checkDbFormat())) {
5893
- logToFile('WARN', 'Database format incompatible — scheduling background re-index');
5894
- startBackgroundReindex();
5895
- } else {
5896
- logToFile('INFO', 'Existing database is compatible — reusing index');
5897
- }
5898
- } else if (config.magentoRoot && existsSync(config.magentoRoot)) {
5899
- logToFile('INFO', 'No index database found — scheduling background index');
5900
- startBackgroundReindex();
5901
- }
5902
-
5903
- const canStartServe = !reindexInProgress || (existsSync(config.dbPath) && (() => { try { return statSync(config.dbPath).size > 100; } catch { return false; } })());
5904
- if (canStartServe) {
5982
+ // Start serve process in background don't block tool availability
5983
+ // Tools with filesystem fallbacks work immediately via execFileSync.
5984
+ // Serve process provides faster search once ready.
5985
+ (async () => {
5905
5986
  try {
5906
- startServeProcess();
5907
- if (serveReadyPromise) {
5908
- const ready = await Promise.race([
5909
- serveReadyPromise,
5910
- new Promise(r => setTimeout(() => r(false), 60000))
5911
- ]);
5912
- if (ready) {
5913
- logToFile('INFO', 'Serve process ready (primary)');
5914
- console.error('Serve process ready (primary)');
5915
- startSocketProxy();
5987
+ // Check DB format (uses cache → instant if already validated)
5988
+ if (existsSync(config.dbPath)) {
5989
+ if (!(await checkDbFormat())) {
5990
+ logToFile('WARN', 'Database format incompatible — scheduling background re-index');
5991
+ startBackgroundReindex();
5916
5992
  } else {
5917
- logToFile('WARN', 'Serve process not ready in time, will use fallback');
5918
- console.error('Serve process not ready in time, will use fallback');
5993
+ logToFile('INFO', 'Existing database is compatible reusing index');
5994
+ }
5995
+ } else if (config.magentoRoot && existsSync(config.magentoRoot)) {
5996
+ logToFile('INFO', 'No index database found — scheduling background index');
5997
+ startBackgroundReindex();
5998
+ }
5999
+
6000
+ const canStartServe = !reindexInProgress || (existsSync(config.dbPath) && (() => { try { return statSync(config.dbPath).size > 100; } catch { return false; } })());
6001
+ if (canStartServe) {
6002
+ startServeProcess();
6003
+ if (serveReadyPromise) {
6004
+ const ready = await Promise.race([
6005
+ serveReadyPromise,
6006
+ new Promise(r => setTimeout(() => r(false), 60000))
6007
+ ]);
6008
+ if (ready) {
6009
+ logToFile('INFO', 'Serve process ready (primary)');
6010
+ console.error('Serve process ready (primary)');
6011
+ startSocketProxy();
6012
+ } else {
6013
+ logToFile('WARN', 'Serve process not ready in time, will use fallback');
6014
+ console.error('Serve process not ready in time, will use fallback');
6015
+ }
5919
6016
  }
5920
6017
  }
5921
6018
  } catch {
5922
6019
  // Non-fatal: falls back to execFileSync per query
5923
6020
  }
5924
- }
6021
+ })();
5925
6022
  } else {
5926
- // Another instance is starting up — wait for its socket to appear
5927
- logToFile('INFO', 'Another instance is primary — waiting for socket...');
5928
- console.error('Waiting for primary instance to start serve process...');
5929
- for (let i = 0; i < 12; i++) { // wait up to 60s
5930
- await new Promise(r => setTimeout(r, 5000));
6023
+ // Another instance is starting up — try socket briefly, then fall through
6024
+ logToFile('INFO', 'Another instance is primary — trying socket...');
6025
+ console.error('Trying to join existing serve process...');
6026
+ // Quick check: 3 attempts at 2s intervals (6s max), then give up and use fallback
6027
+ for (let i = 0; i < 3; i++) {
6028
+ await new Promise(r => setTimeout(r, 2000));
5931
6029
  if (await tryConnectSocket()) {
5932
6030
  logToFile('INFO', 'Connected to socket after waiting (secondary)');
5933
6031
  console.error('Joined existing serve process (secondary)');
@@ -5935,15 +6033,21 @@ async function main() {
5935
6033
  }
5936
6034
  }
5937
6035
  if (!globalServeQuery) {
5938
- logToFile('WARN', 'Socket not available after waiting using cold-start fallback');
6036
+ logToFile('INFO', 'Socket not available tools will use cold-start fallback');
5939
6037
  }
5940
6038
  }
5941
6039
 
5942
- await loadDescriptions();
5943
- } finally {
6040
+ // Mark tools as available ASAP — filesystem fallbacks work without serve process
5944
6041
  warmupInProgress = false;
5945
- logToFile('INFO', 'Warmup complete all tools available');
6042
+ logToFile('INFO', 'Tools available (serve process loading in background)');
5946
6043
  console.error('Warmup complete — all tools available');
6044
+
6045
+ // Load descriptions in background (non-blocking)
6046
+ loadDescriptions().catch(() => {});
6047
+ } catch (err) {
6048
+ warmupInProgress = false;
6049
+ logToFile('WARN', `Startup error (tools still available via fallback): ${err.message}`);
6050
+ console.error('Warmup complete — all tools available (with fallbacks)');
5947
6051
  }
5948
6052
  }
5949
6053