open-standards/scripts/check-wai-section-refs.py
Transaction Science ddd49ee03d check-wai-section-refs: read wai/SECURITY.md too
SECURITY.md cites sections of SPEC, http-delivery, integer-payloads and
jwp-receipts, and nothing checked them: the checker read only SPEC.md and
extensions/*.md, and the workflow's paths left SECURITY.md out. It reads
SECURITY.md now, and the workflow runs when it changes. Every reference in
it resolves; a reference to http-delivery §66 put in by hand fails the
check. CHANGELOG.md stays out: it is a dated record whose older references
are kept as written.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 17:15:06 -04:00

94 lines
3.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Every section a WAI specification text cites in another WAI text exists.
Reads wai/SPEC.md, wai/extensions/*.md and wai/SECURITY.md. In each, a reference to a
section of SPEC.md or of an extension is checked against the headings of the file it
names. Three spellings are recognised, including where the reference breaks across
lines:
[text](X.md) §N a link, then the section
[text §N](X.md) the section inside the link text
X §N, X.md §N, `X` §N an extension's file name (or SPEC) in prose, then the section
where §N is a section number of one or more parts (§7, §2.4.3), and a range written
§A–§B checks both ends. A heading is numbered when it begins `#`…, then the number, as
in `## 7.`, `### 6.4 ` or `## 7.1.`. Files under wai/research/ are dated records, kept as
written, and are not read.
Exit status 1, with one line per reference that names a missing section; 0 otherwise.
python3 scripts/check-wai-section-refs.py [repo-root]
"""
import os, re, sys
ROOT = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
WAI = os.path.join(ROOT, "wai")
EXT = os.path.join(WAI, "extensions")
HEADING = re.compile(r"^#{1,6}[ \t]+([0-9]+(?:\.[0-9]+)*)\.?(?=[ \t])", re.M)
SECTION = r"§[ \t]*([0-9]+(?:\.[0-9]+)*)"
RANGE = re.compile(r"[ \t]*[–-][ \t]*§[ \t]*([0-9]+(?:\.[0-9]+)*)")
def sections(path):
with open(path, encoding="utf-8") as f:
return set(HEADING.findall(f.read()))
def texts():
out = [os.path.join(WAI, "SPEC.md")]
out += sorted(os.path.join(EXT, n) for n in os.listdir(EXT) if n.endswith(".md"))
out.append(os.path.join(WAI, "SECURITY.md")) # it cites the texts it points readers to
return out
def main():
files = texts()
known = {os.path.normpath(p): sections(p) for p in files}
stems = sorted((os.path.splitext(os.path.basename(p))[0] for p in files if os.path.dirname(p) == EXT),
key=len, reverse=True)
by_stem = {os.path.splitext(os.path.basename(p))[0]: os.path.normpath(p) for p in files if os.path.dirname(p) == EXT}
by_stem["SPEC"] = os.path.normpath(os.path.join(WAI, "SPEC.md"))
# A file name in prose, not inside a link target or a longer word or path.
bare = re.compile(r"(?<![\w/.(-])`?(" + "|".join(map(re.escape, stems + ["SPEC"])) + r")(?:\.md)?`?[ \t\n]+" + SECTION)
link_then = re.compile(r"\[[^\]]*\]\(([^)\s]+\.md)\)[ \t\n]*" + SECTION)
link_with = re.compile(r"\[([^\]]*?)" + SECTION + r"[^\]]*\]\(([^)\s]+\.md)\)")
findings = []
for path in files:
with open(path, encoding="utf-8") as f:
text = f.read()
here = os.path.dirname(path)
def check(target, num, at):
target = os.path.normpath(target)
if target not in known:
return # not a WAI specification text (a README, a research note)
if num not in known[target]:
line = text.count("\n", 0, at) + 1
rel = os.path.relpath(path, ROOT)
findings.append(f"{rel}:{line}: §{num} of {os.path.relpath(target, ROOT)} does not exist")
def check_range(target, m, end):
r = RANGE.match(text, end)
if r:
check(target, r.group(1), m.start())
for m in link_then.finditer(text):
target = os.path.join(here, m.group(1).split("#")[0])
check(target, m.group(2), m.start())
check_range(target, m, m.end())
for m in link_with.finditer(text):
target = os.path.join(here, m.group(3).split("#")[0])
check(target, m.group(2), m.start())
for m in bare.finditer(text):
check(by_stem[m.group(1)], m.group(2), m.start())
check_range(by_stem[m.group(1)], m, m.end())
for f in sorted(set(findings)):
print(f)
return 1 if findings else 0
if __name__ == "__main__":
sys.exit(main())