File size: 11,648 Bytes
e488ce7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
#!/usr/bin/env python3
"""Widget order is a compatibility surface: every node's INPUT_TYPES vs the
last published release.

  python tests/test_widget_order_vs_release.py
      Synthetic, no GPU, no models, no ComfyUI running (only a ComfyUI
      checkout on the path: COMFYUI_DIR, default /mnt/work/ai/apps/ComfyUI).

  python tests/test_widget_order_vs_release.py --regen
      REGENERATE THE BASELINE FIXTURE from the published tag. Extracts the
      tag's tree with `git show published-1.0.6:<file>` into a temp dir
      (the tag is NEVER checked out into a live tree), imports that pack,
      and rewrites tests/fixtures/widget_order_1.0.6.json. Do this only
      when a NEW release tag becomes the baseline: pass --tag published-X.Y.Z
      and commit the new fixture with the release.

WHY: a saved workflow stores widget VALUES positionally, in required-then-
optional declaration order. Insert an input anywhere but the end and every
older workflow silently shifts one slot - the values land on the wrong
widgets and the render is wrong without an error. So the pack's rule is
append-only.

What it checks, for every node id present in BOTH the baseline and the
current tree:

  1. REQUIRED is unchanged and in order. A new required key is a violation
     too, not just a reorder: required widgets precede optional ones, so
     appending one shifts every optional widget of that node.
  2. OPTIONAL is the baseline list as a prefix, plus appended keys only.
  3. A node that is in the baseline and GONE from the current tree FAILS
     loudly. Removing a published node is a release decision, never a
     silent default (it also fires if a module failed to import - the
     loader keeps the pack alive when an alpha module raises).
  4. A brand-new node id passes and is listed as a notice.
  Type changes on a shared key and hidden-input changes are NOTICES, not
  failures: they do not move widget slots.

Exit code 0 = pass.
"""
import argparse
import importlib.util
import json
import os
import subprocess
import sys
import tempfile
import time

HERE = os.path.dirname(os.path.abspath(__file__))
PACK = os.path.dirname(HERE)
FIXTURE_DIR = os.path.join(HERE, "fixtures")
DEFAULT_TAG = "published-1.0.6"
COMFY = os.environ.get("COMFYUI_DIR", "/mnt/work/ai/apps/ComfyUI")

# What a pack needs to import. Media, docs and the web assets are not code
# and would only slow the extraction down.
EXTRACT_SUFFIXES = (".py", ".json", ".toml")
EXTRACT_SKIP_DIRS = ("assets/", "examples/", "docs/", "web/", "tests/")

FAILS = []
NOTICES = []


def check(name, ok, detail=""):
    print(("  PASS  " if ok else "  FAIL  ") + name + ("  " + detail if detail else ""))
    if not ok:
        FAILS.append(name)


def _type_name(spec):
    """A stable, printable name for an input's declared type."""
    if not isinstance(spec, (tuple, list)) or not spec:
        return repr(spec)[:32]
    t = spec[0]
    if isinstance(t, str):
        return t
    if isinstance(t, (list, tuple)):
        return "COMBO"          # choices are runtime file lists; not stable
    return type(t).__name__


def load_pack(root, modname):
    """Import a pack directory AS a package and return its NODE_CLASS_MAPPINGS.

    The directory name is not an importable identifier and the modules use
    relative imports, so bind the root to a name of our choosing.
    """
    if COMFY not in sys.path:
        sys.path.insert(0, COMFY)
    spec = importlib.util.spec_from_file_location(
        modname, os.path.join(root, "__init__.py"),
        submodule_search_locations=[root])
    mod = importlib.util.module_from_spec(spec)
    sys.modules[modname] = mod
    spec.loader.exec_module(mod)
    return mod.NODE_CLASS_MAPPINGS


