215 lines
7.6 KiB
Python
215 lines
7.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Node metadata consistency checker (single-source-of-truth guard).
|
|
|
|
nodes/**/*.py is the single source of truth for every node's node_id
|
|
and display_name. The README node lists and web/docs/<node_id>/ are
|
|
derived views, so this script fails whenever those views drift apart:
|
|
|
|
* node_id and display_name must be unique across the repository;
|
|
* every node must ship web/docs/<node_id>/en.md and .../zh.md and the
|
|
display_name must be mentioned in both;
|
|
* web/docs must not keep an orphan directory for a removed node;
|
|
* the README node tables must list exactly the current display_name set,
|
|
with no duplicate rows;
|
|
* the pyproject version must equal the newest changelog entry in both
|
|
READMEs, so a release has exactly one version authority;
|
|
* display_name should follow the spaced model/version convention
|
|
(reported as a warning only, never fatal).
|
|
|
|
Run from anywhere:
|
|
|
|
python scripts/check_node_consistency.py
|
|
|
|
The exit code is non-zero when a hard error is found, so it can gate CI.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
NODES_DIR = ROOT / "nodes"
|
|
DOCS_DIR = ROOT / "web" / "docs"
|
|
|
|
# README node-list section heading -> file
|
|
READMES = {
|
|
"README.md": "## \U0001F4CB Node List",
|
|
"README.ZH_CN.md": "## \U0001F4CB \u8282\u70b9\u5217\u8868",
|
|
}
|
|
|
|
# README changelog section heading -> file
|
|
CHANGELOGS = {
|
|
"README.md": "## \U0001F4DC Changelog",
|
|
"README.ZH_CN.md": "## \U0001F4DC \u66f4\u65b0\u65e5\u5fd7",
|
|
}
|
|
|
|
PYPROJECT = ROOT / "pyproject.toml"
|
|
|
|
NODE_ID_RE = re.compile(r'node_id="([^"]+)"')
|
|
DISPLAY_NAME_RE = re.compile(r'display_name="([^"]+)"')
|
|
SEPARATOR_RE = re.compile(r":?-{2,}:?")
|
|
VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE)
|
|
CHANGELOG_VERSION_RE = re.compile(r"^\*\*v([0-9][0-9A-Za-z.\-]*)\*\*$")
|
|
|
|
# display_name patterns that violate the spaced naming convention
|
|
CAMEL_RE = re.compile(r"[a-z][A-Z]")
|
|
GLUED_VERSION_RE = re.compile(r"[A-Za-z]\d")
|
|
|
|
# Intentional compact names that are not model+version tokens. Keep this list
|
|
# explicit so every exception is a conscious decision, not a silent oversight.
|
|
COMPACT_NAME_ALLOWLIST = {
|
|
"Image PingPong", # compound word, not a model name
|
|
"Mask to SAM3 Box", # "SAM3" is the model's common compact spelling
|
|
}
|
|
|
|
|
|
def collect_nodes():
|
|
"""Return (node_id, display_name, path) for every node definition."""
|
|
nodes = []
|
|
for path in sorted(NODES_DIR.rglob("*.py")):
|
|
if path.name == "__init__.py":
|
|
continue
|
|
text = path.read_text(encoding="utf-8")
|
|
id_match = NODE_ID_RE.search(text)
|
|
if not id_match:
|
|
continue
|
|
# Only accept a display_name from the same Schema block, i.e. the
|
|
# first one after node_id= (this skips IO port display names).
|
|
name_match = DISPLAY_NAME_RE.search(text, id_match.end())
|
|
if not name_match:
|
|
continue
|
|
nodes.append((id_match.group(1), name_match.group(1), path))
|
|
return nodes
|
|
|
|
|
|
def table_names(readme, heading):
|
|
"""Return the first column of every data row in the node-list section."""
|
|
names = []
|
|
in_section = False
|
|
for line in readme.read_text(encoding="utf-8").splitlines():
|
|
stripped = line.strip()
|
|
if stripped.startswith("## "):
|
|
in_section = stripped == heading
|
|
continue
|
|
if not in_section or not stripped.startswith("|"):
|
|
continue
|
|
cells = [cell.strip() for cell in stripped.split("|")]
|
|
if len(cells) < 4:
|
|
continue
|
|
name = cells[1]
|
|
if not name or name in {"Node Name", "\u8282\u70b9\u540d\u79f0"} or SEPARATOR_RE.fullmatch(name):
|
|
continue
|
|
names.append(name)
|
|
return names
|
|
|
|
|
|
def top_changelog_version(readme, heading):
|
|
"""Return the newest version in the README changelog section."""
|
|
in_section = False
|
|
for line in readme.read_text(encoding="utf-8").splitlines():
|
|
stripped = line.strip()
|
|
if stripped.startswith("## "):
|
|
in_section = stripped == heading
|
|
continue
|
|
if not in_section:
|
|
continue
|
|
match = CHANGELOG_VERSION_RE.match(stripped)
|
|
if match:
|
|
return match.group(1)
|
|
return None
|
|
|
|
|
|
def main():
|
|
errors = []
|
|
warnings = []
|
|
|
|
nodes = collect_nodes()
|
|
ids = [node_id for node_id, _, _ in nodes]
|
|
names = [display_name for _, display_name, _ in nodes]
|
|
|
|
for label, values in (("node_id", ids), ("display_name", names)):
|
|
counts = {}
|
|
for value in values:
|
|
counts[value] = counts.get(value, 0) + 1
|
|
for value, count in sorted(counts.items()):
|
|
if count > 1:
|
|
errors.append("duplicate %s: %r (%dx)" % (label, value, count))
|
|
|
|
for node_id, display_name, path in nodes:
|
|
doc_dir = DOCS_DIR / node_id
|
|
for locale in ("en.md", "zh.md"):
|
|
doc = doc_dir / locale
|
|
if not doc.is_file():
|
|
errors.append(
|
|
"missing docs: %s (node %s)"
|
|
% (doc.relative_to(ROOT), path.relative_to(ROOT))
|
|
)
|
|
elif display_name not in doc.read_text(encoding="utf-8"):
|
|
errors.append(
|
|
"docs %s does not mention display_name %r"
|
|
% (doc.relative_to(ROOT), display_name)
|
|
)
|
|
|
|
for readme_name, heading in READMES.items():
|
|
readme = ROOT / readme_name
|
|
listed = table_names(readme, heading)
|
|
missing = sorted(set(names) - set(listed))
|
|
unknown = sorted(set(listed) - set(names))
|
|
if missing:
|
|
errors.append("%s node table is missing: %s" % (readme_name, ", ".join(missing)))
|
|
if unknown:
|
|
errors.append(
|
|
"%s node table lists unknown nodes: %s" % (readme_name, ", ".join(unknown))
|
|
)
|
|
if len(listed) != len(set(listed)):
|
|
duplicates = sorted({name for name in listed if listed.count(name) > 1})
|
|
errors.append(
|
|
"%s node table has duplicate rows: %s"
|
|
% (readme_name, ", ".join(duplicates))
|
|
)
|
|
|
|
known_ids = set(ids)
|
|
for doc_dir in sorted(DOCS_DIR.iterdir()):
|
|
if doc_dir.is_dir() and doc_dir.name not in known_ids:
|
|
errors.append(
|
|
"orphan docs directory: %s (no node defines it)"
|
|
% doc_dir.relative_to(ROOT)
|
|
)
|
|
|
|
version_match = VERSION_RE.search(PYPROJECT.read_text(encoding="utf-8"))
|
|
if version_match is None:
|
|
errors.append("pyproject.toml has no version")
|
|
else:
|
|
version = version_match.group(1)
|
|
for readme_name, heading in CHANGELOGS.items():
|
|
top = top_changelog_version(ROOT / readme_name, heading)
|
|
if top is None:
|
|
errors.append("%s changelog has no versioned entry" % readme_name)
|
|
elif top != version:
|
|
errors.append(
|
|
"%s newest changelog v%s != pyproject version %s"
|
|
% (readme_name, top, version)
|
|
)
|
|
|
|
for _, display_name, _ in nodes:
|
|
if display_name in COMPACT_NAME_ALLOWLIST:
|
|
continue
|
|
if CAMEL_RE.search(display_name):
|
|
warnings.append("%r contains a camelCase token" % display_name)
|
|
if GLUED_VERSION_RE.search(display_name):
|
|
warnings.append("%r glues a version number to letters" % display_name)
|
|
|
|
print("nodes: %d" % len(nodes))
|
|
for warning in warnings:
|
|
print("WARN " + warning)
|
|
for error in errors:
|
|
print("ERROR " + error)
|
|
print("OK" if not errors else "FAILED (%d error(s))" % len(errors))
|
|
return 1 if errors else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|