Files
ausboss-ComfyUI-AusBoss/scripts/validate_nodes.py
T
ausboss 63125fa021 Close the gaps the registry contract still left open
Four holes. Two empty literals agree with each other, so a module that
registers nothing passed every rule. A del could retire a node the
scanner goes on advertising - the mirror image of the update() case
already covered. An alias made the mutation invisible: _registry =
NODE_CLASS_MAPPINGS then _registry.update(...) never says the mapping
name at all. And the permanent-id check grepped the whole file for
AUSBOSS_NODES_*, so a key renamed in the literal kept passing as long as
the old id survived in a docstring or a search alias.

The contract is now that a mapping is assigned once to a non-empty dict
literal and its name is never used again. That is what makes the rest
enforceable: the alias is refused where it is made, the last point
anything static can still see it, rather than chased through code no
checker can follow. Permanent ids are measured against the keys parsed
out of the literal.

bad_empty, bad_delete and bad_alias join the fixtures; each one was
dropped into nodes/ and confirmed to exit 1 on its own line.
2026-08-14 08:33:56 -05:00

134 lines
5.2 KiB
Python

#!/usr/bin/env python
"""Offline sanity checks for the node pack — no ComfyUI required.
Run from anywhere: python scripts/validate_nodes.py
Checks:
1. Every .py file in the pack compiles.
2. Every nodes/node_*.py keeps the registry contract from
scripts/registry_contract.py: both mappings assigned exactly once, at
module level, to a non-empty dictionary literal with string-literal
keys, never mentioned again afterwards, and carrying the same keys as
each other. Registry scanners parse the source rather than importing
it, so anything they cannot read statically makes the node invisible.
3. Every module listed in NODE_MODULES in __init__.py has a file on
disk, every node file is listed in NODE_MODULES (no orphans), and no
mapping key is claimed by two modules.
4. Permanent public node IDs are still registered - measured against the
mapping keys parsed in check 2, not against the text of the file - and
each public transform node declares exactly IMAGE then MASK outputs.
Exit code 0 = all good, 1 = problems printed below.
"""
import ast
import pathlib
import py_compile
import re
import sys
ROOT = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from registry_contract import (
class_mapping_keys,
duplicate_key_problems,
mapping_problems,
)
errors = []
warnings = []
PUBLIC_NODE_IDS = {
"AUSBOSS_NODES_Compare",
"AUSBOSS_NODES_CropForInpaint",
"AUSBOSS_NODES_FrameChooser",
"AUSBOSS_NODES_ImageCropRotatePad",
"AUSBOSS_NODES_LaMaInpaint",
"AUSBOSS_NODES_LoadVideo",
"AUSBOSS_NODES_LoraLoader",
"AUSBOSS_NODES_RefineMask",
"AUSBOSS_NODES_SaveVideo",
"AUSBOSS_NODES_SelectFrame",
"AUSBOSS_NODES_SelectFrameRange",
"AUSBOSS_NODES_VideoBundle",
"AUSBOSS_NODES_VideoBundleEdit",
"AUSBOSS_NODES_StitchInpaint",
"AUSBOSS_NODES_VideoCropRotatePad",
"AUSBOSS_NODES_VideoUnbundle",
}
# --- 1. everything compiles --------------------------------------------------
for path in sorted(ROOT.rglob("*.py")):
if "__pycache__" in path.parts:
continue
try:
py_compile.compile(str(path), doraise=True)
except py_compile.PyCompileError as exc:
errors.append(f"syntax error: {path.relative_to(ROOT)}\n {exc.msg}")
# --- 2. the registry contract each node module must keep --------------------
node_files = sorted((ROOT / "nodes").glob("node_*.py"))
keys_by_module = {}
for path in node_files:
source = path.read_text(encoding="utf-8")
errors.extend(mapping_problems(source, path.name))
keys_by_module[path.name] = class_mapping_keys(source)
# Two modules claiming one key is a silent drop: the last import wins.
errors.extend(duplicate_key_problems(keys_by_module))
# --- 3. NODE_MODULES list matches the files on disk --------------------------
init_text = (ROOT / "__init__.py").read_text(encoding="utf-8")
listed = set(re.findall(r'"(node_\w+)"', init_text))
on_disk = {path.stem for path in node_files}
for missing in sorted(listed - on_disk):
errors.append(f"__init__.py lists {missing} but nodes/{missing}.py does not exist")
for orphan in sorted(on_disk - listed):
warnings.append(f"nodes/{orphan}.py exists but is not listed in NODE_MODULES")
# --- 4. permanent transform contracts ---------------------------------------
# The keys parsed out of NODE_CLASS_MAPPINGS in check 2, not every
# AUSBOSS_NODES_* string in the file: a docstring, a comment or a search
# alias mentioning an id is not a registration, and matching those would let
# a node lose its mapping entry while this check kept passing.
mapping_keys = set().union(*keys_by_module.values()) if keys_by_module else set()
for path in node_files:
text = path.read_text(encoding="utf-8")
if path.stem in {"node_image_crop_rotate_pad", "node_video_crop_rotate_pad"}:
tree = ast.parse(text)
return_types = None
return_names = None
for node in ast.walk(tree):
if not isinstance(node, ast.Assign):
continue
for target in node.targets:
if isinstance(target, ast.Name) and target.id in {"RETURN_TYPES", "RETURN_NAMES"}:
try:
value = ast.literal_eval(node.value)
except Exception:
value = None
if target.id == "RETURN_TYPES":
return_types = value
else:
return_names = value
if return_types != ("IMAGE", "MASK"):
errors.append(f"{path.name}: RETURN_TYPES must be exactly ('IMAGE', 'MASK')")
if return_names != ("image", "mask"):
errors.append(f"{path.name}: RETURN_NAMES must be exactly ('image', 'mask')")
for missing_id in sorted(PUBLIC_NODE_IDS - mapping_keys):
errors.append(f"missing permanent mapping key: {missing_id}")
# --- report ------------------------------------------------------------------
for warning in warnings:
print(f"WARN {warning}")
for error in errors:
print(f"ERROR {error}")
if errors:
print(f"\n{len(errors)} problem(s) found.")
sys.exit(1)
print(f"OK - {len(node_files)} node file(s) validated.")