diff options
| author | Yuriy Andamasov <yuriy@vyos.io> | 2026-05-14 00:11:56 +0300 |
|---|---|---|
| committer | Yuriy Andamasov <yuriy@vyos.io> | 2026-05-14 00:11:56 +0300 |
| commit | cfc575a1f13b3442e02ac6be133608139b0236f1 (patch) | |
| tree | 96286eb33e0bfc16611790a99a52d5198250d0f2 /scripts | |
| parent | 6b02343f9cbb1295875c067159cb8a764abbf433 (diff) | |
| download | vyos-documentation-cfc575a1f13b3442e02ac6be133608139b0236f1.tar.gz vyos-documentation-cfc575a1f13b3442e02ac6be133608139b0236f1.zip | |
ci: inline doc-linter on sagitta to match rolling
The lint workflow on this branch referenced
`vyos/.github/.github/workflows/lint-doc.yml@feature/T6349-reusable-workflows`,
which doesn't exist in `vyos/.github` (no commit found for that ref).
The reusable-workflow `uses:` line has been silently failing
(no `doc-lint` check on recent sagitta PRs).
Mirror rolling's approach (inlined 2026-05-10):
- Add `scripts/doc-linter.py` in this repo. Sourced from PR #2014's
HEAD on rolling (the version that includes the `is_docs_path()`
docs/-only scope guard so repo-root meta files like AGENTS.md and
README.md are out of scope, plus the realpath / narrow-exception /
Ruff fixes from that PR's review pass).
- Replace `.github/workflows/lint-doc.yml` with an in-repo workflow
that runs `python scripts/doc-linter.py "$FILES_MODIFIED"` against
the changed-file list from `trilom/file-changes-action`. Same job
shape as rolling.
- Update the `## Lint` and `## CI` bullets in AGENTS.md to point at
the in-repo paths.
This restores actual lint coverage on sagitta PRs and keeps the
toolchain identical across rolling / circinus / sagitta. Behavior on
the 2 remaining `.rst` pages (cli.rst, aws.rst) is unchanged — the
linter's SUPPORTED_EXTS already covers `.md`, `.rst`, and `.txt`.
🤖 Generated by [robots](https://vyos.io)
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/doc-linter.py | 286 |
1 files changed, 286 insertions, 0 deletions
diff --git a/scripts/doc-linter.py b/scripts/doc-linter.py new file mode 100644 index 00000000..8e74600f --- /dev/null +++ b/scripts/doc-linter.py @@ -0,0 +1,286 @@ +import os +import re +import ipaddress +import sys +import ast + +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' +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,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 + r'(?:' + IPV6SEG + r':){1,3}(?::' + IPV6SEG + r'){1,4}', # 1::5:6:7:8 1:2:3::5:6:7:8 1:2:3::8 + r'(?:' + IPV6SEG + r':){1,2}(?::' + IPV6SEG + r'){1,5}', # 1::4:5:6:7:8 1:2::4:5:6:7:8 1:2::8 + IPV6SEG + r':(?:(?::' + IPV6SEG + r'){1,6})', # 1::3:4:5:6:7:8 1::3:4:5:6:7:8 1::8 + r':(?:(?::' + IPV6SEG + r'){1,7}|:)', # ::2:3:4:5:6:7:8 ::2:3:4:5:6:7:8 ::8 :: + r'fe80:(?::' + IPV6SEG + r'){0,4}%[0-9a-zA-Z]{1,}', # fe80::7:8%eth0 fe80::7:8%1 (link-local IPv6 addresses with zone index) + r'::(?:ffff(?::0{1,4}){0,1}:){0,1}[^\s:]' + IPV4ADDR, # ::255.255.255.255 ::ffff:255.255.255.255 ::ffff:0:255.255.255.255 (IPv4-mapped IPv6 addresses and IPv4-translated addresses) + r'(?:' + IPV6SEG + r':){1,4}:[^\s:]' + IPV4ADDR, # 2001:db8:3:4::192.0.2.33 64:ff9b::192.0.2.33 (IPv4-Embedded IPv6 Address) +) +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 +# files (README.md, AGENTS.md, .github/copilot-instructions.md) are project +# meta, not docs content, and are out of scope. +DOCS_ROOT = 'docs' + + +def is_docs_path(path): + """Return True iff `path` resolves under the repo's `docs/` tree. + + Accepts both repo-relative and absolute paths. Both `path` and + `DOCS_ROOT` are resolved with `os.path.realpath`, so symlinks are + followed to their real targets — a symlink under `docs/` that + points outside the tree is correctly treated as out-of-scope, and + a `docs/` that is itself a symlink (e.g., in some CI checkouts) is + correctly treated as the docs root. Paths are normalized against + the linter's working directory (CI invokes it from the repo root; + the same is expected for local runs). + """ + abs_path = os.path.realpath(path) + abs_docs = os.path.realpath(DOCS_ROOT) + try: + return os.path.commonpath([abs_path, abs_docs]) == abs_docs + except ValueError: + # commonpath raises on mixed drives (Windows) or empty input. + return False + +# 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,})(.*)$') + +SUPPRESSION_MARKER_RE = { + 'rst': { + 'stop': re.compile(r'^\s*\.\.\s+stop_vyoslinter\s*$'), + 'start': re.compile(r'^\s*\.\.\s+start_vyoslinter\s*$'), + }, + 'md': { + 'stop': re.compile(r'^\s*%\s+stop_vyoslinter\s*$'), + 'start': re.compile(r'^\s*%\s+start_vyoslinter\s*$'), + }, +} + + +def is_suppression_marker(line, kind, in_md_fence, in_rst_codeblock, + md_fence_is_eval_rst, file_ext): + """Detect valid stop/start markers in the parser context where they apply. + + `% ...` is only valid in MyST Markdown (`.md`) at top level (outside any fence). + `.. ...` is valid in `.rst`/`.txt` at top level (outside RST code-block) and + in `.md` only when inside an `{eval-rst}` fence. + """ + if SUPPRESSION_MARKER_RE['md'][kind].match(line): + return file_ext == '.md' and not in_md_fence + if not SUPPRESSION_MARKER_RE['rst'][kind].match(line): + return False + if in_rst_codeblock: + return False + if file_ext in ('.rst', '.txt'): + return True + if in_md_fence: + return md_fence_is_eval_rst + 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) + if ip is not None: + ip = ipaddress.ip_address(ip.group().strip(' ')) + # https://docs.python.org/3/library/ipaddress.html#ipaddress.IPv4Address.is_private + if ip.is_private: + return None + if ip.is_multicast: + return None + if ip.is_global is False: + return None + return (f"Use IPv4 reserved for Documentation (RFC 5737) or private Space: {ip}", cnt, 'error') + + +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(' ')) + if ip.is_private: + return None + if ip.is_multicast: + return None + if ip.is_global is False: + return None + 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() + if len(line) > 80: + return (f"Line too long: len={len(line)}", cnt, 'warning') + +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). + md_fence_stack = [] + md_fence_is_eval_rst = False + 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): + # 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) + info = fence_match.group(3).strip() + # A closer matches the top of the stack (same char, len >= + # opener) and has no info string. Anything else opens a new + # (possibly nested) fence. + is_closer = ( + md_fence_stack + and not info + and fence_char == md_fence_stack[-1][0] + and fence_len >= md_fence_stack[-1][1] + ) + if is_closer: + md_fence_stack.pop() + if not md_fence_stack: + md_fence_is_eval_rst = False + else: + if not md_fence_stack: + md_fence_is_eval_rst = info.startswith('{eval-rst}') + md_fence_stack.append((fence_char, fence_len)) + + in_md_fence = bool(md_fence_stack) + + # RST `.. code-block::` tracking (existing semantics for .rst/.txt). + # 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(): + 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 + + if is_suppression_marker( + line, 'stop', in_md_fence, in_rst_codeblock, + md_fence_is_eval_rst, file_ext, + ): + start_vyoslinter = False + if is_suppression_marker( + line, 'start', in_md_fence, in_rst_codeblock, + md_fence_is_eval_rst, file_ext, + ): + start_vyoslinter = True + + if not start_vyoslinter: + continue + + 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: + ''' + "::{$type} file={$filename},line={$line},col=$column::{$log}" + ''' + print(f"File: {filepath}") + for error in errors: + print(f"::{error[2]} file={filepath},line={error[1]}::{error[0]}") + print('') + return False + + +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. + try: + files = ast.literal_eval(sys.argv[1]) + except (IndexError, SyntaxError, ValueError): + # No argv or malformed list -> fall back to walking DOCS_ROOT. + files = None + + if files is not None: + 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: + bool_error = False + else: + for root, _dirs, files in os.walk(DOCS_ROOT): + path = root.split(os.sep) + for file in files: + if file.endswith(SUPPORTED_EXTS) and "_build" not in path: + fpath = '/'.join(path) + filepath = f"{fpath}/{file}" + if handle_file_action(filepath) is False: + bool_error = False + + return bool_error + + +if __name__ == "__main__": + if main() == False: + exit(1) |