def snapshot(mappings):
    """node id -> its INPUT_TYPES key order, per section."""
    out = {}
    for node_id, cls in mappings.items():
        t = cls.INPUT_TYPES()
        rec = {"class": f"{cls.__module__.split('.', 1)[-1]}.{cls.__name__}"}
        for section in ("required", "optional", "hidden"):
            d = t.get(section) or {}
            rec[section] = list(d)
            if section != "hidden":
                rec[section + "_types"] = {k: _type_name(v) for k, v in d.items()}
        out[node_id] = rec
    return out


def extract_tag(tag, dest):
    """Materialize the tag's code with `git show`, without touching a tree."""
    names = subprocess.run(["git", "-C", PACK, "ls-tree", "-r", "--name-only", tag],
                           capture_output=True, text=True, check=True).stdout.split()
    n = 0
    for name in names:
        if not name.endswith(EXTRACT_SUFFIXES):
            continue
        if any(name.startswith(d) for d in EXTRACT_SKIP_DIRS):
            continue
        blob = subprocess.run(["git", "-C", PACK, "show", f"{tag}:{name}"],
                              capture_output=True, check=True).stdout
        path = os.path.join(dest, name)
        os.makedirs(os.path.dirname(path), exist_ok=True)
        with open(path, "wb") as fh:
            fh.write(blob)
        n += 1
    return n


def regen(tag, fixture):
    sha = subprocess.run(["git", "-C", PACK, "rev-list", "-n1", tag],
                         capture_output=True, text=True, check=True).stdout.strip()
    with tempfile.TemporaryDirectory(prefix="widget-order-") as tmp:
        n = extract_tag(tag, tmp)
        print(f"extracted {n} files of {tag} ({sha[:12]}) to {tmp}")
        nodes = snapshot(load_pack(tmp, "mainodes_baseline"))
    doc = {
        "_comment": ("Baseline INPUT_TYPES key order per node at the published "
                     "tag. Regenerate ONLY when a new release becomes the "
                     "baseline: python tests/test_widget_order_vs_release.py "
                     "--regen --tag <published-X.Y.Z>"),
        "tag": tag,
        "commit": sha,
        "generated_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        "node_count": len(nodes),
        "nodes": dict(sorted(nodes.items())),
    }
    os.makedirs(FIXTURE_DIR, exist_ok=True)
    with open(fixture, "w") as fh:
        json.dump(doc, fh, indent=1, sort_keys=False)
        fh.write("\n")
    print(f"wrote {fixture}: {len(nodes)} nodes at {tag}")


