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"`` מרונדר ל-``

aaa\\nbbb

`` גם + ב-``markdown-it-py`` עצמה וגם ב-cmark-gfm, ואילו ``content`` הגולמי + הוא ``"aaa\\n bbb"``. כלומר ההזחה שורדת אצלנו **רק** מפני + ש-``inline`` מכובה — היא ארטיפקט של אופטימיזציה, לא החלטה. + + הכלל שמיושם כאן הוא בדיוק זה של ``rules_inline/newline.py``, שהוא + הכלל שהיה רץ אילו ``inline`` היה דלוק: ``rstrip`` של **רווחים בלבד** + בסוף כל שורה שאינה האחרונה (שם גם ``" \\n"`` של hardbreak מתקפל), + ו-``lstrip`` של רווח **או טאב** בתחילת כל שורה שאינה הראשונה — + ‏``isStrSpace`` שם הוא ``("\\t", " ")`` ותו לא. ההתאמה נבדקת מול + cmark-gfm ב-``tests/test_md_parser_oracle.py::_compare_titles``. + + השארית המכוונת: ה-``\\n`` **עצמו** בין השורות נשמר, כי הוא כן חלק + מהכותרת. ``normalize_title`` מכווץ אותו לרווח, ולכן חיפוש בשורה אחת + מוצא כותרת שנפרסת על שתיים. + """ + following = tokens[heading_index + 1] if heading_index + 1 < len(tokens) else None + if following is None or following.type != "inline": + return "" + content = following.content.strip() + if "\n" not in content: + # המסלול של ATX ושל setext בשורה אחת — הרוב המוחלט, ובלי עבודה. + return content + parts = content.split("\n") + last = len(parts) - 1 + return "\n".join( + (part if index == 0 else part.lstrip(" \t")).rstrip(" ") if index < last + else (part if index == 0 else part.lstrip(" \t")) + for index, part in enumerate(parts) + ) diff --git a/services/rst_parser.py b/services/rst_parser.py index 62f51d72e..97570a782 100644 --- a/services/rst_parser.py +++ b/services/rst_parser.py @@ -298,6 +298,16 @@ def parse_document(text: str, *, max_sections: int | None = None) -> Document: לחסום. השארית הזאת נקבעת על ידי מספר השורות, והחסם היחיד עליה היום הוא תקרת הבתים של הקריאה. **מתועדת באישו #3379** — שם גם הכיוון שמשלים אותה, ומספר השורות ממילא מחושב כאן בראש הפונקציה. + + .. important:: + + **ברירת המחדל כאן הפוכה מזו של** ``services.md_parser.parse_document``\\ **, + שם היא** ``MAX_SECTIONS``. ההבדל מכוון ומוצהר בשני המקומות: כאן + ``None`` הוא מה ששמר על ``docs_get_section`` זהה בית-בית כשהפרמטר + נוסף לפארסר שכבר רץ בייצור, ושם אין התנהגות קודמת לשמר ולכן נבחרה + ההנחה היקרה. המשמעות שצריך להכיר: **קורא שאינו מעביר תקרה מקבל + הגנה מהפארסר האחד ולא מהשני.** יישור של הפונקציה הזאת נשקל בנפרד, + כי הוא שינוי התנהגות על מסלול חי. """ lines = (text or "").replace("\r\n", "\n").split("\n") n = len(lines) diff --git a/tests/test_md_parser.py b/tests/test_md_parser.py new file mode 100644 index 000000000..c0993295d --- /dev/null +++ b/tests/test_md_parser.py @@ -0,0 +1,1213 @@ +"""טסטים ל-``services/md_parser.py`` — שש ההכרעות שהתוכנית קבעה. + +**למה קריאה ישירה לפונקציה ולא דרך ממשק ה-MCP.** הכלל בפרויקט הוא +שטסט עובר דרך אותו ממשק כמו הצרכן. כאן אין צרכן: הפארסר נוסף ואינו +מחווט לאף כלי — ``docs_handlers`` ממשיך לקרוא ל-``rst_parser`` בלבד — +והחיווט הוא PR נפרד. לפונקציה טהורה בלי צרכן חיצוני, הקריאה הישירה +**היא** הממשק. כשיהיה צרכן, הטסטים שלו יעברו דרך ממשק ה-MCP. + +**וכל טענה כאן נמדדה בהרצה**, ולא נגזרה מקריאת הקוד של +``markdown-it-py``. ההפניות למקור הן לפי שם מודול ופונקציה ולא לפי +מספר שורה. +""" + +import re +from pathlib import Path + +import pytest +from markdown_it import MarkdownIt + +from services import doc_sections, md_parser + +_REPO = Path(__file__).resolve().parents[1] + + +# ════════════════════════════════════════════════════════════════════ +# הכרעה 1 — רק ``token.level == 0`` נכנס למפה +# ════════════════════════════════════════════════════════════════════ + +_CONTAINERS = """## אמיתית א + +טקסט + +> ## בתוך ציטוט + +- ## בתוך פריט רשימה + +## אמיתית ב + +סוף +""" + + +def test_a_heading_inside_a_container_is_not_a_section(): + """כותרת בתוך ציטוט או פריט רשימה מזוהה ככותרת ואינה סעיף במפה. + + נמדד ב-``markdown-it-py``: ברמת המסמך ``token.level`` הוא ``0``, + בתוך ציטוט ``1``, ובתוך פריט רשימה ``2``. + """ + doc = md_parser.parse_document(_CONTAINERS) + assert [s.title for s in doc.sections] == ["אמיתית א", "אמיתית ב"] + + +def test_the_container_does_not_cut_the_range_of_the_section_around_it(): + """זו הטענה שהמוטציה מפילה, ולא רק הספירה. + + הסעיף "אמיתית א" חייב להימשך עד הכותרת הבאה **ברמת המסמך**. אילו + הכותרות שבתוך המכלים היו נכנסות למפה, ``_finalize`` היה מסיים אותו + בשורה שלפני הציטוט — כלומר סעיף שנקטע באמצע. + """ + doc = md_parser.parse_document(_CONTAINERS) + first, second = doc.sections + assert first.end_line == second.heading_line - 1 + body = doc_sections.section_text(doc, first, True) + assert "בתוך ציטוט" in body, "גוף הסעיף חייב להכיל את הציטוט שיושב בתוכו" + + +# ════════════════════════════════════════════════════════════════════ +# BOM — מוסר בכניסה, ורק הוא +# ════════════════════════════════════════════════════════════════════ + +_BOM = "\ufeff" + + +def test_a_leading_bom_does_not_hide_the_headings(): + """קובץ עם BOM נותן **אותה** מפה כמו בלעדיו. + + בלי ההסרה ``markdown-it`` רואה פסקה שמתחילה בתו בלתי נראה ולא + כותרת, והפארסר החזיר **אפס סעיפים בלי חריגה** — מפה ריקה שמתחזה + לקובץ בלי כותרות. נמדד. הייצור לא נפגע (המראה מפענחת ב-``utf-8-sig``), + ולכן זה נתפס רק בסקירה, על הסקריפט. + """ + plain = "# Title\n\ntext\n\n## Sub\n" + with_bom = md_parser.parse_document(_BOM + plain) + without = md_parser.parse_document(plain) + assert [(s.level, s.heading_line, s.title) for s in with_bom.sections] == \ + [(s.level, s.heading_line, s.title) for s in without.sections] + assert with_bom.lines == without.lines, "ה-BOM חייב לרדת גם מ-lines[0]" + assert len(with_bom.sections) == 2 + + +def test_only_one_bom_and_only_at_the_start_is_removed(): + """‏``U+FEFF`` באמצע הטקסט הוא ZWNBSP — תוכן, ולא נוגעים בו. + + ושני BOM רצופים: רק הראשון הוא סימון קידוד. השני הוא כבר תו בגוף + השורה — וזה מה ש-cmark עושה בפועל, נמדד. + """ + doc = md_parser.parse_document(_BOM + _BOM + "# A\n\ntext " + _BOM + "here\n") + assert doc.lines[0] == _BOM + "# A" + assert _BOM in doc.lines[2] + assert doc.sections == [], "BOM שני בראש השורה מונע כותרת — כמו ב-cmark" + + +# ════════════════════════════════════════════════════════════════════ +# הכרעה 2 — ``\r`` בודד נדחה, CRLF לא +# ════════════════════════════════════════════════════════════════════ + +_LF = "# A\n\n## B\n\n### C\n" + + +def test_a_lone_cr_is_refused(): + with pytest.raises(doc_sections.InconsistentLineEndings): + md_parser.parse_document("# A\r\r## B\r\r### C\r") + + +@pytest.mark.parametrize( + "name, text, expected_line", + [ + ("בשורה הראשונה", "# a\rb\n## c\n", 1), + ("באמצע הקובץ", "# a\n\n## b\rx\n\n### c\n", 3), + ("בסוף הקובץ", "# a\n\n## b\r", 3), + ], +) +def test_the_refusal_says_where_the_lone_cr_is(name, text, expected_line): + """מבין שלושת ערוצי הסירוב, זה היה היחיד שלא אמר איפה. + + ‏``TooManySections`` נושאת את השורה שבה נעצרנו, ו-``TypeError`` + נושא את הסוג שהתקבל. ``InconsistentLineEndings`` הורמה ריקה, ומי + שהיה צריך לתקן את הקובץ קיבל רק "יש ``\\r`` איפשהו". המיקום ממילא + מחושב — ``search`` מחזיר אובייקט התאמה — והוא נזרק לפח. + """ + with pytest.raises(doc_sections.InconsistentLineEndings) as caught: + md_parser.parse_document(text) + + assert caught.value.args == (expected_line,), name + + +def test_the_same_file_in_crlf_returns_the_same_line_numbers(): + """CRLF אינו מושפע — זה המקרה הנפוץ, והוא חייב להמשיך לעבוד.""" + lf = md_parser.parse_document(_LF) + crlf = md_parser.parse_document(_LF.replace("\n", "\r\n")) + assert [(s.level, s.heading_line, s.end_line, s.title) for s in lf.sections] == [ + (s.level, s.heading_line, s.end_line, s.title) for s in crlf.sections + ] + assert lf.lines == crlf.lines + + +def test_counting_lines_is_not_enough_to_catch_the_lone_cr(): + """**המקרה החד, וזו הסיבה שהבדיקה היא על נוכחות ולא על ספירה.** + + בקלט הזה ``split("\\n")`` ו-``splitlines()`` נותנים **אותו מספר + בדיוק** — שש שורות — ובכל זאת ``markdown-it-py`` מדווח על הכותרת + האחרונה בשורה 6, בזמן שב-``split("\\n")`` היא בשורה 5. שומר שהיה + משווה ספירות היה עובר את הקלט הזה ומחזיר מפה שמצביעה לשורה הלא + נכונה, עם ``ok: true``. + """ + sneaky = "# A\n\n## B\rX\n\n### C\n" + assert len(sneaky.split("\n")) == len(sneaky.splitlines()) == 6, "הנחת המקרה" + + plain = MarkdownIt("commonmark").disable("inline") + tokens = plain.parse(sneaky, {}) + by_markdown_it = [t.map[0] + 1 for t in tokens if t.type == "heading_open"] + by_split = [i + 1 for i, ln in enumerate(sneaky.split("\n")) if ln.startswith("#")] + assert by_markdown_it[-1] == 6 and by_split[-1] == 5, "שתי הספירות נפרדות כאן" + + with pytest.raises(doc_sections.InconsistentLineEndings): + md_parser.parse_document(sneaky) + + +# ════════════════════════════════════════════════════════════════════ +# הכרעה 3 — front matter אינו מזיז שורות ואינו מייצר סעיף מומצא +# ════════════════════════════════════════════════════════════════════ + +_FRONT_MATTER = """--- +summary: מסמך לדוגמה +tags: [a, b] +--- + +# כותרת אמיתית + +## סעיף +""" + + +def test_front_matter_does_not_become_a_section(): + """בלי הטיפול, שתי שורות ה-YAML היו כותרת setext ברמה 2 בשורה 2. + + נמדד: פארסר בלי התוסף מחזיר על הקלט הזה + ``[('h2', 2, 'summary: …\\ntags: [a, b]'), ('h1', 6, …), ('h2', 8, …)]``. + """ + doc = md_parser.parse_document(_FRONT_MATTER) + assert [(s.level, s.heading_line, s.title) for s in doc.sections] == [ + (1, 6, "כותרת אמיתית"), + (2, 8, "סעיף"), + ] + + +def test_the_document_lines_are_the_source_and_not_the_parsed_text(): + """**הטענה המרכזית של ההכרעה הזאת.** + + ``Document.lines`` הן שורות המקור. קורא שיבקש סעיף שמכיל את הבלוק + חייב לקבל את ה-YAML עצמו — לא שורות ריקות, ולא טקסט שעבר עיבוד. + ומכיוון שמספרי השורות **זהים** בשני המצבים, טעות כאן אינה מסגירה + את עצמה באף מספר. + """ + doc = md_parser.parse_document(_FRONT_MATTER) + assert doc.lines == _FRONT_MATTER.split("\n") + assert doc.lines[1] == "summary: מסמך לדוגמה" + + # וקצה-לקצה: הטקסט שהקורא באמת מקבל + whole = doc_sections.section_text(doc, doc.sections[0], True) + assert whole.startswith("# כותרת אמיתית") + + +def test_an_unclosed_front_matter_block_is_a_no_op(): + """בלי סוגר אין front matter, וה-``---`` חוזר להיות קו רגיל.""" + doc = md_parser.parse_document("---\na: 1\n\n# כותרת\n") + assert [(s.level, s.heading_line, s.title) for s in doc.sections] == [(1, 4, "כותרת")] + + +@pytest.mark.parametrize( + "name, block", + [ + ("בלי בלוק", ""), + ("רגיל", "---\na: 1\n---\n"), + ("סוגר ...", "---\na: 1\n...\n"), + ("פותח מוזח", " ---\na: 1\n---\n"), + ("פותח רווח בסוף", "--- \na: 1\n---\n"), + ("סוגר מוזח", "---\na: 1\n ---\n"), + ("סוגר רווח בסוף", "---\na: 1\n--- \n"), + ("ארבעה מקפים", "----\na: 1\n----\n"), + ("לא נסגר", "---\na: 1\n"), + ("פותח עם טקסט", "---title\na: 1\n---\n"), + ], +) +def test_front_matter_never_moves_a_line_number(name, block): + """עשר צורות, וביניהן ארבע שנמדדו כחלוקות בין שני כללים שנכתבו ביד. + + הטענה היא לא "הבלוק זוהה" אלא **שהכותרות יושבות בשורות שבהן הן + באמת יושבות בקובץ**. זה מה שמחזיק את ההבטחה שכל שורה שהמפה מחזירה + אפשר לבקש עם ``lines=``, והוא נכון כאן בזכות זה שהטקסט אינו משתנה + כלל — לא נמחק ולא הוחלף. + """ + text = block + "# כותרת אמיתית\n\ntext\n\n## סעיף\n" + lines = text.split("\n") + doc = md_parser.parse_document(text) + found = {s.title: s.heading_line for s in doc.sections} + assert found["כותרת אמיתית"] == lines.index("# כותרת אמיתית") + 1 + assert found["סעיף"] == lines.index("## סעיף") + 1 + assert doc.lines == lines + + +# ════════════════════════════════════════════════════════════════════ +# הכרעה 4 — התקרה נאכפת בתוך הפרסור +# ════════════════════════════════════════════════════════════════════ + +def _many_headings(count): + return "".join(f"## h{i}\n\nbody {i}\n\n" for i in range(count)) + + +def test_the_ceiling_stops_inside_the_parse_and_not_after_it(): + """הראיה היא **היכן** נעצרנו, ולא רק שהחריגה הורמה. + + ``TooManySections`` נושאת את מספר השורה שבה הכלל עצר. אם הבדיקה + הייתה רצה אחרי ``md.parse``, כל העבודה כבר הייתה נעשית — והמספר + היה סוף הקובץ או לא היה קיים כלל. + """ + text = _many_headings(50) + total_lines = len(text.split("\n")) + assert total_lines == 201, "הנחת המקרה" + with pytest.raises(doc_sections.TooManySections) as caught: + md_parser.parse_document(text, max_sections=5) + stopped_at = caught.value.args[0] + + # הכותרת השישית — זו שחוצה תקרה של חמש — יושבת בשורה 21, והכלל נורה + # בתחילת הבלוק שאחריה. הסף כאן הדוק בכוונה: ``< total_lines`` לבדו + # היה עובר גם על מימוש שמפרסר הכול ומסנן בסוף, וזה בדיוק מה שנמדד + # בבדיקת מוטציה שקרה לניסוח הרופף הקודם. + assert 0 < stopped_at < 40, (stopped_at, total_lines) + + +def test_the_second_gate_catches_a_section_that_the_in_parse_rule_never_sees(): + """**השער השני אינו קוד מת, וזה נמדד.** + + הכלל שבתוך הפרסור נקרא ב**תחילת** בלוק, ולכן הסעיף האחרון בקובץ — + זה שאין אחריו בלוק נוסף — אינו נסרק על ידו לעולם. בקלט הזה שש + הכותרות נגמרות בסוף הקובץ ממש, והסירוב מגיע מהשער שאחרי הפרסור. + הארגומנט הוא סוף הקובץ, וזה מה שמבדיל בין שני השערים. + """ + text = "".join(f"## h{i}\n\n" for i in range(6)) + total_lines = len(text.split("\n")) + with pytest.raises(doc_sections.TooManySections) as caught: + md_parser.parse_document(text, max_sections=5) + assert caught.value.args[0] == total_lines + + +def test_a_file_with_exactly_the_ceiling_passes(): + """הגבול זהה ל-``Capped.append`` ול-``add_section``: הסעיף שמעליו עוצר.""" + doc = md_parser.parse_document(_many_headings(5), max_sections=5) + assert len(doc.sections) == 5 + with pytest.raises(doc_sections.TooManySections): + md_parser.parse_document(_many_headings(6), max_sections=5) + + +def test_the_default_ceiling_is_the_documented_constant(): + """טענת O(1) על הערך, במקום לבנות 50,001 סעיפים אמיתיים בכל ריצת CI.""" + import inspect + + assert md_parser.MAX_SECTIONS == 50_000 + default = inspect.signature(md_parser.parse_document).parameters["max_sections"].default + assert default is md_parser.MAX_SECTIONS, "ברירת המחדל היא התקרה, לא None" + + +def test_the_ceiling_can_be_turned_off_explicitly(): + doc = md_parser.parse_document(_many_headings(20), max_sections=None) + assert len(doc.sections) == 20 + + +def test_the_counter_never_examines_a_token_twice(): + """המונה סורק רק טוקנים חדשים — אחרת הוא O(n²) והתקרה היא המחיר. + + טענה מדידה על המונה עצמו, ולא בדיקת זמן שמתנדנדת ב-CI. + """ + counter = md_parser._SectionCounter(max_sections=10) + tokens = [] + for step in range(20): + tokens.extend([_FakeToken("paragraph_open", 0), _FakeToken("heading_open", 0)]) + counter.scan(tokens) + assert counter.examined <= len(tokens), f"סריקה חוזרת בצעד {step}" + assert counter.examined == len(tokens) + assert counter.seen == 20 + + +class _FakeToken: + """טוקן מינימלי למונה. + + ‏``type`` הוא מה שהמונה קורא היום; ``level`` נשאר כאן כי הוא מה + שמבדיל כותרת שנכנסת למפה מכותרת שבתוך מכל, וזו בדיוק ההבחנה שהמונה + **אינו** עושה יותר — טסט שבונה טוקן בלי ``level`` לא היה יכול + להראות את זה. + """ + + def __init__(self, type_, level): + self.type = type_ + self.level = level + + +def test_the_counter_counts_headings_inside_containers(): + """התקרה מגבילה עבודה ולא תוצאה, ולכן כותרת במכל נספרת. + + היא **אינה** נכנסת למפה — זה נבדק ב- + ``test_a_heading_inside_a_container_is_not_a_section`` — אבל היא + עולה בדיוק כמו כל כותרת אחרת. נמדד: 500KB של ``> ## h`` הם ~64,000 + כותרות, וספירה לפי ``level == 0`` ראתה בהן אפס ולא עצרה לעולם. + """ + counter = md_parser._SectionCounter(max_sections=100) + counter.scan([_FakeToken("heading_open", 1), _FakeToken("heading_open", 2)]) + assert counter.seen == 2 + + +def test_the_ceiling_is_not_fooled_by_a_container_heading_in_the_last_block(): + """הדלף שהיה נפתח אילו רק המונה היה משתנה, בלי השער השני. + + הכלל שבתוך הפרסור נקרא בתחילת כל בלוק ולכן אינו רואה את הבלוק + האחרון, והשער שאחרי הפרסור היה סופר **סעיפים** — שכאן הם אפס, כי + כל הכותרות בתוך ציטוט. כלומר שני השערים היו מודדים שני דברים + שונים, והקלט הזה היה עובר בלי שום סירוב. נמדד בדיוק כך. + """ + ceiling = 20 + text = "> ## q\n\n" * ceiling + "> ## last\n" + + with pytest.raises(md_parser.TooManySections): + md_parser.parse_document(text, max_sections=ceiling) + + +# ════════════════════════════════════════════════════════════════════ +# המופע המשותף — נבנה **במלואו** לפני שהוא מתפרסם +# ════════════════════════════════════════════════════════════════════ + +def test_every_ruler_a_real_parse_uses_is_compiled_before_publish(monkeypatch): + """אף ruler לא נבנה בפעם הראשונה **תוך כדי** פרסור. + + ‏``markdown-it`` בונה את רשימת הכללים של כל ruler עצלה, בקריאה + הראשונה ל-``getRules``, בלי מנעול; ו-``Ruler.__compile__`` מפרסם + מילון ריק לפני שהוא ממלא אותו. כלומר שני חוטים שמפרסרים ראשונים + יחד יכולים לראות רשימת כללים חלקית — ואז ``parse_document`` מחזיר + מפה ריקה למסמך שיש בו כותרות, בלי שום חריגה. + + ‏``_build_parser`` סוגר את זה בפרסור חימום. **הטסט הזה בודק את + הכיסוי ולא את הקריאה**: הוא מרגל אחרי ``getRules`` ומחפש ruler + שהגיע אליו לא מקומפל. לכן הוא לא יתיישן — אם גרסה עתידית של + ``markdown-it`` תוסיף ruler למסלול והחימום יפספס אותו, הטסט ייפול + במקום שהחלון ייפתח בשקט. + + **ולמה אין כאן טסט עם חוטים, למרות ש-T1 ב-``TESTING-PATTERNS`` + דורש שתכונת מקביליות תיבדק במקביל.** מה שהקוד מבטיח הוא תכונה + **דטרמיניסטית** — "כל ruler שפרסור אמיתי צורך מקומפל לפני + הפרסום" — וזה בדיוק מה שנטען כאן. הסירוב המקבילי עצמו נמדד + בהרצת עומס ולא בטסט: טסט חוטים היה מנסה להוכיח **היעדר** מרוץ, + וריצה ירוקה שלו אינה ראיה. המספרים, לפני ואחרי, בגוף ה-PR. + """ + from markdown_it.ruler import Ruler + + md = md_parser._build_parser() + by_id = { + id(md.core.ruler): "core", + id(md.block.ruler): "block", + id(md.inline.ruler): "inline", + id(md.inline.ruler2): "inline2", + } + found_uncompiled = [] + original = Ruler.getRules + + def spy(self, chainName=""): + if self.__cache__ is None: + found_uncompiled.append(by_id.get(id(self), f"<{id(self)}>")) + return original(self, chainName) + + monkeypatch.setattr(Ruler, "getRules", spy) + # מסמך מייצג ולא מינימלי: כותרת, מכל, טבלה, גדר ו-front matter — + # כדי שכל ענף במסלול יתבקש להביא את הכללים שלו. + md.parse( + "---\na: 1\n---\n\n# כותרת\n\n> ## בציטוט\n\n- ## בפריט\n\n" + "| a | b |\n|---|---|\n| 1 | 2 |\n\n```py\nx = 1\n```\n\nסטקסט\n------\n" + ) + + assert not found_uncompiled, ( + "פרסור החימום ב-``_build_parser`` פספס rulers שפרסור אמיתי צורך, " + f"והם נבנו תוך כדי הפרסור: {sorted(set(found_uncompiled))}. " + "זה החלון של lazy-init-guard-publish-order — הרחב את מסמך החימום." + ) + + +def test_the_ceiling_anchor_fails_loudly_when_the_plugin_is_not_registered(): + """העוגן ``before("front_matter", ...)`` קונה כשל מיידי — ועל מה בדיוק. + + ה-docstring של ``_build_parser`` טען קודם שהעוגן תופס **תלות + חסרה**, וזה לא מדויק: חבילה שאינה מותקנת מפילה את הייבוא שבראש + הקובץ, הרבה לפני שמגיעים לכאן. מה שהעוגן באמת תופס הוא **הסרה של + הרישום** — מישהו שימחק את ``.use(front_matter_plugin)`` וישאיר את + השורה הזאת. הטסט מקבע את המנגנון שהמשפט המתוקן מתאר. + """ + bare = MarkdownIt("commonmark").disable("inline") + + with pytest.raises(KeyError, match="front_matter"): + bare.block.ruler.before("front_matter", "ck_max_sections", md_parser._ceiling_rule) + + +def test_a_heading_token_without_a_map_is_refused_and_not_dropped(): + """הענף היחיד במודול שהיה מפיל סעיף בשקט — עכשיו מרים. + + אומת מול ``rules_block/heading.py`` ו-``lheading.py``: כל מסלול + שדוחף ``heading_open`` מציב ``map`` ללא תנאי, ולכן הענף אינו ניתן + להגעה היום והטסט בונה את המצב ביד. מה שנבדק הוא ההכרעה: מפה שחסר + בה סעיף, בלי לוג ובלי חריגה, היא בדיוק ה"לא נמצא שנראה כמו נשבר" + שה-docstring של המודול מצהיר שאין בו. + """ + + class _MaplessHeading: + type = "heading_open" + level = 0 + tag = "h2" + markup = "##" + map = None + + with pytest.raises(RuntimeError, match="heading_open"): + md_parser._sections_from_tokens([_MaplessHeading()], total_lines=1) + + +# ════════════════════════════════════════════════════════════════════ +# הכרעה 5 — ``inline`` כבוי, והשם גולמי +# ════════════════════════════════════════════════════════════════════ + +def test_the_inline_core_rule_is_disabled(): + """נמדד: עם הכלל כבוי, הכללים הליבתיים הפעילים הם + ``['normalize', 'block', 'text_join']``. + """ + assert "inline" not in md_parser._MD.get_active_rules()["core"] + assert "block" in md_parser._MD.get_active_rules()["core"], "הפרסור עצמו חייב להישאר" + + +@pytest.mark.parametrize( + "source, expected", + [ + ("# כותרת ATX\n", "כותרת ATX"), + ("## כותרת ##\n", "כותרת"), + ("כותרת setext\n===\n", "כותרת setext"), + ("שורה ראשונה\nשורה שנייה\n---\n", "שורה ראשונה\nשורה שנייה"), + ("### דפוס 1 — `MissingGreenlet`\n", "דפוס 1 — `MissingGreenlet`"), + ("# כותרת עם \\# סולמית\n", "כותרת עם \\# סולמית"), + ("## **מודגש** [x](y)\n", "**מודגש** [x](y)"), + ("###\n", ""), + ], +) +def test_the_title_is_the_raw_source_text(source, expected): + """כל שמונה הצורות מטבלת המדידה. הבקטיקים, ההדגשה, הקישור וה-escape + נשמרים; ה-``#`` וקו ה-setext מוסרים; רווחים מסביב מקוצצים. + """ + doc = md_parser.parse_document(source) + assert [s.title for s in doc.sections] == [expected] + + +#: כותרת setext רב-שורתית: המקור, והטקסט שהכותרת אמורה לשאת. +#: המדידה שמאחורי כל שורה היא ה-HTML של ``markdown-it-py`` **עצמה** — +#: לא פרשנות שלנו — ושל cmark-gfm, ושניהם מסכימים. +_MULTILINE_SETEXT = [ + ("המשך מוזח בארבעה רווחים", "aaa\n bbb\n===\n", "aaa\nbbb"), + ("המשך מוזח בטאב", "aaa\n\tbbb\n===\n", "aaa\nbbb"), + ("רווח בודד בסוף השורה הראשונה", "aaa \nbbb\n===\n", "aaa\nbbb"), + ("שני רווחים — hardbreak", "aaa \nbbb\n===\n", "aaa\nbbb"), + ("שלוש שורות, שתי הזחות שונות", "aaa\n bbb\n\tccc\n===\n", "aaa\nbbb\nccc"), + ("רווח סופי והזחה יחד", "aaa \n bbb\n===\n", "aaa\nbbb"), + # השארית המכוונת: רווח כפול **בתוך** השורה אינו רווח של מעבר שורה, + # ולכן הוא נשמר — גם ב-markdown-it וגם ב-cmark. + ("רווח כפול באמצע השורה", "aaa bbb\n===\n", "aaa bbb"), +] + + +@pytest.mark.parametrize("name, source, expected", _MULTILINE_SETEXT) +def test_a_multiline_setext_title_drops_the_whitespace_that_is_not_text(name, source, expected): + """ההזחה של שורת ההמשך אינה חלק מהכותרת, ואצלנו היא הייתה שורדת. + + זו הייתה סטייה **שלנו** ולא של הספרייה: ה-HTML של ``markdown-it-py`` + עצמה מחזיר ``

