diff --git a/CLAUDE.md b/CLAUDE.md index 2f1dfc8fb..121788503 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,13 +60,14 @@ codekeeper_search_repo(repo="amir-bug-patterns", query="<מונח>") | `LOCK_FAIL_OPEN` — מריץ polling בלי מנעול ה-singleton, כלומר שני מריצים במקום אחד | `CORE-PATTERNS.md` U1 | | העלאת שרת HTTP או שינוי כתובת האזנה (`0.0.0.0`) — `main.py`, `services/webserver.py`, `webapp/app.py`, `mcp_server/app.py` | `CRITICAL-PATTERNS.md` K5 + `bugbot-rules/network-exposed-without-auth.md` | | אתחול עצל של משאב משותף (חיבור, לקוח, pool, קאש) — או **הסרה** של התנהגות מנוונת שקיימת מזמן | `CRITICAL-PATTERNS.md` K15 + `bugbot-rules/lazy-init-guard-publish-order.md` | +| מופע של ספרייה חיצונית שנבנה **ברמת המודול** ומשותף לחוטים — ובמיוחד כשהבנייה כוללת קריאות תצורה (`use`, `enable`, `disable`, `register`, `add_*`, `before`) | `CRITICAL-PATTERNS.md` K15 + `bugbot-rules/lazy-init-guard-publish-order.md` — מתי אובייקט נחשב מוכן, וכשהתצורה קורית בתוכו. ואם אותו מופע גם נבנה מ-`os.environ` או מפעיל משהו — ראה גם את השורה על ברמה העליונה של מודול | | `getattr(x, "y", None)` או `except` שאחריו **מסלול חלופי בגלל כשל** — לא ערך ברירת מחדל, ולא זיהוי יכולת סטטי | `bugbot-rules/silent-fallback-to-worse-path.md` | | CSP, כותרות תגובה, או עמוד שנגיש בלי התחברות | `BY-STACK/browser-policy.md` | | `create_index` — ובמיוחד `partialFilterExpression` או `sparse=True` | `BY-STACK/mongodb.md` דפוס 1 + `bugbot-rules/mongo-index-and-operator-traps.md` — אופרטורים שהפילטר החלקי לא מקבל, ואתחול שבולע את השגיאה | | צינור `aggregate` — `$project`, `$sort`, `$group` — או `find_one` בתוך לולאה | `RECURRING-PATTERNS.md` R8 + `bugbot-rules/work-disproportionate-to-answer.md` | | `startswith` / `endswith` על נתיב, URL או דומיין | `CRITICAL-PATTERNS.md` K16 | | `replace(tzinfo=` · `datetime.now()` **בלי** אזור זמן · כל `date.today()` · הצגת תאריך למשתמש | `RECURRING-PATTERNS.md` R7 | -| **ברמה העליונה של מודול** (מחוץ לכל פונקציה): `Thread(`, `.start()`, `scheduler`, `asyncio.create_task`, לקוח או חיבור שנבנה מ-`os.environ`, או קריאת רשת, מסד או קובץ | `bugbot-rules/import-time-side-effects.md` | +| **ברמה העליונה של מודול** (מחוץ לכל פונקציה): `Thread(`, `.start()`, `scheduler`, `asyncio.create_task`, לקוח או חיבור שנבנה מ-`os.environ`, או קריאת רשת, מסד או קובץ | `bugbot-rules/import-time-side-effects.md` — מה רץ בזמן ייבוא. ואם מה שנבנה שם הוא מופע של ספרייה חיצונית שמשותף לחוטים — ראה גם את השורה על מופע של ספרייה חיצונית | | שינוי שנעשה כדי לספק לינטר: הזזת `import`, ניקוי אזהרת escape, הרחבת `except` | `bugbot-rules/linter-fix-changes-runtime-behavior.md` | | מחיקה / שיתוף / שינוי שם לפי `_id` שהגיע מהממשק, `created_at`, או פעולה גורפת על `code_snippets` ו-`large_files` | `bugbot-rules/logical-entity-vs-version-document.md` | | שליחה לספק עם תקרת קלט (טוקנים, אורך שדה), או `value[:LIMIT]` לפני שמירה | `bugbot-rules/silent-truncation-at-sink.md` | diff --git a/mcp_server/outline.py b/mcp_server/outline.py index da2451415..e102628cb 100644 --- a/mcp_server/outline.py +++ b/mcp_server/outline.py @@ -34,6 +34,13 @@ #: #: ``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)") #: **השורה היחידה שהמסלול הזה כותב, והיא נכתבת כאן ולא בסורקים.** כאן יש גם diff --git a/mcp_server/outline_scanners/_ceiling.py b/mcp_server/outline_scanners/_ceiling.py index 4edfe981a..ec29818e5 100644 --- a/mcp_server/outline_scanners/_ceiling.py +++ b/mcp_server/outline_scanners/_ceiling.py @@ -61,6 +61,12 @@ #: #: **מי שמשנה את המספר צריך למדוד מחדש את שתיהן.** העלאה בלי המדידה #: הראשונה מחזירה את מחלקת הכשל; הורדה בלי השנייה חוסמת קובץ אמיתי. +#: +#: **ויש כאן עותק שני של אותו מספר:** ``services/md_parser.py::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/requirements/base.txt b/requirements/base.txt index d00e73a05..ee826d15b 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -138,6 +138,37 @@ rapidfuzz==3.14.1 # CLI Tools # Flask-Limiter 3.5.0 דורש rich<14 — נצמיד לגרסה יציבה תואמת rich==14.2.0 + +# פארסר ה-Markdown של ``services/md_parser.py``, שבונה את מפת הסעיפים +# ל-``codekeeper_docs_get_section``. **הוא כבר היה מותקן כאן כתלות +# עקיפה** — ``rich`` מבקשת ``markdown-it-py>=2.2.0``, בטווח פתוח — ולכן +# הנעיצה הישירה אינה מוסיפה משטח התקנה; היא מונעת ששדרוג של ``rich``, +# או שינוי בפותר התלויות, יזיז את הפארסר בשקט. אותו נימוק בדיוק שכתוב +# למעלה על ``http-ece``. +# +# **והמספר עצמו נגזר מ-docs/requirements.txt ולא נבחר כאחרון.** +# ``myst-parser==4.0.1`` שנעוץ שם דורש ``markdown-it-py~=3.0`` ו- +# ``mdit-py-plugins>=0.4.1,~=0.4``. נמדד: התקנה של 4.2.0 יחד איתו נופלת +# ב-``ResolutionImpossible``, ו-``documentation-py39.yml`` מתקין את שתי +# הקבוצות באותה סביבה. כלומר נעיצה גבוהה יותר כאן הייתה סתירה בין שני +# קבצים באותו ריפו. נמדד גם הכיוון השני: **כל** חבילת הטסטים של +# הפארסר, כולל 11,543 הצורות מול cmark-gfm, עוברת על 3.0.0 בדיוק כמו +# על 4.2.0. (בלי מספר טסטים: הוא משתנה בכל תוספת טסט, כולל כאלה +# שמתווספים באותו PR, ומספר שמתיישן בכל שינוי לגיטימי הוא מספר שלומדים +# להתעלם ממנו.) +# +# המעבר ל-4.x גדור בשדרוג של myst-parser ל-5.x (שדורש ``~=4.2`` ו- +# ``mdit-py-plugins~=0.6``), והוא שינוי של שרשרת בניית התיעוד — לא של +# הפארסר. ``tests/test_md_parser.py`` מקבע את הקשר הזה כדי ששני הצדדים +# לא ייסחפו. +markdown-it-py==3.0.0 +# התוסף שמזהה front matter. הוא ההגדרה ש-MyST משתמש בה, ולכן הוא הכלל +# שקובע מהו front matter גם בקובצי ה-.md שתחת docs/ בריפו הזה. הוא נרשם +# על מופע הפארסר במקום שנכתוב את הכלל ביד: נמדד שכלל שכתבנו בעצמנו חולק +# על התוסף בחמש מתוך שתים-עשרה צורות — `---` מוזח, סוגר מוזח בארבעה +# רווחים, ארבעה מקפים, סוגר ארוך מהפותח, ופותח שיש אחריו טקסט. הטבלה +# עצמה ב-`tests/test_md_parser.py`, והמספר נגזר ממנה ולא מוקלד פעמיים. +mdit-py-plugins==0.4.2 typer==0.20.0 # Resilience utilities diff --git a/requirements/development.txt b/requirements/development.txt index 869234729..b7f58a79d 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -1,6 +1,13 @@ # Development requirements - extend production -r production.txt +# האורקל הבלתי-תלוי של tests/test_md_parser_oracle.py: ספריית cmark-gfm +# עצמה, כלומר הפארסר ש-GitHub מריץ בפועל. **לטסטים בלבד** — הפרודקשן +# משתמש ב-markdown-it-py — והיא כאן ולא ב-base.txt כי אורקל שגוזר את +# הציפייה מאותו מימוש שהוא בודק אינו אורקל. הרחבת C עם גלגלי manylinux +# ל-cp311 ול-cp312, שתי הגרסאות שרצות ב-CI, ולכן אין קומפילציה בהתקנה. +cmarkgfm==2025.10.22 + # Test frameworks pytest==8.4.2 pytest-asyncio==1.2.0 diff --git a/scripts/compare_md_parser_to_cmark.py b/scripts/compare_md_parser_to_cmark.py new file mode 100755 index 000000000..e655b3d26 --- /dev/null +++ b/scripts/compare_md_parser_to_cmark.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""משווה את ``services/md_parser`` מול cmark-gfm על **כל** קובצי ה-``.md`` +בריפו שמעבירים לו. + +**מה מושווה, כדי שהתוצאה לא תיקרא רחבה ממה שהיא:** לכל כותרת ברמת +המסמך — **הרמה ומספר השורה**. טקסט הכותרת אינו מושווה כאן, כי אצלנו +הוא המקור הגולמי ומ-cmark חוזר HTML מרונדר. הצד הזה נבדק בנפרד ועל +תת-קבוצה, ב-``tests/test_md_parser_oracle.py``. + +**למה הוא קיים, ולמה זה לא טסט.** ``tests/test_md_parser_oracle.py`` רץ +ב-CI על **צורות מחוללות** ולא על קבצים אמיתיים, וזו החלטה: קורפוס +אמיתי הוא בדיקת שפיות חד-פעמית ולא רשת שתופסת רגרסיה עתידית, והוא היה +דורש להחזיק בריפו הזה עותק של תוכן שאינו שלו — עותק שמתיישן ברגע +שהמקור זז. הסקריפט הוא מה שמחליף אותו: אותה השוואה בדיוק, על הקורפוס +החי, **על פי דרישה**. + +**שלוש הנקודות בזמן שבהן כדאי להריץ אותו:** לפני שלב 2 (האאוטליין +ל-``.md``), אחרי שדרוג של ``markdown-it-py``, וכשנוגעים ב- +``services/md_parser.py``. + +**ואותה השוואה פירושה אותו קוד.** האורקל אינו נכתב כאן מחדש אלא נטען +מקובץ הטסטים, כי שני עותקים של "מה נחשב הסכמה" היו נסחפים זה מזה — +והראשון שהיה נשבר הוא זה שרץ לעיתים רחוקות, כלומר הסקריפט. + +שימוש:: + + python scripts/compare_md_parser_to_cmark.py /path/to/amir-bug-patterns + python scripts/compare_md_parser_to_cmark.py /path/to/repo --out /tmp/report.txt + +דורש ``cmarkgfm``, שנעוץ ב-``requirements/development.txt``. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import sys +from pathlib import Path + +_REPO = Path(__file__).resolve().parent.parent +# לפני כל ייבוא מ-``services``: הסקריפט רץ כנקודת כניסה עצמאית, +# ו-``scripts`` אינה חבילה. +sys.path.insert(0, str(_REPO)) + +from services.doc_sections import InconsistentLineEndings, TooManySections # noqa: E402 + + +def _load_oracle(): + """טוען את האורקל מקובץ הטסטים — הגדרה אחת לשני המריצים.""" + spec = importlib.util.spec_from_file_location( + "md_parser_oracle", _REPO / "tests" / "test_md_parser_oracle.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + # הנתיב הוא ארגומנט **חובה** ובלי ברירת מחדל: ברירת מחדל ל-``.`` + # הייתה גורמת לסקריפט לסרוק את הריפו שהוא עצמו יושב בו, ולדווח + # "אפס פערים" על קורפוס שאינו הקורפוס. + parser.add_argument("repo", type=Path, help="שורש הריפו שקובצי ה-.md שלו ייסרקו") + parser.add_argument("--out", type=Path, default=None, help="קובץ דוח (ברירת מחדל: stdout)") + args = parser.parse_args(argv) + + root: Path = args.repo.expanduser().resolve() + if not root.is_dir(): + parser.error(f"אינו תיקייה: {root}") + + oracle = _load_oracle() + files = sorted(p for p in root.rglob("*.md") if ".git" not in p.parts) + if not files: + parser.error(f"אפס קובצי .md תחת {root} — זה כישלון, לא ריצה ריקה") + + lines: list[str] = [] + skipped: list[str] = [] + mismatched = 0 + compared = 0 + total_headings = 0 + for path in files: + name = path.relative_to(root) + # **``read_bytes().decode`` ולא ``read_text``.** האחרון פותח את + # הקובץ במצב טקסט עם universal newlines וממיר כל ``\r`` ל- + # ``\n`` לפני שהמחרוזת מגיעה לפארסר — כלומר + # ``InconsistentLineEndings`` לא הייתה יכולה להידלק כאן על שום + # קובץ, והסקריפט היה מדווח "אפס אי-הסכמות" גם על מחלקת קלט + # שבורה לגמרי. ``newline=""`` אינו פתרון: הפרמטר נוסף ל- + # ``Path.read_text`` רק בפייתון 3.13, וה-CI רץ על 3.11 ו-3.12. + # + # **וכל קובץ עומד בפני עצמו.** הסקריפט מכוון על ריפו זר, ולכן + # קובץ מוזר הוא המקרה הצפוי ולא החריג — נפילה עליו הייתה מוחקת + # גם את התוצאות של כל מה שכבר נסרק, כי הדוח נבנה אחרי הלולאה. + # **``utf-8`` ולא ``utf-8-sig`` — בכוונה.** הסקריפט הוא בדיקה של + # הפארסר, ו-``parse_document`` הוא זה שמסיר BOM. אילו הפענוח כאן + # היה מסיר אותו קודם, רגרסיה בדיוק בהתנהגות הזאת הייתה בלתי + # נראית מכאן. הייצור מפענח אחרת — ``git_mirror_service. + # _try_decode_content`` משתמש ב-``utf-8-sig`` — וזה בסדר: שם + # המטרה היא תוכן נקי, כאן המטרה היא לראות מה הפארסר עושה. + try: + text = path.read_bytes().decode("utf-8") + ours = oracle._ours(text) + theirs = oracle._oracle_sections(text) + except UnicodeDecodeError: + skipped.append(f"⊘ {name} — אינו UTF-8") + continue + except OSError as exc: + skipped.append(f"⊘ {name} — לא ניתן לקריאה: {exc.strerror}") + continue + except InconsistentLineEndings: + skipped.append(f"⊘ {name} — \\r בודד, הפארסר סירב") + continue + except TooManySections as exc: + skipped.append(f"⊘ {name} — מעל התקרה, הפארסר סירב בשורה {exc.args[0]}") + continue + + compared += 1 + total_headings += len(theirs) + if ours != theirs: + mismatched += 1 + lines.append(f"✘ {name}") + lines.append(f" שלנו : {ours}") + lines.append(f" cmark: {theirs}") + + header = [ + f"ריפו: {root}", + f"קבצים: {len(files)} (הושוו {compared}, סורבו {len(skipped)})", + f"כותרות: {total_headings} (לפי cmark-gfm, ברמת המסמך)", + f"אי-הסכמות: {mismatched} — ההשוואה היא על **רמה ומספר שורה** בלבד,", + " ולא על טקסט הכותרת. ההנמקה בראש קובץ האורקל.", + "", + ] + if skipped: + header.extend(skipped) + header.append("") + report = "\n".join(header + lines) + "\n" + if args.out: + args.out.write_text(report, encoding="utf-8") + print(f"הדוח נכתב ל-{args.out}") + print(report) + + # **"אפס אי-הסכמות" על אפס קבצים שהושוו אינו הצלחה.** מאותו נימוק + # בדיוק שכתוב למעלה על ריפו בלי קובצי ``.md``: מספר שנראה טוב כי + # לא נבדק דבר הוא אישור שקרי, ומי שיראה ``exit 0`` יסיק שהפארסר + # מסכים עם cmark על הקורפוס הזה. + if not compared: + print("כל הקבצים סורבו — לא הושווה דבר, וזה כישלון ולא ריצה נקייה.") + return 1 + return 1 if mismatched else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate_ai_map.py b/scripts/generate_ai_map.py index 26f4d3880..b06b917d2 100644 --- a/scripts/generate_ai_map.py +++ b/scripts/generate_ai_map.py @@ -130,6 +130,22 @@ def _body_start(lines: list[str]) -> int: לפי ``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`` + כדי שלא ייסחף בשקט, והיעד — שהפונקציה הזאת תשתמש בתוסף — הוא + אישו נפרד. """ if not lines or lines[0].strip() != "---": return 0 diff --git a/services/doc_sections.py b/services/doc_sections.py index ef83e5c55..15294dc56 100644 --- a/services/doc_sections.py +++ b/services/doc_sections.py @@ -5,9 +5,9 @@ וההיררכיה, והפונקציות שעונות על שאלות מעל העץ — חיפוש כותרת, טווח, חיתוך טקסט, שכנים, תוכן עניינים והצעות. -**למה מודול נפרד, ולא "פשוט להשאיר ב-``rst_parser``".** מתוכנן צרכן שני -לאותו חוזה — פארסר Markdown (``services/md_parser.py``, ראו למטה מה -שאינו קיים עדיין). שני פארסרים שבונים ``Section`` משלהם ומחשבים +**למה מודול נפרד, ולא "פשוט להשאיר ב-``rst_parser``".** יש צרכן שני +לאותו חוזה — ``services/md_parser.py``, פארסר ה-Markdown. שני פארסרים +שבונים ``Section`` משלהם ומחשבים ``end_line`` משלהם הם **שתי הגדרות לאותו כלל**, וזה בדיוק הכשל שהמודול הזה קיים כדי למנוע: מי שיתקן באג בחישוב ההיררכיה יתקן אותו באחד משניהם, והשני יסטה בשקט. במודול הזה יש הגדרה אחת, ושני הפארסרים בונים לתוכה. @@ -25,11 +25,33 @@ front matter, גדרות קוד — כל אלה הם עניין של הפארסר שבונה את הסעיפים. כאן לא מופיעה אף מחרוזת שתלויה בתחביר של שפה אחת. -**ומה שעדיין לא קיים, כדי שהקורא לא ישלים מהדמיון:** נכון לכתיבת -השורות האלה יש **פארסר אחד בלבד** שבונה לתוך המודול הזה — -``services/rst_parser.py``. ``services/md_parser.py`` **אינו קיים**, והוא -ה-PR הבא בתוכנית. כלומר המודול הזה נכתב עבור צרכן שני מתוכנן, לא עבור -צרכן שני שכבר עובד. +**שני הפארסרים שבונים לתוך המודול הזה**, כדי שהקורא לא יצטרך לחפש: +``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. + +**ומי הבעלים של שני ערוצי הסירוב: המתקשר, והיום אין מתקשר שתופס אותם.** +``TooManySections`` ו-``InconsistentLineEndings`` אינן נתפסות בשום מקום +במסלול הזה — ``mcp_server/docs_handlers.py`` אינו מכיל ``except`` בכלל, +והקריאה היחידה שלו היא ``rst_parser.parse_document(content)``. מי שיחליף +שם פארסר מכניס שני מסלולי חריגה לאתר קריאה לא-מוגן, והמרתם לתשובת +``error`` של MCP היא עבודה של אותו שינוי — לא של המודול הזה, שאינו יודע +דבר על MCP. + +**ובדיקת הכניסה עצמה עדיין אינה זהה בין השניים.** נמדד: +``rst_parser.parse_document(None)`` מחזיר ``Document`` ריק **בשקט**, +ו-``rst_parser.parse_document(17)`` זורק ``AttributeError`` גולמי; שניהם +ב-``md_parser`` הם ``TypeError`` שאומר מה התקבל. הפארסר החדש הוא זה +שמתנהג נכון, ולכן אין כאן מה להחליש — אבל יישור של הוותיק הוא שינוי +התנהגות על מסלול חי, ולכן הוא **מתועד באישו #3421** ואינו נעשה כאן. **ייבוא קל בלבד** — ``re``, ``dataclasses``, ``difflib``, ``typing``. הכלל מנומק ב-``mcp_server/__init__.py``: ``import database.schemas`` מריץ @@ -97,7 +119,14 @@ class Document: class TooManySections(Exception): - """הקלט מייצר יותר סקשנים מהתקרה שהמתקשר העביר ב-``max_sections``. + """הקלט חוצה את התקרה שהמתקשר העביר ב-``max_sections``. + + **ומה בדיוק נספר תלוי בפארסר, וזה מכוון.** ב-``rst_parser`` נספרים + סקשנים, וב-``md_parser`` נספרות **כותרות** — כולל כותרת בתוך ציטוט + או פריט רשימה, שאינה נכנסת למפה בכלל. בשני המקרים התקרה מגבילה את + העבודה שהפרסור עושה, ובמסלול ה-Markdown כותרת בתוך מכל עולה בדיוק + כמו כל אחרת — ההנמקה המלאה ב-``services/md_parser.py::MAX_SECTIONS``. + המחלקה אחת כי מה שהקורא צריך לדעת זהה: הקלט גדול מדי, ואין מפה. **חריגה, ולא ערך החזרה ולא ``Document`` חלקי עם דגל "נקטע".** המתקשר היחיד שמעביר תקרה הוא סורק האאוטליין, ושם הכלל כבר נקבע ומנומק @@ -121,6 +150,34 @@ class TooManySections(Exception): """ +class InconsistentLineEndings(Exception): + """הקלט מערבב סיומות שורה, ולכן ספירת השורות שלו אינה חד-משמעית. + + הצורה היחידה שמפילה את ההבחנה היא ``\\r`` שאינו חלק מ-``\\r\\n`` — הפורמט + של Mac שלפני 2001. **CRLF אינו מושפע ועובד במלואו**, כי שם שתי + הספירות מסכימות, והוא המקרה הנפוץ. + + **למה זה חשוב דווקא כאן:** כל שדות ה-``lines`` בתשובות ה-MCP נספרים + ב-``split("\\n")``, ואילו ``markdown-it-py`` מנרמל לפי + ``rules_core/normalize.py`` עם ``\\r\\n?|\\n`` — כלומר ``\\r`` בודד הוא אצלו + שורה חדשה. בקובץ כזה המפה מצביעה לשורה ש-``lines=[start, end]`` לא + מגיעה אליה, ו**מפה שמצביעה לשומקום גרועה ממפה שאין**. + + **חריגה ולא ערך החזרה**, מאותו נימוק שכתוב ב-:class:`TooManySections` + ממש למעלה: ערך "עצור" דורש בדיקה בכל אתר קריאה, ובדיקה אחת שנשכחת + מחזירה מפה שגויה בשקט. + + **וכאן ולא בפארסר**, כדי שהמטפל שימיר אותה לתשובת MCP ייבא את שתי + חריגות הסירוב מאותו מודול, ושפארסר שלישי ירים את **אותה** מחלקה. + השם שהתשובה תישא בחוץ, ``inconsistent_line_endings``, כבר קיים + באוצר המילים — ``mcp_server/outline.py`` מחזיר אותו כ-``reason`` + על בדיוק אותה מחלקת קלט. + + **מה שהיא אינה:** היא אינה מתארת קובץ ב-CRLF, ואינה מתארת קידוד + שבור. רק ``\\r`` בודד. + """ + + def _finalize(sections: List[Section], total_lines: int) -> None: """מחשב end_line, parent/children, ו-breadcrumb לכל סקשן.""" for idx, sec in enumerate(sections): diff --git a/services/md_parser.py b/services/md_parser.py new file mode 100644 index 000000000..bd5306b20 --- /dev/null +++ b/services/md_parser.py @@ -0,0 +1,526 @@ +"""פארסר Markdown שבונה את מודל הסעיפים המשותף שב-``doc_sections``. + +מודול תאום ל-``services/rst_parser.py``: אותו ``Document``, אותם +``Section``, ואותן פונקציות עץ — ולכן ``mcp_server/docs_handlers.py`` +יוכל לבחור פארסר לפי סיומת ולהמשיך זהה בית-בית. כל מה שאינו תלוי-שפה +נקרא מ-``doc_sections`` ואינו נכתב כאן מחדש, ובכלל זה חישוב ה-``end_line`` +וההיררכיה. + +**למה ``markdown-it-py`` ולא פארסר משלנו.** נמדד מול cmark-gfm — הפארסר +ש-GitHub מריץ בפועל — על **11,543 צורות מחוללות**: אפס אי-הסכמות, למעט +מחלקה אחת ידועה ומקובעת של **8 צורות** — תגית HTML מסוג 7 שצמודה +לשורת פריט רשימה, שבה ``markdown-it`` (הפורט וגם המקור ב-JS) סוטה +מ-cmark-gfm וממימוש הייחוס של המפרט. היא אינה מתוקנת כאן — הנימוק +והאישו upstream ב-``tests/test_md_parser_oracle.py::_type7_after_list_shapes`` +— ואין לה מופע ב-521 הקבצים האמיתיים שנמדדו. שני המספרים אינם מוקלדים +כאן ביד אלא נגזרים מהמחוללים ומושווים למה שכתוב בשורות האלה +ב-``tests/test_md_parser_oracle.py::test_the_generated_shape_count_matches_the_prose``, +כדי שתוספת משפחת צורות בלי עדכון המשפט הזה תפיל את החבילה. + +ובנוסף, על קורפוס אמיתי — **93 קבצים ו-1,171 כותרות, אפס אי-הסכמות**. +המספר הזה **אינו מקובע בטסט** כי הוא תלוי בריפו חיצוני שמשתנה, ולכן הוא +מתוארך במקום: נמדד ב-20 בספטמבר 2026 על ``amir-bug-patterns`` בקומיט +``835bf04``, והפקודה שמפיקה אותו היא +``python scripts/compare_md_parser_to_cmark.py <נתיב לריפו>``. מי שירצה +לאמת יריץ אותה, ולא יסתמך על המספר. + +פארסר שנכתב ביד היה +צריך להוכיח את השקילות הזאת מחדש בכל שינוי, והסורק ל-RST שכן נכתב ביד +עבר ארבעה סבבי סקירה שכל אחד מהם מצא מה שקודמו פספס. המחיר מוצהר: הוא +איטי פי 10–25 מ-cmark-gfm, וזה נסבל בשכבה שחלה כאן. + +**מה שהמודול הזה לא עושה, כדי שהקורא לא ישלים מהדמיון:** + +* **אין חסם על מספר שורות.** התקרה שכאן סופרת **כותרות** — כל כותרת, + גם בתוך ציטוט ובתוך פריט רשימה — והזיכרון של ``markdown-it-py`` נגזר + ממספר ה**שורות**. כלומר קובץ שכולו שורות ריקות אינו מייצר אף כותרת, + התקרה אינה רואה אותו, ומחירו נשאר ליניארי בגודל הקלט: נמדד כ-107 + בתים לשורה, כלומר 10MB של שורות ריקות הם כג'יגה-בייט. + מה שחוסם אותם היום הוא תקרת ה-500KB של שירות המראה, שחלה על הקורא + היחיד המתוכנן (``docs_get_section`` קורא בלי ``lines`` ובלי + ``outline``). חסם שורות יידרש כשהמודול הזה ישרת גם את האאוטליין, ששם + התקרה היא 10MB. +* **אין דה-דופליקציה של כותרות ואין נרמול של טקסט הכותרת.** השם מוחזר + כפי שהוא במקור, עם בקטיקים, הדגשות וקישורים. +* **אין ``includes``** — לצורה הזאת אין מקבילה ב-Markdown, והשדה נשאר ריק. + +**ערוץ הכשל הוא חריגה בלבד.** ``parse_document`` אף פעם אינו מחזיר +``None``, ``Document`` חלקי, או מפה ריקה כדי לסמן כשל: קובץ בלי כותרות +מחזיר מפה ריקה **תקינה**, וכל סירוב הוא חריגה — ``TypeError``, +``InconsistentLineEndings`` או ``TooManySections``. זה נכתב במפורש כי +פונקציה עם שני ערוצי כשל היא בדיוק מה שמלכד את הקורא הבא. +""" + +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 .doc_sections import ( + Document, + InconsistentLineEndings, + Section, + TooManySections, + _finalize, + build_toc, + direct_subsections, + find_sections, + neighbors, + normalize_title, + section_bounds, + section_text, + suggest, +) + +# ``__all__`` מוצהר מאותו נימוק שכתוב ב-``rst_parser``: בלי ההצהרה, כל +# שם שמיובא כאן רק כדי להיות מיוצא מחדש מקבל ``F401`` מ-pyflakes, ו-``# +# noqa`` על כל אחד מהם היה מסתיר את האזהרה במקום להצהיר על הכוונה. +# +# והייצוא־מחדש עצמו אינו נוחות: הוא מה שהופך את שני הפארסרים ל**בני +# החלפה** — במודל, בפונקציות העץ, ב-``parse_document`` וב-``TooManySections``. +# ``docs_handlers`` יוכל לבחור מודול לפי סיומת ולקרוא לשמות האלה בלי אף +# ``if`` נוסף במסלול. +# +# **ומה שאינו משותף, כדי שהטענה לא תיקרא רחבה ממה שהיא:** ``MAX_SECTIONS`` +# ו-``InconsistentLineEndings`` מיוצאים רק מכאן. ל-``rst_parser`` אין +# תקרה קבועה (ברירת המחדל שלו היא ``None``), והוא **אינו מרים** את +# החריגה השנייה לעולם — ייצוא שלה משם היה מצהיר על סירוב שלא קיים. +# לכן המטפל ב-PR 5 מייבא את **שתי** חריגות הסירוב מ-``doc_sections``, +# לא ``parser.X``, כפי שכתוב ב-docstring של ``InconsistentLineEndings``. +# ההפרש בין שני ה-``__all__`` מקובע ב- +# ``tests/test_md_parser.py::test_the_two_parsers_export_the_same_names_but_two``. +__all__ = [ + # שם היסטורי: הוא סופר כותרות, גם כאלה שאינן נכנסות למפה. ההנמקה + # המלאה ב-``#:`` שמעל ההגדרה. + "MAX_SECTIONS", + "Document", + "InconsistentLineEndings", + "Section", + "TooManySections", + "build_toc", + "direct_subsections", + "find_sections", + "neighbors", + "normalize_title", + "parse_document", + "section_bounds", + "section_text", + "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)") + +#: המפתח שתחתיו יושב מונה ה**כותרות** ב-``env`` של פרסור בודד. +#: המחרוזת עצמה נשארה ``section`` כשמשמעות המונה השתנתה, כי היא +#: מזהה אטום פנימי ולא שם שמישהו קורא — אותו שיקול בדיוק שבגללו +#: ``MAX_SECTIONS`` לא שינה שם. +_ENV_COUNTER = "ck_md_section_counter" + + +class _SectionCounter: + """סופר **כותרות** תוך כדי הפרסור, בלי לסרוק טוקן פעמיים. + + **כל** ``heading_open``, גם כזה שיושב בתוך ציטוט או בתוך פריט + רשימה ולכן לא ייכנס למפה. זה מכוון: התקרה מגבילה **עבודה** ולא + **תוצאה**, ומה שמייקר את הפרסור הוא הכותרת עצמה — לא השאלה אם + היא נספרת כסעיף בסוף. נמדד: 500KB של ``> ## h`` הם ~64,000 + כותרות שאף אחת מהן אינה סעיף, וספירה לפי ``level == 0`` הייתה + רואה בהן אפס ולא עוצרת לעולם. + + **המצב יושב כאן ולא על המופע המשותף של ``MarkdownIt``.** המופע נבנה + פעם אחת ברמת המודול ונקרא ממספר חוטים — גופי הכלים רצים ב- + ``asyncio.to_thread`` — ולכן מונה שהיה יושב עליו היה מרוץ. ``env`` + הוא פר-פרסור ומועבר לתוך ``md.parse``, ואומת שהוא אותו אובייקט + שיוצא בסוף. + + **ו-``scanned`` הוא כל העניין.** כלל שסורק את ``state.tokens`` כולו + בכל קריאה הוא O(n²), וזה בדיוק מה שהופך תקרה שנועדה לחסוך עבודה + למקור העבודה. ``examined`` נספר כדי שאפשר יהיה לטעון על זה בטסט + במקום למדוד זמן שמתנדנד ב-CI. + """ + + __slots__ = ("max_sections", "seen", "scanned", "examined") + + def __init__(self, max_sections: int) -> None: + self.max_sections = max_sections + self.seen = 0 + self.scanned = 0 + self.examined = 0 + + def scan(self, tokens: List[Token]) -> bool: + """סורק את הטוקנים החדשים בלבד. ``True`` = התקרה נחצתה. + + **צורת** הגבול זהה ל-``Capped.append`` וְל-``add_section`` ב- + ``rst_parser`` — קלט עם בדיוק ``max_sections`` יחידות עובר + במלואו, והיחידה שמעליו עוצרת — אבל **היחידה עצמה שונה**: שם + סקשן, כאן כותרת, כולל כותרת בתוך מכל שלא תיכנס למפה. ראו את + ה-docstring של המחלקה. + """ + for token in tokens[self.scanned:]: + self.examined += 1 + if token.type == "heading_open": + self.seen += 1 + self.scanned = len(tokens) + return self.seen > self.max_sections + + +def _ceiling_rule(state, startLine: int, endLine: int, silent: bool) -> bool: + """כלל בלוק שאינו מייצר טוקנים — הוא רק סופר, ועוצר בתקרה. + + החתימה היא ``RuleFuncBlockType`` של ``markdown_it/parser_block.py``, + ו-``False`` פירושו "לא אני" — כלומר שרשרת הכללים ממשיכה כרגיל + והפלט זהה בדיוק לפלט בלי הכלל הזה. + + **למה בתוך הפרסור ולא אחריו:** ``ParserBlock.tokenize`` קורא לכל + כלל בתחילת כל בלוק, כולל בתוך ציטוטים ורשימות (הוא משתמש ב- + ``getRules("")``, שהיא **כל** הכללים). לכן חריגה כאן עוצרת את העבודה + באמצע. נמדד על 50 כותרות עם תקרה של 5: העצירה בשורה 23 מתוך 201. + בדיקה אחרי ``md.parse`` הייתה מסננת פלט אחרי שכל העבודה כבר נעשתה — + וזה בדיוק ההבדל שנמדד ב-``_ceiling.py``. + """ + counter: Optional[_SectionCounter] = state.env.get(_ENV_COUNTER) + if counter is not None and counter.scan(state.tokens): + raise TooManySections(startLine + 1) + return False + + +def _build_parser() -> MarkdownIt: + """המופע היחיד. + + **מה כן רץ כאן בזמן ייבוא:** הגדרה, ועוד פרסור חימום אחד של מסמך + בן שורה — ראו את הנימוק בסוף ה-docstring. אין רשת, אין דיסק, אין + תהליכון ואין ``os.environ``, כלומר אף אחד מהטריגרים של + ``import-time-side-effects``. והכלל ההוא אינו מסתפק בכך שהאובייקט + מותר: הוא דורש שמה שהאובייקט **מפעיל** בזמן היצירה ייבדק לגופו — + ולכן המחיר נמדד, והוא 0.05 מילישניות (0.10 בלי החימום מול 0.15 + איתו, חציון של 200 בניות). זה נאמר במפורש כי + "הגדרה בלבד" כבר **אינו** מדויק, ומשפט שמתיישן הוא בדיוק מה + שמונע מהקורא הבא לבדוק. + + שלוש החלטות, וכל אחת נמדדה: + + 1. **``commonmark`` כבסיס**, ולא ``gfm-like``: הוא מדליק בדיוק את + מה שנמדד כנחוץ, במקום להדליק הכול ולקוות. ההרחבה היחידה שנוספת + מעליו היא ``table`` — ראו (4). + 2. **``front_matter_plugin``** במקום סימון ידני של הבלוק. זו + ההגדרה ש-MyST משתמש בה, ולכן היא הכלל שקובע מהו front matter גם + בקובצי ה-``.md`` שתחת ``docs/`` בריפו הזה. נמדד שהיא **אינה מזיזה + אף מספר שורה** באף אחת מעשר הצורות שנבדקו — כולל בלוק שאינו נסגר, + ``---`` מוזח, ו-``---`` עם רווח בסוף — כי הטקסט עצמו אינו משתנה + כלל. וכלל שהיינו כותבים בעצמנו כן היה סוטה ממנה: נמדד שפרוטוטיפ + שמשווה ``---`` מדויק חולק עליה ב**חמש מתוך שתים-עשרה** צורות. + המספר נגזר מהטבלה ב-``tests/test_md_parser.py`` ומושווה למשפט + הזה ב-``test_the_measured_front_matter_gap_matches_the_prose``. + 3. **``disable("inline")``** — הכלל ה**ליבתי** בשם הזה, שהוא זה + שמפרק את תוכן הכותרת לטוקנים פנימיים. נמדד: עד 40% חיסכון בזמן + ו-37% בזיכרון, **ואפס שינוי בזיהוי הכותרות ובטווחים** — כי השם + נלקח מ-``token.content``, שהוא הטקסט הגולמי מהמקור בשני המצבים. + 4. **``enable("table")``** — הפריסט ``commonmark`` משאיר אותו כבוי, + וזו **הרחבת ה-GFM היחידה שמזיזה כותרת**. + + בלעדיו, שורת טבלה שאחריה ``---`` או ``===`` **צמודים** אינה טבלה + אלא פסקה, והקו שאחריה הופך אותה לכותרת setext: נמדד על + ``| a | b |`` / ``|---|---|`` / ``| 1 | 2 |`` / ``---`` שנוצר סעיף + ברמה 2 שכותרתו היא **הטבלה כולה**, והסעיף שמעליה נגמר לפני + הטבלה במקום אחריה — כלומר ``section_text`` שלו כבר אינו מכיל + אותה. עם הכלל, הפלט זהה בדיוק ל-cmark-gfm. + + **וארבע ההרחבות האחרות נבדקו ואינן משנות דבר**, ולכן הן נשארות + כבויות: ``strikethrough`` ו-``autolink`` הן inline בלבד, + ``tagfilter`` נוגע ברינדור ולא בפרסור, ו-``tasklist`` משנה פריט + רשימה ולא גבול בלוק. הערות שוליים אינן ברשימה כלל — + ``cmarkgfm.github_flavored_markdown_to_html`` מפעיל חמש הרחבות + בדיוק (``table``, ``autolink``, ``tagfilter``, ``strikethrough``, + ``tasklist``), ולכן ``[^1]: note`` הוא הגדרת קישור בשני הצדדים. + + ``before("front_matter", ...)`` ולא ``before("table", ...)``: הוא + מציב את התקרה ראשונה בשרשרת. **והעוגן הזה נכשל ב-``KeyError`` אם + התוסף לא נרשם על השרשרת** — כלומר מי שיסיר את ``.use(...)`` וישאיר + את השורה הזאת ייפול כאן, מיד, ולא בשקט בזמן פרסור. (תלות **חסרה** + היא מקרה אחר והיא נופלת מוקדם יותר, בייבוא שבראש הקובץ.) + + **ופרסור החימום שבסוף אינו קישוט.** ``markdown-it`` בונה את רשימת + הכללים של כל ruler **עצלה**, בקריאה הראשונה ל-``getRules``, בלי + מנעול — ו-``Ruler.__compile__`` מפרסם מילון ריק לפני שהוא ממלא + אותו. שלוש הקריאות למעלה רק מבטלות את המטמון ואינן בונות אותו + מחדש, ולכן בלי החימום המופע מתפרסם כשחלק ממנו עדיין ייבנה — בפרסור + הראשון, שהוא בדיוק הרגע שבו כמה חוטים יכולים להיות עליו יחד. + + **חימום, ולא מניית ה-rulers בשמם:** רשימת שמות מתיישנת ברגע שגרסה + עתידית מוסיפה ruler למסלול, ואז החלון נפתח שוב בלי שאיש יידע. + פרסור עובר באותו מסלול שפרסור אמיתי עובר בו, ולכן הוא מקמפל בדיוק + את מה שנצרך — לא פחות, וגם לא rulers שממילא אינם בשימוש. נמדד: + ``md.parse("")`` **אינו** מספיק, כי הוא משאיר את ``block.ruler`` לא + מקומפל; מסמך עם כותרת אחת מקמפל את ``core`` ואת ``block``, שהם + בדיוק שני ה-rulers שפרסור אמיתי מתייעץ איתם (``inline`` מכובה). + ``tests/test_md_parser.py::test_every_ruler_a_real_parse_uses_is_compiled_before_publish`` + מקבע את זה: הוא מרגל אחרי ``getRules`` ונכשל אם החימום פספס ruler. + """ + md = MarkdownIt("commonmark").use(front_matter_plugin).disable("inline").enable("table") + md.block.ruler.before("front_matter", "ck_max_sections", _ceiling_rule) + # בלי ``env``, ולכן ``state.env`` הוא מילון ריק, ``_ceiling_rule`` + # מקבל ``None`` ומחזיר ``False`` מיד — החימום אינו משאיר שום מצב. + md.parse("# ck warm-up\n") + return md + + +#: ההצבה עצמה אטומית, ולכן אף חוט אינו רואה כאן ``_MD`` חצי-בנוי. +#: +#: **וזה בדיוק מה שהטעה כאן קודם.** ל-``lazy-init-guard-publish-order`` +#: יש פטור מפורש ל"מצב שנבנה כולו לפני שהוא מתפרסם — בנייה במשתנים +#: מקומיים, הצבה אחת", והשורות האלה נראות כמוהו בדיוק. אבל הפטור נבדק +#: על הקוד שלנו בלבד, ובפועל **האובייקט שפרסמנו בונה חלק מעצמו +#: בעצלתיים**: ``markdown-it`` מקמפל את רשימת הכללים של כל ruler רק +#: בקריאה הראשונה ל-``getRules``, ו-``__compile__`` מפרסם מילון **ריק** +#: לפני שהוא ממלא אותו כלל-כלל. זה סעיף 2 בכלל, "פרסום לפני אימות": +#: המשתנה שנבדק הוא אמנם זה שמוחזר, אבל הוא מתפרסם לפני שהערך שבו שלם. +#: (**לא** סעיף 4 — שם מדובר במסלול מהיר מחוץ לנעילה, וכאן אין מנעול +#: בכלל, מה שמרחיב את החלון ולא מצמצם אותו.) הלקח: "בניתי לפני +#: שפרסמתי" חייב להיות נכון **גם על מה שבפנים**, ולא רק על ההצבה שלנו. +#: +#: מה שסוגר את זה הוא פרסור החימום ב-``_build_parser``, וההנמקה המלאה +#: שם. נמדד לפני ואחרי, ראו את גוף ה-PR. +_MD = _build_parser() + + +def parse_document(text: str, *, max_sections: Optional[int] = MAX_SECTIONS) -> Document: + """בונה ``Document`` מטקסט Markdown. + + **סדר הבדיקות בכניסה, והוא אינו שרירותי:** + + 1. ``isinstance(text, str)`` — הטקסט מגיע מחוץ לתהליך (קובץ מהמראה, + גוף בקשה), ו-``.replace`` על ערך שאינו מחרוזת היה מפיל + ``AttributeError`` ממקום שלא מסביר כלום. + 2. הסרת ``U+FEFF`` (BOM) **אחד, בתחילת המחרוזת בלבד** — ההנמקה + בהערה ליד הקוד. לפני בדיקת ה-``\\r``, כדי שמספר השורה שהחריגה + נושאת יתאים לטקסט שהפארסר באמת רואה. + 3. ``\\r`` בודד → :class:`~services.doc_sections.InconsistentLineEndings`. + 4. נרמול ``\\r\\n`` ← ``\\n``. ``splitlines()`` נשקל ונדחה באותו + נימוק שכתוב ב-``rst_parser``: הוא מפצל על עשרה תווים במקום אחד. + 5. סימון ה-front matter — בתוך הפרסור, על ידי התוסף. + 6. פרסור. + + **שני הנרמולים — הסרת ה-BOM ו-``\\r\\n`` ← ``\\n`` — הם היחידים + שמותרים, ומאותו תנאי בדיוק:** אף אחד מהם אינו משנה כמה שורות יש, + ולכן ``lines`` ומספרי השורות במפה נשארים מיושרים. + + **ה-front matter מטופל בתוך הפרסור ולא לפניו**, ולכן הוא שלב 5 ולא + שלב שלפני הבדיקות. אילו היינו מחליפים את שורות הבלוק לפני שלב 3, + הוא היה יכול לבלוע ``\\r`` בודד שיושב בתוכו, ולהסתיר בדיוק את + הקלט ששלב 3 קיים בשבילו. + + :param max_sections: התקרה, **במספר כותרות ולא במספר סעיפים** — + ראו :data:`MAX_SECTIONS`. ברירת המחדל היא :data:`MAX_SECTIONS`, + ו-``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``. + :raises ~services.doc_sections.TooManySections: הקלט מכיל יותר + כותרות מהתקרה. הארגומנט הוא מספר השורה שבה נעצרנו — מה שמבדיל + עצירה בתוך הפרסור מסינון של פלט אחריו. + """ + 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) + + normalized = text.replace("\r\n", "\n") + lines = normalized.split("\n") + + env: dict = {} + if max_sections is not None: + env[_ENV_COUNTER] = _SectionCounter(max_sections) + + tokens = _MD.parse(normalized, env) + + # **שער שני לאותה תקרה, ולא כפילות.** הכלל שבתוך הפרסור נקרא + # ב**תחילת** כל בלוק, ולכן הטוקנים שנדחפו אחרי הקריאה האחרונה שלו + # אינם נסרקים — כלומר קלט שחוצה את התקרה בכותרת האחרונה ממש היה + # חומק. זה בדיוק התקדים שכבר קיים במסלול ה-RST, שם ``add_section`` + # ו-``Capped.append`` שומרים על אותו מספר משני צדדים. + # + # **וזו סריקה אחרונה של אותו מונה, ולא ספירה שנייה של משהו אחר.** + # בדיקה על ``len(sections)`` הייתה סופרת סעיפים בזמן שהכלל שבפנים + # סופר כותרות, ואז שני השערים מודדים שני דברים — נמדד שקלט של + # כותרות בתוך ציטוט, שהאחרונה בהן היא הבלוק האחרון, חומק בין + # השניים בלי שום סירוב. ``scan`` מדלג על מה שכבר נסרק, ולכן + # הקריאה הזאת היא רק השארית. + counter: Optional[_SectionCounter] = env.get(_ENV_COUNTER) + if counter is not None and counter.scan(tokens): + # הארגומנט הוא **סוף הקובץ** ולא שורת הכותרת החורגת, וזה מכוון: + # זה המקום שאליו הפרסור באמת הגיע לפני שסירב. מספר שהיה מצביע על + # הכותרת עצמה היה נראה זהה לעצירה בתוך הפרסור, ואז הטענה "נעצרנו + # באמצע" לא הייתה ניתנת לבדיקה — נמדד בבדיקת מוטציה שבדיוק זה קרה. + raise TooManySections(len(lines)) + + sections = _sections_from_tokens(tokens, total_lines=len(lines)) + _finalize(sections, len(lines)) + return Document(lines=lines, sections=sections) + + +def _sections_from_tokens(tokens: List[Token], *, total_lines: int) -> List[Section]: + """הסעיפים שבמפה, לפי הכללים שהוכרעו במדידה. + + **רק ``token.level == 0``.** כותרת בתוך ציטוט או בתוך פריט רשימה + **מזוהה** ככותרת — הפארסר אינו מתווכח עם GitHub — אבל אינה סעיף + במסמך: היא חלק מהמכל שלה. ו**הטווח שלה שבור מעצם ההגדרה**, כי + ``end_line`` נמתח עד הכותרת הבאה באותה רמה או גבוהה ממנה, כלומר + הרבה מעבר לסוף הציטוט. נמדד: ברמת המסמך ``level`` הוא ``0``, בתוך + ציטוט ``1``, בתוך פריט רשימה ``2``, ובקינון עמוק יותר — יותר. + + **וה-``end_line`` כאן זמני.** ``token.map`` מכסה את שורת הכותרת + בלבד — ``(1, 1)`` ל-ATX ו-``(1, 3)`` ל-setext רב-שורתי — ואינו יודע + דבר על גוף הסעיף. החישוב האמיתי הוא של ``_finalize`` המשותף, וזו + הסיבה שהוא לא נכתב כאן מחדש. + """ + sections: List[Section] = [] + for index, token in enumerate(tokens): + if token.type != "heading_open" or token.level != 0: + continue + # ``is None`` ולא בדיקה בוליאנית: ``map`` הוא שדה של אובייקט + # SDK, והצורה הזאת נכונה תמיד. + # + # **וזו אינה נפילה-לאחור, היא הרמה.** דילוג שקט כאן היה מפיל + # סעיף בלי לוג, בלי מטריקה ובלי חריגה — כלומר "לא נמצא" ו"נשבר" + # נראים זהים לקורא, בדיוק הצורה שהמודול הזה מצהיר שאין בו. אומת + # מול ``rules_block/heading.py`` ו-``lheading.py``: כל מסלול + # שדוחף ``heading_open`` מציב ``map`` ללא תנאי, ולכן הענף אינו + # ניתן להגעה היום — והרמה כאן פירושה שהספרייה שינתה חוזה. + # + # ``RuntimeError`` ולא ``AssertionError``: האחרונה מסמנת שבירת + # הנחה פנימית, והמטפל שימפה חריגות לתשובת MCP יזהה טיפוסים + # מוכרים. **וזה אינו ערוץ סירוב רביעי** — שלושת הערוצים נשארים + # ``TypeError``, ``InconsistentLineEndings`` ו-``TooManySections``, + # וזה כאן הוא שגיאה פנימית שאין למשתמש מה לעשות איתה. + if token.map is None: + raise RuntimeError( + f"markdown-it דחף heading_open בלי map בשורה {index} — " + "חוזה הספרייה השתנה, ראו _sections_from_tokens" + ) + start = token.map[0] + 1 + sections.append( + Section( + title=_title_of(tokens, index), + level=int(token.tag[1:]), + title_line=start, + heading_line=start, + end_line=total_lines, + adornment=token.markup, + over=False, + ) + ) + return sections + + +def _title_of(tokens: List[Token], heading_index: int) -> str: + """טקסט הכותרת, גולמי מהמקור — למעט הרווחים שאינם חלק ממנו. + + עם ``inline`` כבוי, ``token.content`` של הטוקן שאחרי ה-``heading_open`` + הוא **כמעט** הטקסט כפי שנכתב: ה-``#`` וקו ה-setext מוסרים, הרווחים + מסביב מקוצצים, וכל הסימון נשמר — בקטיקים, הדגשה, קישורים, ו-``\\#`` + שעבר escape. **הסימון נשאר בכוונה**, וזו ההכרעה המקורית: הכותרת היא + מה שכתוב בקובץ. + + **ומה שכן מנורמל כאן, ולמה זה לא סותר את זה.** בכותרת setext + רב-שורתית, ``content`` נושא גם את ההזחה של שורות ההמשך ואת הרווחים + בסוף כל שורה — ושני אלה **אינם טקסט הכותרת אצל אף אחד**. נמדד: + ``"aaa\\n bbb\\n===\\n"`` מרונדר ל-````, ````, ````.
+ # הטקסט שלה נאסף כמו כל טקסט אחר, אבל הסימון שהוליד אותה
+ # כבר אבד, ובדיוק לכן הכותרות האלה מוחרגות מהשוואת הטקסט.
+ self._depth += 1
+ self.open_tags.append(tag)
+
+ def handle_data(self, data):
+ if self._depth:
+ self.texts[-1] += data
+
+ def handle_endtag(self, tag):
+ # אותה החרגה כמו בפתיחה, ומאותה סיבה: ``
`` מגיע כ-
+ # ``handle_startendtag``, כלומר פתיחה **וסגירה** — בלי ההחרגה
+ # הזאת הוא היה מוריד את המונה בלי שהעלה אותו.
+ if tag in ("hr", "br", "img"):
+ return
+ if self._depth:
+ self._depth -= 1
+ if tag in self.open_tags:
+ # סגירה של התגית הפתוחה האחרונה מאותו שם
+ del self.open_tags[len(self.open_tags) - 1 - self.open_tags[::-1].index(tag)]
+
+
+#: הפארסר ששימש **אך ורק** כדי לאתר את גבול ה-front matter באורקל. זו
+#: הספרייה עצמה ולא כלל שכתבתי, ולכן האורקל נשאר בלתי תלוי במימוש.
+_FRONT_MATTER_PROBE = MarkdownIt("commonmark").use(front_matter_plugin).disable("inline")
+
+
+def _without_front_matter(text: str) -> str:
+ """מחזיר את הטקסט כשה-front matter הוחלף בשורות ריקות.
+
+ **זו השכבה שחסרה ל-cmark-gfm, וזה נמדד ולא הונח.** GitHub מטפל
+ ב-front matter בשכבה נפרדת **מעל** cmark, ולכן ההתנהגות של cmark
+ לבדו על קובץ שפותח ב-``---`` אינה "מה ש-GitHub עושה": היא מה
+ ש-GitHub עושה **אחרי** שהשכבה שמעליו כבר הסירה את הבלוק. בלי
+ ההשלמה הזאת האורקל מדווח כותרת על שורה שיושבת בתוך ה-front matter
+ — נמדד על ``---\n### רמה 3\n---\n``, שבו cmark לבדו מחזיר ``h3``
+ בשורה 2.
+
+ ההחלפה היא בשורות ריקות ולא במחיקה, כדי שמספרי השורות של כל
+ השאר יישארו זהים — וזו בדיוק ההתאמה למה שהפארסר שנבדק רואה, שם
+ התוסף בולע את הבלוק והשאר נשאר במקומו.
+ """
+ for token in _FRONT_MATTER_PROBE.parse(text, {}):
+ if token.type == "front_matter" and token.map is not None:
+ lines = text.split("\n")
+ start, end = token.map
+ return "\n".join([""] * (end - start) + lines[end:])
+ return text
+
+
+def _oracle_all_headings(text: str) -> list[tuple[int, int, bool]]:
+ """כל הכותרות לפי cmark-gfm: (רמה, שורה, בתוך מכל)."""
+ html = cmarkgfm.github_flavored_markdown_to_html(
+ _without_front_matter(text), options=Options.CMARK_OPT_SOURCEPOS
+ )
+ collector = _HeadingCollector()
+ collector.feed(html)
+ collector.close()
+ return collector.headings
+
+
+def _oracle_sections(text: str) -> list[tuple[int, int]]:
+ """(רמה, שורה) לכל כותרת **ברמת המסמך**."""
+ return [(level, line) for level, line, inside in _oracle_all_headings(text) if not inside]
+
+
+def _ours(text: str) -> list[tuple[int, int]]:
+ return [(s.level, s.heading_line) for s in parse_document(text).sections]
+
+
+#: תווים שפותחים סימון פנימי ב-CommonMark, או שמייצגים משהו שעבר
+#: רינדור בדרך ל-HTML. כותרת שהמקור שלה נקי מכולם חוזרת מ-cmark
+#: **כטקסט זהה**, ולכן אפשר להשוות אותה מילה במילה.
+_INLINE_MARKUP = frozenset("`*_[]<>&\\")
+
+
+def _has_inline_markup(title: str) -> bool:
+ return any(ch in _INLINE_MARKUP for ch in title)
+
+
+def _oracle_titles(text: str) -> list[tuple[int, int, str]]:
+ """(רמה, שורה, **טקסט**) לכל כותרת ברמת המסמך, לפי cmark-gfm."""
+ collector = _HeadingCollector()
+ collector.feed(
+ cmarkgfm.github_flavored_markdown_to_html(
+ _without_front_matter(text), options=Options.CMARK_OPT_SOURCEPOS
+ )
+ )
+ collector.close()
+ return [
+ (level, line, title)
+ for (level, line, inside), title in zip(collector.headings, collector.texts)
+ if not inside
+ ]
+
+
+def _compare_titles(shapes) -> int:
+ """משווה את **טקסט** הכותרת, ורק על הכותרות שאפשר להשוות.
+
+ **למה בכלל תת-קבוצה, ולא כל הכותרות.** אצלנו ``title`` הוא המקור
+ הגולמי כפי שנכתב בקובץ — ``inline`` מכובה, ולכן ``` `code` ``` נשאר
+ ``` `code` ```. מ-cmark חוזר **HTML מרונדר**, ושם אותה כותרת היא
+ ``code``; אחרי שמסירים את התגיות נשאר ``code``, בלי
+ הבקטיקים. השוואה ישירה בין השניים הייתה נכשלת על כל כותרת שיש בה
+ סימון — לא כי מישהו טועה, אלא כי הם מתארים שני דברים שונים. לכן
+ מושווה רק מה שאין בו סימון פנימי בכלל, ושם **אין** למה להיות פער.
+
+ **וזו פונקציה נפרדת ולא הרחבה של** ``_ours``/``_oracle_sections``.
+ הטאפל ``(רמה, שורה)`` שלהן נצרך בשישה מקומות ובסקריפט ההשוואה
+ החיצוני; הרחבה שלו הייתה גוררת את כולם בשביל שדה שרובם אינם
+ בודקים.
+
+ :returns: כמה כותרות באמת הושוו. המתקשר מוודא שזה אינו אפס — "אפס
+ אי-הסכמות" על אפס השוואות הוא אישור שקרי, אותו נימוק שכתוב
+ ב-``scripts/compare_md_parser_to_cmark.py`` על ריצה בלי קבצים.
+ """
+ compared = 0
+ mismatches = []
+ for text in shapes:
+ ours = [(s.level, s.heading_line, s.title) for s in parse_document(text).sections]
+ theirs = _oracle_titles(text)
+ # **לא ``continue`` על פער מיקום.** הצורות האלה הן אותן צורות
+ # ש-``_compare`` כבר מאשר עליהן הסכמה מלאה על רמה ושורה, ולכן
+ # פער כאן פירושו שמשהו אחר נשבר — ודילוג שקט היה מסתיר אותו.
+ assert [(lvl, ln) for lvl, ln, _ in ours] == [
+ (lvl, ln) for lvl, ln, _ in theirs
+ ], f"פער מיקום שאמור להיתפס ב-_compare: {text!r}"
+ for (lvl, line, mine), (_, _, rendered) in zip(ours, theirs):
+ if _has_inline_markup(mine):
+ continue
+ compared += 1
+ if mine != rendered:
+ mismatches.append((text, mine, rendered))
+ assert not mismatches, (
+ f"{len(mismatches)} כותרות שטקסטן נבדל מ-cmark-gfm. הראשונות:\n"
+ + "\n".join(
+ f" קלט={text!r}\n שלנו={mine!r}\n cmark={rendered!r}"
+ for text, mine, rendered in mismatches[:5]
+ )
+ )
+ return compared
+
+
+def _compare(shapes) -> None:
+ """מריץ את שני הצדדים על כל צורה, ומדווח את **כל** הפערים.
+
+ לא ``assert`` בתוך הלולאה: פער יחיד היה מסתיר את השאר, ומספר
+ הפערים הוא מה שמבדיל בין באג נקודתי למחלקה שלמה שנשברה.
+ """
+ mismatches = []
+ for text in shapes:
+ ours, theirs = _ours(text), _oracle_sections(text)
+ if ours != theirs:
+ mismatches.append((text, ours, theirs))
+ assert not mismatches, (
+ f"{len(mismatches)} אי-הסכמות מול cmark-gfm. הראשונות:\n"
+ + "\n".join(
+ f" קלט={text!r}\n שלנו={ours}\n cmark={theirs}"
+ for text, ours, theirs in mismatches[:5]
+ )
+ )
+
+
+# ════════════════════════════════════════════════════════════════════
+# אוצר השורות — הצורות מהמדידה, כולל כאלה שאין להן מופע בקורפוס
+# ════════════════════════════════════════════════════════════════════
+
+_RLM = ""
+
+_PREVIOUS_LINES = [
+ "",
+ "טקסט רגיל",
+ "# כותרת קודמת",
+ "> ציטוט",
+ "- פריט",
+ "```",
+ "~~~",
+ " קוד מוזח",
+ "\tקוד בטאב",
+ "| a | b |",
+ "|---|---|",
+ "---",
+ "===",
+ ":::",
+ "",
+]
+
+_FOCUS_LINES = [
+ "# רמה 1",
+ "## רמה 2",
+ "### רמה 3",
+ "#### רמה 4",
+ "##### רמה 5",
+ "###### רמה 6",
+ "####### שבע סולמיות",
+ "#בלי רווח",
+ "## סולמית סוגרת ##",
+ "###",
+ f"## {_RLM}כותרת עם RLM",
+ "## כותרת עם `בקטיקים`",
+ "## **מודגש**",
+ "## [קישור](https://example.com)",
+ "## כותרת עם \\# escape",
+ "טקסט שעשוי להיות setext",
+ "---",
+ "===",
+ "```",
+ "```python",
+ "~~~",
+ ":::",
+ "\tכותרת בטאב",
+ " # כותרת מוזחת",
+ "> # כותרת בציטוט",
+ "- # כותרת בפריט",
+ "",
+ "",
+]
+
+_NEXT_LINES = [
+ "",
+ "===",
+ "---",
+ "טקסט אחרי",
+ "# כותרת אחרי",
+ "```",
+ " קוד מוזח",
+ "\tקוד בטאב",
+ "|---|---|",
+ ":::",
+ "",
+ "",
+ "> ציטוט אחרי",
+]
+
+_ENDINGS = ["\n", ""]
+
+
+def _context_shapes():
+ for previous, focus, following, ending in itertools.product(
+ _PREVIOUS_LINES, _FOCUS_LINES, _NEXT_LINES, _ENDINGS
+ ):
+ yield f"{previous}\n{focus}\n{following}{ending}"
+
+
+@pytest.mark.parametrize("bucket", range(6))
+def test_the_context_matrix_agrees_with_cmark(bucket):
+ """המכפלה הקרטזית: שורה קודמת × שורת מוקד × שורה עוקבת × צורת סיום.
+
+ מחולקת לשישה דליים כדי שאף טסט לא יתקרב לתקרת הזמן של ``pytest``.
+ """
+ shapes = [s for i, s in enumerate(_context_shapes()) if i % 6 == bucket]
+ assert len(shapes) > 500, "הטבלה התכווצה — זו רגרסיה בכיסוי"
+ _compare(shapes)
+
+
+# ════════════════════════════════════════════════════════════════════
+# קינון גדרות — השורש של הבאג האמיתי שנמצא בקורפוס
+# ════════════════════════════════════════════════════════════════════
+
+def _fence_nesting_shapes():
+ """חיצונית × פנימית × הזחות × תו, ואחרי כל אחת כותרות.
+
+ **הצורה שמכילה את הבאג:** גדר פנימית עם שם שפה ובאותו אורך
+ כחיצונית. היא אינה יכולה לסגור — לסוגרת אסור שיהיה טקסט אחריה —
+ ולכן ה-``\\`\\`\\``` הבא סוגר דווקא את ה**חיצונית**, ומשם הזוגיות
+ הפוכה עד סוף הקובץ. זו בדיוק המחלקה שלא הייתה בטבלה של הסבב
+ הראשון.
+ """
+ for outer_len, inner_len, inner_indent, closing_indent, char in itertools.product(
+ (3, 4, 5), (3, 4, 5), (0, 2, 3, 4), (0, 3), ("`", "~")
+ ):
+ outer, inner = char * outer_len, char * inner_len
+ pad, close_pad = " " * inner_indent, " " * closing_indent
+ for inner_info in ("", "python"):
+ yield (
+ f"{outer}\n"
+ f"{pad}{inner}{inner_info}\n"
+ f"code\n"
+ f"{close_pad}{inner}\n"
+ f"{outer}\n"
+ f"\n## אחרי\n"
+ f"\n### עמוק יותר\n"
+ )
+
+
+@pytest.mark.parametrize("bucket", range(2))
+def test_fence_nesting_agrees_with_cmark(bucket):
+ shapes = [s for i, s in enumerate(_fence_nesting_shapes()) if i % 2 == bucket]
+ assert len(shapes) > 100, "טבלת הקינון התכווצה"
+ _compare(shapes)
+
+
+def test_a_fence_with_a_language_name_cannot_close_its_own_block():
+ """המקרה הבודד שהיה באג אמיתי בקובץ בריפו, מקובע בנפרד.
+
+ בלי הצורה הזאת בטבלה, שמונה שורות שנראות בדיוק כמו כותרות סעיף
+ נבלעות בתוך בלוק קוד — ופארסר שמציג אותן היה מראה סעיפים
+ ש-GitHub מסתיר.
+ """
+ text = "```\n```python\ncode\n```\n\n## נבלעת?\n"
+ assert _ours(text) == _oracle_sections(text)
+
+
+# ════════════════════════════════════════════════════════════════════
+# בלוקי HTML — צמודים ומופרדים
+# ════════════════════════════════════════════════════════════════════
+
+def _html_shapes():
+ """שש תגיות, צמודות ומופרדות, עם ובלי מאפיינים, והכותרת בשלושה מקומות.
+
+ ``span`` ו-``unknown-tag`` כאן בכוונה: הן אינן ברשימת התגיות של
+ CommonMark, ולכן הן נופלות למחלקת בלוק ה-HTML מסוג 7 — שהיא זו
+ שדורשת שורה ריקה לפניה. ההבדל בין "צמוד" ל"מופרד" הוא בדיוק מה
+ שמפעיל או מכבה את המחלקה הזאת.
+ """
+ tags = ("div", "details", "table", "p", "span", "unknown-tag")
+ for tag, attributes, blank_before, blank_inside, position, blank_before_close in (
+ itertools.product(
+ tags, ("", ' class="x"'), (True, False), (True, False),
+ ("inside", "after", "both"), (True, False),
+ )
+ ):
+ opening = f"<{tag}{attributes}>"
+ gap_before = "\n" if blank_before else ""
+ gap_inside = "\n" if blank_inside else ""
+ gap_close = "\n" if blank_before_close else ""
+ body = "## בתוך הבלוק\n" if position in ("inside", "both") else "טקסט\n"
+ tail = "\n## אחרי הבלוק\n" if position in ("after", "both") else ""
+ yield (
+ f"# לפני\n{gap_before}"
+ f"{opening}\n{gap_inside}"
+ f"{body}{gap_close}"
+ f"{tag}>\n"
+ f"{tail}"
+ )
+
+
+_HTML_EDGE_CASES = [
+ "\n## בלי סגירה\n",
+ "\n\n## אחרי הערה\n",
+ "\n## צמוד להערה\n",
+ "\n\n## אחרי processing instruction\n",
+ "\n\n## בלוק שנסגר בשורה ריקה\n",
+]
+
+
+def test_html_blocks_agree_with_cmark():
+ _compare(list(_html_shapes()) + _HTML_EDGE_CASES)
+
+
+# ════════════════════════════════════════════════════════════════════
+# טבלאות — המחלקה שהמטריצה למעלה אינה מייצרת
+# ════════════════════════════════════════════════════════════════════
+#
+# **למה משפחה נפרדת ולא עוד שורה באוצר המילים.** טבלה היא **שתי שורות
+# צמודות** — שורת תאים ומיד אחריה שורת מפריד — והמטריצה למעלה בונה כל
+# צורה משורה קודמת, שורת מוקד ושורה עוקבת, ולכן היא לעולם אינה מציבה
+# את השתיים זו אחרי זו. זה בדיוק החור שדרכו עברה רגרסיה אמיתית: בלי
+# ``enable("table")`` הטבלה היא פסקה, ו-``---`` שאחריה הופך אותה לכותרת
+# setext.
+
+
+def _table_shapes():
+ """טבלה × מה שבא אחריה × האם יש שורת גוף, ואחריה כותרת."""
+ followers = ("---", "===", "", "## כותרת אחרי", "text", "|---|---|", "-")
+ for follower, with_body, blank_before in itertools.product(
+ followers, (True, False), (True, False)
+ ):
+ body = "| 1 | 2 |\n" if with_body else ""
+ gap = "\n" if blank_before else ""
+ tail = f"{follower}\n" if follower else ""
+ yield f"## לפני\n\n| a | b |\n|---|---|\n{body}{gap}{tail}\n## הסעיף הבא\n"
+
+
+def test_tables_agree_with_cmark():
+ shapes = list(_table_shapes())
+ assert len(shapes) > 20, "טבלת הטבלאות התכווצה"
+ _compare(shapes)
+
+
+# ════════════════════════════════════════════════════════════════════
+# מכלים — cmark רואה את הכותרת, המפה שלנו לא
+# ════════════════════════════════════════════════════════════════════
+
+_CONTAINER_SHAPES = [
+ "## לפני\n\n> ## בתוך ציטוט\n\n## אחרי\n",
+ "## לפני\n\n- ## בתוך פריט\n\n## אחרי\n",
+ "## לפני\n\n> > ## ציטוט בתוך ציטוט\n\n## אחרי\n",
+ "## לפני\n\n1. ## בתוך פריט ממוספר\n\n## אחרי\n",
+ "## לפני\n\n- - ## פריט בתוך פריט\n\n## אחרי\n",
+ "> ## ציטוט בלבד\n",
+]
+
+
+@pytest.mark.parametrize("text", _CONTAINER_SHAPES)
+def test_a_heading_inside_a_container_is_seen_by_cmark_and_excluded_by_us(text):
+ """הסינון הוא חלק מההשוואה, לא חריגה ממנה.
+
+ האורקל **כן** מזהה את הכותרת — היא כותרת, ו-GitHub מרנדר אותה —
+ ולכן הטסט דורש את שני הדברים יחד: שהיא קיימת אצלו כשהיא בתוך מכל,
+ ושהיא אינה במפה שלנו.
+ """
+ all_headings = _oracle_all_headings(text)
+ assert any(inside for _lvl, _line, inside in all_headings), "הצורה אינה מכילה מכל"
+ assert _ours(text) == _oracle_sections(text)
+
+
+# ════════════════════════════════════════════════════════════════════
+# הפער הידוע היחיד — תגית HTML מסוג 7 צמודה לשורת פריט רשימה
+# ════════════════════════════════════════════════════════════════════
+
+#: תגיות שאינן ברשימת ה-block של CommonMark ולכן נופלות ל**סוג 7**: תג
+#: פתיחה שלם (לא ``pre``/``script``/``style``/``textarea``) או תג סגירה
+#: שלם, לבדו בשורה. ```` הוא סוג 7 כי הוא **סגירה**: הכלל של סוג 1
+#: תופס רק פתיחה.
+_TYPE7_TAGS = ("
", "", "", '
')
+_LIST_MARKERS = ("- ", "1. ")
+
+
+def _type7_after_list_shapes():
+ """(טקסט, האם שני הפארסרים מסכימים) — תגית מסוג 7 אחרי שורת פריט.
+
+ **זו המחלקה היחידה שידועה בה אי-הסכמה**, והיא נמדדה: כשהתגית
+ **צמודה** לשורת הפריט, ``markdown-it`` (הפורט ל-Python **וגם**
+ המקור ב-JS, 14.3.2 — נמדד, הפורט נאמן) רואה בה המשך עצל של פסקת
+ הפריט, ולכן ``## אחרי`` שאחריה הוא כותרת; cmark-gfm סוגר את הרשימה,
+ פותח בלוק HTML, והבלוק בולע את ``## אחרי`` עד שורה ריקה. **מימוש
+ הייחוס של המפרט** (``commonmark.py`` 0.9.2, פורט של commonmark.js)
+ מסכים עם cmark — כלומר זו סטייה של ``markdown-it`` ולא שלנו, ולא של
+ GitHub.
+
+ עם **שורה ריקה** בין הפריט לתגית שני הצדדים מסכימים, וזה מה שהופך
+ את החצי הזה למקרה בקרה: הוא עובר ב-``_compare`` כמו כל משפחה אחרת.
+
+ **ולמה זה לא מתוקן אצלנו.** התיקון המתבקש — להפוך את דגל
+ ה-terminate של סוג 7 ב-``rules_block/html_block.py`` — נמדד ונדחה:
+ הוא גורם ל-``
`` להפריע לפסקה גם **בלי** רשימה, ושם שני הצדדים
+ מסכימים היום. כלומר הוא מחליף אי-הסכמה אחת באחרת. התיקון הנכון
+ הוא בדיקה מודעת-מכל כמו של cmark, והוא שייך ל-``markdown-it-py``
+ ולא לכלל שנכתוב ביד. **מתועד upstream** — טקסט האישו המלא, עם שלושת המימושים והצורה המינימלית,
+ בגוף PR #3418; הוא נפתח מסשן שיש לו גישה ל-``executablebooks/markdown-it-py``
+ ומספרו יוכנס כאן.
+ אפס מופעים ב-521 הקבצים האמיתיים שנמדדו (93 + 428).
+ """
+ for tag, marker, adjacent in itertools.product(_TYPE7_TAGS, _LIST_MARKERS, (True, False)):
+ gap = "" if adjacent else "\n"
+ yield f"## לפני\n\n{marker}פריט\n{gap}{tag}\n## אחרי\n", not adjacent
+
+
+def test_the_type7_family_agrees_when_a_blank_line_separates_it():
+ """חצי הבקרה של המשפחה עובר דרך אותה ``_compare`` כמו כולם."""
+ agreeing = [text for text, agrees in _type7_after_list_shapes() if agrees]
+ assert len(agreeing) == len(_TYPE7_TAGS) * len(_LIST_MARKERS)
+ _compare(agreeing)
+
+
+@pytest.mark.parametrize(
+ "text", [text for text, agrees in _type7_after_list_shapes() if not agrees]
+)
+def test_a_type7_tag_glued_to_a_list_item_still_disagrees_as_measured(text):
+ """**הטסט הזה מקבע פער מדוד, ולא התנהגות רצויה.**
+
+ אצלנו שני סעיפים — ``## לפני`` ו-``## אחרי`` — ואצל cmark-gfm אחד,
+ כי ``## אחרי`` נבלע לתוך בלוק ה-HTML. הצורה המדויקת נטענת כאן
+ בשני הצדדים, כדי שהפער לא יוכל להיסחף לכיוון שלישי בשקט.
+
+ .. warning::
+
+ **נפילה של הטסט הזה היא תוצאה מכוונת**, ופירושה ש-``markdown-it``
+ תיקנה את ההתנהגות (או ש-cmark שינתה את שלה). אז הצורה הזאת
+ עוברת לחצי המסכים של המשפחה, והמשפט על "המחלקה הידועה היחידה"
+ ב-``services/md_parser.py`` נמחק יחד איתה. אל "תתקן" את הטסט
+ הזה כאילו היה באג.
+ """
+ ours, theirs = _ours(text), _oracle_sections(text)
+ assert theirs == [(2, 1)], ("cmark-gfm שינה את התנהגותו", text, theirs)
+ assert [lvl for lvl, _ in ours] == [2, 2] and ours[0] == (2, 1), (
+ "markdown-it שינה את התנהגותו — ראו את האזהרה", text, ours,
+ )
+
+
+def test_the_excluded_class_is_exactly_the_glued_shapes_and_nothing_more():
+ """ההחרגה נאמרת בקול: מה שלא נכנס ל-``_compare`` — ולמה.
+
+ כמו ``compared > 100`` ב-``_compare_titles``: החרגה שקטה יכולה
+ להתרחב בלי שאיש ישים לב. כאן נטען שהקבוצה המוחרגת היא **בדיוק**
+ הצורות הצמודות, לא ריקה ולא רחבה מזה — ושכל אחת מהן באמת חלוקה,
+ כלומר ההחרגה מוצדקת מופע-מופע ולא כהנחה.
+ """
+ excluded = [text for text, agrees in _type7_after_list_shapes() if not agrees]
+ assert len(excluded) == len(_TYPE7_TAGS) * len(_LIST_MARKERS)
+ assert all("\n\n" not in text.split("פריט\n", 1)[1].split("\n## אחרי")[0] for text in excluded), (
+ "צורה מופרדת נכנסה לקבוצה המוחרגת"
+ )
+ for text in excluded:
+ assert _ours(text) != _oracle_sections(text), ("מוחרגת אבל מסכימה — ההחרגה רחבה מדי", text)
+
+
+# ════════════════════════════════════════════════════════════════════
+# המספר שבפרוזה נגזר מכאן, ולא מוקלד פעמיים
+# ════════════════════════════════════════════════════════════════════
+
+def test_the_generated_shape_count_matches_the_prose():
+ """כמה צורות באמת מושוות, ומה הדוקסטרינג מצהיר.
+
+ **זה כבר נסחף.** ``services/md_parser.py`` אמר 11,400,
+ ``requirements/base.txt`` אמר 11,507, והמחוללים ייצרו מספר שלישי —
+ כי משפחת הטבלאות נוספה בלי שאיש עדכן את המשפטים. מספר שמוקלד ביד
+ מתיישן בשקט; מספר שנגזר מהקוד מפיל את החבילה.
+
+ הספירה כאן חייבת לכלול **כל** משפחה שמגיעה ל-``_compare``. משפחה
+ חדשה שתישכח כאן תוריד את הסכום ותפיל — וזו התוצאה הרצויה.
+ """
+ import re
+ from pathlib import Path
+
+ type7 = list(_type7_after_list_shapes())
+ total = (
+ len(list(_context_shapes()))
+ + len(list(_fence_nesting_shapes()))
+ + len(list(_html_shapes()))
+ + len(_HTML_EDGE_CASES)
+ + len(list(_table_shapes()))
+ + len(_CONTAINER_SHAPES)
+ + sum(1 for _text, agrees in type7 if agrees)
+ )
+ excluded = sum(1 for _text, agrees in type7 if not agrees)
+ assert total > 10_000, f"מחולל נשמט מהספירה — {total} צורות בלבד"
+ assert excluded > 0, "המחלקה המוחרגת ריקה — הפרוזה על 'מחלקה ידועה אחת' כבר אינה נכונה"
+
+ source = (Path(__file__).resolve().parents[1] / "services" / "md_parser.py").read_text(
+ encoding="utf-8"
+ )
+ quoted = re.search(r"על \*\*([\d,]+) צורות מחוללות\*\*", source)
+ assert quoted, "המשפט על מספר הצורות אינו במקומו ב-``md_parser`` docstring"
+ assert quoted.group(1) == f"{total:,}", (
+ f"הדוקסטרינג אומר {quoted.group(1)} והמחוללים מייצרים {total:,}. "
+ "עדכן את שני המקומות שמצטטים אותו — ``services/md_parser.py`` "
+ "ו-``requirements/base.txt``."
+ )
+ # **וגם המספר של המחלקה המוחרגת נגזר, לא מוקלד.** אותה סחיפה בדיוק
+ # מחכה לו: תגית שתתווסף ל-``_TYPE7_TAGS`` בלי עדכון המשפט.
+ quoted_excluded = re.search(r"מחלקה אחת ידועה ומקובעת של \*\*(\d+) צורות\*\*", source)
+ assert quoted_excluded, "המשפט על המחלקה המוחרגת אינו במקומו ב-``md_parser`` docstring"
+ assert quoted_excluded.group(1) == str(excluded)
+
+ base = (Path(__file__).resolve().parents[1] / "requirements" / "base.txt").read_text(
+ encoding="utf-8"
+ )
+ assert f"{total:,} הצורות" in base, (
+ f"``requirements/base.txt`` אינו אומר {total:,} — אותו מספר, שני מקומות."
+ )
+
+
+# ════════════════════════════════════════════════════════════════════
+# טקסט הכותרת — התוספת של SUGG-010, על תת-קבוצה ובמפורש
+# ════════════════════════════════════════════════════════════════════
+
+
+@pytest.mark.parametrize("bucket", range(6))
+def test_the_heading_text_agrees_with_cmark_where_it_can_be_compared(bucket):
+ """אותה מטריצה בדיוק, והפעם על **הטקסט** ולא על המיקום.
+
+ מושווה רק מה שאין בו סימון פנימי — ההנמקה המלאה ב-
+ ``_compare_titles``. הדלי זהה לזה של מבחן המיקום, מאותה סיבה:
+ ``pytest.ini`` קובע ``timeout = 60``.
+ """
+ shapes = [s for i, s in enumerate(_context_shapes()) if i % 6 == bucket]
+ compared = _compare_titles(shapes)
+ # **"אפס אי-הסכמות" על אפס השוואות אינו הצלחה.** בלי השורה הזאת,
+ # שינוי ב-``_has_inline_markup`` שיחריג בטעות את כל הכותרות היה
+ # משאיר את הטסט ירוק בזמן שהוא כבר לא בודק דבר.
+ assert compared > 100, f"רק {compared} כותרות הושוו — הסינון בלע את הטבלה"
+
+
+#: הצורות שהמבחן למעלה מדלג עליהן, וכאן נאמר **למה**: לכל אחת, מה
+#: אנחנו מחזירים מול מה ש-cmark מחזיר אחרי הרינדור.
+_RENDERED_AWAY = [
+ ("## כותרת עם `בקטיקים`", "כותרת עם `בקטיקים`", "כותרת עם בקטיקים"),
+ ("## **מודגש**", "**מודגש**", "מודגש"),
+ ("## [קישור](https://example.com)", "[קישור](https://example.com)", "קישור"),
+ ("## כותרת עם \\# escape", "כותרת עם \\# escape", "כותרת עם # escape"),
+]
+
+
+@pytest.mark.parametrize("source, ours, cmark", _RENDERED_AWAY)
+def test_a_heading_with_inline_markup_is_excluded_on_purpose(source, ours, cmark):
+ """ההחרגה נאמרת בקול, כדי שלא תיראה כמו חור בכיסוי.
+
+ לכל צורה כאן שלוש טענות: הכותרת שלנו היא המקור הגולמי, של cmark
+ היא הטקסט אחרי הרינדור, **והשתיים באמת נבדלות**. זו הסיבה שהן
+ מוחרגות — ולא הנחה על מה שאולי יקרה.
+
+ .. note::
+
+ **הטסט הזה אינו יכול ליפול על הקוד שלפני הסבב הזה, וזה מכוון.**
+ הוא אינו שומר על תיקון אלא מקבע החלטה שלא השתנתה: שהכותרת אצלנו
+ היא המקור הגולמי. מי ש"יתקן" את ``_title_of`` להסיר גם סימון —
+ כלומר להחזיר טקסט מרונדר — יפיל אותו. ואת הכיוון ההפוך, צמצום
+ של ``_has_inline_markup`` שיפסיק להחריג אחת מהצורות, מפיל מבחן
+ הטקסט שלמעלה.
+ """
+ text = f"{source}\n"
+ assert _has_inline_markup(ours), "הצורה הזאת אמורה להיות מוחרגת"
+ assert [s.title for s in parse_document(text).sections] == [ours]
+ assert [title for _, _, title in _oracle_titles(text)] == [cmark]
+ assert ours != cmark, "אילו השתיים היו זהות, לא היה טעם להחריג"