The change of wai/conform-output, on current main, without its wai doctor: main's doctor, its tests, its catalogue entry, its MCP tool and its changelog entry stand as they are, and this change's copies of them are gone, with the lines dev.rs held for them. wai/README.md, which had no line for main's doctor, gains one in its first commands and `toolchain_doctor` in the core tools. - wai conform --format text|json|junit|html, reports made from the signed records. - --independent beside a kind holds the corpus's independent verifier to the same cases; alone, it runs every corpus's verifier, a record each, from one table. - wai-rs/build.rs reads that table from the `# independent:` lines of the step files (.github/wai-conformance/verifiers/), which take the verifiers' command lines out of the steps' bodies. scripts/wai-conformance-lists.py reads and checks the same lines. - wai-conformance.yml runs the union as wai conform --independent --format junit. - wai mcp's conformance tools read the table. - wai-release.yml runs the quickstart from each archive as a new user runs it (clean-machine.sh, its PowerShell twin clean-machine.ps1), held by check-wai-release-quickstart.py. clean-machine.ps1 keeps clean-machine.sh's contents step and its check of WAI Studio. Each leg keeps main's archive_doctor.py check; the step's own wai hello, verify and dev --check are clean-machine.sh's now. - check-wai-ops-doors.py reads VERBS and NEAR_SYNONYMS from catalogue.rs and keeps no list, and the verb-list test reads what that check reads. - The catalogue's export and Studio's actions hold the new operations. Co-Authored-By: Claude Opus 5.5 <[email protected]>
311 lines
13 KiB
Python
Executable file
311 lines
13 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""check-wai-ops-doors.py: every door of WAI names its operations as the catalogue does.
|
|
|
|
WAI's operations are one catalogue, `noun.verb` (wai/wai-rs/src/catalogue.rs), exported as
|
|
wai/catalogue/ops.json by `wai ops export` (CI holds the export to the table with
|
|
`wai ops export --check`). Each door names an operation by a mechanical transform of its id:
|
|
|
|
MCP tool `noun_verb` (or its `op`) wai/wai-rs/src/bin/wai_cli/mcp/tools.rs
|
|
HTTP API operationId `noun.verb` every openapi.json of the services
|
|
C function `wai_noun_verb` wai/wai-rs/include/wai.h
|
|
Swift type `Wai<Noun>`, method `<verb>…` wai/wai-sink-swift/Sources
|
|
Kotlin class `Wai<Noun>`, fun `<verb>…` wai/wai-sink-android/*/src/main
|
|
|
|
The command line is held by the `wai` binary's own tests (wai_cli/door.rs).
|
|
|
|
What each door is held to:
|
|
|
|
- MCP: every tool's name is `noun_verb` of an operation in the catalogue.
|
|
- HTTP API: every operationId is an operation of the catalogue that runs on a service
|
|
(placement `service` or `both`), at the route the catalogue gives it.
|
|
- C, Swift, Kotlin: a name in a catalogue noun's namespace whose verb is an operation verb
|
|
(naming rule N3's closed list) names an operation, and that operation must be in the
|
|
catalogue; a near-synonym verb (`get` for `show`, `init` for `create`, …) in a catalogue
|
|
noun's namespace is refused. Any other name is library API the catalogue does not list
|
|
(the C ABI's codec and sink functions, the native sinks' classes), and is reported as such.
|
|
The verbs and their near-synonyms are the catalogue's own lists, `VERBS` and
|
|
`NEAR_SYNONYMS` in wai/wai-rs/src/catalogue.rs, read from it: there is one list, and a verb
|
|
the catalogue gains is held here from the change that adds it.
|
|
|
|
A door that has no operation of the catalogue yet is reported as such; that is not a
|
|
failure. A wrong name is.
|
|
|
|
check-wai-ops-doors.py [--root DIR] [--ops FILE]
|
|
|
|
Exit 0 when every door holds, 1 with one line a finding otherwise. Standard library only.
|
|
"""
|
|
import glob
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
# The catalogue's source: its closed list of operation verbs (naming rule N3) and their
|
|
# near-synonyms, each with the verb to use.
|
|
CATALOGUE = "wai/wai-rs/src/catalogue.rs"
|
|
# Read from CATALOGUE by main().
|
|
VERBS = set()
|
|
NEAR = {}
|
|
|
|
MCP_TABLE = "wai/wai-rs/src/bin/wai_cli/mcp/tools.rs"
|
|
OPENAPI_GLOBS = ["joule-code/joule-code-rs/crates/*/openapi.json", "wai/*/openapi.json", "wai/wai-rs/openapi.json"]
|
|
C_HEADER = "wai/wai-rs/include/wai.h"
|
|
SWIFT_GLOB = "wai/wai-sink-swift/Sources/**/*.swift"
|
|
KOTLIN_GLOB = "wai/wai-sink-android/*/src/main/**/*.kt"
|
|
|
|
|
|
def load_ops(path):
|
|
with open(path, encoding="utf-8") as f:
|
|
doc = json.load(f)
|
|
if doc.get("format") != "wai.ops/1":
|
|
raise SystemExit(f"{path}: not a catalogue export (format {doc.get('format')!r})")
|
|
return {o["id"]: o for o in doc["operations"]}
|
|
|
|
|
|
def load_verbs(path):
|
|
"""(verbs, near-synonyms) from the catalogue's source: `pub const VERBS: &[&str] = &[…];`
|
|
and `pub const NEAR_SYNONYMS: &[(&str, &str)] = &[(…), …];`. A source either list cannot be
|
|
read from stops the check: a door held to no verb would hold nothing."""
|
|
with open(path, encoding="utf-8") as f:
|
|
src = f.read()
|
|
m = re.search(r"pub const VERBS: &\[&str\] = &\[(.*?)\];", src, re.S)
|
|
verbs = set(re.findall(r'"([a-z0-9_]+)"', m.group(1))) if m else set()
|
|
if not verbs:
|
|
raise SystemExit(f"{path}: no operation verbs could be read (`pub const VERBS: &[&str] = &[...];`)")
|
|
m = re.search(r"pub const NEAR_SYNONYMS: &\[\(&str, &str\)\] = &\[(.*?)\];", src, re.S)
|
|
near = dict(re.findall(r'\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*\)', m.group(1))) if m else {}
|
|
if not near:
|
|
raise SystemExit(f"{path}: no near-synonyms could be read (`pub const NEAR_SYNONYMS: &[(&str, &str)] = &[...];`)")
|
|
return verbs, near
|
|
|
|
|
|
def split_id(i):
|
|
n, _, v = i.partition(".")
|
|
return n, v
|
|
|
|
|
|
def check_mcp(root, ops, findings, notes):
|
|
path = os.path.join(root, MCP_TABLE)
|
|
if not os.path.exists(path):
|
|
notes.append("MCP: no MCP door yet (no tool table); nothing to hold")
|
|
return
|
|
with open(path, encoding="utf-8") as f:
|
|
src = f.read()
|
|
# A tool is named outright (`name: "noun_verb"`) or by its operation (`op: "noun.verb"`),
|
|
# its name then the operation's with `_` for the dot.
|
|
names = []
|
|
for body in re.findall(r"\bTool\s*\{(.*?)\}\s*,", src, re.S):
|
|
named = re.search(r'\bname:\s*"([^"]+)"', body)
|
|
by_op = re.search(r'\bop:\s*"([^"]+)"', body)
|
|
if named:
|
|
names.append(named.group(1))
|
|
elif by_op:
|
|
names.append(by_op.group(1).replace(".", "_", 1))
|
|
if not names:
|
|
findings.append(f"MCP: {MCP_TABLE}: no tool name could be read (`Tool {{ name: \"…\" }}` or `Tool {{ op: \"…\" }}`)")
|
|
return
|
|
bad = 0
|
|
for t in names:
|
|
if t.count("_") != 1:
|
|
findings.append(f"MCP: tool {t!r} is not noun_verb of an operation")
|
|
bad += 1
|
|
continue
|
|
i = t.replace("_", ".")
|
|
if i not in ops:
|
|
findings.append(f"MCP: tool {t!r} names {i}, which the catalogue does not hold")
|
|
bad += 1
|
|
if not bad:
|
|
notes.append(f"MCP: {len(names)} tools, each an operation of the catalogue")
|
|
|
|
|
|
def openapi_operations(doc):
|
|
for path, item in (doc.get("paths") or {}).items():
|
|
for method, op in item.items():
|
|
if method.lower() in ("get", "put", "post", "delete", "patch", "head", "options", "trace") and isinstance(op, dict):
|
|
yield method.upper(), path, op.get("operationId")
|
|
|
|
|
|
def check_openapi(root, ops, findings, notes):
|
|
files = sorted({f for g in OPENAPI_GLOBS for f in glob.glob(os.path.join(root, g))})
|
|
if not files:
|
|
notes.append("HTTP API: no OpenAPI document yet; nothing to hold")
|
|
return
|
|
for f in files:
|
|
rel = os.path.relpath(f, root)
|
|
with open(f, encoding="utf-8") as fh:
|
|
doc = json.load(fh)
|
|
n = bad = 0
|
|
for method, path, oid in openapi_operations(doc):
|
|
n += 1
|
|
where = f"HTTP API: {rel}: {method} {path}"
|
|
if not oid:
|
|
findings.append(f"{where}: no operationId")
|
|
bad += 1
|
|
elif oid not in ops:
|
|
findings.append(f"{where}: operationId {oid!r} is not an operation of the catalogue")
|
|
bad += 1
|
|
else:
|
|
o = ops[oid]
|
|
if o["placement"] not in ("service", "both"):
|
|
findings.append(f"{where}: {oid} is placed {o['placement']}, not on a service")
|
|
bad += 1
|
|
r = o.get("route")
|
|
if r and (r["method"].upper(), r["path"]) != (method, path):
|
|
findings.append(f"{where}: the catalogue routes {oid} to {r['method']} {r['path']}")
|
|
bad += 1
|
|
if not bad:
|
|
notes.append(f"HTTP API: {rel}: {n} operations, each an operation of the catalogue at its route")
|
|
|
|
|
|
def held_name(door, where, noun, verb, nouns, ops, findings):
|
|
"""A name in `noun`'s namespace with `verb`: a finding when it names an operation the
|
|
catalogue does not hold, or uses a near-synonym. True when it names an operation."""
|
|
if noun not in nouns:
|
|
return False
|
|
if verb in NEAR:
|
|
findings.append(f"{door}: {where}: the verb {verb} is a near-synonym; the operation verb is {NEAR[verb]}")
|
|
return True
|
|
if verb not in VERBS:
|
|
return False
|
|
i = f"{noun}.{verb}"
|
|
if i not in ops:
|
|
near = sorted(o for o in ops if split_id(o)[1] == verb)
|
|
hint = f" (operations with that verb: {', '.join(near)})" if near else ""
|
|
findings.append(f"{door}: {where} names {i}, which the catalogue does not hold{hint}")
|
|
return True
|
|
|
|
|
|
def check_c(root, ops, nouns, findings, notes):
|
|
path = os.path.join(root, C_HEADER)
|
|
if not os.path.exists(path):
|
|
notes.append("C: no header; nothing to hold")
|
|
return
|
|
with open(path, encoding="utf-8") as f:
|
|
src = f.read()
|
|
fns = sorted(set(re.findall(r"\b(wai_[a-z0-9_]+)\s*\(", src)))
|
|
mapped = other = 0
|
|
before = len(findings)
|
|
for fn in fns:
|
|
parts = fn.split("_")[1:]
|
|
if len(parts) >= 2 and held_name("C", fn, parts[0], parts[1], nouns, ops, findings):
|
|
mapped += 1
|
|
else:
|
|
other += 1
|
|
if len(findings) == before:
|
|
if mapped:
|
|
notes.append(f"C: {mapped} functions name operations of the catalogue, each held; {other} are library API")
|
|
else:
|
|
notes.append(f"C: no operation of the catalogue in this door yet; its {other} functions are library API (codecs, envelopes, the sink)")
|
|
|
|
|
|
def camel_verb(name):
|
|
m = re.match(r"[a-z]+", name)
|
|
return m.group(0) if m else ""
|
|
|
|
|
|
TYPE_RE = {
|
|
"Swift": re.compile(r"^\s*(?:@\w+\s+)*(public|open)\s+(?:final\s+)?(?:class|struct|enum|actor)\s+(\w+)|^\s*(?:public\s+)?extension\s+(\w+)"),
|
|
"Kotlin": re.compile(r"^\s*(?!private|internal)(?:public\s+)?(?:open\s+|abstract\s+|sealed\s+|data\s+|enum\s+|final\s+)*(?:class|object|interface)\s+(\w+)"),
|
|
}
|
|
FUNC_RE = {
|
|
"Swift": re.compile(r"^\s*(?:@\w+\s+)*(?:public|open)\s+(?:static\s+|class\s+|final\s+|override\s+)*func\s+(\w+)"),
|
|
"Kotlin": re.compile(r"^\s*(?!.*\b(?:private|internal|protected)\b)(?:public\s+|override\s+|suspend\s+|inline\s+|operator\s+)*fun\s+(?:<[^>]*>\s*)?(\w+)"),
|
|
}
|
|
|
|
|
|
def sdk_names(lang, src):
|
|
"""Every (type, method) the source declares public: a method under the type whose body
|
|
holds it, a companion object's under its class. A type's body opens at the first line
|
|
after its declaration that ends with `{` (a Kotlin primary constructor can take several
|
|
lines, and a default argument can hold a lambda's braces)."""
|
|
out = []
|
|
stack = [] # (type name, brace depth inside its body)
|
|
depth = 0
|
|
pending = None
|
|
for line in src.splitlines():
|
|
code = re.sub(r'"(?:\\.|[^"\\])*"', '""', line.split("//")[0])
|
|
name = None
|
|
t = TYPE_RE[lang].match(code)
|
|
if t:
|
|
name = next(g for g in t.groups() if g and g not in ("public", "open"))
|
|
elif lang == "Kotlin" and re.match(r"^\s*companion\s+object\b", code) and stack:
|
|
name = stack[-1][0]
|
|
f = FUNC_RE[lang].match(code)
|
|
if f and stack:
|
|
out.append((stack[-1][0], f.group(1)))
|
|
if name:
|
|
pending = name
|
|
if pending and code.rstrip().endswith("{"):
|
|
stack.append((pending, depth + 1))
|
|
pending = None
|
|
for ch in code:
|
|
if ch == "{":
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
while stack and depth < stack[-1][1]:
|
|
stack.pop()
|
|
return out
|
|
|
|
|
|
def check_sdk(lang, root, pattern, ops, nouns, findings, notes):
|
|
files = sorted(glob.glob(os.path.join(root, pattern), recursive=True))
|
|
if not files:
|
|
notes.append(f"{lang}: no sources; nothing to hold")
|
|
return
|
|
mapped = other = 0
|
|
before = len(findings)
|
|
for f in files:
|
|
with open(f, encoding="utf-8") as fh:
|
|
src = fh.read()
|
|
for typ, meth in sdk_names(lang, src):
|
|
if not typ.startswith("Wai") or len(typ) <= 3:
|
|
other += 1
|
|
continue
|
|
noun = typ[3:].lower()
|
|
where = f"{os.path.relpath(f, root)}: {typ}.{meth}"
|
|
if held_name(lang, where, noun, camel_verb(meth), nouns, ops, findings):
|
|
mapped += 1
|
|
else:
|
|
other += 1
|
|
if len(findings) == before:
|
|
if mapped:
|
|
notes.append(f"{lang}: {mapped} methods name operations of the catalogue, each held; {other} are library API")
|
|
else:
|
|
notes.append(f"{lang}: no operation of the catalogue in this door yet; its {other} public names are the native sink's API, whose nouns the catalogue does not hold")
|
|
|
|
|
|
def main(argv):
|
|
root, ops_path = ".", None
|
|
i = 0
|
|
while i < len(argv):
|
|
if argv[i] == "--root" and i + 1 < len(argv):
|
|
root = argv[i + 1]
|
|
i += 2
|
|
elif argv[i] == "--ops" and i + 1 < len(argv):
|
|
ops_path = argv[i + 1]
|
|
i += 2
|
|
else:
|
|
print(__doc__.strip().splitlines()[0], file=sys.stderr)
|
|
print("usage: check-wai-ops-doors.py [--root DIR] [--ops FILE]", file=sys.stderr)
|
|
return 2
|
|
ops = load_ops(ops_path or os.path.join(root, "wai/catalogue/ops.json"))
|
|
global VERBS, NEAR
|
|
VERBS, NEAR = load_verbs(os.path.join(root, CATALOGUE))
|
|
nouns = {split_id(i)[0] for i in ops}
|
|
findings, notes = [], []
|
|
check_mcp(root, ops, findings, notes)
|
|
check_openapi(root, ops, findings, notes)
|
|
check_c(root, ops, nouns, findings, notes)
|
|
check_sdk("Swift", root, SWIFT_GLOB, ops, nouns, findings, notes)
|
|
check_sdk("Kotlin", root, KOTLIN_GLOB, ops, nouns, findings, notes)
|
|
for n in notes:
|
|
print(f"ok {n}")
|
|
for f in findings:
|
|
print(f"FAIL {f}")
|
|
print(f"check-wai-ops-doors: {len(ops)} operations, {len(VERBS)} verbs; {len(findings)} finding(s)")
|
|
return 1 if findings else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|