From 7a3aa29fc010c2604b84d6437ed87053947f3f27 Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Sun, 10 May 2026 19:11:24 +0300 Subject: ci: extend doc-linter to MyST Markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Active docs are now MyST `.md`; the linter previously only inspected `.rst` and `.txt`, so ~250 active pages were unchecked for IP usage and line length on every PR. scripts/doc-linter.py: - Add `.md` to the extension filter (use `endswith` for correctness; the prior 4-char slice silently skipped `.md` files). - Track MyST/Markdown fenced code blocks (```` ``` ```` and `:::`) for line-length exemption — same semantics as `.. code-block::` for RST. - Recognize both suppression marker forms: `.. stop_vyoslinter` / `.. start_vyoslinter` (RST and `.txt` includes) and `% stop_vyoslinter` / `% start_vyoslinter` (MyST). Both work in either context; pick the form that matches the surrounding parser. - Replace the brittle `try/finally: fp.close()` with a `with` block — the previous form raised `UnboundLocalError` if `open()` itself failed. - Fix typo `forgett` → `forget`. .github/instructions/rst-linter.instructions.md → doc-linter.instructions.md: - Broaden `applyTo` from `**/*.rst` to `**/*.md,**/*.rst,**/*.txt`. - Document MyST suppression syntax and fenced-code line-length exemption. - Note the parser-form rule for `{eval-rst}` blocks. No regression on `.txt` includes: identical lint output verified against the origin/rolling baseline on a sample of files. Pre-existing IP violations exist in 14 `.md` files (e.g. `configexamples/lac-lns.md` line 95 — a `8.8.8.8` already wrapped in `% stop_vyoslinter`/`% start_vyoslinter`, correctly suppressed). PRs touching unsuppressed violations will start failing CI; this is the intent of enabling the check. 🤖 Generated by [robots](https://vyos.io) --- .github/instructions/doc-linter.instructions.md | 90 ++++++++++++++++ .github/instructions/rst-linter.instructions.md | 70 ------------- scripts/doc-linter.py | 133 +++++++++++++----------- 3 files changed, 163 insertions(+), 130 deletions(-) create mode 100644 .github/instructions/doc-linter.instructions.md delete mode 100644 .github/instructions/rst-linter.instructions.md diff --git a/.github/instructions/doc-linter.instructions.md b/.github/instructions/doc-linter.instructions.md new file mode 100644 index 00000000..5e7d359e --- /dev/null +++ b/.github/instructions/doc-linter.instructions.md @@ -0,0 +1,90 @@ +--- +applyTo: "**/*.md,**/*.rst,**/*.txt" +--- + +## Doc Linter Rules + +Every pull request is automatically linted by `scripts/doc-linter.py`. It checks **only changed files** for two things: IP address usage and line length. MyST Markdown (`.md`), legacy RST (`.rst`), and `.txt` includes are all linted. + +### IP Address Rules + +The linter rejects public IP addresses that are not reserved for documentation. + +**Use these documentation-reserved addresses (no suppression needed):** + +| Type | Range | RFC | +|------|-------|-----| +| IPv4 | `192.0.2.0/24` (TEST-NET-1) | RFC 5737 | +| IPv4 | `198.51.100.0/24` (TEST-NET-2) | RFC 5737 | +| IPv4 | `203.0.113.0/24` (TEST-NET-3) | RFC 5737 | +| IPv6 | `2001:db8::/32` | RFC 3849 | + +**These are allowed without suppression (not flagged by linter):** + +| Type | Range | Why | +|------|-------|-----| +| RFC 1918 private | `10.0.0.0/8` | Private address space | +| RFC 1918 private | `172.16.0.0/12` | Private address space | +| RFC 1918 private | `192.168.0.0/16` | Private address space | +| Loopback | `127.0.0.0/8` | Loopback range | +| Link-local | `169.254.0.0/16` | Link-local | + +**These require `stop/start_vyoslinter` suppression:** + +| Example | Why suppression is needed | +|---------|--------------------------| +| `8.8.8.8` (Google DNS) | Real public IP, not documentation-reserved | +| `64:ff9b::/96` (NAT64) | Well-known prefix, not in doc ranges | +| Real provider IPs in examples | Authenticity matters for the example | + +### Line Length Rules + +Maximum 80 characters per line. The line-length check is **skipped** inside fenced code: + +- MyST / Markdown: ` ```...``` ` and `:::...:::` fenced blocks (any info string, including ` ```{eval-rst} `, ` ```{cfgcmd} `, etc.). +- RST: lines indented under `.. code-block::` directives. + +### Suppression Syntax + +When real public IPs or long lines are unavoidable, wrap the block. The marker form depends on the surrounding parser: + +**MyST Markdown (`.md`)** — use the MyST comment form: + +````md +% stop_vyoslinter + +``` +set system name-server '8.8.8.8' +``` + +% start_vyoslinter +```` + +**RST (`.rst`) and `.txt` includes** — use the RST comment form: + +```rst +.. stop_vyoslinter + +.. code-block:: none + + set system name-server '8.8.8.8' + +.. start_vyoslinter +``` + +**Inside a `{eval-rst}` block in MyST** — the embedded content is parsed as RST, so the RST form (`.. stop_vyoslinter`) matches the surrounding parser. The linter recognizes both forms regardless of context, but use the form that fits the parser of the enclosing block. + +**Rules for suppression markers:** + +- Place the stop marker on its own line, immediately before the content. +- Place the start marker on its own line, immediately after the content. +- Always re-enable the linter — never leave it stopped for the rest of the file. +- Suppress the smallest possible region. +- Do NOT use suppression for content that can use documentation-reserved addresses instead. + +### Common Mistakes + +- Removing `stop/start_vyoslinter` markers without fixing the underlying issue (exposes the line to the linter and fails CI). +- Using real public IPs when documentation addresses would work just as well. +- Adding suppression markers around content that only has private (RFC 1918) addresses or `0.0.0.0/0` — these are allowed and don't need suppression. +- Mixing marker forms (using `% stop_vyoslinter` inside an `{eval-rst}` block, or `.. stop_vyoslinter` in plain MyST prose). diff --git a/.github/instructions/rst-linter.instructions.md b/.github/instructions/rst-linter.instructions.md deleted file mode 100644 index 419f094a..00000000 --- a/.github/instructions/rst-linter.instructions.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -applyTo: "**/*.rst" ---- - -## RST Linter Rules - -Every pull request is automatically linted by `doc-linter.py` (from the `vyos/.github` repository). It checks **only changed files** for two things: IP address usage and line length. - -### IP Address Rules - -The linter rejects public IP addresses that are not reserved for documentation. - -**Use these documentation-reserved addresses (no suppression needed):** - -| Type | Range | RFC | -|------|-------|-----| -| IPv4 | `192.0.2.0/24` (TEST-NET-1) | RFC 5737 | -| IPv4 | `198.51.100.0/24` (TEST-NET-2) | RFC 5737 | -| IPv4 | `203.0.113.0/24` (TEST-NET-3) | RFC 5737 | -| IPv6 | `2001:db8::/32` | RFC 3849 | - -**These are allowed without suppression (not flagged by linter):** - -| Type | Range | Why | -|------|-------|-----| -| RFC 1918 private | `10.0.0.0/8` | Private address space | -| RFC 1918 private | `172.16.0.0/12` | Private address space | -| RFC 1918 private | `192.168.0.0/16` | Private address space | -| Loopback | `127.0.0.0/8` | Loopback range | -| Link-local | `169.254.0.0/16` | Link-local | - -**These require `stop/start_vyoslinter` suppression:** - -| Example | Why suppression is needed | -|---------|--------------------------| -| `8.8.8.8` (Google DNS) | Real public IP, not documentation-reserved | -| `64:ff9b::/96` (NAT64) | Well-known prefix, not in doc ranges | -| Real provider IPs in examples | Authenticity matters for the example | - -### Line Length Rules - -Maximum 80 characters per line. Lines inside `.. code-block::` directives are **exempt** from the line length limit (they render with `
` tags and preserve source formatting).
-
-### Suppression Syntax
-
-When real public IPs or long lines are unavoidable, wrap the block:
-
-```rst
-.. stop_vyoslinter
-
-.. code-block:: none
-
-   set system name-server '8.8.8.8'
-
-.. start_vyoslinter
-```
-
-**Rules for suppression markers:**
-
-- Place `.. stop_vyoslinter` on its own line, immediately before the content
-- Place `.. start_vyoslinter` on its own line, immediately after the content
-- Always re-enable the linter — never leave it stopped for the rest of the file
-- Suppress the smallest possible region
-- Do NOT use suppression for content that can use documentation-reserved addresses instead
-
-### Common Mistakes
-
-- Removing `stop/start_vyoslinter` markers without fixing the underlying issue (exposes the line to the linter and fails CI)
-- Using real public IPs when documentation addresses would work just as well
-- Adding suppression markers around content that only has private (RFC 1918) addresses or `0.0.0.0/0` — these are allowed and don't need suppression
diff --git a/scripts/doc-linter.py b/scripts/doc-linter.py
index 3dc7c2fc..ec00472c 100644
--- a/scripts/doc-linter.py
+++ b/scripts/doc-linter.py
@@ -27,6 +27,20 @@ MAC = r'([0-9A-F]{2}[:-]){5}([0-9A-F]{2})'
 
 NUMBER = r"([\s']\d+[\s'])"
 
+SUPPORTED_EXTS = ('.md', '.rst', '.txt')
+
+# MyST / Markdown fenced code block: leading whitespace + 3+ backticks or 3+ colons.
+# Same character and length-or-greater closes.
+MD_FENCE_RE = re.compile(r'^(\s*)(`{3,}|:{3,})')
+
+
+def is_suppression_marker(line, kind):
+    """Detect `.. stop_vyoslinter` (RST/txt) or `% stop_vyoslinter` (MyST)."""
+    token = f"{kind}_vyoslinter"
+    if token not in line:
+        return False
+    return f".. {token}" in line or f"% {token}" in line
+
 
 def lint_mac(cnt, line):
     mac = re.search(MAC, line, re.I)
