magector 2.16.0 → 2.16.2

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 +194 -61
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magector",
3
- "version": "2.16.0",
3
+ "version": "2.16.2",
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.16.0",
37
- "@magector/cli-linux-x64": "2.16.0",
38
- "@magector/cli-linux-arm64": "2.16.0",
39
- "@magector/cli-win32-x64": "2.16.0"
36
+ "@magector/cli-darwin-arm64": "2.16.2",
37
+ "@magector/cli-linux-x64": "2.16.2",
38
+ "@magector/cli-linux-arm64": "2.16.2",
39
+ "@magector/cli-win32-x64": "2.16.2"
40
40
  },
41
41
  "keywords": [
42
42
  "magento",
package/src/mcp-server.js CHANGED
@@ -423,6 +423,13 @@ let reindexInProgress = false;
423
423
  let reindexProcess = null;
424
424
  let warmupInProgress = true; // true until checkDbFormat + serve process ready
425
425
 
426
+ // Re-index progress tracking (updated from INDEX log lines)
427
+ let reindexStartTime = null;
428
+ let reindexPhase = 0; // 0=init, 1=AST, 2=embeddings, 3=HNSW
429
+ let reindexTotalFiles = 0;
430
+ let reindexItemsToEmbed = 0;
431
+ let reindexPhase2Start = null;
432
+
426
433
  /**
427
434
  * Check if the database file is compatible with the current binary.
428
435
  * Uses a cached result to avoid running stats (30-60s) on every startup.
@@ -557,6 +564,11 @@ function startBackgroundReindex() {
557
564
  }
558
565
 
559
566
  reindexInProgress = true;
567
+ reindexStartTime = Date.now();
568
+ reindexPhase = 0;
569
+ reindexTotalFiles = 0;
570
+ reindexItemsToEmbed = 0;
571
+ reindexPhase2Start = null;
560
572
 
561
573
  const hadExistingDb = existsSync(config.dbPath);
562
574
  logToFile('WARN', `Starting background re-index to temp path. Old DB ${hadExistingDb ? 'preserved for queries' : 'not found'}.`);
@@ -590,18 +602,31 @@ function startBackgroundReindex() {
590
602
  // entries arrive in large chunks instead of in real time.
591
603
  const indexStdout = createInterface({ input: reindexProcess.stdout });
592
604
  const indexStderr = createInterface({ input: reindexProcess.stderr });
605
+ const parseIndexProgress = (text) => {
606
+ const m = text.match(/Found (\d[\d,]+) files to index/);
607
+ if (m) reindexTotalFiles = parseInt(m[1].replace(/,/g, ''), 10);
608
+ if (text.includes('PHASE 1') || text.includes('AST analyzer')) reindexPhase = 1;
609
+ if (text.includes('PHASE 2') || text.includes('semantic embedding') || text.includes('Generating semantic')) {
610
+ if (reindexPhase < 2) { reindexPhase = 2; reindexPhase2Start = Date.now(); }
611
+ }
612
+ if (text.includes('PHASE 3') || text.includes('Building HNSW') || text.includes('HNSW')) reindexPhase = 3;
613
+ const em = text.match(/Items to embed: (\d[\d,]+)/);
614
+ if (em) reindexItemsToEmbed = parseInt(em[1].replace(/,/g, ''), 10);
615
+ };
593
616
  indexStdout.on('line', (line) => {
594
617
  const text = line.replace(/\x1b\[[0-9;]*m/g, '').trim();
595
- if (text) logToFile('INDEX', text);
618
+ if (text) { logToFile('INDEX', text); parseIndexProgress(text); }
596
619
  });
597
620
  indexStderr.on('line', (line) => {
598
621
  const text = line.replace(/\x1b\[[0-9;]*m/g, '').trim();
599
- if (text) logToFile('INDEX', text);
622
+ if (text) { logToFile('INDEX', text); parseIndexProgress(text); }
600
623
  });
601
624
 
602
625
  reindexProcess.on('exit', (code) => {
603
626
  reindexInProgress = false;
604
627
  reindexProcess = null;
628
+ reindexStartTime = null;
629
+ reindexPhase = 0;
605
630
  removeReindexPidFile();
606
631
  if (code === 0) {
607
632
  // Atomic swap: old → .bak, new → current
@@ -938,6 +963,10 @@ function tryConnectSocket() {
938
963
  let globalServeQuery = null;
939
964
 
940
965
  function serveQuery(command, params = {}, timeoutMs = 30000) {
966
+ if (!serveProcess || !serveReady) {
967
+ logToFile('WARN', `serveQuery(${command}): serve process not ready — returning error`);
968
+ return Promise.resolve({ ok: false, error: 'Serve process not ready' });
969
+ }
941
970
  return new Promise((resolve, reject) => {
942
971
  const id = serveNextId++;
943
972
  logToFile('QUERY', `[${id}] → ${command}(${JSON.stringify(params).slice(0, 200)})`);
@@ -3631,7 +3660,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
3631
3660
  tools: [
3632
3661
  {
3633
3662
  name: 'magento_search',
3634
- description: 'Search Magento codebase semantically — find any PHP class, method, XML config, PHTML template, JS file, or GraphQL schema by describing what you need in natural language. Use this as a general-purpose search when no specialized tool fits. See also: magento_find_class, magento_find_method, magento_find_config for targeted searches.',
3663
+ description: 'Search Magento codebase semantically — find any PHP class, method, XML config, PHTML template, JS file, or GraphQL schema by describing what you need in natural language. Use this as a general-purpose search when no specialized tool fits. Works best for Magento core and popular vendor modules. For small/custom project-specific modules (e.g. proprietary modules not widely known to the embedding model), use magento_grep instead — semantic search may return 0 results for these. See also: magento_find_class, magento_find_method, magento_find_config for targeted searches.',
3635
3664
  inputSchema: {
3636
3665
  type: 'object',
3637
3666
  properties: {
@@ -4278,7 +4307,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
4278
4307
  },
4279
4308
  {
4280
4309
  name: 'magento_batch',
4281
- 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).',
4310
+ 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). Supported tools: magento_search, magento_find_class, magento_find_method, magento_find_plugin, magento_find_observer, magento_find_config, magento_find_event_flow, magento_find_di_wiring, magento_find_callers, magento_find_preference, magento_find_fieldset, magento_module_structure, magento_trace_dependency, magento_impact_analysis, magento_grep, magento_read, magento_ast_search, magento_find_dataobject_issues, magento_find_null_risks.',
4282
4311
  inputSchema: {
4283
4312
  type: 'object',
4284
4313
  properties: {
@@ -4391,7 +4420,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
4391
4420
  },
4392
4421
  {
4393
4422
  name: 'magento_find_null_risks',
4394
- description: 'Find method chains without null guards using the pre-built enrichment index. Returns all ->firstMethod()->secondMethod() calls where no null check (=== null, !== null, ?->, ??, isset, is_null) was detected in surrounding code. Requires magento_enrich to have been run first. 100× faster than grep — O(1) SQLite query vs O(n) file scan. Use firstMethod to filter (e.g., "getPayment" finds all ->getPayment()->anything() without null guard). ⚡ For multi-query workflows use magento_batch.',
4423
+ description: 'Find method chains without null guards using the pre-built enrichment index. Returns all ->firstMethod()->secondMethod() calls where no null check (=== null, !== null, ?->, ??, isset, is_null) was detected in surrounding code. Requires magento_enrich to have been run first (magento_index triggers it automatically in the background). 100× faster than grep — O(1) SQLite query vs O(n) file scan. Use firstMethod to filter (e.g., "getPayment" finds all ->getPayment()->anything() without null guard). ⚡ For multi-query workflows use magento_batch.',
4395
4424
  inputSchema: {
4396
4425
  type: 'object',
4397
4426
  properties: {
@@ -4458,7 +4487,49 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
4458
4487
  ]
4459
4488
  }));
4460
4489
 
4461
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
4490
+ /**
4491
+ * Build a reindex warning string for tool responses during background re-index.
4492
+ * Returns null when not re-indexing.
4493
+ */
4494
+ function getReindexWarning() {
4495
+ if (!reindexInProgress || !reindexStartTime) return null;
4496
+ const elapsedSec = Math.round((Date.now() - reindexStartTime) / 1000);
4497
+ const elapsedStr = elapsedSec >= 60
4498
+ ? `${Math.floor(elapsedSec / 60)}m ${elapsedSec % 60}s`
4499
+ : `${elapsedSec}s`;
4500
+
4501
+ let phaseStr, etaStr;
4502
+ if (reindexPhase <= 0) {
4503
+ phaseStr = 'initializing';
4504
+ etaStr = 'est. ~40–70 min total';
4505
+ } else if (reindexPhase === 1) {
4506
+ const filesStr = reindexTotalFiles > 0 ? ` (${reindexTotalFiles.toLocaleString('en')} files)` : '';
4507
+ phaseStr = `phase 1/3: AST parsing${filesStr}`;
4508
+ etaStr = 'est. ~1–3 min for this phase, then ~40–60 min for embeddings';
4509
+ } else if (reindexPhase === 2) {
4510
+ const itemsStr = reindexItemsToEmbed > 0 ? ` (${reindexItemsToEmbed.toLocaleString('en')} items)` : '';
4511
+ phaseStr = `phase 2/3: generating embeddings${itemsStr}`;
4512
+ if (reindexPhase2Start && reindexItemsToEmbed > 0) {
4513
+ // Empirical rate: ~87k items ≈ 45 min on 8-core ONNX. Scale linearly.
4514
+ const estimatedTotalSec = Math.round((reindexItemsToEmbed / 87000) * 45 * 60);
4515
+ const phase2Elapsed = (Date.now() - reindexPhase2Start) / 1000;
4516
+ const remainingSec = Math.max(estimatedTotalSec - phase2Elapsed, 0);
4517
+ etaStr = remainingSec > 60
4518
+ ? `est. ~${Math.round(remainingSec / 60)} min remaining`
4519
+ : 'almost done with embeddings';
4520
+ } else {
4521
+ etaStr = 'est. 30–60 min for this phase';
4522
+ }
4523
+ } else {
4524
+ phaseStr = 'phase 3/3: building HNSW vector index';
4525
+ etaStr = 'est. ~5–10 min remaining';
4526
+ }
4527
+
4528
+ return `> ⏳ **Re-indexing in progress** — ${elapsedStr} elapsed, ${phaseStr}. ${etaStr}.\n` +
4529
+ `> Results below use the **previous index** — valid, but may miss recently added files.\n\n`;
4530
+ }
4531
+
4532
+ const _callToolHandler = async (request) => {
4462
4533
  const { name, arguments: args } = request.params;
4463
4534
  const reqStart = Date.now();
4464
4535
  logToFile('REQ', `${name}(${JSON.stringify(args || {})})`);
@@ -5200,43 +5271,63 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5200
5271
  }
5201
5272
 
5202
5273
  case 'magento_module_structure': {
5203
- const raw = await rustSearchAsync(args.moduleName, 200);
5204
- // Support both app/code (Magento/Catalog/) and vendor (module-catalog/) paths
5205
- const modulePath = args.moduleName.replace('_', '/') + '/';
5206
5274
  const parts = args.moduleName.split('_');
5275
+ // Support both app/code (Magento/Catalog/) and vendor (magento/module-catalog/) paths
5276
+ const modulePath = args.moduleName.replace('_', '/') + '/';
5207
5277
  // Hyphenate camelCase for vendor path: OrderSplit → order-split
5278
+ const vendorDir = parts.length === 2 ? parts[0].toLowerCase() : '';
5208
5279
  const vendorPath = parts.length === 2
5209
5280
  ? `module-${parts[1].replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()}/`
5210
5281
  : '';
5211
- let results = raw.map(normalizeResult).filter(r => {
5212
- const p = r.path || '';
5213
- const mod = r.module || '';
5214
- // Exact module match or directory-level path match (trailing slash prevents Catalog matching CatalogRule)
5215
- return mod === args.moduleName ||
5216
- p.includes(modulePath) ||
5217
- (vendorPath && p.toLowerCase().includes(vendorPath));
5218
- });
5219
-
5220
- // Filesystem fallback: if vector search found nothing, glob the module directory
5221
- if (results.length === 0 && config.magentoRoot && vendorPath) {
5222
- logToFile('INFO', `module_structure: vector search returned 0 results for "${args.moduleName}" — using filesystem fallback (glob ${vendorPath})`);
5223
- try {
5224
- const vendorGlob = `**/${vendorPath}**/*.{php,xml,phtml}`;
5225
- const files = await glob(vendorGlob, { cwd: config.magentoRoot, absolute: false, nodir: true });
5226
- for (const f of files.slice(0, 100)) {
5227
- const entry = { path: f, score: 0.5 };
5228
- if (f.includes('/Controller/')) entry.isController = true;
5229
- if (f.includes('/Model/')) entry.isModel = true;
5230
- if (f.includes('/Block/')) entry.isBlock = true;
5231
- if (f.includes('/Plugin/')) entry.isPlugin = true;
5232
- if (f.includes('/Observer/')) entry.isObserver = true;
5233
- if (f.endsWith('.xml')) entry.type = 'xml';
5234
- // Extract class name from path
5235
- const phpMatch = f.match(/\/([A-Z]\w+)\.php$/);
5236
- if (phpMatch) entry.className = phpMatch[1];
5237
- results.push(entry);
5282
+ let results = [];
5283
+
5284
+ // Primary: filesystem-based (authoritative avoids mixing cross-references from vector search)
5285
+ if (config.magentoRoot) {
5286
+ const fsGlobs = [];
5287
+ if (parts.length === 2) {
5288
+ // app/code/{Vendor}/{Module}/
5289
+ fsGlobs.push(`app/code/${parts[0]}/${parts[1]}/**/*.{php,xml,phtml}`);
5290
+ // vendor/{vendor-lower}/{module-lower}/ — vendor-specific to avoid false positives
5291
+ if (vendorDir && vendorPath) {
5292
+ fsGlobs.push(`vendor/${vendorDir}/${vendorPath}**/*.{php,xml,phtml}`);
5238
5293
  }
5239
- } catch {}
5294
+ }
5295
+ for (const globPattern of fsGlobs) {
5296
+ try {
5297
+ const files = await glob(globPattern, { cwd: config.magentoRoot, absolute: false, nodir: true });
5298
+ if (files.length > 0) {
5299
+ logToFile('INFO', `module_structure: filesystem found ${files.length} files for "${args.moduleName}" (${globPattern})`);
5300
+ for (const f of files.slice(0, 100)) {
5301
+ const entry = { path: f, score: 1.0 };
5302
+ if (f.includes('/Controller/')) entry.isController = true;
5303
+ if (f.includes('/Model/')) entry.isModel = true;
5304
+ if (f.includes('/Block/')) entry.isBlock = true;
5305
+ if (f.includes('/Plugin/')) entry.isPlugin = true;
5306
+ if (f.includes('/Observer/')) entry.isObserver = true;
5307
+ if (f.endsWith('.xml')) entry.type = 'xml';
5308
+ const phpMatch = f.match(/\/([A-Z]\w+)\.php$/);
5309
+ if (phpMatch) entry.className = phpMatch[1];
5310
+ results.push(entry);
5311
+ }
5312
+ break; // Found in one location, stop
5313
+ }
5314
+ } catch {}
5315
+ }
5316
+ }
5317
+
5318
+ // Fallback: vector search with strict path/module filtering (only if filesystem found nothing)
5319
+ if (results.length === 0) {
5320
+ logToFile('INFO', `module_structure: filesystem found 0 files for "${args.moduleName}" — falling back to vector search`);
5321
+ const raw = await rustSearchAsync(args.moduleName, 200);
5322
+ results = raw.map(normalizeResult).filter(r => {
5323
+ const p = r.path || '';
5324
+ const mod = r.module || '';
5325
+ // Exact module match or directory-level path match (trailing slash prevents Catalog matching CatalogRule)
5326
+ return mod === args.moduleName ||
5327
+ p.includes(modulePath) ||
5328
+ // Vendor-specific path check to avoid matching other vendors' same-named modules
5329
+ (vendorDir && vendorPath && p.toLowerCase().includes(`${vendorDir}/${vendorPath}`));
5330
+ });
5240
5331
  }
5241
5332
 
5242
5333
  const structure = {
@@ -6182,6 +6273,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6182
6273
  }
6183
6274
  break;
6184
6275
  }
6276
+ case 'magento_find_config': {
6277
+ let cfgQuery = a.query;
6278
+ if (a.configType && a.configType !== 'other') cfgQuery = `${a.configType}.xml xml config ${a.query}`;
6279
+ const cfgRaw = await rustSearchAsync(cfgQuery, 100);
6280
+ let cfgRes = cfgRaw.map(normalizeResult).filter(r =>
6281
+ r.type === 'xml' || r.path?.endsWith('.xml') || r.path?.includes('.xml')
6282
+ );
6283
+ if (a.configType && a.configType !== 'other') {
6284
+ const cfgTypeFile = `${a.configType}.xml`;
6285
+ const cfgTyped = cfgRes.filter(r => r.path?.includes(cfgTypeFile));
6286
+ if (cfgTyped.length >= 3) cfgRes = cfgTyped;
6287
+ }
6288
+ text = formatSearchResults(cfgRes.slice(0, 10));
6289
+ break;
6290
+ }
6185
6291
  case 'magento_trace_dependency': {
6186
6292
  const dep = await traceDependency(a.className, a.direction || 'both');
6187
6293
  text = `Preferences: ${dep.preferences.length}, Plugins: ${dep.plugins.length}, VirtualTypes: ${dep.virtualTypes.length}, Args: ${dep.argumentOverrides.length}\n`;
@@ -6271,35 +6377,52 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6271
6377
  break;
6272
6378
  }
6273
6379
  case 'magento_module_structure': {
6274
- const raw = await rustSearchAsync(a.moduleName, 200);
6275
- const modulePath = a.moduleName.replace('_', '/') + '/';
6276
6380
  const mParts = a.moduleName.split('_');
6277
- // Hyphenate camelCase for vendor path: OrderSplit → order-split
6381
+ const modulePath = a.moduleName.replace('_', '/') + '/';
6382
+ const mVendorDir = mParts.length === 2 ? mParts[0].toLowerCase() : '';
6278
6383
  const vendorPath = mParts.length === 2
6279
6384
  ? `module-${mParts[1].replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()}/`
6280
6385
  : '';
6281
- let res = raw.map(normalizeResult).filter(r => {
6282
- const p = r.path || '';
6283
- const mod = r.module || '';
6284
- return mod === a.moduleName || p.includes(modulePath) || (vendorPath && p.toLowerCase().includes(vendorPath));
6285
- });
6286
- // Filesystem fallback
6287
- if (res.length === 0 && config.magentoRoot && vendorPath) {
6288
- try {
6289
- const vendorGlob = `**/${vendorPath}**/*.{php,xml,phtml}`;
6290
- const files = await glob(vendorGlob, { cwd: config.magentoRoot, absolute: false, nodir: true });
6291
- for (const f of files.slice(0, 100)) {
6292
- const entry = { path: f, score: 0.5 };
6293
- if (f.includes('/Controller/')) entry.isController = true;
6294
- if (f.includes('/Model/')) entry.isModel = true;
6295
- if (f.includes('/Plugin/')) entry.isPlugin = true;
6296
- if (f.includes('/Observer/')) entry.isObserver = true;
6297
- if (f.endsWith('.xml')) entry.type = 'xml';
6298
- const phpMatch = f.match(/\/([A-Z]\w+)\.php$/);
6299
- if (phpMatch) entry.className = phpMatch[1];
6300
- res.push(entry);
6386
+ let res = [];
6387
+ // Primary: filesystem-based (avoids mixing cross-references)
6388
+ if (config.magentoRoot) {
6389
+ const msGlobs = [];
6390
+ if (mParts.length === 2) {
6391
+ msGlobs.push(`app/code/${mParts[0]}/${mParts[1]}/**/*.{php,xml,phtml}`);
6392
+ if (mVendorDir && vendorPath) {
6393
+ msGlobs.push(`vendor/${mVendorDir}/${vendorPath}**/*.{php,xml,phtml}`);
6301
6394
  }
6302
- } catch {}
6395
+ }
6396
+ for (const gp of msGlobs) {
6397
+ try {
6398
+ const files = await glob(gp, { cwd: config.magentoRoot, absolute: false, nodir: true });
6399
+ if (files.length > 0) {
6400
+ for (const f of files.slice(0, 100)) {
6401
+ const entry = { path: f, score: 1.0 };
6402
+ if (f.includes('/Controller/')) entry.isController = true;
6403
+ if (f.includes('/Model/')) entry.isModel = true;
6404
+ if (f.includes('/Plugin/')) entry.isPlugin = true;
6405
+ if (f.includes('/Observer/')) entry.isObserver = true;
6406
+ if (f.endsWith('.xml')) entry.type = 'xml';
6407
+ const phpMatch = f.match(/\/([A-Z]\w+)\.php$/);
6408
+ if (phpMatch) entry.className = phpMatch[1];
6409
+ res.push(entry);
6410
+ }
6411
+ break;
6412
+ }
6413
+ } catch {}
6414
+ }
6415
+ }
6416
+ // Fallback: vector search with vendor-specific path filtering
6417
+ if (res.length === 0) {
6418
+ const raw = await rustSearchAsync(a.moduleName, 200);
6419
+ res = raw.map(normalizeResult).filter(r => {
6420
+ const p = r.path || '';
6421
+ const mod = r.module || '';
6422
+ return mod === a.moduleName ||
6423
+ p.includes(modulePath) ||
6424
+ (mVendorDir && vendorPath && p.toLowerCase().includes(`${mVendorDir}/${vendorPath}`));
6425
+ });
6303
6426
  }
6304
6427
  text = `Module: ${a.moduleName} (${res.length} files)\n`;
6305
6428
  const cats = { controllers: '/Controller/', models: '/Model/', plugins: '/Plugin/', observers: '/Observer/', api: '/Api/' };
@@ -6872,6 +6995,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6872
6995
  serveQuery('feedback', { signals }).catch((err) => logToFile('WARN', `Feedback signal send failed: ${err.message}`));
6873
6996
  }
6874
6997
  }
6998
+ };
6999
+
7000
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
7001
+ const result = await _callToolHandler(request);
7002
+ // Append re-index warning to non-error responses during background re-index
7003
+ if (reindexInProgress && !result?.isError && result?.content?.[0]?.type === 'text') {
7004
+ const warning = getReindexWarning();
7005
+ if (warning) result.content[0].text = warning + result.content[0].text;
7006
+ }
7007
+ return result;
6875
7008
  });
6876
7009
 
6877
7010
  server.setRequestHandler(ListResourcesRequestSchema, async () => ({