magector 2.2.6 → 2.3.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 +6 -12
  2. package/src/mcp-server.js +408 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magector",
3
- "version": "2.2.6",
3
+ "version": "2.3.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",
@@ -23,13 +23,7 @@
23
23
  "test": "node tests/unit.test.js && node tests/mcp-server.test.js",
24
24
  "test:unit": "node tests/unit.test.js",
25
25
  "test:integration": "node tests/mcp-server.test.js",
26
- "test:no-index": "node tests/mcp-server.test.js --no-index",
27
- "test:accuracy": "node tests/mcp-accuracy.test.js",
28
- "test:accuracy:verbose": "node tests/mcp-accuracy.test.js --verbose",
29
- "test:sona-eval": "node tests/mcp-sona-eval.test.js",
30
- "test:sona-eval:verbose": "node tests/mcp-sona-eval.test.js --verbose",
31
- "test:describe-eval": "node tests/describe-benefit-eval.test.js",
32
- "test:describe-eval:verbose": "node tests/describe-benefit-eval.test.js --verbose"
26
+ "test:no-index": "node tests/mcp-server.test.js --no-index"
33
27
  },
34
28
  "dependencies": {
35
29
  "@modelcontextprotocol/sdk": "^1.0.0",
@@ -39,10 +33,10 @@
39
33
  "ruvector": "^0.1.96"
40
34
  },
41
35
  "optionalDependencies": {
42
- "@magector/cli-darwin-arm64": "2.2.6",
43
- "@magector/cli-linux-x64": "2.2.6",
44
- "@magector/cli-linux-arm64": "2.2.6",
45
- "@magector/cli-win32-x64": "2.2.6"
36
+ "@magector/cli-darwin-arm64": "2.3.0",
37
+ "@magector/cli-linux-x64": "2.3.0",
38
+ "@magector/cli-linux-arm64": "2.3.0",
39
+ "@magector/cli-win32-x64": "2.3.0"
46
40
  },
