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]>
94 lines
3.9 KiB
Python
94 lines
3.9 KiB
Python
#!/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())
|