diff --git a/docs/environment-variables.rst b/docs/environment-variables.rst index a8bb3a994..b5aae9844 100644 --- a/docs/environment-variables.rst +++ b/docs/environment-variables.rst @@ -2576,6 +2576,18 @@ - ``300`` - ``120`` - MCP + * - ``MCP_MAX_REQUEST_BYTES`` + - תקרת גודל (בתים) לגוף בקשה לשרת ה-MCP, במתודות שנושאות גוף (``POST``/``PUT``/``PATCH``) ולכל הכלים. ברירת המחדל **נגזרת** מ-``MAX_CODE_SIZE`` (``request_bytes_for`` ב-``mcp_server/limits.py``: פי שישה ועוד מעטפת, מעוגל ל-MiB שלם — 1MiB על ברירת המחדל של ``MAX_CODE_SIZE``), והמשתנה הזה רק מעלה אותה בלי דיפלוי; הוא אינו kill switch — ``0`` אינו מכבה, המינימום ``65536`` חל. ``Content-Length`` מעל התקרה נדחה ב-``413`` עם ``{"error": "body_too_large", "max_bytes": ...}`` בלי שנקרא בית; גוף בלי אורך מוצהר נספר עד התקרה תחת דדליין של 30 שניות (``408 body_read_timeout``). ערך שאינו מספר — ברירת המחדל, עם WARNING. ראו "גבולות הבקשה" ב-:doc:`mcp-server`. + - לא + - ``1048576`` + - ``2097152`` + - MCP + * - ``MCP_RATE_LIMIT_PER_MINUTE`` + - כמה קריאות כלים מותרות לזהות אחת (משתמש מאומת) בחלון מתגלגל של דקה, בשרת ה-MCP. קריאה מעבר לזה מחזירה ``{"ok": false, "error": "rate_limited", "limit_per_minute": ..., "retry_after_seconds": ...}`` בלי לתפוס חוט. ‏``0`` מכבה במפורש (WARNING בעלייה); ערך שאינו מספר — ברירת המחדל. ‏``/healthz`` ושאר נתיבי ה-HTTP אינם נספרים. ראו "גבולות הבקשה" ב-:doc:`mcp-server`. + - לא + - ``60`` + - ``120`` + - MCP * - ``POSTHOG_PROJECT_TOKEN`` - טוקן הפרויקט ב-PostHog (``phc_...``) עבור מדידת השימוש בשרת ה-MCP. חסר (או חסר ``POSTHOG_HOST``) — בפרודקשן המדידה כבויה והשרת עולה רגיל; בסביבת פיתוח העלייה נכשלת ברעש. ראו :ref:`mcp-analytics`. - לא diff --git a/docs/mcp-server.rst b/docs/mcp-server.rst index e8948f355..9562392fa 100644 --- a/docs/mcp-server.rst +++ b/docs/mcp-server.rst @@ -115,8 +115,9 @@ usable=8)`` מול ``cpu quota cgroup v2: 0.50 cpu``, כלומר 12 חוטים המספרים מחדש **לשני הפרסרים** — מריצים אותו כשאחד מהם משתנה. - **ההחזקה הגדולה ביותר של חוט קריאה אינה הפרסור, וזו החלטה מתועדת.** כלי האדמין ``codekeeper_get_repo_file`` עם ``outline=true`` או ``lines=`` קורא עד - ``RANGE_READ_MAX_BYTES`` (‏10MiB), והמראה טוענת את ה-blob כולו לזיכרון - **לפני** בדיקת הגודל. נמדד (2026-09-20): outline של קובץ RST בגודל 10MB + ``RANGE_READ_MAX_BYTES`` (‏10MiB), והמראה טוענת blob **שמתחת לתקרה** כולו + לזיכרון לפני הפענוח והחיתוך (blob שמעליה נדחה מ-``git cat-file -s`` בלי + להיקרא — ראו #3433 למטה). נמדד (2026-09-20): outline של קובץ RST בגודל 10MB מחזיק 61–77MiB לאורך המסלול כולו — הטקסט של 10MB בזיכרון ועליו הפרסור; שורת ``outline_densest_real`` של הסקריפט מראה את הפרסור לבדו מעל השיא שלפניו, 51MiB — וקריאת ``lines=`` של ``webapp/static/js/md_preview.bundle.js`` @@ -128,7 +129,10 @@ usable=8)`` מול ``cpu quota cgroup v2: 0.50 cpu``, כלומר 12 חוטים (הגדול ביותר כאן 169KB). תמחור לפי ``RANGE_READ_MAX_BYTES`` היה מצמצם את המסלול הציבורי ל-4 חוטים (לפי 77MiB) או ל-9 (לפי 37MiB) בגלל צורה שאין לה מקור. מה שכן השתנה בגלל זה הוא התקרה (למטה); והשורש — לחסום את הקריאה - במקור, בבדיקת גודל לפני ``git show`` — נמצא ב-#3433. + במקור — נסגר ב-#3433: ``get_file_at_commit`` שואל את מאגר האובייקטים לגודל + (``git cat-file -s :``) לפני ``git show``, ולכן ``max_size`` הוא + חסם על מה שנקרא ולא בדיקה בדיעבד. נמדד: קובץ של 12MB שנדחה עלה 20MiB שיא + לפני, ואפס אחרי; קובץ של 7MB שמתחת לתקרה נשאר 14MiB, כי אותו כן קוראים. - **רצפה** (``_READ_POOL_FLOOR``, ‏2): כדי שקריאת מונגו תקועה אחת לא תשבית את השאר. שניים הוא הגודל הקטן ביותר עם התכונה הזאת, וכל יחידה מעליו היא זיכרון שהחשבון אמר שאין. **תקרה** (``_READ_POOL_CAP``, ‏12): הרוחב שהייצור @@ -406,6 +410,15 @@ Starlette עם ``lifespan=`` משלה, ולכן ``on_startup`` אינו רץ כ * - ``OUTPUT_BYTE_BUDGET`` - 256,000 - תקציב הבתים של תשובה אחת + * - ``DEFAULT_MAX_REQUEST_BYTES`` + - 1,048,576 + - גוף בקשה אחת במתודות שנושאות גוף, ב-``mcp_server/limits.py``. נגזר + מ-``MAX_CODE_SIZE`` (``request_bytes_for``) ולא מוקלד לצידו; + ‏``MCP_MAX_REQUEST_BYTES`` מעלה אותו. ראו "גבולות הבקשה" + * - ``DEFAULT_RATE_LIMIT_PER_MINUTE`` + - 60 + - קריאות כלים לזהות אחת בדקה, ב-``mcp_server/limits.py``; + ‏``MCP_RATE_LIMIT_PER_MINUTE`` מכוון אותו. ראו "גבולות הבקשה" * - ``TREE_PER_PAGE_DEFAULT`` · ``TREE_PER_PAGE_MAX`` - 200 · 1000 - עמוד בעץ הקבצים @@ -539,6 +552,112 @@ Starlette עם ``lifespan=`` משלה, ולכן ``on_startup`` אינו רץ כ ריפו: הוא מסונן ל-``user_id`` של הקורא ולכן יכול להחזיר רק פתקים שלו, ומשתמש רגיל אינו יכול ליצור פתק ריפו מלכתחילה. +.. _mcp-request-limits: + +גבולות הבקשה — גודל הגוף והקצב +------------------------------- + +עד #3431 המידלוור היחיד בשרת היה האימות: כל משתמש מאומת יכול היה לשלוח בקשות +בכל גודל ובכל תדירות. תקרת האורך ל-``path`` של ``codekeeper_docs_get_section`` +(#3428) חסמה הגברה בבקשה בודדת, ולא נגעה לא במספר הבקשות ולא בשאר הכלים. +היום יש שני גבולות, כל אחד בשכבה שמתאימה לו, ושניהם חיים ב-``mcp_server/limits.py``. + +**גודל גוף הבקשה — במידלוור ASGI, לפני שהטרנספורט מפענח JSON.** הטרנספורט של +ה-SDK קורא את הגוף כולו ומפענח אותו לפני שאף כלי רואה ארגומנט, ולכן התקרה +הזאת היא הגבול היחיד על **כל** ארגומנט של **כל** כלי, קיים ועתידי. **התקרה +נגזרת ולא מוקלדת:** הארגומנט הלגיטימי הגדול ביותר הוא ``code`` של +``codekeeper_save_file``, שחסום ב-``MAX_CODE_SIZE`` (תווים), ולקוח שמקודד JSON +עם ``ensure_ascii`` הופך כל תו עברי לשישה בתים; ``request_bytes_for`` ב- +``mcp_server/limits.py`` מחשב ``MAX_CODE_SIZE × 6`` ועוד מעטפת של 64KiB (שם +הכלי, שם הקובץ, שפה, ותיאור בתקרתו) ומעגל כלפי מעלה ל-MiB שלם — על ברירת המחדל +של התצורה זה 1MiB. השירות גוזר את הערך בעלייה מהתצורה שהוא באמת רץ איתה +(``max_code_size()``), כך שהעלאת ``MAX_CODE_SIZE`` לעולם אינה משאירה את התקרה +מאחור, וטסט בונה את בקשת השמירה הגדולה ביותר (קובץ עברי בגודל המקסימלי, בקידוד +היקר) ומודד שהיא נכנסת. + +**מה נקרא ומה לא, ולמה זה חשוב.** התקרה מסתכלת רק על מתודות שנושאות גוף +(``POST``, ‏``PUT``, ‏``PATCH``); ``GET`` ודומיה עוברות כמות שהן, ולכן +``Content-Length`` מזויף על ``GET /healthz`` אינו 413 (עד סקירת שבעת ה-PRים הוא +כן היה, מול uvicorn אמיתי). ``Content-Length`` תקין — ספרות בלבד, כמו ש-h11 +מקבל — שמצהיר על יותר מהתקרה נדחה לפני שנקרא בית אחד; תקין ומתחת לתקרה, בלי +``Transfer-Encoding`` לצידו, **עובר בלי שהמידלוור קורא בית**, כי השרת (uvicorn/h11) +תוחם את הגוף לאורך המוצהר ואי אפשר להעביר דרכו יותר — נמדד. מה שנשאר לספירה +הוא גוף בלי אורך מוצהר (chunked, או ``Content-Length`` לצד ``Transfer-Encoding``, +ששם h11 תוחם לפי chunked): הוא נקרא עד התקרה, וכל הלולאה תחת דדליין של 30 +שניות (``DEFAULT_BODY_READ_SECONDS``), כי ל-uvicorn אין timeout לגוף בקשה +וההמתנה לנתח הבא הייתה אחרת בלתי חסומה. הסירוב נוקב בסיבתו, באותה צורה של +ה-401 של האימות, ונושא ``Connection: close`` — הוא הגיע לפני שהגוף נגמר, ואין +טעם להמשיך לקרוא אותו: + +.. code-block:: json + + {"error": "body_too_large", "max_bytes": 1048576, "content_length": 20971520} + +.. code-block:: json + + {"error": "body_read_timeout", "read_timeout_seconds": 30.0, "received_bytes": 4096} + +במצב PAT האימות נשאר מחוץ לתקרה (בקשה בלי טוקן היא 401 גם כשהיא ענקית). במצב +OAuth התקרה היא השכבה החיצונית ברמת האפליקציה, והאימות של ה-SDK יושב בתוך +ה-mount של ``/mcp``: אורך מוצהר מעל התקרה נדחה לפני שנבדק אישור כלשהו, ואורך +מוצהר תקין עובר בלי קריאה, כך שה-401 של ה-SDK עדיין מגיע בלי שנקרא בית מהגוף. +**זו הייתה רגרסיה של #3431** (SEC-001 בסקירת שבעת ה-PRים): הגרסה הראשונה קראה +כל גוף אנונימי עד התקרה לפני ה-401 — נמדדו 5 קריאות ``receive()`` לפני התשובה +על גוף של חמישה נתחים, גם עם אורך מוצהר — ובלי דדליין. היום: 0 קריאות לאורך +מוצהר, וגוף chunked אנונימי, המקרה שנשאר, חסום בתקרה ובדדליין. + +**קצב — לפי זהות, בנקודת השיגור של הכלים.** הזהות שאפשר לספור לפיה קיימת +במידלוור רק במצב PAT; במצב OAuth (הייצור) ה-SDK מאמת בתוך ``/mcp``, והמקום היחיד +שרואה את הזהות בשני המצבים הוא קריאת הכלי עצמה. לכן המגביל יושב ב-``call_tool`` +של ``AdminAwareFastMCP`` — המתודה שה-SDK רושם כמטפל של ``tools/call``, כלומר +נקודה אחת שכל קריאת כלי עוברת בה — ומכריע **לפני** שגוף סינכרוני נמסר לחוט +מהמאגר ולפני שגוף אסינכרוני רץ על הלולאה: קריאה שנדחתה אינה עולה עובד, ומה +שאינו קריאת כלי (``initialize``, ‏``tools/list``) אינו נספר. הוא בנוי מעל +``rate_limiter.RateLimiter`` — אותו חלון מתגלגל של דקה שהבוט משתמש בו, בזיכרון +ובלי Redis, וזה מספיק כי השירות רץ במופע אחד — הגדרה של השירות ב-Render +(``numInstances: 1``), מחוץ לריפו; מי שמוסיף מופע מאבד את הזיכרון המשותף, וזה +המקום להתחיל ממנו. ברירת המחדל היא 60 קריאות בדקה +לזהות, ומאיפה המספר: הבקשה הציבורית היקרה ביותר היום היא עמוד RST של 500KB — +‏0.24 שניות מעבד עם תקרת הסקשנים; במסלול ה-Markdown המסמך הצפוף האמיתי עולה +0.47 שניות והצורה העוינת 2.3; המכסה היא 0.5 מעבד, כלומר 30 שניות-מעבד בדקה. +שישים קריאות של המסמך הצפוף האמיתי הן 28 שניות — זהות אחת יכולה לכל היותר למלא +דקת מעבד אחת משלה, ולא לחנוק את השאר; קריאה רגילה (10–50ms) הופכת 60 בדקה +לאחוז עד שלושה מהמכסה. הסירוב הוא תשובת כלי רגילה, עם הזמן שנותר עד שהחלון +משתחרר: + +.. code-block:: json + + {"ok": false, "error": "rate_limited", "limit_per_minute": 60, "retry_after_seconds": 17} + +**הפטור לנתיב הדופק — שני חצאים, ורק אחד מהם מבני.** מהמגביל ``/healthz`` פטור +במבנה: הוא אינו קריאת כלי ולכן לעולם אינו מגיע למגביל. זו המלכודת שמתוארת +ב-``blanket-policy-silent-block`` §7 — מגבלה גורפת שמחזירה ``429`` למוניטור +החיצוני ומכריזה על שירות בריא כמנותק — והיא נסגרת כאן על ידי המבנה. מתקרת +הגוף הוא פטור **בכלל**, לא במבנה: ``GET`` אינה מתודה שנושאת גוף, ולכן התקרה +אינה מסתכלת עליה — עד סקירת שבעת ה-PRים היא כן הסתכלה, ו-``GET /healthz`` עם +``Content-Length`` מזויף מעל התקרה ענה ``413``. שני החצאים מקובעים בטסטים על +האפליקציה האמיתית, בשני מצבי האימות: מאה דגימות של ``/healthz`` תחת מגבלה של +קריאה אחת בדקה מחזירות מאה ``200``, ו-``GET /healthz`` עם אורך מזויף מחזיר ``200``. + +**מה נרשם בלוג.** בעלייה — שורה אחת עם שני הגבולות שנקבעו; סירוב על גודל או על +הדדליין — WARNING עם הסיבה, המספרים והנתיב (לא הגוף ולא הטוקן); סירוב על קצב — +WARNING אחד לזהות לחלון, כדי שסוכן שממשיך לדפוק לא יהפוך את הלוג לתור. **וסירובי +המכסה אינם נספרים ב-PostHog**: המדידה עוטפת את ``_tool_manager.call_tool`` של +ה-SDK, וההכרעה על המכסה קורית לפניו, ולכן קריאה שסורבה אינה נראית שם כלל; וגם +סירוב שכן עובר במדידה (תשובת ``{"ok": false, ...}`` מגוף כלי) נרשם כהצלחה, כי +``is_error`` מסומן רק על חריגה. האות היחיד על סירובי מכסה הוא ה-WARNING ההוא, +שאי אפשר לסכם. ההחלטה מפורטת באישו #3442, ושם גם מה שנשאר לעצב. + +**כיוון.** ‏``MCP_MAX_REQUEST_BYTES`` ו-``MCP_RATE_LIMIT_PER_MINUTE`` (‏``0`` מכבה +במפורש, עם WARNING). שניהם נקראים ב-``create_app`` — בכניסה לשירות, לא בזמן +ייבוא — וערך שאינו מספר מחזיר את ברירת המחדל עם WARNING; בשום מסלול טעות תצורה +אינה הופכת לגבול רחב יותר. **``MCP_MAX_REQUEST_BYTES`` אינו kill switch, וזו +החלטה סגורה** (YAGNI-002 בסקירת שבעת ה-PRים): ``0`` אינו מכבה אותו — הרצפה של +65,536 בתים חלה — והוא קיים כדי להעלות את התקרה בלי דיפלוי כשבקשה לגיטימית +נדחית ב-``body_too_large``, למשל אחרי שכלי יקבל ארגומנט גדול מ-``MAX_CODE_SIZE``. +הדדליין על גוף בלי אורך מוצהר (30 שניות) אינו ניתן לכיוון מהסביבה: אין לו +קורא שמצדיק זאת. + .. _mcp-update-description: עדכון תיאור בלי גרסה חדשה @@ -1051,6 +1170,17 @@ Claude Desktop ההבדל בין שני הקודים אינו סגנוני: ``not_found`` אומר "אין קובץ כזה" ומזמין לנסות שם אחר, ו-``path_denied`` אומר "יש, ולא תקבל". +**ועוד שני סירובים שנוקבים בסיבתם, משתי סיבות שהתחזו לאחרות (#3432).** +``path_outside_root`` — הנתיב תקין בצורתו ופותר אל מחוץ לשורש התיעוד של +הריפו (``docs/../secrets``, ``/etc/hosts``, ``../README.md`` בריפו ששורשו +ריק); התשובה נושאת את ``root``, שממילא כתוב בתיאור הכלי. עד היום זה חזר +כ-``missing_path``, והקורא לא ידע אם שכח נתיב או ניסה לצאת מהשורש; +``missing_path`` נשאר לנתיב ריק בלבד. ו-``repo_not_mirrored`` — למארח אין +עותק של הריפו כלל (הכלי ``codekeeper_get_repo_file`` מחזיר אותו באותו מצב). +עד היום גם זה חזר כ-``not_found``, וסוכן שביקש עמוד אמיתי ניסה שם אחר בזמן +שהבעיה הייתה אצל המפעיל. אם sync רץ באותו רגע, התשובה היא +``sync_in_progress`` כמו תמיד. + התנהגות בזמן sync ~~~~~~~~~~~~~~~~~~ @@ -1147,11 +1277,15 @@ Claude Desktop לשורה שאי אפשר לבקש גרועה ממפה שאין. **CRLF עובד במלואו.** ``too_many_sections`` - הקובץ חוצה את תקרת הכותרות — 50,000 בשני המסלולים, אבל הדרך שונה: - Markdown דרך ברירת המחדל של ``md_parser`` (``MAX_SECTIONS``), ו-RST דרך - התקרה שהכלי מעביר במפורש (``_ceiling.MAX_SYMBOLS``, מאז #3429), כי - ברירת המחדל של ``rst_parser`` היא ללא תקרה. טסט מחזיק את שני המספרים - שווים. **ומה שאף תקרה כאן אינה עוצרת:** Markdown עוין של שורות-תבליט בלי + הקובץ חוצה את תקרת הכותרות — 50,000 בשני המסלולים, ובאותה דרך: ברירת + המחדל של ``max_sections`` בשני הפארסרים היא ``MAX_SECTIONS`` של + ``services.doc_sections``, והכלי אינו מעביר תקרה משלו. (עד #3420 ברירת + המחדל של ``rst_parser`` הייתה ללא תקרה, והכלי העביר לו את + ``_ceiling.MAX_SYMBOLS`` במפורש מאז #3429; היישור נעשה אחרי מדידה על כל + 208 קובצי ה-RST — הקובץ העשיר ביותר נושא 50 סקשנים, אחד לאלף מהתקרה — + ופלט הכלי על כולם זהה לפני ואחרי.) התקרה של סורק האאוטליין, + ``_ceiling.MAX_SYMBOLS``, היא אותו מספר, וטסט מחזיק את השניים שווים. + **ומה שאף תקרה כאן אינה עוצרת:** Markdown עוין של שורות-תבליט בלי כותרות — 500KB עולים 141MiB ו-2.3 שניות, ו-``MAX_SECTIONS`` סופר כותרות ולכן אינו נוגע בו. הפער הזה פתוח ושייך ל-#3391, לא ל-#3429; ראו "מודל הריצה של הכלים" למעלה. @@ -1213,7 +1347,13 @@ Claude Desktop בצורת המזהה שהחוזה מגדיר. **מזהה שחוזר פעמיים באותו עמוד הוא ``ambiguous_section``**, עם רשימת -המועמדים — בדיוק כמו כל כותרת כפולה. הכלי אינו מנחש. +המועמדים — בדיוק כמו כל כותרת כפולה. הכלי אינו מנחש. **והרשימה חסומה ב-50 +מועמדים**, אותו מספר ומאותה סיבה כמו ההצעות: מלאי בסדר הופעה ולא דירוג, +גבוה מספיק שכל עמוד אמיתי ייענה במלואו. אם היו יותר, התשובה נושאת +``candidates_truncated: true`` — ורק אז, כמו ``suggestions_truncated``. לפני +התקרה זה היה השדה היחיד בתשובה בלי גבול: עמוד סינתטי של 512KB עם 13,030 +כותרות שנפתחות באותו מזהה החזיר 13,030 מועמדים ו-1.4MB על שאילתה בת שלושה +תווים (#3426). **ותקרה על מספר הסקשנים בעמוד — אותה תקרה של האאוטליין, 50,000.** עמוד שחוצה אותה מוחזר כ-``{"ok": false, "error": "too_many_sections", "max": 50000}`` @@ -2302,8 +2442,26 @@ JSON גדול, או כלל אחד מתוך מסמך, מושך היום את הק לפניה. **היעדר שורה של כלי אינו סימן שהכלי לא נקרא**: קריאות אינן רושמות שורה לכל קריאה — רק כתיבות (זמן ההמתנה בתור וזמן הריצה) — ומדידת השימוש עוברת ב-PostHog + **וגם סירוב אינו רושם שורה, וזו החלטה ולא פער** (#3432, SUGG-022): + תשובת ``{"ok": false, "error": ...}`` היא תשובה תקינה של הפרוטוקול — + הקורא ביקש משהו שאין, או שאסור — ולא תקרית של השרת; ‏78 מסלולי סירוב + בשכבת המטפלים אינם רושמים דבר, בכל הקבצים באותה מידה. השורה היחידה + שנרשמת היא על תקלת תצורה שרק המפעיל יכול לתקן + (``repo_not_configured``), וזה הקו: לוג למה שהמפעיל צריך לדעת, תשובה + למה שהקורא צריך לדעת. **ואין היום מקום שסופר סירובים לפי סוג:** PostHog + מודד כל קריאה שהגיעה לגוף כלי, אבל סירוב מגוף כלי נרשם שם כהצלחה + (``is_error`` מסומן רק על חריגה), וסירוב מכסה (``rate_limited``) אינו + מגיע למדידה כלל — הוא מוכרע לפני התפר שהיא עוטפת. הפער מתועד באישו + #3442; ראו גם "מה נרשם בלוג" ב-:ref:`mcp-request-limits` * - ``repo_or_ref_not_found`` - ה-mirror עוד לא שוכפל מקומית (המתינו למעבר autosync) או שאין דיסק/``REPO_MIRROR_PATH`` + * - ``repo_not_mirrored`` בקריאת קובץ או עמוד תיעוד + - אותו מצב, מהצד של ``get_file``: למארח אין עותק של הריפו. זה אינו שם + קובץ שגוי — אל תנסו נתיב אחר; בדקו את המראה (``REPO_MIRROR_PATH``, + autosync). בזמן sync התשובה היא ``sync_in_progress`` + * - ``path_outside_root`` ב-``codekeeper_docs_get_section`` + - הנתיב פותר אל מחוץ לשורש התיעוד של הריפו (``root`` בתשובה). נתיב + מלא נלקח כמות שהוא; slug בלי ``/`` מעוגן לשורש * - הסוכן שמר קובץ ולא הגיעה התראה - לפי הסדר: ‏(1) יש מנוי Push פעיל בדפדפן? נרשמים ב-``/settings``, ובלי מנוי האירועים נפסלים מיד. ‏(2) ``MCP_PUSH_NOTIFICATIONS_ENABLED`` כבוי diff --git a/docs/whats-new.rst b/docs/whats-new.rst index ef65c8cd0..728d3fbba 100644 --- a/docs/whats-new.rst +++ b/docs/whats-new.rst @@ -2,8 +2,19 @@ What's New ========== :summary: יומן השינויים של הבוט וה-WebApp לפי תאריך — מה נוסף, מה השתנה ומה תוקן בכל עדכון, עם קישורים ל-Issues הרלוונטיים. +2026-09-21 +---------- +- **fix (MCP): סגירת ממצאי סקירת שבעת ה-PRים #3434–#3441 — תקרת הגוף מפסיקה לקרוא גוף אנונימי לפני האימות, ועוד ארבע-עשרה הצעות.** **הרגרסיה (SEC-001):** תקרת הגוף מ-#3431 קראה כל גוף עד 1MiB לפני שהשכבה הבאה ראתה את הבקשה — במצב OAuth גם גוף אנונימי שהאימות של ה-SDK דוחה מהכותרות בלבד, ובלי דדליין (ל-uvicorn אין timeout לגוף בקשה): נמדדו 5 קריאות ``receive()`` לפני ה-401 על גוף של חמישה נתחים, גם עם ``Content-Length``. שלושה כללים, כל אחד עם טסט שנפל לפני שנכתב: ``Content-Length`` תקין (ספרות בלבד, בלי ``Transfer-Encoding``) עובר בלי קריאה, כי השרת תוחם את הגוף לאורך המוצהר — 0 קריאות לפני ה-401; גוף בלי אורך מוצהר נקרא עד התקרה תחת דדליין של 30 שניות על הלולאה כולה, ואז ``408 body_read_timeout`` שאומר כמה נקרא; ורק ``POST``/``PUT``/``PATCH`` נבדקות, ולכן ``GET /healthz`` עם ``Content-Length`` מזויף מחזיר ``200`` ולא ``413`` (WARN-002). שני הסירובים נושאים ``Connection: close``, וטסט סדר-התקנה למצב OAuth עומד ליד זה של מצב PAT. **התקרה נגזרת:** ``DEFAULT_MAX_REQUEST_BYTES`` היה מוקלד לצד הערה שגוזרת אותו ביד; עכשיו ``request_bytes_for(MAX_CODE_SIZE)`` — פי שישה ועוד מעטפת, מעוגל ל-MiB — גם בעליית השירות מהתצורה בפועל, וטסט בונה את בקשת השמירה הגדולה ביותר (קובץ עברי בגודל המקסימלי) ומודד שהיא נכנסת (SUGG-022). **המגביל:** הסירוב הוא ``CallToolResult`` שלם ולא ערך שעובר ב-``convert_result`` של הכלי, ולכן כלי עם טיפוס החזרה מוצהר או שם כלי לא מוכר מקבלים ``rate_limited`` ולא ``ValidationError`` או שגיאת SDK (SUGG-009); ``is None`` במקום ``or`` בבחירת המגביל, כדי ש-``ToolRateLimiter(0)`` לא יוחלף בשקט ב-60 בדקה ברגע שיתווסף ``__len__`` (SUGG-023); טסט מקבילי — 25 קריאות בו-זמנית, תקציב 2, בדיוק 2 עוברות (SUGG-024); ``RateLimiter`` אינו יוצר עוד רשומה לזהות ששאלו עליה בלבד, ופנקס האזהרות מנוקה (SUGG-011); ``ToolRateLimiter.enabled`` נמחק (YAGNI-001) ו-``MCP_MAX_REQUEST_BYTES`` נשאר כהחלטה סגורה — לא kill switch אלא דרך להעלות את התקרה בלי דיפלוי (YAGNI-002). **החיווט נבדק:** טסט בתת-תהליך מייבא את ``mcp_server.app`` כמו uvicorn, בשני מצבי האימות ועם ריצת בקרה, ומוודא ששני משתני הסביבה ו-``MAX_CODE_SIZE`` מגיעים לאפליקציה הבנויה (WARN-001). **ומה שלא נספר:** סירובי מכסה אינם נספרים ב-PostHog — ההכרעה קורית לפני התפר שהמדידה עוטפת, וגם סירוב מגוף כלי נרשם שם כהצלחה — התיעוד אומר זאת עכשיו במפורש, והעיצוב פתוח באישו #3442 (WARN-003). **קטנות באותו PR:** ``token_count`` ו-``front_matter_end`` עוברים באותו שער כניסה כמו ``parse_document`` (BOM, ``\r`` בודד, טיפוס), ולכן ``generate_ai_map`` אינו יכול עוד לקבל גבול front matter שחולק על ``split("\n")`` (SUGG-019, SUGG-013); הצורה העוינת בסקריפט מדידת הפרסור רצה עם ``max_sections=None`` במפורש, כי עם התקרה היא מדדה עצירה (41.0 בתים לבית) ולא את הפרסר (90.1) (WARN-004); ``_CANDIDATES_MAX`` מיובא מ-``doc_sections`` במקום להיות מוקלד (SUGG-002); תנאי מת ב-``utils.normalize_code`` ומספר מוקלד בטסט של שורת הקיבולת הוסרו (SUGG-021, SUGG-020); והערות עם מספרים שהתיישנו אחרי המעבר לפרנקפורט אומרות זאת בתאריך (SUGG-005, SUGG-006, SUGG-010). עשר ההצעות שנדחו — באישו #3442. ראו :ref:`mcp-request-limits`. +- **chore (MCP): יתרת ממצאי הסקירה על #3428 (#3432).** שני סירובים חדשים שנוקבים בסיבתם: ``path_outside_root`` ב-``codekeeper_docs_get_section`` (עד היום נתיב שיצא מהשורש חזר כ-``missing_path``, ו-``missing_path`` נשאר לנתיב ריק בלבד) ו-``repo_not_mirrored`` מ-``RepoBackend.get_file`` (עד היום מארח בלי עותק של הריפו חזר כ-``not_found``, וסוכן ניסה שם אחר בזמן שהבעיה אצל המפעיל). טבלאות המדיניות של הכלי (``DOCS_PATH_POLICY``, ``_PARSERS``) מיוצאות כ-``MappingProxyType``, כי הוולידציה בייבוא היא הבטחה רק אם הטבלה אינה משתנה אחריה; זנב עיצוב התשובה של ``docs_get_section`` נפרד ל-``_answer_from_document`` (אפס-דיף מדוד על 208 קובצי ה-RST); שתי בדיקות הנרמול של הוולידטור ושורת הלוג על ``repo_not_configured`` קיבלו טסטים; טסט ההסכמה בין חיפוש לקריאה כבר אינו דורש שוויון מדויק (המדיניות מצהירה ש-``is_denied`` נשאר שכבה אחרונה), ושני הטסטים תלויי-git מדלגים בלי git באותה צורה. **החלטה מפורשת על לוגים לסירובים:** סירוב הוא תשובה של הפרוטוקול ולא תקרית, ואף מסלול סירוב בשכבת המטפלים אינו רושם שורה — מתועד ב-:doc:`mcp-server`. שני ממצאים נדחו בנימוק: תצוגת התצורה אינה יכולה לייבא את ``docs_handlers`` (הכיוון ``services`` ← ``mcp_server`` חד-סטרי), ושני חצאי מדיניות הסודות נשארים בשתי שפות כי אין מנוע אחד לשניהם — מה שמחזיק אותם יחד הוא רשימת תבניות אחת ושני טסטים. + 2026-09-20 ---------- +- **fix: ``codekeeper_docs_get_section`` חוסם את רשימת המועמדים של ``ambiguous_section`` ב-50 (#3426).** זה היה השדה היחיד בתשובה בלי תקרה: ``toc`` חסום ב-400 ו-``suggestions`` ב-50, ורק המועמדים חזרו כולם — נמדד 13,030 מועמדים ו-1.4MB על שאילתה בת שלושה תווים בעמוד סינתטי. הענף נדלק מכותרות שנושאות מזהה, ומאז #3428 הקורפוס שנושא אותם מוגש. אותו מספר כמו ההצעות ומאותה סיבה (מלאי בסדר הופעה, לא דירוג), והדגל ``candidates_truncated`` מופיע רק כשנחתך — כמו ``suggestions_truncated`` — ולכן תצלום אפס-הדיף של הכלי על 208 קובצי ה-RST זהה לפני ואחרי. ובאותו PR: טסט שמקבע שגם במסלול ה-Markdown ``suggestions`` הוא רשימת מחרוזות ולא ה-``NamedTuple`` (הצרכן השני מ-#3426 הוא אותו אתר קריאה, והוא כותב ``.titles``). +- **fix (MCP): תקרת גודל לגוף בקשה והגבלת קצב לפי זהות, עם סירוב שנוקב בסיבתו (#3431).** עד עכשיו המידלוור היחיד היה האימות, וכל משתמש מאומת יכול היה לשלוח בקשות בכל גודל ובכל תדירות. שני גבולות חדשים ב-``mcp_server/limits.py``: גוף בקשה מעל 1MiB (``MCP_MAX_REQUEST_BYTES``) נדחה ב-``413 body_too_large`` במידלוור ASGI, לפני שהטרנספורט מפענח JSON — לפי ``Content-Length`` ואם היא חסרה או מכזבת, לפי מה שבאמת מגיע; וקריאות כלים מוגבלות ל-60 בדקה לזהות (``MCP_RATE_LIMIT_PER_MINUTE``) בנקודת השיגור של הכלים — המקום היחיד שרואה את הזהות גם במצב OAuth — ולפני שנתפס חוט, עם ``rate_limited`` ו-``retry_after_seconds`` בתשובה. המגביל הוא ``rate_limiter.RateLimiter`` הקיים של הבוט, לא עותק. ‏``/healthz`` פטור מבנית משניהם (``blanket-policy-silent-block`` §7), וזה מקובע בטסט. המספרים נגזרו מהמדידות ב-#3431: 0.24 שניות מעבד לעמוד RST עוין של 500KB, 0.47 למסמך ה-Markdown הצפוף, 2.3 לצורה העוינת, מול מכסה של 0.5 מעבד. ראו "גבולות הבקשה" ב-:doc:`mcp-server`. +- **fix (mirror): ‏``get_file_at_commit`` בודק את גודל הקובץ במאגר האובייקטים לפני ``git show``, ולכן קובץ מעל התקרה אינו נטען לזיכרון כלל (#3433, פריט 9).** עד היום ``git show`` נטען כולו לזיכרון ורק אז ``max_size`` נבדק — כלומר התקרה הייתה בדיקה בדיעבד: קובץ של 12MB שנדחה עלה 20MiB שיא בחוט הקריאה לפני שהתשובה הייתה ``file_too_large``. מעכשיו ``git cat-file -s :`` מחזיר את גודל האובייקט בלי לקרוא אותו, שתי הפקודות פונות לאותו קומיט שנפתר פעם אחת (אין חלון לסנכרון להיכנס בו), וקובץ מעל התקרה נדחה לפני שנקרא בית אחד — אותה תשובה, אותו ``size``, אפס זיכרון. כשל בבדיקת הגודל אינו נופל לקריאה בלי תקרה, ומה שמוחזר עדיין נבדק מול התקרה בנפרד, לכל סוג אובייקט. נמדד: 12MB מעל תקרה של 500KB ושל 10MiB — 20.2 ו-20.4MiB לפני, אפס אחרי; 7MB מתחת לתקרה — 14MiB לפני ואחרי, כי אותו כן קוראים. ראו "מודל הריצה של הכלים" ב-:doc:`mcp-server`. +- **chore (MCP): ארבעה פריטים קטנים מסקירת #3429 (#3433).** שורת הקיבולת של מאגר הקריאות קוראת את ``ThreadPoolExecutor._max_workers`` הפרטי דרך ``_installed_width``, וכשהמאפיין ייעלם היא אומרת "unreadable" לצד הרוחב שהתבקש עם WARNING, במקום שהשירות לא יעלה בגלל שורת לוג (SUGG-004); ‏``scripts/measure_md_parse_cost.py`` מודד צפיפות דרך ``md_parser.token_count`` הציבורית — על המופע שהכלי מריץ — במקום לקרוא ל-``_build_parser`` הפרטית ולבנות פרסר לכל קובץ (SUGG-010); שני טסטים חדשים מקבעים שקובץ ``memory.max`` שקיים אבל ריק או לא-מספרי נופל ל-cgroup v1 (SUGG-014); ואסרשן שלא יכול היה ליפול הוחלף בטסט שמצמיד ``os.cpu_count`` ל-16 כדי שה-fallback ייבדק מול 20 ולא מול עצמו (SUGG-001). +- **fix (parsers): ‏``rst_parser.parse_document`` בודק את הקלט בכניסה, וברירת המחדל של ``max_sections`` מיושרת לזו של ``md_parser`` (#3421, #3420).** עד היום ``rst_parser.parse_document(None)`` החזיר מסמך ריק **בשקט** — מפה ריקה שמתחזה למפה של קובץ בלי כותרות — ו-``17`` נפל ב-``AttributeError`` גולמי, בעוד ``md_parser`` זרק ``TypeError`` שאומר מה התקבל. מעכשיו שני הפארסרים עוברים באותה בדיקה, ``doc_sections.require_str``, ומרימים את אותה חריגה עם אותן מילים; אף קורא לא נשען על הצורה הישנה (המטפל, הסורק והסקריפטים מעבירים תמיד מחרוזת). ובאותו PR ההכרעה על ברירת המחדל: ``max_sections`` ב-``rst_parser`` היה ``None`` (בלי תקרה) כי הפרמטר נוסף לפארסר שכבר רץ בייצור, ואילו ב-``md_parser`` הוא ``MAX_SECTIONS`` — כלומר קורא ששכח להעביר תקרה קיבל הגנה מהפארסר האחד ולא מהשני. עכשיו שניהם על אותה ברירת מחדל, ``MAX_SECTIONS`` שעבר ל-``services.doc_sections`` ומיוצא משני הפארסרים, ו-``codekeeper_docs_get_section`` אינו מעביר תקרה לאף אחד מהם — וזה מחליף את מה שכתוב ברשומת הקריאה של Markdown שלמטה ("RST דרך התקרה שהכלי מעביר מאז #3429"): מאז השינוי הזה הכלי אינו מעביר תקרה, וברירת המחדל של הפרסר היא שעוצרת. **נמדד, לא הונח:** על כל 208 קובצי ה-RST ב-``docs/`` הקובץ העשיר ביותר נושא 50 סקשנים מול תקרה של 50,000 (אחד לאלף), ופלט הכלי על כולם — 5,931 רשומות — זהה בית-בית לפני ואחרי. סורק האאוטליין ממשיך להעביר את התקרה שלו במפורש, וטסט מקבע זאת. ראו "שני סירובים שמגיעים מהפרסור עצמו" ב-:doc:`mcp-server`. +- **refactor: שלושה כללים שנכתבו פעמיים חזרו למקום אחד (#3419, #3427).** ‏(1) כלל ה-``\r`` הבודד — סיומת שורה שאינה חד-משמעית — ישב גם במנתב האאוטליין וגם בפארסר ה-Markdown, ושני העותקים היו צריכים להסכים לנצח; עכשיו הוא במודול עלה חדש, ``services/line_endings.py``, ושני הצרכנים קוראים לו. ‏(2) ``scripts/generate_ai_map.py`` כתב ביד את גבול ה-front matter ונמדד שהוא חולק על התוסף ש-MyST מריץ בחמש מתוך שתים-עשרה צורות; עכשיו הוא קורא את הגבול מהפארסר של הכלי (``md_parser.front_matter_end``, דרך ``mdit_py_plugins.front_matter``), ו-``AI-MAP.md`` שנוצר זהה בית-בית לזה שבריפו — הטסט שקיבע את הפער בכוונה נערך יחד עם הסגירה. ‏(3) שתי רשימות זהות של 16 קודי תווים נסתרים — ב-``utils.normalize_code`` וב-``CodeNormalizer`` — לא הוסיפו דבר על בדיקת ``unicodedata.category == "Cf"`` שישבה מתחתן (כל 16 הערכים הם ``Cf``); הרשימות נמחקו, שתי הפונקציות אוחדו ל-``strip_hidden_escapes`` אחת בשכבת הדומיין, וענף ה-Variation Selectors (``Mn``, לא ``Cf``) נשאר נפרד ונדלק רק לפי בקשה. לשני הנרמולים אפס-דיף מדוד על קורפוס של רצפי בריחה, בכל צירוף אפשרויות. - **feat: ``codekeeper_docs_get_section`` קורא גם קובצי Markdown, ולכל ריפו מדיניות נתיבים משלו.** עד היום הכלי ידע לקרוא ``.rst`` בלבד, ורק מתחת ל-``docs/``. הפארסר של Markdown נכתב ונמדד כבר קודם — ואף אחד לא קרא לו; זה השינוי שמחווט אותו. מעכשיו הסיומת בוחרת את הפארסר, ולכל ריפו מוצהר **איפה התיעוד שלו יושב ובאיזה פורמט**: ``CodeBot`` נשאר ``docs/`` עם ``.rst`` ללא שינוי, ו-``amir-bug-patterns`` מגיש ``.md`` משורש הריפו. **וזו הרחבה מכוונת של גבול הקריאה** — הכלי ציבורי, כלומר כל משתמש מאומת מפעיל אותו בלי אדמין, והריפו שנוסף ציבורי ב-GitHub. מה ש**לא** השתנה: מדיניות סינון הסודות חלה על המסלול הזה במלואה, ולכן ``path="secrets"`` מחזיר ``path_denied`` ולא ``not_found``, וגם ``secrets.md`` ו-``credentials.md`` חסומים; ותקרת 500KB של שירות המראה נשארת ההגנה על הפרסור, כי הכלי קורא את הקובץ כולו בלי ``lines``. **שני שערים ולא אחד:** ``MCP_DOCS_REPO`` מתיר בזמן ריצה, טבלת המדיניות בקוד יודעת לקרוא, וריפו שנמצא במשתנה הסביבה ואין לו מדיניות נדחה ב-``repo_not_configured`` במקום ליפול לברירת מחדל מתירנית. **ובקשה לפורמט שהריפו אינו מגיש נדחית בשמה** — ``suffix_not_allowed`` עם רשימת הסיומות שכן מוגשות, במקום להשלים סיומת בשקט מעל הקיימת ולחזור כ"לא נמצא" על קובץ שקיים. שני סירובים חדשים מגיעים מהפרסור עצמו: ``inconsistent_line_endings`` על ``\r`` בודד (CRLF עובד במלואו), ו-``too_many_sections`` על קובץ שחוצה את תקרת 50,000 הכותרות — Markdown דרך ברירת המחדל של הפרסר, ו-RST דרך התקרה שהכלי מעביר מאז #3429; סירוב שהפארסר מספק לו מספר שורה נושא גם ``line`` — תמיד ב-Markdown, ולא ב-``too_many_sections`` של RST, כי ``rst_parser`` מרים אותה בלי מספר. ``includes`` הוא שדה של RST ולכן בעמוד ``.md`` הוא תמיד ריק. **ומסלול ה-RST לא זז**: אפס דיף מדוד על 5,920 רשומות מ-208 קובצי RST, לפני ואחרי. ראו :ref:`mcp-docs-path-policy`. - **fix: מאגר הקריאות של ה-MCP נגזר ממכסת הזיכרון של הקונטיינר, ולא ממספר המעבדים של המארח (#3391).** כלי הקריאה רצים ב-``asyncio.to_thread``, כלומר ב-executor ברירת המחדל של הלולאה, שגודלו ``min(32, os.cpu_count() + 4)`` — ו-``os.cpu_count()`` בקונטיינר הוא מספר הליבות של **המארח**, כפי ש-CPython מתעד במפורש. שורת הקיבולת מהייצור אמרה את זה במספרים: ``read pool 12 threads (os.cpu_count=8, usable=8)`` על מכסה של ``0.50 cpu``. לקריאת מונגו זה לא מזיק; לפרסור בפייתון טהור אין מקביליות ב-CPU בגלל ה-GIL, ולכן 12 חוטים היו 12 עצי פרסור בזיכרון בו-זמנית — יותר ממה שנכנס ב-512MiB — ורגע לפני שהפרסור הופך לכלי ציבורי. מעכשיו ``attach_read_pool`` מתקין בעליית ה-lifespan של אפליקציית ה-ASGI מאגר משלו, בגודל ``(מגבלת הזיכרון − קו בסיס − שוליים) ÷ עלות פרסור אחד``: המגבלה נקראת מ-cgroup בזמן ריצה (``memory.max`` ב-v2, ‏``memory.limit_in_bytes`` ב-v1) ולא מקובעת בקוד, קו הבסיס והשוליים הם מדידות מהשירות בייצור עם תאריך, ועלות הפרסור נגזרת מ-``MAX_FILE_SIZE_FOR_DISPLAY`` כי הכלי דוחה ``too_large`` לפני שהוא מפרסר. על תוכנית הייצור זה **10** חוטים במקום 12 — עלות הפרסור נמדדה מחדש על הפרסר הנעוץ (``scripts/measure_md_parse_cost.py``; הקבוע נמדד על ``services.md_parser`` כהכנה לכלי ה-Markdown של PR 5, בעוד הכלי הציבורי היום מפרסר RST ב-``services.rst_parser``, שעולה כ-6 בתים לבית גם על העמוד הצפוף ביותר — והסקריפט מודד את שניהם), והיא תלויה בצפיפות הטוקנים של המסמך ולא בגודלו: המסמך הצפוף ביותר בקורפוס הוא הקבוע, וצורה עוינת של שורות-תבליט בודדות עולה פי ארבעה ממנו — מה ששום רוחב מאגר אינו סופג, ומחכה לתקרת טוקנים בפרסר עצמו. **ובעקבות הסקירה:** ``codekeeper_docs_get_section`` מעביר לפרסר את תקרת הסקשנים של האאוטליין (50,000) ומחזיר ``too_many_sections`` במקום לפרסר את הקובץ כולו — צורה עוינת של RST (כותרת בת תו אחד בכל שורה) עלתה 44.0MiB לפרסור, יותר מ-35.2MiB שהמאגר מקצה לחוט, ועכשיו נעצרת ב-20.1MiB; אף עמוד אמיתי אינו מתקרב לתקרה. רצפה של 2 (קריאה תקועה אחת לא משביתה את השאר), תקרה של 12, הרוחב שהייצור כבר הריץ (מעבר לתוכנית גדולה לא מרחיב את המאגר בלי החלטה; הגרסה הראשונה נקבה ב-32, והסקירה של #3429 הורידה אותה כי המחלק מתאר את הפרסור ולא את קריאות הטווח של האדמין; חסימת הקריאה במקור — #3433), וכשאין מגבלה קריאה — הרצפה, **לא** ``cpu_count + 4``, עם WARNING שאומר שזה קרה. **ושורת הקיבולת מפסיקה לחשב בעצמה:** היא נרשמת מתוך ה-lifespan, קוראת את ``_max_workers`` של המאגר שהותקן בפועל, ומדווחת גם את מגבלת הזיכרון לצד מכסת ה-CPU — כי רשומה שמתארת מצב ומתעדכנת בנפרד ממנו היא בדיוק מה שמשקר בשקט. ה-seam הוא עטיפת ``router.lifespan_context``, אותו seam שניקוז ה-PostHog כבר משתמש בו, כי ``FastMCP.streamable_http_app()`` בונה את Starlette עם ``lifespan=`` משלה ו-``on_startup`` אינו רץ. הטסט המרכזי נכנס ל-lifespan של האפליקציה האמיתית ומוכיח שקריאת ``to_thread`` נוחתת על חוט של המאגר החדש — לא שפונקציה נקראה; שורת הקיבולת נבדקת בתת-תהליך נקי דרך הפלט האמיתי ולא דרך ``caplog``. ראו "מודל הריצה של הכלים" ב-:doc:`mcp-server`. - **feat: ``codekeeper_docs_get_section`` מוצא סעיף גם לפי המזהה שלו, ולא רק לפי השם המלא.** הסוכנים מפנים זה לזה לפי מזהה — "קרא K11", "ראו U3" — ועד היום ``section="K11"`` החזיר ``section_not_found`` עם רשימת הצעות **ריקה**, כי ההתאמה הייתה שוויון מלא ו-``difflib`` עם ``cutoff=0.5`` אינו מוצא קרבה בין שלושה תווים לכותרת עברית ארוכה. כלומר הזרימה המרכזית של הכלי לא עבדה. מעכשיו, **ורק אחרי שהשוויון המלא לא מצא כלום**, שאילתה שבנויה כמזהה (עד שלוש אותיות ואחריהן עד שלוש ספרות, עם נקודה אופציונלית) מותאמת לכותרת שנפתחת באותו מזהה. **והמזהה נקרא כיחידה ולא כקידומת של מחרוזת**, ולכן ``K1`` מחזיר את ``K1`` בלבד ולעולם לא את ``K10`` עד ``K15`` — הצורה השבורה של הבדיקה הזאת היא אותה מחלקה בדיוק שבה גבול נתיב נבדק כרצף תווים. שאילתה שאינה בנויה כמזהה אינה עוברת במסלול הזה כלל, ולכן ``section="איך"`` ממשיך להחזיר אפס התאמות גם בעמוד שיש בו חמש-עשרה כותרות שמתחילות במילה הזאת. מזהה שחוזר פעמיים הוא ``ambiguous_section`` עם מועמדים, כמו כל כותרת כפולה, ושאילתת מזהה שלא נמצאה מקבלת ב-``suggestions`` קודם כול כותרת קרובה, אם יש כזו בעמוד, **ורק כשאין אף כותרת קרובה** את המזהים שכן קיימים — עד 50, ועם ``suggestions_truncated`` כשהיו עוד. **והתקרה גבוהה בכוונה**, כי רשימת מזהים אינה מדורגת: לשאלה "אלה שכן קיימים" אין תשובה "החמש הטובות", וחיתוך שלה מסתיר פריטים בלי קריטריון ובלי דרך לבקש את השאר. **והדגל הזה אומר את אותו דבר בשני המסלולים**: גם רשימת מזהים שנחתכה וגם רשימת כותרות קרובות שנחתכה מדליקות אותו. שדה בשם ``truncated`` שמשמעותו "נחתך" במסלול אחד ו"שקט" בשני הוא בדיוק ההפתעה שאנחנו מנסים למנוע. **ונקודה עוד יותר קלה לפספס: הנקודה שפותחת תת-מספור אינה גבול.** בכותרת ``K11. טקסט`` הנקודה **מסיימת** את המזהה, ובכותרת ``K11.1 טקסט`` היא **מפרידה** בתוך מזהה ארוך יותר — ולכן ``K11`` לעולם אינו מחזיר את ``K11.1``, וכותרת בתת-מספור נגישה בשמה המלא בלבד. זו אותה מחלקה בדיוק כמו ``K1`` שתופס את ``K10``: גבול שנבדק כתו בודד, בלי לשאול מה בא אחריו. **ההתנהגות על התיעוד כאן לא זזה, וזה נמדד ולא הונח:** הפלט של הכלי על כל קובצי ה-RST — כל כותרת, בכל צורותיה — זהה בית-בית לפני ואחרי. וההנחה שהכול נשען עליה, שאף כותרת בעמודים כאן אינה נפתחת במזהה, **אינה ספירה שנרשמה פעם אחת אלא טסט שרץ בכל CI**: ``tests/test_docs_headings_carry_no_identifier.py`` סורק את כל העמודים ונופל על כותרת שנפתחת במזהה, עם דוגמת autodoc אמיתית שמוכיחה שהוא מסוגל ליפול. **ומה שלא נבדק בקורפוס הזה נבדק בבקרה סינתטית**, כי סוללה שכל תשובותיה "לא נמצא" אינה מבדילה בין ענף כבוי לבין פרובים עיוורים. שני הדברים שמפתיעים קורא — קיצור המזהה, וכותרת שנושאת בקטיקים ולכן דורשת אותם בשאילתה — עברו לתיאור הפרמטר ``section``, שאינו נחתך אצל הלקוח כמו תיאור הכלי. ראו :ref:`mcp-docs-section`. diff --git a/mcp_server/app.py b/mcp_server/app.py index eaeb72bd6..13bc341a6 100644 --- a/mcp_server/app.py +++ b/mcp_server/app.py @@ -51,6 +51,15 @@ ) from .backend import ProductionBackend +from .handlers import max_code_size +from .limits import ( + DEFAULT_RATE_LIMIT_PER_MINUTE, + MAX_REQUEST_BYTES_ENV, + MIN_MAX_REQUEST_BYTES, + RATE_LIMIT_ENV, + limit_from_env, + request_bytes_for, +) from .server import build_app from .token_store import MCPTokenStore from .wiring import resolve_mongo @@ -116,6 +125,17 @@ def create_app(): backend = ProductionBackend(db_manager=db_manager, mongo_db=mongo) name = os.getenv("MCP_SERVER_NAME", "CodeKeeper") + # Request limits (#3431) — read here, at the service entry, not at import. + # The body cap is derived from the code-size ceiling the service actually + # runs with (``MAX_CODE_SIZE`` from the config, or the handlers' fallback), + # so raising one can never leave the other behind; ``MCP_MAX_REQUEST_BYTES`` + # is the way to raise the cap further without a deploy, not a kill switch. + max_request_bytes = limit_from_env( + MAX_REQUEST_BYTES_ENV, request_bytes_for(max_code_size()), minimum=MIN_MAX_REQUEST_BYTES + ) + rate_limit_per_minute = limit_from_env( + RATE_LIMIT_ENV, DEFAULT_RATE_LIMIT_PER_MINUTE, minimum=1, zero_disables=True + ) # Phase D: admin-only repo-browser tools (hidden + gated for non-admins). from .repo_backend import RepoBackend @@ -148,10 +168,19 @@ def create_app(): consent_routes=consent, repo_backend=repo_backend, name=name, + max_request_bytes=max_request_bytes, + rate_limit_per_minute=rate_limit_per_minute, ) # Fallback: PAT-only (Claude Code/Desktop) — runs without OAuth config. - return build_app(backend, MCPTokenStore(mongo), repo_backend=repo_backend, name=name) + return build_app( + backend, + MCPTokenStore(mongo), + repo_backend=repo_backend, + name=name, + max_request_bytes=max_request_bytes, + rate_limit_per_minute=rate_limit_per_minute, + ) app = create_app() diff --git a/mcp_server/docs_handlers.py b/mcp_server/docs_handlers.py index 8f13e5b8a..a1c2e1e5b 100644 --- a/mcp_server/docs_handlers.py +++ b/mcp_server/docs_handlers.py @@ -20,12 +20,11 @@ import os import posixpath from dataclasses import dataclass -from types import ModuleType -from typing import Any, NamedTuple +from types import MappingProxyType, ModuleType +from typing import Any, Mapping, NamedTuple from services import doc_sections, md_parser, rst_parser from .handlers import _clamp -from .outline_scanners import _ceiling logger = logging.getLogger(__name__) @@ -56,6 +55,20 @@ DEFAULT_DOCS_REPO = "CodeBot" _TOC_MAX = 400 # תקרת פריטי TOC בתשובה (הגנת גודל) +#: תקרת המועמדים בתשובת ``ambiguous_section``. **אותו מספר כמו +#: ``doc_sections.MAX_IDENTIFIER_SUGGESTIONS``, ומאותה סיבה:** רשימת המועמדים +#: היא מלאי בסדר הופעה ולא דירוג, ולכן חיתוך שלה מסתיר פריטים בלי קריטריון — +#: והתקרה גבוהה מספיק כדי שכל עמוד אמיתי ייענה במלואו (כותרת כפולה בקורפוס +#: חוזרת פעמיים או שלוש). מה שהיא עוצרת נמדד בסקירת #3425: עמוד סינתטי של +#: 512KB עם 13,030 כותרות שנפתחות ב-``K11.`` החזיר 13,030 מועמדים — 1,393,787 +#: בתים ושיא הקצאה של 12.6MB — על שאילתה בת שלושה תווים, וזה היה השדה היחיד +#: בתשובה בלי גבול (``toc`` ב-``_TOC_MAX``, ‏``suggestions`` +#: ב-``MAX_IDENTIFIER_SUGGESTIONS``). הענף נדלק רק מכותרות שנושאות מזהה, ומאז +#: #3428 הקורפוס שנושא מזהים (``amir-bug-patterns``) מוגש. אותו מספר ממש — +#: מיובא ולא מוקלד שוב (הכיוון ``services`` ← ``mcp_server`` מותר; ההפוך לא), +#: והטסט שומר שלא יוקלד מחדש. אישו #3426. +_CANDIDATES_MAX = doc_sections.MAX_IDENTIFIER_SUGGESTIONS + #: הסיומת ← **המודול** שמפרסר אותה. זהו המקום היחיד שאומר איזה פורמט הכלי #: יודע לקרוא בכלל, ו-``DOCS_PATH_POLICY`` למטה אומר מי מהם מוגש בכל ריפו. #: שתי שאלות שונות, ולכן שתי טבלאות — ו-``_validate_policy_tables`` קושר @@ -67,7 +80,13 @@ #: קופאת על הפונקציה המקורית וה-spy היה נעקף **בשקט** — בלי שאף טסט קיים #: ייפול. ``tests/test_mcp_docs_handlers.py::test_the_parser_table_holds_modules_ #: so_a_monkeypatch_on_the_module_is_seen`` מקבע את זה. -_PARSERS: dict[str, ModuleType] = {".rst": rst_parser, ".md": md_parser} +_PARSER_TABLE: dict[str, ModuleType] = {".rst": rst_parser, ".md": md_parser} +#: **מה שמיוצא אינו ניתן לשינוי** (#3432, SUGG-021): ``_validate_policy_tables`` +#: רץ פעם אחת בייבוא, ושינוי של הטבלה אחריו עוקף אותו בשקט. ``MappingProxyType`` +#: הופך את ההבטחה לתכונה — ``_PARSERS[".txt"] = x`` הוא ``TypeError`` — והטבלה +#: שמתחת, ``_PARSER_TABLE``, נשארת התפר לטסטים (``monkeypatch.setitem`` עליה +#: נראה דרך הפרוקסי מיד, ומוחזר בסוף הטסט). +_PARSERS: Mapping[str, ModuleType] = MappingProxyType(_PARSER_TABLE) @dataclass(frozen=True) @@ -121,10 +140,14 @@ class _DocsPathPolicy: #: מה ש**לא** משתנה: ``mcp_server.repo_policy.is_denied`` הוא ההוראה #: הראשונה ב-``RepoBackend.get_file`` וחל על המסלול הזה במלואו, כך #: ש-``secrets.md``, ``credentials.md`` ו-``.env*`` חסומים גם כאן. -DOCS_PATH_POLICY: dict[str, _DocsPathPolicy] = { +_DOCS_PATH_POLICY_TABLE: dict[str, _DocsPathPolicy] = { "CodeBot": _DocsPathPolicy(root="docs", suffix=".rst"), "amir-bug-patterns": _DocsPathPolicy(root="", suffix=".md"), } +#: קריאה בלבד, מאותו נימוק שכתוב ליד ``_PARSERS``: הוולידציה בייבוא היא הבטחה +#: רק אם הטבלה אינה משתנה אחריה. הטסטים שמוסיפים ריפו סינתטי עושים זאת על +#: ``_DOCS_PATH_POLICY_TABLE``. +DOCS_PATH_POLICY: Mapping[str, _DocsPathPolicy] = MappingProxyType(_DOCS_PATH_POLICY_TABLE) def _validate_policy_tables() -> None: @@ -164,7 +187,11 @@ class _ResolvedPath(NamedTuple): ``str | None`` הספיק כל עוד הייתה סיבת דחייה אחת. הצורה ``value-or-error-string`` שב-``repo_backend.normalize_line_range`` אינה עובדת כאן, כי הנתיב וקוד השגיאה שניהם ``str`` ו-``isinstance`` אינו - מבדיל ביניהם. + מבדיל ביניהם. ארבעה קודים חיים כאן היום, אחד לכל דחייה: ``missing_path`` + (אין נתיב — ריק או עם NUL), ``path_too_long``, ``suffix_not_allowed``, + ו-``path_outside_root`` (הנתיב תקין בצורתו ופותר אל מחוץ לשורש התיעוד; + עד #3432 הוא חלק קוד עם ``missing_path``, והקורא לא ידע אם שכח נתיב או + ניסה לצאת מהשורש). **ו-``suffix`` נישא ולא נגזר מחדש, וזה תיקון לבאג שנתפס בבדיקה.** הגרסה הראשונה קראה ל-``_suffix_of`` פעם שנייה על הנתיב המוגמר כדי @@ -253,7 +280,8 @@ def _resolve_docs_path(path: str, policy: _DocsPathPolicy) -> _ResolvedPath: ל-``docs/secrets.rst``, **ויגיש אותו**. 4. ``normpath`` — כאן, ורק כאן. 5. הגבול מול השורש. זו הנקודה השנייה: ``startswith`` חשוף הוא - ``K16``, ולכן הוא עטוף ב-``_is_under``. + ``K16``, ולכן הוא עטוף ב-``_is_under`` — ונתיב שנופל בה נדחה + ב-``path_outside_root``, בשמו, ולא כ"נתיב חסר". """ p = (path or "").strip().strip("/") if not p or "\x00" in p: @@ -283,16 +311,26 @@ def _resolve_docs_path(path: str, policy: _DocsPathPolicy) -> _ResolvedPath: norm = posixpath.normpath(p) if _is_under(norm, policy.root): return _ResolvedPath(norm, suffix, None) - return _ResolvedPath(None, None, "missing_path") + return _ResolvedPath(None, None, "path_outside_root") -def _toc(doc: doc_sections.Document) -> tuple[list, bool]: - items = doc_sections.build_toc(doc) - if len(items) > _TOC_MAX: - return items[:_TOC_MAX], True +def _capped(items: list, limit: int) -> tuple[list, bool]: + """‏``(items, truncated)`` — הרשימה עד ``limit``, והאם באמת נחתכה. + + **פונקציה אחת לשני הגבולות של התשובה** (``toc`` ו-``candidates``), כדי + שהשאלה "מתי הדגל נכון" תיענה במקום אחד: ``True`` רק כשהיו יותר מהתקרה, + ולעולם לא על רשימה שבדיוק בגודלה — אותו גבול כמו ``Capped.append`` + ב-``_ceiling`` וכמו ``max_sections`` בפארסר. + """ + if len(items) > limit: + return items[:limit], True return items, False +def _toc(doc: doc_sections.Document) -> tuple[list, bool]: + return _capped(doc_sections.build_toc(doc), _TOC_MAX) + + def _line_of(exc: BaseException) -> dict: """``{"line": N}`` כשהחריגה נושאת מספר שורה, ו-``{}`` כשלא. @@ -353,6 +391,11 @@ def docs_get_section( if resolved.error == "path_too_long": return {"ok": False, "error": "path_too_long", "repo": repo_name, "max_chars": MAX_PATH_CHARS, "actual_chars": len(path or "")} + if resolved.error == "path_outside_root": + # השורש כבר כתוב בתיאור הכלי לכל ריפו, ולכן אמירתו כאן אינה מגלה + # דבר — היא רק חוסכת לקורא לחפש את הטעות בשם הקובץ. + return {"ok": False, "error": "path_outside_root", "repo": repo_name, + "root": policy.root} if not resolved.path: return {"ok": False, "error": "missing_path"} file_path = resolved.path @@ -387,16 +430,13 @@ def docs_get_section( # ``resolved.suffix`` ולא גזירה שנייה מ-``file_path``: ראו :class:`_ResolvedPath`. parser = _PARSERS[resolved.suffix] # לעולם לא KeyError: ראו _validate_policy_tables - # **תקרת הסקשנים עוברת ל-RST בלבד, ו-Markdown נשאר על ברירת המחדל של - # הפרסר שלו.** שני המסלולים מוגבלים לאותו מספר — ``_ceiling.MAX_SYMBOLS`` - # ו-``md_parser.MAX_SECTIONS`` שניהם 50,000, וטסט קושר ביניהם — אבל הדרך - # שונה בכוונה: ל-``rst_parser`` ברירת המחדל היא ``None`` (קוראים אחרים - # שלו אינם מוגנים ויודעים זאת), ולכן הכלי מעביר את התקרה במפורש; ל- - # ``md_parser`` ברירת המחדל **היא** התקרה, והעברה מפורשת מכאן הייתה עותק - # שני של החלטה ש-PR הפארסר כבר הכריע. ``parser is rst_parser`` שואל על - # הפרסר ולא על הסיומת — זו תכונה של המודול, לא מקום שלישי שבו סמנטיקת - # הסיומת חיה. התקרה נקראת מהמודול **בזמן הקריאה**, כדי שהטסט יוכל - # להקטין אותה במקום להציף אותה. + # **שני הפרסרים רצים על ברירת המחדל שלהם, והכלי אינו מעביר תקרה.** + # ברירת המחדל של ``max_sections`` בשניהם היא ``doc_sections.MAX_SECTIONS`` + # (50,000; מיושרת מאז #3420 — בין #3429 ל-#3420 ברירת המחדל של + # ``rst_parser`` הייתה ``None`` והכלי העביר לו את ``_ceiling.MAX_SYMBOLS`` + # במפורש). העברה מפורשת מכאן הייתה עותק שני של החלטה שהפארסר כבר הכריע, + # ו-``parser is rst_parser`` היה מקום שלישי שבו סמנטיקת הסיומת חיה. הטסט + # שמקטין את התקרה עושה זאת בפרסר (``functools.partial``), לא כאן. # # מה שהתקרה עוצרת ב-RST (סקירת #3429): כותרת בת תו אחד בכל שורה ב-500KB # עולה 44.0MiB לפרסור אחד — יותר מ-35.2MiB שמאגר הקריאות מקצה לחוט @@ -406,7 +446,6 @@ def docs_get_section( # **ומה שאף תקרה כאן אינה עוצרת, ונשאר פתוח:** Markdown עוין של שורות- # תבליט בלי כותרות — 500KB עולים 141MiB ו-2.3 שניות, ו-``MAX_SECTIONS`` # סופר כותרות ולכן אינו נוגע בו. זה אישו #3391 ולא #3429. - parse_kwargs = {"max_sections": _ceiling.MAX_SYMBOLS} if parser is rst_parser else {} # **ה-``try`` הזה אינו ``K11``, וזה נכתב כדי שסקירה עתידית לא תגזור זאת # מחדש.** ``parse_document`` מתועד כ"ערוץ הכשל הוא חריגה בלבד": הוא אינו @@ -419,10 +458,11 @@ def docs_get_section( # מייצא אותה, וייצוא משם היה מצהיר על סירוב שאינו קיים. התפיסה אינה # מותנית במי שפרסר, כי תנאי כזה היה רשימה שנייה לסנכרן. # - # **ומה שלא נתפס כאן, בכוונה:** ``TypeError`` ו-``RuntimeError`` של - # ``md_parser``. שניהם אומרים "חוזה נשבר" ולא "הקלט נדחה", ועטיפתם - # הייתה בדיוק ``widened-exception-scope``. ``content`` הוא תמיד מחרוזת - # במסלול הזה, כי ``binary`` ו-``too_large`` נחסמו למעלה. + # **ומה שלא נתפס כאן, בכוונה:** ``TypeError`` של שני הפרסרים (מאז #3421 + # גם ``rst_parser`` מרים אותה, דרך ``doc_sections.require_str``) ו- + # ``RuntimeError`` של ``md_parser``. שניהם אומרים "חוזה נשבר" ולא "הקלט + # נדחה", ועטיפתם הייתה בדיוק ``widened-exception-scope``. ``content`` הוא + # תמיד מחרוזת במסלול הזה, כי ``binary`` ו-``too_large`` נחסמו למעלה. # # תקרת המקביליות על הפרסור אינה כאן ואינה צריכה להיות: היא נגזרת מגודל # מאגר הקריאות, ומקומה ב-lifespan של השרת — נחת ב-#3429. @@ -436,18 +476,44 @@ def docs_get_section( # ``_line_of`` ולא ``exc.args[0]`` ישירות: חריגה שתיבנה מחר בלי # ארגומנט לא תפיל כאן ``IndexError`` באמצע בקשה. # - # ``"max"`` הוא ``_ceiling.MAX_SYMBOLS`` בשני המסלולים, וזה נכון ל-Markdown - # רק מפני שהטסט שקושר את שתי התקרות מחזיק אותן שוות — בלעדיו המספר - # שמדווח על סירוב Markdown היה יכול להיות תקרה של פרסר אחר. + # ``"max"`` הוא ``doc_sections.MAX_SECTIONS`` — המספר שהפרסר באמת השתמש + # בו, בשני המסלולים, ולא עותק שלו ממודול אחר. try: - doc = parser.parse_document(content, **parse_kwargs) + doc = parser.parse_document(content) except doc_sections.InconsistentLineEndings as exc: return {"ok": False, "error": "inconsistent_line_endings", **context, **_line_of(exc)} except doc_sections.TooManySections as exc: return {"ok": False, "error": "too_many_sections", - "max": _ceiling.MAX_SYMBOLS, **context, **_line_of(exc)} + "max": doc_sections.MAX_SECTIONS, **context, **_line_of(exc)} + + return _answer_from_document(doc, context=context, section=section, + include_subsections=include_subsections, + max_chars=max_chars, offset=offset) + +def _answer_from_document( + doc: doc_sections.Document, + *, + context: dict[str, Any], + section: str | None, + include_subsections: bool, + max_chars: int, + offset: int, +) -> dict[str, Any]: + """ארבע צורות התשובה של הכלי, מתוך מסמך שכבר נפרסר. + + **מה שנפרד מ-``docs_get_section`` (#3432, SUGG-020):** הפונקציה ההיא + פותרת ריפו, פותרת נתיב, קוראת קובץ ובוחרת פארסר — ארבע החלטות שכל אחת + מהן יכולה לסרב — ומכאן והלאה יש רק מסמך ושאלה. הזנב הזה הוא החלק שנפרד + הכי נקי, ואפס-דיף על 208 קובצי ה-RST (``scripts/docs_section_zero_diff.py``) + הוא מה שמוכיח שהוא רק זז. + + ארבע הצורות: ``toc`` כשאין ``section``; ``section_not_found`` עם הצעות; + ``ambiguous_section`` עם מועמדים; ו-``section`` עם התוכן, השכנים + ותת-הסקשנים. ``context`` הוא ``repo``/``path``/``ref``/``resolved_commit`` + שכל תשובה נושאת. + """ toc_items, toc_truncated = _toc(doc) # ``includes`` הוא שדה של פארסר: ``rst_parser`` ממלא אותו מיעדי @@ -496,13 +562,19 @@ def docs_get_section( # כותרת כפולה → כל המועמדים עם breadcrumb (בלי לנחש) if len(matches) > 1: + candidates, candidates_truncated = _capped(matches, _CANDIDATES_MAX) base.update({ "ok": False, "error": "ambiguous_section", "requested": section, "candidates": [{ "title": s.title, "breadcrumb": list(s.breadcrumb), "level": s.level, "line_range": [s.heading_line, s.end_line], - } for s in matches], + } for s in candidates], }) + if candidates_truncated: + # רק כשנחתך — אותה מוסכמה של ``suggestions_truncated`` ו-``remaining_chars``: + # שדה שקיים תמיד היה משנה כל תשובת ``ambiguous_section`` בתצלום + # אפס-הדיף בלי ששום התנהגות השתנתה. + base["candidates_truncated"] = True return base # התאמה יחידה → הסקשן + ניווט (שכנים ותת-סקשנים) diff --git a/mcp_server/handlers.py b/mcp_server/handlers.py index 77dfd7d09..def5c4278 100644 --- a/mcp_server/handlers.py +++ b/mcp_server/handlers.py @@ -461,8 +461,13 @@ def get_collection_items( ) -def _max_code_size() -> int: - """The app's per-file size gate (characters, not bytes), with a safe fallback.""" +def max_code_size() -> int: + """The app's per-file size gate (characters, not bytes), with a safe fallback. + + Public, because ``mcp_server/app.py`` derives the request-body cap from it + (``limits.request_bytes_for``): one lookup of ``MAX_CODE_SIZE`` serves both + gates, so the two cannot drift apart. + """ try: from config import config as _cfg @@ -493,7 +498,7 @@ def save_file( # Reject oversize content (the large-file path is non-versioned; out of scope # here). Mirror the app's own gate, which counts characters, not bytes. - max_size = _max_code_size() + max_size = max_code_size() if len(code) > max_size: return {"ok": False, "error": "code_too_large", "max": max_size} @@ -615,7 +620,7 @@ def _resave_edited( an edit never resets them; the same size gate as ``save_file`` applies to the resulting body. """ - max_size = _max_code_size() + max_size = max_code_size() if len(new_code) > max_size: return {"ok": False, "error": "code_too_large", "max": max_size} return backend.save_file( diff --git a/mcp_server/limits.py b/mcp_server/limits.py new file mode 100644 index 000000000..6454b6949 --- /dev/null +++ b/mcp_server/limits.py @@ -0,0 +1,383 @@ +"""גבולות הבקשה של שרת ה-MCP — תקרת גודל לגוף הבקשה, והגבלת קצב לפי זהות (#3431). + +**מה היה חסר.** המידלוור היחיד שרץ היה ``PATAuthMiddleware`` — אימות ותו לא: כל +משתמש מאומת יכול היה לשלוח בקשות בכל גודל ובכל תדירות. ‏#3428 הוסיף תקרת אורך +ל-``path`` של כלי אחד, אחרי שנמדד שנתיב של 400KB עלה 608ms של מעבד — וזו +הקטנת משטח לבקשה בודדת, לא תחליף לשני הגבולות שכאן. + +**שני גבולות, בשתי שכבות שונות, ולא במקרה.** + +1. **גודל הגוף — במידלוור ASGI, לפני שהטרנספורט מפענח JSON.** הטרנספורט של + ה-SDK (``mcp/server/streamable_http.py``, ‏``_handle_post_request``, ‏mcp 1.28.1) + קורא את הגוף כולו ב-``await request.body()`` ואז ``json.loads`` — כלומר גוף + של 20MB נטען ומפוענח במלואו לפני שאף כלי רואה ארגומנט. התקרה כאן היא לכן + הגבול היחיד על **כל** ארגומנט של **כל** כלי, קיים ועתידי. + + **מה המידלוור קורא, ומה לא.** הגרסה הראשונה (#3431) קראה כל גוף עד התקרה + לפני שהשכבה הבאה ראתה את הבקשה — גם גוף אנונימי שהאימות היה דוחה מהכותרות + בלבד, ובלי דדליין; זו הייתה רגרסיה (SEC-001 בסקירת שבעת ה-PRים): במצב OAuth + האימות של ה-SDK יושב **בתוך** ה-mount של ``/mcp``, ולכן התקרה, שהיא השכבה + החיצונית, החזיקה עד 1MiB לכל חיבור אנונימי לפני ה-401, וכל עוד הלקוח טפטף + בתים. שלושה כללים סוגרים את זה, וכל אחד מקובע בטסט שנפל לפני שנכתב: + + - **רק מתודות שנושאות גוף נבדקות** (:data:`METHODS_WITH_BODY`). שום route כאן + אינו קורא גוף במתודה אחרת — הטרנספורט קורא ב-``POST``, ו-DCR, token ו-consent + הם ``POST`` — ולכן ``GET /healthz`` עם ``Content-Length`` מזויף, שקודם ענה + 413, עובר בלי שהתקרה מסתכלת עליו. + - **אורך מוצהר תקין עובר בלי קריאה.** ‏``Content-Length`` של ספרות ASCII (מה + ש-h11 מקבל: ``_content_length_re = re.compile(rb"[0-9]+")``, ‏``h11/_headers.py``) + שאין לצידו ``Transfer-Encoding`` הוא תיחום של השרת: uvicorn/h11 מוסרים + לאפליקציה בדיוק את המספר הזה — נמדד: 20 בתים מאחורי ``Content-Length: 10`` + הגיעו כ-10. גדול מהתקרה — 413 בלי שנקרא בית; קטן ממנה — הבקשה עוברת הלאה + כמות שהיא, והשכבה שעונה מהכותרות (ה-401) עונה בלי קריאה. **הסייג הוא + ``Transfer-Encoding``:** לצידו h11 תוחם לפי chunked ומעביר את שתי הכותרות + (RFC 7230 §3.3.3), ונמדד ש-200 בתים מאחורי ``Content-Length: 5`` הגיעו + במלואם — ולכן שם סופרים כמו בלי כותרת. + - **מה שנשאר לספירה — גוף בלי אורך מוצהר — נקרא עד התקרה, ותחת דדליין.** + ל-uvicorn 0.38.0 אין timeout לגוף בקשה (``timeout_keep_alive`` חל אחרי + תשובה), ולכן בלי דדליין ההמתנה לנתח הבא אינה חסומה. הלולאה כולה — לא כל + קריאה בנפרד, כי בית כל 29 שניות היה עובר דדליין-לקריאה — רצה תחת + ``anyio.fail_after`` (:data:`DEFAULT_BODY_READ_SECONDS`), וכשהוא פג התשובה + היא 408 שאומר כמה נקרא. + + **ולמה חיץ עד התקרה, ולא חריגה מתוך ``receive``.** אותו ``_handle_post_request`` + עוטף את קריאת הגוף ב-``try`` שתופס ``Exception`` ומחזיר שגיאת JSON-RPC משלו — + חריגה שהיינו מרימים מתוך ``receive`` הייתה נבלעת שם והופכת ל"שגיאת שרת" בלי + סיבה. לכן המידלוור קורא בעצמו את הודעות ``http.request`` עד התקרה, מסרב + ב-413 **בעצמו** כשהיא נחצית, ואחרת משדר את מה שכבר נקרא הלאה כמות שהוא. + גוף שמתחת לתקרה ממילא מוחזק בשלמותו על ידי ``request.body()``, ולכן החיץ אינו + עולה זיכרון נוסף. הפרוטוקול: הודעות ``http.request`` עם ``body`` ו-``more_body``, + ו-``http.disconnect`` — כפי ש-``starlette/requests.py::Request.stream`` + (Starlette 1.6.0) קורא אותן. + + **סירוב סוגר את החיבור.** שני הסירובים מגיעים לפני שהגוף נגמר, ולכן נושאים + ``Connection: close``: h11 מכבה keep-alive על תשובה שנושאת אותו + (``h11/_connection.py::_keep_alive``), ו-uvicorn סוגר את ה-transport מיד אחרי + התשובה (``h11_impl.py::send`` — ``our_state is MUST_CLOSE``). בלעדיו השרת היה + ממשיך לקרוא ולזרוק את שארית הגוף שסירבנו לו, ומחזיק חיבור פתוח לגוף + שלא הגיע. ל-408 זה גם מה שהתקן מבקש: "the server SHOULD send the 'close' + connection option in the response, since 408 implies that the server has + decided to close the connection rather than continue waiting" (RFC 9110 + §15.5.9, מצוטט כאן לפי MDN). + +2. **קצב — בנקודת הכניסה של קריאות הכלים, לא במידלוור.** הזהות שהאישו מציע + לספור לפיה ("הטוקן כבר מזוהה במידלוור") קיימת ב-``request.state`` רק במצב PAT; + במצב OAuth — הייצור — ``PATAuthMiddleware`` אינו מותקן כלל, וה-SDK מאמת בתוך + ה-mount של ``/mcp`` (ראו את ה-docstring של ``mcp_server/auth.py``). המקום היחיד + שרואה את הזהות **בשני המצבים** הוא ``current_user_id`` על ה-``Context`` של + הקריאה, ולכן ההכרעה יושבת ב-``AdminAwareFastMCP.call_tool`` — המתודה שה-SDK + רושם כמטפל של ``tools/call`` (``FastMCP._setup_handlers``, ‏mcp 1.28.1), כלומר + נקודה אחת שכל קריאת כלי עוברת בה: גוף סינכרוני לפני שהוא נמסר לחוט, גוף + אסינכרוני לפני שהוא רץ על הלולאה. קריאה שנדחתה אינה עולה עובד. מה שאינו + קריאת כלי — ``initialize``, ‏``tools/list``, זרם ה-SSE — זול ואינו נספר. + + **הפטור לנתיב הדופק — שני חצאים, ורק אחד מהם מבני.** מהמגביל ``/healthz`` פטור + במבנה: הוא אינו קריאת כלי, ולכן לעולם אינו מגיע לנקודת השיגור, והמוניטור + החיצוני שדוגם אותו בקצב קבוע אינו יכול לקבל 429 — המלכודת של + ``blanket-policy-silent-block`` §7. מתקרת הגוף הוא פטור **בכלל**, לא במבנה: + ``GET`` אינה מתודה שנושאת גוף ולכן התקרה אינה מסתכלת עליה — עד SEC-001 היא + כן הסתכלה, ו-``GET /healthz`` עם ``Content-Length`` מזויף ענה 413. שני + החצאים מקובעים בטסט על האפליקציה האמיתית, בשני מצבי האימות. + +**הסירוב נוקב בסיבתו** — כמו ``path_too_long`` ו-``too_many_sections``: ‏413 עם +``{"error": "body_too_large", "max_bytes": ...}`` ו-408 עם ``{"error": +"body_read_timeout", "read_timeout_seconds": ..., "received_bytes": ...}``, באותה +צורה של ה-401 ב-``auth.py``; וקריאת כלי שנחסמה מחזירה ``{"ok": false, "error": +"rate_limited", "limit_per_minute": ..., "retry_after_seconds": ...}`` — תשובת +כלי רגילה, כי זה מה שהסוכן קורא, ועם הזמן שנותר עד שהחלון משתחרר במקום "נסה +שוב". + +**המספרים, ומאיפה.** ראו ליד הקבועים. תקרת הגוף **נגזרת** מ-``MAX_CODE_SIZE`` +(:func:`request_bytes_for`) ואינה מוקלדת לצידו, והקצב ניתן לכיוון במשתנה +סביבה; שניהם נקראים ב-``create_app`` (בכניסה לשירות, לא בזמן ייבוא), דרך +:func:`limit_from_env`, שלעולם אינו הופך ערך פגום לגבול רחב יותר מברירת המחדל. +""" + +from __future__ import annotations + +import logging +import math +import os +import re +import time +from typing import Any, Awaitable, Callable + +# anyio הוא תלות ישירה של mcp (``anyio>=4.5``) ושל starlette, ומיובא כאן ישירות +# בדיוק כמו ש-starlette עצמו מיובא — בלי נעיצה נפרדת בדרישות. +import anyio +from starlette.datastructures import Headers +from starlette.responses import JSONResponse + +from rate_limiter import RateLimiter + +from .handlers import DEFAULT_MAX_CODE_SIZE + +logger = logging.getLogger(__name__) + +#: כמה בתים תו אחד של ``code`` יכול לעלות על החוט. לקוח שמקודד JSON עם +#: ``ensure_ascii`` — ברירת המחדל של ``json.dumps`` — כותב כל תו שאינו ASCII +#: כ-``\\uXXXX``: שישה בתים. עברית היא המקרה הרגיל כאן, וכולה מחוץ ל-ASCII. +JSON_BYTES_PER_CHAR = 6 + +#: כל מה שבבקשת ``tools/call`` אינו ``code``: מעטפת ה-JSON-RPC, שם הכלי, ושאר +#: הארגומנטים של ``codekeeper_save_file`` — שם הקובץ (בלי תקרה בשכבה הזאת), +#: שפה, ותיאור שחסום ב-``FILE_DESCRIPTION_MAX_CHARS`` (500 תווים, כלומר 3,000 +#: בתים בקידוד היקר). ‏64KiB הם סדר גודל מעל כל זה, וטסט בונה את הבקשה הגדולה +#: ביותר ומודד שהיא נכנסת. +REQUEST_ENVELOPE_BYTES = 65_536 + +_MIB = 1024 * 1024 + + +def request_bytes_for(max_code_chars: int) -> int: + """תקרת הגוף שמכילה את הבקשה הלגיטימית הגדולה ביותר לתקרת קוד נתונה. + + הבקשה הגדולה ביותר היא ``codekeeper_save_file`` על קובץ בגודל ``MAX_CODE_SIZE`` + (תווים, לא בתים — ``mcp_server/handlers.py::max_code_size``), ולכן: + ``MAX_CODE_SIZE × JSON_BYTES_PER_CHAR + REQUEST_ENVELOPE_BYTES``, מעוגל כלפי + מעלה ל-MiB שלם. העיגול הוא כדי שהמספר יישאר מוכר ויציב מול שינוי קטן + במרכיבים — תקרה היא תקרה, לא התאמה מדויקת. על ברירת המחדל של התצורה + (100,000 תווים) זה 1MiB; ‏``tests/test_mcp_limits.py`` מצמיד את הגזירה. + """ + needed = int(max_code_chars) * JSON_BYTES_PER_CHAR + REQUEST_ENVELOPE_BYTES + return -(-needed // _MIB) * _MIB + + +#: תקרת גוף הבקשה בבתים על ברירת המחדל של ``MAX_CODE_SIZE`` — 1MiB. הערך +#: שהשירות באמת מריץ נגזר ב-``create_app`` מהתצורה בפועל (``max_code_size()``), +#: וזה כאן הוא ברירת המחדל של ``build_app`` ושל המידלוור, והמספר שהתיעוד מציג. +DEFAULT_MAX_REQUEST_BYTES = request_bytes_for(DEFAULT_MAX_CODE_SIZE) + +#: הרצפה לערך ממשתנה הסביבה: תקרה של עשרה בתים היא הפסקת שירות, לא הקשחה — +#: הודעת ``initialize`` לבדה היא כמה מאות בתים. +MIN_MAX_REQUEST_BYTES = 65_536 + +#: המתודות שנושאות גוף — ולכן היחידות שהתקרה בודקת. הערך ב-scope הוא כפי +#: שהשרת נותן אותו ("The HTTP method name, uppercased" — מפרט ASGI), ומושווה +#: כמות שהוא, בדיוק כמו ש-Starlette מנתב לפיו. ``PUT`` ו-``PATCH`` אינם בשימוש +#: כאן היום, אבל הם המחלקה: route חדש שיקרא גוף באחת מהן מכוסה בלי לגעת בשער. +METHODS_WITH_BODY = frozenset({"POST", "PUT", "PATCH"}) + +#: כמה זמן הלולאה שקוראת גוף **בלי אורך מוצהר** מוכנה להמתין לו כולו. המסלול +#: הזה רץ רק ללקוח שאינו מצהיר ``Content-Length`` — לקוח MCP רגיל מצהיר, כי הוא +#: שולח הודעת JSON שלמה (httpx, שלקוח ה-SDK בנוי עליו, מוסיף ``Content-Length`` +#: לכל גוף בתים: ``httpx/_content.py::encode_content``). ‏30 שניות לגוף של עד +#: 1MiB הן כ-35KB/s — מתחת לכל קו שלקוח כזה רץ עליו. מחיר טעות לכל כיוון: קצר +#: מדי — 408 שלקוח יכול לנסות שוב עם אורך מוצהר; ארוך מדי — קורוטינה (לא חוט) +#: שמחזיקה חיץ עד התקרה למשך הדדליין, לכל חיבור אנונימי שטורח. +DEFAULT_BODY_READ_SECONDS = 30.0 + +#: קריאות כלים לזהות אחת בדקה. נגזר מהמדידות בתגובה ב-#3431 (2026-09-20): +#: הבקשה הציבורית היקרה ביותר היום היא עמוד RST של 500KB — ‏0.24 שניות מעבד +#: עם תקרת הסקשנים (0.035 לעמוד הצפוף האמיתי); במסלול ה-Markdown (#3428) +#: המסמך הצפוף האמיתי עולה 0.47 שניות והצורה העוינת 2.3; המכסה היא 0.5 מעבד, +#: כלומר 30 שניות-מעבד בדקה. שישים קריאות של המסמך הצפוף האמיתי הן 28 שניות — +#: זהות אחת יכולה לכל היותר למלא דקת מעבד אחת משלה, ולא יותר; קריאה רגילה +#: (10–50ms) הופכת 60 בדקה לאחוז עד שלושה מהמכסה. ומול מאגר של 10 חוטים, +#: 60 בדקה הן קריאה אחת בשנייה לזהות — הוגנות, לא חנק. +#: +#: **וגם צד הכתיבה (סקירת שבעת ה-PRים, SUGG-005):** אותן 60 בדקה לזהות עומדות +#: גם מול מאגר הכתיבה של עובד אחד (``mcp_server/server.py::_WRITE_POOL``). +#: המגביל אינו ההגנה על תור כתיבה שאינו מתרוקן — זו ה-WARNING של +#: ``_SLOW_WRITE_QUEUE_WAIT`` — ובמספרים של היום התור מתרוקן הרבה מעל 60 בדקה: +#: בלוגי השירות בפרנקפורט (``srv-dal7o0u1egvs73eqp0pg``, מאז 2026-09-16, נקראו +#: 2026-09-21) 30 שורות ``mcp write``, ‏``queued 0.000s`` בכולן פרט לאחת (0.003s), +#: ו-``ran`` בין 0.012 ל-1.993 שניות. המספרים הישנים על גוף כתיבה (7.5 שניות +#: ב-p95, ‏4.3–4.9 ב-p50) נמדדו לפני המעבר לפרנקפורט והתיישנו. +DEFAULT_RATE_LIMIT_PER_MINUTE = 60 + +MAX_REQUEST_BYTES_ENV = "MCP_MAX_REQUEST_BYTES" +RATE_LIMIT_ENV = "MCP_RATE_LIMIT_PER_MINUTE" + +BODY_TOO_LARGE = "body_too_large" +BODY_READ_TIMEOUT = "body_read_timeout" +RATE_LIMITED = "rate_limited" + +# מה ש-h11 מקבל כאורך: ``_content_length_re = re.compile(rb"[0-9]+")`` +# (``h11/_headers.py``). ‏``str.isdigit()`` היה מקבל גם ``²`` ונופל על ``int()``. +_CONTENT_LENGTH = re.compile(r"[0-9]+") + + +def limit_from_env(name: str, default: int, *, minimum: int, zero_disables: bool = False) -> int: + """ערך מספרי ממשתנה סביבה, בלי שטעות תצורה תפתח את הגבול. + + ריק או חסר — ברירת המחדל. ערך שאינו מספר שלם — ברירת המחדל, עם WARNING + שאומר מה נקרא (U3: משתנה סביבה הוא קלט חיצוני). ‏``0`` כשמותר לכבות — + כיבוי מפורש, עם WARNING, כי זה מצב שצריך להיראות בלוג העלייה ולא להתגלות + במקרה (K12 §3: בלי ברירת מחדל שקטה שמרחיבה). מתחת למינימום — המינימום, + עם WARNING. בשום מסלול ערך פגום אינו הופך לגבול רחב יותר מברירת המחדל. + """ + raw = (os.getenv(name) or "").strip() + if not raw: + return default + try: + value = int(raw) + except ValueError: + logger.warning("%s=%r is not an integer; using the default %d", name, raw, default) + return default + if value == 0 and zero_disables: + logger.warning("%s=0: this limit is switched off by configuration", name) + return 0 + if value < minimum: + logger.warning("%s=%d is below the minimum %d; using the minimum", name, value, minimum) + return minimum + return value + + +class BodySizeLimitMiddleware: + """מידלוור ASGI טהור: גוף בקשה גדול מ-``max_bytes`` נדחה ב-413 לפני שמישהו מפענח אותו. + + ASGI טהור ולא ``BaseHTTPMiddleware``, כי הוא צריך את ``receive`` עצמו: ההודעות + נקראות כאן, נספרות, ומשודרות הלאה — ו-``BaseHTTPMiddleware`` מסתיר את זה + מאחורי ``Request``. ראו את ה-docstring של המודול על מה נקרא כאן ומה לא, + ועל למה חיץ ולא חריגה. + + ``read_timeout`` — הדדליין, בשניות, על קריאת גוף בלי אורך מוצהר; פרמטר כדי + שטסט יוכל להקטין אותו במקום להמתין לו. + """ + + def __init__( + self, + app: Any, + *, + max_bytes: int = DEFAULT_MAX_REQUEST_BYTES, + read_timeout: float = DEFAULT_BODY_READ_SECONDS, + ) -> None: + self.app = app + self.max_bytes = int(max_bytes) + self.read_timeout = float(read_timeout) + + async def __call__(self, scope: dict, receive: Callable[[], Awaitable[dict]], send: Any) -> None: + if scope.get("type") != "http" or scope.get("method") not in METHODS_WITH_BODY: + await self.app(scope, receive, send) + return + + headers = Headers(scope=scope) + declared = _declared_length(headers) + if declared is not None: + if declared > self.max_bytes: + await self._refuse( + scope, receive, send, + status=413, reason=BODY_TOO_LARGE, max_bytes=self.max_bytes, content_length=declared, + ) + return + if headers.get("transfer-encoding") is None: + # השרת תוחם את הגוף לאורך המוצהר — אין מה לקרוא כאן (ראו את + # ה-docstring של המודול, "אורך מוצהר תקין עובר בלי קריאה"). + await self.app(scope, receive, send) + return + + buffered: list[dict] = [] + received = 0 + over_cap = False + try: + with anyio.fail_after(self.read_timeout): + while True: + message = await receive() + buffered.append(message) + if message.get("type") != "http.request": + break # ``http.disconnect`` — משודר הלאה כמות שהוא + received += len(message.get("body", b"")) + if received > self.max_bytes: + over_cap = True + break + if not message.get("more_body", False): + break + except TimeoutError: + # ``anyio.fail_after`` מרים ``TimeoutError`` (``anyio/_core/_tasks.py``, + # anyio 4.15.1) אחרי שביטל את ההמתנה ל-``receive``. + await self._refuse( + scope, receive, send, + status=408, reason=BODY_READ_TIMEOUT, + read_timeout_seconds=self.read_timeout, received_bytes=received, + ) + return + if over_cap: + await self._refuse( + scope, receive, send, + status=413, reason=BODY_TOO_LARGE, max_bytes=self.max_bytes, received_bytes=received, + ) + return + + async def replay() -> dict: + if buffered: + return buffered.pop(0) + return await receive() + + await self.app(scope, replay, send) + + async def _refuse( + self, scope: dict, receive: Any, send: Any, *, status: int, reason: str, **detail: int | float + ) -> None: + # גודל, זמן ונתיב בלבד — לא הגוף ולא הטוקן (K13). + logger.warning( + "mcp request refused: %s (%s) on %s", + reason, + ", ".join(f"{k}={v}" for k, v in detail.items()), + scope.get("path"), + ) + # ``Connection: close`` — הסירוב הגיע לפני שהגוף נגמר; ראו את ה-docstring + # של המודול, "סירוב סוגר את החיבור". + response = JSONResponse({"error": reason, **detail}, status_code=status, headers={"connection": "close"}) + await response(scope, receive, send) + + +def _declared_length(headers: Headers) -> int | None: + """‏``Content-Length`` כמספר — ורק כשהוא ספרות ASCII, כמו ש-h11 מקבל אותו (U3). + + כל צורה אחרת אינה אורך מוצהר: מול uvicorn היא נדחתה ב-400 עוד לפני ה-ASGI, + ומול שרת אחר סופרים את מה שבאמת מגיע. + """ + value = headers.get("content-length") + if value is None or _CONTENT_LENGTH.fullmatch(value) is None: + return None + return int(value) + + +class ToolRateLimiter: + """הגבלת קצב לפי זהות על קריאות כלים, מעל ``rate_limiter.RateLimiter`` הקיים. + + אותו מגביל שהבוט משתמש בו (R6: לא עותק שני של חלון מתגלגל) — בזיכרון, + ללא Redis, וזה מספיק כי שירות ה-MCP רץ במופע אחד. **המופע היחיד הוא הגדרה + של השירות ב-Render, מחוץ לריפו** (``numInstances: 1``; נבדק מול ה-API של + Render ב-2026-09-21) — ``docs/mcp-server.rst`` מתאר זאת ואינו המקור. מי + שמוסיף מופע מאבד את הזיכרון המשותף, וזה המקום להתחיל ממנו. ‏``per_minute == 0`` + פירושו כבוי במפורש. + """ + + def __init__(self, per_minute: int = DEFAULT_RATE_LIMIT_PER_MINUTE) -> None: + self.per_minute = int(per_minute) + self._limiter = RateLimiter(max_per_minute=self.per_minute) if self.per_minute > 0 else None + self._warned_at: dict[int, float] = {} + + async def admit(self, user_id: int) -> dict | None: + """‏``None`` כשהקריאה מותרת; אחרת תשובת הסירוב שהכלי מחזיר במקום לרוץ.""" + if self._limiter is None: + return None + if await self._limiter.check_rate_limit(user_id): + return None + retry_after = await self._limiter.seconds_until_allowed(user_id) + self._warn_once_per_window(user_id, retry_after) + return { + "ok": False, + "error": RATE_LIMITED, + "limit_per_minute": self.per_minute, + "retry_after_seconds": math.ceil(retry_after), + } + + def _warn_once_per_window(self, user_id: int, retry_after: float) -> None: + # סירוב אחד נרשם לכל זהות לכל חלון — סוכן שממשיך לדפוק לא הופך את הלוג לתור. + now = time.monotonic() + last = self._warned_at.get(user_id) + if last is not None and now - last < 60.0: + return + # הפנקס החזיק חותמת לכל זהות שסורבה אי פעם (SUGG-011); חותמות שחלונן + # עבר יורדות במעבר שממילא כותב חותמת חדשה — בלי מעבר נוסף על המסלול החם. + self._warned_at = {uid: at for uid, at in self._warned_at.items() if now - at < 60.0} + self._warned_at[user_id] = now + logger.warning( + "mcp tool rate limit: identity %s exceeded %d calls/min; refused for the next %.0fs", + user_id, + self.per_minute, + retry_after, + ) diff --git a/mcp_server/outline.py b/mcp_server/outline.py index e102628cb..63987cb98 100644 --- a/mcp_server/outline.py +++ b/mcp_server/outline.py @@ -19,29 +19,19 @@ from __future__ import annotations import logging -import re from collections.abc import Callable from typing import Any +from services.line_endings import find_lone_cr + from .outline_scanners import css as _css from .outline_scanners import html as _html from .outline_scanners import python as _python from .outline_scanners import rst as _rst -#: ``\r`` שאינו חלק מ-``\r\n``. ה-lookahead השלילי הוא כל ההבחנה: CRLF -#: הוא המקרה הנפוץ ושתי ספירות השורות מסכימות עליו, ולכן הוא חייב להמשיך -#: לעבוד. רק CR בודד — הפורמט של Mac שלפני 2001 — מפריד ביניהן. -#: -#: ``search`` ולא ``replace``: הוא עוצר על ההתאמה הראשונה ואינו מקצה עותק -#: של הטקסט, שיכול להיות 10MB לפי ``RANGE_READ_MAX_BYTES``. -#: -#: **עותק שני של אותו כלל קיים ב-**``services/md_parser.py::_CR_WITHOUT_LF``. -#: הוא אינו מיובא משם ולא לשם, כי ``services`` אינו מייבא מ-``mcp_server`` -#: והמסלול השני אינו עובר דרך המנתב הזה בכלל. השקילות אינה תקווה: -#: ``tests/test_md_parser.py::test_the_lone_cr_rule_matches_markdown_it`` -#: מריץ את שניהם מול ההתנהגות של ``markdown-it-py`` עצמו. איחוד השניים -#: בשכבת ``services`` הוא אישו נפרד. -_CR_WITHOUT_LF = re.compile(r"\r(?!\n)") +# כלל ה-``\r`` הבודד יושב ב-``services/line_endings.py`` (מאז #3419), משותף +# למנתב הזה ול-``services/md_parser.py`` — עותק אחד במקום שניים שטסט השווה +# ביניהם. הנימוק המלא, ו"למה ``search`` ולא ``replace``", שם. #: **השורה היחידה שהמסלול הזה כותב, והיא נכתבת כאן ולא בסורקים.** כאן יש גם #: את הנתיב וגם את גודל הקלט, ולכן זו הנקודה היחידה שבה שורה אחת מזהה מה @@ -128,7 +118,7 @@ def extract_outline(text: str, path: str, symbol: str | None = None) -> dict[str # # CRLF **אינו** מושפע ועובד במלואו: שם שתי הספירות זהות, וזה המקרה # הנפוץ. מה שנדחה הוא ``\r`` בודד בלבד. - if _CR_WITHOUT_LF.search(text): + if find_lone_cr(text): return {"status": "no_outline", "reason": "inconsistent_line_endings"} # ``chars`` ולא ``bytes``, כי ``len`` על ``str`` מודד תווים. השם הקודם diff --git a/mcp_server/outline_scanners/_ceiling.py b/mcp_server/outline_scanners/_ceiling.py index ec29818e5..94606a6a8 100644 --- a/mcp_server/outline_scanners/_ceiling.py +++ b/mcp_server/outline_scanners/_ceiling.py @@ -62,9 +62,9 @@ #: **מי שמשנה את המספר צריך למדוד מחדש את שתיהן.** העלאה בלי המדידה #: הראשונה מחזירה את מחלקת הכשל; הורדה בלי השנייה חוסמת קובץ אמיתי. #: -#: **ויש כאן עותק שני של אותו מספר:** ``services/md_parser.py::MAX_SECTIONS``. -#: הוא אינו מיובא מכאן, כי ``services`` אינו מייבא מ-``mcp_server``, ולכן -#: הערך מוקלד פעמיים. מה שמונע סחיפה הוא +#: **ויש כאן עותק שני של אותו מספר:** ``services/doc_sections.py::MAX_SECTIONS``, +#: ברירת המחדל של ``max_sections`` בשני הפארסרים. הוא אינו מיובא מכאן, כי +#: ``services`` אינו מייבא מ-``mcp_server``, ולכן הערך מוקלד פעמיים. מה שמונע סחיפה הוא #: ``tests/test_md_parser.py::test_the_two_ceilings_are_the_same_number``, #: שקורא את שניהם ומשווה — כלומר שינוי כאן לבדו מפיל את ה-CI. MAX_SYMBOLS = 50_000 diff --git a/mcp_server/outline_scanners/rst.py b/mcp_server/outline_scanners/rst.py index 1b6abec6f..38ec5b48c 100644 --- a/mcp_server/outline_scanners/rst.py +++ b/mcp_server/outline_scanners/rst.py @@ -120,6 +120,13 @@ def extract(text: str) -> dict[str, Any]: קו בלי כותרת, בלוק שלא נסגר — והוא מחזיר את מה שכן זיהה. זה נמדד על כל קובצי ה-RST בריפו ועל שתי טבלאות צורות. לכן אין כאן ``except`` רחב: באג שלנו חייב ליפול בקול ולא להתחפש ל"אין אאוטליין" על קובץ תקין. + (``TypeError`` על ``text`` שאינו מחרוזת, מאז #3421, הוא בדיוק כזה: + ``text`` כאן תמיד מחרוזת מפוענחת, ולכן זה חוזה שנשבר ולא קלט פגום.) + + **והתקרה מועברת כאן במפורש — ``_ceiling.MAX_SYMBOLS`` — ולא נשענת על + ברירת המחדל של הפארסר**, גם אחרי ש-#3420 יישר אותה לאותו מספר: המספר + כאן הוא של המפה (כותרות ותוויות יחד, ``Capped``), והסורק אומר אותו + בעצמו. טסט מקבע שהקריאה נושאת אותו. התקרה נוסעת פנימה כחריגה ויוצאת כאן כערך החזרה — הנימוק ב- ``_ceiling.TooManySymbols`` — וה-``except`` תופס **את שתי החריגות diff --git a/mcp_server/repo_backend.py b/mcp_server/repo_backend.py index 4eb348f4c..bdc237a43 100644 --- a/mcp_server/repo_backend.py +++ b/mcp_server/repo_backend.py @@ -38,11 +38,13 @@ #: ``too_large`` עם הפרמטר בדיוק כמו בלעדיו (#3317). ``webapp/app.py`` בן #: 805,594 הבייטים, הקובץ שהכי הרבה עובדים עליו, היה בלתי קריא דרך הכלי. #: -#: **למה תקרה אחרת ולא ביטול.** הבלוב עדיין נקרא ומפוענח במלואו לפני +#: **למה תקרה אחרת ולא ביטול.** בלוב שמתחת לתקרה נקרא ומפוענח במלואו לפני #: החיתוך, אז "בלי תקרה" פירושו שקובץ פתולוגי בריפו יגיע ל-RAM כמו שהוא. #: נמדד: קריאה ופענוח צורכים כפי שלושה מגודל הקובץ — 6.6MB הגיעו ל-35.7MB #: שיא. תקרה של 10MB חוסמת את זה בערך ב-30MB, ונותנת פי 12 מרווח מעל -#: הקובץ הגדול ביותר שבאמת קוראים. +#: הקובץ הגדול ביותר שבאמת קוראים. ומאז #3433 התקרה נבדקת מול גודל +#: האובייקט במאגר **לפני** ``git show``, ולכן קובץ שמעליה אינו נטען כלל — +#: החסם הוא על מה שנקרא, לא בדיקה בדיעבד. #: #: זו החלטת מדיניות של שכבת ה-MCP, לא של שירות המראה — ולכן היא כאן ולא #: שם, ואינה מייתרת את 500KB שממשיכה לחול על קריאה מלאה ועל הוובאפ. @@ -538,7 +540,15 @@ def get_file( return {"ok": False, "error": "invalid_input"} # repo_not_found / invalid_commit / git_error / timeout / internal_error: # possibly a transient race with a running sync — say so if it is. - fallback = "not_found" if err in ("repo_not_found", "invalid_commit") else "read_failed" + # + # A mirror the host does not have is its own code (#3432, SUGG-011): + # ``not_found`` invites the agent to try another file name, when what + # is missing is the whole repository — an operator's matter, not the + # caller's. ``invalid_commit`` stays ``not_found``: the repo is there, + # and the ref is what the caller can change. + if err == "repo_not_found": + return self._transient_error(repo, "repo_not_mirrored") + fallback = "not_found" if err == "invalid_commit" else "read_failed" return self._transient_error(repo, fallback) def search( diff --git a/mcp_server/server.py b/mcp_server/server.py index ee9094438..aae332970 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -35,7 +35,9 @@ from concurrent.futures import ThreadPoolExecutor from typing import Annotated, Any, NamedTuple +import pydantic_core from mcp.server.fastmcp import Context, FastMCP +from mcp.types import CallToolResult, TextContent from pydantic import Field from mcp.server.transport_security import TransportSecuritySettings from starlette.responses import JSONResponse @@ -50,6 +52,14 @@ from . import docs_handlers, handlers, repo_handlers from .handlers import StrictInt, StrictLines +from .limits import ( + BODY_TOO_LARGE, + DEFAULT_MAX_REQUEST_BYTES, + DEFAULT_RATE_LIMIT_PER_MINUTE, + RATE_LIMITED, + BodySizeLimitMiddleware, + ToolRateLimiter, +) from .analytics import attach_shutdown_drain, instrument_mcp_server from .auth import ( PATAuthMiddleware, @@ -203,6 +213,8 @@ def _build_note_color_doc() -> str: # ייבוא היא ``import-time-side-effects``, ותיאור הכלי היה משתנה בין פריסות — # כלומר שני לקוחות היו קוראים שני חוזים שונים לאותו כלי. מה שה-ENV קובע # נאמר בתיאור במילים, בלי למנות ממנו. + + def _build_docs_path_doc() -> str: parts = [] for repo, policy in docs_handlers.DOCS_PATH_POLICY.items(): @@ -224,7 +236,10 @@ def _build_docs_path_doc() -> str: "repo_not_configured. A path longer than " f"{docs_handlers.MAX_PATH_CHARS} characters is refused with " "path_too_long before anything reads it — the longest real path in " - "any served repo is under 120 characters." + "any served repo is under 120 characters. A path that resolves outside " + "the repo's docs root is refused with path_outside_root (the root is " + "in the answer), and a repo this host has no mirror of with " + "repo_not_mirrored — an operator matter, not a wrong file name." ) @@ -261,7 +276,9 @@ def _build_docs_path_doc() -> str: "sub-numbered heading is reachable by its full name only. " "An identifier that repeats in " "the file is ambiguous_section with candidates, like any duplicate " - "heading. A query that is not shaped like an identifier never takes " + "heading — at most 50 of them, in document order, with " + "candidates_truncated: true only when more were cut. A query that is " + "not shaped like an identifier never takes " "this path. (2) BACKTICKS: headings are returned as raw source, so a " "heading written with ``literal`` markup needs those backticks in the " "query too — section=\"MissingGreenlet\" finds nothing when the " @@ -508,11 +525,17 @@ def _build_query_doc() -> str: #: How long a write may wait for the queue before the wait is worth a WARNING. #: #: Picked against this service's own numbers rather than a round figure. A -#: ``save_file`` body runs 7.5s at p95, so a threshold below one whole body -#: would fire every time two writes from the same agent arrive together — the -#: ordinary case, and an alert that fires on the ordinary case is noise. Past -#: this, the caller waited for more than one complete write ahead of it, which -#: is the shape of a queue that is not draining rather than of a busy moment. +#: ``save_file`` body ran 7.5s at p95 when this was set (before the service +#: moved to Frankfurt, next to Mongo, on 2026-09-16), so a threshold below one +#: whole body would fire every time two writes from the same agent arrive +#: together — the ordinary case, and an alert that fires on the ordinary case +#: is noise. Past this, the caller waited for more than one complete write +#: ahead of it, which is the shape of a queue that is not draining rather than +#: of a busy moment. **The 7.5s figure is stale:** the Frankfurt service's logs +#: (2026-09-16 to 2026-09-21) show ``ran`` between 0.012s and 1.993s over 30 +#: writes, so today the threshold sits well above five whole bodies — still a +#: queue that is not draining, and not worth lowering until a slow write shows +#: up in those logs. _SLOW_WRITE_QUEUE_WAIT = 10.0 #: ``FastMCP.add_tool``'s own signature, read once at import so that the @@ -750,7 +773,10 @@ def _memory_limit() -> tuple[int | None, str]: #: Pricing by ``RANGE_READ_MAX_BYTES`` would have cut the public path to 4 #: threads (at 77MiB) or 9 (at 37MiB) for a shape without a source. What did #: change because of this is :data:`_READ_POOL_CAP`; the root fix — bounding -#: the read at its source with a size probe before ``git show`` — is #3433. +#: the read at its source with a size probe before ``git show`` (#3433) — has +#: landed in ``get_file_at_commit``: a blob over ``max_size`` is refused from +#: ``git cat-file -s`` without being read (12MB: 20MiB peak before, 0 after, +#: measured), so the hold above is now only what a file *under* the cap costs. _PARSE_COST_BYTES = _PARSE_RSS_PER_INPUT_BYTE * MAX_FILE_SIZE_FOR_DISPLAY @@ -803,6 +829,28 @@ def _read_pool_size(memory_limit: int | None) -> _ReadPoolSizing: return _ReadPoolSizing(int(allowed), "memory", f"sized from memory: {arithmetic}") +def _installed_width(pool: ThreadPoolExecutor) -> int | None: + """The width the executor actually has — or ``None`` when this Python no longer exposes it. + + ``ThreadPoolExecutor._max_workers`` is private to CPython, and it is read + on purpose (``state-record-without-state-change``: the capacity line + describes the pool that exists, not the one that was requested). Private + means it can go away. When it does, the answer here is ``None`` — not the + requested width, which would be exactly the echo the line exists to avoid + — and the callers print that they do not know, with a warning, instead of + failing the startup of the service over a log line (#3433, SUGG-004). + """ + return getattr(pool, "_max_workers", None) + + +def _width_label(pool: ThreadPoolExecutor, sizing: _ReadPoolSizing) -> str: + """What the capacity line prints for the read pool: the installed width, or an honest "unreadable".""" + width = _installed_width(pool) + if width is not None: + return str(width) + return f"{sizing.workers} (requested; installed width unreadable)" + + def _log_dispatch_capacity( read_pool: ThreadPoolExecutor, memory_display: str, sizing: _ReadPoolSizing ) -> None: @@ -821,7 +869,10 @@ def _log_dispatch_capacity( number the pool is sized from. Every value is computed before the call rather than inside it, so a level - guard or a deleted line takes the line and nothing else with it. + guard or a deleted line takes the line and nothing else with it. And the + private attribute is read through :func:`_installed_width`: should it + vanish, the line says "unreadable" beside the requested width and a + warning names the reason — a log line never fails the startup. """ detected = os.cpu_count() or 1 try: @@ -829,10 +880,17 @@ def _log_dispatch_capacity( except (AttributeError, OSError): usable = detected quota = _cpu_budget() + if _installed_width(read_pool) is None: + logger.warning( + "mcp read pool width is not readable on this Python " + "(ThreadPoolExecutor._max_workers is gone); the capacity line reports " + "the requested width %d instead of the installed one", + sizing.workers, + ) logger.info( - "mcp dispatch capacity: read pool %d threads (%s; os.cpu_count=%d, " + "mcp dispatch capacity: read pool %s threads (%s; os.cpu_count=%d, " "usable=%d), write pool 1 thread, cpu quota %s, memory limit %s", - read_pool._max_workers, + _width_label(read_pool, sizing), sizing.detail, detected, usable, @@ -904,9 +962,9 @@ async def _lifespan_with_read_pool(scope_app: Any): # path nobody reports is the one that stays. The capacity line says # the size; this says that it was not chosen. logger.warning( - "mcp read pool fell back to %d threads: memory limit %s — reads are " + "mcp read pool fell back to %s threads: memory limit %s — reads are " "narrower than the plan allows until the cgroup limit is readable", - pool._max_workers, + _width_label(pool, sizing), memory_display, ) async with original(scope_app) as state: @@ -1005,9 +1063,12 @@ def _log_write_timing(tool: str, waited: float, ran: float | None) -> None: configures logging at ``INFO`` (``mcp_server/app.py``, from ``LOG_LEVEL``), so a ``debug`` line is not quiet — it is *absent*, which is the failure this whole area was just fixed for. Against that, the volume cannot run away: - one worker runs one write at a time and a write body is 4.3-4.9s at p50, so - this line is bounded at roughly thirteen a minute no matter how much load - arrives. And logging only the slow case would say when the wait is bad + one worker runs one write at a time, so this line is bounded by how many + writes a minute one worker can finish — thirteen at the 4.3-4.9s p50 this + was written against (before the 2026-09-16 move to Frankfurt; the service's + logs since then show 0.012-1.993s, so the bound is higher now, and it is + still one line per write, never more). And logging only the slow case would + say when the wait is bad without ever saying what it normally is, which is the number any decision about the queue's width depends on. @@ -1198,6 +1259,34 @@ def _timed() -> Any: return _run_on_write_pool +def _refusal_result(refusal: dict[str, Any]) -> CallToolResult: + """A rate-limit refusal as the whole ``CallToolResult`` — the one shape every tool can return. + + ``FastMCP.call_tool`` normally returns what the tool's own ``convert_result`` + built for its return type, and the low-level ``tools/call`` handler then + validates structured content against the tool's ``outputSchema``: a bare + content list for a tool that declares one becomes an ``isError`` result + ("outputSchema defined but no structured output returned"). A + ``CallToolResult`` is returned by that handler as it is + (``if isinstance(results, types.CallToolResult): return + types.ServerResult(results)`` — ``mcp/server/lowlevel/server.py``, mcp + 1.28.1), so it serves a ``dict`` tool, a typed tool and an unknown name + alike. Until the seven-PR review (SUGG-009) the refusal went through the + refused tool's ``convert_result``, which turned the throttle of a tool + annotated ``-> list[str]`` into a ``pydantic.ValidationError`` exactly when + it fired, and an unknown name into the SDK's unknown-tool error. + + The text is built the way ``_convert_to_content`` builds it for a ``dict`` + (``mcp/server/fastmcp/utilities/func_metadata.py``: + ``pydantic_core.to_json(result, fallback=str, indent=2)``), so the client + reads the same block a body's own refusal produces. ``isError`` stays + ``False``: a refusal is a regular protocol answer, not a server incident — + the rule ``docs/mcp-server.rst`` states for every refusal. + """ + text = pydantic_core.to_json(refusal, fallback=str, indent=2).decode() + return CallToolResult(content=[TextContent(type="text", text=text)], isError=False) + + class AdminAwareFastMCP(FastMCP): """FastMCP that hides the admin-only tools from non-admin tools/list, and keeps every tool body off the event loop. @@ -1207,6 +1296,20 @@ class AdminAwareFastMCP(FastMCP): doubt (no request context, unauthenticated, lookup error) ⇒ non-admin view. """ + def __init__( + self, *args: Any, tool_rate_limiter: ToolRateLimiter | None = None, **kwargs: Any + ) -> None: + # Set before ``super().__init__`` so the attribute exists whatever the + # SDK's constructor does; ``call_tool`` below reads it on every call. + # ``is None`` and not ``or``: ``ToolRateLimiter(0)`` is the documented + # kill switch, and ``or`` kept it only because the class defines neither + # ``__bool__`` nor ``__len__`` — the first one added would have swapped a + # switched-off limiter for the 60/min default in silence (K12 §3). + if tool_rate_limiter is None: + tool_rate_limiter = ToolRateLimiter(DEFAULT_RATE_LIMIT_PER_MINUTE) + self._tool_rate_limiter = tool_rate_limiter + super().__init__(*args, **kwargs) + def add_tool(self, fn: Any, *args: Any, **kwargs: Any) -> Any: # type: ignore[override] """Register a tool, moving a sync body onto a worker thread first. @@ -1226,6 +1329,57 @@ def add_tool(self, fn: Any, *args: Any, **kwargs: Any) -> Any: # type: ignore[o serialize = _declares_write(_annotations_of(args, kwargs)) return super().add_tool(_offload_to_thread(fn, serialize=serialize), *args, **kwargs) + async def call_tool(self, name: str, arguments: dict[str, Any]) -> Any: # type: ignore[override] + """Decide the per-identity rate limit here, and nowhere else (#3431). + + The SDK registers this method as the low-level ``tools/call`` handler + (``FastMCP._setup_handlers`` — ``self._mcp_server.call_tool(...)(self.call_tool)``, + mcp 1.28.1), so every tool call passes through it: sync bodies before + they are handed to a worker, async bodies before they run on the loop. + A refused call therefore costs no thread. And the registered functions + stay exactly what ``add_tool`` built — the routing tests read + ``fn.__code__.co_name`` off them, which an outer wrapper would hide. + + The refusal is a whole ``CallToolResult`` (:func:`_refusal_result`), so + the client sees the same text block a body's own refusal has + (``{"ok": false, "error": ...}``), whatever the tool's return type is + and whether or not the name exists — the budget is charged before the + name is looked up, so an identity over budget cannot probe tool names + for free. + """ + user_id = self._caller_identity() + if user_id is not None: + refusal = await self._tool_rate_limiter.admit(user_id) + if refusal is not None: + return _refusal_result(refusal) + return await super().call_tool(name, arguments) + + def _caller_identity(self) -> int | None: + """Whose budget a call is charged to — or ``None`` when there is nobody to charge. + + Outside a request ``Server.request_context`` raises ``LookupError`` + ("If called outside of a request context, this will raise a + LookupError" — ``mcp/server/lowlevel/server.py``): no client, nothing + to count, and the tool bodies keep answering as they do in the tests + that call them directly. Inside a request the identity is + ``current_user_id`` on the SDK's context — the same function every gate + uses, in both auth modes. Its documented failure, ``PermissionError`` + (no identity on the request), is unreachable for a ``tools/call`` in + production, because both auth modes reject an unauthenticated request + upstream: ``RequireAuthMiddleware`` on the ``/mcp`` mount in OAuth + mode, ``PATAuthMiddleware`` in PAT mode. So answering ``None`` there is + not a way past the limiter; the test that reaches it does so by + monkeypatching ``current_user_id``. + """ + try: + self._mcp_server.request_context + except LookupError: + return None + try: + return current_user_id(self.get_context()) + except PermissionError: + return None + async def list_tools(self): # type: ignore[override] tools = await super().list_tools() if self._request_is_admin(): @@ -1270,6 +1424,7 @@ def build_mcp( auth_provider: Any = None, auth_settings: Any = None, repo_backend: Any = None, + rate_limit_per_minute: int = DEFAULT_RATE_LIMIT_PER_MINUTE, ) -> FastMCP: kwargs: dict[str, Any] = { "instructions": _INSTRUCTIONS, @@ -1281,7 +1436,9 @@ def build_mcp( # register) plus the auth layer that calls provider.load_access_token. kwargs["auth_server_provider"] = auth_provider kwargs["auth"] = auth_settings - mcp: FastMCP = AdminAwareFastMCP(name, **kwargs) + mcp: FastMCP = AdminAwareFastMCP( + name, tool_rate_limiter=ToolRateLimiter(rate_limit_per_minute), **kwargs + ) # PostHog MCP analytics. Additive: no tool is changed and no tool schema is # touched. Must run before ``streamable_http_app()`` below, which the same # call also wraps. See ``mcp_server/analytics.py`` for the privacy gate. @@ -2129,6 +2286,8 @@ def build_app( consent_routes: Any = None, repo_backend: Any = None, name: str = "CodeKeeper", + max_request_bytes: int = DEFAULT_MAX_REQUEST_BYTES, + rate_limit_per_minute: int = DEFAULT_RATE_LIMIT_PER_MINUTE, ): """Build the authenticated Streamable-HTTP ASGI app. @@ -2145,6 +2304,7 @@ def build_app( auth_provider=auth_provider if oauth else None, auth_settings=auth_settings if oauth else None, repo_backend=repo_backend, + rate_limit_per_minute=rate_limit_per_minute, ) app = mcp.streamable_http_app() # Starlette app exposing POST/GET /mcp # Drain analytics on ASGI shutdown, before uvicorn's event loop closes. @@ -2174,9 +2334,32 @@ def build_app( auth_provider=auth_provider if oauth else None, ) ) + # Request-body cap for every route, in both auth modes (#3431). Added + # before ``PATAuthMiddleware`` on purpose: ``add_middleware`` inserts at the + # front of the stack (``starlette/applications.py``, Starlette 1.6.0), so the + # middleware added last is the outermost — the PAT check stays outside, and an + # unauthenticated oversized POST is a 401 before it is a 413. In OAuth mode + # the SDK's auth wraps only the ``/mcp`` mount, so here the cap is the + # outermost app-level layer: it refuses a declared 20MB body before any + # credential is looked at, and hands a declared body under the cap on + # *unread*, so the SDK's 401 still comes without a byte of it read + # (SEC-001 in the seven-PR review; the drain that remains, for a body with + # no declared length, is bounded by the cap and by a deadline — + # ``mcp_server/limits.py``). ``/healthz`` is a GET, and GET is not a + # method the cap looks at, so a spoofed ``Content-Length`` there is not a + # 413; both modes pin that in ``tests/test_mcp_limits.py``. + app.add_middleware(BodySizeLimitMiddleware, max_bytes=max_request_bytes) if oauth: for route in consent_routes or []: app.router.routes.append(route) else: app.add_middleware(PATAuthMiddleware, token_store=token_store) + logger.info( + "mcp request limits: body <= %d bytes (413 %s), tool calls <= %s per identity " + "per minute (%s); /healthz sits outside both", + max_request_bytes, + BODY_TOO_LARGE, + rate_limit_per_minute if rate_limit_per_minute > 0 else "unlimited", + RATE_LIMITED, + ) return app diff --git a/rate_limiter.py b/rate_limiter.py index d8abe5271..bf40afcaf 100644 --- a/rate_limiter.py +++ b/rate_limiter.py @@ -1,5 +1,4 @@ import asyncio -from collections import defaultdict from datetime import datetime, timedelta, timezone from typing import Dict, List @@ -13,28 +12,50 @@ class RateLimiter: def __init__(self, max_per_minute: int = 30) -> None: self.max_per_minute = max(1, int(max_per_minute or 30)) - self._requests: Dict[int, List[datetime]] = defaultdict(list) + # מילון רגיל ולא ``defaultdict``: רשומה נוצרת רק כשקריאה אושרה + # (``check_rate_limit``), ולא לכל זהות ששאלו עליה — ראו ``_live_entries``. + self._requests: Dict[int, List[datetime]] = {} self._lock = asyncio.Lock() + def _live_entries(self, user_id: int, now: datetime) -> List[datetime]: + """הרשומות של המשתמש שעדיין בחלון, אחרי ניקוי הישנות — נקרא תחת המנעול. + + ניקוי אחד לשלוש השאלות (מותר? כמה בשימוש? כמה לחכות?), במקום שלושה + עותקים של אותה לולאה שצריכים להסכים זה עם זה לנצח. הרשומות נשמרות + בסדר הזמן, ולכן הראשונה שעדיין בחלון היא הגבול, ומה שלפניה נמחק + (תיקון off-by-one כאשר כל הערכים פגי-תוקף: אז נמחק הכול). + + **ואינו יוצר רשומה למי ששואלים עליו בלבד.** עם ``defaultdict`` הקריאה + ``self._requests[user_id]`` יצרה מפתח קבוע לכל זהות ש-``get_current_usage_ratio`` + או ``seconds_until_allowed`` נשאלו עליה (סקירת שבעת ה-PRים, SUGG-011); + עכשיו זהות בלי רשומה מקבלת רשימה ריקה שאינה נשמרת, ומפתח שהחלון שלו + התרוקן נמחק. מי ששומר הוא ``check_rate_limit``, וברגע האישור בלבד. + """ + entries = self._requests.get(user_id) + if entries is None: + return [] + one_min_ago = now - timedelta(seconds=60) + delete_upto = len(entries) + for idx, ts in enumerate(entries): + if ts > one_min_ago: + delete_upto = idx + break + if delete_upto > 0: + del entries[:delete_upto] + if not entries: + del self._requests[user_id] + return entries + async def check_rate_limit(self, user_id: int) -> bool: """מחזיר True אם מותר להמשיך, אחרת False.""" now = datetime.now(timezone.utc) - one_min_ago = now - timedelta(seconds=60) async with self._lock: - entries = self._requests[user_id] - # נקה בקשות ישנות מהחלון (תיקון off-by-one כאשר כל הערכים פגי-תוקף) - delete_upto = len(entries) - for idx, ts in enumerate(entries): - if ts > one_min_ago: - delete_upto = idx - break - if delete_upto > 0: - del entries[:delete_upto] - + entries = self._live_entries(user_id, now) if len(entries) >= self.max_per_minute: return False - entries.append(now) + # הרשימה עשויה להיות חדשה, או כזו ש-``_live_entries`` ניתק כשהתרוקנה. + self._requests[user_id] = entries return True async def get_current_usage_ratio(self, user_id: int) -> float: @@ -43,18 +64,21 @@ async def get_current_usage_ratio(self, user_id: int) -> float: מנקה ערכים ישנים לפני החישוב כדי לשקף את החלון המתגלגל של 60 שניות. """ now = datetime.now(timezone.utc) - one_min_ago = now - timedelta(seconds=60) async with self._lock: - entries = self._requests.get(user_id, []) - if entries: - delete_upto = len(entries) - for idx, ts in enumerate(entries): - if ts > one_min_ago: - delete_upto = idx - break - if delete_upto > 0: - del entries[:delete_upto] - used = len(self._requests.get(user_id, [])) + used = len(self._live_entries(user_id, now)) limit = max(1, int(self.max_per_minute)) return min(1.0, float(used) / float(limit)) + async def seconds_until_allowed(self, user_id: int) -> float: + """כמה שניות עד שהחלון משחרר מקום — ‏0.0 כשיש מקום כבר עכשיו. + + הרשומה הישנה ביותר בחלון היא שתפוג ראשונה, ואז ``check_rate_limit`` + יאשר שוב; לכן התשובה היא הזמן שנותר לה. זו התשובה שסירוב יכול לשאת + החוצה כ-``retry_after`` במקום "נסה שוב" סתמי. + """ + now = datetime.now(timezone.utc) + async with self._lock: + entries = self._live_entries(user_id, now) + if len(entries) < self.max_per_minute: + return 0.0 + return max(0.0, (entries[0] + timedelta(seconds=60) - now).total_seconds()) diff --git a/requirements/base.txt b/requirements/base.txt index ee826d15b..45dc2196f 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -164,10 +164,11 @@ rich==14.2.0 markdown-it-py==3.0.0 # התוסף שמזהה front matter. הוא ההגדרה ש-MyST משתמש בה, ולכן הוא הכלל # שקובע מהו front matter גם בקובצי ה-.md שתחת docs/ בריפו הזה. הוא נרשם -# על מופע הפארסר במקום שנכתוב את הכלל ביד: נמדד שכלל שכתבנו בעצמנו חולק -# על התוסף בחמש מתוך שתים-עשרה צורות — `---` מוזח, סוגר מוזח בארבעה -# רווחים, ארבעה מקפים, סוגר ארוך מהפותח, ופותח שיש אחריו טקסט. הטבלה -# עצמה ב-`tests/test_md_parser.py`, והמספר נגזר ממנה ולא מוקלד פעמיים. +# על מופע הפארסר במקום שנכתוב את הכלל ביד: נמדד שכלל שנכתב ביד +# (`scripts/generate_ai_map.py`, עד #3419) חלק על התוסף בחמש מתוך +# שתים-עשרה צורות — `---` מוזח, סוגר מוזח בארבעה רווחים, ארבעה מקפים, סוגר +# ארוך מהפותח, ופותח שיש אחריו טקסט. מאז #3419 גם הסקריפט קורא את הגבול +# מהפארסר, והטבלה ב-`tests/test_md_parser.py` מקבעת שהשניים מסכימים. mdit-py-plugins==0.4.2 typer==0.20.0 diff --git a/scripts/generate_ai_map.py b/scripts/generate_ai_map.py index b06b917d2..bcaba6e83 100644 --- a/scripts/generate_ai_map.py +++ b/scripts/generate_ai_map.py @@ -35,6 +35,11 @@ import yaml ROOT = Path(__file__).resolve().parent.parent +# הסקריפט רץ גם לבדו (``python3 scripts/generate_ai_map.py``), ואז ``sys.path[0]`` +# הוא ``scripts/`` ולא שורש הריפו; גבול ה-front matter נקרא מ-``services``. +sys.path.insert(0, str(ROOT)) + +from services import md_parser # noqa: E402 DOCS = ROOT / "docs" OUTPUT = ROOT / "AI-MAP.md" SUMMARY_CAP = 220 @@ -126,33 +131,24 @@ def _toctree_blocks(lines: list[str]): def _body_start(lines: list[str]) -> int: - """האינדקס שאחרי ה-front matter, או 0 אם אין. - - לפי ``markdown_it``/MyST, front matter הוא בלוק שנפתח ב-``---`` בשורה - הראשונה ממש ונסגר ב-``---`` או ``...``. - - .. warning:: - - **הכלל כאן אינו מדויק מול התוסף שהוא מתאר, וזה נמדד.** - ``mdit_py_plugins.front_matter`` — שהוא מה ש-MyST באמת מריץ, וש- - ``services/md_parser.py`` רושם על מופע הפארסר שלו במקום לכתוב את - הכלל ביד — חולק על הפונקציה הזאת בחמש צורות: ``---`` מוזח (כאן - מתקבל, שם נדחה), ``----`` בן ארבעה מקפים, סוגר ארוך מהפותח, פותח - שיש אחריו טקסט, וסוגר מוזח בארבעה רווחים שהוא בלוק קוד. בקורפוס - של היום אין לאף אחת מהן מופע — 23 מתוך 34 קובצי ה-``.md`` תחת - ``docs/`` פותחים ב-``---`` מדויק — ולכן המפה שנוצרת כאן נכונה. - - הפער מקובע ב- - ``tests/test_md_parser.py::test_the_front_matter_rules_still_disagree_as_measured`` - כדי שלא ייסחף בשקט, והיעד — שהפונקציה הזאת תשתמש בתוסף — הוא - אישו נפרד. + """האינדקס שאחרי ה-front matter, או 0 אם אין — לפי התוסף, לא לפי כלל שנכתב ביד. + + ``services.md_parser.front_matter_end`` שואל את ``mdit_py_plugins.front_matter`` + שרשום על הפארסר של הכלי — ההגדרה ש-MyST באמת מריץ על קובצי ה-``.md`` + שתחת ``docs/``. עד #3419 הפונקציה הזאת כתבה את הכלל ביד, ונמדד שהיא + חלקה על התוסף בחמש מתוך שתים-עשרה צורות (``---`` מוזח, סוגר מוזח + בארבעה רווחים, ארבעה מקפים, סוגר ארוך מהפותח, פותח שיש אחריו טקסט). + בקורפוס לא היה מופע לאף אחת מהן, ולכן המפה שנוצרה הייתה נכונה — וזה + מה שאפס-הדיף על ``AI-MAP.md`` מקבע גם אחרי המעבר. + + ``"\\n".join`` מחזיר את הטקסט ש-``_read`` פיצל ב-``split("\\n")``, ולכן + האינדקסים של הפרסר הם האינדקסים של ``lines`` — בתנאי שהקלט עבר את שער + הכניסה של הפרסר, ומאז סקירת שבעת ה-PRים (SUGG-019) הוא עובר: קובץ עם + ``\\r`` בודד, ש-markdown-it היה סופר כמעבר שורה ו-``split("\\n")`` לא, + מרים כאן ``InconsistentLineEndings`` עם מספר השורה — כמו בכלי — במקום + להזיז את הגבול בשקט, ו-BOM בתחילת הקובץ מוסר במקום להסתיר את הבלוק. """ - if not lines or lines[0].strip() != "---": - return 0 - for i in range(1, len(lines)): - if lines[i].strip() in ("---", "..."): - return i + 1 - return 0 # לא נסגר — MyST גם לא היה מזהה אותו כ-front matter + return md_parser.front_matter_end("\n".join(lines)) def _title(lines: list[str], path: Path) -> str: @@ -160,7 +156,9 @@ def _title(lines: list[str], path: Path) -> str: # מדלגים על front matter לפני חיפוש הכותרת: ה-``---`` הסוגר שלו # הוא קו-תחתון חוקי לפי ``_UNDERLINE_RE``, ולכן שורת ``summary:`` # שמעליו נקראה ככותרת setext והכותרת של העמוד יצאה "summary: ...". - for idx, ln in enumerate(lines[_body_start(lines):], start=_body_start(lines)): + # פעם אחת: כל קריאה היא פרסור מלא של העמוד. + start = _body_start(lines) + for idx, ln in enumerate(lines[start:], start=start): if ln.startswith("# "): return ln[2:].strip() if ln.strip() and idx + 1 < len(lines) and _UNDERLINE_RE.match(lines[idx + 1]): diff --git a/scripts/measure_md_parse_cost.py b/scripts/measure_md_parse_cost.py index f064db5d2..713b9e343 100644 --- a/scripts/measure_md_parse_cost.py +++ b/scripts/measure_md_parse_cost.py @@ -25,7 +25,13 @@ לתקרת הקריאה ``MAX_FILE_SIZE_FOR_DISPLAY``); המסמך הצפוף ביותר בו משוכפל עד התקרה — זה המועמד לקבוע, כי זה הגרוע ביותר שהכלי באמת מגיש; וצורה עוינת (Markdown: שורות-תבליט בודדות, RST: כותרת בת תו אחד בכל שורה) שהיא הגבול - העליון של הפרסר ושהקבוע במפורש אינו מכסה. ל-RST נמדד גם מסלול ה-outline + העליון של הפרסר ושהקבוע במפורש אינו מכסה. הקורפוס והמסמך הצפוף רצים על + ברירת המחדל של ``max_sections`` — ``MAX_SECTIONS`` בשני הפרסרים מאז #3420 — + כלומר בדיוק כמו הכלי. **הצורה העוינת רצה בלי תקרה, ``max_sections=None`` + במפורש:** היא מודדת את הגבול העליון של הפרסר ולא את מה שהכלי מגיש, ועם + ברירת המחדל היא נעצרת על התקרה ומדווחת עלות של עצירה במקום של הפרסר — + נמדד ב-RST 41.0 בתים לבית עם התקרה מול 90.1 בלעדיה (סקירת שבעת ה-PRים, + WARN-004). ל-RST נמדד גם מסלול ה-outline של ``codekeeper_get_repo_file``: המסמך הצפוף ביותר משוכפל עד ``RANGE_READ_MAX_BYTES`` ומפורסר עם ``max_sections`` של ``MAX_SYMBOLS``, כי זו ההחזקה הגדולה ביותר שחוט קריאה נושא היום, והיא גדולה מעלות הפרסור @@ -160,7 +166,7 @@ def density(text: str, parser: str = "md") -> float: if parser == "md": from services import md_parser - return len(md_parser._build_parser().parse(text)) * 1024 / size + return md_parser.token_count(text) * 1024 / size from services import rst_parser return len(rst_parser.parse_document(text).sections) * 1024 / size @@ -182,7 +188,9 @@ def real_files(suffix: str) -> list[pathlib.Path]: def rank(files: list[pathlib.Path], parser: str) -> list[tuple[float, pathlib.Path, str]]: - """הקבצים לפי צפיפות, מהצפוף ביותר; קובץ שאינו UTF-8 מדולג בקול ב-stderr.""" + """הקבצים לפי צפיפות, מהצפוף ביותר; קובץ שאינו UTF-8, או עם ``\\r`` בודד, מדולג בקול ב-stderr.""" + from services.doc_sections import InconsistentLineEndings + ranked = [] for p in files: try: @@ -192,7 +200,13 @@ def rank(files: list[pathlib.Path], parser: str) -> list[tuple[float, pathlib.Pa # ביותר יכול לצאת מהדירוג בלי שאיש יידע. print(json.dumps({"skipped": str(p), "reason": f"not utf-8: {exc.reason}"}), file=sys.stderr) continue - ranked.append((density(text, parser), p, text)) + try: + ranked.append((density(text, parser), p, text)) + except InconsistentLineEndings as exc: + # אותו כלל: הפרסר של הכלי מסרב לקובץ עם ``\r`` בודד (מאז SUGG-019 + # גם ``token_count`` עובר בשער הכניסה שלו), והכלי לא היה מגיש אותו. + print(json.dumps({"skipped": str(p), "reason": f"lone CR at line {exc.args[0]}"}), file=sys.stderr) + continue if not ranked: suffix = PARSERS[parser]["suffix"] raise SystemExit(f"no {suffix} file of at least {MIN_DOC_BYTES} bytes under {REPO} — nothing to measure") @@ -241,14 +255,20 @@ def measure(parser: str, work: pathlib.Path) -> list[dict]: "density_per_kb": round(top_density, 1), **peak_cost(tiled, work, parser=parser), }) - # הצורה העוינת — הגבול העליון של הפרסר, שהקבוע במפורש אינו מכסה. + # הצורה העוינת — הגבול העליון של הפרסר, שהקבוע במפורש אינו מכסה. בלי + # תקרה, במפורש: עם ברירת המחדל הפרסר נעצר על ``MAX_SECTIONS`` והמספר + # מתאר עצירה, לא את הפרסר (ראו את ה-docstring של המודול). unit = spec["hostile_unit"] hostile = work / ("hostile" + spec["suffix"]) hostile.write_text( clip_to_bytes(unit * (MAX_FILE_SIZE_FOR_DISPLAY // len(unit) + 1), MAX_FILE_SIZE_FOR_DISPLAY), encoding="utf-8", ) - results.append({"parser": parser, "shape": "hostile", **peak_cost(hostile, work, parser=parser)}) + results.append({ + "parser": parser, + "shape": "hostile", + **peak_cost(hostile, work, parser=parser, kwargs={"max_sections": None}), + }) if parser == "rst": # מסלול ה-outline של codekeeper_get_repo_file: קריאה עד RANGE_READ_MAX_BYTES, # פרסור עם תקרת סקשנים — ההחזקה הגדולה ביותר של חוט קריאה היום. @@ -286,7 +306,8 @@ def main() -> int: } | { "note": ( "the constant is the densest document the tool actually serves; " - "the hostile bound is not a pool problem" + "the hostile bound is the parser's own ceiling, measured with max_sections=None, " + "and is not a pool problem" ), })) return 0 diff --git a/services/config_inspector_service.py b/services/config_inspector_service.py index c0507cb90..c8b0cf516 100644 --- a/services/config_inspector_service.py +++ b/services/config_inspector_service.py @@ -1913,6 +1913,26 @@ class ConfigService: description="מרווח בשניות בין מעברי ה-autosync של דפדפן הריפו ב-MCP (מינימום 30).", category="mcp", ), + "MCP_MAX_REQUEST_BYTES": ConfigDefinition( + key="MCP_MAX_REQUEST_BYTES", + services=("mcp",), + default="1048576", + description=( + "תקרת גודל (בתים) לגוף בקשה לשרת ה-MCP, לכל הכלים; גוף גדול יותר נדחה ב-413 " + "body_too_large לפני שמפוענח. מינימום 65536; ערך פגום — ברירת המחדל." + ), + category="mcp", + ), + "MCP_RATE_LIMIT_PER_MINUTE": ConfigDefinition( + key="MCP_RATE_LIMIT_PER_MINUTE", + services=("mcp",), + default="60", + description=( + "קריאות כלים לזהות אחת בדקה בשרת ה-MCP; קריאה מעבר לזה מחזירה rate_limited עם " + "retry_after_seconds. 0 מכבה במפורש (נרשם WARNING בעלייה); /healthz אינו נספר." + ), + category="mcp", + ), "POSTHOG_PROJECT_TOKEN": ConfigDefinition( key="POSTHOG_PROJECT_TOKEN", services=("mcp",), diff --git a/services/doc_sections.py b/services/doc_sections.py index e8bc87f79..43edc8244 100644 --- a/services/doc_sections.py +++ b/services/doc_sections.py @@ -29,29 +29,35 @@ ``services/rst_parser.py`` ו-``services/md_parser.py``. שניהם מייצאים מחדש את השמות שכאן, ולכן ``rst_parser.Section`` ו-``md_parser.Section`` הם **אותו אובייקט מחלקה** — וזה מה שמאפשר לצרכן לבחור מודול לפי סיומת -ולקרוא לאותם שמות. **בדיוק אותם שמות — למעט שניים:** ``md_parser`` -מייצא גם ``MAX_SECTIONS`` ו-``InconsistentLineEndings``, ש-``rst_parser`` -אינו מרים ואינו מגדיר. חריגות הסירוב נתפסות לכן דרך המודול הזה, לא דרך -הפארסר — ראו את ה-docstring של :class:`InconsistentLineEndings`. - -**ומה שהם כן עושים אחרת, כי זה נראה כמו סתירה למי שלא יודע:** ברירת -המחדל של ``max_sections``. ב-``rst_parser`` היא ``None`` וב-``md_parser`` -היא התקרה, וההבדל מנומק בשני ה-docstrings. +ולקרוא לאותם שמות. **בדיוק אותם שמות — למעט אחד:** ``md_parser`` מייצא +גם ``InconsistentLineEndings``, ש-``rst_parser`` אינו מרים ואינו מגדיר. +חריגות הסירוב נתפסות לכן דרך המודול הזה, לא דרך הפארסר — ראו את +ה-docstring של :class:`InconsistentLineEndings`. + +**ואותה ברירת מחדל ל-``max_sections`` בשניהם — :data:`MAX_SECTIONS`, שמוגדר +כאן.** עד #3420 היא הייתה ``None`` ב-``rst_parser`` — מה ששמר על +``docs_get_section`` זהה בית-בית כשהפרמטר נוסף לפארסר שכבר רץ בייצור — ו- +``MAX_SECTIONS`` ב-``md_parser``, כלומר קורא ששכח להעביר תקרה קיבל הגנה +מהפארסר האחד ולא מהשני. היישור נעשה אחרי מדידה: הקובץ העשיר ביותר ב-``docs/`` +נושא 50 סקשנים מול תקרה של 50,000, ופלט ``docs_get_section`` על כל 208 קובצי +ה-RST זהה לפני ואחרי. ההיגיון של ברירת המחדל כתוב ליד הקבוע. **ומי הבעלים של שני ערוצי הסירוב: המתקשר.** ``mcp_server/docs_handlers.py`` -בוחר פארסר לפי סיומת, מעביר ל-``rst_parser.parse_document`` את תקרת הסקשנים -של הסורק (מאז #3429) ומשאיר את ``md_parser`` על ברירת המחדל שלו, ו**תופס את -שתי החריגות** — ``TooManySections`` ו-``InconsistentLineEndings`` — מהמודול -הזה ולא מהפארסר, כך שהתפיסה אינה מותנית במי שפרסר. שתיהן הופכות לתשובת -``error`` של MCP עם מספר השורה שהן נושאות. ההמרה הזאת היא עבודה של המתקשר -— לא של המודול הזה, שאינו יודע דבר על MCP. - -**ובדיקת הכניסה עצמה עדיין אינה זהה בין השניים.** נמדד: -``rst_parser.parse_document(None)`` מחזיר ``Document`` ריק **בשקט**, -ו-``rst_parser.parse_document(17)`` זורק ``AttributeError`` גולמי; שניהם -ב-``md_parser`` הם ``TypeError`` שאומר מה התקבל. הפארסר החדש הוא זה -שמתנהג נכון, ולכן אין כאן מה להחליש — אבל יישור של הוותיק הוא שינוי -התנהגות על מסלול חי, ולכן הוא **מתועד באישו #3421** ואינו נעשה כאן. +בוחר פארסר לפי סיומת, משאיר את שניהם על ברירת המחדל שלהם (בין #3429 ל-#3420 +הוא העביר ל-``rst_parser`` את תקרת הסורק במפורש, כי ברירת המחדל שם הייתה +``None``), ו**תופס את שתי החריגות** — ``TooManySections`` ו- +``InconsistentLineEndings`` — מהמודול הזה ולא מהפארסר, כך שהתפיסה אינה +מותנית במי שפרסר. שתיהן הופכות לתשובת ``error`` של MCP עם מספר השורה שהן +נושאות. ההמרה הזאת היא עבודה של המתקשר — לא של המודול הזה, שאינו יודע דבר +על MCP. + +**ובדיקת הכניסה זהה בשניהם — :func:`require_str`, שמוגדרת כאן.** עד #3421 +``rst_parser.parse_document(None)`` החזיר ``Document`` ריק **בשקט** — מפה +ריקה שמתחזה למפה של קובץ בלי כותרות — ו-``rst_parser.parse_document(17)`` +זרק ``AttributeError`` גולמי, בעוד ``md_parser`` זרק ``TypeError`` שאומר +מה התקבל. עכשיו שניהם עוברים באותה בדיקה ומרימים את אותה חריגה עם אותן +מילים; אף קורא לא נשען על הצורה הישנה (נבדק: המטפל, הסורק והסקריפטים +מעבירים תמיד מחרוזת). **ייבוא קל בלבד** — ``re``, ``dataclasses``, ``difflib``, ``typing``. הכלל מנומק ב-``mcp_server/__init__.py``: ``import database.schemas`` מריץ @@ -68,6 +74,57 @@ from difflib import get_close_matches from typing import List, NamedTuple, Optional, Tuple +#: מקסימום יחידות עבודה לפרסור אחד, ומעליו הפרסור נעצר ב-:class:`TooManySections`. +#: זו **ברירת המחדל** של ``max_sections`` בשני הפארסרים (מאז #3420), ו-``None`` +#: מכבה אותה במפורש. +#: +#: **מה נספר תלוי בפארסר, וזה ההבדל שצריך להכיר.** ב-``rst_parser`` נספרים +#: סקשנים; ב-``md_parser`` נספרת כל **כותרת** שהפרסור נתקל בה, כולל כותרת +#: בתוך ציטוט או בתוך פריט רשימה שאינה נכנסת למפה בכלל. הקבוע מגביל +#: **עבודה** ולא **תוצאה**: מה שמייקר הוא הכותרת עצמה, ולא השאלה אם היא +#: נספרת כסעיף בסוף. נמדד: 500KB של ``> ## h`` הם ~64,000 כותרות שאף +#: אחת מהן אינה סעיף, וספירה לפי ``level == 0`` הייתה רואה בהן אפס ולא +#: עוצרת לעולם. הטענה "לא יותר מ-``MAX_SECTIONS`` סעיפים" נשארת נכונה — היא +#: פשוט לא כל מה שהקבוע אוכף. שם מדויק יותר לקבוע הוא שינוי בפני עצמו +#: והוא אישו נפרד. +#: +#: **ולמה ברירת מחדל ולא ``None``:** קורא ששכח להעביר תקרה מקבל הגנה ולא +#: את היעדרה — אותו כלל fail-closed שכבר מנומק ב-``_declares_write`` +#: וב-``_ceiling.py``. המחיר שלה על קלט אמיתי הוא אפס: הקובץ העשיר ביותר +#: ב-``docs/`` נושא 50 סקשנים, אחד לאלף מהתקרה (נמדד על כל 208 קובצי +#: ה-RST, 2026-09-20), ופלט ``docs_get_section`` על כולם זהה בית-בית +#: לפני היישור ואחריו. +#: +#: **אותו ערך בדיוק כמו ``MAX_SYMBOLS`` ב- +#: ``mcp_server/outline_scanners/_ceiling.py``**, ושם כתובות שתי +#: המדידות שקבעו אותו — השיא שהתקרה מרשה, והיחס לקובץ אמיתי. מי +#: שמשנה את אחד מהשניים צריך לפתוח את השני: הם מתארים את אותו גבול על +#: אותה מפה, וסורק האאוטליין מעביר את זה במקום את זה. השוויון +#: מקובע ב-``tests/test_md_parser.py::test_the_two_ceilings_are_the_same_number``. +#: +#: ``_ceiling.MAX_SYMBOLS`` אינו מיובא לכאן, כי ``services`` אינו מייבא +#: מ-``mcp_server`` — הכיוון חד-סטרי ומנומק ב-``TooManySections``. +MAX_SECTIONS = 50_000 + + +def require_str(text: object) -> str: + """בדיקת הכניסה של שני הפארסרים: ``text`` חייב להיות מחרוזת. + + הטקסט מגיע מחוץ לתהליך (קובץ מהמראה, גוף בקשה), ו-``.replace`` על ערך + שאינו מחרוזת היה מפיל ``AttributeError`` ממקום שלא מסביר כלום — או, גרוע + מזה, ``None`` שהופך ל-``""`` ומחזיר מפה ריקה שמתחזה למפה של קובץ בלי + כותרות (זו הצורה שהייתה ב-``rst_parser`` עד #3421). החריגה אומרת מה + התקבל, כי זה מה שמבדיל בין "הקלט פסול" ל"הקוד הקורא שבור". + + **פונקציה אחת לשני הפארסרים ולא שתי שורות זהות בכל אחד**, כדי שהמילים + יישארו אותן מילים: ``TypeError`` היא חוזה שהמטפל מצהיר עליו ("חוזה + נשבר" ולא "הקלט נדחה", ולכן אינה נתפסת ב-``docs_get_section``), ושני + עותקים של אותה בדיקה הם מה שנסחף בתיקון הבא. + """ + if not isinstance(text, str): + raise TypeError(f"parse_document expects str, got {type(text).__name__}") + return text + @dataclass class Section: @@ -126,11 +183,12 @@ class TooManySections(Exception): סקשנים, וב-``md_parser`` נספרות **כותרות** — כולל כותרת בתוך ציטוט או פריט רשימה, שאינה נכנסת למפה בכלל. בשני המקרים התקרה מגבילה את העבודה שהפרסור עושה, ובמסלול ה-Markdown כותרת בתוך מכל עולה בדיוק - כמו כל אחרת — ההנמקה המלאה ב-``services/md_parser.py::MAX_SECTIONS``. + כמו כל אחרת — ההנמקה המלאה ליד :data:`MAX_SECTIONS` למעלה. המחלקה אחת כי מה שהקורא צריך לדעת זהה: הקלט גדול מדי, ואין מפה. **חריגה, ולא ערך החזרה ולא ``Document`` חלקי עם דגל "נקטע".** המתקשר - היחיד שמעביר תקרה הוא סורק האאוטליין, ושם הכלל כבר נקבע ומנומק + היחיד שמעביר תקרה במפורש הוא סורק האאוטליין (השאר על ברירת המחדל), + ושם הכלל כבר נקבע ומנומק ב-``mcp_server/outline_scanners/_ceiling.py``: מפה חלקית שמתחזה למלאה היא בדיוק הכשל שהתקרה קיימת כדי למנוע. דגל דורש בדיקה בכל אתר קריאה, ובדיקה אחת שנשכחת מחזירה תוכן עניינים שחסרות בו כותרות בלי diff --git a/services/git_mirror_service.py b/services/git_mirror_service.py index 12654aa1b..99856ee65 100644 --- a/services/git_mirror_service.py +++ b/services/git_mirror_service.py @@ -1573,6 +1573,41 @@ def get_file_history( self.logger.error(f"Error getting file history: {e}") return {"error": "internal_error", "message": "שגיאה פנימית"} + def _object_size( + self, mirror_path: Path, resolved_commit: str, safe_file_path: str, timeout: float = 10 + ) -> Dict[str, Any]: + """גודל האובייקט ``:`` מהמאגר, בלי לקרוא את תוכנו — ``git cat-file -s``. + + מחזיר ``{"size": int}`` או ``{"error": ..., "message": ...}`` באותה מפה + של ``git show`` (:meth:`_object_read_error`), כי git 2.43 מדפיס את + **אותן** הודעות לשתי הפקודות על נתיב חסר (``path 'x' does not exist + in ''``) ועל קומיט שאינו מכיל אותו (``exists on disk, but not + in``) — נמדד, לא הונח. הפלט הוא קלט חיצוני (U3): מספר שאינו מתפענח + הוא כשל ולא אפס, כדי שלא ייקרא כ"קטן מהתקרה". ``TimeoutExpired`` עולה + לקורא, שכבר ממפה אותה ל-``timeout``. + """ + result = subprocess.run( + ["git", "-C", str(mirror_path), "cat-file", "-s", f"{resolved_commit}:{safe_file_path}"], + capture_output=True, + timeout=timeout, + ) + if result.returncode != 0: + return self._object_read_error(result.stderr) + try: + return {"size": int(result.stdout.decode("ascii", errors="replace").strip())} + except ValueError: + self.logger.warning("git cat-file -s returned a non-numeric size for %s", safe_file_path) + return {"error": "git_error", "message": "git לא החזיר גודל אובייקט תקין"} + + def _object_read_error(self, stderr_bytes: bytes) -> Dict[str, Any]: + """ממפה stderr של ``git show`` ושל ``git cat-file`` לתשובת שגיאה — הגדרה אחת לשתיהן.""" + stderr = self._sanitize_output(stderr_bytes.decode('utf-8', errors='replace')) + if "does not exist" in stderr or "exists on disk" in stderr: + return {"error": "file_not_in_commit", "message": "הקובץ לא קיים ב-commit זה"} + if "unknown revision" in stderr or "bad revision" in stderr: + return {"error": "invalid_commit", "message": "Commit לא נמצא"} + return {"error": "git_error", "message": stderr} + def get_file_at_commit( self, repo_name: str, @@ -1587,7 +1622,10 @@ def get_file_at_commit( repo_name: שם הריפו file_path: נתיב הקובץ commit: Hash, tag, branch או expression - max_size: גודל מקסימלי להחזרה (bytes) + max_size: תקרה בבתים. **נבדקת מול גודל האובייקט במאגר לפני + ``git show``** (#3433), ולכן קובץ גדול ממנה אינו נטען לזיכרון + כלל — ``max_size`` הוא חסם על מה שנקרא, לא בדיקה בדיעבד. ונבדקת + שוב על מה שמוחזר, כדי שהחסם יחול על כל סוג אובייקט. Returns: Dict עם תוכן הקובץ או שגיאה @@ -1615,6 +1653,29 @@ def get_file_at_commit( resolved_commit = ref_validation["resolved_sha"] try: + # **הגודל נבדק מול מאגר האובייקטים לפני שנקרא בית אחד של תוכן (#3433).** + # עד כאן ``git show`` נטען כולו לזיכרון (``capture_output=True``) ורק + # אז ``max_size`` נבדק — כלומר התקרה הייתה בדיקה בדיעבד ולא חסם על + # הזיכרון: קובץ של 12MB נטען במלואו (20MiB שיא, נמדד) לפני שנדחה. + # ``git cat-file -s`` מחזיר את גודל האובייקט מהמאגר בלי לקרוא אותו, + # ושתי הפקודות פונות לאותו ``:`` שנפתר פעם אחת למעלה — + # אובייקט בקומיט נתון אינו משתנה, ולכן אין חלון בין הבדיקה לקריאה + # שסנכרון של המראה יכול להיכנס בו (TOCTOU נבחן ונדחה). + probe = self._object_size(mirror_path, resolved_commit, safe_file_path) + if "error" in probe: + # כשל בבדיקה אינו נופל לקריאה בלי תקרה — זו בדיוק הנפילה-לאחור + # השקטה למסלול הגרוע (``silent-fallback-to-worse-path``). הסירוב + # עולה כמות שהוא, באותה מפה שהייתה ל-``git show``. + return probe + file_size = probe["size"] + if file_size > max_size: + return { + "error": "file_too_large", + "message": f"הקובץ גדול מדי ({file_size:,} bytes)", + "size": file_size, + "max_size": max_size + } + # שליפת תוכן כ-bytes (לא text) לזיהוי בינארי נכון cmd = [ "git", "-C", str(mirror_path), @@ -1629,25 +1690,15 @@ def get_file_at_commit( ) if result.returncode != 0: - stderr = result.stderr.decode('utf-8', errors='replace') - stderr = self._sanitize_output(stderr) - - if "does not exist" in stderr or "exists on disk" in stderr: - return { - "error": "file_not_in_commit", - "message": "הקובץ לא קיים ב-commit זה" - } - if "unknown revision" in stderr or "bad revision" in stderr: - return { - "error": "invalid_commit", - "message": "Commit לא נמצא" - } - return {"error": "git_error", "message": stderr} + return self._object_read_error(result.stderr) raw_content = result.stdout file_size = len(raw_content) - # בדיקת גודל + # **החסם על מה שמוחזר, בנוסף לחסם על מה שנקרא.** לבלוב שני + # המספרים זהים; לנתיב של תיקייה ``cat-file -s`` מודד את אובייקט + # העץ ואילו ``git show`` מדפיס רשימה מעוצבת — ומה שחוזר מכאן + # לעולם אינו גדול מ-``max_size``, יהיה סוג האובייקט אשר יהיה. if file_size > max_size: return { "error": "file_too_large", diff --git a/services/line_endings.py b/services/line_endings.py new file mode 100644 index 000000000..d74707c78 --- /dev/null +++ b/services/line_endings.py @@ -0,0 +1,42 @@ +"""כלל אחד ל-``\\r`` בודד — סיומת שורה שאינה חד-משמעית — לשני הצרכנים שלו (#3419). + +**למה מודול משלו.** הכלל נכתב פעמיים: ``mcp_server/outline.py`` (המנתב מחזיר +``no_outline`` עם ``inconsistent_line_endings``) ו-``services/md_parser.py`` +(הפארסר מרים ``InconsistentLineEndings``). שני העותקים היו נכונים, וטסט השווה +אותם מול ``markdown-it-py`` — אבל עותק שני של כלל הוא מה שנסחף בתיקון הבא +(R6, ``duplicate-rule-second-copy``). ``services`` אינו מייבא מ-``mcp_server``, +הכיוון חד-סטרי, ולכן ההגדרה יושבת כאן ושני הצרכנים קוראים לה — אותו תקדים +כמו ``webapp/size_format.py``, שנולד אחרי שפורמוט גודל נמצא בחמישה מקומות. + +**מה הכלל אומר.** ה-lookahead השלילי הוא כל ההבחנה: CRLF הוא המקרה הנפוץ +ושתי ספירות השורות מסכימות עליו, ולכן הוא חייב להמשיך לעבוד. רק CR בודד — +הפורמט של Mac שלפני 2001 — מפריד ביניהן: ``markdown-it-py`` מנרמל +``\\r\\n?|\\n`` (``rules_core/normalize.py``), כלומר ``\\r`` בודד הוא אצלו שורה +חדשה, בעוד שדות ה-``lines`` של ה-MCP נספרים ב-``split("\\n")``. בקובץ כזה +המפה מצביעה לשורה שקריאת טווח לא מגיעה אליה — ומפה שמצביעה לשומקום גרועה +ממפה שאין. ``tests/test_md_parser.py::test_the_lone_cr_rule_matches_markdown_it`` +מריץ את הכלל מול הספרייה עצמה על טבלת קלטים. + +**``search`` ולא ``replace``**: עוצר על ההתאמה הראשונה ואינו מקצה עותק של +הטקסט, שיכול להיות 10MB לפי ``RANGE_READ_MAX_BYTES``. + +ייבוא קל בלבד — ``re``; בזמן ייבוא רצה רק קומפילציה של הביטוי. +""" + +from __future__ import annotations + +import re +from typing import Optional + +#: ``\\r`` שאינו חלק מ-``\\r\\n``. +CR_WITHOUT_LF = re.compile(r"\r(?!\n)") + + +def find_lone_cr(text: str) -> Optional["re.Match[str]"]: + """ההתאמה הראשונה של ``\\r`` בודד, או ``None`` כשאין. + + מחזירה את ההתאמה ולא ``bool``, כי הצרכן שמרים חריגה גוזר ממנה את מספר + השורה (``text.count("\\n", 0, match.start()) + 1``), והצרכן שמחזיר סירוב + צריך רק לדעת שיש. + """ + return CR_WITHOUT_LF.search(text) diff --git a/services/md_parser.py b/services/md_parser.py index cf5ca530d..a962e00fb 100644 --- a/services/md_parser.py +++ b/services/md_parser.py @@ -53,14 +53,15 @@ from __future__ import annotations -import re from typing import List, Optional from markdown_it import MarkdownIt from markdown_it.token import Token from mdit_py_plugins.front_matter import front_matter_plugin +from .line_endings import find_lone_cr from .doc_sections import ( + MAX_SECTIONS, Document, InconsistentLineEndings, Section, @@ -72,6 +73,7 @@ find_sections, neighbors, normalize_title, + require_str, section_bounds, section_text, suggest, @@ -86,17 +88,17 @@ # ``docs_handlers`` יוכל לבחור מודול לפי סיומת ולקרוא לשמות האלה בלי אף # ``if`` נוסף במסלול. # -# **ומה שאינו משותף, כדי שהטענה לא תיקרא רחבה ממה שהיא:** ``MAX_SECTIONS`` -# ו-``InconsistentLineEndings`` מיוצאים רק מכאן. ל-``rst_parser`` אין -# תקרה קבועה (ברירת המחדל שלו היא ``None``), והוא **אינו מרים** את -# החריגה השנייה לעולם — ייצוא שלה משם היה מצהיר על סירוב שלא קיים. -# לכן המטפל ב-PR 5 מייבא את **שתי** חריגות הסירוב מ-``doc_sections``, -# לא ``parser.X``, כפי שכתוב ב-docstring של ``InconsistentLineEndings``. +# **ומה שאינו משותף, כדי שהטענה לא תיקרא רחבה ממה שהיא:** +# ``InconsistentLineEndings`` מיוצאת רק מכאן. ``rst_parser`` **אינו מרים** +# אותה לעולם — ייצוא שלה משם היה מצהיר על סירוב שלא קיים. לכן המטפל מייבא +# את **שתי** חריגות הסירוב מ-``doc_sections``, לא ``parser.X``, כפי שכתוב +# ב-docstring של ``InconsistentLineEndings``. (``MAX_SECTIONS`` היה גם הוא +# רק כאן עד #3420; מאז הוא מוגדר ב-``doc_sections`` ומיוצא משני הפארסרים.) # ההפרש בין שני ה-``__all__`` מקובע ב- -# ``tests/test_md_parser.py::test_the_two_parsers_export_the_same_names_but_two``. +# ``tests/test_md_parser.py::test_the_two_parsers_export_the_same_names_but_one``. __all__ = [ # שם היסטורי: הוא סופר כותרות, גם כאלה שאינן נכנסות למפה. ההנמקה - # המלאה ב-``#:`` שמעל ההגדרה. + # המלאה ב-``#:`` שמעל ההגדרה ב-``doc_sections``. "MAX_SECTIONS", "Document", "InconsistentLineEndings", @@ -114,36 +116,12 @@ "suggest", ] -#: מקסימום **כותרות** לקובץ, ומעליו הפרסור נעצר. -#: -#: **וכותרות ולא סעיפים, וזה ההבדל שצריך להכיר.** נספרת כל כותרת -#: שהפרסור נתקל בה, כולל כותרת בתוך ציטוט או בתוך פריט רשימה שאינה -#: נכנסת למפה בכלל. הקבוע מגביל **עבודה** ולא **תוצאה**: מה שמייקר -#: הוא הכותרת עצמה, ולא השאלה אם היא נספרת כסעיף בסוף. נמדד: 500KB -#: של ``> ## h`` הם ~64,000 כותרות שאף אחת מהן אינה סעיף, וספירה לפי -#: ``level == 0`` הייתה רואה בהן אפס ולא עוצרת לעולם. הטענה "לא יותר -#: מ-``MAX_SECTIONS`` סעיפים" נשארת נכונה — היא פשוט לא כל מה שהקבוע -#: אוכף. שם מדויק יותר לקבוע הוא שינוי בפני עצמו והוא אישו נפרד. -#: -#: **אותו ערך בדיוק כמו ``MAX_SYMBOLS`` ב- -#: ``mcp_server/outline_scanners/_ceiling.py``**, ושם כתובות שתי -#: המדידות שקבעו אותו — השיא שהתקרה מרשה, והיחס לקובץ אמיתי. מי -#: שמשנה את אחד מהשניים צריך לפתוח את השני: הם מתארים את אותו גבול על -#: אותה מפה, והסורק שיגיע לכאן בשלב 2 יעביר את זה במקום את זה. השוויון -#: מקובע ב-``tests/test_md_parser.py::test_the_two_ceilings_are_the_same_number``. -#: -#: ``_ceiling.MAX_SYMBOLS`` אינו מיובא לכאן, כי ``services`` אינו מייבא -#: מ-``mcp_server`` — הכיוון חד-סטרי ומנומק ב-``TooManySections``. -MAX_SECTIONS = 50_000 - -#: ``\r`` שאינו חלק מ-``\r\n``. ההגדרה חוזרת כאן ואינה מיובאת מ- -#: ``mcp_server/outline.py::_CR_WITHOUT_LF``, מאותה סיבה: הכיוון חד-סטרי. -#: השקילות בין השניים אינה תקווה — ``tests/test_md_parser.py`` משווה את -#: שניהם מול ההתנהגות של ``markdown-it-py`` עצמו על טבלת קלטים אחת. -#: -#: ``search`` ולא ``replace``: הוא עוצר על ההתאמה הראשונה ואינו מקצה -#: עותק של הטקסט. -_CR_WITHOUT_LF = re.compile(r"\r(?!\n)") +# ``MAX_SECTIONS`` מוגדר ב-``doc_sections`` (מאז #3420, כשהפך לברירת המחדל +# של שני הפארסרים) ומיובא לכאן. מה שהוא סופר **במסלול הזה** — כותרות ולא +# סעיפים — מנומק ליד הקבוע שם וב-``_SectionCounter`` למטה. + +# כלל ה-``\r`` הבודד יושב ב-``services/line_endings.py`` (מאז #3419), משותף +# למנתב האאוטליין ולפארסר הזה — עותק אחד, לא שניים שצריכים להסכים לנצח. #: המפתח שתחתיו יושב מונה ה**כותרות** ב-``env`` של פרסור בודד. #: המחרוזת עצמה נשארה ``section`` כשמשמעות המונה השתנתה, כי היא @@ -242,10 +220,11 @@ def _build_parser() -> MarkdownIt: בקובצי ה-``.md`` שתחת ``docs/`` בריפו הזה. נמדד שהיא **אינה מזיזה אף מספר שורה** באף אחת מעשר הצורות שנבדקו — כולל בלוק שאינו נסגר, ``---`` מוזח, ו-``---`` עם רווח בסוף — כי הטקסט עצמו אינו משתנה - כלל. וכלל שהיינו כותבים בעצמנו כן היה סוטה ממנה: נמדד שפרוטוטיפ - שמשווה ``---`` מדויק חולק עליה ב**חמש מתוך שתים-עשרה** צורות. - המספר נגזר מהטבלה ב-``tests/test_md_parser.py`` ומושווה למשפט - הזה ב-``test_the_measured_front_matter_gap_matches_the_prose``. + כלל. וכלל שהיינו כותבים בעצמנו כן היה סוטה ממנה: נמדד שהכלל שנכתב + ביד ב-``scripts/generate_ai_map.py`` חלק עליה בחמש מתוך שתים-עשרה + צורות. מאז #3419 הסקריפט קורא את הגבול מהמופע הזה, דרך + :func:`front_matter_end`, והטבלה ב-``tests/test_md_parser.py`` + מקבעת שהשניים מסכימים על כל צורה. 3. **``disable("inline")``** — הכלל ה**ליבתי** בשם הזה, שהוא זה שמפרק את תוכן הכותרת לטוקנים פנימיים. נמדד: עד 40% חיסכון בזמן ו-37% בזיכרון, **ואפס שינוי בזיהוי הכותרות ובטווחים** — כי השם @@ -318,16 +297,85 @@ def _build_parser() -> MarkdownIt: _MD = _build_parser() +def _entry_checks(text: object) -> str: + """שער הכניסה המשותף לכל מי שמפרסר כאן: ``parse_document``, ``token_count`` ו-``front_matter_end``. + + שלושה צעדים, בסדר הזה (ההנמקה לסדר ב-docstring של ``parse_document``): + ``require_str``, הסרת BOM אחד בתחילת המחרוזת, ו-``\\r`` בודד → + :class:`~services.doc_sections.InconsistentLineEndings`. הנרמול של + ``\\r\\n`` נשאר ב-``parse_document``, כי רק היא בונה ``lines`` שחייב להסכים + עם המפה; markdown-it מנרמל אותו בעצמו לפני הפרסור. + + עד סקירת שבעת ה-PRים (SUGG-019) שתי הפונקציות הקטנות קראו ל-``_MD.parse`` + ישירות ודילגו על שלושתם, ונמדד מה זה עושה: ``front_matter_end`` על בלוק + שיש בו ``\\r`` בודד החזיר 4 בעוד ה-``---`` הסוגר יושב באינדקס 2 של + ``split("\\n")`` — markdown-it סופר ``\\r`` כמעבר שורה והקורא לא — ועם BOM + החזיר 0, כלומר לא ראה את הבלוק כלל; ערך שאינו מחרוזת נפל עם ההודעה של + markdown-it ולא של המודול. + """ + text = require_str(text) + # **BOM אחד בתחילת הקובץ מוסר, ולא יותר מזה.** בלי ההסרה, ``# Title`` + # שלפניו ``U+FEFF`` אינו כותרת אצל ``markdown-it`` — הוא פסקה שמתחילה + # בתו בלתי נראה — והפארסר מחזיר **מפה ריקה בלי חריגה** על קובץ שיש + # בו כותרות, שזו בדיוק מחלקת הכשל שהמודול הזה מצהיר שאין בו. נמדד. + # + # **וזה יישור ל-cmark-gfm ול-GitHub, לא למפרט.** מימוש הייחוס של + # CommonMark (``commonmark.py`` 0.9.2) **אינו** מסיר BOM ומרנדר + # ``

# Title

`` בדיוק כמו ``markdown-it``; cmark מסיר. + # האורקל שנבחר למודול הזה הוא GitHub, ולכן הולכים אחריו. + # + # **למה זה אינו מזיז מספרי שורות:** התו יושב בעמודה 0 של שורה 1 + # ואינו מעבר שורה, ולכן ``lines`` ומספרי המפה זהים עם ובלי ההסרה — + # אותו תנאי שהתיר את נרמול ה-``\r\n``. חיתוך בתווים, לא בבייטים. + # + # **ורק בתחילת המחרוזת.** ``U+FEFF`` באמצע הטקסט הוא ZERO WIDTH + # NO-BREAK SPACE רגיל — תוכן, לא סימון קידוד — ואיש אינו נוגע בו. + # מסלול הייצור (``git_mirror_service._try_decode_content``) מפענח + # ב-``utf-8-sig`` ולכן לא יעביר BOM לכאן; ההגנה היא בשביל כל קורא + # אחר — הסקריפט, טסט, או הצרכן הבא — שיפענח ב-``utf-8`` רגיל. + if text.startswith(""): + text = text[1:] + # **החריגה נושאת את מספר השורה**, כמו ``TooManySections``. המיקום + # כבר מחושב כאן — ``search`` מחזיר אובייקט התאמה — והוא ההבדל בין + # "יש ``\r`` איפשהו בקובץ" לבין משהו שאפשר לפתוח ולתקן. + lone_cr = find_lone_cr(text) + if lone_cr: + raise InconsistentLineEndings(text.count("\n", 0, lone_cr.start()) + 1) + return text + + +def token_count(text: str) -> int: + """מספר הטוקנים שהפרסר של הכלי מייצר לטקסט — מדד צפיפות, לא מפה. + + קיים בשביל ``scripts/measure_md_parse_cost.py``, שמדרג מסמכים לפי + צפיפות טוקני-בלוק ל-KB, וקרא עד #3433 ל-``_build_parser`` הפרטית — + תלות סמויה בפנימי של המודול, ובנייה של פרסר חדש לכל קובץ. הפונקציה + הזאת היא התלות בגלוי: על ``_MD``, המופע שהכלי מריץ, בלי ``env`` ולכן + בלי תקרה (``_ceiling_rule`` מקבל ``None`` ומחזיר ``False``), ובלי + טוקני inline (``disable("inline")``) — כלומר בדיוק מה שהפרסור האמיתי + סופר. ודרך אותו שער כניסה (:func:`_entry_checks`): BOM מוסר, ``\\r`` בודד + נדחה, וערך שאינו מחרוזת מקבל את החריגה של המודול — כמו בכלי. + + **ומחוץ ל-``__all__`` בכוונה:** הרשימה שם היא החוזה של "שני פארסרים + בני-החלפה", וטסט מקבע את ההפרש בינה לבין זו של ``rst_parser``. מדד + למדידה אינו חלק מהחוזה הזה ואינו קיים ל-RST, ולכן הוא ציבורי (בלי קו + תחתון, מתועד) אך אינו מיוצא. + """ + return len(_MD.parse(_entry_checks(text))) + + def parse_document(text: str, *, max_sections: Optional[int] = MAX_SECTIONS) -> Document: """בונה ``Document`` מטקסט Markdown. **סדר הבדיקות בכניסה, והוא אינו שרירותי:** - 1. ``isinstance(text, str)`` — הטקסט מגיע מחוץ לתהליך (קובץ מהמראה, - גוף בקשה), ו-``.replace`` על ערך שאינו מחרוזת היה מפיל - ``AttributeError`` ממקום שלא מסביר כלום. + 1. :func:`~services.doc_sections.require_str` — הטקסט מגיע מחוץ + לתהליך (קובץ מהמראה, גוף בקשה), ו-``.replace`` על ערך שאינו מחרוזת + היה מפיל ``AttributeError`` ממקום שלא מסביר כלום. אותה בדיקה + בדיוק, ואותן מילים, כמו ב-``rst_parser`` (מאז #3421). 2. הסרת ``U+FEFF`` (BOM) **אחד, בתחילת המחרוזת בלבד** — ההנמקה - בהערה ליד הקוד. לפני בדיקת ה-``\\r``, כדי שמספר השורה שהחריגה + בהערה ליד הקוד ב-:func:`_entry_checks`, השער המשותף לשלוש הפונקציות + שמפרסרות כאן. לפני בדיקת ה-``\\r``, כדי שמספר השורה שהחריגה נושאת יתאים לטקסט שהפארסר באמת רואה. 3. ``\\r`` בודד → :class:`~services.doc_sections.InconsistentLineEndings`. 4. נרמול ``\\r\\n`` ← ``\\n``. ``splitlines()`` נשקל ונדחה באותו @@ -345,20 +393,12 @@ def parse_document(text: str, *, max_sections: Optional[int] = MAX_SECTIONS) -> הקלט ששלב 3 קיים בשבילו. :param max_sections: התקרה, **במספר כותרות ולא במספר סעיפים** — - ראו :data:`MAX_SECTIONS`. ברירת המחדל היא :data:`MAX_SECTIONS`, + ראו :data:`~services.doc_sections.MAX_SECTIONS`. ברירת המחדל היא + :data:`~services.doc_sections.MAX_SECTIONS` — אותה ברירת מחדל + כמו ב-``services.rst_parser.parse_document`` (מיושר מאז #3420; + לפני כן היא הייתה ``None`` שם, וההבדל היה מוצהר בשני המקומות) — ו-``None`` מכבה אותה במפורש. - .. important:: - - **ברירת המחדל כאן הפוכה מזו של** - ``services.rst_parser.parse_document``\\ **, שם היא ``None``.** - שם הפרמטר נוסף לפארסר שכבר היה בייצור, וברירת מחדל שאינה - ``None`` הייתה משנה את התנהגות ``docs_get_section`` באותו - קומיט; כאן אין התנהגות קודמת לשמר, ולכן נבחרה ההנחה היקרה — - קורא ששכח להעביר תקרה מקבל הגנה ולא את היעדרה. ההבדל מוצהר - בשני המקומות בכוונה, ויישור של ``rst_parser`` נשקל בנפרד כי - הוא שינוי התנהגות על מסלול חי. - :raises TypeError: ``text`` אינו מחרוזת. :raises ~services.doc_sections.InconsistentLineEndings: יש ``\\r`` שאינו חלק מ-``\\r\\n``. @@ -366,35 +406,7 @@ def parse_document(text: str, *, max_sections: Optional[int] = MAX_SECTIONS) -> כותרות מהתקרה. הארגומנט הוא מספר השורה שבה נעצרנו — מה שמבדיל עצירה בתוך הפרסור מסינון של פלט אחריו. """ - if not isinstance(text, str): - raise TypeError(f"parse_document expects str, got {type(text).__name__}") - # **BOM אחד בתחילת הקובץ מוסר, ולא יותר מזה.** בלי ההסרה, ``# Title`` - # שלפניו ``U+FEFF`` אינו כותרת אצל ``markdown-it`` — הוא פסקה שמתחילה - # בתו בלתי נראה — והפארסר מחזיר **מפה ריקה בלי חריגה** על קובץ שיש - # בו כותרות, שזו בדיוק מחלקת הכשל שהמודול הזה מצהיר שאין בו. נמדד. - # - # **וזה יישור ל-cmark-gfm ול-GitHub, לא למפרט.** מימוש הייחוס של - # CommonMark (``commonmark.py`` 0.9.2) **אינו** מסיר BOM ומרנדר - # ``

\ufeff# Title

`` בדיוק כמו ``markdown-it``; cmark מסיר. - # האורקל שנבחר למודול הזה הוא GitHub, ולכן הולכים אחריו. - # - # **למה זה אינו מזיז מספרי שורות:** התו יושב בעמודה 0 של שורה 1 - # ואינו מעבר שורה, ולכן ``lines`` ומספרי המפה זהים עם ובלי ההסרה — - # אותו תנאי שהתיר את נרמול ה-``\r\n``. חיתוך בתווים, לא בבייטים. - # - # **ורק בתחילת המחרוזת.** ``U+FEFF`` באמצע הטקסט הוא ZERO WIDTH - # NO-BREAK SPACE רגיל — תוכן, לא סימון קידוד — ואיש אינו נוגע בו. - # מסלול הייצור (``git_mirror_service._try_decode_content``) מפענח - # ב-``utf-8-sig`` ולכן לא יעביר BOM לכאן; ההגנה היא בשביל כל קורא - # אחר — הסקריפט, טסט, או הצרכן הבא — שיפענח ב-``utf-8`` רגיל. - if text.startswith("\ufeff"): - text = text[1:] - # **החריגה נושאת את מספר השורה**, כמו ``TooManySections``. המיקום - # כבר מחושב כאן — ``search`` מחזיר אובייקט התאמה — והוא ההבדל בין - # "יש ``\r`` איפשהו בקובץ" לבין משהו שאפשר לפתוח ולתקן. - lone_cr = _CR_WITHOUT_LF.search(text) - if lone_cr: - raise InconsistentLineEndings(text.count("\n", 0, lone_cr.start()) + 1) + text = _entry_checks(text) normalized = text.replace("\r\n", "\n") lines = normalized.split("\n") @@ -526,3 +538,29 @@ def _title_of(tokens: List[Token], heading_index: int) -> str: else (part if index == 0 else part.lstrip(" \t")) for index, part in enumerate(parts) ) + + +def front_matter_end(text: str) -> int: + """אינדקס השורה שאחרי בלוק ה-front matter — או 0 כשאין בלוק, כולל בלוק שלא נסגר. + + לפי התוסף ``mdit_py_plugins.front_matter`` שרשום על ``_MD`` — ההגדרה + ש-MyST מריץ — ולא לפי כלל שנכתב ביד. ``scripts/generate_ai_map.py`` כתב + עד #3419 כלל משלו, ונמדד שהוא חלק על התוסף בחמש מתוך שתים-עשרה צורות: + ``---`` מוזח, סוגר מוזח בארבעה רווחים, ארבעה מקפים, סוגר ארוך מהפותח, + ופותח שיש אחריו טקסט. הטוקן ``front_matter`` הוא תמיד הראשון (התוסף + מזהה בלוק רק בשורה 0) ונושא ``map = [0, end]``, ו-``end`` הוא בדיוק + האינדקס שהסקריפט צריך. + + פרסור מלא של הטקסט, בלי ``env`` ולכן בלי תקרה — הקלט הוא עמודי + תיעוד. ודרך :func:`_entry_checks`, כי האינדקס שמוחזר חייב להסכים עם + ``split("\\n")`` של הקורא: ``\\r`` בודד (ש-markdown-it סופר כמעבר שורה) + היה מזיז אותו בשקט, ו-BOM היה מסתיר את הבלוק כולו — עכשיו הראשון + נדחה בשם, והשני מוסר, בדיוק כמו ב-``parse_document``. **מחוץ ל-``__all__`` + בכוונה**, מאותו נימוק שכתוב ליד :func:`token_count` אם הוא קיים כאן: + הרשימה שם היא החוזה של "שני פארסרים בני-החלפה", וגבול front matter אינו + קיים ל-RST. + """ + tokens = _MD.parse(_entry_checks(text)) + if tokens and tokens[0].type == "front_matter" and tokens[0].map: + return tokens[0].map[1] + return 0 diff --git a/services/rst_parser.py b/services/rst_parser.py index 011738342..990cd8b09 100644 --- a/services/rst_parser.py +++ b/services/rst_parser.py @@ -25,6 +25,7 @@ from typing import List, Optional from .doc_sections import ( + MAX_SECTIONS, Document, Section, Suggestions, @@ -35,6 +36,7 @@ find_sections, neighbors, normalize_title, + require_str, section_bounds, section_text, suggest, @@ -57,6 +59,7 @@ #: מסתיר את האזהרה במקום להצהיר על הכוונה; ``__all__`` מצהיר עליה, וגם עונה #: לכל כלי שקורא ייצוא-מחדש במפורש. __all__ = [ + "MAX_SECTIONS", "Document", "Section", "Suggestions", @@ -262,7 +265,7 @@ def _skip_paragraph_block(lines: List[str], i: int, n: int) -> int: return i -def parse_document(text: str, *, max_sections: int | None = None) -> Document: +def parse_document(text: str, *, max_sections: int | None = MAX_SECTIONS) -> Document: """מפרסר טקסט RST לעץ סקשנים. עמיד ל-literal/code blocks ולדירקטיבות מוזחות. **סיומות השורה מנורמלות כאן, בגבול הקלט, ו-``split("\\n")`` נשאר יחידת @@ -283,12 +286,25 @@ def parse_document(text: str, *, max_sections: int | None = None) -> Document: ``\\v``, ``\\f``, NBSP, ``U+2028`` — אינו נעלם כאן. ההגנה עליו יושבת ב-``_is_title_text``, שם ההגדרה של "שורה ריקה" חייבת להיות אחת. - **``max_sections`` — תקרה על מספר הסקשנים, וברירת המחדל היא ללא תקרה.** - ``None`` פירושו התנהגות זהה בית-בית לזו שקדמה לפרמטר. ``docs_get_section`` - — הצרכן השני של המודול הזה — לא העביר תקרה עד הסקירה של #3429, ומאז הוא - מעביר את אותה תקרה של הסורק ומתרגם את החריגה לתשובת ``too_many_sections``; - ברירת המחדל נשארת ``None`` לכל קורא אחר. כשהתקרה מועברת ונחצית מורמת - ``TooManySections`` **בזמן הפרסור**, ולא אחריו. + **הכניסה: ``text`` חייב להיות מחרוזת, ואחרת ``TypeError`` שאומר מה + התקבל** — :func:`~services.doc_sections.require_str`, אותה בדיקה ואותן + מילים כמו ב-``md_parser`` (מאז #3421). עד אז ``None`` הפך כאן ל-``""`` + והחזיר ``Document`` ריק **בשקט** — מפה ריקה שמתחזה למפה של קובץ בלי + כותרות — ו-``17`` נפל ב-``AttributeError`` גולמי מתוך ``.replace``. + אף קורא לא נשען על הצורה הישנה: המטפל, הסורק והסקריפטים מעבירים תמיד + מחרוזת (נבדק), ולכן היישור לא שינה אף תשובה קיימת. + + **``max_sections`` — תקרה על מספר הסקשנים, וברירת המחדל היא + :data:`~services.doc_sections.MAX_SECTIONS` (50,000), כמו ב-``md_parser``.** + ``None`` מכבה אותה במפורש. כשהתקרה נחצית מורמת ``TooManySections`` + **בזמן הפרסור**, ולא אחריו. ההיסטוריה, כי היא מסבירה למה זה נראה כמו + שינוי: הפרמטר נוסף לפארסר שכבר רץ בייצור, ו-``None`` היה מה ששמר על + ``docs_get_section`` זהה בית-בית באותו קומיט; ``docs_get_section`` העביר + את תקרת הסורק במפורש מאז הסקירה של #3429, וכל קורא אחר נשאר בלי הגנה. + היישור ב-#3420 נעשה אחרי מדידה על כל 208 קובצי ה-RST ב-``docs/``: הקובץ + העשיר ביותר נושא 50 סקשנים, אחד לאלף מהתקרה, ופלט ``docs_get_section`` + על כולם זהה לפני ואחרי — כלומר ברירת המחדל מגינה על מי ששכח, ואינה + נוגעת באף קלט אמיתי. **למה בזמן הפרסור, במספרים שנמדדו על קלט של 10MB:** כותרת בת תו אחד בכל שתי שורות נותנת 2.6 מיליון סקשנים, ובלי התקרה זה **18.5 שניות @@ -303,17 +319,13 @@ def parse_document(text: str, *, max_sections: int | None = None) -> Document: הוא תקרת הבתים של הקריאה. **מתועדת באישו #3379** — שם גם הכיוון שמשלים אותה, ומספר השורות ממילא מחושב כאן בראש הפונקציה. - .. important:: - - **ברירת המחדל כאן הפוכה מזו של** ``services.md_parser.parse_document``\\ **, - שם היא** ``MAX_SECTIONS``. ההבדל מכוון ומוצהר בשני המקומות: כאן - ``None`` הוא מה ששמר על ``docs_get_section`` זהה בית-בית כשהפרמטר - נוסף לפארסר שכבר רץ בייצור, ושם אין התנהגות קודמת לשמר ולכן נבחרה - ההנחה היקרה. המשמעות שצריך להכיר: **קורא שאינו מעביר תקרה מקבל - הגנה מהפארסר האחד ולא מהשני.** יישור של הפונקציה הזאת נשקל בנפרד, - כי הוא שינוי התנהגות על מסלול חי. + **ומה שהתקרה עוצרת כאן נמדד ולא הונח:** כותרת בת תו אחד בכל שורה + ב-500KB עולה 44.0MiB לפרסור אחד בלי תקרה, ואיתה נעצרת ב-20.1MiB + (סקירת #3429). קורא שרוצה את המחיר בלי התקרה — הסקריפט שמודד את + עלות הפרסור, למשל — מעביר ``max_sections=None`` ואומר זאת. """ - lines = (text or "").replace("\r\n", "\n").split("\n") + text = require_str(text) + lines = text.replace("\r\n", "\n").split("\n") n = len(lines) sections: List[Section] = [] includes: List[str] = [] diff --git a/src/domain/services/code_normalizer.py b/src/domain/services/code_normalizer.py index 7b25df1b4..b9b18dba3 100644 --- a/src/domain/services/code_normalizer.py +++ b/src/domain/services/code_normalizer.py @@ -50,12 +50,6 @@ class CodeNormalizer: "\u2069", # PDI } - _KNOWN_ESCAPE_HEX4 = { - "200B", "200C", "200D", "2060", "FEFF", # zero-width set - "200E", "200F", "202A", "202B", "202C", "202D", "202E", # directional - "2066", "2067", "2068", "2069", # directional isolates - } - def normalize(self, text: Any) -> str: """Normalize code text. @@ -69,7 +63,7 @@ def normalize(self, text: Any) -> str: # Handle sequences like "\\u200B" and "\\U0001F600" that represent hidden/format chars literally if ("\\u" in out) or ("\\U" in out): - out = self._strip_hidden_escapes(out) + out = strip_hidden_escapes(out) # 1) Strip BOM at start if out.startswith("\ufeff"): @@ -115,37 +109,61 @@ def _keep_char(ch: str) -> bool: return out - def _strip_hidden_escapes(self, s: str) -> str: - """Remove literal unicode escape sequences for hidden/format characters. + # ``_strip_hidden_escapes`` היה כאן כמתודה, עם ``_KNOWN_ESCAPE_HEX4`` — עותק + # שני של אותה פונקציה ואותה רשימה מ-``utils.normalize_code`` (#3427). + # ההגדרה האחת היא :func:`strip_hidden_escapes` למטה, ושני הצרכנים קוראים לה. + + +# תווי Variation Selector: ``Mn`` ולא ``Cf``, ולכן בדיקת הקטגוריה אינה תופסת +# אותם והם צריכים תנאי משלהם — שנדלק רק לפי בקשה. +_VS_HEX4 = range(0xFE00, 0xFE0F + 1) +_IDEOGRAPHIC_VS = range(0xE0100, 0xE01EF + 1) + +_ESCAPE_U4 = re.compile(r"\\u([0-9a-fA-F]{4})") +_ESCAPE_U8 = re.compile(r"\\U([0-9a-fA-F]{8})") + + +def strip_hidden_escapes(s: str, *, remove_variation_selectors: bool = False) -> str: + """מסיר רצפי בריחה טקסטואליים (``\\uXXXX``, ``\\UXXXXXXXX``) שמייצגים תווי פורמט. + + **ההגדרה היחידה, לשני הצרכנים** — :meth:`CodeNormalizer.normalize` ו- + ``utils.normalize_code`` (#3427). עד אז כל אחד מהם החזיק עותק של אותה + פונקציה, ולפניה רשימה של 16 קודים "ידועים" (``_KNOWN_ESCAPE_HEX4`` / + ``known_hex4``) שנבדקה לפני בדיקת הקטגוריה. נמדד: כל 16 הערכים הם + ``Cf``, כלומר הרשימה לא הוסיפה דבר על ``unicodedata.category`` — מסלול + מהיר שאיש לא מדד, בשני עותקים שהיו צריכים להסכים לנצח. ומי שהיה מוסיף + קוד לרשימה באחד הקבצים לא היה משנה דבר בפועל, ומסיק שהתיקון עבד. + **רשימה היא העתק של ידע שצריך להסכים עם עצמו; קטגוריה היא הגדרה + שמתעדכנת עם התקן** — ולכן נשארה הקטגוריה לבדה. + + - ``Cf`` (format) מוסר תמיד, בשתי הצורות. + - Variation Selectors (``U+FE00``–``U+FE0F`` ו-``U+E0100``–``U+E01EF``) + הם ``Mn`` ולא ``Cf``, ולכן הם ענף נפרד שנדלק רק עם + ``remove_variation_selectors=True`` — ברירת המחדל שומרת אותם, כמו קודם + בשני הצרכנים. **ההחלטה היא על קוד התו, לא על צורת הכתיב:** ``\\U0000FE0F`` + הוא אותו תו כמו ``\\uFE0F`` ומקבל אותה תשובה. עד סקירת #3443 הענף הארוך + בדק רק את הטווח האידאוגרפי, כאילו הכתיב קובע את הטווח. + - רצף שאינו מתפענח לקוד תו (``\\uZZZZ`` אינו תואם את הביטוי; ``chr`` + מעבר לטווח) נשאר כמות שהוא. + """ - - Removes escapes in KNOWN sets or with Unicode category Cf - - Handles both \\uXXXX and \\UXXXXXXXX forms - """ - def _strip_if_hidden_u4(m: "re.Match[str]") -> str: - hexcode = m.group(1).upper() - if hexcode in self._KNOWN_ESCAPE_HEX4: - return "" - try: - ch = chr(int(hexcode, 16)) - cat = unicodedata.category(ch) - if cat == "Cf": - return "" - # Variation Selectors (FE00..FE0F) are kept by default (compatibility) - except Exception: - pass - return m.group(0) - - def _strip_if_hidden_u8(m: "re.Match[str]") -> str: - hexcode = m.group(1).upper() - try: - ch = chr(int(hexcode, 16)) - if unicodedata.category(ch) == "Cf": - return "" - # Ideographic Variation Selectors (E0100..E01EF) are kept by default - except Exception: - pass - return m.group(0) - - s = re.sub(r"\\u([0-9a-fA-F]{4})", _strip_if_hidden_u4, s) - s = re.sub(r"\\U([0-9a-fA-F]{8})", _strip_if_hidden_u8, s) - return s + def _is_hidden(code: int) -> bool: + # הכלל האחד לשתי הצורות. ``chr`` מרים ``ValueError``/``OverflowError`` + # מעל U+10FFFF — הצורה הארוכה היא היחידה שיכולה להגיע לשם, והיא תופסת. + if unicodedata.category(chr(code)) == "Cf": + return True + return remove_variation_selectors and (code in _VS_HEX4 or code in _IDEOGRAPHIC_VS) + + def _strip_if_hidden_u4(m: "re.Match[str]") -> str: + return "" if _is_hidden(int(m.group(1), 16)) else m.group(0) + + def _strip_if_hidden_u8(m: "re.Match[str]") -> str: + try: + hidden = _is_hidden(int(m.group(1), 16)) + except (ValueError, OverflowError): + return m.group(0) # מעבר לטווח Unicode — לא תו, נשאר כמות שהוא + return "" if hidden else m.group(0) + + s = _ESCAPE_U4.sub(_strip_if_hidden_u4, s) + s = _ESCAPE_U8.sub(_strip_if_hidden_u8, s) + return s diff --git a/tests/test_ai_map_generator.py b/tests/test_ai_map_generator.py index 866cea5d7..89e13f021 100644 --- a/tests/test_ai_map_generator.py +++ b/tests/test_ai_map_generator.py @@ -204,6 +204,19 @@ def test_declared_summary_is_capped(): assert got.endswith("…") +def test_body_start_is_read_from_the_parser_and_not_computed_here(monkeypatch): + """הגבול של ה-front matter מגיע מ-``services.md_parser.front_matter_end`` (#3419) — לא מכלל שנכתב כאן. + + ערך שאף כלל ידני לא היה מחזיר על הקלט הזה מוכיח שהפונקציה מאצילה ולא + מחשבת; ההסכמה עם התוסף על כל צורה נבדקת ב-``tests/test_md_parser.py``. + """ + from services import md_parser + + g = _load_generator() + monkeypatch.setattr(md_parser, "front_matter_end", lambda text: 7) + assert g._body_start(["# not front matter at all"]) == 7 + + def test_front_matter_does_not_become_the_title(): """ה-``---`` הסוגר של front matter אינו קו-תחתון של כותרת setext. diff --git a/tests/test_git_mirror_service.py b/tests/test_git_mirror_service.py index 5c1e48000..faf8884f2 100644 --- a/tests/test_git_mirror_service.py +++ b/tests/test_git_mirror_service.py @@ -249,3 +249,157 @@ def test_constructor_token_overrides_map(tmp_path, monkeypatch): svc = GitMirrorService(base_path=str(tmp_path), github_token="ghp_EXPLICIT") # טוקן שהוזרק במפורש ל-constructor גובר על הכל assert svc._token_for_url("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/Campaign-AI4U/campaign-ai.git") == "ghp_EXPLICIT" + + +# --------------------------------------------------------------------------- +# get_file_at_commit: התקרה נבדקת מול מאגר האובייקטים לפני git show (#3433, פריט 9) +# --------------------------------------------------------------------------- +# +# עד #3433 ``git show`` נטען כולו לזיכרון ורק אז ``max_size`` נבדק — קובץ של +# 12MB שנדחה עלה 20MiB שיא. הטסטים כאן רצים על מראה git אמיתית שנבנית +# ב-``tmp_path`` (bare clone, כמו בייצור), ומרגלים על ``subprocess.run`` כדי +# לראות **אילו** פקודות git רצו — כי התכונה הנבדקת היא מה לא נקרא, ואת זה +# רואים רק ברשימת הפקודות. + +import subprocess # noqa: E402 + +from services import git_mirror_service as _gms # noqa: E402 + + +def _bare_mirror(tmp_path, files): + """מראה bare בשם ``probe`` תחת ``tmp_path``, עם הקבצים הנתונים בקומיט אחד; מחזיר את השירות ואת ה-sha.""" + src = tmp_path / "src" + src.mkdir() + for name, data in files.items(): + target = src / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + env = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@t", "PATH": __import__("os").environ["PATH"]} + for cmd in (["git", "init", "-q"], ["git", "add", "-A"], ["git", "commit", "-qm", "init"]): + subprocess.run(cmd, cwd=src, check=True, env=env, capture_output=True) + sha = subprocess.run(["git", "rev-parse", "HEAD"], cwd=src, check=True, env=env, + capture_output=True, text=True).stdout.strip() + subprocess.run(["git", "clone", "-q", "--bare", str(src), str(tmp_path / "probe.git")], + check=True, env=env, capture_output=True) + return GitMirrorService(base_path=str(tmp_path)), sha + + +def _spy_on_git(monkeypatch, override=None): + """מרגל שמעביר כל קריאה ל-``subprocess.run`` האמיתי ורושם את ה-argv; ``override(argv)`` יכול להחזיר תוצאה מזויפת.""" + calls = [] + real = subprocess.run + + def spy(cmd, *args, **kwargs): + calls.append(list(cmd)) + if override is not None: + faked = override(list(cmd)) + if faked is not None: + return faked + return real(cmd, *args, **kwargs) + + monkeypatch.setattr(_gms.subprocess, "run", spy) + return calls + + +def _subcommands(calls): + return [cmd[cmd.index("-C") + 2] if "-C" in cmd else cmd[1] for cmd in calls] + + +def test_a_file_over_the_cap_is_refused_before_git_show_reads_it(tmp_path, monkeypatch): + """הסירוב מגיע מ-``git cat-file -s``, ו-``git show`` אינו רץ כלל — אפס בתים נקראים.""" + svc, _ = _bare_mirror(tmp_path, {"big.bin": b"x" * 3000}) + calls = _spy_on_git(monkeypatch) + + out = svc.get_file_at_commit("probe", "big.bin", "HEAD", max_size=1000) + + assert out["error"] == "file_too_large" and out["size"] == 3000 and out["max_size"] == 1000 + assert "cat-file" in _subcommands(calls) + assert "show" not in _subcommands(calls), f"git show רץ על קובץ שמעל התקרה: {calls}" + + +def test_a_file_under_the_cap_is_read_exactly_as_before(tmp_path, monkeypatch): + """הבקרה: מתחת לתקרה התשובה זהה לזו של קודם — תוכן, קידוד, גודל, שורות, sha.""" + svc, sha = _bare_mirror(tmp_path, {"a.txt": "שלום\nעולם\n".encode("utf-8")}) + calls = _spy_on_git(monkeypatch) + + out = svc.get_file_at_commit("probe", "a.txt", "HEAD", max_size=1000) + + assert out["success"] and out["content"] == "שלום\nעולם\n" and out["is_binary"] is False + assert out["encoding"] == "utf-8" and out["size"] == len("שלום\nעולם\n".encode("utf-8")) + assert out["lines"] == 3 and out["resolved_commit"] == sha + assert "show" in _subcommands(calls), "מתחת לתקרה הקובץ כן נקרא" + + +def test_a_missing_path_is_still_file_not_in_commit_through_the_probe(tmp_path): + """git מדפיס את אותה הודעה ל-``cat-file -s`` ול-``show`` על נתיב חסר, והמפה אחת לשתיהן.""" + svc, _ = _bare_mirror(tmp_path, {"a.txt": b"x"}) + out = svc.get_file_at_commit("probe", "nope.txt", "HEAD") + assert out == {"error": "file_not_in_commit", "message": "הקובץ לא קיים ב-commit זה"} + + +def test_both_commands_name_the_same_resolved_commit(tmp_path, monkeypatch): + """בלי חלון בין הבדיקה לקריאה: שתי הפקודות פונות ל-``:`` שנפתר פעם אחת, לא ל-``HEAD``.""" + svc, sha = _bare_mirror(tmp_path, {"a.txt": b"x"}) + calls = _spy_on_git(monkeypatch) + + svc.get_file_at_commit("probe", "a.txt", "HEAD") + + # ``rev-parse`` של אימות ה-ref רץ גם הוא עם ``-C``; כאן מעניינות רק שתי פקודות האובייקט. + targets = {sub: cmd[-1] for cmd in calls if "-C" in cmd + for sub in [cmd[cmd.index("-C") + 2]] if sub in ("cat-file", "show")} + assert targets == {"cat-file": f"{sha}:a.txt", "show": f"{sha}:a.txt"} + assert [s for s in _subcommands(calls) if s in ("cat-file", "show")] == ["cat-file", "show"], ( + "הבדיקה קודמת לקריאה — זה הסדר שהופך את max_size לחסם על מה שנקרא") + + +def test_a_failed_size_probe_never_falls_back_to_an_unbounded_read(tmp_path, monkeypatch): + """כשל בבדיקת הגודל הוא סירוב, לא קריאה בלי תקרה — ``silent-fallback-to-worse-path``.""" + svc, _ = _bare_mirror(tmp_path, {"a.txt": b"x" * 3000}) + + def fail_the_probe(cmd): + if "cat-file" in cmd: + return subprocess.CompletedProcess(cmd, 128, stdout=b"", stderr=b"fatal: object store unreadable") + return None + + calls = _spy_on_git(monkeypatch, override=fail_the_probe) + out = svc.get_file_at_commit("probe", "a.txt", "HEAD", max_size=10) + + assert out["error"] == "git_error" and "unreadable" in out["message"] + assert "show" not in _subcommands(calls) + + +def test_a_non_numeric_size_is_a_failure_and_not_a_small_file(tmp_path, monkeypatch): + """הפלט של git הוא קלט חיצוני (U3): "lots" אינו אפס, והוא אינו עובר את התקרה בשקט.""" + svc, _ = _bare_mirror(tmp_path, {"a.txt": b"x" * 3000}) + + def garble_the_probe(cmd): + if "cat-file" in cmd: + return subprocess.CompletedProcess(cmd, 0, stdout=b"lots\n", stderr=b"") + return None + + calls = _spy_on_git(monkeypatch, override=garble_the_probe) + out = svc.get_file_at_commit("probe", "a.txt", "HEAD", max_size=10) + + assert out["error"] == "git_error" + assert "show" not in _subcommands(calls) + + +def test_what_comes_back_never_exceeds_the_cap_whatever_the_probe_said(tmp_path, monkeypatch): + """החסם השני, על מה שמוחזר: גם אם המאגר דיווח גודל קטן, מה ש-``git show`` הדפיס נבדק שוב. + + פין של תכונה שהייתה קיימת — הבדיקה בדיעבד — ונשארת בכוונה, כי לנתיב + של תיקייה ``cat-file -s`` מודד את אובייקט העץ ואילו ``git show`` מדפיס + רשימה מעוצבת. הדרך היחידה לבודד אותה היא מאגר שמשקר. + """ + svc, _ = _bare_mirror(tmp_path, {"a.txt": b"x" * 3000}) + + def understate_the_probe(cmd): + if "cat-file" in cmd: + return subprocess.CompletedProcess(cmd, 0, stdout=b"10\n", stderr=b"") + return None + + _spy_on_git(monkeypatch, override=understate_the_probe) + out = svc.get_file_at_commit("probe", "a.txt", "HEAD", max_size=1000) + + assert out["error"] == "file_too_large" and out["size"] == 3000 diff --git a/tests/test_mcp_app_wiring.py b/tests/test_mcp_app_wiring.py new file mode 100644 index 000000000..b7d7d3b50 --- /dev/null +++ b/tests/test_mcp_app_wiring.py @@ -0,0 +1,136 @@ +"""The service entry point carries the two request limits from the environment into the app it builds. + +``limit_from_env`` is unit-tested and ``build_app`` is tested with explicit +values; the lines that connect them in ``mcp_server/app.py::create_app`` were +exercised by no test (WARN-001 in the seven-PR review). A misspelled variable +name or a dropped keyword would have shipped silently: the service would run +on the defaults while the config inspector shows the operator's value. + +The technique is the one ``tests/test_mcp_logging_visible.py`` uses: a fresh +interpreter imports ``mcp_server.app`` exactly as uvicorn does. What differs is +that ``create_app`` has to *succeed* here, so the probe puts a fake ``database`` +module on ``sys.modules`` before the import (the only place the entry point +touches the database layer is ``from database import db`` inside +``create_app``; every ``mcp_server`` module imports it lazily) and switches the +repo autosync off through its own environment flag. Both auth branches of +``create_app`` are driven — PAT, and OAuth with a strong ``SECRET_KEY`` — and a +control run without the variables shows the defaults, so the runs with them are +measuring the wiring and not a coincidence. + +The body cap is read off the installed ``BodySizeLimitMiddleware`` (Starlette +keeps ``cls``/``kwargs`` on ``app.user_middleware``); the limiter's budget is +observed through a subclass installed on ``mcp_server.server`` before the import, +because ``build_mcp`` constructs ``ToolRateLimiter`` through that module's +global name and the built app does not expose the ``FastMCP`` instance. +""" + +import json +import os +import pathlib +import subprocess +import sys +import textwrap + +import pytest + +pytest.importorskip("mcp") + +from mcp_server import handlers, limits # noqa: E402 + +REPO = str(pathlib.Path(__file__).resolve().parents[1]) +TESTS = str(pathlib.Path(__file__).resolve().parent) + +_PROBE = """ +import json, sys, types +# The repo root first, so ``config`` resolves to the real module the service +# imports and not to the stub ``tests/config.py``; ``tests/`` after it, for the fake. +sys.path.insert(0, {tests!r}) +sys.path.insert(0, {repo!r}) +from _fake_mongo import FakeDB + +# ``from database import db as db_manager`` inside create_app, then +# ``resolve_mongo(db_manager)`` reads ``db_manager.db``: a manager whose ``db`` +# is the in-memory fake, and nothing else from the real package. +fake_database = types.ModuleType("database") +fake_database.db = types.SimpleNamespace(db=FakeDB()) +sys.modules["database"] = fake_database + +import mcp_server.server as server_module + +constructed = [] + + +class _Observed(server_module.ToolRateLimiter): + def __init__(self, per_minute=server_module.DEFAULT_RATE_LIMIT_PER_MINUTE): + constructed.append(per_minute) + super().__init__(per_minute) + + +server_module.ToolRateLimiter = _Observed + +import mcp_server.app as app_module +from mcp_server.limits import BodySizeLimitMiddleware + +caps = [dict(m.kwargs) for m in app_module.app.user_middleware if m.cls is BodySizeLimitMiddleware] +print("WIRING=" + json.dumps({{ + "caps": caps, + "limiters": constructed, + "middleware": [m.cls.__name__ for m in app_module.app.user_middleware], +}}), flush=True) +print("PROBE-DONE", flush=True) +""" + +_STRONG_SECRET = "wiring-test-secret-" + "x" * 48 + + +def _wiring(mode: str, **variables: str) -> dict: + """Import the entry point in a fresh interpreter and return what it wired.""" + env = dict(os.environ) + for key in ("MCP_MAX_REQUEST_BYTES", "MCP_RATE_LIMIT_PER_MINUTE", "MAX_CODE_SIZE", + "MCP_SERVER_URL", "WEBAPP_URL"): + env.pop(key, None) + env["MCP_REPO_AUTOSYNC"] = "0" + if mode == "oauth": + env["MCP_SERVER_URL"] = "https://mcp.test" + env["WEBAPP_URL"] = "https://web.test" + env["SECRET_KEY"] = _STRONG_SECRET + env.update(variables) + proc = subprocess.run( + [sys.executable, "-B", "-c", textwrap.dedent(_PROBE.format(repo=REPO, tests=TESTS))], + capture_output=True, text=True, timeout=180, cwd=REPO, env=env, + ) + assert "PROBE-DONE" in proc.stdout, f"the probe did not finish\nSTDOUT:\n{proc.stdout}\nSTDERR:\n{proc.stderr}" + line = next(ln for ln in proc.stdout.splitlines() if ln.startswith("WIRING=")) + return json.loads(line[len("WIRING="):]) + + +@pytest.mark.parametrize("mode", ["pat", "oauth"]) +def test_the_two_limits_from_the_environment_reach_the_built_app(mode): + wired = _wiring(mode, MCP_MAX_REQUEST_BYTES="131072", MCP_RATE_LIMIT_PER_MINUTE="5") + + assert wired["caps"] == [{"max_bytes": 131072}], wired + assert wired["limiters"] == [5], wired + assert "BodySizeLimitMiddleware" in wired["middleware"] + assert ("PATAuthMiddleware" in wired["middleware"]) == (mode == "pat"), wired["middleware"] + + +def test_without_the_variables_the_app_runs_on_the_documented_defaults(): + """The control: the same probe, nothing set — so the run above measured the variables.""" + wired = _wiring("pat") + + assert wired["caps"] == [{"max_bytes": limits.DEFAULT_MAX_REQUEST_BYTES}], wired + assert wired["limiters"] == [limits.DEFAULT_RATE_LIMIT_PER_MINUTE], wired + + +def test_the_body_cap_follows_the_code_size_ceiling_the_service_runs_with(): + """SUGG-022 at the entry point: ``MAX_CODE_SIZE`` raised in the environment raises the cap with it. + + ``create_app`` derives the cap from ``max_code_size()`` — the config's value, + read at startup — so the two ceilings cannot drift apart in a deployment + either; ``MCP_MAX_REQUEST_BYTES`` then only raises it further. + """ + raised = 3 * handlers.DEFAULT_MAX_CODE_SIZE + wired = _wiring("pat", MAX_CODE_SIZE=str(raised)) + + assert wired["caps"] == [{"max_bytes": limits.request_bytes_for(raised)}], wired + assert wired["caps"][0]["max_bytes"] > limits.DEFAULT_MAX_REQUEST_BYTES diff --git a/tests/test_mcp_docs_handlers.py b/tests/test_mcp_docs_handlers.py index fbe329d81..1c1086c6c 100644 --- a/tests/test_mcp_docs_handlers.py +++ b/tests/test_mcp_docs_handlers.py @@ -170,6 +170,52 @@ def test_ambiguous_section_returns_candidates_with_breadcrumb(): assert crumbs[0] != crumbs[1] # Alpha vs Beta +def test_candidates_are_capped_and_the_flag_appears_only_when_they_were_cut(monkeypatch): + """‏``candidates`` היה השדה היחיד בתשובה בלי תקרה (#3426). + + מזהה שחוזר בעמוד החזיר את **כל** המופעים — נמדד 13,030 מועמדים ו-1.39MB + על שאילתה בת שלושה תווים. עכשיו הוא חסום כמו ``toc`` ו-``suggestions``, + והדגל קיים רק כשהוא נכון, כדי שכל תשובת ``ambiguous_section`` על עמוד + רגיל תישאר זהה בית-בית (אפס-דיף). התקרה נקראת מהמודול בזמן הקריאה + ומוקטנת כאן ל-2, במקום לבנות חמישים ואחת כותרות — אותו נימוק כמו בטסט + תקרת הסקשנים. + """ + monkeypatch.setattr(docs_handlers, "_CANDIDATES_MAX", 2) + + at_the_cap = docs_handlers.docs_get_section( + _TextBackend(_identified_rst("P3 — ראשון", "P3 — שני")), path="x", section="P3") + assert at_the_cap["error"] == "ambiguous_section" + assert [c["title"] for c in at_the_cap["candidates"]] == ["P3 — ראשון", "P3 — שני"] + assert "candidates_truncated" not in at_the_cap, "עמוד שבדיוק בתקרה נענה במלואו, בלי דגל" + + over = docs_handlers.docs_get_section( + _TextBackend(_identified_rst("P3 — ראשון", "P3 — שני", "P3 — שלישי")), + path="x", section="P3") + assert over["error"] == "ambiguous_section" + assert [c["title"] for c in over["candidates"]] == ["P3 — ראשון", "P3 — שני"], "בסדר הופעה" + assert over["candidates_truncated"] is True + + +def test_the_candidates_cap_is_the_suggestions_inventory_number(): + """שני מלאים לא-מדורגים באותה תשובה — מספר אחד, כמו שני גבולות האאוטליין והפארסר.""" + assert docs_handlers._CANDIDATES_MAX == doc_sections.MAX_IDENTIFIER_SUGGESTIONS + + +def test_suggestions_on_a_markdown_page_are_a_list_of_strings_too(both_repos): + """הצרכן שנוסף ב-#3428 הוא אותו אתר קריאה לשני הפורמטים, והוא כותב ``.titles``. + + ‏#3426 ביקש שזה ייקבע בטסט ולא רק ב-docstring של ``Suggestions``: מי שישכח + את ``.titles`` ישלח ``[["K11"], false]`` — JSON תקין בלי שום שגיאה — וגם + במסלול ה-Markdown איש לא היה תופס את זה. + """ + md = "# K11. כשל\n\nטקסט\n\n# K12. דגל\n\nטקסט\n" + out = docs_handlers.docs_get_section(_TextBackend(md), path="x.md", + repo="amir-bug-patterns", section="K99") + assert out["error"] == "section_not_found" + assert out["suggestions"] == ["K11", "K12"] + assert all(isinstance(s, str) for s in out["suggestions"]) + + def test_include_subsections_false_smaller_than_true(): full = docs_handlers.docs_get_section(_FsBackend(), path="environment-variables", section="משתני סביבה - רפרנס", include_subsections=True) @@ -210,7 +256,7 @@ def test_explicit_repo_in_allowlist_allowed(monkeypatch): def test_path_outside_docs_rejected(bad_path): be = _FsBackend() out = docs_handlers.docs_get_section(be, path=bad_path) - assert out["ok"] is False and out["error"] == "missing_path" + assert out["ok"] is False and out["error"] == "path_outside_root" assert be.calls == [] # נחסם ב-handler לפני ה-backend @@ -295,25 +341,29 @@ def test_an_overlined_heading_is_one_level_deeper_than_the_same_character_underl assert out["toc"][1]["breadcrumb"] == ["כותרת א", "כותרת ב"] -def test_the_docs_reader_passes_the_shared_section_ceiling_and_refuses_above_it(monkeypatch): - """הצרכן בייצור מעביר את תקרת הסקשנים של הסורק, ומעליה מסרב — לא מפרסר את הכול. - - זה היפוך מכוון של הבדיקה שישבה כאן מ-#3378 ("הצרכן בייצור אינו מוגבל - בתקרה"). הנימוק שלה — שקובץ תיעוד גדול לא ייחסם — נמדד ונמצא ריק - בתקרת הקריאה של הכלי: 500KB של העמוד הצפוף ביותר בריפו (6.5 סקשנים - ל-KB) הם כ-3,300 סקשנים, פי 15 מתחת לתקרה. מה שהתקרה כן עוצרת הוא - הצורה העוינת — כותרת בת תו אחד בכל שורה — שבלעדיה עולה 44.0MiB לפרסור - אחד של 500KB, יותר מ-35.2MiB שמאגר הקריאות מקצה לחוט - (``_PARSE_COST_BYTES`` ב-``mcp_server/server.py``), ואיתה נעצרת - ב-20.1MiB. סקירת #3429. - - **ונבדק בלי להציף את התקרה, מאותה סיבה שנימקה הבדיקה הקודמת:** בניית - ``MAX_SYMBOLS + 1`` סקשנים אמיתיים קושרת את מחיר הבדיקה לקבוע שהיא - מגנה עליו (819KB ו-71MB בכל ריצה, ופי עשר מזה כשהתקרה תעלה). הכלי - קורא את התקרה מהמודול בזמן הקריאה, ולכן היא מוקטנת כאן ל-2, והפרסר - האמיתי הוא שמרים את החריגה — על שלושה סקשנים. ברירת המחדל של הפרסר - נשארת ``None``, וזה עדיין נבדק, כי קורא אחר שאינו מעביר תקרה חייב - לדעת שאינו מוגן. +def test_the_rst_reader_is_capped_by_the_parser_default_and_refuses_above_it(monkeypatch): + """הצרכן בייצור אינו מעביר תקרה, וברירת המחדל של הפרסר **היא** התקרה (#3420). + + הגלגול השלישי של הבדיקה שיושבת כאן. #3378: "הצרכן בייצור אינו מוגבל + בתקרה". סקירת #3429 הפכה אותה: הכלי מעביר את ``_ceiling.MAX_SYMBOLS`` + במפורש, כי ברירת המחדל של ``rst_parser`` הייתה ``None`` וכל קורא אחר + נשאר בלי הגנה. #3420 יישר את ברירת המחדל ל-``MAX_SECTIONS`` של + ``doc_sections`` — אחרי מדידה על כל 208 קובצי ה-RST (הקובץ העשיר ביותר + נושא 50 סקשנים, אחד לאלף מהתקרה) ואפס-דיף על פלט הכלי — ומאז הכלי + אינו מעביר דבר, בדיוק כמו במסלול ה-Markdown: העברה מפורשת מכאן הייתה + עותק שני של החלטה שהפארסר כבר הכריע. + + **ומה שנשמר משתי הגרסאות הקודמות:** הסירוב מעל התקרה בא כ-``error`` + ולא כחריגה שבורחת, קובץ שיושב בדיוק על התקרה עובר במלואו, ו-``"max"`` + בתשובה הוא המספר שהפרסר השתמש בו. מה שהתקרה עוצרת — כותרת בת תו אחד + בכל שורה, 44.0MiB לפרסור של 500KB בלעדיה ו-20.1MiB איתה — נמדד בסקירת + #3429 ולא זז. + + **ונבדק בלי להציף את התקרה, מאותה סיבה שנימקו שתי הקודמות:** בניית + ``MAX_SECTIONS + 1`` סקשנים אמיתיים קושרת את מחיר הבדיקה לקבוע שהיא + מגנה עליו. ברירת המחדל קפואה בחתימה, ולכן היא מוקטנת ב-``partial`` + על הפרסר עצמו — וזה עובד רק מפני שהכלי אינו מעביר ארגומנט שדורס + אותה, שזו הטענה הראשונה כאן. """ passed: dict[str, object] = {} real = rst_parser.parse_document @@ -328,14 +378,14 @@ def spy(text, **kwargs): assert out["ok"] and out["mode"] == "toc" assert out["section_count"] == 2 - assert passed.get("max_sections") == docs_handlers._ceiling.MAX_SYMBOLS, ( - f"הכלי לא העביר את תקרת הסורק לפרסור: {passed}" - ) - assert inspect.signature(real).parameters["max_sections"].default is None, ( - "ברירת המחדל של max_sections השתנתה — ההגנה כאן באה מהכלי, לא מהפרסר" - ) + assert passed == {}, f"הכלי העביר תקרה לפרסור: {passed}" + assert (inspect.signature(real).parameters["max_sections"].default + is doc_sections.MAX_SECTIONS), ( + "ברירת המחדל של max_sections ב-rst_parser אינה MAX_SECTIONS — " + "ההגנה במסלול הזה באה מהפרסר, וכשהיא זזה שם היא זזה גם כאן") - monkeypatch.setattr(docs_handlers._ceiling, "MAX_SYMBOLS", 2) + monkeypatch.setattr(docs_handlers.rst_parser, "parse_document", + functools.partial(real, max_sections=2)) at_the_ceiling = docs_handlers.docs_get_section(_TextBackend("א\n=\n\nב\n=\n\n"), path="x") assert at_the_ceiling["ok"] and at_the_ceiling["section_count"] == 2, ( @@ -346,9 +396,12 @@ def spy(text, **kwargs): above = docs_handlers.docs_get_section(_TextBackend(three), path="x") assert {k: above[k] for k in ("ok", "error", "max", "repo", "path", "ref", "resolved_commit")} == { - "ok": False, "error": "too_many_sections", "max": 2, + "ok": False, "error": "too_many_sections", "max": doc_sections.MAX_SECTIONS, "repo": "CodeBot", "path": "docs/x.rst", "ref": "HEAD", "resolved_commit": "c0ffee", } + # ``"max"`` הוא הקבוע ולא ה-2 של ה-``partial``: הכלי מדווח את התקרה + # שהפרסר מצהיר עליה, והטסט הוא שהנמיך אותה מאחורי גבו. + # # **ואין ``line``, וזה לא חסר אלא נכון.** ``rst_parser`` מרים # ``TooManySections`` בלי ארגומנט (``raise TooManySections``, נמדד), בעוד # ``md_parser`` מרים אותה עם השורה. ``_line_of`` מוסיף את השדה רק כשיש @@ -578,7 +631,7 @@ def test_the_root_anchor_is_decided_before_normalisation(): """ be = _RecordingBackend() out = docs_handlers.docs_get_section(be, path="docs/../secrets", repo="CodeBot") - assert out == {"ok": False, "error": "missing_path"} + assert out == {"ok": False, "error": "path_outside_root", "repo": "CodeBot", "root": "docs"} assert be.kwargs == [] @@ -591,7 +644,7 @@ def test_traversal_out_of_the_repo_is_refused_even_at_the_repo_root(both_repos, """שורש ריק אינו "הכול" — הוא "כל דבר **בתוך** הריפו".""" be = _RecordingBackend() out = docs_handlers.docs_get_section(be, path=bad_path, repo="amir-bug-patterns") - assert out == {"ok": False, "error": "missing_path"} + assert out == {"ok": False, "error": "path_outside_root", "repo": "amir-bug-patterns", "root": ""} assert be.kwargs == [] @@ -630,11 +683,11 @@ def test_a_root_is_matched_as_a_path_unit_and_not_as_a_prefix(monkeypatch): כזה. ``norm.startswith("docs")`` בלי המפריד מקבל אותו. """ monkeypatch.setenv("MCP_DOCS_REPO", "CodeBot,synthetic") - monkeypatch.setitem(docs_handlers.DOCS_PATH_POLICY, "synthetic", + monkeypatch.setitem(docs_handlers._DOCS_PATH_POLICY_TABLE, "synthetic", docs_handlers._DocsPathPolicy(root="docs", suffix=".rst")) be = _RecordingBackend() out = docs_handlers.docs_get_section(be, path="docsecret/x", repo="synthetic") - assert out == {"ok": False, "error": "missing_path"} + assert out == {"ok": False, "error": "path_outside_root", "repo": "synthetic", "root": "docs"} assert be.kwargs == [] @@ -724,11 +777,11 @@ def test_the_parser_table_holds_modules_so_a_monkeypatch_on_the_module_is_seen( monkeypatch): """הטבלה מחזיקה **מודולים**, ולכן החלפת ``parse_document`` עליהם נתפסת. - **וזה מה ש-``test_the_docs_reader_passes_the_shared_section_ceiling_and_refuses_above_it`` אינו + **וזה מה ש-``test_the_rst_reader_is_capped_by_the_parser_default_and_refuses_above_it`` אינו תופס.** אילו הטבלה הייתה מחזיקה את הפונקציה עצמה, היא הייתה קופאת על המקורית, ה-spy היה נעקף **בשקט**, ו-``passed`` שם היה נשאר ריק — - כלומר ``passed.get("max_sections") is None`` היה ממשיך לעבור. שומר - שעובר משתי סיבות שונות אינו שומר. + כלומר ``passed == {}`` היה ממשיך לעבור. שומר שעובר משתי סיבות שונות + אינו שומר. """ calls = [] real = rst_parser.parse_document @@ -767,7 +820,7 @@ def test_a_markdown_file_over_the_heading_ceiling_is_refused_by_name(both_repos, של ``MAX_SECTIONS + 1`` כותרות אמיתיות הוא מאות מגה-בייט בכל ריצת CI, והמחיר שלו צמוד לקבוע שהוא מגן עליו — כלומר הוא מתדרדר בדיוק כשהמערכת גדלה. אותו נימוק בדיוק כתוב ב- - ``test_the_docs_reader_passes_the_shared_section_ceiling_and_refuses_above_it`` שמעליו. + ``test_the_rst_reader_is_capped_by_the_parser_default_and_refuses_above_it`` שמעליו. """ real = md_parser.parse_document monkeypatch.setattr(md_parser, "parse_document", @@ -785,11 +838,14 @@ def test_the_two_refusals_are_mapped_whichever_parser_raised_them(monkeypatch): התניה על ``parser is md_parser`` הייתה רשימה שנייה לסנכרן: כשהתקרה נוספה למסלול ה-RST (#3429), הסירוב היה בורח מהכלי כחריגה גולמית. - **ה-``partial`` שהיה כאן כבר אינו עובד, וזו לא תקלה של הטסט:** הכלי - מעביר עכשיו ``max_sections`` במפורש, וארגומנט מפורש בקריאה דורס את - זה שב-``partial``. לכן מקטינים את התקרה במקום שהכלי קורא אותה. + **התקרה מונמכת ב-``partial`` על הפרסר, כמו במסלול ה-Markdown.** בין + #3429 ל-#3420 זה לא עבד כאן — הכלי העביר ``max_sections`` במפורש, + וארגומנט מפורש בקריאה דורס את זה שב-``partial`` — ולכן הטסט הנמיך אז + את ``_ceiling.MAX_SYMBOLS``. מאז #3420 הכלי אינו מעביר דבר לאף פארסר, + וה-``partial`` הוא הדרך היחידה להנמיך, בשני המסלולים. """ - monkeypatch.setattr(docs_handlers._ceiling, "MAX_SYMBOLS", 1) + monkeypatch.setattr(docs_handlers.rst_parser, "parse_document", + functools.partial(rst_parser.parse_document, max_sections=1)) out = docs_handlers.docs_get_section(_TextBackend("א\n=\n\nב\n=\n"), path="x.rst", repo="CodeBot") assert out["ok"] is False and out["error"] == "too_many_sections" @@ -814,11 +870,11 @@ def test_the_reader_asks_for_the_whole_file_and_so_keeps_the_display_ceiling(bot def test_the_markdown_reader_is_capped_by_the_parser_default(both_repos, monkeypatch): """התקרה האפקטיבית של מסלול ה-Markdown היא ``MAX_SECTIONS``. - **התמונה ההפוכה של ``test_the_docs_reader_passes_the_shared_section_ceiling_and_refuses_above_it``**, + **אותה תמונה כמו ``test_the_rst_reader_is_capped_by_the_parser_default_and_refuses_above_it``**, ושתי הטענות מאותו סוג: הכלי אינו מעביר תקרה, וברירת המחדל בחתימה **היא** התקרה. העברה מפורשת של ``md_parser.MAX_SECTIONS`` מה-handler - הייתה עותק שני של אותה החלטה, ו-PR הפארסר כבר הכריע אותה ונימק - למה ברירת המחדל שם הפוכה מזו של RST. + הייתה עותק שני של אותה החלטה, ו-PR הפארסר כבר הכריע אותה — ומאז + #3420 גם מסלול ה-RST על אותה ברירת מחדל בדיוק. """ passed: dict = {} real = md_parser.parse_document @@ -852,12 +908,79 @@ def test_every_suffix_a_repo_policy_names_has_a_parser(monkeypatch): for repo, policy in docs_handlers.DOCS_PATH_POLICY.items(): assert policy.suffix in docs_handlers._PARSERS, f"{repo}: {policy.suffix}" - monkeypatch.setitem(docs_handlers.DOCS_PATH_POLICY, "broken", + monkeypatch.setitem(docs_handlers._DOCS_PATH_POLICY_TABLE, "broken", docs_handlers._DocsPathPolicy(root="", suffix=".txt")) with pytest.raises(RuntimeError, match="_PARSERS"): docs_handlers._validate_policy_tables() +@pytest.mark.parametrize("policy, message", [ + (docs_handlers._DocsPathPolicy(root="docs", suffix="RST"), "סיומת לא מנורמלת"), + (docs_handlers._DocsPathPolicy(root="docs", suffix="rst"), "סיומת לא מנורמלת"), + (docs_handlers._DocsPathPolicy(root="/docs", suffix=".rst"), "שורש לא מנורמל"), + (docs_handlers._DocsPathPolicy(root="docs/", suffix=".rst"), "שורש לא מנורמל"), + (docs_handlers._DocsPathPolicy(root="./docs", suffix=".rst"), "שורש לא מנורמל"), +]) +def test_the_validator_refuses_an_unnormalised_suffix_or_root(monkeypatch, policy, message): + """שתי בדיקות הנרמול ב-``_validate_policy_tables``, שהיו בלי טסט (#3432, SUGG-009). + + כל אחת מהצורות כאן הייתה עוברת בשקט את ``_suffix_of``/``_is_under`` ומחזירה + ``suffix_not_allowed`` או ``path_outside_root`` על כל קריאה — הכלי מת בלי + שאיש ידע למה. הוולידטור הוא מה שהופך הקלדה בקבוע לשרת שלא עולה. + """ + monkeypatch.setitem(docs_handlers._DOCS_PATH_POLICY_TABLE, "bad", policy) + with pytest.raises(RuntimeError, match=message): + docs_handlers._validate_policy_tables() + + +def test_the_policy_tables_cannot_be_changed_after_import_but_the_seam_can(monkeypatch): + """הוולידציה בייבוא היא הבטחה רק אם הטבלאות אינן משתנות אחריה (#3432, SUGG-021). + + ``MappingProxyType`` דוחה כתיבה ישירה, והטבלה שמתחת נשארת התפר לטסטים — + שינוי בה נראה דרך הפרוקסי מיד, ו-``monkeypatch`` מחזיר אותו בסוף. + """ + policy = docs_handlers._DocsPathPolicy(root="", suffix=".md") + with pytest.raises(TypeError): + docs_handlers.DOCS_PATH_POLICY["seam"] = policy # type: ignore[index] + with pytest.raises(TypeError): + docs_handlers._PARSERS[".txt"] = docs_handlers.md_parser # type: ignore[index] + + monkeypatch.setitem(docs_handlers._DOCS_PATH_POLICY_TABLE, "seam", policy) + assert docs_handlers.DOCS_PATH_POLICY["seam"] is policy + + +def test_a_repo_without_a_policy_is_logged_for_the_operator(monkeypatch, caplog): + """התשובה מגיעה לסוכן; השורה מגיעה למפעיל — ומחיקתה הייתה משאירה הכול ירוק (#3432, SUGG-010).""" + import logging as _logging + + monkeypatch.setenv("MCP_DOCS_REPO", "CodeBot,ghost-repo") + with caplog.at_level(_logging.WARNING, logger="mcp_server.docs_handlers"): + out = docs_handlers.docs_get_section(_RecordingBackend(), path="anything", repo="ghost-repo") + assert out["error"] == "repo_not_configured" + warnings = [r.getMessage() for r in caplog.records if r.levelno == _logging.WARNING] + assert any("ghost-repo" in m and "MCP_DOCS_REPO" in m for m in warnings), warnings + + +@pytest.mark.parametrize("path", ["", " ", "a\x00b"]) +def test_no_usable_path_is_still_missing_path(path): + """‏``missing_path`` נשאר לדחייה שהוא נבנה בשבילה — אין נתיב — וזו כבר אינה גם "יצאת מהשורש" (#3432, SUGG-013).""" + be = _RecordingBackend() + out = docs_handlers.docs_get_section(be, path=path, repo="CodeBot") + assert out == {"ok": False, "error": "missing_path"} + assert be.kwargs == [] + + +def test_a_host_without_the_mirror_is_passed_through_as_repo_not_mirrored_with_context(): + """‏``repo_not_mirrored`` מהמראה עובר לקורא עם הריפו והנתיב — לא כ-``not_found`` (#3432, SUGG-011).""" + + class _NoMirror: + def get_file(self, **kwargs): + return {"ok": False, "error": "repo_not_mirrored"} + + out = docs_handlers.docs_get_section(_NoMirror(), path="mcp-server", repo="CodeBot") + assert out == {"ok": False, "error": "repo_not_mirrored", "repo": "CodeBot", "path": "docs/mcp-server.rst"} + + def test_the_default_repo_has_a_path_policy(): """הריפו שמוגש כשאיש לא נקב בריפו חייב להיות כזה שהכלי יודע לקרוא. @@ -1088,14 +1211,17 @@ def _boom(text, **k): def test_the_two_section_ceilings_are_the_same_number(): - """``_ceiling.MAX_SYMBOLS`` ו-``md_parser.MAX_SECTIONS`` הם אותו מספר. - - RST מקבל את התקרה מהכלי (``_ceiling``), Markdown מברירת המחדל של - הפרסר שלו (``MAX_SECTIONS``). שני מודולים, מספר אחד, בלי ייבוא ביניהם - — זה ``duplicate-rule-second-copy`` §2 בצורתו המדויקת, והתיקון שהכלל - מבקש כשהכפילות מוצדקת בגבול מודול הוא **טסט שקורא את שני המקורות - ומשווה**. שני דברים נשענים על השוויון הזה: התיעוד שאומר "שני המסלולים - מוגבלים ל-50,000", ו-``"max"`` בסירוב, שמדווח את ``_ceiling`` גם על - קובץ Markdown. + """``_ceiling.MAX_SYMBOLS`` ו-``doc_sections.MAX_SECTIONS`` הם אותו מספר. + + שני הפארסרים מקבלים את התקרה מברירת המחדל שלהם — ``MAX_SECTIONS`` של + ``doc_sections``, אותו אובייקט בדיוק דרך שני הייצואים-מחדש — וסורק + האאוטליין מ-``_ceiling.MAX_SYMBOLS``. שני מודולים, מספר אחד, בלי ייבוא + ביניהם — זה ``duplicate-rule-second-copy`` §2 בצורתו המדויקת, והתיקון + שהכלל מבקש כשהכפילות מוצדקת בגבול מודול הוא **טסט שקורא את שני + המקורות ומשווה**. מה שנשען על השוויון: התיעוד שאומר "50,000 בשני + המסלולים ובסורק", והמדידות ב-``_ceiling`` שקבעו את המספר לשניהם. """ - assert docs_handlers._ceiling.MAX_SYMBOLS == md_parser.MAX_SECTIONS + from mcp_server.outline_scanners import _ceiling + + assert rst_parser.MAX_SECTIONS is md_parser.MAX_SECTIONS is doc_sections.MAX_SECTIONS + assert _ceiling.MAX_SYMBOLS == doc_sections.MAX_SECTIONS diff --git a/tests/test_mcp_limits.py b/tests/test_mcp_limits.py new file mode 100644 index 000000000..2e580a9f0 --- /dev/null +++ b/tests/test_mcp_limits.py @@ -0,0 +1,874 @@ +"""גבולות הבקשה של שרת ה-MCP (#3431): תקרת גוף במידלוור, והגבלת קצב לפי זהות בנקודת השיגור. + +שני הגבולות נבדקים בשכבה שבה הם חיים — המידלוור מול הודעות ASGI ממש, והמגביל +דרך ``call_tool`` של ה-``FastMCP`` האמיתי — ואז על האפליקציה המלאה דרך +``TestClient``, כי הפטור לנתיבי הדופק והסדר מול האימות הם תכונות של ההרכבה +ולא של אף רכיב לבדו. בלי ``sleep``: התקרה, החלון והדדליין מוקטנים במקום +להמתין להם. + +**מה המידלוור קורא, ומה לא (SEC-001 בסקירת שבעת ה-PRים).** הגרסה הראשונה קראה +כל גוף עד התקרה לפני שהשכבה הבאה ראתה את הבקשה — גם גוף אנונימי שהאימות היה +דוחה מהכותרות בלבד, ובלי דדליין. הטסטים כאן מקבעים את שלושת החלקים של התיקון: +אורך מוצהר תקין עובר בלי קריאה (השרת תוחם), הלולאה שנשארה לגוף chunked נסגרת +בדדליין, ורק מתודות שנושאות גוף נבדקות בכלל. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import importlib.util +import json +import logging +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +pytest.importorskip("mcp") + +from mcp import types as mcp_types # noqa: E402 +from mcp.server.fastmcp import Context # noqa: E402 +from mcp.server.lowlevel.server import request_ctx # noqa: E402 +from mcp.shared.context import RequestContext # noqa: E402 +from starlette.requests import ClientDisconnect, Request # noqa: E402 +from starlette.responses import PlainTextResponse # noqa: E402 +from starlette.testclient import TestClient # noqa: E402 + +import mcp_server.server as srv # noqa: E402 +from mcp_server import handlers, limits # noqa: E402 +from mcp_server.limits import BodySizeLimitMiddleware, ToolRateLimiter, limit_from_env # noqa: E402 +from mcp_server.server import _READ_ONLY_TOOL, build_app, build_mcp # noqa: E402 +from rate_limiter import RateLimiter # noqa: E402 + +try: # local `tests` pkg can be shadowed by an unrelated top-level `tests` on sys.path + from tests._fake_mongo import FakeDB # noqa: E402 +except ImportError: # fall back to the sibling module (tests/ is on sys.path under pytest) + from _fake_mongo import FakeDB # noqa: E402 + +_REPO = Path(__file__).resolve().parent.parent + + +class _Backend: + """המשטח הקטן ביותר שהכלי הנבדק נוגע בו: ``get_file`` שמחזיר "לא נמצא".""" + + def get_file(self, *args, **kwargs): + return None + + +class _Store: + def verify(self, token): + return {"user_id": 7, "scopes": ["read"]} if token == "good" else None + + +# --------------------------------------------------------------------------- +# המידלוור, מול הודעות ASGI ממש +# --------------------------------------------------------------------------- + + +def _scope(path="/mcp", method="POST", headers=()): + # ``method`` is uppercased by the server ("The HTTP method name, uppercased" — + # ASGI HTTP connection scope), and the middleware compares it as given. + return { + "type": "http", + "http_version": "1.1", + "method": method, + "path": path, + "raw_path": path.encode(), + "root_path": "", + "headers": [(k.encode(), v.encode()) for k, v in headers], + "query_string": b"", + "server": ("test", 80), + "client": ("127.0.0.1", 1), + "scheme": "http", + } + + +def _mcp_post(headers=()): + """‏``POST /mcp`` כפי שלקוח MCP שולח אותו: JSON, ומקבל JSON או SSE.""" + return _scope(headers=[("content-type", "application/json"), + ("accept", "application/json, text/event-stream"), *headers]) + + +def _chunks(sizes, byte=b"x"): + sizes = list(sizes) + return [{"type": "http.request", "body": byte * n, "more_body": i < len(sizes) - 1} + for i, n in enumerate(sizes)] + + +class _Echo: + """אפליקציה שקוראת את הגוף כמו הטרנספורט — דרך ``Request.body()`` — ורושמת מה ראתה.""" + + def __init__(self): + self.calls = 0 + self.seen = None + + async def __call__(self, scope, receive, send): + self.calls += 1 + self.seen = await Request(scope, receive).body() + await PlainTextResponse("ok")(scope, receive, send) + + +async def _drive(app, scope, messages): + """מריץ את האפליקציה על רצף הודעות נתון; ``receive`` שנקרא מעבר לרצף מפיל את הטסט.""" + queue = list(messages) + sent = [] + + async def receive(): + assert queue, "receive() was called after the last message" + return queue.pop(0) + + async def send(message): + sent.append(message) + + await app(scope, receive, send) + return sent + + +async def _drive_traced(app, scope, messages, mark=lambda: None): + """כמו ``_drive``, ורושם לכל קריאת ``receive()`` כמה הודעות כבר נשלחו וערך סימון מהטסט. + + ``(0, ...)`` ברשומה פירושו קריאה **לפני** שהתשובה התחילה; ``mark()`` נותן + לטסט לרשום מה שהוא רוצה — למשל אם האפליקציה כבר נכנסה. + """ + queue = list(messages) + sent = [] + trace = [] + + async def receive(): + assert queue, "receive() was called after the last message" + trace.append((len(sent), mark())) + return queue.pop(0) + + async def send(message): + sent.append(message) + + await app(scope, receive, send) + return sent, trace + + +def _status(sent): + return next(m["status"] for m in sent if m["type"] == "http.response.start") + + +def _headers(sent): + start = next(m for m in sent if m["type"] == "http.response.start") + return {k.lower(): v for k, v in start.get("headers", [])} + + +def _json_body(sent): + return json.loads(b"".join(m.get("body", b"") for m in sent if m["type"] == "http.response.body")) + + +async def test_a_declared_length_over_the_cap_is_refused_before_a_byte_is_read(): + echo = _Echo() + mw = BodySizeLimitMiddleware(echo, max_bytes=100) + + sent = await _drive(mw, _scope(headers=[("content-length", "101")]), messages=[]) + + assert _status(sent) == 413 + assert _json_body(sent) == {"error": "body_too_large", "max_bytes": 100, "content_length": 101} + assert echo.calls == 0 + assert _headers(sent)[b"connection"] == b"close", "the body was refused unread; the connection is not kept" + + +async def test_a_valid_declared_length_under_the_cap_is_handed_on_without_a_read(): + """SEC-001, חלק 1: ``Content-Length`` תקין ובלי ``Transfer-Encoding`` — המידלוור אינו קורא בית. + + השרת תוחם את הגוף לאורך המוצהר: מול uvicorn 0.38.0 (h11) אמיתי, 20 בתים + מאחורי ``Content-Length: 10`` הגיעו לאפליקציה כ-10. לכן אין כאן מה לספור, + ומי שעונה מהכותרות בלבד — ה-401 של האימות — עונה בלי שנקרא דבר. לפני + התיקון הקריאה הראשונה קרתה לפני שהאפליקציה נכנסה (``echo.calls == 0`` + בזמן הקריאה); עכשיו הקריאה היחידה היא של האפליקציה עצמה. + """ + echo = _Echo() + mw = BodySizeLimitMiddleware(echo, max_bytes=100) + + sent, trace = await _drive_traced(mw, _scope(headers=[("content-length", "50")]), + _chunks([50]), mark=lambda: echo.calls) + + assert _status(sent) == 200 and echo.seen == b"x" * 50 + assert [entered for _, entered in trace] == [1], "the only read belongs to the app, after it was entered" + + +async def test_a_declared_length_beside_transfer_encoding_is_not_trusted(): + """‏``Content-Length`` לצד ``Transfer-Encoding: chunked`` — סופרים, כמו בלי כותרת. + + h11 תוחם לפי chunked ומעביר את **שתי** הכותרות ל-scope (נמדד מול uvicorn + 0.38.0: 200 בתים מאחורי ``Content-Length: 5`` הגיעו במלואם). זו הכותרת + המכזבת היחידה שמסוגלת להעביר יותר ממה שהצהירה, ולכן המסלול המהיר דורש + שאין ``Transfer-Encoding``. הטסט עובר גם על הקוד שלפני התיקון; הוא מפיל + מסלול מהיר שמסתכל על ``Content-Length`` בלבד. + """ + echo = _Echo() + mw = BodySizeLimitMiddleware(echo, max_bytes=100) + + sent = await _drive(mw, _scope(headers=[("content-length", "5"), ("transfer-encoding", "chunked")]), + _chunks([60, 60])) + + assert _status(sent) == 413 + assert _json_body(sent) == {"error": "body_too_large", "max_bytes": 100, "received_bytes": 120} + assert echo.calls == 0 + + +async def test_a_declared_length_that_is_not_ascii_digits_is_not_a_length(): + """‏``Content-Length: ²`` — ``str.isdigit()`` מקבל ספרות-על ו-``int()`` נופל עליהן (U3). + + h11 דוחה כותרת כזאת ב-400 לפני ה-ASGI (``[0-9]+`` בלבד), ולכן מול uvicorn + זה לא מגיע לכאן; מול שרת אחר, או ב-``TestClient``, זה היה 500. ערך שאינו + ספרות ASCII אינו אורך מוצהר: סופרים את מה שבאמת מגיע. + """ + echo = _Echo() + mw = BodySizeLimitMiddleware(echo, max_bytes=100) + scope = _scope() + # latin-1 on the wire, as ``Headers`` decodes it: ``b"\xb2"`` reads back as ``²``. + scope["headers"] = [(b"content-length", b"\xb2")] + + sent = await _drive(mw, scope, _chunks([40])) + + assert _status(sent) == 200 and echo.seen == b"x" * 40 + + +async def test_a_streamed_body_over_the_cap_is_refused_and_the_app_never_runs(): + """בלי ``Content-Length`` (גוף chunked) — הספירה של מה שבאמת מגיע היא שעוצרת. + + **הטענה השתנתה ב-SEC-001, בכוונה.** עד אז הטסט שלח ``Content-Length: 90`` + עם 120 בתים, ו"כותרת מכזבת" נתפסה בספירה. אורך מוצהר תקין (בלי + ``Transfer-Encoding``) עובר עכשיו בלי קריאה, כי השרת תוחם את הגוף לאורך + המוצהר ואי אפשר להעביר דרכו יותר (נמדד מול uvicorn/h11 בטסט המסלול + המהיר). הכותרת המכזבת היחידה שמסוגלת להעביר יותר היא זו שלצד + ``Transfer-Encoding``, ויש לה טסט משלה. מה שנשאר לספירה הוא גוף בלי אורך + מוצהר — וזה מה שכאן. + """ + echo = _Echo() + mw = BodySizeLimitMiddleware(echo, max_bytes=100) + + sent = await _drive(mw, _scope(), _chunks([60, 60])) + + assert _status(sent) == 413 + assert _json_body(sent) == {"error": "body_too_large", "max_bytes": 100, "received_bytes": 120} + assert echo.calls == 0 + + +async def test_a_body_that_stops_arriving_is_refused_at_the_deadline_with_what_was_read(): + """SEC-001, חלק 2: גוף chunked אנונימי שנעצר באמצע נסגר ב-408, ולא מוחזק לנצח. + + ל-uvicorn 0.38.0 אין timeout לגוף בקשה (``timeout_keep_alive`` חל אחרי + תשובה), ולכן בלי דדליין ההמתנה כאן אינה חסומה — וזה המקרה שנשאר אחרי + המסלול המהיר: לקוח בלי ``Content-Length``, לפני האימות. הסירוב נוקב + בסיבתו ובכמה נקרא, ונושא ``Connection: close`` — "since 408 implies that + the server has decided to close the connection rather than continue + waiting" (MDN, ‏``408 Request Timeout``). ``asyncio.wait_for`` הוא השומר של + הטסט עצמו: על הקוד שלפני התיקון הוא זה שנופל. + """ + echo = _Echo() + mw = BodySizeLimitMiddleware(echo, max_bytes=100, read_timeout=0.05) + queue = _chunks([40, 40]) # the second chunk never arrives + sent = [] + + async def receive(): + if len(queue) == 2: + return queue.pop(0) + await asyncio.Event().wait() + + async def send(message): + sent.append(message) + + await asyncio.wait_for(mw(_scope(), receive, send), timeout=5) + + assert _status(sent) == 408 + assert _json_body(sent) == {"error": "body_read_timeout", "read_timeout_seconds": 0.05, "received_bytes": 40} + assert _headers(sent)[b"connection"] == b"close" + assert echo.calls == 0 + + +async def test_the_deadline_bounds_the_whole_body_and_not_each_chunk(): + """דדליין על הלולאה כולה: לקוח ששולח בית כל 29 שניות היה עובר דדליין-לקריאה. + + כאן כל נתח מגיע הרבה לפני הדדליין, ויש יותר נתחים ממה שהדדליין מכיל — + מימוש שמודד כל קריאה בנפרד היה ממשיך לקרוא עד התקרה. + """ + mw = BodySizeLimitMiddleware(_Echo(), max_bytes=1000, read_timeout=0.1) + sent = [] + + async def receive(): + await asyncio.sleep(0.03) + return {"type": "http.request", "body": b"x", "more_body": True} + + async def send(message): + sent.append(message) + + await asyncio.wait_for(mw(_scope(), receive, send), timeout=5) + + assert _status(sent) == 408 + assert _json_body(sent)["received_bytes"] >= 2, "more than one chunk was read, each within the deadline" + + +@pytest.mark.parametrize("method", ["GET", "HEAD", "OPTIONS", "DELETE"]) +async def test_a_method_that_carries_no_body_is_neither_capped_nor_read(method): + """SEC-001, חלק 3 (WARN-002): רק מתודות שנושאות גוף נבדקות. + + ‏``GET /healthz`` עם ``Content-Length: 99999999`` מזויף ענה 413 — נמדד מול + uvicorn אמיתי — בניגוד ל"פטור מבני" שהתיעוד הצהיר. שום route כאן אינו + קורא גוף במתודה אחרת: הטרנספורט של ה-SDK קורא ב-``POST``, ו-DCR, token + ו-consent הם ``POST``. התקרה היא לכן תכונה של המתודות שנושאות גוף. + """ + echo = _Echo() + mw = BodySizeLimitMiddleware(echo, max_bytes=100) + + sent = await _drive(mw, _scope(method=method, headers=[("content-length", "99999999")]), _chunks([0])) + + assert _status(sent) == 200 + assert echo.calls == 1, "handed straight to the app; the empty body read is the app's own" + + +@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH"]) +async def test_a_method_that_carries_a_body_is_capped(method): + sent = await _drive(BodySizeLimitMiddleware(_Echo(), max_bytes=100), + _scope(method=method, headers=[("content-length", "101")]), []) + assert _status(sent) == 413 + + +async def test_a_body_under_the_cap_reaches_the_app_intact(): + echo = _Echo() + mw = BodySizeLimitMiddleware(echo, max_bytes=100) + chunks = [ + {"type": "http.request", "body": b"a" * 40, "more_body": True}, + {"type": "http.request", "body": b"b" * 50, "more_body": False}, + ] + + sent = await _drive(mw, _scope(), chunks) + + assert _status(sent) == 200 + assert echo.calls == 1 and echo.seen == b"a" * 40 + b"b" * 50 + + +async def test_exactly_the_cap_passes_and_one_more_byte_does_not(): + at_cap = _Echo() + await _drive(BodySizeLimitMiddleware(at_cap, max_bytes=5), _scope(), + [{"type": "http.request", "body": b"12345", "more_body": False}]) + assert at_cap.seen == b"12345" + + over = _Echo() + sent = await _drive(BodySizeLimitMiddleware(over, max_bytes=5), _scope(), + [{"type": "http.request", "body": b"123456", "more_body": False}]) + assert _status(sent) == 413 and over.calls == 0 + + +async def test_a_disconnect_is_handed_on_to_the_app_unchanged(): + seen = {} + + async def app(scope, receive, send): + with pytest.raises(ClientDisconnect): + await Request(scope, receive).body() + seen["disconnected"] = True + + await _drive(BodySizeLimitMiddleware(app, max_bytes=100), _scope(), [{"type": "http.disconnect"}]) + assert seen == {"disconnected": True} + + +async def test_a_non_http_scope_passes_straight_through(): + seen = {} + + async def app(scope, receive, send): + seen["type"] = scope["type"] + + await BodySizeLimitMiddleware(app, max_bytes=1)({"type": "lifespan"}, None, None) + assert seen == {"type": "lifespan"} + + +# --------------------------------------------------------------------------- +# האפליקציה המלאה: הסדר מול האימות בשני המצבים, והפטור לנתיב הדופק +# --------------------------------------------------------------------------- + + +def test_on_the_real_app_the_401_comes_before_the_413_and_the_413_names_its_reason(): + app = build_app(_Backend(), _Store(), max_request_bytes=1000, rate_limit_per_minute=1) + big = b"{" + b" " * 2000 + b"}" + + with TestClient(app) as client: + anonymous = client.post("/mcp", content=big, headers={"content-type": "application/json"}) + assert anonymous.status_code == 401 + + refused = client.post( + "/mcp", content=big, + headers={"content-type": "application/json", "authorization": "Bearer good"}, + ) + assert refused.status_code == 413 + assert refused.json() == {"error": "body_too_large", "max_bytes": 1000, "content_length": len(big)} + + small = client.post( + "/mcp", content=b"{}", + headers={"content-type": "application/json", "authorization": "Bearer good"}, + ) + assert small.status_code not in (413, 500), "a body under the cap must reach the transport" + + +def _oauth_app(backend, **kwargs): + """האפליקציה במצב OAuth — הייצור — כפי ש-``tests/test_mcp_oauth_e2e.py`` בונה אותה.""" + from pydantic import AnyHttpUrl + + from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions + from mcp_server.oauth_provider import CodeKeeperOAuthProvider + from mcp_server.oauth_routes import oauth_consent_routes + from mcp_server.oauth_store import OAuthStore + + base = "https://mcp.test" + store = OAuthStore(FakeDB()) + provider = CodeKeeperOAuthProvider( + store=store, pat_verify=lambda t: None, + identify_url=f"{base}/fake-identify", consent_url=f"{base}/oauth/consent", + ) + settings = AuthSettings( + issuer_url=AnyHttpUrl(base), resource_server_url=AnyHttpUrl(base), + client_registration_options=ClientRegistrationOptions( + enabled=True, valid_scopes=["read", "write"], default_scopes=["read"]), + revocation_options=RevocationOptions(enabled=True), required_scopes=[], + ) + return build_app(backend, auth_provider=provider, auth_settings=settings, + consent_routes=oauth_consent_routes(store, "e2e-secret"), **kwargs) + + +async def test_in_oauth_mode_an_anonymous_post_is_a_401_before_a_byte_of_its_body_is_read(): + """טסט סדר-ההתקנה למצב OAuth, אח של הטסט במצב PAT שמעליו. + + במצב OAuth התקרה היא השכבה החיצונית ברמת האפליקציה, וה-``RequireAuthMiddleware`` + של ה-SDK יושב בתוך ה-mount של ``/mcp`` ועונה 401 מהכותרות בלבד + (``mcp/server/auth/middleware/bearer_auth.py``, mcp 1.28.1). לפני SEC-001 + התקרה קראה את הגוף האנונימי כולו לפני ה-401: נמדדו 5 קריאות ``receive()`` + לפני התשובה על גוף של חמישה נתחים, גם עם ``Content-Length`` מוצהר. עכשיו: + אורך מוצהר תקין — אפס קריאות; אורך מוצהר מעל התקרה — 413 בלי קריאה; + וגוף chunked אנונימי הוא המקרה שנשאר — נספר עד התקרה, תחת הדדליין, + והאפליקציה אינה נכנסת. + """ + app = _oauth_app(_Backend(), max_request_bytes=limits.MIN_MAX_REQUEST_BYTES) + body = _chunks([1000] * 5) + + sent, trace = await _drive_traced(app, _mcp_post([("content-length", "5000")]), body) + assert _status(sent) == 401 + assert trace == [], "declared under the cap: the SDK's auth answered before any read" + + sent, trace = await _drive_traced(app, _mcp_post([("content-length", str(2 * limits.MIN_MAX_REQUEST_BYTES))]), body) + assert _status(sent) == 413 and trace == [] + + over = _chunks([limits.MIN_MAX_REQUEST_BYTES // 2 + 1] * 3) + sent, trace = await _drive_traced(app, _mcp_post(), over) + assert _status(sent) == 413 + assert [n for n, _ in trace] == [0, 0], "chunked and anonymous: read up to the cap, refused, the app never ran" + + +def test_health_is_never_counted_or_capped(): + """הפטור של ``blanket-policy-silent-block`` §7: ``/healthz`` אינו קריאת כלי, ו-``GET`` אינו נבדק.""" + app = build_app(_Backend(), _Store(), max_request_bytes=limits.MIN_MAX_REQUEST_BYTES, rate_limit_per_minute=1) + with TestClient(app) as client: + statuses = {client.get("/healthz").status_code for _ in range(100)} + assert statuses == {200} + + +@pytest.mark.parametrize("mode", ["pat", "oauth"]) +async def test_a_spoofed_length_on_the_health_check_is_still_a_200_in_both_modes(mode): + """WARN-002 על האפליקציה האמיתית: ``GET /healthz`` עם ``Content-Length`` מעל התקרה ענה 413. + + כל לקוח אנונימי יכול היה לעשות זאת לבקשה שלו, בניגוד למה שהתיעוד הצהיר. + עכשיו ``GET`` אינו נבדק כלל, בשני מצבי האימות. + """ + if mode == "pat": + app = build_app(_Backend(), _Store(), max_request_bytes=limits.MIN_MAX_REQUEST_BYTES) + else: + app = _oauth_app(_Backend(), max_request_bytes=limits.MIN_MAX_REQUEST_BYTES) + + sent = await _drive(app, _scope(path="/healthz", method="GET", headers=[("content-length", "99999999")]), []) + + assert _status(sent) == 200 + + +# --------------------------------------------------------------------------- +# המגביל, דרך call_tool של ה-FastMCP האמיתי +# --------------------------------------------------------------------------- + + +@contextlib.contextmanager +def _inside_a_request(): + """מה שה-SDK עושה לכל ``tools/call``: ``request_ctx`` נקבע לפני שהמטפל נקרא. + + ``Server.request_context`` הוא ``request_ctx.get()`` (``mcp/server/lowlevel/server.py``, + ‏mcp 1.28.1), ומחוץ לבקשה הוא מרים ``LookupError`` — וזה בדיוק הסימן שהמגביל + קורא כדי לדעת שיש מישהו לחייב. ``RequestContext`` נבנה עם ארבעת שדות החובה + שלו (``mcp/shared/context.py``); סשן אינו נדרש, כי איש כאן אינו שולח הודעות. + """ + token = request_ctx.set(RequestContext(request_id=1, meta=None, session=None, lifespan_context=None)) + try: + yield + finally: + request_ctx.reset(token) + + +async def _call(mcp, name="codekeeper_get_file", arguments=None, *, inside_request=True): + with _inside_a_request() if inside_request else contextlib.nullcontext(): + result = await mcp.call_tool(name, arguments if arguments is not None else {"file_name": "x"}) + # A tool's own dict answer comes back as content blocks; a refusal comes back + # as a whole ``CallToolResult`` (see ``AdminAwareFastMCP.call_tool``). Both + # carry one text block with the JSON the client reads. + content = result.content if isinstance(result, mcp_types.CallToolResult) else result + (block,) = content + return json.loads(block.text) + + +async def test_a_call_over_the_per_minute_budget_is_refused_with_the_reason_and_the_wait(monkeypatch): + monkeypatch.setattr(srv, "current_user_id", lambda ctx=None: 4242) + mcp = build_mcp(_Backend(), rate_limit_per_minute=2) + + first, second, third = await _call(mcp), await _call(mcp), await _call(mcp) + + assert first == {"found": False} and second == {"found": False} + assert third["ok"] is False and third["error"] == "rate_limited" + assert third["limit_per_minute"] == 2 + assert 0 < third["retry_after_seconds"] <= 60 + + +async def test_each_identity_has_its_own_budget(monkeypatch): + identity = {"id": 1} + monkeypatch.setattr(srv, "current_user_id", lambda ctx=None: identity["id"]) + mcp = build_mcp(_Backend(), rate_limit_per_minute=1) + + assert await _call(mcp) == {"found": False} + assert (await _call(mcp))["error"] == "rate_limited" + identity["id"] = 2 + assert await _call(mcp) == {"found": False}, "a second identity is not charged for the first" + + +async def test_outside_a_request_nothing_is_charged(monkeypatch): + """בלי בקשה אין לקוח לחייב: הטסטים שקוראים לכלים ישירות ממשיכים כמו היום.""" + monkeypatch.setattr(srv, "current_user_id", lambda ctx=None: 4242) + mcp = build_mcp(_Backend(), rate_limit_per_minute=1) + for _ in range(3): + assert await _call(mcp, inside_request=False) == {"found": False} + + +async def test_a_refused_call_does_not_run_the_body(monkeypatch): + monkeypatch.setattr(srv, "current_user_id", lambda ctx=None: 9) + calls = [] + + class _Counting(_Backend): + def get_file(self, *args, **kwargs): + calls.append(1) + return None + + mcp = build_mcp(_Counting(), rate_limit_per_minute=1) + await _call(mcp) + await _call(mcp) + assert len(calls) == 1, "the second call was refused before the body ran" + + +async def test_an_async_tool_is_limited_too(monkeypatch): + """הגוף האסינכרוני אינו עובר ב-``_offload_to_thread``, והמגביל חייב לעטוף גם אותו.""" + monkeypatch.setattr(srv, "current_user_id", lambda ctx=None: 3) + mcp = build_mcp(_Backend(), rate_limit_per_minute=1) + + @mcp.tool(name="probe_async", annotations=_READ_ONLY_TOOL) + async def probe_async(ctx: Context) -> dict: + return {"ok": True} + + assert await _call(mcp, "probe_async", {}) == {"ok": True} + assert (await _call(mcp, "probe_async", {}))["error"] == "rate_limited" + + +async def test_zero_switches_the_limiter_off_explicitly(monkeypatch): + monkeypatch.setattr(srv, "current_user_id", lambda ctx=None: 5) + mcp = build_mcp(_Backend(), rate_limit_per_minute=0) + for _ in range(80): + assert await _call(mcp) == {"found": False} + + +async def test_without_an_identity_nothing_is_charged_and_the_body_answers(monkeypatch): + """בלי זהות (``PermissionError``) המגביל אינו מכריע — הגוף הוא שמסרב, כמו היום.""" + + def _no_identity(ctx=None): + raise PermissionError("unauthenticated") + + monkeypatch.setattr(srv, "current_user_id", _no_identity) + mcp = build_mcp(_Backend(), rate_limit_per_minute=1) + with pytest.raises(Exception, match="unauthenticated"): + await _call(mcp) + with pytest.raises(Exception, match="unauthenticated"): + await _call(mcp) + + +async def test_a_refusal_is_logged_once_per_identity_per_window(caplog): + limiter = ToolRateLimiter(per_minute=1) + with caplog.at_level(logging.WARNING, logger="mcp_server.limits"): + assert await limiter.admit(11) is None + assert (await limiter.admit(11))["error"] == "rate_limited" + assert (await limiter.admit(11))["error"] == "rate_limited" + warnings = [r for r in caplog.records if "rate limit" in r.getMessage()] + assert len(warnings) == 1 + assert "11" in warnings[0].getMessage() + + +async def test_the_once_per_window_ledger_forgets_identities_whose_window_passed(monkeypatch): + """SUGG-011: ``_warned_at`` גדל עם כל זהות שסורבה אי פעם; הוא מנוקה במעבר שכבר כותב אליו.""" + limiter = ToolRateLimiter(per_minute=1) + clock = {"now": 1000.0} + monkeypatch.setattr(limits.time, "monotonic", lambda: clock["now"]) + + for identity in (1, 2, 3): + assert await limiter.admit(identity) is None + assert (await limiter.admit(identity))["error"] == "rate_limited" + assert set(limiter._warned_at) == {1, 2, 3} + + clock["now"] += 61.0 + assert await limiter.admit(4) is None + assert (await limiter.admit(4))["error"] == "rate_limited" + assert set(limiter._warned_at) == {4}, "stamps older than a window go on the write that adds a new one" + + +async def test_the_budget_holds_under_concurrent_calls_from_one_identity(monkeypatch): + """SUGG-024, ‏``TESTING-PATTERNS`` T1(d): טסט סדרתי אינו מוכיח תכונת מקביליות. + + 25 קריאות בו-זמנית לזהות אחת עם תקציב של 2 — בדיוק 2 עוברות ו-23 נדחות, + כי ``RateLimiter`` מכריע ומוסיף תחת ``asyncio.Lock`` אחד בלי ``await`` + באמצע. הטסטים שמעל רצים ברצף ולא היו מוכיחים את זה. + """ + monkeypatch.setattr(srv, "current_user_id", lambda ctx=None: 4242) + mcp = build_mcp(_Backend(), rate_limit_per_minute=2) + + results = await asyncio.gather(*(_call(mcp) for _ in range(25))) + + assert sum(r == {"found": False} for r in results) == 2 + assert sum(r.get("error") == "rate_limited" for r in results) == 23 + + +async def test_a_limiter_passed_in_is_kept_even_when_the_object_is_falsy(monkeypatch): + """SUGG-023, ‏K12 §3: ``or`` היה מחליף מגביל כבוי בברירת המחדל ברגע שלמחלקה יש ``__len__``. + + ``ToolRateLimiter(0)`` הוא ה-kill switch המתועד. בלי ``__bool__`` ו-``__len__`` + כל מופע אמיתי היום, ולכן ``or`` עבד — עד שמישהו מוסיף ``__len__`` (למשל כמה + זהויות נספרות), והמגביל הכבוי הופך שקט ל-60 בדקה: ברירת מחדל שמרחיבה + בלי שאיש החליט. ``is None`` שואל את השאלה שבאמת נשאלת. + """ + + class _Measurable(ToolRateLimiter): + def __len__(self): + return 0 + + off = _Measurable(0) + mcp = srv.AdminAwareFastMCP("probe", tool_rate_limiter=off) + assert mcp._tool_rate_limiter is off + + monkeypatch.setattr(srv, "current_user_id", lambda ctx=None: 5) + + @mcp.tool(name="probe", annotations=_READ_ONLY_TOOL) + def probe(ctx: Context) -> dict: + return {"ok": True} + + for _ in range(80): + assert await _call(mcp, "probe", {}) == {"ok": True}, "switched off means switched off" + + +async def test_a_refusal_does_not_depend_on_the_tools_return_type(monkeypatch): + """SUGG-009: הסירוב הוא ``CallToolResult`` שלם, לא ערך שעובר ב-``convert_result`` של הכלי. + + ``convert_result`` מאמת את הערך מול ``outputSchema`` של הכלי — וכלי עם + טיפוס החזרה מוצהר (``-> list[str]``) הפך את הסירוב ל-``ValidationError`` + בדיוק כשהמגביל נדלק. מטפל ה-``tools/call`` של השרת הנמוך מחזיר + ``CallToolResult`` כמות שהוא, בלי ולידציה של הפלט (``mcp/server/lowlevel/ + server.py``, mcp 1.28.1), ולכן זו הצורה שמשרתת כל כלי — ומה שהלקוח רואה + הוא אותו בלוק טקסט של ``{"ok": false, "error": "rate_limited", ...}``. + """ + monkeypatch.setattr(srv, "current_user_id", lambda ctx=None: 8) + mcp = build_mcp(_Backend(), rate_limit_per_minute=1) + + @mcp.tool(name="probe_typed", annotations=_READ_ONLY_TOOL) + def probe_typed(ctx: Context) -> list[str]: + return ["first"] + + with _inside_a_request(): + first = await mcp.call_tool("probe_typed", {}) + assert first[1] == {"result": ["first"]}, "the typed tool's own answer: content and structured content" + + refused = await _call(mcp, "probe_typed", {}) + assert refused["error"] == "rate_limited" and refused["ok"] is False + + +async def test_a_refusal_survives_the_low_level_handlers_output_validation(monkeypatch): + """אותו סירוב דרך המטפל שה-SDK באמת מריץ על ``tools/call``, לכלי עם ``outputSchema``. + + רשימת בלוקים חשופה הייתה הופכת שם ל-``isError`` ("outputSchema defined but + no structured output returned"); ``CallToolResult`` עובר כמות שהוא. + """ + monkeypatch.setattr(srv, "current_user_id", lambda ctx=None: 12) + mcp = build_mcp(_Backend(), rate_limit_per_minute=1) + + @mcp.tool(name="probe_typed", annotations=_READ_ONLY_TOOL) + def probe_typed(ctx: Context) -> list[str]: + return ["first"] + + handler = mcp._mcp_server.request_handlers[mcp_types.CallToolRequest] + request = mcp_types.CallToolRequest( + method="tools/call", params=mcp_types.CallToolRequestParams(name="probe_typed", arguments={})) + with _inside_a_request(): + first = (await handler(request)).root + second = (await handler(request)).root + + assert isinstance(first, mcp_types.CallToolResult) and first.isError is False + assert first.structuredContent == {"result": ["first"]} + assert isinstance(second, mcp_types.CallToolResult) and second.isError is False + assert json.loads(second.content[0].text)["error"] == "rate_limited" + + +async def test_an_unknown_tool_over_budget_is_refused_before_the_name_is_looked_up(monkeypatch): + """SUGG-009: שם כלי לא מוכר מעל התקציב קיבל את שגיאת ה-SDK במקום ``rate_limited``.""" + monkeypatch.setattr(srv, "current_user_id", lambda ctx=None: 9) + mcp = build_mcp(_Backend(), rate_limit_per_minute=1) + + assert await _call(mcp) == {"found": False} + assert (await _call(mcp, "no_such_tool", {}))["error"] == "rate_limited" + + +# --------------------------------------------------------------------------- +# הגדרה ממשתני סביבה: טעות תצורה לעולם אינה מרחיבה +# --------------------------------------------------------------------------- + + +def test_limit_from_env_never_widens_on_a_bad_value(monkeypatch, caplog): + name = "MCP_LIMIT_UNDER_TEST" + monkeypatch.delenv(name, raising=False) + assert limit_from_env(name, 60, minimum=1) == 60 + + with caplog.at_level(logging.WARNING, logger="mcp_server.limits"): + monkeypatch.setenv(name, "sixty") + assert limit_from_env(name, 60, minimum=1) == 60 + monkeypatch.setenv(name, "10") + assert limit_from_env(name, 1_048_576, minimum=65_536) == 65_536 + monkeypatch.setenv(name, "0") + assert limit_from_env(name, 60, minimum=1, zero_disables=True) == 0 + assert limit_from_env(name, 60, minimum=1) == 1, "0 without zero_disables is below the minimum" + assert sum("not an integer" in r.getMessage() for r in caplog.records) == 1 + assert sum("below the minimum" in r.getMessage() for r in caplog.records) == 2 + assert sum("switched off" in r.getMessage() for r in caplog.records) == 1 + + monkeypatch.setenv(name, "120") + assert limit_from_env(name, 60, minimum=1) == 120 + + +# --------------------------------------------------------------------------- +# ההרחבה של המגביל המשותף +# --------------------------------------------------------------------------- + + +async def test_seconds_until_allowed_is_zero_with_room_and_the_rest_of_the_window_without(): + limiter = RateLimiter(max_per_minute=1) + assert await limiter.seconds_until_allowed(1) == 0.0 + assert await limiter.check_rate_limit(1) is True + wait = await limiter.seconds_until_allowed(1) + assert 0 < wait <= 60 + assert await limiter.check_rate_limit(1) is False + + +async def test_asking_about_an_identity_does_not_create_an_entry_for_it(): + """SUGG-011: ``_live_entries`` קרא ``self._requests[user_id]`` על ``defaultdict`` — ויצר רשומה קבועה למי ששאלו עליו בלבד.""" + limiter = RateLimiter(max_per_minute=1) + + assert await limiter.get_current_usage_ratio(1) == 0.0 + assert await limiter.seconds_until_allowed(1) == 0.0 + assert 1 not in limiter._requests + + assert await limiter.check_rate_limit(1) is True + assert 1 in limiter._requests, "an admitted call is what creates the entry" + + +async def test_an_identity_whose_window_emptied_is_forgotten(): + limiter = RateLimiter(max_per_minute=2) + assert await limiter.check_rate_limit(2) is True + limiter._requests[2] = [datetime.now(timezone.utc) - timedelta(seconds=120)] + + assert await limiter.get_current_usage_ratio(2) == 0.0 + assert 2 not in limiter._requests, "nothing left in the window: the key goes with it" + assert await limiter.check_rate_limit(2) is True + assert len(limiter._requests[2]) == 1 + + +# --------------------------------------------------------------------------- +# התקרה נגזרת מגודל הקובץ המותר, ולא מוקלדת לצידו +# --------------------------------------------------------------------------- + + +def _real_config_module(cwd): + """המודול ``config`` האמיתי, לפי נתיב — התקדים ב-``tests/test_redis_safety.py``. + + תחת pytest התיקייה ``tests`` בראש ``sys.path`` ו-``tests/config.py`` מאפיל + על המודול האמיתי. pydantic דורש שהמודול יהיה ב-``sys.modules`` בזמן + הגדרת המחלקה (אחרת ``BotConfig`` "is not fully defined"), ו-``BotConfig()`` + שנבנה בייבוא קורא ``.env`` יחסית לתיקיית העבודה — לכן הטעינה רצה + בתוך ``tmp_path``, ומה שנשמר ומשוחזר הוא ה-cwd ורשומת המודול. + """ + import os + import sys + + spec = importlib.util.spec_from_file_location("_real_config_for_the_cap", _REPO / "config.py") + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + saved_cwd = os.getcwd() + sys.modules[spec.name] = module + try: + os.chdir(cwd) + spec.loader.exec_module(module) + finally: + os.chdir(saved_cwd) + sys.modules.pop(spec.name, None) + return module + + +def test_the_body_cap_is_derived_from_the_code_size_ceiling(tmp_path): + """SUGG-022, ‏R6: עותק שני של כלל בלי מנגנון שתופס סחיפה הוא הצורה הפסולה. + + ‏``DEFAULT_MAX_REQUEST_BYTES`` היה מוקלד (1MiB) לצד הערה שגוזרת אותו ביד + מ-``MAX_CODE_SIZE``; ``MAX_CODE_SIZE`` שיעלה מעל כ-170,000 תווים היה מביא + שמירה לגיטימית של קובץ עברי ל-413. עכשיו התקרה נגזרת מהמספר, והגזירה + מוצמדת כאן לפי התקדים של ``test_the_two_ceilings_are_the_same_number``: + העותק ב-``handlers`` שווה לברירת המחדל של התצורה, המספר המתועד (1MiB) + הוא הגזירה על ברירת המחדל, ותקרת קוד אחרת נותנת תקרת גוף אחרת. + """ + assert handlers.DEFAULT_MAX_CODE_SIZE == _real_config_module(tmp_path).BotConfig.model_fields["MAX_CODE_SIZE"].default + assert limits.DEFAULT_MAX_REQUEST_BYTES == limits.request_bytes_for(handlers.DEFAULT_MAX_CODE_SIZE) == 1_048_576 + assert limits.request_bytes_for(3 * handlers.DEFAULT_MAX_CODE_SIZE) == 2 * 1_048_576 + assert limits.request_bytes_for(handlers.DEFAULT_MAX_CODE_SIZE) % (1024 * 1024) == 0, "whole MiBs, rounded up" + + +def test_a_hebrew_file_at_the_size_ceiling_fits_under_the_cap_with_its_envelope(): + """הבקשה הלגיטימית הגדולה ביותר נכנסת: ``codekeeper_save_file`` על קובץ עברי בגודל המקסימלי, בקידוד היקר. + + ``json.dumps`` בברירת המחדל (``ensure_ascii=True``) כותב כל תו עברי כ-``\\uXXXX`` + — שישה בתים — וזה מה שלקוח שמקודד כך שולח. המעטפת: JSON-RPC, שם הכלי, שם + הקובץ, שפה, ותיאור בתקרתו (``FILE_DESCRIPTION_MAX_CHARS``); לשם הקובץ אין + תקרה בשכבה הזאת, ולכן נלקח שם ארוך מהמקובל. + """ + from database.repository import FILE_DESCRIPTION_MAX_CHARS + + request = { + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { + "name": "codekeeper_save_file", + "arguments": { + "file_name": "שם-קובץ-ארוך-" * 20 + ".py", + "code": "ש" * handlers.DEFAULT_MAX_CODE_SIZE, + "language": "python", + "description": "ת" * FILE_DESCRIPTION_MAX_CHARS, + }, + }, + } + body = json.dumps(request, ensure_ascii=True).encode("ascii") + + assert len(body) > handlers.DEFAULT_MAX_CODE_SIZE * limits.JSON_BYTES_PER_CHAR, "the escape really costs six bytes a character" + assert len(body) <= limits.DEFAULT_MAX_REQUEST_BYTES + + +def test_the_config_inspector_shows_the_limits_the_service_reads(): + """שני משתני הסביבה בתצוגת התצורה: אותם שמות ואותן ברירות מחדל שהקוד קורא — לא עותק שנסחף.""" + from services.config_inspector_service import ConfigService + + cap = ConfigService.CONFIG_DEFINITIONS[limits.MAX_REQUEST_BYTES_ENV] + rate = ConfigService.CONFIG_DEFINITIONS[limits.RATE_LIMIT_ENV] + assert cap.key == limits.MAX_REQUEST_BYTES_ENV and cap.default == str(limits.DEFAULT_MAX_REQUEST_BYTES) + assert rate.key == limits.RATE_LIMIT_ENV and rate.default == str(limits.DEFAULT_RATE_LIMIT_PER_MINUTE) + assert str(limits.MIN_MAX_REQUEST_BYTES) in cap.description diff --git a/tests/test_mcp_outline.py b/tests/test_mcp_outline.py index 0561e71f9..c64ad7d79 100644 --- a/tests/test_mcp_outline.py +++ b/tests/test_mcp_outline.py @@ -2819,6 +2819,33 @@ def counting(**fields): assert len(built) <= 21, f"נבנו {len(built)} סקשנים — הפרסור לא נעצר על התקרה" +def test_the_rst_scanner_passes_its_own_ceiling_and_does_not_lean_on_the_parser_default(monkeypatch): + """הסורק אומר את התקרה שלו בעצמו — ``_ceiling.MAX_SYMBOLS`` — בכל קריאה לפארסר. + + **פין, לא תיקון (#3420):** כשברירת המחדל של ``max_sections`` ב-``rst_parser`` + יושרה ל-``MAX_SECTIONS``, האישו דרש שהסורק יישאר לא-מושפע, וש"זה ייאמר + בטסט ולא בהיגיון". המספר כאן הוא של המפה — כותרות ותוויות יחד, דרך + ``Capped`` — ולכן הוא עובר במפורש גם כששני המספרים שווים. מוטציה שמוחקת + את הארגומנט מפילה את זה; ריצה על הקוד שלפני #3420 עוברת, וזה מכוון. + """ + from services import rst_parser + + passed: list[dict] = [] + real = rst_parser.parse_document + + def spy(text, **kwargs): + passed.append(dict(kwargs)) + return real(text, **kwargs) + + monkeypatch.setattr(rst_parser, "parse_document", spy) + + names = [row["name"] for row in extract_outline("a\n=\n\nb\n-\n\n", "a.rst")["symbols"]] + + assert names == ["a", "a.b"] + assert passed == [{"max_sections": _ceiling.MAX_SYMBOLS}], ( + f"הסורק נשען על ברירת המחדל של הפארסר במקום לומר את התקרה שלו: {passed}") + + def test_the_tool_reports_the_ceiling_through_the_real_path(monkeypatch): """דרך ``repo_handlers.get_repo_file`` ולא רק דרך ``extract_outline``. diff --git a/tests/test_mcp_repo_backend.py b/tests/test_mcp_repo_backend.py index 39c766748..0faec0f85 100644 --- a/tests/test_mcp_repo_backend.py +++ b/tests/test_mcp_repo_backend.py @@ -280,6 +280,27 @@ def test_get_file_not_found_and_denied(): assert mirror.calls == [] +def test_get_file_names_a_missing_mirror_and_keeps_not_found_for_a_bad_ref(): + """‏``repo_not_found`` מהמראה הוא ``repo_not_mirrored`` — עניין של המפעיל, לא שם קובץ שגוי (#3432, SUGG-011). + + ‏``invalid_commit`` נשאר ``not_found``: הריפו קיים, וה-ref הוא מה שהקורא + יכול לשנות. ובזמן sync שניהם ``sync_in_progress``, כמו קודם. + """ + no_mirror = RepoBackend(db=_repos_db(), mirror=_Mirror(file_result={"error": "repo_not_found"}), + search_service=_Search()) + assert no_mirror.get_file(repo="alpha", path="a.py") == {"ok": False, "error": "repo_not_mirrored"} + + bad_ref = RepoBackend(db=_repos_db(), mirror=_Mirror(file_result={"error": "invalid_commit"}), + search_service=_Search()) + assert bad_ref.get_file(repo="alpha", path="a.py") == {"ok": False, "error": "not_found"} + + syncing = _DB(repos=[{"repo_name": "alpha", "default_branch": "main"}], + jobs=[{"repo_name": "alpha", "status": "running"}]) + mid_sync = RepoBackend(db=syncing, mirror=_Mirror(file_result={"error": "repo_not_found"}), + search_service=_Search()) + assert mid_sync.get_file(repo="alpha", path="a.py")["error"] == "sync_in_progress" + + def test_get_file_sync_in_progress_instead_of_not_found(): db = _DB( repos=[{"repo_name": "alpha", "default_branch": "main"}], diff --git a/tests/test_mcp_repo_policy.py b/tests/test_mcp_repo_policy.py index c588d4bd2..ef380d16d 100644 --- a/tests/test_mcp_repo_policy.py +++ b/tests/test_mcp_repo_policy.py @@ -1,5 +1,6 @@ """Unit tests for the repo secrets-path policy (mandatory, fail-closed).""" +import shutil from pathlib import Path import pytest @@ -7,6 +8,19 @@ from mcp_server import repo_policy from mcp_server.repo_policy import is_denied + +def _require_git() -> None: + """הטסטים שמריצים git מדלגים בלי git — כולם באותה צורה (#3432, SUGG-005). + + ``pytest.importorskip("services.git_mirror_service")`` בודק מודול פייתון, + לא את הבינארי; עד כאן טסט אחד דילג על ``returncode`` וטסט שני קרס על + ``check=True``. אותו אידיום כמו ``_GIT = shutil.which("git")`` ב- + ``tests/test_mcp_search_total.py``. + """ + if shutil.which("git") is None: + pytest.skip("git אינו מותקן") + + _ROOT = Path(__file__).resolve().parent.parent @@ -166,10 +180,11 @@ def test_the_policy_blocks_exactly_the_known_files_in_this_repository(): """ import subprocess + _require_git() proc = subprocess.run(["git", "ls-files", "-z"], cwd=str(_ROOT), capture_output=True, text=True) if proc.returncode != 0: - pytest.skip("אין git או שזו אינה עבודה מגיט") + pytest.skip("זו אינה עבודה מגיט") paths = [p for p in proc.stdout.split("\0") if p] assert len(paths) > 1000, "רשימת הקבצים קצרה מדי — הבדיקה איבדה את הקורפוס שלה" assert any(" " in p for p in paths), ( @@ -191,6 +206,7 @@ def _seeded_mirror(tmp_path): """ import subprocess + _require_git() work = tmp_path / "work" seeded = { "config/.env/README.md": "SEEDTOKEN\n", @@ -229,7 +245,11 @@ def test_the_search_side_skips_every_path_the_read_side_denies(tmp_path): כי הם דורשים שהנתיב **יסתיים** ב-``.pem``. הטענה כאן היא על ההסכמה עצמה ולא על רשימה מוקלדת: כל נתיב שהקריאה - חוסמת חייב להיעדר מתוצאות החיפוש, וההפך. + חוסמת חייב להיעדר מתוצאות החיפוש. **ולא ההפך** — ``repo_policy`` מצהיר + ש-``is_denied`` נשאר השכבה האחרונה, כלומר תבנית שהמנוע אינו יכול לבטא + (או מבטא רחב יותר) עדיין נחסמת בקריאה; שוויון מדויק בין שני החצאים היה + דורש את ההפך ממה שמתועד (#3432, SUGG-004). מה שכן נדרש: החיפוש אינו + ריק, אחרת "אפס חסומים בתוצאות" היה נכון גם על מנוע שבור. """ pytest.importorskip("services.git_mirror_service") from services.git_mirror_service import GitMirrorService @@ -241,14 +261,12 @@ def test_the_search_side_skips_every_path_the_read_side_denies(tmp_path): returned = {r["path"] for r in out.get("results", [])} denied = {p for p in seeded if is_denied(p)} - allowed = set(seeded) - denied assert denied, "אף נתיב שנזרע אינו נחסם — הזריעה איבדה את הנושא שלה" assert returned & denied == set(), ( f"החיפוש החזיר נתיבים שהקריאה חוסמת: {sorted(returned & denied)}") - assert returned == allowed, ( - f"החיפוש ותשובת הקריאה אינם מסכימים: חיפוש={sorted(returned)}, " - f"מותר={sorted(allowed)}") + assert "docs/ok.md" in returned, ( + f"החיפוש לא החזיר את הקובץ המותר — התוצאות ריקות מסיבה אחרת: {sorted(returned)}") # =========================================================================== diff --git a/tests/test_mcp_to_thread.py b/tests/test_mcp_to_thread.py index 74d9e50b1..987809a5c 100644 --- a/tests/test_mcp_to_thread.py +++ b/tests/test_mcp_to_thread.py @@ -962,6 +962,33 @@ def test_startup_names_the_read_pool_the_cpu_quota_and_the_memory_limit(caplog): assert expected in line, (expected, line) +def test_a_pool_without_a_readable_width_does_not_fail_startup_and_says_so(caplog): + """``_max_workers`` is CPython-private; if it goes, the line says so instead of the service not starting. + + A record that describes state reads the state — and when the state cannot + be read, it says *that*, rather than echoing the request as if it were + the state. The pool here is an object with no ``_max_workers`` at all, + which is what a future Python would look like to this code (#3433, + SUGG-004). + """ + import logging as _logging + import types as _types + + from mcp_server.server import _log_dispatch_capacity, _read_pool_size + + sizing = _read_pool_size(512 * _MiB) + with caplog.at_level(_logging.INFO, logger="mcp_server.server"): + _log_dispatch_capacity(_types.SimpleNamespace(), "cgroup v2: 512.0MiB", sizing) + + info = [r.getMessage() for r in caplog.records if r.levelno == _logging.INFO] + warnings = [r.getMessage() for r in caplog.records if r.levelno == _logging.WARNING] + # The width belongs to the memory arithmetic, which has its own test; this + # one is about the label, so it reads the number back instead of typing it. + expected = f"read pool {sizing.workers} (requested; installed width unreadable) threads" + assert any(expected in m for m in info), info + assert any("_max_workers" in m for m in warnings), warnings + + def test_the_pool_is_sized_from_the_memory_budget(): """``(limit - baseline - margin) // cost of one parse`` — and a different limit gives a different answer. @@ -1009,20 +1036,25 @@ def test_the_pool_never_rises_above_the_cap(): assert sizing.detail.startswith("cap 12:"), sizing.detail -def test_no_readable_memory_limit_falls_back_to_the_floor_and_not_to_cpu_count(): +def test_no_readable_memory_limit_falls_back_to_the_floor_and_not_to_cpu_count(monkeypatch): """The fallback is the conservative constant, and it is not ``cpu_count + 4``. ``cpu_count + 4`` is exactly the number this sizing exists to stop relying - on, so the test pins the fallback to the floor on any machine — on a - 16-core box the old default would have given 20. + on. ``os.cpu_count`` is pinned to 16 here so that the old default, + ``min(32, 16 + 4) = 20``, is a number the floor can never be — a fallback + that consulted the host's cores would answer 20 and fail. The line this + replaced (``workers != min(32, cpu_count + 4) or workers == 2``) could not + fail after the assertion before it had pinned ``workers == 2`` (#3433, + SUGG-001). """ import os from mcp_server.server import _READ_POOL_FLOOR, _read_pool_size + monkeypatch.setattr(os, "cpu_count", lambda: 16) + sizing = _read_pool_size(None) assert (sizing.workers, sizing.source) == (_READ_POOL_FLOOR, "fallback"), sizing - assert sizing.workers != min(32, (os.cpu_count() or 1) + 4) or sizing.workers == 2 assert "no memory limit readable" in sizing.detail, sizing.detail @@ -1100,6 +1132,42 @@ def test_the_memory_limit_is_read_from_cgroup_v1_when_v2_is_absent(tmp_path, mon assert server_module._memory_limit() == (268435456, "cgroup v1: 256.0MiB") +def test_an_empty_cgroup_v2_file_falls_through_to_v1(tmp_path, monkeypatch): + """A ``memory.max`` that exists but holds nothing is not a limit of zero — v1 answers. + + ``split()`` of an empty file is ``[]`` and ``raw[0]`` is the ``IndexError`` + the reader's ``except`` names; until now only a *missing* v2 file proved + the fall-through (#3433, SUGG-014). Line coverage was complete, the + scenario was not. + """ + import mcp_server.server as server_module + + _fake_cgroup( + monkeypatch, + tmp_path, + { + "/sys/fs/cgroup/memory.max": "", + "/sys/fs/cgroup/memory/memory.limit_in_bytes": "268435456\n", + }, + ) + assert server_module._memory_limit() == (268435456, "cgroup v1: 256.0MiB") + + +def test_a_non_numeric_cgroup_v2_file_falls_through_to_v1(tmp_path, monkeypatch): + """``memory.max`` with a word that is not ``max`` is the ``ValueError`` branch — v1 answers.""" + import mcp_server.server as server_module + + _fake_cgroup( + monkeypatch, + tmp_path, + { + "/sys/fs/cgroup/memory.max": "lots\n", + "/sys/fs/cgroup/memory/memory.limit_in_bytes": "268435456\n", + }, + ) + assert server_module._memory_limit() == (268435456, "cgroup v1: 256.0MiB") + + def test_the_cgroup_v1_unlimited_sentinel_is_no_limit(tmp_path, monkeypatch): """v1 has no word for unlimited: it reads back ``LONG_MAX`` rounded to a page. diff --git a/tests/test_md_parser.py b/tests/test_md_parser.py index ef351ec15..f89e16d1a 100644 --- a/tests/test_md_parser.py +++ b/tests/test_md_parser.py @@ -306,6 +306,7 @@ def test_the_default_ceiling_is_the_documented_constant(): import inspect assert md_parser.MAX_SECTIONS == 50_000 + assert md_parser.MAX_SECTIONS is doc_sections.MAX_SECTIONS, "ייצוא-מחדש, לא עותק" default = inspect.signature(md_parser.parse_document).parameters["max_sections"].default assert default is md_parser.MAX_SECTIONS, "ברירת המחדל היא התקרה, לא None" @@ -430,6 +431,19 @@ def spy(self, chainName=""): ) +def test_token_count_is_the_tool_parsers_own_count_and_not_an_export(): + """המדד למדידה סופר על ``_MD`` — המופע שהכלי מריץ — ונשאר מחוץ לחוזה של ``__all__``. + + הסקריפט שמודד את עלות הפרסור קרא ל-``_build_parser`` הפרטית ובנה פרסר + חדש לכל קובץ; ``token_count`` היא אותה תלות בגלוי (#3433, SUGG-010). + """ + text = "---\na: 1\n---\n\n# כותרת\n\n> ## בציטוט\n\n- פריט\n\n| a | b |\n|---|---|\n| 1 | 2 |\n" + + assert md_parser.token_count(text) == len(md_parser._MD.parse(text)) + assert md_parser.token_count("") == 0 + assert "token_count" not in md_parser.__all__ + + def test_the_ceiling_anchor_fails_loudly_when_the_plugin_is_not_registered(): """העוגן ``before("front_matter", ...)`` קונה כשל מיידי — ועל מה בדיוק. @@ -648,25 +662,62 @@ def test_input_that_is_not_a_string_is_refused_in_the_entry(bad): md_parser.parse_document(bad) +@pytest.mark.parametrize("bad", [None, 17, b"# bytes\n", ["# list"]]) +@pytest.mark.parametrize("entry", [md_parser.token_count, md_parser.front_matter_end]) +def test_the_two_small_entries_refuse_a_non_string_with_the_modules_own_message(entry, bad): + """SUGG-019: ``token_count`` ו-``front_matter_end`` עוברים באותו שער כמו ``parse_document``. + + עד עכשיו הן קראו ל-``_MD.parse`` ישירות: ערך שאינו מחרוזת נפל ב-``TypeError`` + של markdown-it, בלי לומר מה התקבל. ה-``match`` הוא מה שמבדיל: ההודעה של + המודול, לא של הספרייה. + """ + with pytest.raises(TypeError, match="expects str"): + entry(bad) + + +def test_the_two_small_entries_see_past_a_bom_like_the_parser(): + """BOM בתחילת הקובץ הסתיר את בלוק ה-front matter (0 במקום 3) והפך תבליט לפסקה.""" + page = "---\nsummary: x\n---\n\n# T\n" + assert md_parser.front_matter_end("" + page) == md_parser.front_matter_end(page) == 3 + assert md_parser.token_count("- a\n") == md_parser.token_count("- a\n") + + +def test_the_two_small_entries_refuse_a_lone_cr_instead_of_disagreeing_with_split(): + """‏``\\r`` בודד: markdown-it סופר אותו כמעבר שורה ו-``split("\\n")`` לא. + + ``front_matter_end`` החזיר 4 על בלוק שה-``---`` הסוגר שלו יושב באינדקס 2 + של ``split("\\n")`` — ו-``scripts/generate_ai_map.py`` היה מדלג על השורה + הראשונה של הגוף. עכשיו שתי הפונקציות מרימות את החריגה של הכלי, עם מספר + השורה. + """ + page = "---\rsummary: x\r---\n\n# T\n" + for entry in (md_parser.front_matter_end, md_parser.token_count): + with pytest.raises(md_parser.InconsistentLineEndings) as excinfo: + entry(page) + assert excinfo.value.args == (1,) + + def test_the_two_exceptions_come_from_the_same_module(): """המטפל שימיר אותן לתשובת MCP מייבא את שתיהן ממקום אחד.""" assert md_parser.TooManySections is doc_sections.TooManySections assert md_parser.InconsistentLineEndings is doc_sections.InconsistentLineEndings -def test_the_two_parsers_export_the_same_names_but_two(): +def test_the_two_parsers_export_the_same_names_but_one(): """"בני-החלפה" היא טענה בת-בדיקה, לא משאלה. כל שם ש-``rst_parser`` מייצא קיים גם ב-``md_parser``, וההפרש הוא - **בדיוק** שני השמות שהפרוזה מונה. שם שיתווסף לאחד מהם בלי השני, + **בדיוק** השם היחיד שהפרוזה מונה. שם שיתווסף לאחד מהם בלי השני, או ייצוא של ``InconsistentLineEndings`` מ-``rst_parser`` (שאינו מרים - אותה), מפיל את זה. + אותה), מפיל את זה. עד #3420 ההפרש היה שניים — ``MAX_SECTIONS`` היה + רק כאן; מאז הוא מוגדר ב-``doc_sections`` ומיוצא משני הפארסרים, כי + הוא ברירת המחדל של שניהם. """ from services import rst_parser rst, md = set(rst_parser.__all__), set(md_parser.__all__) assert rst <= md, f"שמות שרק ב-rst_parser: {sorted(rst - md)}" - assert md - rst == {"MAX_SECTIONS", "InconsistentLineEndings"} + assert md - rst == {"InconsistentLineEndings"} @pytest.mark.parametrize( @@ -906,134 +957,96 @@ def _load_ai_map_generator(): @pytest.mark.parametrize("name, text", _LINE_ENDING_INPUTS) def test_the_lone_cr_rule_matches_markdown_it(name, text): - """שני העותקים של כלל ה-``\\r`` הבודד, מול ההתנהגות שהם מתארים. + """הכלל המשותף של ה-``\\r`` הבודד, מול ההתנהגות שהוא מתאר. - אמת המידה אינה אחד מהם אלא ``markdown-it-py``: הכלל שלו, + אמת המידה אינה הכלל אלא ``markdown-it-py``: הכלל שלו, ``rules_core/normalize.py`` עם ``\\r\\n?|\\n``, הוא מה שהופך ``\\r`` בודד לשורה חדשה — ומשם הפער מול ``split("\\n")`` שכל שדות ה- - ``lines`` ב-MCP נספרים בו. - - הטסט מריץ את הביטוי משני המקומות ואת הספרייה, ודורש שהשלושה יסכימו - על כל קלט. פיצול עתידי של אחד מהם ייפול כאן. + ``lines`` ב-MCP נספרים בו. עד #3419 היו כאן שני עותקים (המנתב + והפארסר) שהטסט השווה ביניהם; מאז יש הגדרה אחת ב-``services/line_endings``, + והטסט שאחרי זה מוכיח ששני הצרכנים באמת קוראים לה. """ - from mcp_server import outline as outline_router + from services import line_endings - by_router = bool(outline_router._CR_WITHOUT_LF.search(text)) - by_parser = bool(md_parser._CR_WITHOUT_LF.search(text)) + by_rule = bool(line_endings.find_lone_cr(text)) # האמת: האם ``markdown-it-py`` סופר שורות אחרת מ-``split("\n")``? normalized_by_library = re.sub(r"\r\n?|\n", "\n", text) normalized_by_us = text.replace("\r\n", "\n") by_library = normalized_by_library != normalized_by_us - assert by_router == by_parser == by_library, { - "מנתב": by_router, - "פארסר": by_parser, - "הספרייה": by_library, - } - - -#: הפער שנמדד בין ``_body_start`` לבין התוסף, צורה ← האם התוסף רואה כאן -#: front matter. ``True`` פירושו שהתוסף מזהה בלוק והפונקציה הישנה לא, או -#: להפך — הפירוט בעמודה השלישית. -_FRONT_MATTER_SHAPES = [ - # (שם, טקסט, האם התוסף מזהה בלוק, האם _body_start מזהה בלוק) - ("רגיל", "---\na: 1\n---\n\n# כותרת\n", True, True), - ("סוגר ...", "---\na: 1\n...\n\n# כותרת\n", True, True), - ("לא נסגר", "---\na: 1\n\n# כותרת\n", False, False), - ("פותח 4 סוגר 3", "----\na: 1\n---\n\n# כותרת\n", False, False), - ("ריק", "", False, False), - # ── צורות שנראות חשודות ובכל זאת **מסכימות** — כאן בכוונה, כי - # "בדקנו ואין פער" הוא מידע בדיוק כמו פער ── - ("פותח רווח בסוף", "--- \na: 1\n---\n\n# כותרת\n", True, True), - ("סוגר מוזח 2 רווחים", "---\na: 1\n ---\n\n# כותרת\n", True, True), - # ── והצורות שבהן הם באמת חלוקים. המספר כאן אינו מוקלד בפרוזה: - # ``test_the_measured_front_matter_gap_matches_the_prose`` גוזר - # אותו מהטבלה ומשווה למה שכתוב ב-``md_parser`` ── - ("פותח מוזח", " ---\na: 1\n---\n\n# כותרת\n", False, True), - # ארבעה רווחים הם בלוק קוד לפי CommonMark, ולכן התוסף אינו רואה - # סוגר — ואילו ``_body_start`` עושה ``.strip()`` ומקבל. הצורה - # הזאת נמדדה, הוזכרה ב-``generate_ai_map`` ונשמטה מהטבלה. - ("סוגר מוזח 4 רווחים", "---\na: 1\n ---\n\n# כותרת\n", False, True), - ("ארבעה מקפים", "----\na: 1\n----\n\n# כותרת\n", True, False), - ("פותח 3 סוגר 4", "---\na: 1\n----\n\n# כותרת\n", True, False), - ("פותח עם טקסט", "---title\na: 1\n---\n\n# כותרת\n", True, False), -] - -#: מספר הצורות שבהן התוסף ו-``_body_start`` חלוקים, **נגזר מהטבלה**. -#: הפרוזה ב-``services/md_parser.py`` וב-``requirements/base.txt`` -#: מצטטת אותו, וטסט משווה — כדי שתוספת צורה בלי עדכון הפרוזה תפיל. -_FRONT_MATTER_DISAGREEMENTS = sum( - 1 for _, _, plugin_sees, body_start_sees in _FRONT_MATTER_SHAPES - if plugin_sees is not body_start_sees -) - -#: מילות המספר שהפרוזה משתמשת בהן. טווח קטן בכוונה: אם הטבלה תגדל מעבר -#: לו, הטסט ייפול על ``KeyError`` ויכריח מבט — וזה עדיף על השלמה שקטה. -_HEBREW_NUMBERS = { - 3: "שלוש", 4: "ארבע", 5: "חמש", 6: "שש", 7: "שבע", 8: "שמונה", - 9: "תשע", 10: "עשר", 11: "אחת-עשרה", 12: "שתים-עשרה", - 13: "שלוש-עשרה", 14: "ארבע-עשרה", 15: "חמש-עשרה", -} + assert by_rule == by_library, {"הכלל": by_rule, "הספרייה": by_library} -def test_the_measured_front_matter_gap_matches_the_prose(): - """הפער שנמדד בטבלה הוא מה שכתוב בפרוזה, בשני המקומות שמצטטים אותו. +def test_both_consumers_use_the_shared_lone_cr_rule_and_neither_keeps_a_copy(): + """המנתב והפארסר מייבאים את הכלל מ-``services.line_endings`` — ואף אחד מהם אינו מגדיר אותו מחדש. - **למה טסט ולא סתם לתקן את המספר.** זה בדיוק מה שכבר קרה: הטבלה - גדלה, והפרוזה נשארה על "חמש מתוך שלוש-עשרה" בזמן שבטבלה היו - אחת-עשרה שורות וארבע חלוקות — שלוש רשימות שונות של "חמש הצורות" - חיו בריפו בו-זמנית. מספר שמוקלד ביד במקום שני מתיישן בשקט; מספר - שנגזר מהקוד לא יכול. + הבדיקה היא על המקור ולא על התנהגות, כי התנהגות זהה היא בדיוק מה ששני + עותקים מספקים ביום הראשון (R6): מה שצריך לתפוס הוא העותק שיחזור מחר. """ - disagreements = _HEBREW_NUMBERS[_FRONT_MATTER_DISAGREEMENTS] - total = _HEBREW_NUMBERS[len(_FRONT_MATTER_SHAPES)] - expected = f"{disagreements} מתוך {total}" + import ast - parser_source = (_REPO / "services" / "md_parser.py").read_text(encoding="utf-8") - base = (_REPO / "requirements" / "base.txt").read_text(encoding="utf-8") - - for name, text in (("services/md_parser.py", parser_source), ("requirements/base.txt", base)): - assert expected in text, ( - f"{name} אינו אומר {expected!r}. הטבלה מכילה עכשיו " - f"{len(_FRONT_MATTER_SHAPES)} צורות ובהן {_FRONT_MATTER_DISAGREEMENTS} " - "חלוקות — עדכן את הפרוזה יחד עם הטבלה." - ) - - -@pytest.mark.parametrize("name, text, plugin_sees, body_start_sees", _FRONT_MATTER_SHAPES) -def test_the_front_matter_rules_still_disagree_as_measured( - name, text, plugin_sees, body_start_sees -): - """**הטסט הזה מקבע פער מדוד, ולא התנהגות רצויה.** - - אמת המידה היא ``mdit_py_plugins.front_matter`` — מה ש-MyST באמת - מריץ, ומה ש-``md_parser`` רושם על מופע הפארסר שלו. - ``scripts/generate_ai_map.py::_body_start`` הוא **הסוטה**: הוא - כותב את אותו כלל ביד ו-``.strip()`` שבו מקבל ``---`` מוזח שהתוסף - דוחה, ופוסל ``----`` וסוגר ארוך מהפותח שהתוסף מקבל. + from mcp_server import outline as outline_router + from services import line_endings + + rule = r"\r(?!\n)" + assert line_endings.CR_WITHOUT_LF.pattern == rule + for module in (outline_router, md_parser): + source = Path(module.__file__).read_text(encoding="utf-8") + assert rule not in source, f"{module.__name__} מחזיק עותק משלו של כלל ה-CR הבודד" + imported = { + alias.name + for node in ast.walk(ast.parse(source)) + if isinstance(node, ast.ImportFrom) and (node.module or "").endswith("line_endings") + for alias in node.names + } + assert "find_lone_cr" in imported, f"{module.__name__} אינו מייבא את find_lone_cr" + + +#: הצורות שבהן נמדד, ב-#3418, מה התוסף רואה — כולל חמש שכלל שנכתב ביד +#: (``scripts/generate_ai_map.py::_body_start``, עד #3419) חלק עליו בהן. +#: העמודה השלישית היא האמת: האם ``mdit_py_plugins.front_matter`` מזהה בלוק. +_FRONT_MATTER_SHAPES = [ + # (שם, טקסט, האם התוסף מזהה בלוק) + ("רגיל", "---\na: 1\n---\n\n# כותרת\n", True), + ("סוגר ...", "---\na: 1\n...\n\n# כותרת\n", True), + ("לא נסגר", "---\na: 1\n\n# כותרת\n", False), + ("פותח 4 סוגר 3", "----\na: 1\n---\n\n# כותרת\n", False), + ("ריק", "", False), + ("פותח רווח בסוף", "--- \na: 1\n---\n\n# כותרת\n", True), + ("סוגר מוזח 2 רווחים", "---\na: 1\n ---\n\n# כותרת\n", True), + # ── חמש הצורות שהכלל שנכתב ביד חלק עליהן, עד #3419 ── + ("פותח מוזח", " ---\na: 1\n---\n\n# כותרת\n", False), + # ארבעה רווחים הם בלוק קוד לפי CommonMark, ולכן התוסף אינו רואה סוגר. + ("סוגר מוזח 4 רווחים", "---\na: 1\n ---\n\n# כותרת\n", False), + ("ארבעה מקפים", "----\na: 1\n----\n\n# כותרת\n", True), + ("פותח 3 סוגר 4", "---\na: 1\n----\n\n# כותרת\n", True), + ("פותח עם טקסט", "---title\na: 1\n---\n\n# כותרת\n", True), +] - .. warning:: - **נפילה של הטסט הזה אחרי שהפער ייסגר היא תוצאה מכוונת.** היעד - הוא ש-``_body_start`` יעבור להשתמש בתוסף; ביום שזה יקרה, הטסט - הזה **חייב** להיערך יחד עם השינוי ועם האישו שמתעד אותו. אל - "תתקן" אותו כאילו היה באג — הוא הדבר היחיד שמונע מהפער להיסחף - בשקט לכיוון שלישי. +@pytest.mark.parametrize("name, text, plugin_sees", _FRONT_MATTER_SHAPES) +def test_front_matter_end_is_the_plugins_boundary_on_every_shape(name, text, plugin_sees): + """``md_parser.front_matter_end`` אומר בדיוק מה שהתוסף אומר — גם בחמש הצורות שהכלל הידני חלק עליהן. - בקורפוס של היום אין לאף אחת מחמש הצורות החלוקות מופע: 23 מתוך 34 - קובצי ה-``.md`` תחת ``docs/`` פותחים ב-``---`` מדויק, ואפס במוזח או - עם רווח. כלומר המפה שנוצרת היום נכונה, והפער הוא חוב ולא תקלה. + **הגלגול השני של הטסט שישב כאן.** הקודם — ``..._still_disagree_as_measured`` + — קיבע פער מדוד בין ``_body_start`` של ``generate_ai_map`` לבין התוסף, + והכריז שנפילתו אחרי סגירת הפער היא תוצאה מכוונת. הפער נסגר ב-#3419: + הסקריפט קורא את הגבול מהפארסר, והטסט נערך יחד עם השינוי, כפי שה-docstring + שלו דרש. אמת המידה לא זזה: התוסף עצמו, על פרסר בקרה שנבנה כאן. """ from mdit_py_plugins.front_matter import front_matter_plugin probe = MarkdownIt("commonmark").use(front_matter_plugin).disable("inline") - saw_block = any(t.type == "front_matter" for t in probe.parse(text, {})) + tokens = probe.parse(text, {}) + saw_block = any(t.type == "front_matter" for t in tokens) assert saw_block is plugin_sees, f"התנהגות התוסף השתנתה בצורה {name!r}" + expected = next((t.map[1] for t in tokens if t.type == "front_matter"), 0) + assert md_parser.front_matter_end(text) == expected, name generator = _load_ai_map_generator() - assert bool(generator._body_start(text.split("\n"))) is body_start_sees, ( - f"‏_body_start השתנה בצורה {name!r} — עדכן את הטבלה יחד עם האישו" + assert generator._body_start(text.split("\n")) == expected, ( + f"‏_body_start חולק על התוסף בצורה {name!r} — הפער שנסגר ב-#3419 נפתח מחדש" ) @@ -1044,7 +1057,7 @@ def test_the_parser_never_invents_a_section_where_the_plugin_sees_front_matter() ה"מומצאת" היא התנהגות CommonMark נכונה ולא באג שלנו, ולכן היא מוצהרת כאן במפורש. """ - for name, text, plugin_sees, _old in _FRONT_MATTER_SHAPES: + for name, text, plugin_sees in _FRONT_MATTER_SHAPES: if not text: continue titles = [s.title for s in md_parser.parse_document(text).sections] diff --git a/tests/test_measure_md_parse_cost_script.py b/tests/test_measure_md_parse_cost_script.py index a2320c987..804ee7b1d 100644 --- a/tests/test_measure_md_parse_cost_script.py +++ b/tests/test_measure_md_parse_cost_script.py @@ -66,9 +66,12 @@ def test_main_reports_each_parsers_candidate_from_its_densest_shape(monkeypatch, monkeypatch.setattr(script, "real_files", lambda suffix: [docs[suffix]]) monkeypatch.setattr(script, "density", lambda text, parser="md": 1.0) costs = {"corpus": 25.0, "tiled_": 70.7, "hostile": 290.0, "outline_": 7.7} + seen_kwargs = {} def fake_peak_cost(path, workdir, *, parser="md", kwargs=None): - cost = next(v for prefix, v in costs.items() if path.name.startswith(prefix)) + prefix = next(prefix for prefix in costs if path.name.startswith(prefix)) + seen_kwargs[(parser, prefix)] = kwargs + cost = costs[prefix] return { "module": script.PARSERS[parser]["module"], "input_bytes": 1, @@ -90,6 +93,17 @@ def fake_peak_cost(path, workdir, *, parser="md", kwargs=None): assert last["rst"]["constant_candidate_bytes_per_input_byte"] == 70.7 assert last["rst"]["hostile_bound_bytes_per_input_byte"] == 290.0 + # WARN-004 (סקירת שבעת ה-PRים): הצורה העוינת מודדת את הפרסר בלי התקרה — + # עם ברירת המחדל היא נעצרת על MAX_SECTIONS והמספר מתאר עצירה. הקורפוס + # והמסמך הצפוף רצים כמו הכלי, בלי kwargs; ה-outline עם התקרה שלו. + from mcp_server.outline_scanners._ceiling import MAX_SYMBOLS + + for parser in ("md", "rst"): + assert seen_kwargs[(parser, "hostile")] == {"max_sections": None}, parser + assert seen_kwargs[(parser, "corpus")] is None and seen_kwargs[(parser, "tiled_")] is None + assert seen_kwargs[("rst", "outline_")] == {"max_sections": MAX_SYMBOLS} + assert "max_sections=None" in last["note"] + def test_a_checkout_with_no_document_of_the_minimum_size_fails_with_a_named_message(): """בלי קובץ מתאים הסקריפט אומר זאת — לא ``StatisticsError`` גולמי מהחציון.""" @@ -128,6 +142,19 @@ def test_tiling_stops_at_the_ceiling_and_is_longer_than_its_input(): assert 997 <= len(script.tiled_to_ceiling(text, 1000).encode("utf-8")) <= 1000 +def test_density_goes_through_the_public_token_count_and_not_the_private_builder(monkeypatch): + """הסקריפט אינו קורא ל-``md_parser._build_parser`` (#3433, SUGG-010): התלות היא ``token_count``.""" + from services import md_parser + + script = _load_script() + + def _forbidden(): + raise AssertionError("הסקריפט קרא ל-_build_parser הפרטית") + + monkeypatch.setattr(md_parser, "_build_parser", _forbidden) + assert script.density("- a\n" * 10, "md") > 0 + + def test_density_ranks_a_dense_document_above_a_sparse_one_of_the_same_size(): """הצפיפות היא מה שמדרג: טוקני בלוק ל-KB ב-Markdown, סקשנים ל-KB ב-RST.""" script = _load_script() diff --git a/tests/test_rst_parser.py b/tests/test_rst_parser.py index f18a7cabd..97f3b3e11 100644 --- a/tests/test_rst_parser.py +++ b/tests/test_rst_parser.py @@ -1,13 +1,14 @@ """טסטים לפארסר ה-RST — על קבצי RST אמיתיים מהריפו + מקרי קצה סינתטיים ממוקדים.""" import ast +import inspect import json import re from pathlib import Path import pytest -from services import rst_parser +from services import doc_sections, rst_parser _ENV_RST = Path(__file__).resolve().parents[1] / "docs" / "environment-variables.rst" @@ -1168,6 +1169,65 @@ def test_the_section_ceiling_refuses_above_the_line_and_not_on_it(): rst_parser.parse_document(three + "d\n=\n\n", max_sections=3) +# ---- בדיקת הכניסה, וברירת המחדל של התקרה (#3421, #3420) ---- +# +# שני הפארסרים מתועדים כבני-החלפה, ועל קלט שאינו מחרוזת הם התנהגו אחרת: +# ``rst_parser.parse_document(None)`` החזיר מסמך ריק **בשקט** — מפה ריקה +# שמתחזה למפה של קובץ בלי כותרות — ו-``17`` נפל ב-``AttributeError`` גולמי +# מתוך ``.replace``. וברירת המחדל של ``max_sections`` הייתה ``None`` כאן +# ו-``MAX_SECTIONS`` שם, כלומר קורא ששכח להעביר תקרה קיבל הגנה מהפארסר +# האחד ולא מהשני. + +_NOT_STRINGS = {"None": None, "int": 17, "bytes": b"x"} + + +@pytest.mark.parametrize("kind", sorted(_NOT_STRINGS)) +def test_input_that_is_not_a_string_is_refused_by_name(kind): + """``TypeError`` שאומר מה התקבל — ולא מסמך ריק, ולא ``AttributeError`` מבפנים. + + ``match`` על שם הטיפוס הוא מה שמבדיל את זה מהצורה הישנה גם במקרה של + ``bytes``, שנפל אז ב-``TypeError`` **אחר** ("a bytes-like object is + required") מתוך ``.replace``: אותו טיפוס חריגה, הודעה שאינה מסבירה כלום. + """ + with pytest.raises(TypeError, match=f"parse_document expects str, got {kind}"): + rst_parser.parse_document(_NOT_STRINGS[kind]) + + +@pytest.mark.parametrize("kind", sorted(_NOT_STRINGS)) +def test_both_parsers_refuse_a_non_string_with_the_same_words(kind): + """בדיקה אחת לשניהם (``doc_sections.require_str``), ולכן אותן מילים בדיוק. + + שתי שורות זהות בשני מודולים הן מה שנסחף בתיקון הבא; המילים נבדקות + כאן כי הן החוזה שהמטפל מצהיר עליו — ``TypeError`` היא "חוזה נשבר" + ואינה נתפסת ב-``docs_get_section``. + """ + from services import md_parser + + with pytest.raises(TypeError) as from_rst: + rst_parser.parse_document(_NOT_STRINGS[kind]) + with pytest.raises(TypeError) as from_md: + md_parser.parse_document(_NOT_STRINGS[kind]) + assert str(from_rst.value) == str(from_md.value) + + +def test_the_default_ceiling_is_the_shared_constant(): + """טענת O(1) על הערך, כמו הטסט המקביל ב-``test_md_parser`` — לא 50,001 סקשנים בכל CI. + + ברירת המחדל היא **אותו אובייקט** ``doc_sections.MAX_SECTIONS`` שגם + ``md_parser`` מחזיק, וגם מיוצא מחדש מכאן, כדי שקורא שכותב + ``rst_parser.MAX_SECTIONS`` יקבל את המספר שהפארסר באמת משתמש בו. + המנגנון עצמו — עצירה בזמן הפרסור, גבול "מעל ולא על" — מקובע בטסטים + שמעל עם ערכים קטנים מפורשים, ו-``None`` מכבה אותו במפורש. + """ + from services import md_parser + + default = inspect.signature(rst_parser.parse_document).parameters["max_sections"].default + assert default is doc_sections.MAX_SECTIONS, "ברירת המחדל היא התקרה, לא None" + assert rst_parser.MAX_SECTIONS is md_parser.MAX_SECTIONS is doc_sections.MAX_SECTIONS + assert "MAX_SECTIONS" in rst_parser.__all__ + assert len(rst_parser.parse_document("a\n=\n\n" * 3, max_sections=None).sections) == 3 + + def test_no_heading_site_reaches_the_section_list_around_the_funnel(): """כל אתר כותרת עובר ב-``add_section``, ואין דרך עקיפה — נבדק על המקור. diff --git a/tests/test_utils_normalize_hidden_escapes.py b/tests/test_utils_normalize_hidden_escapes.py index c317279c7..a40441097 100644 --- a/tests/test_utils_normalize_hidden_escapes.py +++ b/tests/test_utils_normalize_hidden_escapes.py @@ -43,3 +43,74 @@ def test_normalize_code_disable_strip_literal_escapes(): out = normalize_code(text, remove_escaped_format_escapes=False) assert "\\u200B" in out assert "\\U000E0001" in out + + +# --------------------------------------------------------------------------- +# #3427: רשימה אחת נמחקה משני מקומות, ופונקציה אחת במקום שתיים +# --------------------------------------------------------------------------- + +#: 16 הקודים שישבו ב-``known_hex4`` (utils) וב-``_KNOWN_ESCAPE_HEX4`` (הדומיין) — +#: זהים תו-בתו, ונמדד שכולם ``Cf``, כלומר הרשימה לא הוסיפה דבר על הקטגוריה. +_FORMERLY_LISTED = [ + "200B", "200C", "200D", "2060", "FEFF", + "200E", "200F", "202A", "202B", "202C", "202D", "202E", + "2066", "2067", "2068", "2069", +] + + +def test_the_two_normalizers_share_one_hidden_escape_rule_and_no_list(): + """הגדרה אחת בשכבת הדומיין, ושני הצרכנים קוראים לה — בלי רשימה באף אחד מהם.""" + import inspect + + import utils + from src.domain.services import code_normalizer + + assert utils._strip_hidden_escapes is code_normalizer.strip_hidden_escapes + assert not hasattr(code_normalizer.CodeNormalizer, "_KNOWN_ESCAPE_HEX4") + assert not hasattr(code_normalizer.CodeNormalizer, "_strip_hidden_escapes") + assert "known_hex4" not in inspect.getsource(utils.normalize_code) + + +@pytest.mark.parametrize("hexcode", _FORMERLY_LISTED) +def test_every_code_the_deleted_lists_named_is_cf_and_is_still_stripped_on_both_paths(hexcode): + """המדידה מהאישו, כטסט: כל קוד שהרשימה תפסה נתפס על ידי הקטגוריה — בשני הנרמולים.""" + from src.domain.services.code_normalizer import CodeNormalizer + + assert unicodedata.category(chr(int(hexcode, 16))) == "Cf" + text = f"a\\u{hexcode}b" + assert CodeNormalizer().normalize(text) == "ab" + assert normalize_code(text, strip_bom=False) == "ab", "המסלול הישן של utils (אפשרות שאינה ברירת מחדל)" + + +def test_variation_selectors_stay_a_separate_branch_because_they_are_mn_not_cf(): + """‏``U+FE0F`` הוא ``Mn``: הקטגוריה לא תופסת אותו, ולכן הוא ענף משלו שנדלק רק לפי בקשה.""" + from src.domain.services.code_normalizer import CodeNormalizer, strip_hidden_escapes + + assert unicodedata.category("\uFE0F") == "Mn" + assert strip_hidden_escapes("a\\uFE0Fb") == "a\\uFE0Fb" + assert strip_hidden_escapes("a\\uFE0Fb", remove_variation_selectors=True) == "ab" + assert CodeNormalizer().normalize("a\\uFE0Fb") == "a\\uFE0Fb" + assert normalize_code("a\\uFE0Fb", strip_bom=False) == "a\\uFE0Fb" + assert normalize_code("a\\uFE0Fb", strip_bom=False, remove_variation_selectors=True) == "ab" + + +def test_a_variation_selector_is_stripped_whatever_escape_form_spells_it(): + """‏``\\U0000FE0F`` ו-``\\uFE0F`` הם אותו תו, ולכן אותה החלטה בשתי הצורות (סקירת CodeRabbit על #3443). + + הענף של ``\\UXXXXXXXX`` בדק רק את הטווח האידאוגרפי (``U+E0100``–``U+E01EF``), + כאילו הצורה הארוכה מאייתת רק אותו — אבל ``\\U0000FE0F`` היא כתיב חוקי של + ``U+FE0F``, ונשארה כמות שהיא בזמן ש-``\\uFE0F`` הוסר. כלל אחד לשתי הצורות. + """ + from src.domain.services.code_normalizer import strip_hidden_escapes + + assert strip_hidden_escapes("a\\U0000FE0Fb") == "a\\U0000FE0Fb", "default: kept, like the short form" + assert strip_hidden_escapes("a\\U0000FE0Fb", remove_variation_selectors=True) == "ab" + assert strip_hidden_escapes("a\\U0000FE00b", remove_variation_selectors=True) == "ab" + assert normalize_code("a\\U0000FE0Fb", strip_bom=False, remove_variation_selectors=True) == "ab" + + +def test_an_escape_beyond_unicode_is_left_exactly_as_written(): + """‏``\\U00110000`` אינו תו (מעל U+10FFFF): לא ``Cf``, לא שגיאה — נשאר כמות שהוא, כמו קודם.""" + from src.domain.services.code_normalizer import strip_hidden_escapes + + assert strip_hidden_escapes("a\\U00110000b") == "a\\U00110000b" diff --git a/utils.py b/utils.py index 03951e906..c1141f857 100644 --- a/utils.py +++ b/utils.py @@ -25,12 +25,14 @@ from typing import Any, Callable, Dict, List, Optional, Tuple, Union from zoneinfo import ZoneInfo -# Try to use the new domain CodeNormalizer when available (backwards compatible) -try: # pragma: no cover - optional import during gradual refactor - from src.domain.services.code_normalizer import CodeNormalizer as _DomainCodeNormalizer # type: ignore - _DOMAIN_NORMALIZER = _DomainCodeNormalizer() -except Exception: # pragma: no cover - keep legacy path if domain not present - _DOMAIN_NORMALIZER = None # type: ignore +# The domain CodeNormalizer is pure Python with no I/O (``src/domain/services/ +# code_normalizer.py``), and it is the single home of the hidden-escape rule +# that both normalizers use (#3427) — so it is a plain import, not an optional +# one: a legacy path that silently ran without it would be a second copy again. +from src.domain.services.code_normalizer import CodeNormalizer as _DomainCodeNormalizer +from src.domain.services.code_normalizer import strip_hidden_escapes as _strip_hidden_escapes + +_DOMAIN_NORMALIZER = _DomainCodeNormalizer() # Optional telegram import with safe fallback for web-only environments try: @@ -1519,10 +1521,11 @@ def normalize_code(text: str, # Fast path: delegate to domain normalizer when all defaults are used # This preserves behavior and enables gradual migration to domain layer + # (the normalizer is imported unconditionally since #3427 — there is no + # path on which it is missing). try: if ( - _DOMAIN_NORMALIZER is not None - and strip_bom is True + strip_bom is True and normalize_newlines is True and replace_nbsp is True and replace_all_space_separators is True @@ -1538,60 +1541,11 @@ def normalize_code(text: str, # Fallback to legacy logic below on any error pass - # Handle sequences like "\u200B" that represent hidden/format chars literally - # We do NOT decode arbitrary escapes; only strip escapes that would decode to Cf/hidden sets + # Handle sequences like "\u200B" that represent hidden/format chars literally. + # One definition for both normalizers (#3427): Cf by category, and the + # Variation-Selector branch (Mn, not Cf) only when asked for. if remove_escaped_format_escapes and ("\\u" in out or "\\U" in out): - try: - import re as _re - # Known hidden/format codepoints we target explicitly - known_hex4 = { - "200B", "200C", "200D", "2060", "FEFF", # zero-width set - "200E", "200F", "202A", "202B", "202C", "202D", "202E", # directional - "2066", "2067", "2068", "2069", # directional isolates - } - - def _strip_if_hidden(m: 're.Match[str]') -> str: - hexcode = m.group(1).upper() - # Quick allowlist: only remove if in known set or Unicode category Cf - if hexcode in known_hex4: - return "" - try: - ch = chr(int(hexcode, 16)) - cat = unicodedata.category(ch) - if cat == 'Cf': - return "" - # Remove Unicode Variation Selectors (U+FE00..U+FE0F) - if remove_variation_selectors: - v = int(hexcode, 16) - if 0xFE00 <= v <= 0xFE0F: - return "" - except Exception: - pass - return m.group(0) # keep original escape - - # Replace \uXXXX sequences - out = _re.sub(r"\\u([0-9a-fA-F]{4})", _strip_if_hidden, out) - - # Replace \UXXXXXXXX sequences (rare for these marks, but safe) - def _strip_if_hidden_u8(m: 're.Match[str]') -> str: - hexcode = m.group(1).upper() - try: - ch = chr(int(hexcode, 16)) - if unicodedata.category(ch) == 'Cf': - return "" - # Remove Ideographic Variation Selectors (U+E0100..U+E01EF) - if remove_variation_selectors: - v = int(hexcode, 16) - if 0xE0100 <= v <= 0xE01EF: - return "" - except Exception: - pass - return m.group(0) - - out = _re.sub(r"\\U([0-9a-fA-F]{8})", _strip_if_hidden_u8, out) - except Exception: - # Best-effort: ignore on failure - pass + out = _strip_hidden_escapes(out, remove_variation_selectors=remove_variation_selectors) # Strip BOM at start if strip_bom and out.startswith("\ufeff"): diff --git a/webapp/push_api.py b/webapp/push_api.py index 1508769cc..609165bd2 100644 --- a/webapp/push_api.py +++ b/webapp/push_api.py @@ -1106,7 +1106,7 @@ def _send_for_user(user_id: int | str, reminders: list[dict]) -> None: #: שני נימוקים, ושניהם חיצוניים לקוד הזה: #: #: 1. אין היום שום תקרת אורך על ``file_name`` בכלי הכתיבה של ה-MCP — יש על -#: גוף הקובץ (``_max_code_size``) ועל תוכן פתק, לא על השם. כלומר האורך +#: גוף הקובץ (``max_code_size``) ועל תוכן פתק, לא על השם. כלומר האורך #: מגיע מחוץ לתהליך ואין עליו הגבלה. #: 2. גוף ההתראה נשלח בבקשת Web Push, ושם התקרה היא של הפרוטוקול: #: *"Push services MUST NOT return a 413 status code in responses to an