Skip to content
106 changes: 106 additions & 0 deletions docs/mcp-server.rst
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,112 @@ Claude Desktop

זה סימן **לנסות שוב אחרי המתנה קצרה** — לא להסיק שהקובץ או הריפו לא קיימים.

.. _mcp-outline:

מפת סימבולים (``outline``)
---------------------------

``codekeeper_get_repo_file`` עם ``outline=true`` מחזיר את **המפה** של הקובץ
במקום את התוכן: כל פונקציה וכל מחלקה, עם שורת התחלה וסיום. משם ממשיכים
ל-``lines=[start, end]`` וקוראים בדיוק את מה שצריך.

.. code-block:: json

{"ok": true,
"status": "outline",
"file": {"path": "webapp/app.py", "ref": "refs/heads/main",
"resolved_commit": "aca7599", "size": 805594, "lines": 20035,
"encoding": "utf-8"},
"symbols": [{"name": "api_db_collections", "start": 5421, "end": 5481}],
"total": 530, "page": 1, "per_page": 100}

זו התשובה במלואה, לא קטע ממנה: ``ok`` ו-``file`` תמיד שם, ואין שדה
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
``truncated``.

.. note::

**אין כאן ``truncated``, ובכוונה.** ב-``codekeeper_list_repo_tree`` המילה
הזו פירושה "תקציב הפלט חתך רשומות **בתוך** העמוד" — לא "יש עמוד נוסף".
באאוטליין אין חיתוך כזה: עמוד שאינו נכנס בתקציב **נדחה במלואו** ולא
מוחזר חלקית, כי חיתוך בתוך עמוד יחד עם עימוד אריתמטי מאבד סימבולים —
העמוד נעצר באמצע והבא מתחיל אחרי ``per_page`` המלא.

הגודל נמדד על הסריאליזציה האמיתית ב-UTF-8, ולא בספירת תווים. גרסה
קודמת חסמה את אורך השם ב-200 **תווים** והסיקה מכך שהעמוד נכנס — וזה
שגוי פעמיים: תו CJK הוא שלושה בתים, כך שעמוד מקסימלי הגיע ל-323,000
בתים מול תקציב של 256,000; וקיצוץ השם הרס את הזהות, כך ש-``symbol=``
עם השם המלא כבר לא מצא את הסימבול.

שדה שתמיד ``false`` ומסמן משמעות אחרת מזו של הכלי השכן גרוע מהיעדרו.
**מה שאומר אם יש עוד עמודים הוא ``total`` מול ``page`` ו-``per_page``.**

**מגבלת הגודל.** אאוטליין נשפט מול אותה תקרה של קריאת טווח — **10MB**, ולא
500KB — כי הוא קורא את הקובץ ומחזיר פלט זעיר. ראו :ref:`mcp-line-range`.

**``lines`` ו-``outline`` אינם מצטברים.** קריאה שמעבירה את שניהם מוחזרת
כ-``{"ok": false, "error": "outline_and_lines"}``, ולא מתעלמת בשקט מאחד
מהם. בקשו מפה, ואז טווח.

**השם מלא, עם נקודות.** ``ClassName.method``, ``outer.inner``,
``_register_repo_tools.get_repo_file``. הכלל אחד: מרחב שמות בפייתון הוא
פונקציה או מחלקה, ולכן התחילית גדלה רק בהם. ``if``, ``try``, ``with``
ו-``for`` **אינם** מרחב שמות — פונקציה שהוגדרה בתוך ``except ImportError``
מופיעה ברמה שלה, בלי תחילית.

.. note::

הכלל הזה הוא כל הפיצ'ר. בלעדיו ``mcp_server/server.py`` החזיר תשעה
סימבולים במקום 38, ו-``build_mcp`` נראתה כבלוק אטום של 513 שורות שכל
כלי ה-MCP חבויים בתוכו. גרסת ביניים שירדה רק לתוך גופי פונקציות עדיין
החמיצה 62 סימבולים ב-``webapp/app.py``, כי מחלקות ה-fallback שם יושבות
בתוך ``except ImportError``.

**שורת ההתחלה היא של המעטר.** ב-``webapp/app.py`` 203 מתוך 408 הפונקציות
ברמה העליונה מעוטרות; טווח שהיה מתחיל ב-``def`` היה מחמיץ את
``@app.route(...)``, כלומר בדיוק את השורה שמזהה את הנתיב.

**``symbol=`` מסנן על השם המלא**, ללא תלות ברישיות. לכן
``symbol="build_mcp"`` מחזיר גם את הפונקציה וגם את כל מה שמוגדר בתוכה —
"תן לי הכול תחת המרחב הזה". ``total`` סופר את ההתאמות **אחרי** הסינון, כי
עליו נשען העימוד. על ``webapp/app.py``, ``symbol="api_db"`` מחזיר חמישה
סימבולים במקום 530.

