magector 2.12.0 → 2.13.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.
- package/package.json +5 -5
- package/src/mcp-server.js +259 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magector",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.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.
|
|
37
|
-
"@magector/cli-linux-x64": "2.
|
|
38
|
-
"@magector/cli-linux-arm64": "2.
|
|
39
|
-
"@magector/cli-win32-x64": "2.
|
|
36
|
+
"@magector/cli-darwin-arm64": "2.13.1",
|
|
37
|
+
"@magector/cli-linux-x64": "2.13.1",
|
|
38
|
+
"@magector/cli-linux-arm64": "2.13.1",
|
|
39
|
+
"@magector/cli-win32-x64": "2.13.1"
|
|
40
40
|
},
|
|
41
41
|
"keywords": [
|
|
42
42
|
"magento",
|
package/src/mcp-server.js
CHANGED
|
@@ -3425,6 +3425,175 @@ async function traceCallChain(startClass, startMethod, maxDepth = 3) {
|
|
|
3425
3425
|
return result;
|
|
3426
3426
|
}
|
|
3427
3427
|
|
|
3428
|
+
// ─── Method Chain Enrichment ────────────────────────────────────
|
|
3429
|
+
// Scans PHP files for two-step method chains (->first()->second()) and detects
|
|
3430
|
+
// null guards in surrounding code. Results stored in SQLite enrichment.db for
|
|
3431
|
+
// instant O(1) queries — eliminates 20+ grep calls for null-risk analyses.
|
|
3432
|
+
|
|
3433
|
+
const ENRICHMENT_DB_PATH = (root) => path.join(root, '.magector', 'enrichment.db');
|
|
3434
|
+
|
|
3435
|
+
/**
|
|
3436
|
+
* Detect null guard for a chained call in surrounding lines.
|
|
3437
|
+
* Checks ±guardRadius lines for: null checks, ?->, ??, isset()
|
|
3438
|
+
*/
|
|
3439
|
+
function hasNullGuard(lines, matchLineIdx, receiverExpr, guardRadius = 6) {
|
|
3440
|
+
const start = Math.max(0, matchLineIdx - guardRadius);
|
|
3441
|
+
const end = Math.min(lines.length - 1, matchLineIdx + guardRadius);
|
|
3442
|
+
const matchLine = lines[matchLineIdx] || '';
|
|
3443
|
+
const window = lines.slice(start, end + 1).join('\n');
|
|
3444
|
+
|
|
3445
|
+
// ?-> only counts if it's on the same line as the chain (avoid false positives from unrelated variables)
|
|
3446
|
+
if (matchLine.includes('?->')) return true;
|
|
3447
|
+
if (/\?\?|\?:/.test(window)) return true;
|
|
3448
|
+
|
|
3449
|
+
if (receiverExpr) {
|
|
3450
|
+
const esc = receiverExpr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
3451
|
+
if (new RegExp(`(?:is_null\\s*\\(\\s*${esc}|${esc}\\s*(?:===|!==)\\s*null|!\\s*${esc}\\s*[,)]|isset\\s*\\(\\s*${esc})`, 'i').test(window)) return true;
|
|
3452
|
+
}
|
|
3453
|
+
return false;
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3456
|
+
/**
|
|
3457
|
+
* Scan vendor/ PHP files for ->first()->second() chains and store null-safety
|
|
3458
|
+
* analysis in enrichment.db. Called by magento_enrich and after magento_index.
|
|
3459
|
+
*/
|
|
3460
|
+
async function enrichMethodChains(root) {
|
|
3461
|
+
const dbPath = ENRICHMENT_DB_PATH(root);
|
|
3462
|
+
|
|
3463
|
+
// Use node:sqlite (built-in, no deps)
|
|
3464
|
+
let DatabaseSync;
|
|
3465
|
+
try {
|
|
3466
|
+
({ DatabaseSync } = await import('node:sqlite'));
|
|
3467
|
+
} catch {
|
|
3468
|
+
throw new Error('node:sqlite not available — requires Node.js 22.5+');
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
const db = new DatabaseSync(dbPath);
|
|
3472
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
3473
|
+
db.exec(`
|
|
3474
|
+
CREATE TABLE IF NOT EXISTS method_chains (
|
|
3475
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3476
|
+
file TEXT NOT NULL,
|
|
3477
|
+
line INTEGER NOT NULL,
|
|
3478
|
+
chain TEXT NOT NULL,
|
|
3479
|
+
first_method TEXT NOT NULL,
|
|
3480
|
+
second_method TEXT NOT NULL,
|
|
3481
|
+
has_null_guard INTEGER NOT NULL DEFAULT 0,
|
|
3482
|
+
updated_at INTEGER NOT NULL
|
|
3483
|
+
);
|
|
3484
|
+
CREATE INDEX IF NOT EXISTS idx_first_method ON method_chains (first_method);
|
|
3485
|
+
CREATE INDEX IF NOT EXISTS idx_null_guard ON method_chains (has_null_guard, first_method);
|
|
3486
|
+
`);
|
|
3487
|
+
|
|
3488
|
+
// Two-step chain: $var->firstMethod(...)->secondMethod(
|
|
3489
|
+
// Captures: receiver ($var), firstMethod, secondMethod
|
|
3490
|
+
const chainRegex = /(\$\w+)\s*->\s*(\w+)\s*\([^)]{0,60}\)\s*->\s*(\w+)\s*\(/g;
|
|
3491
|
+
const now = Date.now();
|
|
3492
|
+
|
|
3493
|
+
const phpFiles = await glob('vendor/**/*.php', { cwd: root, absolute: true, nodir: true });
|
|
3494
|
+
let scanned = 0;
|
|
3495
|
+
let chains = 0;
|
|
3496
|
+
|
|
3497
|
+
const insertStmt = db.prepare(
|
|
3498
|
+
'INSERT INTO method_chains (file, line, chain, first_method, second_method, has_null_guard, updated_at) VALUES (?,?,?,?,?,?,?)'
|
|
3499
|
+
);
|
|
3500
|
+
const deleteFile = db.prepare('DELETE FROM method_chains WHERE file = ?');
|
|
3501
|
+
|
|
3502
|
+
// Build line-offset index for O(1) line number lookups
|
|
3503
|
+
function buildLineIndex(content) {
|
|
3504
|
+
const offsets = [0];
|
|
3505
|
+
let idx = 0;
|
|
3506
|
+
while ((idx = content.indexOf('\n', idx)) !== -1) {
|
|
3507
|
+
idx++;
|
|
3508
|
+
offsets.push(idx);
|
|
3509
|
+
}
|
|
3510
|
+
return offsets;
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
function lineFromOffset(offsets, charIndex) {
|
|
3514
|
+
let lo = 0, hi = offsets.length - 1;
|
|
3515
|
+
while (lo < hi) {
|
|
3516
|
+
const mid = (lo + hi + 1) >> 1;
|
|
3517
|
+
if (offsets[mid] <= charIndex) lo = mid; else hi = mid - 1;
|
|
3518
|
+
}
|
|
3519
|
+
return lo + 1; // 1-based
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
db.exec('BEGIN');
|
|
3523
|
+
try {
|
|
3524
|
+
for (const phpFile of phpFiles) {
|
|
3525
|
+
let content;
|
|
3526
|
+
try { content = readFileSync(phpFile, 'utf-8'); } catch { continue; }
|
|
3527
|
+
if (!content.includes('->')) continue;
|
|
3528
|
+
|
|
3529
|
+
const relPath = phpFile.replace(root + '/', '');
|
|
3530
|
+
const lines = content.split('\n');
|
|
3531
|
+
const lineOffsets = buildLineIndex(content);
|
|
3532
|
+
const rows = [];
|
|
3533
|
+
|
|
3534
|
+
chainRegex.lastIndex = 0;
|
|
3535
|
+
let m;
|
|
3536
|
+
while ((m = chainRegex.exec(content)) !== null) {
|
|
3537
|
+
const lineNum = lineFromOffset(lineOffsets, m.index);
|
|
3538
|
+
rows.push({
|
|
3539
|
+
file: relPath, line: lineNum,
|
|
3540
|
+
chain: `->${m[2]}()->${m[3]}()`,
|
|
3541
|
+
firstMethod: m[2], secondMethod: m[3],
|
|
3542
|
+
hasNullGuard: hasNullGuard(lines, lineNum - 1, m[1]) ? 1 : 0
|
|
3543
|
+
});
|
|
3544
|
+
chains++;
|
|
3545
|
+
}
|
|
3546
|
+
|
|
3547
|
+
if (rows.length > 0) {
|
|
3548
|
+
deleteFile.run(relPath);
|
|
3549
|
+
for (const r of rows) {
|
|
3550
|
+
insertStmt.run(r.file, r.line, r.chain, r.firstMethod, r.secondMethod, r.hasNullGuard, now);
|
|
3551
|
+
}
|
|
3552
|
+
}
|
|
3553
|
+
scanned++;
|
|
3554
|
+
}
|
|
3555
|
+
db.exec('COMMIT');
|
|
3556
|
+
} catch (err) {
|
|
3557
|
+
db.exec('ROLLBACK');
|
|
3558
|
+
throw err;
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3561
|
+
db.close();
|
|
3562
|
+
return { scanned, chains };
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
/**
|
|
3566
|
+
* Query enrichment.db for unsafe method chains (no null guard).
|
|
3567
|
+
*/
|
|
3568
|
+
async function queryNullRisks(root, firstMethod, limit = 100) {
|
|
3569
|
+
const dbPath = ENRICHMENT_DB_PATH(root);
|
|
3570
|
+
if (!existsSync(dbPath)) return null;
|
|
3571
|
+
|
|
3572
|
+
let DatabaseSync;
|
|
3573
|
+
try {
|
|
3574
|
+
({ DatabaseSync } = await import('node:sqlite'));
|
|
3575
|
+
} catch {
|
|
3576
|
+
return null;
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3579
|
+
const db = new DatabaseSync(dbPath, { open: true });
|
|
3580
|
+
let rows;
|
|
3581
|
+
try {
|
|
3582
|
+
if (firstMethod) {
|
|
3583
|
+
rows = db.prepare(
|
|
3584
|
+
'SELECT file, line, chain, second_method FROM method_chains WHERE has_null_guard = 0 AND first_method = ? ORDER BY file, line LIMIT ?'
|
|
3585
|
+
).all(firstMethod, limit);
|
|
3586
|
+
} else {
|
|
3587
|
+
rows = db.prepare(
|
|
3588
|
+
'SELECT file, line, chain, first_method, second_method FROM method_chains WHERE has_null_guard = 0 ORDER BY first_method, file, line LIMIT ?'
|
|
3589
|
+
).all(limit);
|
|
3590
|
+
}
|
|
3591
|
+
} finally {
|
|
3592
|
+
db.close();
|
|
3593
|
+
}
|
|
3594
|
+
return rows;
|
|
3595
|
+
}
|
|
3596
|
+
|
|
3428
3597
|
// ─── AST Search (semgrep) ───────────────────────────────────────
|
|
3429
3598
|
|
|
3430
3599
|
async function astSearch(pattern, searchPath, lang, maxResults) {
|
|
@@ -4243,6 +4412,29 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
4243
4412
|
required: ['pattern']
|
|
4244
4413
|
}
|
|
4245
4414
|
},
|
|
4415
|
+
{
|
|
4416
|
+
name: 'magento_enrich',
|
|
4417
|
+
description: 'Build the method-chain enrichment index. Scans all vendor/ PHP files for two-step method chains (->firstMethod()->secondMethod()) and analyses whether each call has a null guard in surrounding code. Results stored in .magector/enrichment.db. Run this once after magento_index, then use magento_find_null_risks for instant O(1) null-safety queries instead of 20+ grep calls. Also runs automatically after magento_index completes.',
|
|
4418
|
+
inputSchema: { type: 'object', properties: {} }
|
|
4419
|
+
},
|
|
4420
|
+
{
|
|
4421
|
+
name: 'magento_find_null_risks',
|
|
4422
|
+
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
|
+
inputSchema: {
|
|
4424
|
+
type: 'object',
|
|
4425
|
+
properties: {
|
|
4426
|
+
firstMethod: {
|
|
4427
|
+
type: 'string',
|
|
4428
|
+
description: 'Filter by first method name. Example: "getPayment" returns all ->getPayment()->$X() without null guard. Omit to get all unsafe chains.'
|
|
4429
|
+
},
|
|
4430
|
+
limit: {
|
|
4431
|
+
type: 'number',
|
|
4432
|
+
description: 'Maximum results (default: 100, max: 500)',
|
|
4433
|
+
default: 100
|
|
4434
|
+
}
|
|
4435
|
+
}
|
|
4436
|
+
}
|
|
4437
|
+
},
|
|
4246
4438
|
{
|
|
4247
4439
|
name: 'magento_trace_api',
|
|
4248
4440
|
description: 'Trace a REST or GraphQL API endpoint from URL to implementation. Parses webapi.xml to find the service interface, resolves the DI preference to the concrete class, reads the execute/method body, and checks di.xml for constructor arguments. Returns the complete chain in one call.',
|
|
@@ -4309,7 +4501,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4309
4501
|
// These tools have filesystem/di.xml fallbacks — work without serve process
|
|
4310
4502
|
'magento_find_class', 'magento_find_method', 'magento_find_plugin',
|
|
4311
4503
|
'magento_find_observer', 'magento_find_di_wiring', 'magento_module_structure',
|
|
4312
|
-
'magento_batch', 'magento_find_config', 'magento_find_callers', 'magento_grep', 'magento_read', 'magento_trace_api', 'magento_ast_search'];
|
|
4504
|
+
'magento_batch', 'magento_find_config', 'magento_find_callers', 'magento_grep', 'magento_read', 'magento_trace_api', 'magento_ast_search', 'magento_find_null_risks'];
|
|
4313
4505
|
if (warmupInProgress && !indexFreeTools.includes(name)) {
|
|
4314
4506
|
logToFile('REQ', `${name} → blocked (warmup: loading index)`);
|
|
4315
4507
|
return {
|
|
@@ -4572,10 +4764,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4572
4764
|
case 'magento_index': {
|
|
4573
4765
|
const root = args.path || config.magentoRoot;
|
|
4574
4766
|
const output = rustIndex(root);
|
|
4767
|
+
// Auto-enrich after indexing: runs in background, doesn't block response
|
|
4768
|
+
enrichMethodChains(root).then(({ scanned, chains }) => {
|
|
4769
|
+
logToFile('INFO', `Auto-enrich complete: ${scanned} files, ${chains} chains`);
|
|
4770
|
+
}).catch(err => {
|
|
4771
|
+
logToFile('WARN', `Auto-enrich failed: ${err.message}`);
|
|
4772
|
+
});
|
|
4575
4773
|
return {
|
|
4576
4774
|
content: [{
|
|
4577
4775
|
type: 'text',
|
|
4578
|
-
text: `Indexing complete (Rust core).\n\n${output}`
|
|
4776
|
+
text: `Indexing complete (Rust core).\n\n${output}\n\n_Method-chain enrichment index is being built in the background. Use \`magento_enrich\` to run it manually or wait ~30-60s._`
|
|
4579
4777
|
}]
|
|
4580
4778
|
};
|
|
4581
4779
|
}
|
|
@@ -6173,8 +6371,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
6173
6371
|
const maxRes = Math.min(a.maxResults || 30, 100);
|
|
6174
6372
|
const batchCtx = a.context !== undefined ? a.context : 4;
|
|
6175
6373
|
const batchFilesOnly = a.filesOnly || false;
|
|
6176
|
-
const gArgs = ['-rn', '-E'];
|
|
6177
|
-
if (batchFilesOnly) { gArgs[0] = '-rl'; gArgs.splice(1, 1); } // -rl = recursive + files-only, drop -n
|
|
6374
|
+
const gArgs = batchFilesOnly ? ['-rl', '-E'] : ['-rn', '-E'];
|
|
6178
6375
|
if (a.ignoreCase) gArgs.push('-i');
|
|
6179
6376
|
if (!batchFilesOnly && batchCtx > 0) gArgs.push('-C', String(batchCtx));
|
|
6180
6377
|
for (const pat of include.split(',').map(p => p.trim())) gArgs.push('--include=' + pat);
|
|
@@ -6203,6 +6400,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
6203
6400
|
}
|
|
6204
6401
|
break;
|
|
6205
6402
|
}
|
|
6403
|
+
case 'magento_find_null_risks': {
|
|
6404
|
+
const bRoot = config.magentoRoot;
|
|
6405
|
+
const bLimit = Math.min(a.limit || 100, 500);
|
|
6406
|
+
const bRows = bRoot ? await queryNullRisks(bRoot, a.firstMethod || null, bLimit) : null;
|
|
6407
|
+
if (!bRows) { text = '⚠️ Run magento_enrich first.'; break; }
|
|
6408
|
+
if (bRows.length === 0) { text = 'No unsafe chains found.'; break; }
|
|
6409
|
+
text = `Found ${bRows.length} unsafe chain(s):\n`;
|
|
6410
|
+
for (const r of bRows.slice(0, 50)) {
|
|
6411
|
+
const chain = r.chain || `->${r.first_method}()->${r.second_method}()`;
|
|
6412
|
+
text += `${r.file}:${r.line}: ${chain}\n`;
|
|
6413
|
+
}
|
|
6414
|
+
break;
|
|
6415
|
+
}
|
|
6206
6416
|
default:
|
|
6207
6417
|
text = `Unsupported batch tool: ${q.tool}`;
|
|
6208
6418
|
}
|
|
@@ -6460,6 +6670,51 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
6460
6670
|
return { content: [{ type: 'text', text }] };
|
|
6461
6671
|
}
|
|
6462
6672
|
|
|
6673
|
+
case 'magento_enrich': {
|
|
6674
|
+
const root = config.magentoRoot;
|
|
6675
|
+
if (!root) return { content: [{ type: 'text', text: 'MAGENTO_ROOT not set.' }], isError: true };
|
|
6676
|
+
let text = `## magento_enrich\n\nScanning vendor/ PHP files for method chains...\n`;
|
|
6677
|
+
try {
|
|
6678
|
+
const { scanned, chains } = await enrichMethodChains(root);
|
|
6679
|
+
text += `\n✅ **Done**\n- Files scanned: ${scanned}\n- Method chains indexed: ${chains}\n- Null-risk index saved to: \`.magector/enrichment.db\`\n\nUse \`magento_find_null_risks\` to query unsafe chains.`;
|
|
6680
|
+
} catch (err) {
|
|
6681
|
+
text += `\n❌ Error: ${err.message}`;
|
|
6682
|
+
}
|
|
6683
|
+
return { content: [{ type: 'text', text }] };
|
|
6684
|
+
}
|
|
6685
|
+
|
|
6686
|
+
case 'magento_find_null_risks': {
|
|
6687
|
+
const root = config.magentoRoot;
|
|
6688
|
+
if (!root) return { content: [{ type: 'text', text: 'MAGENTO_ROOT not set.' }], isError: true };
|
|
6689
|
+
const limit = Math.min(args.limit || 100, 500);
|
|
6690
|
+
const rows = await queryNullRisks(root, args.firstMethod || null, limit);
|
|
6691
|
+
if (rows === null) {
|
|
6692
|
+
return { content: [{ type: 'text', text: `## magento_find_null_risks\n\n⚠️ Enrichment index not found. Run \`magento_enrich\` first to build the method-chain index.` }] };
|
|
6693
|
+
}
|
|
6694
|
+
if (rows.length === 0) {
|
|
6695
|
+
const filter = args.firstMethod ? ` for \`->${args.firstMethod}()\`` : '';
|
|
6696
|
+
return { content: [{ type: 'text', text: `## magento_find_null_risks${filter}\n\nNo unsafe chains found. All detected chains have null guards.` }] };
|
|
6697
|
+
}
|
|
6698
|
+
const filter = args.firstMethod ? ` for \`->${args.firstMethod}()\`` : '';
|
|
6699
|
+
let text = `## magento_find_null_risks${filter}\n\nFound **${rows.length}** chain(s) without null guard:\n\n`;
|
|
6700
|
+
// Group by chain type for readability
|
|
6701
|
+
const byChain = {};
|
|
6702
|
+
for (const r of rows) {
|
|
6703
|
+
const key = r.chain || `->${r.first_method}()->${r.second_method}()`;
|
|
6704
|
+
if (!byChain[key]) byChain[key] = [];
|
|
6705
|
+
byChain[key].push(r);
|
|
6706
|
+
}
|
|
6707
|
+
for (const [chain, sites] of Object.entries(byChain)) {
|
|
6708
|
+
text += `### \`${chain}\` (${sites.length} site${sites.length > 1 ? 's' : ''})\n`;
|
|
6709
|
+
for (const s of sites.slice(0, 20)) {
|
|
6710
|
+
text += `- \`${s.file}:${s.line}\`\n`;
|
|
6711
|
+
}
|
|
6712
|
+
if (sites.length > 20) text += `- ... and ${sites.length - 20} more\n`;
|
|
6713
|
+
text += '\n';
|
|
6714
|
+
}
|
|
6715
|
+
return { content: [{ type: 'text', text }] };
|
|
6716
|
+
}
|
|
6717
|
+
|
|
6463
6718
|
default:
|
|
6464
6719
|
return {
|
|
6465
6720
|
content: [{
|