Files
ausboss-ComfyUI-AusBoss/scripts/registry_contract.py
ausboss b4f985a8f4 Validate only the Python files a registry scanner reads
The compile check walked every .py under the checkout, gitignored
_scratch/ ports and nested checkouts included, and py_compile wrote a
__pycache__ folder next to each file it checked. It now compiles in
memory. It and the stray-mapping scan (and its test) share one file
list: tracked plus untracked-but-not-ignored files from git, or, when
git cannot answer for the tree (an installed copy), a walk that skips
dot-folders, caches and _scratch/.
2026-09-24 00:20:58 -05:00

333 lines
14 KiB
Python

"""The node registry contract, as a checkable rule set. Stdlib only.
ComfyUI-Manager and the Registry discover a pack's nodes by parsing its
source with an AST walk -- they never import it. Anything they cannot read
statically makes the node invisible: the pack still loads and works locally
while "install missing custom nodes" silently stops offering it for a shared
workflow. That failure is invisible from inside ComfyUI, so it is checked here
instead.
The rule is deliberately narrow. Each node module assigns each mapping exactly
once, at module level, to a non-empty dictionary literal whose keys are string
literals -- and then never mentions that name again. `dict(...)`, a helper's
return value, a later `update()`, an item assignment, a `del`, an empty
literal: every one of them reads to a scanner as "no keys here".
Not mentioning the name again is what makes the rest enforceable. A mapping
handed to something else -- `alias = NODE_CLASS_MAPPINGS` and then
`alias.update(...)`, or a helper called with the mapping as an argument --
can be changed anywhere, and no amount of static analysis will follow it. So
the alias is refused where it is created, which is the last point a checker
can still see what is happening. The one gap left is a module that reaches
its own globals by string (`globals()["NODE_CLASS_MAPPINGS"]`); that names
nothing this can read, and no node module has any reason to.
`mapping_problems` takes source text so it is testable against fixtures;
`duplicate_key_problems` compares the keys collected across modules;
`scanned_python_files` is the set of files a scanner reads in the first place.
"""
from __future__ import annotations
import ast
import subprocess
from pathlib import Path
MAPPING_NAMES = ("NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS")
# Methods that would change a mapping after its literal assignment.
_MUTATORS = frozenset({"update", "setdefault", "pop", "popitem", "clear", "__setitem__"})
def _describe(node: ast.AST) -> str:
"""A short, human-readable label for an unreadable key or value."""
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Call):
function = node.func
name = getattr(function, "id", None) or getattr(function, "attr", None)
return f"{name}(...)" if name else "a call"
if isinstance(node, ast.Attribute):
return node.attr
if isinstance(node, ast.Constant):
return repr(node.value)
return type(node).__name__
def _parent_map(tree: ast.AST) -> dict[int, ast.AST]:
"""Child -> parent across the module, so a mention of a mapping can be
judged by the statement it sits in."""
parents: dict[int, ast.AST] = {}
for node in ast.walk(tree):
for child in ast.iter_child_nodes(node):
parents[id(child)] = node
return parents
def _reference_problem(name: str, ref: ast.Name, parents: dict[int, ast.AST]) -> str:
"""Why this mention of a mapping outside its own assignment is refused.
They all end the same way -- a scanner sees only the literal -- but naming
the shape matters, because "aliased" and "deleted from" are different
mistakes to go and fix."""
parent = parents.get(id(ref))
grand = parents.get(id(parent)) if parent is not None else None
if isinstance(parent, ast.Attribute):
if parent.attr in _MUTATORS:
return f"{name}.{parent.attr}() changes it after assignment"
return f"{name}.{parent.attr} is used after assignment"
if isinstance(parent, ast.Subscript):
if isinstance(parent.ctx, ast.Del) or isinstance(grand, ast.Delete):
return f"{name} has an entry deleted after assignment"
if isinstance(parent.ctx, ast.Store):
return f"{name} has an entry assigned after assignment"
return f"{name} is indexed after assignment"
if isinstance(parent, ast.Delete):
return f"{name} is deleted after assignment"
if isinstance(parent, ast.AugAssign):
return f"{name} is extended after assignment"
if isinstance(parent, (ast.Assign, ast.AnnAssign)):
if parent.value is ref:
return (
f"{name} is aliased after assignment, and an alias can be "
"changed somewhere no scanner will follow"
)
return f"{name} is reassigned after assignment"
if isinstance(parent, (ast.Tuple, ast.List)) and isinstance(ref.ctx, ast.Store):
return f"{name} is assigned by unpacking after assignment"
return f"{name} is used after assignment"
def mapping_problems(source: str, label: str) -> list[str]:
"""Every way ``source`` breaks the registry contract, newest rule last."""
try:
tree = ast.parse(source)
except SyntaxError as exc:
return [f"{label}: cannot be parsed: {exc}"]
problems: list[str] = []
literals: dict[str, ast.Dict] = {}
parents = _parent_map(tree)
for name in MAPPING_NAMES:
# A conforming assignment: module level, single Name target, dict literal.
direct = [
node
for node in tree.body
if isinstance(node, ast.Assign)
and len(node.targets) == 1
and isinstance(node.targets[0], ast.Name)
and node.targets[0].id == name
]
if len(direct) != 1:
problems.append(
f"{label}: {name} must be assigned exactly once at module level "
f"as a dictionary literal (found {len(direct)})"
)
for extra in direct[1:]:
problems.append(f"{label}:{extra.lineno}: {name} is reassigned here")
if direct:
value = direct[0].value
if not isinstance(value, ast.Dict):
problems.append(
f"{label}:{direct[0].lineno}: {name} must be a dictionary "
f"literal, not {_describe(value)} - a scanner reads the "
"literal, it does not run the module"
)
else:
literals[name] = value
if not value.keys:
problems.append(
f"{label}:{direct[0].lineno}: {name} is empty - a module "
"listed in NODE_MODULES must declare at least one node, "
"and an empty literal reads exactly like a missing one"
)
seen: set[str] = set()
for key in value.keys:
if key is None: # {**other}
problems.append(
f"{label}:{direct[0].lineno}: {name} unpacks another "
"mapping; a scanner cannot resolve those keys"
)
continue
if not (isinstance(key, ast.Constant) and isinstance(key.value, str)):
problems.append(
f"{label}:{getattr(key, 'lineno', direct[0].lineno)}: "
f"{name} key {_describe(key)} must be a string literal "
"so registry scanners can find it"
)
continue
if key.value in seen:
problems.append(
f"{label}:{key.lineno}: {name} lists {key.value!r} twice"
)
seen.add(key.value)
# Every other mention of the name, anywhere in the module: a mutation,
# a del, an alias, or a plain read that says the module has plans for
# it. The targets of the module-level assignments are skipped - a
# second one is already reported as a reassignment above - and one
# complaint per line is enough to go and look.
assigned = {id(node.targets[0]) for node in direct}
references = sorted(
(
node
for node in ast.walk(tree)
if isinstance(node, ast.Name)
and node.id == name
and id(node) not in assigned
),
key=lambda node: (node.lineno, node.col_offset),
)
reported: set[int] = set()
for ref in references:
if ref.lineno in reported:
continue
reported.add(ref.lineno)
problems.append(
f"{label}:{ref.lineno}: {_reference_problem(name, ref, parents)}"
" - a scanner reads only the literal"
)
# The two mappings describe the same nodes, so they carry the same keys.
if len(literals) == 2:
keys = {
name: {
key.value
for key in literal.keys
if isinstance(key, ast.Constant) and isinstance(key.value, str)
}
for name, literal in literals.items()
}
classes, names = keys["NODE_CLASS_MAPPINGS"], keys["NODE_DISPLAY_NAME_MAPPINGS"]
for missing in sorted(classes - names):
problems.append(f"{label}: {missing} has no NODE_DISPLAY_NAME_MAPPINGS entry")
for missing in sorted(names - classes):
problems.append(f"{label}: {missing} has no NODE_CLASS_MAPPINGS entry")
return problems
def class_mapping_keys(source: str) -> set[str]:
"""The NODE_CLASS_MAPPINGS keys a conforming module declares.
This is what a scanner would find, so it is also what the permanent-ID
check must be measured against: a key named only in a docstring, a
comment or a search alias is not a registration.
"""
try:
tree = ast.parse(source)
except SyntaxError:
return set()
for node in tree.body:
if (
isinstance(node, ast.Assign)
and len(node.targets) == 1
and isinstance(node.targets[0], ast.Name)
and node.targets[0].id == "NODE_CLASS_MAPPINGS"
and isinstance(node.value, ast.Dict)
):
return {
key.value
for key in node.value.keys
if isinstance(key, ast.Constant) and isinstance(key.value, str)
}
return set()
def module_list(source: str, name: str = "NODE_MODULES") -> set[str] | None:
"""The module names a list literal declares, or None if it cannot be read.
Parsed, not grepped, for the same reason as the mapping keys: a name in a
docstring, a comment or a commented-out line is not a registration, and a
regex would count it as one. Returns None when the assignment is missing
or is not a literal, so the caller can say so instead of reporting an
empty list as though nothing were registered.
"""
try:
tree = ast.parse(source)
except SyntaxError:
return None
for node in tree.body:
if (
isinstance(node, ast.Assign)
and len(node.targets) == 1
and isinstance(node.targets[0], ast.Name)
and node.targets[0].id == name
):
try:
value = ast.literal_eval(node.value)
except (ValueError, SyntaxError):
return None
if not isinstance(value, (list, tuple, set)):
return None
return {item for item in value if isinstance(item, str)}
return None
def duplicate_key_problems(keys_by_label: dict[str, set[str]]) -> list[str]:
"""Mapping keys claimed by more than one module.
The last module imported wins the registration, so a collision silently
drops a node instead of failing.
"""
owners: dict[str, list[str]] = {}
for label, keys in keys_by_label.items():
for key in keys:
owners.setdefault(key, []).append(label)
return [
f"mapping key {key} is declared by {', '.join(sorted(labels))}"
for key, labels in sorted(owners.items())
if len(labels) > 1
]
# Folders no scanner ever sees: gitignored scratch and bytecode caches.
_LOCAL_DIRS = frozenset({"__pycache__", "_scratch"})
def git_paths(root: Path, *args: str) -> list[str] | None:
"""The paths ``git ls-files -z <args>`` lists for ``root``, or None.
None means git cannot answer for this tree: no git binary, no
repository, or ``root`` is a folder inside another repository (an
installed copy under ComfyUI's own checkout) rather than its top level.
"""
root = Path(root).resolve()
try:
top = subprocess.run(
["git", "-C", str(root), "rev-parse", "--show-toplevel"],
capture_output=True, check=True,
).stdout.decode("utf-8", "surrogateescape").strip()
if not top or Path(top).resolve() != root:
return None
listed = subprocess.run(
["git", "-C", str(root), "ls-files", "-z", *args],
capture_output=True, check=True,
).stdout
except (OSError, subprocess.CalledProcessError):
return None
return [name for name in listed.decode("utf-8", "surrogateescape").split("\0") if name]
def scanned_python_files(root: Path) -> list[Path]:
"""The .py files a registry scanner would read in this checkout.
With git: every tracked file plus every untracked one that is not
ignored, so a new node module counts before it is committed while
_scratch/, caches and nested checkouts never do. Without git (an
installed copy): a walk that skips dot-directories, caches and _scratch.
"""
root = Path(root).resolve()
listed = git_paths(root, "--cached", "--others", "--exclude-standard", "--", "*.py")
if listed is not None:
return sorted({root / name for name in listed if (root / name).is_file()})
return sorted(
path
for path in root.rglob("*.py")
if not any(
part.startswith(".") or part in _LOCAL_DIRS
for part in path.relative_to(root).parts
)
)