**שמות אינם ייחודיים.** ``if``/``else`` אינם מרחב שמות, ולכן שתי הגדרות
בשני הענפים חולקות שם מלא ונבדלות רק בשורה. שתיהן מוחזרות — בריפו הזה יש
היום שישה מקרים כאלה. הרשימה ממוינת לפי שורת התחלה, ושובר-השוויון הוא השם,
כדי שגבול העמוד יהיה יציב בין קריאות.

**השם מוחזר במלואו ולעולם אינו מקוצץ** — מזהה שנחתך אינו מזהה, ו-``symbol=``
עם השם המלא חייב למצוא אותו.

**הסיומות הנתמכות** הן ``.py`` ו-``.pyi``, ללא תלות ברישיות. ``symbol=``
משווה ב-``casefold``, כך ש-``STRASSE`` מוצא את ``straße``.

.. list-table::
:header-rows: 1
:widths: 34 66

* - מצב
- התשובה
* - קובץ שאינו פייתון
- ``{"ok": true, "file": {...}, "status": "no_outline",
"reason": "unsupported_language"}``
* - תחביר שבור, פייתון 2, קידוד פגום
- ``{"ok": true, "file": {...}, "status": "no_outline",
"reason": "parse_error", "error_type": "UnicodeEncodeError", "line": 12}``.
``error_type`` הוא שם מחלקת החריגה — בלעדיו אי אפשר לאבחן למה קובץ
שנראה תקין לא נותן מפה
* - עמוד גדול מתקציב הפלט
- ``{"ok": false, "error": "page_too_large", "bytes": 292783,
"max": 256000, "per_page": 500}``. בקשו ``per_page`` קטן יותר; אין
כאן חיתוך שקט

.. important::

``no_outline`` הוא ``status`` ולא ``error``, בדיוק כמו ``binary``:
הקריאה **הצליחה**, פשוט אין תוכן מהסוג שביקשו. מי שבודק רק אם נזרקה
חריגה יראה קובץ שבור כקובץ בלי סימבולים — לכן חובה לבדוק את ``status``.

.. _mcp-line-range:

קריאת טווח שורות
Expand Down
144 changes: 144 additions & 0 deletions mcp_server/outline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""חילוץ מפת סימבולים מקובץ פייתון, לניווט בקבצים גדולים.

הכלי מחזיר שם, שורת התחלה ושורת סיום לכל פונקציה ומחלקה, כדי שהקורא
יוכל להמשיך ל-``lines=[start, end]`` במקום לנחש חלון. ``webapp/app.py``
הוא 20,035 שורות; בלי מפה, סוכן שמחפש בו פונקציה קורא ומנחש.

**ערוץ הכשל הוא ערך ההחזרה, לא חריגה.** הפונקציה מחזירה תמיד מילון עם
``status``: ``"ok"`` או ``"no_outline"``. קורא שבודק רק אם נזרקה חריגה
יקבל "אין אאוטליין" בשקט ויחשוב שהקובץ ריק — זהו דפוס K11 ב-
``amir-bug-patterns``, ולכן הבחירה מוצהרת כאן ולא משתמעת.
"""

from __future__ import annotations

import ast
from typing import Any

#: מרחב שמות בפייתון הוא פונקציה או מחלקה. ``if``/``try``/``with``/``for``
#: **אינם** — הם משנים זרימה, לא שיוך. לכן המעבר חוצה אותם בלי להוסיף
#: תחילית, ופונקציה שהוגדרה בתוך ``except ImportError`` נשארת ברמה שלה.
_FUNCTION_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
_SCOPE_NODES = _FUNCTION_NODES + (ast.ClassDef,)

#: ``.pyi`` הוא פייתון תקין ש-``ast.parse`` מנתחת, והוא נפוץ הרבה יותר
#: מסיומת באותיות גדולות. הבדיקה מנורמלת, ולא השוואת מחרוזת אחת.
_PYTHON_SUFFIXES = frozenset({".py", ".pyi"})


def extract_outline(text: str, path: str, symbol: str | None = None) -> dict[str, Any]:
"""מפת הסימבולים של ``text``, או ``no_outline`` עם הסיבה.