47
41
  "keywords": [
48
42
  "magento",
package/src/mcp-server.js CHANGED
@@ -2362,6 +2362,244 @@ async function findDiWiring(className) {
2362
2362
  return result;
2363
2363
  }
2364
2364
 
2365
+ // ─── Trace Data Flow ───────────────────────────────────────────
2366
+ // Find all setters and getters for a data attribute across the codebase
2367
+
2368
+ async function traceDataFlow(attributeKey, modelClass) {
2369
+ const root = config.magentoRoot;
2370
+
2371
+ // Convert snake_case to PascalCase for magic method names
2372
+ const pascal = attributeKey.split('_').map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('');
2373
+ const setterMethod = `set${pascal}`;
2374
+ const getterMethod = `get${pascal}`;
2375
+
2376
+ const result = {
2377
+ attributeKey,
2378
+ setterMethod,
2379
+ getterMethod,
2380
+ setters: [],
2381
+ getters: [],
2382
+ xmlReferences: []
2383
+ };
2384
+
2385
+ // 1. Search PHP files for setters/getters
2386
+ const phpFiles = await glob('**/*.php', {
2387
+ cwd: root, absolute: true, nodir: true,
2388
+ ignore: ['**/test/**', '**/tests/**', '**/Test/**', '**/Tests/**']
2389
+ });
2390
+
2391
+ const setterRegex = new RegExp(
2392
+ `(?:->|::)${setterMethod}\\s*\\(|` +
2393
+ `setData\\s*\\(\\s*['"]${attributeKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]`
2394
+ );
2395
+ const getterRegex = new RegExp(
2396
+ `(?:->|::)${getterMethod}\\s*\\(|` +
2397
+ `getData\\s*\\(\\s*['"]${attributeKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]`
2398
+ );
2399
+ // Also match addData calls that could set this attribute
2400
+ const addDataRegex = new RegExp(
2401
+ `addData\\s*\\(.*${attributeKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`
2402
+ );
2403
+ // Match constant definitions for this key
2404
+ const constRegex = new RegExp(
2405
+ `(?:const\\s+\\w+\\s*=\\s*['"]${attributeKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"])`
2406
+ );
2407
+
2408
+ const shortModel = modelClass ? modelClass.split('\\').pop() : null;
2409
+
2410
+ for (const phpFile of phpFiles) {
2411
+ let content;
2412
+ try { content = readFileSync(phpFile, 'utf-8'); } catch { continue; }
2413
+
2414
+ // Quick pre-check
2415
+ if (!content.includes(setterMethod) && !content.includes(getterMethod) &&
2416
+ !content.includes(attributeKey)) continue;
2417
+
2418
+ // If modelClass specified, prioritize files that reference it
2419
+ const refsModel = !shortModel || content.includes(shortModel);
2420
+
2421
+ const relativePath = phpFile.replace(root + '/', '');
2422
+ const classMatch = content.match(/(?:class|abstract\s+class|trait)\s+(\w+)/);
2423
+ const nsMatch = content.match(/namespace\s+([\w\\]+)/);
2424
+ const className = classMatch ? classMatch[1] : path.basename(phpFile, '.php');
2425
+ const fqcn = nsMatch ? `${nsMatch[1]}\\${className}` : className;
2426
+ const lines = content.split('\n');
2427
+
2428
+ // Determine area from path
2429
+ const area = relativePath.includes('/frontend/') ? 'frontend'
2430
+ : relativePath.includes('/adminhtml/') ? 'adminhtml'
2431
+ : relativePath.includes('/graphql/') ? 'graphql' : 'global';
2432
+
2433
+ // Find setter lines
2434
+ for (let i = 0; i < lines.length; i++) {
2435
+ const line = lines[i];
2436
+ if (setterRegex.test(line) || (addDataRegex.test(line) && line.includes(attributeKey))) {
2437
+ // Find enclosing method
2438
+ let methodName = null;
2439
+ for (let j = i; j >= Math.max(0, i - 30); j--) {
2440
+ const mMatch = lines[j].match(/(?:public|protected|private|static)\s+function\s+(\w+)/);
2441
+ if (mMatch) { methodName = mMatch[1]; break; }
2442
+ }
2443
+ result.setters.push({
2444
+ path: relativePath,
2445
+ class: fqcn,
2446
+ method: methodName,
2447
+ line: i + 1,
2448
+ snippet: line.trim().slice(0, 200),
2449
+ referencesModel: refsModel,
2450
+ area
2451
+ });
2452
+ break; // one entry per file for setters
2453
+ }
2454
+ }
2455
+
2456
+ // Find getter lines
2457
+ for (let i = 0; i < lines.length; i++) {
2458
+ const line = lines[i];
2459
+ if (getterRegex.test(line)) {
2460
+ let methodName = null;
2461
+ for (let j = i; j >= Math.max(0, i - 30); j--) {
2462
+ const mMatch = lines[j].match(/(?:public|protected|private|static)\s+function\s+(\w+)/);
2463
+ if (mMatch) { methodName = mMatch[1]; break; }
2464
+ }
2465
+ result.getters.push({
2466
+ path: relativePath,
2467
+ class: fqcn,
2468
+ method: methodName,
2469
+ line: i + 1,
2470
+ snippet: line.trim().slice(0, 200),
2471
+ referencesModel: refsModel,
2472
+ area
2473
+ });
2474
+ break; // one entry per file for getters
2475
+ }
2476
+ }
2477
+
2478
+ // Find constant definitions
2479
+ if (constRegex.test(content)) {
2480
+ const constLine = lines.findIndex(l => constRegex.test(l));
2481
+ if (constLine >= 0) {
2482
+ result.setters.push({
2483
+ path: relativePath,
2484
+ class: fqcn,
2485
+ method: null,
2486
+ line: constLine + 1,
2487
+ snippet: lines[constLine].trim().slice(0, 200),
2488
+ type: 'constant',
2489
+ area
2490
+ });
2491
+ }
2492
+ }
2493
+ }
2494
+
2495
+ // 2. Search XML files (sales.xml, extension_attributes.xml) for attribute references
2496
+ const xmlFiles = await glob('**/etc/**/*.xml', { cwd: root, absolute: true, nodir: true });
2497
+ const escapedKey = attributeKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2498
+ const xmlRegex = new RegExp(escapedKey);
2499
+
2500
+ for (const xmlFile of xmlFiles) {
2501
+ let content;
2502
+ try { content = readFileSync(xmlFile, 'utf-8'); } catch { continue; }
2503
+ if (!xmlRegex.test(content)) continue;
2504
+
2505
+ const relativePath = xmlFile.replace(root + '/', '');
2506
+ const lines = content.split('\n');
2507
+ for (let i = 0; i < lines.length; i++) {
2508
+ if (xmlRegex.test(lines[i])) {
2509
+ result.xmlReferences.push({
2510
+ path: relativePath,
2511
+ line: i + 1,
2512
+ snippet: lines[i].trim().slice(0, 200)
2513
+ });
2514
+ break;
2515
+ }
2516
+ }
2517
+ }
2518
+
2519
+ // Sort: files referencing the model get priority
2520
+ if (shortModel) {
2521
+ result.setters.sort((a, b) => (b.referencesModel ? 1 : 0) - (a.referencesModel ? 1 : 0));
2522
+ result.getters.sort((a, b) => (b.referencesModel ? 1 : 0) - (a.referencesModel ? 1 : 0));
2523
+ }
2524
+
2525
+ return result;
2526
+ }
2527
+
2528
+ // ─── Find Event Dispatchers ────────────────────────────────────
2529
+ // Find all PHP locations where a specific Magento event is dispatched
2530
+
2531
+ async function findEventDispatchers(eventName) {
2532
+ const root = config.magentoRoot;
2533
+
2534
+ const result = {
2535
+ eventName,
2536
+ dispatchers: [],
2537
+ observerCount: 0
2538
+ };
2539
+
2540
+ const escaped = eventName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2541
+ // Match eventManager->dispatch('event_name' and similar patterns
2542
+ const dispatchRegex = new RegExp(
2543
+ `dispatch\\s*\\(\\s*['"]${escaped}['"]`
2544
+ );
2545
+
2546
+ // 1. Grep PHP files for exact dispatch calls
2547
+ const phpFiles = await glob('**/*.php', {
2548
+ cwd: root, absolute: true, nodir: true,
2549
+ ignore: ['**/test/**', '**/tests/**', '**/Test/**', '**/Tests/**']
2550
+ });
2551
+
2552
+ for (const phpFile of phpFiles) {
2553
+ let content;
2554
+ try { content = readFileSync(phpFile, 'utf-8'); } catch { continue; }
2555
+ if (!content.includes(eventName)) continue;
2556
+
2557
+ const relativePath = phpFile.replace(root + '/', '');
2558
+ const lines = content.split('\n');
2559
+ const classMatch = content.match(/(?:class|abstract\s+class|trait)\s+(\w+)/);
2560
+ const nsMatch = content.match(/namespace\s+([\w\\]+)/);
2561
+ const className = classMatch ? classMatch[1] : path.basename(phpFile, '.php');
2562
+ const fqcn = nsMatch ? `${nsMatch[1]}\\${className}` : className;
2563
+
2564
+ for (let i = 0; i < lines.length; i++) {
2565
+ if (dispatchRegex.test(lines[i])) {
2566
+ // Find enclosing method
2567
+ let methodName = null;
2568
+ for (let j = i; j >= Math.max(0, i - 30); j--) {
2569
+ const mMatch = lines[j].match(/(?:public|protected|private|static)\s+function\s+(\w+)/);
2570
+ if (mMatch) { methodName = mMatch[1]; break; }
2571
+ }
2572
+
2573
+ // Get surrounding context (2 lines before and after)
2574
+ const ctxStart = Math.max(0, i - 2);
2575
+ const ctxEnd = Math.min(lines.length - 1, i + 2);
2576
+ const context = lines.slice(ctxStart, ctxEnd + 1).map(l => l.trimEnd()).join('\n');
2577
+
2578
+ result.dispatchers.push({
2579
+ path: relativePath,
2580
+ class: fqcn,
2581
+ method: methodName,
2582
+ line: i + 1,
2583
+ snippet: lines[i].trim().slice(0, 200),
2584
+ context: context.slice(0, 500)
2585
+ });
2586
+ }
2587
+ }
2588
+ }
2589
+
2590
+ // 2. Count registered observers for context
2591
+ const eventsFiles = await glob('**/etc/**/events.xml', { cwd: root, absolute: true, nodir: true });
2592
+ for (const file of eventsFiles) {
2593
+ let content;
2594
+ try { content = readFileSync(file, 'utf-8'); } catch { continue; }
2595
+ if (!content.includes(eventName)) continue;
2596
+ const obsMatches = content.match(/<observer\s+/g);
2597
+ if (obsMatches) result.observerCount += obsMatches.length;
2598
+ }
2599
+
2600
+ return result;
2601
+ }
2602
+
2365
2603
  // ─── Trace Call Chain ───────────────────────────────────────────
2366
2604
  // Trace internal method execution: method calls, dispatched events, observers
2367
2605
 
@@ -2606,8 +2844,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2606
2844
  default: 10
2607
2845
  },
