magector 2.11.0 → 2.12.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 +149 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magector",
3
- "version": "2.11.0",
3
+ "version": "2.12.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.11.0",
37
- "@magector/cli-linux-x64": "2.11.0",
38
- "@magector/cli-linux-arm64": "2.11.0",
39
- "@magector/cli-win32-x64": "2.11.0"
36
+ "@magector/cli-darwin-arm64": "2.12.0",
37
+ "@magector/cli-linux-x64": "2.12.0",
38
+ "@magector/cli-linux-arm64": "2.12.0",
39
+ "@magector/cli-win32-x64": "2.12.0"
40
40
  },
41
41
  "keywords": [
42
42
  "magento",
package/src/mcp-server.js CHANGED
@@ -3425,6 +3425,66 @@ async function traceCallChain(startClass, startMethod, maxDepth = 3) {
3425
3425
  return result;
3426
3426
  }
3427
3427
 
3428
+ // ─── AST Search (semgrep) ───────────────────────────────────────
3429
+
3430
+ async function astSearch(pattern, searchPath, lang, maxResults) {
3431
+ const root = config.magentoRoot;
3432
+ if (!root) throw new Error('MAGENTO_ROOT not set');
3433
+
3434
+ const targetPath = searchPath ? path.join(root, searchPath) : root;
3435
+ const semgrepLang = lang || 'php';
3436
+ const limit = Math.min(maxResults || 50, 200);
3437
+
3438
+ // Create a temporary empty .semgrepignore in the target directory if none exists.
3439
+ // Semgrep's default ignore list includes "vendor/" which is exactly what we need to scan.
3440
+ // An empty .semgrepignore overrides the defaults: https://semgrep.dev/docs/ignoring-files-folders-code/
3441
+ const semgrepIgnorePath = path.join(targetPath, '.semgrepignore');
3442
+ let createdSemgrepIgnore = false;
3443
+ if (!existsSync(semgrepIgnorePath)) {
3444
+ try { writeFileSync(semgrepIgnorePath, '# Magector: scan vendor/ and all project files\n'); createdSemgrepIgnore = true; } catch { /* best effort */ }
3445
+ }
3446
+
3447
+ const semgrepArgs = [
3448
+ '--pattern', pattern,
3449
+ '--lang', semgrepLang,
3450
+ '--json',
3451
+ '--no-git-ignore',
3452
+ targetPath
3453
+ ];
3454
+
3455
+ let rawOutput;
3456
+ try {
3457
+ rawOutput = execFileSync('semgrep', semgrepArgs, {
3458
+ encoding: 'utf-8',
3459
+ timeout: 60000,
3460
+ maxBuffer: 20 * 1024 * 1024,
3461
+ stdio: ['pipe', 'pipe', 'pipe'],
3462
+ env: { ...process.env, PATH: process.env.PATH + ':/home/swed/.local/bin' }
3463
+ });
3464
+ } catch (err) {
3465
+ // semgrep exits non-zero when it has findings — stdout still contains valid JSON
3466
+ rawOutput = err.stdout || '';
3467
+ if (!rawOutput) throw new Error(`semgrep failed: ${(err.stderr || err.message || '').slice(0, 500)}`);
3468
+ } finally {
3469
+ if (createdSemgrepIgnore) { try { unlinkSync(semgrepIgnorePath); } catch { /* best effort */ } }
3470
+ }
3471
+
3472
+ let parsed;
3473
+ try {
3474
+ parsed = JSON.parse(rawOutput);
3475
+ } catch {
3476
+ throw new Error(`Failed to parse semgrep output. First 300 chars: ${rawOutput.slice(0, 300)}`);
3477
+ }
3478
+
3479
+ const findings = (parsed.results || []).slice(0, limit);
3480
+ return findings.map(r => ({
3481
+ file: r.path.replace(root + '/', ''),
3482
+ line: r.start.line,
3483
+ endLine: r.end.line,
3484
+ snippet: (r.extra?.lines || '').trim()
3485
+ }));
3486
+ }
3487
+
3428
3488
  // ─── MCP Server ─────────────────────────────────────────────────
3429
3489
 
3430
3490
  const server = new Server(
@@ -4143,8 +4203,41 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
4143
4203
  },
4144
4204
  context: {
4145
4205
  type: 'number',
4146
- description: 'Lines of context around each match (default: 2). Like grep -C.',
4147
- default: 2
4206
+ description: 'Lines of context around each match (default: 4). Like grep -C. Use 0 for broad scans with many matches, then batch-read specific files.',
4207
+ default: 4
4208
+ },
4209
+ filesOnly: {
4210
+ type: 'boolean',
4211
+ description: 'Return only file paths (like grep -l). No content, no context. Use for discovery: first find which files match, then batch-read them with magento_read. Dramatically reduces tokens when pattern matches many files.',
4212
+ default: false
4213
+ }
4214
+ },
4215
+ required: ['pattern']
4216
+ }
4217
+ },
4218
+ {
4219
+ name: 'magento_ast_search',
4220
+ description: 'Structural PHP code search using semgrep patterns. Unlike magento_grep (text-based), this understands PHP AST — matches code structure regardless of variable names, ignores comments/strings, understands operator precedence. Use when grep gives false positives or you need structural awareness. Pattern syntax: $X = any expression/variable, $Y = any identifier, ... = any arguments. Examples: "$ORDER->getPayment()->$M(...)" finds all method calls on payment regardless of variable name; "$X->getPayment()->$Y(...)" finds all two-step chains involving getPayment. ⚡ For multi-query workflows use magento_batch.',
4221
+ inputSchema: {
4222
+ type: 'object',
4223
+ properties: {
4224
+ pattern: {
4225
+ type: 'string',
4226
+ description: 'Semgrep PHP pattern. $X = any expr, $Y = any identifier, ... = any args. Examples: "$X->getPayment()->$Y(...)", "if ($X !== null) { ... $X->$Y(...) }", "$X = $Y->getPayment(); ... $X->$Z(...)"'
4227
+ },
4228
+ path: {
4229
+ type: 'string',
4230
+ description: 'Subdirectory to search (relative to MAGENTO_ROOT). Default: entire codebase. Example: "vendor/acme/"'
4231
+ },
4232
+ lang: {
4233
+ type: 'string',
4234
+ description: 'Language to search (default: php). Options: php, xml, js.',
4235
+ default: 'php'
4236
+ },
4237
+ maxResults: {
4238
+ type: 'number',
4239
+ description: 'Maximum matches to return (default: 50, max: 200)',
4240
+ default: 50
4148
4241
  }
4149
4242
  },
4150
4243
  required: ['pattern']
@@ -4216,7 +4309,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4216
4309
  // These tools have filesystem/di.xml fallbacks — work without serve process
4217
4310
  'magento_find_class', 'magento_find_method', 'magento_find_plugin',
4218
4311
  'magento_find_observer', 'magento_find_di_wiring', 'magento_module_structure',
4219
- 'magento_batch', 'magento_find_config', 'magento_find_callers', 'magento_grep', 'magento_read', 'magento_trace_api'];
4312
+ 'magento_batch', 'magento_find_config', 'magento_find_callers', 'magento_grep', 'magento_read', 'magento_trace_api', 'magento_ast_search'];
4220
4313
  if (warmupInProgress && !indexFreeTools.includes(name)) {
4221
4314
  logToFile('REQ', `${name} → blocked (warmup: loading index)`);
4222
4315
  return {
@@ -4578,8 +4671,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4578
4671
  if (pluginFile) {
4579
4672
  reg.methods = extractPluginMethods(pluginFile);
4580
4673
  reg.resolvedFile = pluginFile.replace(fpRoot2 + '/', '');
4581
- // Read full method bodies so the agent sees actual code without follow-up calls
4582
- for (const m of reg.methods) {
4674
+ // Read method bodies only for targetMethod if specified (reduces token bloat)
4675
+ const methodsToRead = args.targetMethod
4676
+ ? reg.methods.filter(m => m.targetMethod === args.targetMethod)
4677
+ : reg.methods;
4678
+ for (const m of methodsToRead) {
4583
4679
  const body = readFullMethodBody(pluginFile, m.name);
4584
4680
  if (body) m.body = body;
4585
4681
  }
@@ -6075,10 +6171,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6075
6171
  const searchPath = a.path || '.';
6076
6172
  const include = a.include || '*.php';
6077
6173
  const maxRes = Math.min(a.maxResults || 30, 100);
6078
- const batchCtx = a.context !== undefined ? a.context : 2;
6174
+ const batchCtx = a.context !== undefined ? a.context : 4;
6175
+ const batchFilesOnly = a.filesOnly || false;
6079
6176
  const gArgs = ['-rn', '-E'];
6177
+ if (batchFilesOnly) { gArgs[0] = '-rl'; gArgs.splice(1, 1); } // -rl = recursive + files-only, drop -n
6080
6178
  if (a.ignoreCase) gArgs.push('-i');
6081
- if (batchCtx > 0) gArgs.push('-C', String(batchCtx));
6179
+ if (!batchFilesOnly && batchCtx > 0) gArgs.push('-C', String(batchCtx));
6082
6180
  for (const pat of include.split(',').map(p => p.trim())) gArgs.push('--include=' + pat);
6083
6181
  gArgs.push('--', a.pattern, searchPath);
6084
6182
  let out;
@@ -6086,8 +6184,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6086
6184
  out = execFileSync('grep', gArgs, { cwd: config.magentoRoot, encoding: 'utf-8', timeout: 15000, maxBuffer: 5 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] });
6087
6185
  } catch (err) { out = err.stdout || ''; }
6088
6186
  const gLines = out.trim().split('\n').filter(Boolean);
6089
- text = `Found ${gLines.length} matches${gLines.length > maxRes ? ` (showing ${maxRes})` : ''}:\n`;
6090
- for (const gl of gLines.slice(0, maxRes)) text += gl + '\n';
6187
+ if (batchFilesOnly) {
6188
+ text = `Files matching \`${a.pattern}\` (${gLines.length}):\n`;
6189
+ for (const gl of gLines.slice(0, maxRes)) text += gl + '\n';
6190
+ } else {
6191
+ text = `Found ${gLines.length} matches${gLines.length > maxRes ? ` (showing ${maxRes})` : ''}:\n`;
6192
+ for (const gl of gLines.slice(0, maxRes)) text += gl + '\n';
6193
+ }
6194
+ break;
6195
+ }
6196
+ case 'magento_ast_search': {
6197
+ const astResults = await astSearch(a.pattern, a.path, a.lang, a.maxResults);
6198
+ if (astResults.length === 0) {
6199
+ text = `No matches for pattern: \`${a.pattern}\``;
6200
+ } else {
6201
+ text = `Found ${astResults.length} match(es) for \`${a.pattern}\`:\n\n`;
6202
+ for (const r of astResults) text += `${r.file}:${r.line}: ${r.snippet}\n`;
6203
+ }
6091
6204
  break;
6092
6205
  }
6093
6206
  default:
@@ -6112,10 +6225,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6112
6225
  const searchPath = args.path || '.';
6113
6226
  const include = args.include || '*.php';
6114
6227
  const maxResults = Math.min(args.maxResults || 50, 200);
6115
- const ctxLines = args.context !== undefined ? args.context : 2;
6116
- const grepArgs = ['-rn', '-E'];
6228
+ const ctxLines = args.context !== undefined ? args.context : 4;
6229
+ const filesOnly = args.filesOnly || false;
6230
+ const grepArgs = filesOnly ? ['-rl', '-E'] : ['-rn', '-E'];
6117
6231
  if (args.ignoreCase) grepArgs.push('-i');
6118
- if (ctxLines > 0) grepArgs.push('-C', String(ctxLines));
6232
+ if (!filesOnly && ctxLines > 0) grepArgs.push('-C', String(ctxLines));
6119
6233
  // Support multiple include patterns (e.g., "*.{php,xml}")
6120
6234
  for (const pat of include.split(',').map(p => p.trim())) {
6121
6235
  grepArgs.push('--include=' + pat);
@@ -6135,8 +6249,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6135
6249
  const lines = output.trim().split('\n').filter(Boolean);
6136
6250
  const total = lines.length;
6137
6251
  const truncated = lines.slice(0, maxResults);
6138
- let text = `## grep: \`${args.pattern}\`\n`;
6139
- text += `Found **${total}** matches${total > maxResults ? ` (showing first ${maxResults})` : ''}\n\n`;
6252
+ let text = filesOnly
6253
+ ? `## grep (files only): \`${args.pattern}\`\nFound **${total}** file(s)${total > maxResults ? ` (showing first ${maxResults})` : ''}. Use magento_read with methodName to read specific methods.\n\n`
6254
+ : `## grep: \`${args.pattern}\`\nFound **${total}** matches${total > maxResults ? ` (showing first ${maxResults})` : ''}\n\n`;
6140
6255
  for (const line of truncated) {
6141
6256
  text += line + '\n';
6142
6257
  }
@@ -6321,10 +6436,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6321
6436
  const numbered = sliced.map((line, i) => `${start + i + 1}\t${line}`).join('\n');
6322
6437
  let text = `## ${args.path}`;
6323
6438
  if (args.startLine || args.endLine) text += ` (lines ${start + 1}-${end})`;
6439
+ // Hint: large file read without method extraction wastes tokens
6440
+ if (!args.startLine && !args.endLine && allLines.length > 100) {
6441
+ const methodMatches = content.match(/(?:public|protected|private)\s+(?:static\s+)?function\s+(\w+)/g) || [];
6442
+ const methodNames = methodMatches.slice(0, 8).map(m => m.replace(/.*function\s+/, '')).join(', ');
6443
+ text += `\n> 💡 **${allLines.length} lines** — add \`methodName\` to extract a single method (~10× fewer tokens). Methods: ${methodNames}${methodMatches.length > 8 ? ', ...' : ''}`;
6444
+ }
6324
6445
  text += `\n\n${numbered}`;
6325
6446
  return { content: [{ type: 'text', text }] };
6326
6447
  }
6327
6448
 
6449
+ case 'magento_ast_search': {
6450
+ const astResults = await astSearch(args.pattern, args.path, args.lang, args.maxResults);
6451
+ if (astResults.length === 0) {
6452
+ return { content: [{ type: 'text', text: `## magento_ast_search: \`${args.pattern}\`\n\nNo matches found.` }] };
6453
+ }
6454
+ let text = `## magento_ast_search: \`${args.pattern}\`\n`;
6455
+ text += `Found **${astResults.length}** match(es)\n\n`;
6456
+ for (const r of astResults) {
6457
+ const lineInfo = r.endLine && r.endLine !== r.line ? `${r.line}-${r.endLine}` : String(r.line);
6458
+ text += `**${r.file}:${lineInfo}**\n\`\`\`php\n${r.snippet}\n\`\`\`\n\n`;
6459
+ }
6460
+ return { content: [{ type: 'text', text }] };
6461
+ }
6462
+
6328
6463
  default:
6329
6464
  return {
6330
6465
  content: [{