magector 2.15.1 → 2.16.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.
Files changed (3) hide show
  1. package/README.md +14 -16
  2. package/package.json +5 -5
  3. package/src/mcp-server.js +376 -410
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  Magector is a Model Context Protocol (MCP) server that deeply understands Magento 2 and Adobe Commerce. It builds a semantic vector index of your entire codebase — 18,000+ files across hundreds of modules — and exposes 46 tools that let AI assistants search, navigate, and understand the code with domain-specific intelligence. Instead of grepping for keywords, your AI asks *"how are checkout totals calculated?"* and gets ranked, relevant results in under 50ms, enriched with Magento pattern detection (plugins, observers, controllers, DI preferences, layout XML, and 20+ more).
6
6
 
7
7
  [![Rust](https://img.shields.io/badge/rust-1.75+-orange.svg)](https://www.rust-lang.org)
8
- [![Node.js](https://img.shields.io/badge/node-22.5+-green.svg)](https://nodejs.org)
8
+ [![Node.js](https://img.shields.io/badge/node-18+-green.svg)](https://nodejs.org)
9
9
  [![Magento](https://img.shields.io/badge/magento-2.4.x-blue.svg)](https://magento.com)
10
10
  [![Adobe Commerce](https://img.shields.io/badge/adobe%20commerce-supported-blue.svg)](https://business.adobe.com/products/magento/magento-commerce.html)
11
11
  [![Accuracy](https://img.shields.io/badge/accuracy-99.2%25-brightgreen.svg)](#validation)
@@ -129,8 +129,7 @@ flowchart LR
129
129
  | JS parsing | `tree-sitter-javascript` | AMD/ES6 module detection |
130
130
  | Pattern detection | Custom Rust | 20+ Magento-specific patterns |
131
131
  | CLI | `clap` | Command-line interface (index, search, serve, validate) |
132
- | Descriptions | `rusqlite` (bundled SQLite) | LLM-generated di.xml descriptions stored in `.magector/sqlite.db`, prepended to embeddings |
133
- | Null-safety index | `node:sqlite` (Node.js 22.5+ built-in) | Method-chain enrichment index in `.magector/enrichment.db` — O(1) null-risk queries |
132
+ | Unified metadata | rusqlite (bundled SQLite) | LLM descriptions, method-chain enrichment, process state, cache — all in .magector/data.db |
134
133
  | SONA | Custom Rust | Feedback learning with MicroLoRA + EWC++ |
135
134
  | MCP server | `@modelcontextprotocol/sdk` | AI tool integration with structured JSON output |
136
135
 
@@ -171,8 +170,7 @@ If you find a security issue, please open an issue on the GitHub repo and mark i
171
170
 
172
171
  ### Prerequisites
173
172
 
174
- - [Node.js 22.5+](https://nodejs.org) — required for built-in `node:sqlite` (used by `magento_enrich` / `magento_find_null_risks`)
175
- - [semgrep](https://semgrep.dev) (optional) — required for `magento_ast_search`: `pip install semgrep`
173
+ - [Node.js 18+](https://nodejs.org)
176
174
 
177
175
  ### 1. Initialize in Your Project
178
176
 
@@ -246,7 +244,7 @@ Options:
246
244
  -v, --verbose Enable verbose output
247
245
  ```
248
246
 
249
- When `--descriptions-db` is provided (or auto-detected as `sqlite.db` next to the index), descriptions are prepended to the embedding text as `"Description: {text}\n\n"` before the raw file content. This places semantic terms within the 256-token ONNX window, significantly improving retrieval of di.xml files for natural-language queries.
247
+ When `--descriptions-db` is provided (or auto-detected as `data.db` next to the index), descriptions are prepended to the embedding text as `"Description: {text}\n\n"` before the raw file content. This places semantic terms within the 256-token ONNX window, significantly improving retrieval of di.xml files for natural-language queries.
250
248
 
251
249
  #### `search`
252
250
 
@@ -266,7 +264,7 @@ magector-core describe [OPTIONS]
266
264
 
267
265
  Options:
268
266
  -m, --magento-root <PATH> Path to Magento root directory
269
- -o, --output <PATH> Output SQLite database [default: ./.magector/sqlite.db]
267
+ -o, --output <PATH> Output SQLite database [default: ./.magector/data.db]
270
268
  --force Re-describe all files (ignore cache)
271
269
  ```
272
270
 
@@ -504,7 +502,7 @@ Auto-detects entry type from pattern (`/V1/...` → API, `snake_case` → event,
504
502
  |------|-------------|
505
503
  | `magento_module_structure` | Show complete module structure -- controllers, models, blocks, plugins, observers, configs |
506
504
  | `magento_index` | Trigger re-indexing of the codebase (also kicks off background enrichment) |
507
- | `magento_describe` | Generate LLM descriptions for di.xml files (requires `ANTHROPIC_API_KEY`), stored in `.magector/sqlite.db`, auto-reindexes affected files |
505
+ | `magento_describe` | Generate LLM descriptions for di.xml files (requires `ANTHROPIC_API_KEY`), stored in `.magector/data.db`, auto-reindexes affected files |
508
506
  | `magento_stats` | View index statistics |
509
507
  | `magento_batch` | Execute multiple tool queries in parallel in one MCP roundtrip. Supports all search, find, grep, read, and null-risk tools. Use to avoid N×3-5s roundtrip overhead. |
510
508
  | `magento_grep` | Exact text/regex search across PHP/XML/PHTML files (`grep -rn -E` internally). Supports `filesOnly` mode (like `grep -l`), `context` lines, `ignoreCase`, `include` patterns. **(v2.9)** |
@@ -517,10 +515,10 @@ Auto-detects entry type from pattern (`/V1/...` → API, `snake_case` → event,
517
515
 
518
516
  | Tool | Description |
519
517
  |------|-------------|
520
- | `magento_ast_search` | Structural PHP code search using [semgrep](https://semgrep.dev). Understands PHP AST matches by structure regardless of variable names, ignores comments/strings. Pattern syntax: `$X` = any expression, `$Y` = any identifier, `...` = any args. Example: `$X->getPayment()->$Y(...)`. Requires `semgrep`. **(v2.12)** |
521
- | `magento_enrich` | Build the method-chain enrichment index. Scans all `vendor/` PHP files for `->firstMethod()->secondMethod()` chains and detects null guards in surrounding code. Stores results in `.magector/enrichment.db` (SQLite, `node:sqlite`). Runs automatically after `magento_index`. **(v2.13)** |
518
+ | `magento_ast_search` | Structural PHP code search using tree-sitter. Named patterns: `dataobject-set-null` (detect setX(null) anti-pattern), `unchecked-method-chain` (detect $this->dep->method() chains). Pattern arg is an enum, not free-text. Executed in Rust serve process no external dependency. **(v2.16)** |
519
+ | `magento_enrich` | Build the method-chain enrichment index. Scans all `vendor/` PHP files for `->firstMethod()->secondMethod()` chains and detects null guards in surrounding code. Stores results in `.magector/data.db` (SQLite, via Rust serve). Runs automatically after `magento_index`. **(v2.13, moved to Rust v2.16)** |
522
520
  | `magento_find_null_risks` | Query the enrichment index for method chains without null guards. O(1) SQLite query instead of file scanning. Pass `firstMethod` to filter (e.g., `"getPayment"` → all `->getPayment()->anything()` without null guard). Requires `magento_enrich`. **(v2.13)** |
523
- | `magento_find_dataobject_issues` | Detect `setX(null)` anti-pattern on Magento `DataObject` subclasses. `setX(null)` stores `['x' => null]` in `_data` — `hasX()` (via `array_key_exists`) returns `true` even when the value is `null`, creating false-positive guard conditions. Use during field-lifecycle audits or when debugging "value persists but shouldn't" bugs. Requires `semgrep`. **(v2.15)** |
521
+ | `magento_find_dataobject_issues` | Detect `setX(null)` anti-pattern on Magento `DataObject` subclasses. `setX(null)` stores `['x' => null]` in `_data` — `hasX()` (via `array_key_exists`) returns `true` even when the value is `null`, creating false-positive guard conditions. Use during field-lifecycle audits or when debugging "value persists but shouldn't" bugs. Uses tree-sitter. **(v2.15, tree-sitter v2.16)** |
524
522
 
525
523
  ### Search Enhancements (v2.1)
526
524
 
@@ -908,7 +906,7 @@ npx magector index /path/to/magento
908
906
 
909
907
  Or via the MCP tool: `magento_describe()` generates descriptions and auto-reindexes affected files in one step.
910
908
 
911
- **How it works:** Each di.xml file is sent to Claude Sonnet with a prompt optimized for semantic search retrieval. The resulting description (~70 words) is stored in a SQLite database (`.magector/sqlite.db`). During indexing, descriptions are prepended to the embedding text as `"Description: {text}\n\n"` before the raw file content, placing semantic terms (preferences, plugins, virtual types, subsystem names) within the ONNX model's 256-token window.
909
+ **How it works:** Each di.xml file is sent to Claude Sonnet with a prompt optimized for semantic search retrieval. The resulting description (~70 words) is stored in a SQLite database (`.magector/data.db`). During indexing, descriptions are prepended to the embedding text as `"Description: {text}\n\n"` before the raw file content, placing semantic terms (preferences, plugins, virtual types, subsystem names) within the ONNX model's 256-token window.
912
910
 
913
911
  **Measured impact** (A/B experiment, 25 queries, Magento 2.4.7, 17,891 vectors, 371 described files):
914
912
 
@@ -1249,8 +1247,8 @@ All MCP server activity is logged to `.magector/magector.log` in the Magento pro
1249
1247
  | Level | Meaning |
1250
1248
  |-------|---------|
1251
1249
  | `INFO` | Normal operations: startup config, tool completion, search fallbacks, enrichment progress |
1252
- | `WARN` | Recoverable issues: slow grep queries (>5s), missing enrichment.db, file read errors, serve process disconnects |
1253
- | `ERR` | Failures: semgrep crashes, transaction rollbacks, serve process errors, tool execution errors |
1250
+ | `WARN` | Recoverable issues: slow grep queries (>5s), missing data.db, file read errors, serve process disconnects |
1251
+ | `ERR` | Failures: AST query errors, transaction rollbacks, serve process errors, tool execution errors |
1254
1252
  | `REQ` | Every tool call with full input parameters (JSON) |
1255
1253
  | `RES` | Tool completion with elapsed time in milliseconds |
1256
1254
  | `QUERY` | Rust serve process queries (search, feedback) |
@@ -1271,7 +1269,7 @@ grep '\[RES\]' .magector/magector.log | tail -20
1271
1269
  # Enrichment/null-risk analysis
1272
1270
  grep 'enrich:\|null_risks:' .magector/magector.log | tail -20
1273
1271
 
1274
- # AST search (semgrep) issues
1272
+ # AST search (tree-sitter) issues
1275
1273
  grep 'ast_search:' .magector/magector.log | tail -20
1276
1274
 
1277
1275
  # Batch query breakdown (per-tool timing)
@@ -1288,7 +1286,7 @@ grep 'server starting\|Config:\|primary\|Serve process' .magector/magector.log |
1288
1286
 
1289
1287
  Every tool call logs `[REQ]` with input parameters and `[RES]` with elapsed time. Additionally:
1290
1288
 
1291
- - **`magento_ast_search`** — semgrep pattern, target path, execution time, result count, semgrep errors
1289
+ - **`magento_ast_search`** — tree-sitter pattern, target path, execution time, result count, query errors
1292
1290
  - **`magento_enrich`** — file count, progress every 10k files, read errors, transaction failures, final summary
1293
1291
  - **`magento_find_null_risks`** — query parameters, result count, query timing, missing DB warnings
1294
1292
  - **`magento_batch`** — query list on entry, per-sub-tool timing and errors
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magector",
3
- "version": "2.15.1",
3
+ "version": "2.16.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.15.1",
37
- "@magector/cli-linux-x64": "2.15.1",
38
- "@magector/cli-linux-arm64": "2.15.1",
39
- "@magector/cli-win32-x64": "2.15.1"
36
+ "@magector/cli-darwin-arm64": "2.16.1",
37
+ "@magector/cli-linux-x64": "2.16.1",
38
+ "@magector/cli-linux-arm64": "2.16.1",
39
+ "@magector/cli-win32-x64": "2.16.1"
40
40
  },
41
41
  "keywords": [
42
42
  "magento",
package/src/mcp-server.js CHANGED
@@ -142,6 +142,9 @@ function extractJson(stdout) {
142
142
 
143
143
  // ─── PID File & Orphan Cleanup ──────────────────────────────────
144
144
  // Track the serve process PID to clean up orphans on restart.
145
+ // Primary state lives in data.db (state_processes / state_cache tables).
146
+ // File-based paths are kept as fallback for operations that run before
147
+ // the serve process (and thus data.db) is available.
145
148
 
146
149
  const PID_PATH = path.join(config.magentoRoot, '.magector', 'serve.pid');
147
150
  const REINDEX_PID_PATH = path.join(config.magentoRoot, '.magector', 'reindex.pid');
@@ -222,12 +225,15 @@ function expandIncludePattern(include) {
222
225
  /**
223
226
  * Try to acquire the primary lock (O_EXCL = atomic create-or-fail).
224
227
  * Returns true if we are the primary instance, false if another instance holds the lock.
228
+ * Uses file-based O_EXCL for atomicity (runs before serve/DB is available).
229
+ * Also writes lock state to data.db when serve becomes available.
225
230
  */
226
231
  function tryAcquirePrimaryLock() {
227
232
  try {
228
233
  const fd = openSync(PRIMARY_LOCK_PATH, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL);
229
234
  writeFileSync(fd, String(process.pid));
230
235
  closeSync(fd);
236
+ // DB write deferred — serve is not available during lock acquisition
231
237
  return true;
232
238
  } catch {
233
239
  // Lock file exists — check if holder is alive
@@ -258,6 +264,19 @@ function tryAcquirePrimaryLock() {
258
264
  }
259
265
  }
260
266
 
267
+ /**
268
+ * Write primary lock state to data.db (called after serve becomes available).
269
+ * This mirrors the file-based lock so other processes can query DB for lock owner.
270
+ */
271
+ function persistPrimaryLockToDb() {
272
+ if (serveProcess && serveReady) {
273
+ serveQuery('cache_set', {
274
+ key: 'primary_lock',
275
+ value: JSON.stringify({ pid: process.pid, timestamp: Date.now() })
276
+ }, 5000).catch(() => {});
277
+ }
278
+ }
279
+
261
280
  function releasePrimaryLock() {
262
281
  try {
263
282
  // Only remove if we own it
@@ -266,13 +285,27 @@ function releasePrimaryLock() {
266
285
  unlinkSync(PRIMARY_LOCK_PATH);
267
286
  }
268
287
  } catch {}
288
+ // Also clean DB state — fire-and-forget
289
+ if (serveProcess && serveReady) {
290
+ serveQuery('cache_set', {
291
+ key: 'primary_lock',
292
+ value: JSON.stringify({ pid: null, released: true })
293
+ }, 5000).catch(() => {});
294
+ }
269
295
  }
270
296
 
271
297
  /**
272
298
  * Write the serve process PID to disk so future instances can clean up orphans.
299
+ * Also writes to data.db via serve command when the serve process is available.
300
+ * Note: the Rust serve process writes its own PID to data.db on startup, so
301
+ * the file is primarily a fallback for the brief window before serve is ready.
273
302
  */
274
303
  function writePidFile(pid) {
275
304
  try { writeFileSync(PID_PATH, `${pid}\n${__pkg.version}`); } catch {}
305
+ // Async DB write — fire-and-forget, serve process also writes its own PID
306
+ if (serveProcess && serveReady) {
307
+ serveQuery('process_set', { name: 'serve', pid, version: __pkg.version }, 5000).catch(() => {});
308
+ }
276
309
  }
277
310
 
278
311
  function getServePidVersion() {
@@ -286,21 +319,35 @@ function getServePidVersion() {
286
319
 
287
320
  function removePidFile() {
288
321
  try { if (existsSync(PID_PATH)) unlinkSync(PID_PATH); } catch {}
322
+ // Also clean DB state — fire-and-forget
323
+ if (serveProcess && serveReady) {
324
+ serveQuery('process_remove', { name: 'serve' }, 5000).catch(() => {});
325
+ }
289
326
  }
290
327
 
291
328
  function writeReindexPidFile(pid) {
292
329
  try { writeFileSync(REINDEX_PID_PATH, String(pid)); } catch {}
330
+ // Also persist in data.db when serve is available
331
+ if (serveProcess && serveReady) {
332
+ serveQuery('process_set', { name: 'reindex', pid }, 5000).catch(() => {});
333
+ }
293
334
  }
294
335
 
295
336
  function removeReindexPidFile() {
296
337
  try { if (existsSync(REINDEX_PID_PATH)) unlinkSync(REINDEX_PID_PATH); } catch {}
338
+ // Also clean DB state
339
+ if (serveProcess && serveReady) {
340
+ serveQuery('process_remove', { name: 'reindex' }, 5000).catch(() => {});
341
+ }
297
342
  }
298
343
 
299
344
  /**
300
345
  * Check if another reindex process is already running (from another MCP instance).
346
+ * Checks data.db first (via serve query), falls back to PID file.
301
347
  * Returns the PID if alive, null otherwise.
302
348
  */
303
349
  function getRunningReindexPid() {
350
+ // Try file-based check (synchronous, always available)
304
351
  try {
305
352
  if (!existsSync(REINDEX_PID_PATH)) return null;
306
353
  const pid = parseInt(readFileSync(REINDEX_PID_PATH, 'utf-8').trim(), 10);
@@ -319,6 +366,7 @@ function getRunningReindexPid() {
319
366
  * Returns the PID if alive, null if stale/missing.
320
367
  * Does NOT kill it — multiple MCP instances can share one serve process
321
368
  * by sending queries to it via stdin (each instance starts its own).
369
+ * Tries file-based check (synchronous, always available).
322
370
  */
323
371
  function getExistingServePid() {
324
372
  try {
@@ -337,6 +385,7 @@ function getExistingServePid() {
337
385
  * Kill any stale serve process from a previous MCP server instance.
338
386
  * Only called during cleanup (exit/SIGTERM), not during startup —
339
387
  * multiple concurrent MCP instances each run their own serve process.
388
+ * Reads PID from file (DB may not be available if serve is dead).
340
389
  */
341
390
  function killStaleServeProcess() {
342
391
  try {
@@ -376,8 +425,13 @@ let warmupInProgress = true; // true until checkDbFormat + serve process ready
376
425
 
377
426
  /**
378
427
  * Check if the database file is compatible with the current binary.
379
- * Uses a cached result file to avoid running stats (30-60s) on every startup.
428
+ * Uses a cached result to avoid running stats (30-60s) on every startup.
380
429
  * Cache key: binary path mtime + db file mtime + db size.
430
+ *
431
+ * Cache lookup order:
432
+ * 1. data.db state_cache (via serve query, if serve is available)
433
+ * 2. format-ok.json file (fallback — runs before serve starts)
434
+ * Both locations are written on cache miss.
381
435
  */
382
436
  async function checkDbFormat() {
383
437
  if (!existsSync(config.dbPath)) return true;
@@ -389,10 +443,27 @@ async function checkDbFormat() {
389
443
  // Check cached result — avoids 40s stats command on every MCP startup
390
444
  const binaryStat = statSync(config.rustBinary);
391
445
  const cacheKey = `${binaryStat.mtimeMs}|${fstat.mtimeMs}|${fstat.size}`;
446
+
447
+ // Try DB cache first (if serve process is running)
448
+ const queryFn = globalServeQuery || ((serveProcess && serveReady) ? serveQuery : null);
449
+ if (queryFn) {
450
+ try {
451
+ const resp = await queryFn('cache_get', { key: 'format_ok' }, 5000);
452
+ if (resp.ok && resp.data) {
453
+ const cached = JSON.parse(resp.data.value);
454
+ if (cached.key === cacheKey) {
455
+ logToFile('INFO', `Format check cached (DB): ${cached.ok ? 'compatible' : 'incompatible'}`);
456
+ return cached.ok;
457
+ }
458
+ }
459
+ } catch { /* DB cache miss or unavailable */ }
460
+ }
461
+
462
+ // Fall back to file cache
392
463
  try {
393
464
  const cached = JSON.parse(readFileSync(FORMAT_CACHE_PATH, 'utf-8'));
394
465
  if (cached.key === cacheKey) {
395
- logToFile('INFO', `Format check cached: ${cached.ok ? 'compatible' : 'incompatible'}`);
466
+ logToFile('INFO', `Format check cached (file): ${cached.ok ? 'compatible' : 'incompatible'}`);
396
467
  return cached.ok;
397
468
  }
398
469
  } catch { /* no cache or invalid */ }
@@ -412,8 +483,14 @@ async function checkDbFormat() {
412
483
  const vectors = parseInt(result.match(/Total vectors:\s*(\d+)/)?.[1] || '0');
413
484
  const ok = vectors > 0;
414
485
 
415
- // Write cache
416
- try { writeFileSync(FORMAT_CACHE_PATH, JSON.stringify({ key: cacheKey, ok })); } catch {}
486
+ // Write cache to both file and DB
487
+ const cacheValue = JSON.stringify({ key: cacheKey, ok });
488
+ try { writeFileSync(FORMAT_CACHE_PATH, cacheValue); } catch {}
489
+ // Async DB write — fire-and-forget (serve may not be up yet on first startup)
490
+ const queryFn2 = globalServeQuery || ((serveProcess && serveReady) ? serveQuery : null);
491
+ if (queryFn2) {
492
+ queryFn2('cache_set', { key: 'format_ok', value: cacheValue }, 5000).catch(() => {});
493
+ }
417
494
  return ok;
418
495
  } catch {
419
496
  return false;
@@ -717,6 +794,8 @@ function startServeProcess() {
717
794
  serveReady = true;
718
795
  logToFile('INFO', `Serve process ready (PID ${proc.pid})`);
719
796
  if (serveReadyResolve) { serveReadyResolve(true); serveReadyResolve = null; }
797
+ // Now that serve is up, persist primary lock state to data.db
798
+ persistPrimaryLockToDb();
720
799
  return;
721
800
  }
722
801
 
@@ -859,6 +938,10 @@ function tryConnectSocket() {
859
938
  let globalServeQuery = null;
860
939
 
861
940
  function serveQuery(command, params = {}, timeoutMs = 30000) {
941
+ if (!serveProcess || !serveReady) {
942
+ logToFile('WARN', `serveQuery(${command}): serve process not ready — returning error`);
943
+ return Promise.resolve({ ok: false, error: 'Serve process not ready' });
944
+ }
862
945
  return new Promise((resolve, reject) => {
863
946
  const id = serveNextId++;
864
947
  logToFile('QUERY', `[${id}] → ${command}(${JSON.stringify(params).slice(0, 200)})`);
@@ -3502,302 +3585,35 @@ async function traceCallChain(startClass, startMethod, maxDepth = 3) {
3502
3585
  }
3503
3586
 
3504
3587
  // ─── Method Chain Enrichment ────────────────────────────────────
3505
- // Scans PHP files for two-step method chains (->first()->second()) and detects
3506
- // null guards in surrounding code. Results stored in SQLite enrichment.db for
3507
- // instant O(1) queries — eliminates 20+ grep calls for null-risk analyses.
3508
-
3509
- const ENRICHMENT_DB_PATH = (root) => path.join(root, '.magector', 'enrichment.db');
3510
-
3511
- /**
3512
- * Detect null guard for a chained call in surrounding lines.
3513
- * Checks ±guardRadius lines for: null checks, ?->, ??, isset()
3514
- */
3515
- function hasNullGuard(lines, matchLineIdx, receiverExpr, guardRadius = 6) {
3516
- const start = Math.max(0, matchLineIdx - guardRadius);
3517
- const end = Math.min(lines.length - 1, matchLineIdx + guardRadius);
3518
- const matchLine = lines[matchLineIdx] || '';
3519
- const window = lines.slice(start, end + 1).join('\n');
3520
-
3521
- // ?-> only counts if it's on the same line as the chain (avoid false positives from unrelated variables)
3522
- if (matchLine.includes('?->')) return true;
3523
- if (/\?\?|\?:/.test(window)) return true;
3524
-
3525
- if (receiverExpr) {
3526
- const esc = receiverExpr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3527
- if (new RegExp(`(?:is_null\\s*\\(\\s*${esc}|${esc}\\s*(?:===|!==)\\s*null|!\\s*${esc}\\s*[,)]|isset\\s*\\(\\s*${esc})`, 'i').test(window)) return true;
3528
- }
3529
- return false;
3530
- }
3531
-
3532
- /**
3533
- * Scan vendor/ PHP files for ->first()->second() chains and store null-safety
3534
- * analysis in enrichment.db. Called by magento_enrich and after magento_index.
3535
- */
3536
- async function enrichMethodChains(root) {
3537
- const dbPath = ENRICHMENT_DB_PATH(root);
3538
- logToFile('INFO', `enrich: starting method-chain scan, db=${dbPath}`);
3539
- const enrichStart = Date.now();
3540
-
3541
- // Use node:sqlite (built-in, no deps)
3542
- let DatabaseSync;
3543
- try {
3544
- ({ DatabaseSync } = await import('node:sqlite'));
3545
- } catch {
3546
- logToFile('ERR', 'enrich: node:sqlite not available — requires Node.js 22.5+');
3547
- throw new Error('node:sqlite not available — requires Node.js 22.5+');
3548
- }
3549
-
3550
- const db = new DatabaseSync(dbPath);
3551
- db.exec('PRAGMA journal_mode = WAL');
3552
- db.exec(`
3553
- CREATE TABLE IF NOT EXISTS method_chains (
3554
- id INTEGER PRIMARY KEY AUTOINCREMENT,
3555
- file TEXT NOT NULL,
3556
- line INTEGER NOT NULL,
3557
- chain TEXT NOT NULL,
3558
- first_method TEXT NOT NULL,
3559
- second_method TEXT NOT NULL,
3560
- has_null_guard INTEGER NOT NULL DEFAULT 0,
3561
- updated_at INTEGER NOT NULL
3562
- );
3563
- CREATE INDEX IF NOT EXISTS idx_first_method ON method_chains (first_method);
3564
- CREATE INDEX IF NOT EXISTS idx_null_guard ON method_chains (has_null_guard, first_method);
3565
- `);
3566
-
3567
- // Two-step chain: $var->firstMethod(...)->secondMethod(
3568
- // Captures: receiver ($var), firstMethod, secondMethod
3569
- const chainRegex = /(\$\w+)\s*->\s*(\w+)\s*\([^)]{0,60}\)\s*->\s*(\w+)\s*\(/g;
3570
- const now = Date.now();
3571
-
3572
- const phpFiles = await glob('vendor/**/*.php', { cwd: root, absolute: true, nodir: true });
3573
- logToFile('INFO', `enrich: found ${phpFiles.length} PHP files in vendor/`);
3574
- let scanned = 0;
3575
- let chains = 0;
3576
- let readErrors = 0;
3577
-
3578
- const insertStmt = db.prepare(
3579
- 'INSERT INTO method_chains (file, line, chain, first_method, second_method, has_null_guard, updated_at) VALUES (?,?,?,?,?,?,?)'
3580
- );
3581
- const deleteFile = db.prepare('DELETE FROM method_chains WHERE file = ?');
3582
-
3583
- // Build line-offset index for O(1) line number lookups
3584
- function buildLineIndex(content) {
3585
- const offsets = [0];
3586
- let idx = 0;
3587
- while ((idx = content.indexOf('\n', idx)) !== -1) {
3588
- idx++;
3589
- offsets.push(idx);
3590
- }
3591
- return offsets;
3592
- }
3593
-
3594
- function lineFromOffset(offsets, charIndex) {
3595
- let lo = 0, hi = offsets.length - 1;
3596
- while (lo < hi) {
3597
- const mid = (lo + hi + 1) >> 1;
3598
- if (offsets[mid] <= charIndex) lo = mid; else hi = mid - 1;
3599
- }
3600
- return lo + 1; // 1-based
3601
- }
3588
+ // Enrichment logic has been moved to the Rust serve process (enrich / enrich_query commands).
3589
+ // Use serveQuery('enrich', { magento_root }) and serveQuery('enrich_query', { first_method, limit }).
3602
3590
 
3603
- // Progress logging every 10k files
3604
- const progressInterval = 10000;
3591
+ // ─── AST Search (tree-sitter via Rust serve) ────────────────────
3605
3592
 
3606
- db.exec('BEGIN');
3607
- try {
3608
- for (const phpFile of phpFiles) {
3609
- let content;
3610
- try { content = readFileSync(phpFile, 'utf-8'); } catch (err) {
3611
- readErrors++;
3612
- if (readErrors <= 5) logToFile('WARN', `enrich: cannot read ${phpFile}: ${err.code || err.message}`);
3613
- continue;
3614
- }
3615
- if (!content.includes('->')) continue;
3616
-
3617
- const relPath = phpFile.replace(root + '/', '');
3618
- const lines = content.split('\n');
3619
- const lineOffsets = buildLineIndex(content);
3620
- const rows = [];
3621
-
3622
- chainRegex.lastIndex = 0;
3623
- let m;
3624
- while ((m = chainRegex.exec(content)) !== null) {
3625
- const lineNum = lineFromOffset(lineOffsets, m.index);
3626
- rows.push({
3627
- file: relPath, line: lineNum,
3628
- chain: `->${m[2]}()->${m[3]}()`,
3629
- firstMethod: m[2], secondMethod: m[3],
3630
- hasNullGuard: hasNullGuard(lines, lineNum - 1, m[1]) ? 1 : 0
3631
- });
3632
- chains++;
3633
- }
3634
-
3635
- if (rows.length > 0) {
3636
- deleteFile.run(relPath);
3637
- for (const r of rows) {
3638
- insertStmt.run(r.file, r.line, r.chain, r.firstMethod, r.secondMethod, r.hasNullGuard, now);
3639
- }
3640
- }
3641
- scanned++;
3642
- if (scanned % progressInterval === 0) {
3643
- logToFile('INFO', `enrich: progress ${scanned}/${phpFiles.length} files, ${chains} chains so far (${Date.now() - enrichStart}ms)`);
3644
- }
3645
- }
3646
- db.exec('COMMIT');
3647
- } catch (err) {
3648
- logToFile('ERR', `enrich: transaction failed at file ${scanned}/${phpFiles.length}: ${err.message}`);
3649
- db.exec('ROLLBACK');
3650
- throw err;
3651
- }
3652
-
3653
- db.close();
3654
- const enrichElapsed = Date.now() - enrichStart;
3655
- logToFile('INFO', `enrich: complete — ${scanned} files scanned, ${chains} chains indexed, ${readErrors} read errors, ${enrichElapsed}ms`);
3656
- return { scanned, chains };
3657
- }
3658
-
3659
- /**
3660
- * Query enrichment.db for unsafe method chains (no null guard).
3661
- */
3662
- async function queryNullRisks(root, firstMethod, limit = 100) {
3663
- const dbPath = ENRICHMENT_DB_PATH(root);
3664
- if (!existsSync(dbPath)) {
3665
- logToFile('WARN', `null_risks: enrichment.db not found at ${dbPath} — run magento_enrich first`);
3666
- return null;
3667
- }
3668
-
3669
- let DatabaseSync;
3670
- try {
3671
- ({ DatabaseSync } = await import('node:sqlite'));
3672
- } catch (err) {
3673
- logToFile('ERR', `null_risks: node:sqlite not available: ${err.message}`);
3674
- return null;
3675
- }
3676
-
3677
- const queryStart = Date.now();
3678
- logToFile('INFO', `null_risks: querying firstMethod=${firstMethod || '(all)'} limit=${limit}`);
3679
- const db = new DatabaseSync(dbPath, { open: true });
3680
- let rows;
3681
- try {
3682
- if (firstMethod) {
3683
- rows = db.prepare(
3684
- 'SELECT file, line, chain, second_method FROM method_chains WHERE has_null_guard = 0 AND first_method = ? ORDER BY file, line LIMIT ?'
3685
- ).all(firstMethod, limit);
3686
- } else {
3687
- rows = db.prepare(
3688
- 'SELECT file, line, chain, first_method, second_method FROM method_chains WHERE has_null_guard = 0 ORDER BY first_method, file, line LIMIT ?'
3689
- ).all(limit);
3690
- }
3691
- } finally {
3692
- db.close();
3693
- }
3694
- logToFile('INFO', `null_risks: ${rows.length} unsafe chain(s) found in ${Date.now() - queryStart}ms`);
3695
- return rows;
3696
- }
3697
-
3698
- // ─── AST Search (semgrep) ───────────────────────────────────────
3699
-
3700
- async function astSearch(pattern, searchPath, lang, maxResults) {
3593
+ async function astSearch(patternName, searchPath, maxResults) {
3701
3594
  const root = config.magentoRoot;
3702
3595
  if (!root) throw new Error('MAGENTO_ROOT not set');
3703
3596
 
3704
- const targetPath = searchPath ? safePath(root, searchPath) : path.resolve(root);
3705
- if (!targetPath) {
3706
- logToFile('WARN', `ast_search: rejected path traversal attempt: "${searchPath}"`);
3707
- throw new Error(`Path escapes project root: ${searchPath}`);
3708
- }
3709
- const semgrepLang = lang || 'php';
3710
- const limit = Math.min(maxResults || 50, 200);
3711
-
3712
- logToFile('INFO', `ast_search: pattern="${pattern}" path="${searchPath || '.'}" lang=${semgrepLang} limit=${limit}`);
3713
- const astStart = Date.now();
3714
-
3715
- // Semgrep's default ignore list includes "vendor/" which is exactly what we need to scan.
3716
- // Semgrep resolves .semgrepignore from the git repo root, NOT the scan directory.
3717
- // An empty .semgrepignore at root overrides the defaults: https://semgrep.dev/docs/ignoring-files-folders-code/
3718
- const semgrepIgnorePath = path.join(root, '.semgrepignore');
3719
- let createdSemgrepIgnore = false;
3720
- if (!existsSync(semgrepIgnorePath)) {
3721
- try {
3722
- writeFileSync(semgrepIgnorePath, '# Magector: scan vendor/ and all project files\n');
3723
- createdSemgrepIgnore = true;
3724
- logToFile('INFO', `ast_search: created temporary .semgrepignore at ${root}`);
3725
- } catch (err) {
3726
- logToFile('WARN', `ast_search: failed to create .semgrepignore: ${err.message}`);
3727
- }
3728
- }
3729
-
3730
- const semgrepArgs = [
3731
- '--pattern', pattern,
3732
- '--lang', semgrepLang,
3733
- '--json',
3734
- '--no-git-ignore',
3735
- targetPath
3736
- ];
3597
+ const safeSp = searchPath ? safeRelPath(root, searchPath) : '.';
3598
+ if (searchPath && !safeSp) throw new Error(`Path escapes project root: ${searchPath}`);
3737
3599
 
3738
- let rawOutput;
3739
- try {
3740
- rawOutput = execFileSync('semgrep', semgrepArgs, {
3741
- encoding: 'utf-8',
3742
- timeout: 60000,
3743
- maxBuffer: 20 * 1024 * 1024,
3744
- stdio: ['pipe', 'pipe', 'pipe'],
3745
- env: { ...process.env, PATH: process.env.PATH + ':/home/swed/.local/bin' }
3746
- });
3747
- } catch (err) {
3748
- // semgrep exits non-zero when it has findings — stdout still contains valid JSON
3749
- rawOutput = err.stdout || '';
3750
- if (!rawOutput) {
3751
- const errMsg = (err.stderr || err.message || '').slice(0, 500);
3752
- logToFile('ERR', `ast_search: semgrep failed after ${Date.now() - astStart}ms: ${errMsg}`);
3753
- throw new Error(`semgrep failed: ${errMsg}`);
3754
- }
3755
- } finally {
3756
- if (createdSemgrepIgnore) { try { unlinkSync(semgrepIgnorePath); } catch { /* best effort */ } }
3757
- }
3600
+ const limit = Math.min(maxResults || 50, 200);
3601
+ logToFile('INFO', `ast_search: pattern="${patternName}" path="${safeSp}" limit=${limit}`);
3602
+ const start = Date.now();
3758
3603
 
3759
- let parsed;
3760
- try {
3761
- parsed = JSON.parse(rawOutput);
3762
- } catch {
3763
- logToFile('ERR', `ast_search: failed to parse semgrep JSON output (${rawOutput.length} bytes)`);
3764
- throw new Error(`Failed to parse semgrep output. First 300 chars: ${rawOutput.slice(0, 300)}`);
3765
- }
3604
+ const resp = await serveQuery('ast_query', { pattern: patternName, path: safeSp, limit }, 60000);
3605
+ if (!resp.ok) throw new Error(resp.error || 'ast_query failed');
3766
3606
 
3767
- const findings = (parsed.results || []).slice(0, limit);
3768
- const astElapsed = Date.now() - astStart;
3769
- logToFile('INFO', `ast_search: ${findings.length} match(es) in ${astElapsed}ms (semgrep returned ${(parsed.results || []).length} total)`);
3770
- if (parsed.errors && parsed.errors.length > 0) {
3771
- logToFile('WARN', `ast_search: semgrep reported ${parsed.errors.length} error(s): ${parsed.errors.slice(0, 3).map(e => e.message || e.type || JSON.stringify(e)).join('; ')}`);
3772
- }
3773
- return findings.map(r => {
3774
- // semgrep >=1.100 may return "requires login" in r.extra.lines for unlicensed installs.
3775
- // Fall back to r.extra.message which contains the matched expression (always available).
3776
- const rawLines = r.extra?.lines || '';
3777
- const snippet = (rawLines && rawLines !== 'requires login')
3778
- ? rawLines.trim()
3779
- : (r.extra?.message || '').trim();
3780
- return {
3781
- file: r.path.replace(root + '/', ''),
3782
- line: r.start.line,
3783
- endLine: r.end.line,
3784
- snippet
3785
- };
3786
- });
3607
+ const elapsed = Date.now() - start;
3608
+ const results = resp.data || [];
3609
+ logToFile('INFO', `ast_search: ${results.length} match(es) in ${elapsed}ms`);
3610
+ return results;
3787
3611
  }
3788
3612
 
3789
3613
  // ─── DataObject set-null Anti-pattern Detection ─────────────────
3790
3614
 
3791
3615
  async function findDataObjectIssues(searchPath, maxResults) {
3792
- // Detects DataObject::setX(null) anti-pattern:
3793
- // setX(null) stores ['x' => null] in _data — key EXISTS with null value.
3794
- // hasX() / hasData('x') calls array_key_exists() → returns true even for null.
3795
- // This causes false-positive guard conditions: hasX() passes, getX() returns null.
3796
- // Correct way to fully clear: unsetData('x') removes the key entirely.
3797
- const allResults = await astSearch('$X->$SETTER(null)', searchPath, 'php', 500);
3798
- const setterNullRegex = /->set[A-Z]\w+\s*\(\s*null\s*\)/;
3799
- const limit = Math.min(maxResults || 100, 500);
3800
- return allResults.filter(r => setterNullRegex.test(r.snippet)).slice(0, limit);
3616
+ return astSearch('dataobject-set-null', searchPath, maxResults || 100);
3801
3617
  }
3802
3618
 
3803
3619
  // ─── MCP Server ─────────────────────────────────────────────────
@@ -3819,7 +3635,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
3819
3635
  tools: [
3820
3636
  {
3821
3637
  name: 'magento_search',
3822
- 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.',
3638
+ 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.',
3823
3639
  inputSchema: {
3824
3640
  type: 'object',
3825
3641
  properties: {
@@ -4466,7 +4282,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
4466
4282
  },
4467
4283
  {
4468
4284
  name: 'magento_batch',
4469
- 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).',
4285
+ 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.',
4470
4286
  inputSchema: {
4471
4287
  type: 'object',
4472
4288
  properties: {
@@ -4532,23 +4348,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
4532
4348
  },
4533
4349
  {
4534
4350
  name: 'magento_ast_search',
4535
- 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.',
4351
+ description: 'Structural PHP code search using tree-sitter AST queries. Unlike magento_grep (text-based), this understands PHP AST — matches code structure regardless of variable names, ignores comments/strings. Available named patterns: "dataobject-set-null" (finds ->setX(null) anti-pattern calls), "unchecked-method-chain" (finds ->a()->b() chains without null guards). Uses Rust tree-sitter for fast, accurate parsing. ⚡ For multi-query workflows use magento_batch.',
4536
4352
  inputSchema: {
4537
4353
  type: 'object',
4538
4354
  properties: {
4539
4355
  pattern: {
4540
4356
  type: 'string',
4541
- 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(...)"'
4357
+ enum: ['dataobject-set-null', 'unchecked-method-chain'],
4358
+ description: 'Named AST query pattern. "dataobject-set-null": finds ->setX(null) calls on DataObjects. "unchecked-method-chain": finds ->a()->b() chains without null guards.'
4542
4359
  },
4543
4360
  path: {
4544
4361
  type: 'string',
4545
4362
  description: 'Subdirectory to search (relative to MAGENTO_ROOT). Default: entire codebase. Example: "vendor/acme/"'
4546
4363
  },
4547
- lang: {
4548
- type: 'string',
4549
- description: 'Language to search (default: php). Options: php, xml, js.',
4550
- default: 'php'
4551
- },
4552
4364
  maxResults: {
4553
4365
  type: 'number',
4554
4366
  description: 'Maximum matches to return (default: 50, max: 200)',
@@ -4578,12 +4390,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
4578
4390
  },
4579
4391
  {
4580
4392
  name: 'magento_enrich',
4581
- 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.',
4393
+ 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/data.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.',
4582
4394
  inputSchema: { type: 'object', properties: {} }
4583
4395
  },
4584
4396
  {
4585
4397
  name: 'magento_find_null_risks',
4586
- 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.',
4398
+ 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.',
4587
4399
  inputSchema: {
4588
4400
  type: 'object',
4589
4401
  properties: {
@@ -4928,10 +4740,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4928
4740
  case 'magento_index': {
4929
4741
  const root = args.path || config.magentoRoot;
4930
4742
  const output = rustIndex(root);
4931
- // Auto-enrich after indexing: runs in background, doesn't block response
4743
+ // Auto-enrich after indexing: runs in background via Rust serve, doesn't block response
4932
4744
  logToFile('INFO', 'Auto-enrich: starting in background after index');
4933
- enrichMethodChains(root).then(({ scanned, chains }) => {
4934
- logToFile('INFO', `Auto-enrich complete: ${scanned} files, ${chains} chains`);
4745
+ serveQuery('enrich', { magento_root: root }, 120000).then(resp => {
4746
+ if (resp.ok) {
4747
+ logToFile('INFO', `Auto-enrich complete: ${resp.data.scanned} files, ${resp.data.chains} chains`);
4748
+ } else {
4749
+ logToFile('WARN', `Auto-enrich failed: ${resp.error}`);
4750
+ }
4935
4751
  }).catch(err => {
4936
4752
  logToFile('WARN', `Auto-enrich failed: ${err.message}`);
4937
4753
  });
@@ -5388,43 +5204,63 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5388
5204
  }
5389
5205
 
5390
5206
  case 'magento_module_structure': {
5391
- const raw = await rustSearchAsync(args.moduleName, 200);
5392
- // Support both app/code (Magento/Catalog/) and vendor (module-catalog/) paths
5393
- const modulePath = args.moduleName.replace('_', '/') + '/';
5394
5207
  const parts = args.moduleName.split('_');
5208
+ // Support both app/code (Magento/Catalog/) and vendor (magento/module-catalog/) paths
5209
+ const modulePath = args.moduleName.replace('_', '/') + '/';
5395
5210
  // Hyphenate camelCase for vendor path: OrderSplit → order-split
5211
+ const vendorDir = parts.length === 2 ? parts[0].toLowerCase() : '';
5396
5212
  const vendorPath = parts.length === 2
5397
5213
  ? `module-${parts[1].replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()}/`
5398
5214
  : '';
5399
- let results = raw.map(normalizeResult).filter(r => {
5400
- const p = r.path || '';
5401
- const mod = r.module || '';
5402
- // Exact module match or directory-level path match (trailing slash prevents Catalog matching CatalogRule)
5403
- return mod === args.moduleName ||
5404
- p.includes(modulePath) ||
5405
- (vendorPath && p.toLowerCase().includes(vendorPath));
5406
- });
5407
-
5408
- // Filesystem fallback: if vector search found nothing, glob the module directory
5409
- if (results.length === 0 && config.magentoRoot && vendorPath) {
5410
- logToFile('INFO', `module_structure: vector search returned 0 results for "${args.moduleName}" — using filesystem fallback (glob ${vendorPath})`);
5411
- try {
5412
- const vendorGlob = `**/${vendorPath}**/*.{php,xml,phtml}`;
5413
- const files = await glob(vendorGlob, { cwd: config.magentoRoot, absolute: false, nodir: true });
5414
- for (const f of files.slice(0, 100)) {
5415
- const entry = { path: f, score: 0.5 };
5416
- if (f.includes('/Controller/')) entry.isController = true;
5417
- if (f.includes('/Model/')) entry.isModel = true;
5418
- if (f.includes('/Block/')) entry.isBlock = true;
5419
- if (f.includes('/Plugin/')) entry.isPlugin = true;
5420
- if (f.includes('/Observer/')) entry.isObserver = true;
5421
- if (f.endsWith('.xml')) entry.type = 'xml';
5422
- // Extract class name from path
5423
- const phpMatch = f.match(/\/([A-Z]\w+)\.php$/);
5424
- if (phpMatch) entry.className = phpMatch[1];
5425
- results.push(entry);
5215
+ let results = [];
5216
+
5217
+ // Primary: filesystem-based (authoritative avoids mixing cross-references from vector search)
5218
+ if (config.magentoRoot) {
5219
+ const fsGlobs = [];
5220
+ if (parts.length === 2) {
5221
+ // app/code/{Vendor}/{Module}/
5222
+ fsGlobs.push(`app/code/${parts[0]}/${parts[1]}/**/*.{php,xml,phtml}`);
5223
+ // vendor/{vendor-lower}/{module-lower}/ — vendor-specific to avoid false positives
5224
+ if (vendorDir && vendorPath) {
5225
+ fsGlobs.push(`vendor/${vendorDir}/${vendorPath}**/*.{php,xml,phtml}`);
5426
5226
  }
5427
- } catch {}
5227
+ }
5228
+ for (const globPattern of fsGlobs) {
5229
+ try {
5230
+ const files = await glob(globPattern, { cwd: config.magentoRoot, absolute: false, nodir: true });
5231
+ if (files.length > 0) {
5232
+ logToFile('INFO', `module_structure: filesystem found ${files.length} files for "${args.moduleName}" (${globPattern})`);
5233
+ for (const f of files.slice(0, 100)) {
5234
+ const entry = { path: f, score: 1.0 };
5235
+ if (f.includes('/Controller/')) entry.isController = true;
5236
+ if (f.includes('/Model/')) entry.isModel = true;
5237
+ if (f.includes('/Block/')) entry.isBlock = true;
5238
+ if (f.includes('/Plugin/')) entry.isPlugin = true;
5239
+ if (f.includes('/Observer/')) entry.isObserver = true;
5240
+ if (f.endsWith('.xml')) entry.type = 'xml';
5241
+ const phpMatch = f.match(/\/([A-Z]\w+)\.php$/);
5242
+ if (phpMatch) entry.className = phpMatch[1];
5243
+ results.push(entry);
5244
+ }
5245
+ break; // Found in one location, stop
5246
+ }
5247
+ } catch {}
5248
+ }
5249
+ }
5250
+
5251
+ // Fallback: vector search with strict path/module filtering (only if filesystem found nothing)
5252
+ if (results.length === 0) {
5253
+ logToFile('INFO', `module_structure: filesystem found 0 files for "${args.moduleName}" — falling back to vector search`);
5254
+ const raw = await rustSearchAsync(args.moduleName, 200);
5255
+ results = raw.map(normalizeResult).filter(r => {
5256
+ const p = r.path || '';
5257
+ const mod = r.module || '';
5258
+ // Exact module match or directory-level path match (trailing slash prevents Catalog matching CatalogRule)
5259
+ return mod === args.moduleName ||
5260
+ p.includes(modulePath) ||
5261
+ // Vendor-specific path check to avoid matching other vendors' same-named modules
5262
+ (vendorDir && vendorPath && p.toLowerCase().includes(`${vendorDir}/${vendorPath}`));
5263
+ });
5428
5264
  }
5429
5265
 
5430
5266
  const structure = {
@@ -6370,6 +6206,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6370
6206
  }
6371
6207
  break;
6372
6208
  }
6209
+ case 'magento_find_config': {
6210
+ let cfgQuery = a.query;
6211
+ if (a.configType && a.configType !== 'other') cfgQuery = `${a.configType}.xml xml config ${a.query}`;
6212
+ const cfgRaw = await rustSearchAsync(cfgQuery, 100);
6213
+ let cfgRes = cfgRaw.map(normalizeResult).filter(r =>
6214
+ r.type === 'xml' || r.path?.endsWith('.xml') || r.path?.includes('.xml')
6215
+ );
6216
+ if (a.configType && a.configType !== 'other') {
6217
+ const cfgTypeFile = `${a.configType}.xml`;
6218
+ const cfgTyped = cfgRes.filter(r => r.path?.includes(cfgTypeFile));
6219
+ if (cfgTyped.length >= 3) cfgRes = cfgTyped;
6220
+ }
6221
+ text = formatSearchResults(cfgRes.slice(0, 10));
6222
+ break;
6223
+ }
6373
6224
  case 'magento_trace_dependency': {
6374
6225
  const dep = await traceDependency(a.className, a.direction || 'both');
6375
6226
  text = `Preferences: ${dep.preferences.length}, Plugins: ${dep.plugins.length}, VirtualTypes: ${dep.virtualTypes.length}, Args: ${dep.argumentOverrides.length}\n`;
@@ -6459,35 +6310,52 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6459
6310
  break;
6460
6311
  }
6461
6312
  case 'magento_module_structure': {
6462
- const raw = await rustSearchAsync(a.moduleName, 200);
6463
- const modulePath = a.moduleName.replace('_', '/') + '/';
6464
6313
  const mParts = a.moduleName.split('_');
6465
- // Hyphenate camelCase for vendor path: OrderSplit → order-split
6314
+ const modulePath = a.moduleName.replace('_', '/') + '/';
6315
+ const mVendorDir = mParts.length === 2 ? mParts[0].toLowerCase() : '';
6466
6316
  const vendorPath = mParts.length === 2
6467
6317
  ? `module-${mParts[1].replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()}/`
6468
6318
  : '';
6469
- let res = raw.map(normalizeResult).filter(r => {
6470
- const p = r.path || '';
6471
- const mod = r.module || '';
6472
- return mod === a.moduleName || p.includes(modulePath) || (vendorPath && p.toLowerCase().includes(vendorPath));
6473
- });
6474
- // Filesystem fallback
6475
- if (res.length === 0 && config.magentoRoot && vendorPath) {
6476
- try {
6477
- const vendorGlob = `**/${vendorPath}**/*.{php,xml,phtml}`;
6478
- const files = await glob(vendorGlob, { cwd: config.magentoRoot, absolute: false, nodir: true });
6479
- for (const f of files.slice(0, 100)) {
6480
- const entry = { path: f, score: 0.5 };
6481
- if (f.includes('/Controller/')) entry.isController = true;
6482
- if (f.includes('/Model/')) entry.isModel = true;
6483
- if (f.includes('/Plugin/')) entry.isPlugin = true;
6484
- if (f.includes('/Observer/')) entry.isObserver = true;
6485
- if (f.endsWith('.xml')) entry.type = 'xml';
6486
- const phpMatch = f.match(/\/([A-Z]\w+)\.php$/);
6487
- if (phpMatch) entry.className = phpMatch[1];
6488
- res.push(entry);
6319
+ let res = [];
6320
+ // Primary: filesystem-based (avoids mixing cross-references)
6321
+ if (config.magentoRoot) {
6322
+ const msGlobs = [];
6323
+ if (mParts.length === 2) {
6324
+ msGlobs.push(`app/code/${mParts[0]}/${mParts[1]}/**/*.{php,xml,phtml}`);
6325
+ if (mVendorDir && vendorPath) {
6326
+ msGlobs.push(`vendor/${mVendorDir}/${vendorPath}**/*.{php,xml,phtml}`);
6489
6327
  }
6490
- } catch {}
6328
+ }
6329
+ for (const gp of msGlobs) {
6330
+ try {
6331
+ const files = await glob(gp, { cwd: config.magentoRoot, absolute: false, nodir: true });
6332
+ if (files.length > 0) {
6333
+ for (const f of files.slice(0, 100)) {
6334
+ const entry = { path: f, score: 1.0 };
6335
+ if (f.includes('/Controller/')) entry.isController = true;
6336
+ if (f.includes('/Model/')) entry.isModel = true;
6337
+ if (f.includes('/Plugin/')) entry.isPlugin = true;
6338
+ if (f.includes('/Observer/')) entry.isObserver = true;
6339
+ if (f.endsWith('.xml')) entry.type = 'xml';
6340
+ const phpMatch = f.match(/\/([A-Z]\w+)\.php$/);
6341
+ if (phpMatch) entry.className = phpMatch[1];
6342
+ res.push(entry);
6343
+ }
6344
+ break;
6345
+ }
6346
+ } catch {}
6347
+ }
6348
+ }
6349
+ // Fallback: vector search with vendor-specific path filtering
6350
+ if (res.length === 0) {
6351
+ const raw = await rustSearchAsync(a.moduleName, 200);
6352
+ res = raw.map(normalizeResult).filter(r => {
6353
+ const p = r.path || '';
6354
+ const mod = r.module || '';
6355
+ return mod === a.moduleName ||
6356
+ p.includes(modulePath) ||
6357
+ (mVendorDir && vendorPath && p.toLowerCase().includes(`${mVendorDir}/${vendorPath}`));
6358
+ });
6491
6359
  }
6492
6360
  text = `Module: ${a.moduleName} (${res.length} files)\n`;
6493
6361
  const cats = { controllers: '/Controller/', models: '/Model/', plugins: '/Plugin/', observers: '/Observer/', api: '/Api/' };
@@ -6548,27 +6416,69 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6548
6416
  const maxRes = Math.min(a.maxResults || 30, 100);
6549
6417
  const batchCtx = a.context !== undefined ? a.context : 4;
6550
6418
  const batchFilesOnly = a.filesOnly || false;
6551
- const gArgs = batchFilesOnly ? ['-rl', '-E'] : ['-rn', '-E'];
6552
- if (a.ignoreCase) gArgs.push('-i');
6553
- if (!batchFilesOnly && batchCtx > 0) gArgs.push('-C', String(batchCtx));
6554
- for (const pat of expandIncludePattern(include)) gArgs.push('--include=' + pat);
6555
- gArgs.push('--', a.pattern, searchPath);
6556
- let out;
6557
- try {
6558
- out = execFileSync('grep', gArgs, { cwd: config.magentoRoot, encoding: 'utf-8', timeout: 15000, maxBuffer: 5 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] });
6559
- } catch (err) { out = err.stdout || ''; }
6560
- const gLines = out.trim().split('\n').filter(Boolean);
6561
- if (batchFilesOnly) {
6562
- text = `Files matching \`${a.pattern}\` (${gLines.length}):\n`;
6563
- for (const gl of gLines.slice(0, maxRes)) text += gl + '\n';
6564
- } else {
6565
- text = `Found ${gLines.length} matches${gLines.length > maxRes ? ` (showing ${maxRes})` : ''}:\n`;
6566
- for (const gl of gLines.slice(0, maxRes)) text += gl + '\n';
6419
+
6420
+ // Try Rust serve grep first
6421
+ const batchQueryFn = globalServeQuery || ((serveProcess && serveReady) ? serveQuery : null);
6422
+ let batchGrepDone = false;
6423
+ if (batchQueryFn) {
6424
+ try {
6425
+ const bResp = await batchQueryFn('grep', {
6426
+ pattern: a.pattern,
6427
+ magento_root: config.magentoRoot,
6428
+ path: searchPath,
6429
+ include,
6430
+ context: batchCtx,
6431
+ max_results: maxRes,
6432
+ files_only: batchFilesOnly,
6433
+ ignore_case: a.ignoreCase || false
6434
+ }, 15000);
6435
+ if (bResp.ok && bResp.data) {
6436
+ const bMatches = bResp.data.matches || [];
6437
+ const bTotal = bResp.data.total || bMatches.length;
6438
+ if (batchFilesOnly) {
6439
+ text = `Files matching \`${a.pattern}\` (${bTotal}):\n`;
6440
+ for (const m of bMatches) text += (m.file || m) + '\n';
6441
+ } else {
6442
+ text = `Found ${bTotal} matches${bTotal > maxRes ? ` (showing ${maxRes})` : ''}:\n`;
6443
+ for (const m of bMatches) {
6444
+ if (m.is_context) {
6445
+ text += `${m.file}-${m.line}-${m.text}\n`;
6446
+ } else {
6447
+ text += `${m.file}:${m.line}:${m.text}\n`;
6448
+ }
6449
+ }
6450
+ }
6451
+ batchGrepDone = true;
6452
+ }
6453
+ } catch (bErr) {
6454
+ logToFile('WARN', `batch grep: serve query failed, falling back to external grep: ${bErr.message}`);
6455
+ }
6456
+ }
6457
+
6458
+ // Fallback: external GNU grep (cold-start path or serve error)
6459
+ if (!batchGrepDone) {
6460
+ const gArgs = batchFilesOnly ? ['-rl', '-E'] : ['-rn', '-E'];
6461
+ if (a.ignoreCase) gArgs.push('-i');
6462
+ if (!batchFilesOnly && batchCtx > 0) gArgs.push('-C', String(batchCtx));
6463
+ for (const pat of expandIncludePattern(include)) gArgs.push('--include=' + pat);
6464
+ gArgs.push('--', a.pattern, searchPath);
6465
+ let out;
6466
+ try {
6467
+ out = execFileSync('grep', gArgs, { cwd: config.magentoRoot, encoding: 'utf-8', timeout: 15000, maxBuffer: 5 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] });
6468
+ } catch (err) { out = err.stdout || ''; }
6469
+ const gLines = out.trim().split('\n').filter(Boolean);
6470
+ if (batchFilesOnly) {
6471
+ text = `Files matching \`${a.pattern}\` (${gLines.length}):\n`;
6472
+ for (const gl of gLines.slice(0, maxRes)) text += gl + '\n';
6473
+ } else {
6474
+ text = `Found ${gLines.length} matches${gLines.length > maxRes ? ` (showing ${maxRes})` : ''}:\n`;
6475
+ for (const gl of gLines.slice(0, maxRes)) text += gl + '\n';
6476
+ }
6567
6477
  }
6568
6478
  break;
6569
6479
  }
6570
6480
  case 'magento_ast_search': {
6571
- const astResults = await astSearch(a.pattern, a.path, a.lang, a.maxResults);
6481
+ const astResults = await astSearch(a.pattern, a.path, a.maxResults);
6572
6482
  if (astResults.length === 0) {
6573
6483
  text = `No matches for pattern: \`${a.pattern}\``;
6574
6484
  } else {
@@ -6588,15 +6498,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6588
6498
  break;
6589
6499
  }
6590
6500
  case 'magento_find_null_risks': {
6591
- const bRoot = config.magentoRoot;
6592
6501
  const bLimit = Math.min(a.limit || 100, 500);
6593
- const bRows = bRoot ? await queryNullRisks(bRoot, a.firstMethod || null, bLimit) : null;
6594
- if (!bRows) { text = '⚠️ Run magento_enrich first.'; break; }
6595
- if (bRows.length === 0) { text = 'No unsafe chains found.'; break; }
6596
- text = `Found ${bRows.length} unsafe chain(s):\n`;
6597
- for (const r of bRows.slice(0, 50)) {
6598
- const chain = r.chain || `->${r.first_method}()->${r.second_method}()`;
6599
- text += `${r.file}:${r.line}: ${chain}\n`;
6502
+ try {
6503
+ const bResp = await serveQuery('enrich_query', { first_method: a.firstMethod || null, limit: bLimit }, 30000);
6504
+ if (!bResp.ok) { text = `⚠️ ${bResp.error || 'Query failed'}`; break; }
6505
+ const bRows = bResp.data || [];
6506
+ if (bRows.length === 0) { text = 'No unsafe chains found.'; break; }
6507
+ text = `Found ${bRows.length} unsafe chain(s):\n`;
6508
+ for (const r of bRows.slice(0, 50)) {
6509
+ const chain = r.chain || `->${r.first_method}()->${r.second_method}()`;
6510
+ text += `${r.file}:${r.line}: ${chain}\n`;
6511
+ }
6512
+ } catch (bErr) {
6513
+ text = '⚠️ Run magento_enrich first.';
6600
6514
  }
6601
6515
  break;
6602
6516
  }
@@ -6630,6 +6544,51 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6630
6544
  const maxResults = Math.min(args.maxResults || 50, 200);
6631
6545
  const ctxLines = args.context !== undefined ? args.context : 4;
6632
6546
  const filesOnly = args.filesOnly || false;
6547
+ const grepStart = Date.now();
6548
+
6549
+ // Try Rust serve grep first
6550
+ const queryFn = globalServeQuery || ((serveProcess && serveReady) ? serveQuery : null);
6551
+ let output = null;
6552
+ if (queryFn) {
6553
+ try {
6554
+ const resp = await queryFn('grep', {
6555
+ pattern: args.pattern,
6556
+ magento_root: root,
6557
+ path: searchPath,
6558
+ include,
6559
+ context: ctxLines,
6560
+ max_results: maxResults,
6561
+ files_only: filesOnly,
6562
+ ignore_case: args.ignoreCase || false
6563
+ }, 30000);
6564
+ if (resp.ok && resp.data) {
6565
+ const matches = resp.data.matches || [];
6566
+ const total = resp.data.total || matches.length;
6567
+ const grepElapsed = Date.now() - grepStart;
6568
+ if (grepElapsed > 5000) logToFile('WARN', `grep: slow query "${args.pattern}" — ${total} matches in ${grepElapsed}ms`);
6569
+
6570
+ if (filesOnly) {
6571
+ let text = `## 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`;
6572
+ for (const m of matches) text += (m.file || m) + '\n';
6573
+ return { content: [{ type: 'text', text }] };
6574
+ } else {
6575
+ let text = `## grep: \`${args.pattern}\`\nFound **${total}** matches${total > maxResults ? ` (showing first ${maxResults})` : ''}\n\n`;
6576
+ for (const m of matches) {
6577
+ if (m.is_context) {
6578
+ text += `${m.file}-${m.line}-${m.text}\n`;
6579
+ } else {
6580
+ text += `${m.file}:${m.line}:${m.text}\n`;
6581
+ }
6582
+ }
6583
+ return { content: [{ type: 'text', text }] };
6584
+ }
6585
+ }
6586
+ } catch (err) {
6587
+ logToFile('WARN', `grep: serve query failed, falling back to external grep: ${err.message}`);
6588
+ }
6589
+ }
6590
+
6591
+ // Fallback: external GNU grep (cold-start path or serve error)
6633
6592
  const grepArgs = filesOnly ? ['-rl', '-E'] : ['-rn', '-E'];
6634
6593
  if (args.ignoreCase) grepArgs.push('-i');
6635
6594
  if (!filesOnly && ctxLines > 0) grepArgs.push('-C', String(ctxLines));
@@ -6637,8 +6596,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6637
6596
  grepArgs.push('--include=' + pat);
6638
6597
  }
6639
6598
  grepArgs.push('--', args.pattern, searchPath);
6640
- let output;
6641
- const grepStart = Date.now();
6642
6599
  try {
6643
6600
  output = execFileSync('grep', grepArgs, {
6644
6601
  cwd: root, encoding: 'utf-8', timeout: 30000,
@@ -6859,7 +6816,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6859
6816
  }
6860
6817
 
6861
6818
  case 'magento_ast_search': {
6862
- const astResults = await astSearch(args.pattern, args.path, args.lang, args.maxResults);
6819
+ const astResults = await astSearch(args.pattern, args.path, args.maxResults);
6863
6820
  if (astResults.length === 0) {
6864
6821
  return { content: [{ type: 'text', text: `## magento_ast_search: \`${args.pattern}\`\n\nNo matches found.` }] };
6865
6822
  }
@@ -6894,8 +6851,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6894
6851
  if (!root) return { content: [{ type: 'text', text: 'MAGENTO_ROOT not set.' }], isError: true };
6895
6852
  let text = `## magento_enrich\n\nScanning vendor/ PHP files for method chains...\n`;
6896
6853
  try {
6897
- const { scanned, chains } = await enrichMethodChains(root);
6898
- 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.`;
6854
+ const resp = await serveQuery('enrich', { magento_root: root }, 120000);
6855
+ if (resp.ok) {
6856
+ text += `\n✅ **Done**\n- Files scanned: ${resp.data.scanned}\n- Method chains indexed: ${resp.data.chains}\n- Null-risk index saved to: \`.magector/data.db\``;
6857
+ } else {
6858
+ text += `\n❌ Error: ${resp.error}`;
6859
+ }
6899
6860
  } catch (err) {
6900
6861
  text += `\n❌ Error: ${err.message}`;
6901
6862
  }
@@ -6906,32 +6867,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6906
6867
  const root = config.magentoRoot;
6907
6868
  if (!root) return { content: [{ type: 'text', text: 'MAGENTO_ROOT not set.' }], isError: true };
6908
6869
  const limit = Math.min(args.limit || 100, 500);
6909
- const rows = await queryNullRisks(root, args.firstMethod || null, limit);
6910
- if (rows === null) {
6911
- 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.` }] };
6912
- }
6913
- if (rows.length === 0) {
6870
+ try {
6871
+ const resp = await serveQuery('enrich_query', { first_method: args.firstMethod || null, limit }, 30000);
6872
+ if (!resp.ok) {
6873
+ return { content: [{ type: 'text', text: `## magento_find_null_risks\n\n⚠️ ${resp.error || 'Query failed'}` }] };
6874
+ }
6875
+ const rows = resp.data || [];
6876
+ if (rows.length === 0) {
6877
+ const filter = args.firstMethod ? ` for \`->${args.firstMethod}()\`` : '';
6878
+ return { content: [{ type: 'text', text: `## magento_find_null_risks${filter}\n\nNo unsafe chains found. All detected chains have null guards.` }] };
6879
+ }
6914
6880
  const filter = args.firstMethod ? ` for \`->${args.firstMethod}()\`` : '';
6915
- return { content: [{ type: 'text', text: `## magento_find_null_risks${filter}\n\nNo unsafe chains found. All detected chains have null guards.` }] };
6916
- }
6917
- const filter = args.firstMethod ? ` for \`->${args.firstMethod}()\`` : '';
6918
- let text = `## magento_find_null_risks${filter}\n\nFound **${rows.length}** chain(s) without null guard:\n\n`;
6919
- // Group by chain type for readability
6920
- const byChain = {};
6921
- for (const r of rows) {
6922
- const key = r.chain || `->${r.first_method}()->${r.second_method}()`;
6923
- if (!byChain[key]) byChain[key] = [];
6924
- byChain[key].push(r);
6925
- }
6926
- for (const [chain, sites] of Object.entries(byChain)) {
6927
- text += `### \`${chain}\` (${sites.length} site${sites.length > 1 ? 's' : ''})\n`;
6928
- for (const s of sites.slice(0, 20)) {
6929
- text += `- \`${s.file}:${s.line}\`\n`;
6930
- }
6931
- if (sites.length > 20) text += `- ... and ${sites.length - 20} more\n`;
6932
- text += '\n';
6881
+ let text = `## magento_find_null_risks${filter}\n\nFound **${rows.length}** chain(s) without null guard:\n\n`;
6882
+ // Group by chain type for readability
6883
+ const byChain = {};
6884
+ for (const r of rows) {
6885
+ const key = r.chain || `->${r.first_method}()->${r.second_method}()`;
6886
+ if (!byChain[key]) byChain[key] = [];
6887
+ byChain[key].push(r);
6888
+ }
6889
+ for (const [chain, sites] of Object.entries(byChain)) {
6890
+ text += `### \`${chain}\` (${sites.length} site${sites.length > 1 ? 's' : ''})\n`;
6891
+ for (const s of sites.slice(0, 20)) {
6892
+ text += `- \`${s.file}:${s.line}\`\n`;
6893
+ }
6894
+ if (sites.length > 20) text += `- ... and ${sites.length - 20} more\n`;
6895
+ text += '\n';
6896
+ }
6897
+ return { content: [{ type: 'text', text }] };
6898
+ } catch (err) {
6899
+ return { content: [{ type: 'text', text: `## magento_find_null_risks\n\n⚠️ Enrichment index not found. Run \`magento_enrich\` first.` }] };
6933
6900
  }
6934
- return { content: [{ type: 'text', text }] };
6935
6901
  }
6936
6902
 
6937
6903
  default: