summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorYuriy Andamasov <yuriy@vyos.io>2026-05-14 00:11:56 +0300
committerYuriy Andamasov <yuriy@vyos.io>2026-05-14 00:11:56 +0300
commitcfc575a1f13b3442e02ac6be133608139b0236f1 (patch)
tree96286eb33e0bfc16611790a99a52d5198250d0f2 /scripts
parent6b02343f9cbb1295875c067159cb8a764abbf433 (diff)
downloadvyos-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.py286
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)