aaa\\nbbb

``, כלומר כלל ה-softbreak שלה מוריד + את הרווחים — והוא חי ב-``inline``, שאנחנו מכבים. כל עוד לא שיחזרנו + את הכלל הזה, ``token.content`` נשא הזחה שלא הופיעה בשום רינדור, + והיא הייתה מגיעה למשתמש דרך ``build_toc``. + + נתפס על ידי השוואת הטקסט מול cmark-gfm שנוספה באותו סבב — לפני שהיא + נוספה, ההשוואה הייתה על רמה ומספר שורה בלבד ולא יכלה לראות את זה. + """ + doc = md_parser.parse_document(source) + assert [s.title for s in doc.sections] == [expected], name + + +def test_the_adornment_is_the_markup_of_the_heading(): + doc = md_parser.parse_document("# א\n\n## ב\n\nsetext\n===\n\nאחר\n---\n") + assert [s.adornment for s in doc.sections] == ["#", "##", "=", "-"] + assert all(s.over is False for s in doc.sections) + + +# ════════════════════════════════════════════════════════════════════ +# הכרעה 6 — ``end_line`` מחושב, ולא נלקח מ-``token.map`` +# ════════════════════════════════════════════════════════════════════ + +_NESTED = """## ראשון + +a + +### תת א + +b + +### תת ב + +c + +## שני + +d +""" + + +def test_the_section_ends_before_the_next_heading_at_the_same_or_higher_level(): + doc = md_parser.parse_document(_NESTED) + by_title = {s.title: s for s in doc.sections} + assert by_title["תת א"].end_line == by_title["תת ב"].heading_line - 1 + assert by_title["ראשון"].end_line == by_title["שני"].heading_line - 1 + assert by_title["שני"].end_line == len(doc.lines) + + +def test_token_map_covers_only_the_heading_line_itself(): + """זו הסיבה ש-``end_line`` אינו יכול לבוא מ-``token.map``. + + נמדד: ``map`` הוא ``(1, 1)`` ל-ATX ו-``(1, 3)`` ל-setext רב-שורתי — + כלומר הוא אינו יודע דבר על גוף הסעיף. אילו ה-``end`` היה נלקח משם, + "ראשון" היה נגמר בשורה שלו עצמו. + """ + plain = MarkdownIt("commonmark").disable("inline") + maps = [t.map for t in plain.parse(_NESTED, {}) if t.type == "heading_open"] + assert maps[0] == [0, 1], maps + doc = md_parser.parse_document(_NESTED) + assert doc.sections[0].end_line > maps[0][1] + + +# ════════════════════════════════════════════════════════════════════ +# הרחבת ה-GFM היחידה שמזיזה כותרת +# ════════════════════════════════════════════════════════════════════ + +@pytest.mark.parametrize( + "name, source", + [ + ("טבלה ואז --- צמוד", "| a | b |\n|---|---|\n| 1 | 2 |\n---\n\n## אחרי\n"), + ("טבלה ואז === צמוד", "| a | b |\n|---|---|\n| 1 | 2 |\n===\n\n## אחרי\n"), + ("טבלה בלי גוף ואז ---", "| a | b |\n|---|---|\n---\n\n## אחרי\n"), + ], +) +def test_a_table_is_a_table_and_not_a_setext_heading(name, source): + """**זו הרגרסיה שהפריסט ``commonmark`` לבדו מייצר.** + + בלי ``enable("table")`` שורות הטבלה הן פסקה, והקו שאחריהן הופך אותה + לכותרת setext: מתקבל סעיף שכותרתו היא הטבלה כולה, **והסעיף שמעליו + נגמר לפני הטבלה במקום אחריה**. הטבלה המחוללת שב- + ``tests/test_md_parser_oracle.py`` לא כיסתה את המחלקה הזאת, כי היא + לעולם אינה מציבה שורת תא ושורת מפריד זו אחרי זו — ולכן הצורות האלה + מקובעות כאן בנפרד, ושם נוספה משפחה שמחוללת אותן. + """ + sections = md_parser.parse_document(source).sections + assert [s.title for s in sections] == ["אחרי"], name + + +def test_only_the_table_extension_is_enabled_on_top_of_commonmark(): + """ארבע ההרחבות האחרות של GFM נבדקו ואינן משנות זיהוי כותרות. + + ``strikethrough`` ו-GFM autolink (``linkify`` ב-``markdown-it``) הן + inline, ``tagfilter`` נוגע ברינדור, ו-``tasklist`` משנה פריט רשימה + ולא גבול בלוק. הן נשארות כבויות כדי שלא ידלק כאן שום דבר שלא נמדד. + """ + block_rules = md_parser._MD.get_active_rules()["block"] + assert "table" in block_rules + inline_rules = md_parser._MD.get_active_rules()["inline"] + assert "strikethrough" not in inline_rules + # **``linkify`` ולא ``autolink``.** ``autolink`` הוא הכלל של CommonMark + # ל-```` — הוא דלוק, ונשאר דלוק, ואינו ההרחבה של GFM. + # ההרחבה של GFM (``www.a.b`` הופך לקישור) היא ``linkify``, שיושב + # **בשני** rulers — ``core`` ו-``inline`` — ושניהם חייבים להיות + # כבויים. הטענה הקודמת בדקה ``autolink`` ב-``inline2``, ששם הוא לא + # ישב מעולם — טענה שלא יכלה ליפול. ``enable("linkify")`` מדליק את + # שניהם, ולכן היא נופלת עליו. + active = md_parser._MD.get_active_rules() + assert "linkify" not in active["core"] + assert "linkify" not in active["inline"] + + +# ════════════════════════════════════════════════════════════════════ +# ערוץ הכשל, ונעיצת התלויות +# ════════════════════════════════════════════════════════════════════ + +def test_a_file_without_headings_is_an_empty_map_and_not_a_failure(): + doc = md_parser.parse_document("סתם פסקה\n\nועוד אחת\n") + assert doc.sections == [] + assert doc.lines == ["סתם פסקה", "", "ועוד אחת", ""] + + +@pytest.mark.parametrize("bad", [None, 17, b"# bytes\n", ["# list"]]) +def test_input_that_is_not_a_string_is_refused_in_the_entry(bad): + """הטקסט מגיע מחוץ לתהליך, ולכן הטיפוס נבדק לפני כל ``.replace``.""" + with pytest.raises(TypeError): + md_parser.parse_document(bad) + + +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(): + """"בני-החלפה" היא טענה בת-בדיקה, לא משאלה. + + כל שם ש-``rst_parser`` מייצא קיים גם ב-``md_parser``, וההפרש הוא + **בדיוק** שני השמות שהפרוזה מונה. שם שיתווסף לאחד מהם בלי השני, + או ייצוא של ``InconsistentLineEndings`` מ-``rst_parser`` (שאינו מרים + אותה), מפיל את זה. + """ + 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"} + + +@pytest.mark.parametrize( + "package, module", + [("markdown-it-py", "markdown_it"), ("mdit-py-plugins", "mdit_py_plugins")], +) +def test_the_parser_dependencies_are_pinned_directly_in_base_requirements(package, module): + """``markdown-it-py`` הגיע עד היום רק כתלות עקיפה של ``rich``, בטווח + פתוח. נעיצה ישירה היא התנאי הראשון מדוח המדידה, והטסט הזה מוודא + שהיא לא נשמטה ושהיא תואמת למה שבאמת מותקן. + """ + import importlib.metadata as metadata + + base = (_REPO / "requirements" / "base.txt").read_text(encoding="utf-8") + found = re.search(rf"^{re.escape(package)}==(\S+)$", base, re.MULTILINE) + assert found, f"{package} אינו נעוץ ישירות ב-requirements/base.txt" + assert found.group(1) == metadata.version(package) + __import__(module) + + +#: הדרישות שנקראו מהמטא-דאטה של ``myst-parser`` בגרסה שנעוצה ב- +#: ``docs/requirements.txt``. אילוץ, ולא העדפה: הוא זה שקובע כמה גבוה +#: מותר לנעוץ ב-``requirements/base.txt``. +_MYST_PINNED = "4.0.1" +#: **המפרטים כפי שהם כתובים במטא-דאטה של myst-parser**, ולא הסדרה +#: שגזרנו מהם. ההבדל אינו סגנוני: ``~=3.0`` פירושו ``>=3.0, ==3.*`` +#: ולכן ``3.1.0`` מקיים אותו, ו-``~=0.4`` פירושו ``>=0.4, ==0.*`` ולכן +#: גם ``0.5.0`` מקיים. בדיקה שהשוותה קידומת ``"3.0."`` הייתה דוחה את +#: שניהם ומאשימה את myst-parser בדרישה שאין לו. +_MYST_REQUIRES = { + "markdown-it-py": "~=3.0", + "mdit-py-plugins": "~=0.4,>=0.4.1", +} + + +def _satisfies_myst_requirement(package: str, version: str) -> bool: + """האם ``version`` מקיים את מה ש-myst-parser דורש מ-``package``. + + **הפרדיקט מחולץ כדי שיהיה מה לשמור עליו.** ``packaging`` הוא + המימוש הרשמי של PEP 440 והוא מגיע עם pytest, ולכן זמין בכל מקום + שהטסטים רצים בו בלי להצהיר עליו. לכתוב כאן פרשן מפרטים ביד — או + להסתפק בהשוואת קידומת, כפי שהיה — הוא בדיוק הכלל-שנכתב-שוב שהענף + הזה נלחם בו, ולכן ``test_the_pin_check_accepts_every_version_...`` + שומר עליו. + """ + from packaging.specifiers import SpecifierSet + from packaging.version import Version + + return Version(version) in SpecifierSet(_MYST_REQUIRES[package]) + + +def test_the_parser_pins_can_be_installed_next_to_the_docs_toolchain(): + """שני קבצי התלויות של הריפו הזה חייבים להיות ברי-התקנה יחד. + + **וזה לא תיאורטי:** ``.github/workflows/documentation-py39.yml`` + מתקין את ``docs/requirements.txt`` ואת ``requirements/production.txt`` + לאותה סביבה, ומפתח שמריץ את שניהם יחד מקבל + ``ResolutionImpossible`` — נמדד. ``myst-parser==4.0.1`` דורש + ``markdown-it-py~=3.0``, ולכן נעיצה ל-4.2.0 כאן הייתה סתירה בין שני + קבצים באותו ריפו. + + .. important:: + + **הטסט הזה ייפול כששדרגו את myst-parser, וזו המטרה.** הדרישות + שב-``_MYST_REQUIRES`` נקראו מהמטא-דאטה של הגרסה שב- + ``_MYST_PINNED``, ולכן שינוי בצד אחד מחייב לקרוא מחדש את הצד + השני. ‏myst-parser 5.x, למשל, דורש ``markdown-it-py~=4.2`` — + ואז **צריך** להעלות כאן, לא להשאיר. + """ + import importlib.metadata as metadata + + docs = (_REPO / "docs" / "requirements.txt").read_text(encoding="utf-8") + base = (_REPO / "requirements" / "base.txt").read_text(encoding="utf-8") + + found = re.search(r"^myst-parser==(\S+)$", docs, re.MULTILINE) + assert found, "myst-parser אינו נעוץ ב-docs/requirements.txt" + assert found.group(1) == _MYST_PINNED, ( + f"myst-parser עודכן ל-{found.group(1)} — קרא מחדש את הדרישות שלו " + f"ועדכן את _MYST_REQUIRES יחד עם הנעיצות ב-base.txt" + ) + + for package, requirement in _MYST_REQUIRES.items(): + pinned = re.search(rf"^{re.escape(package)}==(\S+)$", base, re.MULTILINE) + assert pinned, f"{package} אינו נעוץ ב-requirements/base.txt" + assert _satisfies_myst_requirement(package, pinned.group(1)), ( + f"{package}=={pinned.group(1)} ב-base.txt אינו מקיים את " + f"{requirement} ש-myst-parser {_MYST_PINNED} דורש" + ) + # ומה שבאמת מותקן, כדי שהקובץ והסביבה לא ייפרדו בשקט + assert metadata.version(package) == pinned.group(1) + + +def test_the_pin_check_accepts_every_version_myst_parser_allows(): + """הבדיקה למעלה אינה קפדנית מהאילוץ שהיא מצטטת. + + **וזה היה שגוי.** הנוסח הקודם השווה קידומת (``"3.0."``), ולכן דחה + את ``markdown-it-py 3.1.0`` ואת ``mdit-py-plugins 0.5.0`` — שתיהן + גרסאות ש-myst-parser 4.0.1 **מתיר**. הודעת הכשל הייתה אומרת + "אינו מקיים את ~=3.0", טענה שאינה נכונה, ושולחת את המתחזק לחקור + את החבילה הלא נכונה. + + .. note:: + + **הטסט הזה אינו נופל על הקוד שלפני התיקון, וזה מכוון.** הפגם + הישן היה בצורת ההשוואה בתוך הטסט השכן, ולא בקוד ייצור. מה שהוא + כן עושה הוא לשמור על ``_satisfies_myst_requirement``: כל חזרה + להשוואת קידומת תפיל אותו. זו ההגנה שאפשר לתת כאן. + """ + allowed = { + "markdown-it-py": ["3.0.0", "3.1.0", "3.9.9"], + "mdit-py-plugins": ["0.4.1", "0.4.2", "0.5.0", "0.6.0"], + } + forbidden = { + "markdown-it-py": ["2.2.0", "4.0.0", "4.2.0"], + "mdit-py-plugins": ["0.4.0", "1.0.0"], + } + + for package, requirement in _MYST_REQUIRES.items(): + for version in allowed[package]: + assert _satisfies_myst_requirement(package, version), ( + f"{package} {version} מקיים את {requirement} ולכן הבדיקה " + "חייבת לקבל אותו — נוסח שדוחה אותו יאשים את myst-parser בטעות" + ) + for version in forbidden[package]: + assert not _satisfies_myst_requirement(package, version), ( + f"{package} {version} אינו מקיים את {requirement}" + ) + + +def test_the_oracle_is_a_test_dependency_only(): + """``cmarkgfm`` הוא אורקל, לא תלות ייצור. אם הוא ייכנס ל-base הוא + יותקן בשירות בלי שום קורא — ומי שיראה אותו שם יניח שהוא בשימוש. + """ + base = (_REPO / "requirements" / "base.txt").read_text(encoding="utf-8") + dev = (_REPO / "requirements" / "development.txt").read_text(encoding="utf-8") + assert "cmarkgfm" not in base + assert re.search(r"^cmarkgfm==\S+$", dev, re.MULTILINE) + + +# ════════════════════════════════════════════════════════════════════ +# סקריפט ההשוואה — מה הוא באמת רואה, ומה קורה כשקובץ אחד מוזר +# ════════════════════════════════════════════════════════════════════ + +def _load_compare_script(): + """טעינה לפי נתיב, כמו ב-``_load_ai_map_generator``.""" + import importlib.util + + spec = importlib.util.spec_from_file_location( + "compare_md_parser_for_tests", _REPO / "scripts" / "compare_md_parser_to_cmark.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_the_compare_script_sees_a_lone_cr_and_survives_one_bad_file(tmp_path, capsys): + """שני כשלים באותה לולאה, ושניהם שקטים לפני התיקון. + + **הראשון:** ``Path.read_text`` פותח את הקובץ במצב טקסט עם universal + newlines וממיר כל ``\\r`` ל-``\\n``, ולכן ``InconsistentLineEndings`` + לא יכלה להידלק כאן על שום קובץ — הסקריפט היה מדווח "אפס + אי-הסכמות" גם על הקלט שהוא קיים כדי לתפוס. + + **והשני:** הדוח נבנה אחרי הלולאה, ולכן קובץ יחיד שאינו UTF-8 היה + מפיל את הריצה כולה ומוחק גם את מה שכבר נסרק. הסקריפט מכוון על ריפו + **זר**, ולכן קובץ מוזר הוא המקרה הצפוי ולא החריג. + """ + corpus = tmp_path / "corpus" + corpus.mkdir() + (corpus / "a_good.md").write_bytes(b"# \xd7\x90\n\nbody\n\n## \xd7\x91\n") + (corpus / "b_lone_cr.md").write_bytes(b"# Title\r\r## Sub\r\nbody\n") + (corpus / "c_not_utf8.md").write_bytes(b"\xff\xfe# broken\n") + + script = _load_compare_script() + exit_code = script.main([str(corpus)]) + report = capsys.readouterr().out + + # טענות על **מה** שהדוח אומר ולא על הריווח שלו: יישור עמודות הוא + # קוסמטיקה, וטסט שנשבר ממנו הוא טסט שמתרגלים להתעלם ממנו. + assert exit_code == 0, "אין אי-הסכמות בקובץ התקין, ולכן הריצה מצליחה" + assert re.search(r"הושוו\s*1.*סורבו\s*2", report), report + assert "b_lone_cr.md" in report and "\\r בודד" in report, "ה-CR הבודד חייב להיתפס" + assert "c_not_utf8.md" in report and "אינו UTF-8" in report + assert re.search(r"כותרות:\s+2\b", report), "הקובץ התקין חייב להיספר למרות שני הסירובים" + + +def test_the_compare_script_does_not_call_an_empty_run_a_success(tmp_path, capsys): + """"אפס אי-הסכמות" על אפס קבצים שהושוו הוא אישור שקרי. + + זה אותו נימוק שכבר כתוב בסקריפט על ריפו בלי קובצי ``.md`` — מספר + שנראה טוב כי לא נבדק דבר. בלי הבדיקה הזאת, בידוד התקלות לכל קובץ + היה **יוצר** את הכשל הזה: לפניו ריפו כזה היה מפיל את הריצה בקול. + """ + corpus = tmp_path / "corpus" + corpus.mkdir() + (corpus / "only_bad.md").write_bytes(b"\xff\xfe# broken\n") + + script = _load_compare_script() + exit_code = script.main([str(corpus)]) + + assert exit_code == 1, "ריצה שלא השוותה דבר אינה מצליחה" + assert "כל הקבצים סורבו" in capsys.readouterr().out + + +# ════════════════════════════════════════════════════════════════════ +# טסט הסחיפה — שני העותקים הקיימים בריפו, מול הספרייה עצמה +# ════════════════════════════════════════════════════════════════════ +# +# ‏``md_parser`` אינו כותב אף אחד משני הכללים האלה בעצמו: את ה-front +# matter הוא מקבל מהתוסף, ואת ה-``\r`` הבודד הוא בודק בביטוי שזהה +# מילה במילה לזה שבמנתב. מה שנשאר לבדוק הוא **שני העותקים שכבר קיימים +# בריפו** — והאמת שמולה הם נמדדים היא ``markdown-it-py`` עצמו. + + +def _load_ai_map_generator(): + """טעינה לפי נתיב, כי ``scripts`` אינה חבילה — כמו ב- + ``tests/test_ai_map_generator.py``.""" + import importlib.util + + spec = importlib.util.spec_from_file_location( + "generate_ai_map_for_drift", _REPO / "scripts" / "generate_ai_map.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_LINE_ENDING_INPUTS = [ + ("LF", "# A\n\n## B\n"), + ("CRLF", "# A\r\n\r\n## B\r\n"), + ("CR בלבד", "# A\r\r## B\r"), + ("LF עם CR באמצע", "# A\n\n## B\rX\n\n### C\n"), + ("CR בסוף הקובץ", "# A\n\n## B\r"), + ("בלי שום CR", "סתם טקסט\n"), +] + + +@pytest.mark.parametrize("name, text", _LINE_ENDING_INPUTS) +def test_the_lone_cr_rule_matches_markdown_it(name, text): + """שני העותקים של כלל ה-``\\r`` הבודד, מול ההתנהגות שהם מתארים. + + אמת המידה אינה אחד מהם אלא ``markdown-it-py``: הכלל שלו, + ``rules_core/normalize.py`` עם ``\\r\\n?|\\n``, הוא מה שהופך ``\\r`` + בודד לשורה חדשה — ומשם הפער מול ``split("\\n")`` שכל שדות ה- + ``lines`` ב-MCP נספרים בו. + + הטסט מריץ את הביטוי משני המקומות ואת הספרייה, ודורש שהשלושה יסכימו + על כל קלט. פיצול עתידי של אחד מהם ייפול כאן. + """ + from mcp_server import outline as outline_router + + by_router = bool(outline_router._CR_WITHOUT_LF.search(text)) + by_parser = bool(md_parser._CR_WITHOUT_LF.search(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: "חמש-עשרה", +} + + +def test_the_measured_front_matter_gap_matches_the_prose(): + """הפער שנמדד בטבלה הוא מה שכתוב בפרוזה, בשני המקומות שמצטטים אותו. + + **למה טסט ולא סתם לתקן את המספר.** זה בדיוק מה שכבר קרה: הטבלה + גדלה, והפרוזה נשארה על "חמש מתוך שלוש-עשרה" בזמן שבטבלה היו + אחת-עשרה שורות וארבע חלוקות — שלוש רשימות שונות של "חמש הצורות" + חיו בריפו בו-זמנית. מספר שמוקלד ביד במקום שני מתיישן בשקט; מספר + שנגזר מהקוד לא יכול. + """ + disagreements = _HEBREW_NUMBERS[_FRONT_MATTER_DISAGREEMENTS] + total = _HEBREW_NUMBERS[len(_FRONT_MATTER_SHAPES)] + expected = f"{disagreements} מתוך {total}" + + 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()`` שבו מקבל ``---`` מוזח שהתוסף + דוחה, ופוסל ``----`` וסוגר ארוך מהפותח שהתוסף מקבל. + + .. warning:: + + **נפילה של הטסט הזה אחרי שהפער ייסגר היא תוצאה מכוונת.** היעד + הוא ש-``_body_start`` יעבור להשתמש בתוסף; ביום שזה יקרה, הטסט + הזה **חייב** להיערך יחד עם השינוי ועם האישו שמתעד אותו. אל + "תתקן" אותו כאילו היה באג — הוא הדבר היחיד שמונע מהפער להיסחף + בשקט לכיוון שלישי. + + בקורפוס של היום אין לאף אחת מחמש הצורות החלוקות מופע: 23 מתוך 34 + קובצי ה-``.md`` תחת ``docs/`` פותחים ב-``---`` מדויק, ואפס במוזח או + עם רווח. כלומר המפה שנוצרת היום נכונה, והפער הוא חוב ולא תקלה. + """ + 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, {})) + assert saw_block is plugin_sees, f"התנהגות התוסף השתנתה בצורה {name!r}" + + generator = _load_ai_map_generator() + assert bool(generator._body_start(text.split("\n"))) is body_start_sees, ( + f"‏_body_start השתנה בצורה {name!r} — עדכן את הטבלה יחד עם האישו" + ) + + +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: + if not text: + continue + titles = [s.title for s in md_parser.parse_document(text).sections] + if plugin_sees: + assert titles == ["כותרת"], (name, titles) + else: + assert "כותרת" in titles, (name, titles) + + +# ════════════════════════════════════════════════════════════════════ +# פונקציות העץ המשותפות — על מסמך Markdown, ולא רק על RST +# ════════════════════════════════════════════════════════════════════ + +# ‏``services/doc_sections.py`` נכתב עבור ``rst_parser`` והיה לו עד עכשיו +# מזין אחד בלבד. הפארסר הזה מזין אותו בצורה שהוא לא ראה קודם: כותרת +# שנושאת **סימון גולמי** (בקטיקים, הדגשה, קישור), וכותרת setext שנפרסת +# על שתי שורות ולכן ה-``title`` שלה מכיל ``\n`` באמצע. ב-RST שתי +# הצורות אינן קיימות. +# +# הטסטים כאן מריצים את כל הפונקציות המשותפות שאין להן אף הרצה כזאת — +# ‏``build_toc``, ‏``find_sections``, ‏``neighbors``, ‏``normalize_title``, +# ‏``section_bounds``, ‏``suggest``, ‏``direct_subsections``. ‏(``section_text`` +# כבר מורץ למעלה.) הם נכתבו לפני שנבדק אם משהו נשבר: אם הכול עובר, +# הערך שלהם הוא הקיבוע — שינוי עתידי באחת מהן ייתפס גם מהצד הזה. +_MARKDOWN_SHAPED = """# `code` ו-**bold** + +מבוא + +## [קישור](https://example.com) בכותרת + +תוכן א + +כותרת setext +שנפרסת על שתי שורות +--- + +תוכן ב + +### תת-סעיף + +תוכן ג +""" + + +def _shaped() -> doc_sections.Document: + return md_parser.parse_document(_MARKDOWN_SHAPED) + + +def test_the_shared_tree_sees_the_markdown_document_as_three_top_levels(): + """קודם המבנה, כי כל השאר נשען עליו.""" + doc = _shaped() + assert [(s.level, s.title) for s in doc.sections] == [ + (1, "`code` ו-**bold**"), + (2, "[קישור](https://example.com) בכותרת"), + (2, "כותרת setext\nשנפרסת על שתי שורות"), + (3, "תת-סעיף"), + ] + + +def test_find_sections_matches_a_title_that_carries_raw_markup(): + """‏``find_sections`` עובר דרך ``normalize_title``, וזה המסלול שנבדק. + + הסימון הגולמי **אינו** מוסר בדרך — מי שמחפש מחפש את מה שכתוב + בקובץ. מה שכן נסלח הוא רווח כפול, וזה נבדק כאן יחד. + """ + doc = _shaped() + found = doc_sections.find_sections(doc, "`code` ו-**bold**") + assert [s.level for s in found] == [1] + assert doc_sections.find_sections(doc, "code ו-bold") == [] + + +def test_a_setext_title_that_spans_two_lines_is_found_by_its_flattened_form(): + """ה-``\\n`` שבתוך הכותרת מתקפל לרווח בנרמול, ולכן חיפוש בשורה אחת תופס. + + זו הצורה שהפארסר הזה מזין ל-``doc_sections`` ו-RST אינו מזין + לעולם: ב-RST כותרת היא תמיד שורה אחת. + """ + doc = _shaped() + flat = "כותרת setext שנפרסת על שתי שורות" + assert doc_sections.normalize_title(flat) == doc_sections.normalize_title( + "כותרת setext\nשנפרסת על שתי שורות" + ) + found = doc_sections.find_sections(doc, flat) + assert len(found) == 1 + # ‏``adornment`` של setext הוא **תו אחד** ולא הקו המלא, כפי שכבר + # מקובע ב-``test_the_adornment_is_the_markup_of_the_heading``. + assert found[0].adornment == "-" + + +def test_section_bounds_and_direct_subsections_agree_on_where_the_child_starts(): + """שתי הפונקציות מתארות את אותו גבול משני צדדים.""" + doc = _shaped() + setext = doc_sections.find_sections(doc, "כותרת setext שנפרסת על שתי שורות")[0] + children = doc_sections.direct_subsections(doc, setext) + assert [c.title for c in children] == ["תת-סעיף"] + + without = doc_sections.section_bounds(doc, setext, False) + with_kids = doc_sections.section_bounds(doc, setext, True) + assert without[1] == children[0].heading_line - 1 + assert with_kids[1] == len(doc.lines) + # הכותרת עצמה נכללת בטווח, כולל שתי שורות הטקסט שלה וקו ה-``---``. + body = doc_sections.section_text(doc, setext, False) + assert body.startswith("כותרת setext\nשנפרסת על שתי שורות\n---") + + +def test_neighbors_walks_the_two_top_level_twos_and_not_the_child(): + """שכנות היא לפי אותו אב ואותה רמה — כאן שתי ה-``##``.""" + doc = _shaped() + link, setext, child = doc.sections[1], doc.sections[2], doc.sections[3] + + prev, nxt = doc_sections.neighbors(doc, link) + assert prev is None + assert nxt is setext + + prev, nxt = doc_sections.neighbors(doc, setext) + assert prev is link + assert nxt is None + + # ל-``תת-סעיף`` אין אחים, ולכן שני הצדדים ריקים. + assert doc_sections.neighbors(doc, child) == (None, None) + + +def test_build_toc_carries_the_raw_title_and_a_range_that_holds_the_body(): + """ה-TOC הוא מה שיוצג למשתמש, ולכן הכותרת בו היא המקור הגולמי.""" + doc = _shaped() + toc = doc_sections.build_toc(doc) + assert [row["title"] for row in toc] == [s.title for s in doc.sections] + assert [row["level"] for row in toc] == [1, 2, 2, 3] + + child = toc[3] + assert child["breadcrumb"] == [ + "`code` ו-**bold**", + "כותרת setext\nשנפרסת על שתי שורות", + "תת-סעיף", + ] + start, end = child["line_range"] + assert "תוכן ג" in "\n".join(doc.lines[start - 1:end]) + # הגודל נמדד בבייטים של UTF-8, ולכן על עברית הוא גדול ממספר התווים. + assert child["approx_bytes"] > end - start + + +def test_suggest_returns_the_title_as_it_was_written_in_the_file(): + """שאילתה שלא נמצאה מקבלת הצעה — והיא חייבת לחזור עם הסימון הגולמי.""" + doc = _shaped() + assert doc_sections.find_sections(doc, "תת סעיף") == [] + assert "תת-סעיף" in doc_sections.suggest(doc, "תת סעיף") + + +# ════════════════════════════════════════════════════════════════════ +# שתי התקרות — מספר אחד בשני קבצים +# ════════════════════════════════════════════════════════════════════ + + +def test_the_two_ceilings_are_the_same_number(): + """‏``MAX_SECTIONS`` ו-``MAX_SYMBOLS`` מתארים את אותו גבול, ולא נגזרים זה מזה. + + ‏``services`` אינו מייבא מ-``mcp_server`` — הכיוון חד-סטרי ומנומק + ב-``TooManySections`` — ולכן הערך מוקלד פעמיים, ושתי הפרוזות מפנות + זו לזו. טסט רשאי לייבא משתי החבילות, וזה המקום היחיד שבו השוויון + באמת נבדק. בלי הטסט הזה, שינוי באחד מהשניים היה משאיר את השני + מאחור בשקט — ובדיוק זה מה ששני זוגות המספרים האחרים בענף הזה + מקובעים מפניו. + """ + from mcp_server.outline_scanners import _ceiling + + assert md_parser.MAX_SECTIONS == _ceiling.MAX_SYMBOLS diff --git a/tests/test_md_parser_oracle.py b/tests/test_md_parser_oracle.py new file mode 100644 index 000000000..d2a140695 --- /dev/null +++ b/tests/test_md_parser_oracle.py @@ -0,0 +1,695 @@ +"""‏``md_parser`` מול cmark-gfm — טבלת אמת מחוללת. + +**האורקל הוא ספרייה אחרת בשפה אחרת.** ``cmarkgfm`` עוטף את cmark-gfm, +הפארסר ש-GitHub מריץ בפועל, והוא נקרא כאן ברינדור ל-HTML עם +``CMARK_OPT_SOURCEPOS`` — כלומר הרמה, מספר השורה, **וגם** התשובה על +"בתוך איזה מכל השורה הזאת יושבת" מגיעים ממנו ולא מסורק שכתבתי. הקובץ +הזה **אינו מייבא שום דבר מ-``md_parser``** מלבד ``parse_document`` +עצמה, כי אורקל שגוזר את הציפייה מאותו מקור שהוא בודק אינו אורקל. + +**והייבוא ישיר ולא ב-``importorskip``.** ``cmarkgfm`` נעוץ ב- +``requirements/development.txt``, ו-CI מתקין אותו; היעלמות שלו היא +הרגרסיה, לא סיבה לדלג בשקט. אותו נימוק שכתוב ב- +``tests/test_mcp_analytics_privacy.py`` על ``posthog``. + +**למה טבלה מחוללת ולא קורפוס של קבצים אמיתיים.** נמדד על +``amir-bug-patterns``: כל 30 מופעי ה-``#``-שאינו-כותרת שם יושבים בגדרות +קוד — אפס בקוד מוזח, אפס בבלוקי HTML, אפס ב-front matter. כלומר פארסר +שימדל **מחלקת אזור אחת בלבד** יקבל 100% על קורפוס אמיתי ויישבר על +הקובץ הבא. הצורות המחוללות הן הראיה. + +**ומה שהקובץ הזה אינו עושה, כדי שזה לא ייקרא כהשמטה:** הוא **אינו מריץ +אף קובץ אמיתי**. זו החלטה. קורפוס אמיתי הוא בדיקת שפיות חד-פעמית ולא +רשת שתופסת רגרסיה עתידית, והוא היה דורש להחזיק בריפו הזה עותק של תוכן +שאינו שלו. ההרצה על קורפוס אמיתי נעשית על פי דרישה, עם +``scripts/compare_md_parser_to_cmark.py`` — לפני שלב 2, אחרי שדרוג של +``markdown-it-py``, וכשנוגעים בפארסר. + +**מה בדיוק מושווה, כדי שהתוצאה לא תיקרא רחבה ממה שהיא.** ההשוואה +הראשית — ``_compare`` — היא על **הרמה ומספר השורה** של כל כותרת ברמת +המסמך, ולא על טקסט הכותרת. "אפס אי-הסכמות" היא לכן טענה על **גבולות**: +היכן מתחיל סעיף ואיזו רמה הוא. טקסט הכותרת מושווה בנפרד, ב- +``_compare_titles``, ורק על תת-קבוצה — ההנמקה שם. + +**ולטענה יש חריג אחד ידוע, והוא מקובע ולא מושתק:** תגית HTML מסוג 7 +שצמודה לשורת פריט רשימה. שם ``markdown-it`` סוטה מ-cmark-gfm וממימוש +הייחוס של המפרט, והצורות האלה **אינן** עוברות ב-``_compare`` — הן +עוברות בטסט שמאשר את הפער המדוד, ובטסט שמאשר שהמוחרג הוא בדיוק הן +ולא יותר. ``_type7_after_list_shapes`` מנמק. + +**והחלוקה לכמה טסטים אינה קוסמטית:** ``pytest.ini`` קובע +``timeout = 60`` לכל טסט, וטבלה אחת גדולה הייתה מתקרבת לשם. +""" + +from __future__ import annotations + +import itertools +from html.parser import HTMLParser + +import cmarkgfm +import pytest +from cmarkgfm.cmark import Options +from markdown_it import MarkdownIt +from mdit_py_plugins.front_matter import front_matter_plugin + +from services.md_parser import parse_document + +#: מכלים שכותרת בתוכם אינה סעיף במסמך. ``blockquote`` ו-``li`` הם מה +#: ש-cmark מסמן בפועל; ``level`` של ``markdown-it`` מתאר את אותו דבר +#: בדיוק, ונמדד: 0 ברמת המסמך, 1 בציטוט, 2 בפריט רשימה. +_CONTAINER_TAGS = frozenset({"blockquote", "li"}) + + +class _HeadingCollector(HTMLParser): + """כותרות מתוך ה-HTML ש-cmark-gfm מייצר, עם השורה והמכל שמעליהן.""" + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.open_tags: list[str] = [] + self.headings: list[tuple[int, int, bool]] = [] # (רמה, שורה, בתוך מכל) + # ‏**רשימה מקבילה ולא שדה רביעי בטאפל.** ``headings`` נצרך בשלושה + # מקומות שמפרקים אותו לשלושה, והרחבה שם הייתה גוררת את כולם בלי + # שהטקסט מעניין אף אחד מהם. כאן האינדקסים מקבילים, וזה כל הקשר. + self.texts: list[str] = [] + self._depth = 0 + + def handle_starttag(self, tag, attrs): + if tag in ("hr", "br", "img"): + return + if tag in ("h1", "h2", "h3", "h4", "h5", "h6"): + source_pos = dict(attrs).get("data-sourcepos") + assert source_pos, "cmark הורץ בלי CMARK_OPT_SOURCEPOS" + line = int(source_pos.split(":", 1)[0]) + inside = any(t in _CONTAINER_TAGS for t in self.open_tags) + self.headings.append((int(tag[1]), line, inside)) + self.texts.append("") + self._depth = 1 + elif self._depth: + # תגית שנפתחה **בתוך** הכותרת — ````, ````, ````. + # הטקסט שלה נאסף כמו כל טקסט אחר, אבל הסימון שהוליד אותה + # כבר אבד, ובדיוק לכן הכותרות האלה מוחרגות מהשוואת הטקסט. + 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"\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, "אילו השתיים היו זהות, לא היה טעם להחריג"