fix: reconcile package and contract documentation drift #236
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # SPDX-License-Identifier: Apache-2.0 | |
| # Doc hygiene: fail if an internal Markdown link points at a file that doesn't | |
| # exist. The docs (AGENTS.md, context/, planning/) are heavily cross-linked, and | |
| # a rename silently breaks them. Also fails on: | |
| # - a [^label] citation footnote whose label matches no sources[].id in the | |
| # same file's frontmatter. That label is the join key OKF resolves | |
| # attribution through, so a dangling one is a citation pointing nowhere — | |
| # and the spec leaves the case undefined, so consumers can't be relied on | |
| # to flag it. | |
| # - a positional citation (`path.ext:NN`) — CONVENTIONS § Citations bans them. | |
| # Ignores links inside fenced blocks, inline code, and HTML comments, so | |
| # documented link *syntax* examples don't trip it. | |
| # | |
| # Despite the name this now checks citation integrity as well as links. Two | |
| # prepared texts, and each check must use the right one — see link_text and | |
| # cite_text below. | |
| name: link-check | |
| on: | |
| push: | |
| pull_request: | |
| jobs: | |
| links: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Check internal Markdown links | |
| run: | | |
| python3 - <<'PY' | |
| import re, os, glob, sys | |
| bad = 0 | |
| concepts = {os.path.splitext(os.path.basename(p))[0] | |
| for p in glob.glob('context/**/*.md', recursive=True)} | |
| for f in sorted(glob.glob('**/*.md', recursive=True)): | |
| # Skip only root build directories, not tracked root documents | |
| # such as a future build-guide.md. | |
| top = f.split(os.sep, 1) | |
| if len(top) == 2 and (top[0] == 'build' or top[0].startswith('build-')): | |
| continue | |
| raw = open(f, encoding='utf-8').read() | |
| # sources[].id values declared in this file's frontmatter | |
| fm = re.match(r'---\n(.*?)\n---\n', raw, flags=re.DOTALL) | |
| ids = set(re.findall(r'^\s*-?\s*id:\s*(\S+)', fm.group(1), flags=re.M)) if fm else set() | |
| # TWO prepared texts. They differ in one step, and which one a | |
| # check reads decides whether it can see anything at all — so name | |
| # them rather than letting a later check inherit the wrong one. | |
| # | |
| # link_text: for the link and footnote checks. Inline code goes, | |
| # because a doc that *documents* link syntax writes examples there. | |
| # Order matters: code first, comments last. A doc that documents | |
| # comment syntax has a bare `<!--` inside a code block, and | |
| # stripping comments first pairs it with a `-->` far below, | |
| # swallowing the fences in between and blinding every check that | |
| # follows. Removing code spans first leaves only real comments. | |
| link_text = raw | |
| link_text = re.sub(r'```.*?```', '', link_text, flags=re.DOTALL) # drop fenced code | |
| link_text = re.sub(r'`[^`]*`', '', link_text) # drop inline code | |
| link_text = re.sub(r'<!--.*?-->', '', link_text, flags=re.DOTALL) # drop HTML comments | |
| # | |
| # cite_text: for the positional-citation check only. Inline code is | |
| # KEPT — a citation is written `like/this.cpp:42`, so stripping it | |
| # would blind the check completely. Fenced blocks still go, because | |
| # compiler and sanitizer output legitimately carries file:line. | |
| cite_text = re.sub(r'```.*?```', '', raw, flags=re.DOTALL) | |
| cite_text = re.sub(r'<!--.*?-->', '', cite_text, flags=re.DOTALL) | |
| base = os.path.dirname(f) | |
| for m in re.findall(r'\]\(([^)]+)\)', link_text): | |
| t = m.split('#')[0].strip() | |
| if not t or t.startswith(('http://', 'https://', 'mailto:')): | |
| continue | |
| # Root-absolute links resolve against the site root, not the | |
| # bundle root, so they work nowhere. CONVENTIONS bans them — | |
| # flag them, never silently remap them onto context/. | |
| if t.startswith('/'): | |
| print(f'ROOT-ABSOLUTE {f} -> {m} (use a ./relative path)') | |
| bad += 1 | |
| continue | |
| if not os.path.exists(os.path.normpath(os.path.join(base, t))): | |
| print(f'BROKEN {f} -> {m}'); bad += 1 | |
| # [[slug]] wikilinks resolve by concept name, not path. Not the | |
| # house style here, but a stray one should still be checked. | |
| for w in re.findall(r'\[\[([^\]]+)\]\]', link_text): | |
| slug = w.split('|')[0].split('#')[0].strip() | |
| if slug not in concepts: | |
| print(f'BROKEN WIKILINK {f} -> [[{w}]]'); bad += 1 | |
| # [^label] citation footnotes join to sources[].id in this file. | |
| for lab in set(re.findall(r'\[\^([^\]]+)\]', link_text)): | |
| if lab not in ids: | |
| print(f'DANGLING FOOTNOTE {f} -> [^{lab}] (no sources[].id)'); bad += 1 | |
| # Positional citations (`path.ext:NN`) — banned by CONVENTIONS | |
| # § Citations. A line number into a file that later gets edited | |
| # comes to point at the wrong text while still resolving and still | |
| # looking right, so nothing downstream catches it: not review, not | |
| # the checks above. Cite a section or a symbol instead. | |
| # log.md is exempt — its entries are dated snapshots frozen at | |
| # write time, the same place a specific number is allowed to live. | |
| if os.path.basename(f) != 'log.md': | |
| for c in re.findall(r'`([\w./-]+\.(?:md|hpp|cpp|h|py|toml|txt|yml|cmake|sh):\d+)`', cite_text): | |
| print(f'POSITIONAL CITATION {f} -> `{c}` (cite a section or symbol)'); bad += 1 | |
| print('all internal links resolve' if not bad else f'{bad} bad link(s)') | |
| sys.exit(1 if bad else 0) | |
| PY |