2608
2846
  moduleFilter: {
2609
- type: 'string',
2610
- description: 'Filter results by vendor/module pattern. Supports wildcards and vendor prefix matching. Uses "/" or "_" interchangeably as separator. Examples: "Vendor_*" or "Vendor/*" to show modules from that vendor (also matches vendor-extended names like "Vendor-Extra_*"), "Magento_Catalog" for specific module. Omit to search all modules.'
2847
+ oneOf: [
2848
+ { type: 'string' },
2849
+ { type: 'array', items: { type: 'string' } }
2850
+ ],
2851
+ description: 'Filter results by vendor/module pattern(s). Accepts a single string or array of strings. Supports wildcards and vendor prefix matching. Uses "/" or "_" interchangeably as separator. Examples: "Vendor_*", ["drmax_paymentrestrictions", "drmax_module-free-shipping"], "Magento_Catalog".'
2611
2852
  },
2612
2853
  expand: {
2613
2854
  type: 'boolean',
@@ -3136,6 +3377,38 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
3136
3377
  required: ['className', 'methodName']
3137
3378
  }
3138
3379
  },
3380
+ {
3381
+ name: 'magento_trace_data_flow',
3382
+ description: 'Trace how a data attribute flows through the Magento codebase: find all PHP files that set (via magic setter, setData, addData) and get (via magic getter, getData) a specific attribute key. Shows which classes write vs read the attribute, in which methods, and whether XML configs reference it. Use this to understand data dependencies — e.g., who sets drmax_discounted_price_incl_tax on Quote\\Address and who reads it.',
3383
+ inputSchema: {
3384
+ type: 'object',
3385
+ properties: {
3386
+ attributeKey: {
3387
+ type: 'string',
3388
+ description: 'The snake_case data attribute key to trace. Examples: "drmax_discounted_price_incl_tax", "base_grand_total", "drmax_free_shipping_price", "subtotal_with_discount"'
3389
+ },
3390
+ modelClass: {
3391
+ type: 'string',
3392
+ description: 'Optional: model class name to prioritize results that reference this class. Examples: "Quote\\Address", "Order", "Product"'
3393
+ }
3394
+ },
3395
+ required: ['attributeKey']
3396
+ }
3397
+ },
3398
+ {
3399
+ name: 'magento_find_event_dispatchers',
3400
+ description: 'Find all PHP locations where a specific Magento event is dispatched via eventManager->dispatch(). Unlike magento_find_event_flow (which shows the full chain: dispatchers+observers+handlers), this tool focuses exclusively on finding WHERE an event is triggered — with exact grep matching, method context, and surrounding code. Use this to answer "does class X dispatch event Y?" or "who triggers this event?".',
3401
+ inputSchema: {
3402
+ type: 'object',
3403
+ properties: {
3404
+ eventName: {
3405
+ type: 'string',
3406
+ description: 'Magento event name to find dispatchers for. Examples: "sales_order_place_after", "drmax_discount_rule_validation_before", "checkout_cart_add_product_complete"'
3407
+ }
3408
+ },
3409
+ required: ['eventName']
3410
+ }
3411
+ },
3139
3412
  ]