@@ -79,65 +93,64 @@ def lint_linelen(cnt, line):
 
 def handle_file_action(filepath):
     errors = []
-    try:
-        with open(filepath) as fp:
-            line = fp.readline()
-            cnt = 1
-            test_line_lenght = True
-            start_vyoslinter = True
-            indentation = 0
-            while line:
-                # search for ignore linter comments in lines
-                if ".. stop_vyoslinter" in line:
-                    start_vyoslinter = False
-                if ".. start_vyoslinter" in line:
-                    start_vyoslinter = True
-                if start_vyoslinter:
-                    # ignore every '.. code-block::' for line lenght
-                    # rst code-block have its own style in html the format in rst
-                    # and the build page must be the same
-                    if test_line_lenght is False:
-                        if len(line) > indentation:
-                            #print(f"'{line}'")
-                            #print(indentation)
-                            if line[indentation].isspace() is False:
-                                test_line_lenght = True
-
-                    if ".. code-block::" in line:
-                        test_line_lenght = False
-                        indentation = 0
-                        for i in line:
-                            if i.isspace():
-                                indentation = indentation + 1
-                            else:
-                                break
-                    
-                    err_mac = lint_mac(cnt, line.strip())
-                    # disable mac detection for the moment, too many false positives
-                    err_mac = None
-                    err_ip4 = lint_ipv4(cnt, line.strip())
-                    err_ip6 = lint_ipv6(cnt, line.strip())
-                    if test_line_lenght:
-                        err_len = lint_linelen(cnt, line)
+    in_md_fence = False
+    md_fence_char = None
+    md_fence_min_len = 0
+    in_rst_codeblock = False
+    rst_codeblock_indent = 0
+    start_vyoslinter = True
+    cnt = 0
+
+    with open(filepath) as fp:
+        for cnt, line in enumerate(fp, start=1):
+            if is_suppression_marker(line, 'stop'):
+                start_vyoslinter = False
+            if is_suppression_marker(line, 'start'):
+                start_vyoslinter = True
+
+            if not start_vyoslinter:
+                continue
+
+            # MD/MyST fenced code block tracking (``` or :::)
+            fence_match = MD_FENCE_RE.match(line)
+            if fence_match:
+                fence = fence_match.group(2)
+                fence_char = fence[0]
+                fence_len = len(fence)
+                if not in_md_fence:
+                    in_md_fence = True
+                    md_fence_char = fence_char
+                    md_fence_min_len = fence_len
+                elif fence_char == md_fence_char and fence_len >= md_fence_min_len:
+                    in_md_fence = False
+
+            # RST `.. code-block::` tracking (existing semantics for .rst/.txt).
+            if in_rst_codeblock:
+                if len(line) > rst_codeblock_indent and not line[rst_codeblock_indent].isspace():
+                    in_rst_codeblock = False
+            if not in_rst_codeblock and ".. code-block::" in line:
+                in_rst_codeblock = True
+                rst_codeblock_indent = 0
+                for ch in line:
+                    if ch.isspace():
+                        rst_codeblock_indent += 1
                     else:
-                        err_len = None
-                    if err_mac:
-                        errors.append(err_mac)
-                    if err_ip4:
-                        errors.append(err_ip4)
-                    if err_ip6:
-                        errors.append(err_ip6)
-                    if err_len:
-                        errors.append(err_len)
-                
-                line = fp.readline()
-                cnt += 1
-            
-            # ensure linter was not stop on top and forgot to tun on again
-            if start_vyoslinter == False:
-                errors.append((f"Don't forgett to turn linter back on", cnt, 'error'))
-    finally:
-        fp.close()
+                        break
+
+            test_line_length = not (in_md_fence or in_rst_codeblock)
+
+            err_mac = lint_mac(cnt, line.strip())
+            # disable mac detection for the moment, too many false positives
+            err_mac = None
+            err_ip4 = lint_ipv4(cnt, line.strip())
+            err_ip6 = lint_ipv6(cnt, line.strip())
+            err_len = lint_linelen(cnt, line) if test_line_length else None
+            for e in (err_mac, err_ip4, err_ip6, err_len):
+                if e:
+                    errors.append(e)
+
+        if not start_vyoslinter:
+            errors.append(("Don't forget to turn linter back on", cnt, 'error'))
 
     if len(errors) > 0:
         '''
@@ -156,14 +169,14 @@ def main():
     try:
         files = ast.literal_eval(sys.argv[1])
         for file in files:
-                if file[-4:] in [".rst", ".txt"] and "_build" not in file:
+                if file.endswith(SUPPORTED_EXTS) and "_build" not in file:
                     if handle_file_action(file) is False:
                         bool_error = False
     except Exception as e:
         for root, dirs, files in os.walk("docs"):
             path = root.split(os.sep)
             for file in files:
-                if file[-4:] in [".rst", ".txt"] and "_build" not in path:
+                if file.endswith(SUPPORTED_EXTS) and "_build" not in path:
                     fpath = '/'.join(path)
                     filepath = f"{fpath}/{file}"
                     if handle_file_action(filepath) is False:
-- 
cgit v1.2.3