def audit(fixture):
    with open(fixture) as fh:
        base = json.load(fh)
    baseline = base["nodes"]
    print(f"baseline: {base['tag']} ({base['commit'][:12]}), "
          f"{len(baseline)} nodes, generated {base['generated_utc']}")
    current = snapshot(load_pack(PACK, "mainodes_current"))
    print(f"current : {PACK}, {len(current)} nodes")
    print()

    shared = sorted(set(baseline) & set(current))
    gone = sorted(set(baseline) - set(current))
    new = sorted(set(current) - set(baseline))

    rows = []
    for node_id in shared:
        b, c = baseline[node_id], current[node_id]
        req_ok = b["required"] == c["required"]
        req_prefix = c["required"][:len(b["required"])] == b["required"]
        added_req = c["required"][len(b["required"]):] if req_prefix else []
        opt_prefix = c["optional"][:len(b["optional"])] == b["optional"]
        added_opt = c["optional"][len(b["optional"]):] if opt_prefix else []
        ok = req_ok and opt_prefix
        status = ("OK" if not (added_opt or added_req) else "OK +append") if ok else "VIOLATION"
        rows.append((node_id, status, len(b["required"]), len(c["required"]),
                     len(b["optional"]), len(c["optional"]),
                     added_req, added_opt, req_ok, req_prefix, opt_prefix, b, c))
        if b["hidden"] != c["hidden"]:
            NOTICES.append(f"{node_id}: hidden {b['hidden']} -> {c['hidden']}")
        for section in ("required", "optional"):
            bt, ct = b.get(section + "_types", {}), c.get(section + "_types", {})
            for k in b[section]:
                if k in ct and bt.get(k) != ct[k]:
                    NOTICES.append(f"{node_id}: {section} '{k}' type "
                                   f"{bt.get(k)} -> {ct[k]}")

    print(f"PER-NODE TABLE ({len(shared)} shared, {len(new)} new, {len(gone)} gone)")
    head = (f"{'node id':<26} {'status':<11} {'req':>7} {'opt':>7}  appended")
    print(head)
    print("-" * len(head))
    for (node_id, status, br, cr, bo, co, added_req, added_opt, *_rest) in rows:
        tail = []
        if added_req:
            tail.append("required+" + ",".join(added_req))
        if added_opt:
            tail.append("optional+" + ",".join(added_opt))
        print(f"{node_id:<26} {status:<11} {br:>3}->{cr:<3} {bo:>3}->{co:<3}  "
              + ("; ".join(tail) if tail else "-"))
    for node_id in new:
        c = current[node_id]
        print(f"{node_id:<26} {'NEW':<11} {'-':>3}->{len(c['required']):<3} "
              f"{'-':>3}->{len(c['optional']):<3}  brand new since {base['tag']}")
    for node_id in gone:
        b = baseline[node_id]
        print(f"{node_id:<26} {'GONE':<11} {len(b['required']):>3}->-   "
              f"{len(b['optional']):>3}->-    was in {base['tag']}")
    print()

    print("1. REQUIRED unchanged and in order")
    for row in rows:
        node_id, req_ok, req_prefix, b, c = row[0], row[8], row[9], row[11], row[12]
        if req_ok:
            continue
        kind = ("APPENDED required keys (they shift every optional widget)"
                if req_prefix else "REORDERED or REMOVED required keys")
        check(f"{node_id}: required keys unchanged and in order", False,
              f"{kind}: {b['required']} -> {c['required']}")
    if all(r[8] for r in rows):
        print(f"  PASS  all {len(rows)} shared nodes: required identical")

    print("\n2. OPTIONAL is baseline prefix + appends only")
    for row in rows:
        node_id, b, c = row[0], row[11], row[12]
        if row[10]:
            continue
        check(f"{node_id}: optional is the baseline order plus a tail",
              False, f"{b['optional']} -> {c['optional']}")
    if all(r[10] for r in rows):
        print(f"  PASS  all {len(rows)} shared nodes: optional prefix intact")
    appended = [(r[0], r[7]) for r in rows if r[7]]
    for node_id, keys in appended:
        print(f"        {node_id} appended {keys}")

    print("\n3. NO PUBLISHED NODE DISAPPEARED")
    check(f"every node id in {base['tag']} is still registered",
          not gone, ", ".join(gone) if gone else f"{len(baseline)} ids")

    print("\n4. BRAND-NEW NODES (notice only)")
    if new:
        for node_id in new:
            print(f"        {node_id}  ({current[node_id]['class']}, "
                  f"{len(current[node_id]['required'])} required / "
                  f"{len(current[node_id]['optional'])} optional)")
    else:
        print("        none")

    print("\nNOTICES (type / hidden changes; no widget slot moves)")
    for n in NOTICES or ["        none"]:
        print(f"        {n}" if n.strip() != "none" else n)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--regen", action="store_true",
                    help="rewrite the baseline fixture from the tag")
    ap.add_argument("--tag", default=DEFAULT_TAG)
    args = ap.parse_args()
    fixture = os.path.join(FIXTURE_DIR,
                           "widget_order_%s.json" % args.tag.replace("published-", ""))
    if args.regen:
        regen(args.tag, fixture)
        return 0
    if not os.path.exists(fixture):
        print(f"MISSING FIXTURE {fixture} - regenerate with --regen --tag {args.tag}")
        return 1
    audit(fixture)
    print()
    if FAILS:
        print(f"FAILED: {len(FAILS)} check(s): " + ", ".join(FAILS))
        return 1
    print("all checks passed")
    return 0


if __name__ == "__main__":
    sys.exit(main())