From 6baf387d67f49a99b737ded05cf4d31b5bfc6f56 Mon Sep 17 00:00:00 2001 From: Manuel Gerding Date: Thu, 3 Sep 2026 09:59:54 +0200 Subject: [PATCH 1/5] ci: add a deterministic docs linter Checks the things that were wrong across the docs and can be verified without judgment: - relative links and `#anchors` resolve - ```json samples parse - a denylist of misspellings we have actually had - product-name casing in prose (WebSocket, Docker Compose, Helm, GitHub, OpenAPI), skipping code spans, URLs and identifiers - headings are title case, except question-style ones - table rows share a width and a column count - no trailing whitespace, keeping real Markdown hard breaks - every `.gitbook.yml` redirect target resolves - a moved or deleted page has a redirect It reports only findings a change introduces, by running every check against two trees and diffing. Two consequences: - Existing debt never fails a build, so a stricter convention can be adopted without a repo-wide cleanup first. There are 159 pre-existing findings today, mostly heading case. - Comparing against the **target branch tip** rather than the merge base catches a merge that silently undoes a fix already on main. That is what happened between #207 and #208: #207 changed a heading level on a line #208 had just corrected, from a base that predated it, and git reported no conflict. Finding identity excludes the line number, so moving a paragraph does not resurface everything below it as new. Stdlib only, no dependencies, ~2s for the whole repo. Runnable locally: python3 .github/scripts/docs_lint.py --base origin/main --- .github/scripts/docs_lint.py | 511 +++++++++++++++++++++++++++++++++++ 1 file changed, 511 insertions(+) create mode 100644 .github/scripts/docs_lint.py diff --git a/.github/scripts/docs_lint.py b/.github/scripts/docs_lint.py new file mode 100644 index 00000000..f27104ac --- /dev/null +++ b/.github/scripts/docs_lint.py @@ -0,0 +1,511 @@ +#!/usr/bin/env python3 +"""Deterministic consistency checks for the Steadybit docs. + +Runs every check against two trees - the pull request's merge result and the tip +of the branch it targets - and reports only what the pull request adds. Existing +debt therefore never fails a build, while anything a change introduces does. + +Comparing against the *target branch tip* rather than the merge base is what +catches a merge silently undoing a fix that already landed: the fix is present +in the base tree and absent in the merge result, so it shows up as new. + +Usage: + python3 .github/scripts/docs_lint.py # whole tree, no baseline + python3 .github/scripts/docs_lint.py --base origin/main + python3 .github/scripts/docs_lint.py --base origin/main --format github + +Exit code 1 if there is at least one new error. Warnings never fail the build. +""" + +from __future__ import annotations + +import argparse +import fnmatch +import json +import os +import re +import subprocess +import sys +import urllib.parse +from dataclasses import dataclass, field + +# Pruned when walking. `.gitbook/` is kept, because image and asset links +# resolve into it; it is excluded from the linted set separately below. +PRUNE_DIRS = {".git", "node_modules"} +# Prefixes excluded from the set of documents we lint. +NOT_DOCS = (".github/", ".gitbook/") +CONVENTIONS = "CLAUDE.md" + + +# --------------------------------------------------------------------------- trees + + +class Tree: + """A set of files, either the working directory or a git ref.""" + + def __init__(self, ref: str | None = None): + self.ref = ref + self._cache: dict[str, str | None] = {} + # The whole path set is listed once. Resolving a link touches three + # candidate paths, so per-lookup `git cat-file` calls would mean + # thousands of subprocesses. + if ref: + out = subprocess.run( + ["git", "ls-tree", "-r", "--name-only", ref], + capture_output=True, text=True, check=True).stdout.split("\n") + paths = [p for p in out if p] + else: + paths = [] + for root, dirs, names in os.walk("."): + dirs[:] = [d for d in dirs if d not in PRUNE_DIRS] + for n in names: + paths.append(os.path.relpath(os.path.join(root, n), ".")) + self._blobs = {p for p in paths + if p.split("/")[0] not in PRUNE_DIRS} + self._dirs = set() + for p in self._blobs: + parts = p.split("/")[:-1] + for i in range(len(parts)): + self._dirs.add("/".join(parts[: i + 1])) + + def files(self, suffix: str = ".md") -> list[str]: + return sorted(p for p in self._blobs + if p.endswith(suffix) and not p.startswith(NOT_DOCS)) + + def published(self) -> list[str]: + """Documents GitBook actually renders as pages. + + `.bookignore` is the repo's own declaration of what is not published - + CLAUDE.md and the reusable `fragment-*.md` snippets. Their prose is still + linted; only page-level conventions such as heading case are skipped. + """ + patterns = [l.strip() for l in (self.read(".bookignore") or "").split("\n") + if l.strip() and not l.startswith("#")] + out = [] + for path in self.files(): + name = os.path.basename(path) + if any(fnmatch.fnmatch(name, pat) or fnmatch.fnmatch(path, pat) + for pat in patterns): + continue + out.append(path) + return out + + def read(self, path: str) -> str | None: + if path in self._cache: + return self._cache[path] + text: str | None + if self.ref: + r = subprocess.run(["git", "show", f"{self.ref}:{path}"], + capture_output=True, text=True) + text = r.stdout if r.returncode == 0 else None + else: + try: + with open(path, encoding="utf-8") as fh: + text = fh.read() + except (FileNotFoundError, IsADirectoryError, UnicodeDecodeError): + text = None + self._cache[path] = text + return text + + def exists(self, path: str) -> bool: + return path in self._blobs or path in self._dirs + + def isdir(self, path: str) -> bool: + return path in self._dirs and path not in self._blobs + + +@dataclass(frozen=True) +class Finding: + check: str + path: str + message: str + line: int = 0 + warning: bool = False + + def key(self): + """Identity used to diff against the baseline. + + Deliberately excludes the line number, so that shifting a paragraph does + not resurface every finding below it as new. + """ + return (self.check, self.path, self.message) + + +@dataclass +class Result: + findings: list[Finding] = field(default_factory=list) + + def add(self, *a, **kw): + self.findings.append(Finding(*a, **kw)) + + +# ------------------------------------------------------------------- md utilities + + +FENCE = re.compile(r"^\s*(```|~~~)") + + +def prose_lines(text: str): + """Yield (line_number, line) for lines outside fenced code blocks.""" + infence = False + for i, line in enumerate(text.split("\n"), 1): + if FENCE.match(line): + infence = not infence + continue + if not infence: + yield i, line + + +def headings(text: str): + for i, line in prose_lines(text): + m = re.match(r"(#{1,6})\s+(.*\S)\s*$", line) + if m: + yield i, len(m.group(1)), m.group(2) + + +def slugs(title: str) -> set[str]: + """Both slug conventions in use in this repo. + + Links here were written against two different slugifiers - one that collapses + runs of dashes and one that does not - so an anchor counts as resolvable if + either form matches. + """ + t = re.sub(r"[`*_\[\]()]", "", title).strip().lower() + t = re.sub(r"[^\w\s-]", "", t) + t = re.sub(r"\s+", "-", t) + return {t.strip("-"), re.sub(r"-{2,}", "-", t).strip("-")} + + +def anchors_of(text: str) -> set[str]: + out: set[str] = set() + for _, _, title in headings(text): + out |= slugs(title) + return out + + +def resolve(tree: Tree, src: str, target: str) -> str | None: + """Resolve a relative or root-relative doc link to a file path.""" + if target.startswith("/"): + base = os.path.normpath(target.lstrip("/")) + else: + base = os.path.normpath(os.path.join(os.path.dirname(src), target)) + if base in ("", "."): + base = "." + for cand in (base, base + ".md", os.path.join(base, "README.md")): + if tree.exists(cand) and not tree.isdir(cand): + return cand + return base if tree.isdir(base) else None + + +LINK = re.compile(r"\[[^\]]*\]\(\s*(<[^>]*>|[^)\s]+)") + + +def links_in(line: str): + for m in LINK.finditer(line): + raw = m.group(1).strip() + if raw.startswith("<") and raw.endswith(">"): + raw = raw[1:-1] + yield raw + + +# ----------------------------------------------------------------------- the checks + + +DENYLIST = { + "langauge": "Language", "kuberneters": "Kubernetes", "receieve": "receive", + "recieve": "receive", "succesfully": "successfully", "expermiment": "experiment", + "refering": "referring", "versionized": "versioned", "verfiy": "verify", + "similiar": "similar", "usally": "usually", "looses": "loses", + "cirds": "CIDRs", "custer": "cluster", "groupd": "group", + "identifiert": "identifier", "wether": "whether", "exeriment": "experiment", + "mostly likely": "most likely", "heat dump": "heap dump", + "productive usage": "production use", "per default": "by default", + "on the long run": "in the long run", +} + +# The docs are US English throughout. These forms are simply not US spellings, +# so matching them cannot collide with ordinary prose. +BRITISH = { + "colour": "color", "colours": "colors", "coloured": "colored", + "colouring": "coloring", "behaviour": "behavior", "behaviours": "behaviors", + "organisation": "organization", "organisations": "organizations", + "organise": "organize", "organised": "organized", + "authorisation": "authorization", "authorise": "authorize", + "initialisation": "initialization", "initialise": "initialize", + "synchronisation": "synchronization", "synchronise": "synchronize", + "virtualisation": "virtualization", "visualisation": "visualization", + "customise": "customize", "prioritise": "prioritize", + "recognise": "recognize", "summarise": "summarize", "utilise": "utilize", + "familiarise": "familiarize", "familiarising": "familiarizing", + "analyse": "analyze", "analysed": "analyzed", "analysing": "analyzing", + "judgement": "judgment", "licence": "license", "centre": "center", + "cancelled": "canceled", "cancelling": "canceling", + "labelled": "labeled", "labelling": "labeling", + "modelling": "modeling", "travelling": "traveling", + "fulfilment": "fulfillment", "acknowledgement": "acknowledgment", + "catalogue": "catalog", "defence": "defense", "favour": "favor", + "whilst": "while", "amongst": "among", +} + +# prose casing: wrong -> right. Applied outside code fences and outside `code spans`. +PRODUCT_NAMES = [ + (re.compile(r"\bWebsocket\b"), "WebSocket"), + (re.compile(r"\bwebsockets?\b(?!\s*[:=])"), "WebSocket"), + (re.compile(r"\bDocker compose\b"), "Docker Compose"), + (re.compile(r"\bGithub\b"), "GitHub"), + (re.compile(r"\bOpenApi\b"), "OpenAPI"), + (re.compile(r"\bhelm (chart|charts|values|settings|parameter|script|repository)\b"), "Helm"), +] + +MINOR = {"a", "an", "the", "and", "but", "or", "nor", "for", "so", "yet", "at", + "by", "in", "of", "on", "to", "up", "via", "as", "per", "vs", "vs.", + "with", "from", "into", "onto", "over", "is", "if"} + +CODE_SPAN = re.compile(r"`[^`]*`") + + +def strip_code(line: str) -> str: + return CODE_SPAN.sub(lambda m: " " * len(m.group(0)), line) + + +def check_links_and_anchors(tree: Tree, res: Result): + cache: dict[str, set[str]] = {} + for path in tree.files(): + text = tree.read(path) or "" + for i, line in enumerate(text.split("\n"), 1): + for raw in links_in(line): + if raw.startswith(("http://", "https://", "mailto:", "#!")): + continue + target, _, frag = raw.partition("#") + target = urllib.parse.unquote(target) + frag = urllib.parse.unquote(frag).lower() + if target: + dest = resolve(tree, path, target) + if dest is None: + res.add("link", path, f"link target does not exist: {raw}", i) + continue + else: + dest = path + if not frag or frag.startswith("user-content-fn") or tree.isdir(dest): + continue + if dest not in cache: + cache[dest] = anchors_of(tree.read(dest) or "") + if frag not in cache[dest]: + res.add("anchor", path, f"no heading matches #{frag} in {dest}", i) + + +def check_code_blocks(tree: Tree, res: Result): + for path in tree.files(): + text = tree.read(path) or "" + for m in re.finditer(r"```json\n(.*?)```", text, re.S): + body = m.group(1) + if "..." in body: # deliberate elision in an illustrative fragment + continue + try: + json.loads(body) + except ValueError as e: + line = text[:m.start()].count("\n") + 1 + res.add("json", path, f"json code block does not parse: {e}", line) + + +def _scan(line: str) -> str: + """Line with inline code spans and URLs blanked out.""" + return re.sub(r"\S*://\S+", " ", strip_code(line)).lower() + + +def check_denylist(tree: Tree, res: Result): + for path in tree.files(): + text = tree.read(path) or "" + + # Misspellings are scanned everywhere, code fences included: the + # "Kuberneters" typos we fixed lived in `//` comments inside query + # examples. None of these strings can be a legitimate identifier. + for i, line in enumerate(text.split("\n"), 1): + bare = _scan(line) + for bad, good in DENYLIST.items(): + if re.search(rf"(? 2: + widths: dict[int, int] = {} + for _, w in block: + widths[w] = widths.get(w, 0) + 1 + if len(widths) > 1: + major = max(widths, key=widths.get) + for n, w in block: + if w != major: + res.add("table-width", path, + f"table row width {w} does not match the table's {major}", + n + 1) + pipes = {lines[n].count("|") for n, _ in block} + if len(pipes) > 1: + res.add("table-columns", path, + f"table starting on line {start + 1} has rows with differing column counts", + start + 1) + block = [] + + +def check_trailing_whitespace(tree: Tree, res: Result): + for path in tree.files(): + lines = (tree.read(path) or "").split("\n") + infence = False + for i, line in enumerate(lines, 1): + if FENCE.match(line): + infence = not infence + continue + if infence or line.rstrip() == line: + continue + nxt = lines[i] if i < len(lines) else "" + hard_break = line.endswith(" ") and not line.endswith(" ") + if hard_break and nxt.strip(): + continue # a real Markdown hard break + res.add("trailing-whitespace", path, "line has trailing whitespace", i) + + +def redirects_of(tree: Tree) -> dict[str, str]: + text = tree.read(".gitbook.yml") or "" + out: dict[str, str] = {} + inside = False + for line in text.split("\n"): + if line.startswith("redirects:"): + inside = True + continue + if inside: + m = re.match(r"\s+(\S+):\s*(\S+)\s*$", line) + if m: + out[m.group(1)] = m.group(2) + elif line.strip() and not line.startswith((" ", "\t")): + inside = False + return out + + +def check_redirect_targets(tree: Tree, res: Result): + for src, dest in redirects_of(tree).items(): + if resolve(tree, ".gitbook.yml", dest) is None: + res.add("redirect-target", ".gitbook.yml", + f'redirect "{src}" points at "{dest}", which does not exist') + + +def check_moved_files(head: Tree, base: Tree, res: Result): + """A page that moved or went away needs a redirect, or its URL 404s.""" + gone = set(base.files()) - set(head.files()) + redirects = redirects_of(head) + covered = {k.rstrip("/") for k in redirects} + for path in sorted(gone): + if os.path.basename(path).startswith("fragment-") or path == CONVENTIONS: + continue + url = re.sub(r"(^|/)README\.md$", "", path) + url = re.sub(r"\.md$", "", url).rstrip("/") + if url and url not in covered: + res.add("moved-file", ".gitbook.yml", + f'"{path}" was removed or moved but no redirect for "{url}" ' + f"was added to .gitbook.yml") + + +CHECKS = [check_links_and_anchors, check_code_blocks, check_denylist, + check_product_names, check_heading_case, check_tables, + check_trailing_whitespace, check_redirect_targets] + + +def run(tree: Tree) -> Result: + res = Result() + for fn in CHECKS: + fn(tree, res) + return res + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--base", help="git ref to compare against (e.g. origin/main)") + ap.add_argument("--format", choices=["text", "github"], default="text") + args = ap.parse_args() + + head = Tree() + findings = run(head).findings + + if args.base: + base = Tree(args.base) + known = {f.key() for f in run(base).findings} + before = len(findings) + findings = [f for f in findings if f.key() not in known] + check_moved_files(head, base, (moved := Result())) + findings += moved.findings + print(f"comparing against {args.base}: " + f"{before - len(findings) + len(moved.findings)} pre-existing finding(s) ignored\n") + + errors = [f for f in findings if not f.warning] + warnings = [f for f in findings if f.warning] + + for group, label in ((errors, "error"), (warnings, "warning")): + for f in sorted(group, key=lambda x: (x.path, x.line)): + where = f"{f.path}:{f.line}" if f.line else f.path + if args.format == "github": + print(f"::{label} file={f.path},line={max(f.line, 1)}::" + f"[{f.check}] {f.message}") + else: + print(f"{label:7} {where:62} [{f.check}] {f.message}") + + print(f"\n{len(errors)} error(s), {len(warnings)} warning(s)") + if errors: + print(f"\nThese are new relative to the base. See {CONVENTIONS} for the conventions.") + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) From ae4f49c1087829e33141ceddc9744c82fca12231 Mon Sep 17 00:00:00 2001 From: Manuel Gerding Date: Thu, 3 Sep 2026 09:59:54 +0200 Subject: [PATCH 2/5] ci: run the docs linter on pull requests, drop the redirect checker The linter needs the target branch to diff against, hence `fetch-depth: 0` plus an explicit fetch. actions/checkout gives us the merge of the PR into the target branch, which is what makes the merge-regression case detectable. Removes redirect-url-checker.yml. It probed the published site over the network, which GitBook rate-limits, so it never passed reliably - and it was `workflow_dispatch` only, so in practice nothing ran on a pull request at all. Its purpose is covered locally and deterministically by the new redirect-target and moved-file checks, which read `.gitbook.yml` and the tree instead of making requests. --- .github/workflows/docs-lint.yml | 39 ++++++++++++++++++++++ .github/workflows/redirect-url-checker.yml | 28 ---------------- 2 files changed, 39 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/docs-lint.yml delete mode 100644 .github/workflows/redirect-url-checker.yml diff --git a/.github/workflows/docs-lint.yml b/.github/workflows/docs-lint.yml new file mode 100644 index 00000000..266acde2 --- /dev/null +++ b/.github/workflows/docs-lint.yml @@ -0,0 +1,39 @@ +name: Docs Lint + +on: + pull_request: + branches: [main] + types: [opened, synchronize, ready_for_review, reopened] + +concurrency: + group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + docs-lint: + name: Docs Lint + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + + steps: + # The full history of the target branch is needed: the linter reads the + # base tree with `git ls-tree` / `git show` to work out which findings the + # pull request actually introduces. + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Fetch target branch + run: git fetch --no-tags --depth=1 origin "${{ github.base_ref }}" + + # actions/checkout checks out the merge of this PR into the target branch, + # so comparing against the target branch tip also catches a merge that + # silently undoes a fix already on main. + - name: Run docs lint + run: | + python3 .github/scripts/docs_lint.py \ + --base "origin/${{ github.base_ref }}" \ + --format github diff --git a/.github/workflows/redirect-url-checker.yml b/.github/workflows/redirect-url-checker.yml deleted file mode 100644 index 87111f18..00000000 --- a/.github/workflows/redirect-url-checker.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Check Redirects -on: - workflow_dispatch: - -jobs: - check_redirects: - runs-on: ubuntu-latest - name: Check Redirects Job - timeout-minutes: 60 - steps: - - name: Give GitBook a few seconds till it has processed everything - run: sleep 60s - shell: bash - if: "!cancelled()" - - - name: Check Redirects - uses: steadybit/gitbook-redirect-checker@v0.3 - if: "!cancelled()" - - - name: Notify Slack channel - uses: act10ns/slack@d96404edccc6d6467fc7f8134a420c851b1e9054 # v2.2.0 - with: - channel: '#test-docs' - status: ${{ job.status }} - message: "Found broken redirects in docs" - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} # required - if: failure() From 481e49b27426afe03829f26843a15f7c58689105 Mon Sep 17 00:00:00 2001 From: Manuel Gerding Date: Thu, 3 Sep 2026 09:59:54 +0200 Subject: [PATCH 3/5] fix: repoint 38 redirects at pages that had moved The new redirect-target check found 38 redirects aimed at files that no longer exist, so those old URLs 404 today. They are all fallout from the reorganisation that introduced `concepts/` and `quick-start/`; only six distinct targets are involved: use-steadybit/actions.md -> concepts/actions/README.md (25) use-steadybit/discovery/README.md -> concepts/discovery/README.md (6) getting-started.md -> quick-start/getting-started.md (3) troubleshooting/agent.md -> troubleshooting/common-fixes/agents.md (2) use-steadybit/experiments/file-import-export -> .../experiments/share/file-import-export (1) integrate-with-steadybit/webhooks.md -> integrate-with-steadybit/webhooks/README.md (1) Every destination verified to exist. No redirect keys change, so no currently-working URL is affected. --- .gitbook.yml | 76 ++++++++++++++++++++++++++-------------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/.gitbook.yml b/.gitbook.yml index f4c0bf23..7036fdb8 100644 --- a/.gitbook.yml +++ b/.gitbook.yml @@ -12,7 +12,7 @@ redirects: install-configure/30-install-agents/35-aws-ecs-ec2: install-and-configure/install-agent/aws-ecs.md install-configure/30-install-agents/40-aws-cloud: install-and-configure/install-agent/README.md install-configure/30-install-agents/50-advanced-configuration: install-and-configure/install-agent/advanced-configuration.md - install-configure/30-install-agents/50-faq: troubleshooting/agent.md + install-configure/30-install-agents/50-faq: troubleshooting/common-fixes/agents.md install-and-configure: install-and-configure/install-agent/README.md install-and-configure/install-agents: install-and-configure/install-agent/README.md install-and-configure/install-agents/docker: install-and-configure/install-agent/install-as-docker-container.md @@ -22,7 +22,7 @@ redirects: install-and-configure/install-agents/aws-ecs-ec2: install-and-configure/install-agent/aws-ecs.md install-and-configure/install-agents/aws-cloud: install-and-configure/install-agent/README.md install-and-configure/install-agents/advanced-configuration: install-and-configure/install-agent/advanced-configuration.md - install-and-configure/install-agents/faq: troubleshooting/agent.md + install-and-configure/install-agents/faq: troubleshooting/common-fixes/agents.md install-and-configure/install-agent/aws-ecs-ec2: install-and-configure/install-agent/aws-ecs.md install-and-configure/install-agent/extension-discovery: install-and-configure/install-agent/extension-registration.md install-and-configure/install-outpost-agent-preview: install-and-configure/install-agent/README.md @@ -78,13 +78,13 @@ redirects: learn/15-actions/20-http-call: concepts/actions/README.md learn/15-actions/30-prometheus: concepts/actions/README.md learn/15-actions/40-postman: concepts/actions/README.md - learn/20-attacks: use-steadybit/actions.md - learn/20-attacks/application: use-steadybit/actions.md - learn/20-attacks/kubernetes: use-steadybit/actions.md - learn/20-attacks/network: use-steadybit/actions.md - learn/20-attacks/resource: use-steadybit/actions.md - learn/20-attacks/state: use-steadybit/actions.md - learn/30-discovery: use-steadybit/discovery/README.md + learn/20-attacks: concepts/actions/README.md + learn/20-attacks/application: concepts/actions/README.md + learn/20-attacks/kubernetes: concepts/actions/README.md + learn/20-attacks/network: concepts/actions/README.md + learn/20-attacks/resource: concepts/actions/README.md + learn/20-attacks/state: concepts/actions/README.md + learn/30-discovery: concepts/discovery/README.md learn/30-discovery/10-host: concepts/discovery/README.md learn/30-discovery/20-container: concepts/discovery/README.md learn/30-discovery/30-application: concepts/discovery/README.md @@ -106,34 +106,34 @@ redirects: integrate/30-monitoring/40-newrelic: concepts/actions/README.md integrate/30-monitoring/50-prometheus: concepts/actions/README.md integrate/40-slack-notifications: integrate-with-steadybit/slack-notifications.md - integrate/50-webhooks: integrate-with-steadybit/webhooks.md - docs: getting-started.md - use-steadybit/attacks/README: use-steadybit/actions.md - use-steadybit/attacks: use-steadybit/actions.md - use-steadybit/attacks/state: use-steadybit/actions.md - use-steadybit/attacks/resource: use-steadybit/actions.md - use-steadybit/attacks/network: use-steadybit/actions.md - use-steadybit/attacks/kubernetes: use-steadybit/actions.md - use-steadybit/attacks/application: use-steadybit/actions.md - use-steadybit/attacks/aws-cloud-attacks: use-steadybit/actions.md - use-steadybit/checks/README: use-steadybit/actions.md - use-steadybit/checks: use-steadybit/actions.md - use-steadybit/checks/pod-count: use-steadybit/actions.md - use-steadybit/checks/http-call: use-steadybit/actions.md - use-steadybit/checks/prometheus: use-steadybit/actions.md - use-steadybit/checks/postman: use-steadybit/actions.md - use-steadybit/discovery/application: use-steadybit/discovery/README.md - use-steadybit/discovery/cloud: use-steadybit/discovery/README.md - use-steadybit/discovery/container: use-steadybit/discovery/README.md - use-steadybit/discovery/custom: use-steadybit/discovery/README.md - use-steadybit/discovery/host: use-steadybit/discovery/README.md - use-steadybit/load-tests/README: use-steadybit/actions.md - use-steadybit/load-tests: use-steadybit/actions.md - use-steadybit/load-tests/gatling: use-steadybit/actions.md - use-steadybit/load-tests/jmeter: use-steadybit/actions.md - use-steadybit/load-tests/k6: use-steadybit/actions.md - getting-started/20-define-resilience-expectations: getting-started.md - quick-start/define-resilience-expectations: getting-started.md + integrate/50-webhooks: integrate-with-steadybit/webhooks/README.md + docs: quick-start/getting-started.md + use-steadybit/attacks/README: concepts/actions/README.md + use-steadybit/attacks: concepts/actions/README.md + use-steadybit/attacks/state: concepts/actions/README.md + use-steadybit/attacks/resource: concepts/actions/README.md + use-steadybit/attacks/network: concepts/actions/README.md + use-steadybit/attacks/kubernetes: concepts/actions/README.md + use-steadybit/attacks/application: concepts/actions/README.md + use-steadybit/attacks/aws-cloud-attacks: concepts/actions/README.md + use-steadybit/checks/README: concepts/actions/README.md + use-steadybit/checks: concepts/actions/README.md + use-steadybit/checks/pod-count: concepts/actions/README.md + use-steadybit/checks/http-call: concepts/actions/README.md + use-steadybit/checks/prometheus: concepts/actions/README.md + use-steadybit/checks/postman: concepts/actions/README.md + use-steadybit/discovery/application: concepts/discovery/README.md + use-steadybit/discovery/cloud: concepts/discovery/README.md + use-steadybit/discovery/container: concepts/discovery/README.md + use-steadybit/discovery/custom: concepts/discovery/README.md + use-steadybit/discovery/host: concepts/discovery/README.md + use-steadybit/load-tests/README: concepts/actions/README.md + use-steadybit/load-tests: concepts/actions/README.md + use-steadybit/load-tests/gatling: concepts/actions/README.md + use-steadybit/load-tests/jmeter: concepts/actions/README.md + use-steadybit/load-tests/k6: concepts/actions/README.md + getting-started/20-define-resilience-expectations: quick-start/getting-started.md + quick-start/define-resilience-expectations: quick-start/getting-started.md use-steadybit/resilience-policies: use-steadybit/experiments/README.md use-steadybit/resilience-policies/references: use-steadybit/experiments/README.md use-steadybit/resilience-policies/taskandpolicydefinitions: use-steadybit/experiments/README.md @@ -145,7 +145,7 @@ redirects: use-steadybit/query-language: concepts/query-language/README.md use-steadybit/actions: concepts/actions/README.md use-steadybit/discovery: concepts/discovery/README.md - use-steadybit/experiments/share/yml-import-export: use-steadybit/experiments/file-import-export/README.md + use-steadybit/experiments/share/yml-import-export: use-steadybit/experiments/share/file-import-export/README.md install-and-configure/configure-monitoring: concepts/actions/README.md install-and-configure/configure-monitoring/datadog: concepts/actions/README.md install-and-configure/configure-monitoring/instana: concepts/actions/README.md From 301ceaaf7611129972e17f20ea094823872898ef Mon Sep 17 00:00:00 2001 From: Manuel Gerding Date: Thu, 3 Sep 2026 10:12:10 +0200 Subject: [PATCH 4/5] docs: write down the conventions the linter enforces One place for the rules, so the linter, contributors and any future review agent agree. Records the decisions that were open: - Headings are title case, except question-style headings, which stay in sentence case - the troubleshooting pages are written as questions and title-casing them reads wrong. - US English: color, behavior, organization, analyze, canceled. This was already true throughout, but was never written down, so nothing stopped it drifting back. - Lists use the serial (Oxford) comma. Noted as *not* linted: separating a three-item list from a compound such as "open- and closed-source extensions" is not reliably detectable, and every sample the draft check flagged was a false positive. Also notes the scope difference the linter applies - British forms are prose-only, plain misspellings apply everywhere including code samples - and updates the CI/CD section, which still described the removed redirect checker. --- CLAUDE.md | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6a324aef..b030ced6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,77 @@ This is the public documentation for Steadybit, a chaos engineering platform. Th ### When Adding Redirects Add entries to `.gitbook.yml` in the `redirects:` section to map old URLs to new file locations. +## Writing Conventions + +`.github/workflows/docs-lint.yml` enforces the checkable ones on every pull +request. It reports only what a change introduces, so existing violations never +fail a build — but don't add new ones. Run it locally with: + +```bash +python3 .github/scripts/docs_lint.py --base origin/main +``` + +### Headings + +Title case, with minor words (a, an, the, and, or, of, to, in, via, …) lowercase +unless they start the heading: + +- `## Install Agent and Extensions` +- `### Configure a Container Runtime` + +**Exception: question-style headings stay in sentence case.** The troubleshooting +pages are written as questions, and title-casing them reads wrong: + +- `#### Why can't I install extension-container on Docker Desktop?` + +The linter skips any heading containing a `?`. + +### Spelling + +US English: **color**, **behavior**, **organization**, **analyze**, **license**, +**center**, **canceled**, **judgment**, **customize**, **prioritize**, +**initialize**. Not `colour`, `behaviour`, `organisation`, `analyse`, `cancelled`. + +The linter flags British forms in prose only. An identifier can legitimately +contain one - a config key really may be named `labelled` - and renaming +someone's field is not a docs decision. Plain misspellings are flagged +everywhere, code samples included, since a typo like `Kuberneters` is never a +valid identifier. + +### Product and technology names + +Use the vendor's casing in prose: **WebSocket**, **Docker Compose**, **Helm** +(the tool is `helm`), **GitHub**, **Kubernetes**, **OpenAPI**, **PostgreSQL**. + +This applies to prose only. Identifiers keep whatever casing they really have — +`STEADYBIT_AGENT_WEBSOCKET_PING_INTERVAL`, `platform.publicWebsocketPort`, the +`helm` CLI, the `steadybit/helm-charts` repository, and protocol tokens such as +the `Upgrade: websocket` header. + +### Lists + +Use the serial (Oxford) comma: *a, b, and c* — not *a, b and c*. This one is not +linted, because distinguishing a three-item list from a compound like +"open- and closed-source extensions" is not reliably detectable. + +### Dashes + +An em dash (`—`) sets off a phrase inside a sentence. A hyphen stays a hyphen: +in compounds (`on-prem`), as a title separator in headings +(`## Step 1 - Get your keys`), and in literal values (`429 - Too Many Requests`). + +### Tables + +Every row in a table shares one width, padded from the widest cell per column. +After editing cell text, re-pad the table so the diff shows the change and not +the reflow. + +### Moving or deleting a page + +Add a redirect to `.gitbook.yml` for the page's old URL, or the old link 404s. +The linter fails a pull request that removes or renames a `.md` file without one, +and also checks that every redirect target still resolves to a file that exists. + ## Common Tasks ### Adding a New Documentation Page @@ -57,4 +128,8 @@ Add entries to `.gitbook.yml` in the `redirects:` section to map old URLs to new ## CI/CD -- `.github/workflows/redirect-url-checker.yml` - Validates that all redirects in `.gitbook.yml` resolve correctly +- `.github/workflows/docs-lint.yml` - Runs `.github/scripts/docs_lint.py` on every pull + request: broken links and anchors, unparseable JSON samples, known misspellings, + product-name casing, US-English spelling, heading case, table alignment, trailing + whitespace, redirect targets, and missing redirects for moved pages. It compares against the target + branch, so only findings the pull request introduces fail the build. From 9e44ac4245e6d3dd1d0bba62d2808e701be90677 Mon Sep 17 00:00:00 2001 From: Manuel Gerding Date: Thu, 3 Sep 2026 10:21:01 +0200 Subject: [PATCH 5/5] ci: make the misspelling tables readable and their invariant explicit Review question: the table looked like it mixed wrong spellings with right ones. It is a `written -> should say` mapping, so "Kubernetes" is the suggestion for "kuberneters" rather than an entry - but 23 pairs packed onto continuation lines made that genuinely hard to see. One entry per line now, aligned and grouped by kind. Two real problems that question surfaced: - Lookups were done on a lowercased line, so keys had to be lowercase or they would silently never match, and nothing said so. Matching is now explicitly case-insensitive, and an assertion rejects a capitalised key instead of letting it quietly do nothing. - A finding quoted the table key rather than the file's own text, so a heading reading "Langauge" produced `"langauge" should be ...`. It now quotes what is actually written. Also corrects the suggestion for "langauge" to the common noun "language"; it only looked capitalised because the single instance we had sat inside a title. --- .github/scripts/docs_lint.py | 70 ++++++++++++++++++++++++++---------- 1 file changed, 51 insertions(+), 19 deletions(-) diff --git a/.github/scripts/docs_lint.py b/.github/scripts/docs_lint.py index f27104ac..b402dfab 100644 --- a/.github/scripts/docs_lint.py +++ b/.github/scripts/docs_lint.py @@ -211,16 +211,37 @@ def links_in(line: str): # ----------------------------------------------------------------------- the checks +# what is written -> what it should say. +# Matching is case-insensitive, so keys must be lowercase; the assertion below +# enforces that, since a capitalised key would silently never match anything. +# Every entry is a mistake this repo has actually contained. DENYLIST = { - "langauge": "Language", "kuberneters": "Kubernetes", "receieve": "receive", - "recieve": "receive", "succesfully": "successfully", "expermiment": "experiment", - "refering": "referring", "versionized": "versioned", "verfiy": "verify", - "similiar": "similar", "usally": "usually", "looses": "loses", - "cirds": "CIDRs", "custer": "cluster", "groupd": "group", - "identifiert": "identifier", "wether": "whether", "exeriment": "experiment", - "mostly likely": "most likely", "heat dump": "heap dump", - "productive usage": "production use", "per default": "by default", - "on the long run": "in the long run", + # misspellings + "langauge": "language", + "kuberneters": "Kubernetes", + "receieve": "receive", + "recieve": "receive", + "succesfully": "successfully", + "expermiment": "experiment", + "exeriment": "experiment", + "refering": "referring", + "versionized": "versioned", + "verfiy": "verify", + "similiar": "similar", + "usally": "usually", + "looses": "loses", + "cirds": "CIDRs", + "custer": "cluster", + "groupd": "group", + "identifiert": "identifier", + "wether": "whether", + # wrong word or phrase + "mostly likely": "most likely", + "heat dump": "heap dump", + # German turns of phrase + "productive usage": "production use", + "per default": "by default", + "on the long run": "in the long run", } # The docs are US English throughout. These forms are simply not US spellings, @@ -309,8 +330,20 @@ def check_code_blocks(tree: Tree, res: Result): def _scan(line: str) -> str: - """Line with inline code spans and URLs blanked out.""" - return re.sub(r"\S*://\S+", " ", strip_code(line)).lower() + """Line with inline code spans and URLs blanked out. + + Case is preserved so a finding can quote the text as it appears in the file; + the lookups below are case-insensitive instead. + """ + return re.sub(r"\S*://\S+", " ", strip_code(line)) + + +def _misspelled(table: dict[str, str], line: str): + """Yield (text as written, suggestion) for each table entry found in line.""" + for bad, good in table.items(): + m = re.search(rf"(?