From ac9f61e5ae366774e51975b0faddd7ba07996762 Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Thu, 14 May 2026 00:42:34 +0300 Subject: ci(doc-linter): delete lint_mac dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lint_mac()` was called and its return value immediately overwritten with `None`: err_mac = lint_mac(cnt, line.strip()) # disable mac detection for the moment, too many false positives err_mac = None The comment is correct — MAC linting produced too many false positives — but the cleanup never landed. The dead call ran on every line for every linted file, and the function/MAC-regex/MAC-error-text all sat in the source as a misleading hint that MAC linting was a live feature. Delete: - the `MAC` regex constant - the `lint_mac()` function - the `err_mac = lint_mac(...)`, `err_mac = None`, and the `err_mac` entry in the tuple iterated by the error-collection loop in `handle_file_action()` Tracked as item 9 of the rolling-side cleanup backlog flagged across the PR #2014 / #2019 / #2020 reviews. When MAC linting is genuinely wanted again, recover the regex/function from git history and wire it in cleanly. 🤖 Generated by [robots](https://vyos.io) --- scripts/doc-linter.py | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/scripts/doc-linter.py b/scripts/doc-linter.py index 8e74600f..790409de 100644 --- a/scripts/doc-linter.py +++ b/scripts/doc-linter.py @@ -23,10 +23,6 @@ IPV6GROUPS = ( ) IPV6ADDR = '|'.join(['(?:{})'.format(g) for g in IPV6GROUPS[::-1]]) # Reverse rows for greedy match -MAC = r'([0-9A-F]{2}[:-]){5}([0-9A-F]{2})' - -NUMBER = r"([\s']\d+[\s'])" - SUPPORTED_EXTS = ('.md', '.rst', '.txt') # Linter only applies to published documentation sources under docs/. Repo-root @@ -92,17 +88,6 @@ def is_suppression_marker(line, kind, in_md_fence, in_rst_codeblock, return False -def lint_mac(cnt, line): - """Flag MAC addresses outside the RFC 7042 documentation range.""" - mac = re.search(MAC, line, re.I) - if mac is not None: - mac = mac.group() - u_mac = re.search(r'((00)[:-](53)([:-][0-9A-F]{2}){4})', mac, re.I) - m_mac = re.search(r'((90)[:-](10)([:-][0-9A-F]{2}){4})', mac, re.I) - if u_mac is None and m_mac is None: - return (f"Use MAC reserved for Documentation (RFC7042): {mac}", cnt, 'error') - - def lint_ipv4(cnt, line): """Flag IPv4 addresses outside RFC 5737 / private / multicast ranges.""" ip = re.search(IPV4ADDR, line, re.I) @@ -222,13 +207,10 @@ def handle_file_action(filepath): 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): + for e in (err_ip4, err_ip6, err_len): if e: errors.append(e) -- cgit v1.2.3 From 61ecb4e103c6a6225b3d71586f7f65e41c3e3510 Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Thu, 14 May 2026 00:43:01 +0300 Subject: ci(doc-linter): delete lint_AS placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lint_AS()` and its `NUMBER` regex were a placeholder for a future AS-number documentation-range check (RFC 5398). `lint_AS()` was never called from anywhere — it'd merely `pass` on `re.search` hit. Pure dead code that made it look like AS-number linting existed when it didn't. Delete: - the `NUMBER` regex constant - the `lint_AS()` function If/when AS-number linting is actually desired, implement it properly: hook into the lint loop in `handle_file_action()`, return the standard `(message, line, severity)` tuple on violations, and define the allowed AS ranges from `/^AGENTS.md/` (currently 64496–64511 and 65536–65551). Tracked as item 8 of the rolling-side cleanup backlog from PR #2014 / #2019 / #2020 reviews. 🤖 Generated by [robots](https://vyos.io) --- scripts/doc-linter.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/scripts/doc-linter.py b/scripts/doc-linter.py index 790409de..2214a851 100644 --- a/scripts/doc-linter.py +++ b/scripts/doc-linter.py @@ -117,14 +117,6 @@ def lint_ipv6(cnt, line): return (f"Use IPv6 reserved for Documentation (RFC 3849) or private Space: {ip}", cnt, 'error') -def lint_AS(cnt, line): - """Placeholder for future AS-number documentation-range checks (RFC 5398).""" - number = re.search(NUMBER, line, re.I) - if number: - pass - # find a way to detect AS numbers - - def lint_linelen(cnt, line): """Warn when a line exceeds the 80-character docs convention.""" line = line.rstrip() -- cgit v1.2.3 From 85c0c1ea222d2c70662de5a03ecaf04b6498006e Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Thu, 14 May 2026 00:43:41 +0300 Subject: ci(doc-linter): check every IP on a line, not just the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lint_ipv4()` and `lint_ipv6()` used `re.search`, which returns only the first match. A line like Set DNS forwarder 192.0.2.1 then fall back to 8.8.8.8 flagged nothing because `192.0.2.1` (RFC 5737 documentation range) is allowed and the search stopped there. The real public IP `8.8.8.8` slipped through despite being exactly the case the linter was meant to catch. Switch both functions to `re.finditer` and walk every match: return on the first disallowed address; only return None when all matches on the line are allowed (private / multicast / non-global). Also fix the casing of "private space" in both error messages — was "private Space" with a stray capital. Verified with 7 hand-coded cases (allowed + public mixes, boundary cases, IPv6 RFC 3849 / Google DNS). All pass. Tracked as items 1, 2, and 10 of the rolling-side cleanup backlog from PR #2014 / #2019 / #2020 reviews. 🤖 Generated by [robots](https://vyos.io) --- scripts/doc-linter.py | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/scripts/doc-linter.py b/scripts/doc-linter.py index 2214a851..3903e2cf 100644 --- a/scripts/doc-linter.py +++ b/scripts/doc-linter.py @@ -89,32 +89,41 @@ def is_suppression_marker(line, kind, in_md_fence, in_rst_codeblock, def lint_ipv4(cnt, line): - """Flag IPv4 addresses outside RFC 5737 / private / multicast ranges.""" - ip = re.search(IPV4ADDR, line, re.I) - if ip is not None: - ip = ipaddress.ip_address(ip.group().strip(' ')) + """Flag IPv4 addresses outside RFC 5737 / private / multicast ranges. + + Iterates over every IPv4 match on the line — a line with an + allowed address followed by a real public address must not + pass just because the first match is allowed. + """ + for match in re.finditer(IPV4ADDR, line, re.I): + ip = ipaddress.ip_address(match.group().strip(' ')) # https://docs.python.org/3/library/ipaddress.html#ipaddress.IPv4Address.is_private if ip.is_private: - return None + continue if ip.is_multicast: - return None + continue if ip.is_global is False: - return None - return (f"Use IPv4 reserved for Documentation (RFC 5737) or private Space: {ip}", cnt, 'error') + continue + return (f"Use IPv4 reserved for Documentation (RFC 5737) or private space: {ip}", cnt, 'error') + return None def lint_ipv6(cnt, line): - """Flag IPv6 addresses outside RFC 3849 / private / multicast ranges.""" - ip = re.search(IPV6ADDR, line, re.I) - if ip is not None: - ip = ipaddress.ip_address(ip.group().strip(' ')) + """Flag IPv6 addresses outside RFC 3849 / private / multicast ranges. + + Iterates over every IPv6 match on the line — same all-matches + discipline as `lint_ipv4`. + """ + for match in re.finditer(IPV6ADDR, line, re.I): + ip = ipaddress.ip_address(match.group().strip(' ')) if ip.is_private: - return None + continue if ip.is_multicast: - return None + continue if ip.is_global is False: - return None - return (f"Use IPv6 reserved for Documentation (RFC 3849) or private Space: {ip}", cnt, 'error') + continue + return (f"Use IPv6 reserved for Documentation (RFC 3849) or private space: {ip}", cnt, 'error') + return None def lint_linelen(cnt, line): -- cgit v1.2.3 From cc1b4d7c28272786e39a11b37a3ca22b80b12eec Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Thu, 14 May 2026 00:44:19 +0300 Subject: ci(doc-linter): drop \s prefix from compressed-IPv6 regex branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The leading-compression group in `IPV6GROUPS` was r'(?:\s' + IPV6SEG + r':){1,7}:' The `\s` required whitespace before each hextet in the repeated group. In practice this meant compressed forms with leading hextets — `2001:db8::`, `64:ff9b::`, `fe80::1` — only matched when preceded by whitespace inside the line. The linter calls `lint_ipv6(line.strip())`, so at start-of-stripped-line there's no whitespace, and the address fell through to no match. Real-world impact: a documentation page mentioning `2001:4860:4860::8888` (Google DNS) or `64:ff9b::1` (NAT64 well-known prefix) at the start of a line silently passed the IPv6 documentation-address check. None of the other groups in `IPV6GROUPS` use a `\s` prefix. This one was inconsistent. Drop the `\s` so the branch matches compressed forms directly, like its peers. Verified with 6 hand-coded cases (RFC 3849 doc range, Google DNS, NAT64 prefix, mid-line and start-of-line positions). All pass. Tracked as item 3 of the rolling-side cleanup backlog from PR #2014 / #2019 / #2020 reviews. 🤖 Generated by [robots](https://vyos.io) --- scripts/doc-linter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/doc-linter.py b/scripts/doc-linter.py index 3903e2cf..f4628c0b 100644 --- a/scripts/doc-linter.py +++ b/scripts/doc-linter.py @@ -9,7 +9,7 @@ IPV4ADDR = r'\b(?:(?:' + IPV4SEG + r'\.){3,3}' + IPV4SEG + r')\b' IPV6SEG = r'(?:(?:[0-9a-fA-F]){1,4})' IPV6GROUPS = ( r'(?:' + IPV6SEG + r':){7,7}' + IPV6SEG, # 1:2:3:4:5:6:7:8 - r'(?:\s' + IPV6SEG + r':){1,7}:', # 1:: 1:2:3:4:5:6:7:: + r'(?:' + IPV6SEG + r':){1,7}:', # 1:: 1:2:3:4:5:6:7:: r'(?:' + IPV6SEG + r':){1,6}:' + IPV6SEG, # 1::8 1:2:3:4:5:6::8 1:2:3:4:5:6::8 r'(?:' + IPV6SEG + r':){1,5}(?::' + IPV6SEG + r'){1,2}', # 1::7:8 1:2:3:4:5::7:8 1:2:3:4:5::8 r'(?:' + IPV6SEG + r':){1,4}(?::' + IPV6SEG + r'){1,3}', # 1::6:7:8 1:2:3:4::6:7:8 1:2:3:4::8 -- cgit v1.2.3 From 6628b2901933f67568ba68617ee27c6f843c6dcf Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Thu, 14 May 2026 00:44:52 +0300 Subject: ci(doc-linter): fix RST code-block exit on short dedented lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dedent check inside `handle_file_action()` was if in_rst_codeblock: if len(line) > rst_codeblock_indent and not line[rst_codeblock_indent].isspace(): in_rst_codeblock = False This worked only when the next line was at least `rst_codeblock_indent + 1` chars long — the indexing `line[rst_codeblock_indent]` requires that. A short dedented line (e.g., a single character at column 0 under a directive indented at column 4) failed the length guard and `in_rst_codeblock` stayed True. The block remained open longer than it should, suppressing line-length checks on subsequent prose until either EOF or the next `.. code-block::` reset the state. Replace with a leading-whitespace-count check: on any non-blank line, exit the block when leading-ws is <= the directive's column. Blank lines don't reset the block context. Test: a 3-line file with `.. code-block:: text` directive at col 0, one body line, a single `a` at col 0, then a 113-char line at col 0. With the old logic the long line is still treated as inside the code block and not flagged. With the new logic the single-`a` dedent exits the block and the long line is flagged as expected. Tracked as items 6 and 12 of the rolling-side cleanup backlog from PR #2014 / #2019 / #2020 reviews. 🤖 Generated by [robots](https://vyos.io) --- scripts/doc-linter.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/scripts/doc-linter.py b/scripts/doc-linter.py index f4628c0b..fe262d98 100644 --- a/scripts/doc-linter.py +++ b/scripts/doc-linter.py @@ -180,8 +180,21 @@ def handle_file_action(filepath): # Each `.. code-block::` directive resets the tracked indent so a # later dedent past that column exits the block — even when the # directive itself appears inside an already-open outer block. - if in_rst_codeblock: - if len(line) > rst_codeblock_indent and not line[rst_codeblock_indent].isspace(): + # + # Exit when the next non-blank line's leading-whitespace count is + # <= the directive's column. The body of an RST code-block must + # be indented MORE than the directive itself, so leading-ws at or + # below `rst_codeblock_indent` signals the block has ended. + # + # Previously this used `len(line) > rst_codeblock_indent and not + # line[rst_codeblock_indent].isspace()`, which silently skipped + # the exit check on lines shorter than `rst_codeblock_indent + 1` + # chars — e.g., a 3-char dedented line under a directive at + # column 4 left the block open, suppressing line-length checks + # downstream. + if in_rst_codeblock and line.strip(): + leading = len(line) - len(line.lstrip()) + if leading <= rst_codeblock_indent: in_rst_codeblock = False if ".. code-block::" in line: in_rst_codeblock = True -- cgit v1.2.3 From cd5759f26a1c18abc3c137234cf1ab503e2b26b7 Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Thu, 14 May 2026 00:47:20 +0300 Subject: ci(doc-linter): distinguish prose-bearing directive fences from code blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The line-length skip was test_line_length = not (in_md_fence or in_rst_codeblock) `in_md_fence` was True for every MyST/Markdown fence regardless of content type. That includes admonition directives like `:::{note}`, `:::{warning}`, `:::{tip}` whose content is normal prose, not preformatted code. Long lines in admonitions were silently skipped, contradicting the documented 80-char rule which exempts code blocks only. Track an `is_code` property on each fence-stack entry. A fence is code-bearing when: - info string is empty (plain ``` per CommonMark), OR - info string doesn't start with `{` (bare language tag like `python`, `bash`, `yaml`), OR - info string is `{}` and `` is in the CODE_BEARING_DIRECTIVES set (`code-block`, `code`, `sourcecode`, `cfgcmd`, `opcmd`, `cmdinclude`, `cmdincludemd`, `literalinclude`, `parsed-literal`, `raw`, `command-output`, `eval-rst`). Anything else is prose-bearing (`{note}`, `{warning}`, `{tip}`, `{deprecated}`, `{seealso}`, …) and its content gets line-length checked. `in_md_code_fence` checks the topmost stack entry — the innermost fence wins, so a `{note}` containing an inner `{code-block}` lints the outer prose lines and skips the inner code-block body. The classic `is_suppression_marker()` call still uses `in_md_fence` because suppression markers are about "any fence depth" not "code-bearing depth". `{eval-rst}` is kept in CODE_BEARING_DIRECTIVES to preserve current behavior — its body is RST and any line-length on nested `.. code-block::` is handled by the separate RST tracker. Tightening eval-rst is a separate change if wanted. Test coverage: - `_fence_is_code` classifier: 16 cases (code-like vs prose-like) all pass. - Integration: long line in `{note}` flagged ✓; long line in ```python``` not flagged ✓; long line in `{cfgcmd}` not flagged ✓; nested `{note}` > ```text``` — inner skipped ✓; nested `{note}` > prose — flagged ✓. Sweep over current `docs/` tree: 28 new warnings surface across the existing pages (long prose inside admonition directives that the previous logic had been silently hiding). CI on PR scope is changed files only, so the new findings appear only when contributors touch those pages — they won't break this PR or future infra PRs. Tracked as item 5 of the rolling-side cleanup backlog from PR #2014 / #2019 / #2020 reviews. 🤖 Generated by [robots](https://vyos.io) --- scripts/doc-linter.py | 50 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/scripts/doc-linter.py b/scripts/doc-linter.py index fe262d98..1da215a5 100644 --- a/scripts/doc-linter.py +++ b/scripts/doc-linter.py @@ -55,6 +55,38 @@ def is_docs_path(path): # Same character and length-or-greater closes. MD_FENCE_RE = re.compile(r'^(\s*)(`{3,}|:{3,})(.*)$') +# MyST directive names whose body is code-like — line-length exception applies. +# Anything else with a `{directive}` info string is treated as prose-bearing +# (`{note}`, `{warning}`, `{tip}`, `{deprecated}`, …); their content is normal +# Markdown and gets line-length checked. Plain fences with no info string OR a +# bare language tag (`python`, `bash`, `yaml`, …) are code blocks per +# CommonMark and always skip line-length. +CODE_BEARING_DIRECTIVES = frozenset({ + 'code-block', 'code', 'sourcecode', + 'cfgcmd', 'opcmd', 'cmdinclude', 'cmdincludemd', + 'literalinclude', 'parsed-literal', 'raw', + 'command-output', + 'eval-rst', # body is RST; its own line-length is handled via the + # `.. code-block::` tracker for nested code blocks. +}) + + +def _fence_is_code(info): + """Return True iff the fence opener `info` string designates a code-like block. + + Used to gate the line-length-check skip — only code-like fences should + suppress line-length linting; prose-bearing directive fences like + `{note}` / `{warning}` must still have their content checked. + """ + info = info.strip() + if not info: + return True # plain ``` is code per CommonMark + if not info.startswith('{'): + return True # python, bash, yaml, text, etc. + # `{code-block} python` -> `code-block`; `{note}` -> `note`. + name = info[1:].split('}', 1)[0].strip() + return name in CODE_BEARING_DIRECTIVES + SUPPRESSION_MARKER_RE = { 'rst': { 'stop': re.compile(r'^\s*\.\.\s+stop_vyoslinter\s*$'), @@ -136,10 +168,12 @@ def handle_file_action(filepath): """Run all lint checks on one file, respecting fence/code-block and suppression context.""" errors = [] file_ext = os.path.splitext(filepath)[1].lower() - # Stack of open MD/MyST fences: (char, min_len). Supports nesting like - # `:::{note}` containing `::::{code-block}`, where the inner opener does - # not close the outer note (CommonMark closing rule: matching closer must - # have no info string after the fence chars). + # Stack of open MD/MyST fences: (char, min_len, is_code). Supports + # nesting like `:::{note}` containing `::::{code-block}`, where the + # inner opener does not close the outer note (CommonMark closing rule: + # matching closer must have no info string after the fence chars). + # `is_code` records whether THAT fence is a code-bearing one — gates the + # line-length skip per the innermost (top-of-stack) entry. md_fence_stack = [] md_fence_is_eval_rst = False in_rst_codeblock = False @@ -172,9 +206,10 @@ def handle_file_action(filepath): else: if not md_fence_stack: md_fence_is_eval_rst = info.startswith('{eval-rst}') - md_fence_stack.append((fence_char, fence_len)) + md_fence_stack.append((fence_char, fence_len, _fence_is_code(info))) in_md_fence = bool(md_fence_stack) + in_md_code_fence = bool(md_fence_stack) and md_fence_stack[-1][2] # RST `.. code-block::` tracking (existing semantics for .rst/.txt). # Each `.. code-block::` directive resets the tracked indent so a @@ -219,7 +254,10 @@ def handle_file_action(filepath): if not start_vyoslinter: continue - test_line_length = not (in_md_fence or in_rst_codeblock) + # Only code-bearing fences (plain ```python```, `{code-block}`, + # `{cfgcmd}`, etc.) skip line-length. Prose-bearing directives + # like `{note}` and `{warning}` have their content checked. + test_line_length = not (in_md_code_fence or in_rst_codeblock) err_ip4 = lint_ipv4(cnt, line.strip()) err_ip6 = lint_ipv6(cnt, line.strip()) -- cgit v1.2.3 From 379ed4757b62a7c1df965a59bc4f1fd0cef2d3e8 Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Thu, 14 May 2026 00:48:16 +0300 Subject: ci(doc-linter): exclude docs/_rst_legacy/ and docs/_build/ from lint scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_docs_path()` returned True for any path under `docs/`, including the archived RST shadows under `docs/_rst_legacy/` and the build output under `docs/_build/`. Sphinx excludes both from the build (per `docs/conf.py`'s exclude_patterns) and AGENTS marks `_rst_legacy` as reference-only. The linter shouldn't process either. Add a `DOCS_EXCLUDED_SUBDIRS = ('_build', '_rst_legacy')` constant. After confirming a path is under `docs/`, walk each excluded subtree and reject the path if it's contained. Also unify the auto-discover walk fallback to call `is_docs_path()` for the filter — previously it had its own hand-rolled `"_build" not in path` check that didn't handle `_rst_legacy` at all and would have walked the entire legacy archive. Prune `dirs[:]` in-place at each walk level so we don't descend into the excluded subtrees in the first place — optimization on top of correctness. Reverted the `_dirs` -> `dirs` rename here because we now mutate it. Test coverage: 10 hand-coded `is_docs_path()` cases — all pass: - `docs/configuration/foo.md` -> True - `docs/_rst_legacy/foo.rst` -> False (was True) - `docs/_rst_legacy/subdir/rst-foo.rst` -> False (was True) - `docs/_build/html/index.html` -> False (was True) - `docs/_include/foo.txt` -> True (live snippets stay in scope) - `docs` -> True - `AGENTS.md`, `README.md`, `.github/copilot-instructions.md`, `scripts/doc-linter.py` -> False (already correct) Tracked as item 4 of the rolling-side cleanup backlog from PR #2014 / #2019 / #2020 reviews. 🤖 Generated by [robots](https://vyos.io) --- scripts/doc-linter.py | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/scripts/doc-linter.py b/scripts/doc-linter.py index 1da215a5..e352615c 100644 --- a/scripts/doc-linter.py +++ b/scripts/doc-linter.py @@ -30,9 +30,15 @@ SUPPORTED_EXTS = ('.md', '.rst', '.txt') # meta, not docs content, and are out of scope. DOCS_ROOT = 'docs' +# Subtrees under docs/ that are excluded from the build (per docs/conf.py) +# and therefore also excluded from lint. +DOCS_EXCLUDED_SUBDIRS = ('_build', '_rst_legacy') + def is_docs_path(path): - """Return True iff `path` resolves under the repo's `docs/` tree. + """Return True iff `path` resolves under the repo's `docs/` tree AND + is not inside one of the build-excluded subtrees (``_build``, + ``_rst_legacy``). Accepts both repo-relative and absolute paths. Both `path` and `DOCS_ROOT` are resolved with `os.path.realpath`, so symlinks are @@ -46,10 +52,19 @@ def is_docs_path(path): abs_path = os.path.realpath(path) abs_docs = os.path.realpath(DOCS_ROOT) try: - return os.path.commonpath([abs_path, abs_docs]) == abs_docs + if os.path.commonpath([abs_path, abs_docs]) != abs_docs: + return False except ValueError: # commonpath raises on mixed drives (Windows) or empty input. return False + for excluded in DOCS_EXCLUDED_SUBDIRS: + abs_excluded = os.path.realpath(os.path.join(DOCS_ROOT, excluded)) + try: + if os.path.commonpath([abs_path, abs_excluded]) == abs_excluded: + return False + except ValueError: + continue + return True # MyST / Markdown fenced code block: leading whitespace + 3+ backticks or 3+ colons. # Same character and length-or-greater closes. @@ -303,12 +318,18 @@ def main(): if handle_file_action(file) is False: bool_error = False else: - for root, _dirs, files in os.walk(DOCS_ROOT): - path = root.split(os.sep) + # In-place dirs prune: skip descending into build-excluded subtrees. + # is_docs_path() also rejects paths under those subtrees, so the + # prune is an optimization (avoids walking thousands of archived + # legacy RST files) and the filter is correctness. + for root, dirs, files in os.walk(DOCS_ROOT): + dirs[:] = [d for d in dirs if d not in DOCS_EXCLUDED_SUBDIRS] for file in files: - if file.endswith(SUPPORTED_EXTS) and "_build" not in path: - fpath = '/'.join(path) - filepath = f"{fpath}/{file}" + filepath = os.path.join(root, file) + if ( + file.endswith(SUPPORTED_EXTS) + and is_docs_path(filepath) + ): if handle_file_action(filepath) is False: bool_error = False -- cgit v1.2.3 From 1ef5684729646ca3a24aff83ab8edd0aa57914c7 Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Thu, 14 May 2026 00:49:50 +0300 Subject: ci(doc-linter): lint added + renamed files, not only modified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous workflow: env: FILES_MODIFIED: ${{ steps.file_changes.outputs.files_modified }} run: python scripts/doc-linter.py "$FILES_MODIFIED" `trilom/file-changes-action`'s `files_modified` output is modifications-only. A PR adding a new `.md`/`.rst` doc page passed `files_added`, never `files_modified`, so a brand-new page with long lines or real public IPs slipped past the linter entirely. Workflow: also pass `files_added` and `files_renamed` as separate positional args. Each output is a JSON array (action v1.2.4) and is passed via env to avoid shell-quoting issues. Linter: `main()` now accepts one OR multiple positional argv entries, each a JSON array of paths. Arrays are merged and deduplicated before linting. Single-arg invocations remain backward-compatible. Switched from `ast.literal_eval` to `json.loads` — the action's outputs are JSON, and `json.loads` is the right tool (and dodges literal_eval-via-`eval`-substring linter warnings). Test coverage: - Two JSON arrays merge -> single linter run on union. - Empty-string argv entry skipped (no `files_renamed` in many PRs). - Malformed JSON -> falls back to walking DOCS_ROOT. - No argv -> walks DOCS_ROOT. - Single-arg invocation -> backward-compat preserved. Tracked as item 7 of the rolling-side cleanup backlog from PR #2014 / #2019 / #2020 reviews. 🤖 Generated by [robots](https://vyos.io) --- .github/workflows/lint-doc.yml | 11 ++++++++++- scripts/doc-linter.py | 22 +++++++++++++++++----- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/.github/workflows/lint-doc.yml b/.github/workflows/lint-doc.yml index d7f25f38..eb7386d0 100644 --- a/.github/workflows/lint-doc.yml +++ b/.github/workflows/lint-doc.yml @@ -30,5 +30,14 @@ jobs: - name: run doc linter env: + # Pass modified + added + renamed file lists. `files_modified` + # alone misses newly-added pages — a PR adding a new doc with + # long lines or real public IPs would otherwise slip through. FILES_MODIFIED: ${{ steps.file_changes.outputs.files_modified }} - run: python scripts/doc-linter.py "$FILES_MODIFIED" + FILES_ADDED: ${{ steps.file_changes.outputs.files_added }} + FILES_RENAMED: ${{ steps.file_changes.outputs.files_renamed }} + run: | + python scripts/doc-linter.py \ + "$FILES_MODIFIED" \ + "$FILES_ADDED" \ + "$FILES_RENAMED" diff --git a/scripts/doc-linter.py b/scripts/doc-linter.py index e352615c..509b1400 100644 --- a/scripts/doc-linter.py +++ b/scripts/doc-linter.py @@ -2,7 +2,7 @@ import os import re import ipaddress import sys -import ast +import json IPV4SEG = r'(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])' IPV4ADDR = r'\b(?:(?:' + IPV4SEG + r'\.){3,3}' + IPV4SEG + r')\b' @@ -302,11 +302,23 @@ def main(): # Only the argv-parsing step is wrapped in try/except. Errors raised by # handle_file_action() must propagate so CI failures stay visible instead # of silently triggering a full docs/ walk. - try: - files = ast.literal_eval(sys.argv[1]) - except (IndexError, SyntaxError, ValueError): - # No argv or malformed list -> fall back to walking DOCS_ROOT. + # + # Accepts one or more positional argv entries, each a JSON array of + # paths (e.g., `'["foo.md", "bar.rst"]'`). Arrays are merged and + # deduplicated before linting. CI passes `files_modified`, + # `files_added`, etc. as separate args — see lint-doc.yml. + if len(sys.argv) <= 1: files = None + else: + try: + files = [] + for arg in sys.argv[1:]: + if arg.strip(): + files.extend(json.loads(arg)) + files = sorted(set(files)) + except (json.JSONDecodeError, TypeError): + # Malformed input -> fall back to walking DOCS_ROOT. + files = None if files is not None: for file in files: -- cgit v1.2.3 From e99182b911dbe9d3a3f02e000426f7075cadc608 Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Thu, 14 May 2026 00:50:16 +0300 Subject: ci(lint-doc): pin all GitHub Actions to commit SHAs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each `uses:` line was pinned to a mutable version tag (`@v6`, `@v0.8.4`, …). Tags can be rewritten to point to malicious code — CVE-2025-30066 (reviewdog/action-setup) and the tj-actions/changed-files incident in 2025 are the canonical real-world examples. GitHub's hardening guide for Actions recommends pinning to full-length commit SHAs and keeping the tag as a trailing comment for human readability. Resolved each action's tag to its commit SHA via `gh api /repos//git/refs/tags/` and verified the SHA is a commit (not an annotated-tag object) via `gh api /repos//git/commits/`: - actions/checkout v6 -> de0fac2e4500dabe0009e67214ff5f5447ce83dd - bullfrogsec/bullfrog v0.8.4 -> 1831f79cce8ad602eef14d2163873f27081ebfb3 - trilom/file-changes-action v1.2.4 -> a6ca26c14274c33b15e6499323aac178af06ad4b - actions/setup-python v6 -> a309ff8b426b58ec0e2a45f0f869d46889d02405 This change covers `lint-doc.yml` only. A fleet-wide sweep across every workflow in `.github/workflows/` is a separate effort — worth doing because the drift / supply-chain risk is the same in every one. Tracked as a follow-up to this PR's review. Tracked as item 11 of the rolling-side cleanup backlog from PR #2014 / #2019 / #2020 reviews. 🤖 Generated by [robots](https://vyos.io) --- .github/workflows/lint-doc.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/lint-doc.yml b/.github/workflows/lint-doc.yml index eb7386d0..0bd7a197 100644 --- a/.github/workflows/lint-doc.yml +++ b/.github/workflows/lint-doc.yml @@ -11,20 +11,20 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Bullfrog Secure Runner continue-on-error: true - uses: bullfrogsec/bullfrog@v0.8.4 + uses: bullfrogsec/bullfrog@1831f79cce8ad602eef14d2163873f27081ebfb3 # v0.8.4 with: egress-policy: audit - name: Get File Changes id: file_changes - uses: trilom/file-changes-action@v1.2.4 + uses: trilom/file-changes-action@a6ca26c14274c33b15e6499323aac178af06ad4b # v1.2.4 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: '3.x' -- cgit v1.2.3 From be8090a3a09adb950557c2887c9a27c017ffd31d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 21:55:52 +0000 Subject: ci(doc-linter): fix \b regression in compressed-IPv6 regex — replace bare removal with word-boundary prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/vyos/vyos-documentation/sessions/cdefcaf2-e89e-4090-b39a-15b385b774df Co-authored-by: andamasov <12631358+andamasov@users.noreply.github.com> --- scripts/doc-linter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/doc-linter.py b/scripts/doc-linter.py index 509b1400..425d9671 100644 --- a/scripts/doc-linter.py +++ b/scripts/doc-linter.py @@ -9,7 +9,7 @@ IPV4ADDR = r'\b(?:(?:' + IPV4SEG + r'\.){3,3}' + IPV4SEG + r')\b' IPV6SEG = r'(?:(?:[0-9a-fA-F]){1,4})' IPV6GROUPS = ( r'(?:' + IPV6SEG + r':){7,7}' + IPV6SEG, # 1:2:3:4:5:6:7:8 - r'(?:' + IPV6SEG + r':){1,7}:', # 1:: 1:2:3:4:5:6:7:: + r'\b(?:' + IPV6SEG + r':){1,7}:', # 1:: 1:2:3:4:5:6:7:: r'(?:' + IPV6SEG + r':){1,6}:' + IPV6SEG, # 1::8 1:2:3:4:5:6::8 1:2:3:4:5:6::8 r'(?:' + IPV6SEG + r':){1,5}(?::' + IPV6SEG + r'){1,2}', # 1::7:8 1:2:3:4:5::7:8 1:2:3:4:5::8 r'(?:' + IPV6SEG + r':){1,4}(?::' + IPV6SEG + r'){1,3}', # 1::6:7:8 1:2:3:4::6:7:8 1:2:3:4::8 @@ -102,6 +102,7 @@ def _fence_is_code(info): name = info[1:].split('}', 1)[0].strip() return name in CODE_BEARING_DIRECTIVES + SUPPRESSION_MARKER_RE = { 'rst': { 'stop': re.compile(r'^\s*\.\.\s+stop_vyoslinter\s*$'), @@ -324,7 +325,6 @@ def main(): for file in files: if ( file.endswith(SUPPORTED_EXTS) - and "_build" not in file and is_docs_path(file) ): if handle_file_action(file) is False: -- cgit v1.2.3 From e87278ef35660a6257b55f4585274a52d3124583 Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Thu, 14 May 2026 07:45:25 +0300 Subject: ci(doc-linter): anchor .. code-block:: detection + drop debug print MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot review on PR #2023: 1. .. code-block:: tracking was triggered by a plain substring check, which matched mid-line occurrences too. In MD prose like ``.. code-block::`` (docs/documentation.md:222) this set in_rst_codeblock=True spuriously and could suppress line-length checks downstream. Replace with a leading-whitespace- anchored regex and gate on file_ext in ('.rst', '.txt') or an open {eval-rst} MyST fence so the directive opener is only recognized where it can actually occur. 2. print('start') in main() was leftover debug noise — remove it. --- scripts/doc-linter.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/scripts/doc-linter.py b/scripts/doc-linter.py index 425d9671..b95ea505 100644 --- a/scripts/doc-linter.py +++ b/scripts/doc-linter.py @@ -70,6 +70,12 @@ def is_docs_path(path): # Same character and length-or-greater closes. MD_FENCE_RE = re.compile(r'^(\s*)(`{3,}|:{3,})(.*)$') +# RST `.. code-block::` directive: leading whitespace + literal `.. code-block::`. +# Anchored to start-of-line so prose mentions like `\`\`.. code-block::\`\`` inside +# a Markdown paragraph (see docs/documentation.md) don't false-trigger the +# code-block tracker. +RST_CODEBLOCK_RE = re.compile(r'^(\s*)\.\.\s+code-block::') + # MyST directive names whose body is code-like — line-length exception applies. # Anything else with a `{directive}` info string is treated as prose-bearing # (`{note}`, `{warning}`, `{tip}`, `{deprecated}`, …); their content is normal @@ -247,14 +253,15 @@ def handle_file_action(filepath): leading = len(line) - len(line.lstrip()) if leading <= rst_codeblock_indent: in_rst_codeblock = False - if ".. code-block::" in line: - in_rst_codeblock = True - rst_codeblock_indent = 0 - for ch in line: - if ch.isspace(): - rst_codeblock_indent += 1 - else: - break + # Only treat `.. code-block::` as a directive opener in RST/TXT + # files or inside an `{eval-rst}` MyST fence. In plain Markdown + # the same characters can appear as prose (e.g., backtick-quoted + # mentions of the directive name) and must not open a block. + if file_ext in ('.rst', '.txt') or md_fence_is_eval_rst: + rst_open = RST_CODEBLOCK_RE.match(line) + if rst_open: + in_rst_codeblock = True + rst_codeblock_indent = len(rst_open.group(1)) if is_suppression_marker( line, 'stop', in_md_fence, in_rst_codeblock, @@ -299,7 +306,6 @@ def handle_file_action(filepath): def main(): """Entry point: lint the changed-file list from argv, or fall back to walking `docs/`.""" bool_error = True - print('start') # Only the argv-parsing step is wrapped in try/except. Errors raised by # handle_file_action() must propagate so CI failures stay visible instead # of silently triggering a full docs/ walk. -- cgit v1.2.3