``symbol`` מסנן לפי תת-מחרוזת ב**שם המלא**, ללא תלות ברישיות. לכן
``symbol="build_mcp"`` מחזיר גם את הפונקציה וגם את כל מה שמוגדר
בתוכה — "תן לי הכול תחת המרחב הזה". ``total`` סופר את ההתאמות אחרי
הסינון, כי עליו נשען העימוד.
"""
if not any(path.lower().endswith(suffix) for suffix in _PYTHON_SUFFIXES):
return {"status": "no_outline", "reason": "unsupported_language"}

# ההרחבה הגורפת עוטפת **רק** את הפרסינג, ובכוונה. הקלט הוא קובץ
# שמישהו אחר כתב, וסוג החריגה משתנה בין גרסאות פייתון: בייט אפס הוא
# ``SyntaxError`` ב-3.11 ו-``ValueError`` במקומות אחרים, ו-surrogate
# לא חוקי הוא ``UnicodeEncodeError``. מניית סוגים כאן היא בדיוק
# הגישה השברירית שהכלל נגד הרחבת except בא למנוע — הרחבה מנומקת
# עדיפה על רשימה שתתיישן בשקט.
#
# מעבר העץ שמתחת רץ **מחוץ** ל-``try``: באג שלנו חייב ליפול בקול ולא
# להתחפש ל"אין אאוטליין" על קובץ תקין לגמרי.
try:
tree = ast.parse(text)
except Exception as error:
return {
"status": "no_outline",
"reason": "parse_error",
"error_type": type(error).__name__,
"line": getattr(error, "lineno", None),
}

rows = _collect(tree, text.split("\n"))

# ממוין לפי שורת התחלה, ושובר-שוויון לפי שם. אין כאן מקרה של מעטר
# משותף — מעטר שייך לסימבול אחד — אבל שובר-שוויון קבוע הוא מה שהופך
# את גבול העמוד ליציב בין קריאה לקריאה.
rows.sort(key=lambda row: (row["start"], row["name"]))

if symbol:
# ``casefold`` ולא ``lower``: זה הפרימיטיב להשוואה חסרת-רישיות.
# ``lower`` מפספסת מיפויים של יותר מתו אחד, למשל ``straße`` מול
# ``STRASSE``.
needle = symbol.casefold()
rows = [row for row in rows if needle in row["name"].casefold()]

return {"status": "ok", "symbols": rows, "total": len(rows)}


def _start_line(
node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef, lines: list[str]
) -> int:
"""שורת ההתחלה: המעטר הראשון, ולא ה-``def``.

ב-``webapp/app.py`` 203 מתוך 408 הפונקציות ברמה העליונה מעוטרות, וטווח
שהיה מתחיל ב-``def`` היה מחמיץ את ``@app.route(...)``.

``decorator_list[i].lineno`` הוא שורת ה**ביטוי**, לא שורת ה-``@``.
בכתיב הרגיל הם זהים, אבל ב-``@(``ואז ירידת שורה הביטוי מתחיל שורה
מאוחר יותר — נמדד. לכן סריקה אחורה עד השורה שמתחילה ב-``@``. מעטר
קודם שייתפס בדרך שייך לאותו סימבול ממילא, ולכן הרחבה כזו נכונה.
"""
# ``decorator_list`` קיים בוודאות בשלושת הטיפוסים שבחתימה, ולכן גישה
# ישירה ולא ``getattr`` עם ברירת מחדל — הגנה שהטיפוס כבר נותן היא
# רעש שמסתיר את מה שבאמת יכול להיכשל.
decorators = node.decorator_list
start = min([node.lineno] + [d.lineno for d in decorators])
if not decorators:
return start
# סריקה אחורה עד השורה שפותחת ב-``@``. בין ה-``@`` לבין הביטוי יכולות
# לשבת שורות ריקות והערות, ולכן הן מדולגות — אבל **רק** אם בסוף נוחתים
# על ``@`` אמיתי. אם לא, נשארים במקום: עדיף טווח מדויק-חלקית מטווח
# שבלע שורות של סימבול קודם.
index = start - 1 # 0-based
scan = index
while scan > 0:
previous = lines[scan - 1].strip()
if previous.startswith("@"):
scan -= 1
index = scan
continue
if previous == "" or previous.startswith("#"):
scan -= 1
continue
break
return index + 1


def _collect(tree: ast.AST, lines: list[str]) -> list[dict[str, Any]]:
"""מעבר על העץ עם מחסנית מפורשת, ולא ברקורסיה.

קינון עמוק לא יכול לייצר ``RecursionError`` בצד שלנו — וזה מה שמאפשר
ל-``except`` שלמעלה להישאר צר סביב הפרסינג בלבד, בלי פיתוי להרחיב
אותו כדי לבלוע נפילה של המעבר.
"""
rows: list[dict[str, Any]] = []
stack: list[tuple[ast.AST, str]] = [(tree, "")]
while stack:
node, prefix = stack.pop()
for child in ast.iter_child_nodes(node):
if isinstance(child, _SCOPE_NODES):
name = f"{prefix}{child.name}"
rows.append(
{
"name": name,
# שורת ההתחלה היא של המעטר הראשון ולא של ה-``def``.
# ב-``webapp/app.py`` 203 מתוך 408 הפונקציות ברמה
# העליונה מעוטרות, ובלי זה טווח שנקרא לפי האאוטליין
# היה מתחיל **אחרי** ``@app.route(...)`` — כלומר
# מחמיץ את השורה שמזהה את הנתיב.
"start": _start_line(child, lines),
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
"end": child.end_lineno or child.lineno,
}
)
stack.append((child, f"{name}."))
else:
stack.append((child, prefix))
return rows
Loading
Loading