3140
3413
  }));
3141
3414
 
@@ -3147,7 +3420,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3147
3420
  // ── Warmup guard: index compatibility check or serve process still loading ──
3148
3421
  const indexFreeTools = ['magento_stats', 'magento_analyze_diff', 'magento_complexity',
3149
3422
  'magento_trace_dependency', 'magento_error_parser', 'magento_find_layout',
3150
- 'magento_impact_analysis', 'magento_find_event_flow', 'magento_find_test'];
3423
+ 'magento_impact_analysis', 'magento_find_event_flow', 'magento_find_test',
3424
+ 'magento_trace_data_flow', 'magento_find_event_dispatchers'];
3151
3425
  if (warmupInProgress && !indexFreeTools.includes(name)) {
3152
3426
  logToFile('REQ', `${name} → blocked (warmup: loading index)`);
3153
3427
  return {
@@ -3330,12 +3604,65 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3330
3604
  );
3331
3605
  results = rerank(results, { isPlugin: true, pathContains: ['/Plugin/', 'di.xml'] });
3332
3606
 
3333
- return {
3334
- content: [{
3335
- type: 'text',
3336
- text: formatSearchResults(results.slice(0, 15))
3337
- }]
3338
- };
3607
+ // Enrich results with di.xml registration area (frontend/adminhtml/global)
3608
+ const enrichedResults = results.slice(0, 15).map(r => {
3609
+ if (!r.path) return r;
3610
+ let diArea = 'global';
3611
+ if (r.path.includes('/etc/adminhtml/')) diArea = 'adminhtml';
3612
+ else if (r.path.includes('/etc/frontend/')) diArea = 'frontend';
3613
+ else if (r.path.includes('/etc/graphql/')) diArea = 'graphql';
3614
+ else if (r.path.includes('/etc/webapi_rest/')) diArea = 'webapi_rest';
3615
+ else if (r.path.includes('/etc/webapi_soap/')) diArea = 'webapi_soap';
3616
+ else if (r.path.includes('/etc/crontab/')) diArea = 'crontab';
3617
+ return { ...r, diArea };
3618
+ });
3619
+
3620
+ // If targetClass provided, also scan di.xml for explicit registrations
3621
+ let diRegistrations = [];
3622
+ if (args.targetClass) {
3623
+ const fpRoot = config.magentoRoot;
3624
+ const diFiles = await glob('**/etc/**/di.xml', { cwd: fpRoot, absolute: true, nodir: true });
3625
+ const shortTarget = args.targetClass.split('\\').pop();
3626
+ for (const diFile of diFiles) {
3627
+ let content;
3628
+ try { content = readFileSync(diFile, 'utf-8'); } catch { continue; }
3629
+ if (!content.includes(shortTarget)) continue;
3630
+ const relPath = diFile.replace(fpRoot + '/', '');
3631
+ // Find plugin registrations for this target
3632
+ const typeBlockRegex = /<type\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/type>/g;
3633
+ let tm;
3634
+ while ((tm = typeBlockRegex.exec(content)) !== null) {
3635
+ const typeName = tm[1];
3636
+ if (!typeName.includes(shortTarget)) continue;
3637
+ const block = tm[2];
3638
+ const pluginRegex = /<plugin\s+name="([^"]+)"[^>]*type="([^"]+)"[^>]*/g;
3639
+ let pm;
3640
+ while ((pm = pluginRegex.exec(block)) !== null) {
3641
+ let area = 'global';
3642
+ if (relPath.includes('/etc/adminhtml/')) area = 'adminhtml';
3643
+ else if (relPath.includes('/etc/frontend/')) area = 'frontend';
3644
+ else if (relPath.includes('/etc/graphql/')) area = 'graphql';
3645
+ diRegistrations.push({
3646
+ target: typeName,
3647
+ pluginName: pm[1],
3648
+ pluginClass: pm[2],
3649
+ area,
3650
+ file: relPath
3651
+ });
3652
+ }
3653
+ }
3654
+ }
3655
+ }
3656
+
3657
+ let text = formatSearchResults(enrichedResults);
3658
+ if (diRegistrations.length > 0) {
3659
+ text += `\n\n### DI Plugin Registrations for ${args.targetClass} (${diRegistrations.length})\n`;
3660
+ for (const reg of diRegistrations) {
3661
+ text += `- **${reg.pluginName}** → \`${reg.pluginClass}\` [${reg.area}] (${reg.file})\n`;
3662
+ }
3663
+ }
3664
+
3665
+ return { content: [{ type: 'text', text }] };
3339
3666
  }
3340
3667
 
3341
3668
  case 'magento_find_observer': {
@@ -4160,6 +4487,78 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4160
4487
  return { content: [{ type: 'text', text }] };
4161
4488
  }
4162
4489
 
4490
+ case 'magento_trace_data_flow': {
4491
+ const flowResult = await traceDataFlow(args.attributeKey, args.modelClass);
4492
+
4493
+ let text = `## Data Flow: \`${flowResult.attributeKey}\`\n`;
4494
+ text += `Magic setter: \`${flowResult.setterMethod}()\` · Magic getter: \`${flowResult.getterMethod}()\`\n\n`;
4495
+
4496
+ if (flowResult.setters.length > 0) {
4497
+ text += `### Setters (${flowResult.setters.length})\n`;
4498
+ for (const s of flowResult.setters) {
4499
+ const typeTag = s.type === 'constant' ? ' [const]' : '';
4500
+ const methodTag = s.method ? `::${s.method}` : '';
4501
+ text += `- \`${s.class}${methodTag}\`${typeTag} (${s.path}:${s.line})\n`;
4502
+ if (s.snippet) text += ` \`${s.snippet}\`\n`;
4503
+ }
4504
+ text += '\n';
4505
+ } else {
4506
+ text += '### Setters\n_No setters found._\n\n';
4507
+ }
4508
+
4509
+ if (flowResult.getters.length > 0) {
4510
+ text += `### Getters (${flowResult.getters.length})\n`;
4511
+ for (const g of flowResult.getters) {
4512
+ const methodTag = g.method ? `::${g.method}` : '';
4513
+ text += `- \`${g.class}${methodTag}\` (${g.path}:${g.line})\n`;
4514
+ if (g.snippet) text += ` \`${g.snippet}\`\n`;
4515
+ }
4516
+ text += '\n';
4517
+ } else {
4518
+ text += '### Getters\n_No getters found._\n\n';
4519
+ }
4520
+
4521
+ if (flowResult.xmlReferences.length > 0) {
4522
+ text += `### XML References (${flowResult.xmlReferences.length})\n`;
4523
+ for (const x of flowResult.xmlReferences) {
4524
+ text += `- ${x.path}:${x.line}\n \`${x.snippet}\`\n`;
4525
+ }
4526
+ }
4527
+
4528
+ return { content: [{ type: 'text', text }] };
4529
+ }
4530
+
4531
+ case 'magento_find_event_dispatchers': {
4532
+ const dispResult = await findEventDispatchers(args.eventName);
4533
+
4534
+ let text = `## Event Dispatchers: \`${dispResult.eventName}\`\n\n`;
4535
+
4536
+ if (dispResult.dispatchers.length > 0) {
4537
+ text += `Found ${dispResult.dispatchers.length} dispatch location(s)`;
4538
+ if (dispResult.observerCount > 0) {
4539
+ text += ` (${dispResult.observerCount} observer(s) registered)`;
4540
+ }
4541
+ text += '.\n\n';
4542
+
4543
+ for (const d of dispResult.dispatchers) {
4544
+ const methodTag = d.method ? `::${d.method}` : '';
4545
+ text += `### \`${d.class}${methodTag}\`\n`;
4546
+ text += `**File:** ${d.path}:${d.line}\n`;
4547
+ if (d.context) {
4548
+ text += '```php\n' + d.context + '\n```\n';
4549
+ }
4550
+ text += '\n';
4551
+ }
4552
+ } else {
4553
+ text += '_No dispatch() calls found for this event._\n';
4554
+ if (dispResult.observerCount > 0) {
4555
+ text += `\nNote: ${dispResult.observerCount} observer(s) are registered for this event — it may be dispatched by Magento core or a module not in the scanned path.\n`;
4556
+ }
4557
+ }
4558
+
4559
+ return { content: [{ type: 'text', text }] };
4560
+ }
4561
+
4163
4562
  default:
4164
4563
  return {
4165
4564
  content: [{