Compare commits

...

84 Commits

Author SHA1 Message Date
Ethanfel dd3e51301c Gate signature matches by feature intent 2026-07-03 21:52:21 +02:00
Ethanfel 9000b5500b Expand popular node signature artifact 2026-07-02 23:51:18 +02:00
Ethanfel 5dd37c859b Document popular node signatures 2026-07-02 23:29:52 +02:00
Ethanfel 5b511ef295 Add initial popular node signature artifact 2026-07-02 23:23:33 +02:00
Ethanfel d60fc5d14e Preserve nested manager metrics 2026-07-02 22:27:18 +02:00
Ethanfel c6c0551ae0 Fix search ranking priority order 2026-07-02 22:22:05 +02:00
Ethanfel 12d0f87968 Support top-level manager ranking fields 2026-07-02 22:18:10 +02:00
Ethanfel 33690683b7 Fix manager ranking and cache defaults 2026-07-02 22:09:50 +02:00
Ethanfel 28186698d0 Fix manager install URL normalization 2026-07-02 22:02:01 +02:00
Ethanfel 1895a0e677 Add popular node metadata build CLI 2026-07-02 21:57:31 +02:00
Ethanfel dddb136b16 Fail closed on plain definition decorators 2026-07-02 21:45:00 +02:00
Ethanfel f0b83b5505 Preserve empty static display names 2026-07-02 21:37:18 +02:00
Ethanfel a2a5b44436 Fail closed after arbitrary assignment calls 2026-07-02 21:28:47 +02:00
Ethanfel d7c3fc86c1 Invalidate static env after arbitrary calls 2026-07-02 21:19:10 +02:00
Ethanfel ee8496174f Fail closed on no-arg arbitrary calls 2026-07-02 21:09:10 +02:00
Ethanfel 126f5db959 Fail closed on nested mutable env aliases 2026-07-02 20:58:31 +02:00
Ethanfel f23d4ae69a Resolve class lookups through namespace aliases 2026-07-02 20:51:12 +02:00
Ethanfel 42aeafd0e9 Fail closed on starred mapping alias duplicate safety 2026-07-02 20:43:28 +02:00
Ethanfel 49666141fa Fail closed on dynamic mapping duplicate safety 2026-07-02 20:34:35 +02:00
Ethanfel 11acb12658 Fail closed on namespace mapping replacements 2026-07-02 20:28:53 +02:00
Ethanfel 75224982ba Fail closed on ambiguous mapping duplicate keys 2026-07-02 20:21:04 +02:00
Ethanfel ecd8f7c082 Make signature artifact timestamps deterministic 2026-07-02 20:10:09 +02:00
Ethanfel 25b3f69d0d Fail closed on class-body signature references 2026-07-02 20:02:55 +02:00
Ethanfel 447a5c72dd Track alias mapping keys in duplicate preflight 2026-07-02 19:53:45 +02:00
Ethanfel 9792989216 Fail closed on mapping mutation keys and bare input specs 2026-07-02 19:46:55 +02:00
Ethanfel 7e7479fb6a Fail closed on class namespace alias mutations 2026-07-02 19:35:51 +02:00
Ethanfel 2d951c759a Fail closed on class-body namespace aliases 2026-07-02 19:28:44 +02:00
Ethanfel 52ac447e0e Track namespace alias mapping mutations 2026-07-02 19:21:19 +02:00
Ethanfel c45bf3c230 Fail closed on class-body mutations and duplicate inputs 2026-07-02 19:14:53 +02:00
Ethanfel 1b56798018 Fail closed on definition references and sticky mappings 2026-07-02 19:04:25 +02:00
Ethanfel 39b991800a Track chained input type aliases 2026-07-02 18:54:16 +02:00
Ethanfel a3bb718bd2 Fail closed on duplicate keys and observed calls 2026-07-02 18:49:21 +02:00
Ethanfel 73bdca9e1e Track chained static extraction aliases 2026-07-02 18:37:17 +02:00
Ethanfel 3cf4a5eb52 Fail closed on namespace dunders and metadata types 2026-07-02 18:29:47 +02:00
Ethanfel f7143e7bac Fail closed on namespace aliases and input observations 2026-07-02 18:19:48 +02:00
Ethanfel bf46f9b389 Fail closed on duplicate nodes and observed input types 2026-07-02 18:08:48 +02:00
Ethanfel 3219ec0c39 Track starred collection aliases in static extraction 2026-07-02 17:59:53 +02:00
Ethanfel c6d2b2d645 Invalidate mapped classes on signature attribute observation 2026-07-02 17:53:04 +02:00
Ethanfel 07822bc3ec Fail closed on arbitrary static extraction calls 2026-07-02 17:47:11 +02:00
Ethanfel 79d9921ba6 Track starred unpack aliases in static extraction 2026-07-02 17:37:06 +02:00
Ethanfel 5844a0a433 Track namespace-derived class aliases 2026-07-02 17:30:00 +02:00
Ethanfel b560f238a1 Cover type parameter class extraction hazards 2026-07-02 17:23:58 +02:00
Ethanfel 7c4b83ed0e Track namespace and getattr aliases 2026-07-02 17:19:07 +02:00
Ethanfel 065c9ae7ec Skip nontrivial class creation signatures 2026-07-02 17:12:50 +02:00
Ethanfel 34e53e8692 Invalidate namespace lookup mutations 2026-07-02 17:07:25 +02:00
Ethanfel 97e0126a1d Detect getattr mutating method calls 2026-07-02 17:01:08 +02:00
Ethanfel 2ad3cd3a09 Invalidate dynamic namespace mutations 2026-07-02 16:55:52 +02:00
Ethanfel d1f49e7c95 Track mapping and class attribute aliases 2026-07-02 16:47:39 +02:00
Ethanfel f26e441e03 Fail closed on invalid node mapping keys 2026-07-02 16:39:06 +02:00
Ethanfel 2c9452ae67 Fail closed on invalid display mappings 2026-07-02 16:35:19 +02:00
Ethanfel 7e4e85a0bd Fail closed on dynamic patches and displays 2026-07-02 16:27:48 +02:00
Ethanfel 9752248ee9 Track unpacked and class attribute aliases 2026-07-02 16:12:32 +02:00
Ethanfel 05fa411d47 Fail closed on walrus bindings and invalid input sections 2026-07-02 16:00:05 +02:00
Ethanfel f5d72b494d Fail closed on decorated input types and nested aliases 2026-07-02 15:47:17 +02:00
Ethanfel 86ea12924c Invalidate mappings on duplicate keys and class aliases 2026-07-02 15:34:50 +02:00
Ethanfel 51db0d16e5 Skip decorated and patched mapped classes 2026-07-02 15:19:22 +02:00
Ethanfel fc92d1db24 Invalidate static extraction for exception and type alias bindings 2026-07-02 15:08:19 +02:00
Ethanfel 8f872baf0b Invalidate inputs from class definition-time mutations 2026-07-02 14:56:13 +02:00
Ethanfel 317788572e Detect definition-time mutating expressions 2026-07-02 14:50:03 +02:00
Ethanfel 65c3a57052 Resolve node mappings at assignment time 2026-07-02 14:40:16 +02:00
Ethanfel 45e3cbaad8 Detect mutating calls inside statements 2026-07-02 14:29:04 +02:00
Ethanfel 6c2653c803 Fail closed on final input bindings and transitive aliases 2026-07-02 14:18:38 +02:00
Ethanfel 99d2bb25da Fail closed on mutable module captures in class signatures 2026-07-02 14:09:07 +02:00
Ethanfel 034e07269d Fail closed on class alias mutations 2026-07-02 13:58:58 +02:00
Ethanfel bebd4e09aa Resolve input and class attribute environments correctly 2026-07-02 13:53:45 +02:00
Ethanfel 67b875cdd5 Require mapped classes to remain top-level bindings 2026-07-02 13:45:45 +02:00
Ethanfel 90b7e3e872 Fail closed on invalid return name declarations 2026-07-02 13:34:13 +02:00
Ethanfel 0456e8033a Fail closed on invalid return names 2026-07-02 13:29:51 +02:00
Ethanfel 479a70f377 Evaluate class signatures with definition-time env 2026-07-02 13:24:02 +02:00
Ethanfel c8c1205bde Skip extraction after wildcard imports 2026-07-02 13:14:16 +02:00
Ethanfel b2b1bd16bd Invalidate static extraction on nested wildcard imports 2026-07-02 13:06:51 +02:00
Ethanfel 2845daf90c Invalidate static extraction on annotated aliases and wildcard imports 2026-07-02 12:58:24 +02:00
Ethanfel fae0c312bc Invalidate static extraction on rebinding and alias mutation 2026-07-02 12:50:06 +02:00
Ethanfel 21a29b8846 Invalidate static extraction on deletion and mutation 2026-07-02 12:39:38 +02:00
Ethanfel 6be0fe9d1e Fail closed on dynamic node mapping extraction 2026-07-02 12:29:27 +02:00
Ethanfel 6a4617f002 Fail closed on dynamic static extraction inputs 2026-07-02 12:23:09 +02:00
Ethanfel 960c41b330 Harden static signature extraction 2026-07-02 12:13:50 +02:00
Ethanfel 669f209930 Add popular node signature extractor 2026-07-02 12:06:19 +02:00
Ethanfel bf2f6f3b95 Respect serialized input names over generated signatures 2026-07-02 11:56:28 +02:00
Ethanfel 9c17083298 Harden generated signature matching 2026-07-02 11:47:07 +02:00
Ethanfel 38c142d42e Use generated signatures for missing node matching 2026-07-02 11:38:49 +02:00
Ethanfel 8c45016c00 Add generated signature loader 2026-07-02 11:29:54 +02:00
Ethanfel 55e59f5e1d Add popular node signature implementation plan 2026-07-02 11:23:29 +02:00
Ethanfel fef2b8b7f1 Add popular node signature design 2026-07-02 11:09:37 +02:00
12 changed files with 32593 additions and 24 deletions
+28 -4
View File
@@ -34,6 +34,15 @@ Both "Replace…" and the right-click item work on them; the bulk dialog labels
them `⚠ not installed`. (Widget values aren't carried for a node whose them `⚠ not installed`. (Widget values aren't carried for a node whose
definition you don't have — links are.) definition you don't have — links are.)
### Popular missing-node signatures
UTFCN ships a generated `popular_node_signatures.json` artifact built from
ComfyUI-Manager metadata and static scans of public GitHub repos.
The file helps match common missing nodes by their real node signatures even
when the original pack is not installed. It is loaded locally at ComfyUI startup;
UTFCN does not contact GitHub, ComfyUI-Manager, or the Registry while you use the
editor.
## How it decides what's equivalent ## How it decides what's equivalent
The backend reads the live node registry (real `INPUT_TYPES` / `RETURN_TYPES` The backend reads the live node registry (real `INPUT_TYPES` / `RETURN_TYPES`
@@ -43,14 +52,16 @@ and each node's source module) and ranks candidates in three tiers:
|------|---------|---------------| |------|---------|---------------|
| **curated** | a hand-written rule in `mappings.json` / `user_mappings.json` | yes (verified) | | **curated** | a hand-written rule in `mappings.json` / `user_mappings.json` | yes (verified) |
| **exact** | identical input names+types and output types to a core/other-pack node | yes (verified) | | **exact** | identical input names+types and output types to a core/other-pack node | yes (verified) |
| **partial** | can structurally stand in (accepts all inputs, provides all outputs) but names/slots differ | suggestion only | | **partial** | can structurally and semantically stand in (compatible slots plus matching feature intent) but names/slots differ | suggestion only |
"Available" means core is preferred, and if there's no core match it will offer "Available" means core is preferred, and if there's no core match it will offer
an equivalent from a **different installed pack** as a fallback. an equivalent from a **different installed pack** as a fallback.
For an **uninstalled** node only *curated* (by name) and *partial* (by its For an **uninstalled** node, UTFCN tries curated rules by name first, then any
serialized link signature) can apply — the exact tier needs the widget-level bundled generated signature for that node type, then the serialized link
signature, which a node you haven't installed can't provide. signature preserved in the workflow. Generated exact signatures can produce
verified exact matches, but name-only metadata never can; loose structural
matches are filtered by feature intent and remain suggestions.
## Shipped equivalences ## Shipped equivalences
@@ -105,6 +116,19 @@ equivalent:**
never auto-applies a heuristic guess, and it never fires while you're opening never auto-applies a heuristic guess, and it never fires while you're opening
or importing a workflow — only on nodes *you* add. Undo with Ctrl+Z. or importing a workflow — only on nodes *you* add. Undo with Ctrl+Z.
## Refreshing the generated popular-node artifact
Maintainers can refresh the bundled signature artifact with:
```bash
python tools/generate_popular_node_signatures.py --limit 1000 --output popular_node_signatures.json --cache-dir /tmp/utfcn-popular-node-repos
```
The generator uses only Python's standard library plus `git`. It parses custom
node repositories statically with `ast`; it does not import or execute the
downloaded node code. Repositories with dynamic signatures are skipped until a
parser case exists for them.
## Install ## Install
Clone into `ComfyUI/custom_nodes/` and restart ComfyUI: Clone into `ComfyUI/custom_nodes/` and restart ComfyUI:
+4 -2
View File
@@ -14,7 +14,7 @@ the *analysis*: it has the live node registry, so it computes — accurately, fr
real INPUT_TYPES / RETURN_TYPES — which custom nodes have safe equivalents. real INPUT_TYPES / RETURN_TYPES — which custom nodes have safe equivalents.
GET /utfcn/scan[?refresh=1] -> { sources, candidates, stats } GET /utfcn/scan[?refresh=1] -> { sources, candidates, stats }
POST /utfcn/match {nodes:[{type,inputs,outputs,output_names}]} -> { candidates } POST /utfcn/match {nodes:[{type,display,inputs,outputs,output_names}]} -> { candidates }
(for UNINSTALLED / missing nodes in a workflow) (for UNINSTALLED / missing nodes in a workflow)
Curated overrides live in mappings.json (shipped) and user_mappings.json (yours). Curated overrides live in mappings.json (shipped) and user_mappings.json (yours).
@@ -39,7 +39,9 @@ _INDEX_CACHE = None
def _get_ctx(refresh=False): def _get_ctx(refresh=False):
global _CTX_CACHE global _CTX_CACHE
if refresh or _CTX_CACHE is None: if refresh or _CTX_CACHE is None:
_CTX_CACHE = utfcn_core.build_context(utfcn_core.load_rules(_DIR)) rules = utfcn_core.load_rules(_DIR)
generated = utfcn_core.load_generated_signatures(_DIR)
_CTX_CACHE = utfcn_core.build_context(rules, generated)
return _CTX_CACHE return _CTX_CACHE
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,145 @@
# Popular Node Signature Intelligence Design
## Context
UTFCN currently ranks replacements from the live ComfyUI node registry and a small curated `mappings.json` file. That works well for installed custom nodes because the backend can inspect real `INPUT_TYPES`, `RETURN_TYPES`, display names, and source modules. It is weaker for missing or uninstalled nodes because ComfyUI preserves only the serialized workflow slots, which often omit widget-level signature data.
ComfyUI-Manager and the Comfy Registry expose broad custom-node metadata. Manager's list is useful for pack discovery and repository URLs. The Registry can add popularity signals such as downloads, stars, search ranking, and preempted node names. GitHub repositories can often be scanned offline to recover node class declarations and signatures. This design uses those public sources to improve coverage without changing UTFCN's runtime safety model.
## Goals
- Add broad pre-scanned coverage for popular custom nodes and node packs, targeting up to 1000 ranked entries from Manager and/or the Comfy Registry per generation run.
- Improve replacement suggestions for missing or uninstalled nodes by matching against bundled signatures instead of relying only on sparse serialized workflow slots.
- Keep runtime startup and scan behavior local, deterministic, and fast. The ComfyUI server must not fetch GitHub or registry data during normal use.
- Preserve UTFCN's trust model: curated and exact matches are verified; heuristic matches are suggestions only; Force mode never applies heuristics.
- Make the generated data reproducible and reviewable so future updates can refresh coverage without hand-editing large JSON blobs.
## Non-Goals
- Do not treat Manager or Registry metadata alone as proof that a node is equivalent to a core node.
- Do not auto-install custom nodes or their dependencies.
- Do not execute arbitrary custom-node repository code during runtime.
- Do not silently replace missing nodes based only on similar names.
- Do not require ComfyUI-Manager to be installed.
## Proposed Approach
Add a generated data artifact named `popular_node_signatures.json` and an update script that can regenerate it from public metadata. The artifact is committed to the repo and loaded by the existing backend alongside `mappings.json` and `user_mappings.json`.
The update script is a developer tool, not a runtime dependency. It fetches Manager and/or Registry metadata, ranks entries by available popularity signals, scans reachable GitHub repositories, extracts ComfyUI node signatures when feasible, and writes normalized JSON. Repositories that cannot be fetched or parsed are skipped with a recorded reason in generation metadata.
Runtime matching then has three signature sources:
1. Live installed signatures from `nodes.NODE_CLASS_MAPPINGS`.
2. Curated mappings from `mappings.json` and `user_mappings.json`.
3. Bundled popular-node signatures from `popular_node_signatures.json`.
Installed custom nodes continue to prefer live signatures because they are the strongest local truth. Missing nodes use curated mappings first, then bundled signatures by node type, then the existing serialized-slot heuristic fallback.
## Data Artifact
`popular_node_signatures.json` is machine-generated and stable enough for code review. It contains:
- `schema_version`: integer for future migrations.
- `generated_at`: ISO timestamp.
- `sources`: generation inputs, including Manager list URL, Registry query details when used, and limits.
- `packs`: map of pack id to pack metadata such as title, repository, ranking signals, and extraction status.
- `nodes`: map of ComfyUI node type to normalized signature and pack metadata.
Each node entry contains:
- `type`: ComfyUI class mapping key.
- `display`: display name if discoverable.
- `pack`: normalized pack id.
- `repository`: source repository URL.
- `inputs`: map of input name to reduced UTFCN type string.
- `required`: list of required input names.
- `outputs`: ordered list of output type strings.
- `output_names`: ordered list of output names when discoverable.
- `confidence`: extraction confidence such as `static_exact`, `static_partial`, or `metadata_only`.
Only entries with usable signature data participate in exact/partial matching. Metadata-only entries may be used for display or diagnostics but must not create replacement candidates by themselves.
## Extraction Strategy
The generator should start conservatively:
- Fetch Manager's `custom-node-list.json` and optionally the Registry `/nodes` API.
- Prefer entries with a GitHub repository URL.
- Rank by Registry downloads when available, then GitHub stars, search ranking, and Manager order as tie-breakers.
- Clone or fetch repository contents into a temporary cache outside the committed tree.
- Parse Python files with `ast` instead of importing repository code.
- Detect class-level `RETURN_TYPES`, `RETURN_NAMES`, `NODE_DISPLAY_NAME_MAPPINGS`, and `NODE_CLASS_MAPPINGS`.
- Extract common static `INPUT_TYPES` shapes when the method returns a literal dict or simple literal-compatible expression.
- Mark dynamic or unsupported shapes as skipped rather than guessing.
The first implementation can intentionally miss complex dynamic nodes. Coverage can improve over time by adding parser cases backed by fixtures.
## Backend Changes
Add a loader in `utfcn_core.py` for the generated artifact. If the file is absent, malformed, or has an unsupported schema version, UTFCN logs a warning and behaves as it does today.
Extend `build_context()` to include generated signatures in a separate namespace from live signatures. Live signatures and live source metadata remain authoritative for installed nodes. Generated signatures are used for uninstalled source nodes and as optional supplemental metadata.
Candidate ranking should keep the existing tiers:
- `curated`: explicit rule, verified.
- `exact`: identical signature, verified only when based on live installed source signatures or generated signatures with usable extracted data.
- `partial`: structurally feasible but not identical, suggestion only.
For missing nodes, matching order is:
1. Curated rule by node type.
2. Generated signature for that node type, if present and usable.
3. Serialized workflow signature fallback.
This order lets missing nodes benefit from full pre-scanned signatures while preserving the existing behavior for unknown nodes.
## Frontend Changes
The current frontend can remain mostly unchanged because it consumes backend candidates. Small UI improvements are acceptable if needed:
- Show generated-source matches with the existing tier labels.
- Keep Force mode limited to verified candidates returned by the backend.
- Keep preview behavior unchanged for partial matches.
No network access or GitHub-specific logic belongs in `web/utfcn.js`.
## Safety Rules
- Runtime never fetches remote metadata.
- Runtime never imports scanned third-party repository code from the generated dataset.
- A generated signature must include concrete input and output type data before it can influence matching.
- Metadata-only node names cannot produce verified matches.
- Name similarity can affect ranking only for suggestions, not Force mode.
- `user_mappings.json` continues to override shipped curated mappings for local user control.
## Testing Plan
Add focused backend tests that do not require a running ComfyUI instance:
- Rule loading still merges shipped and user mappings correctly.
- Generated artifact loading accepts the expected schema and rejects malformed data gracefully.
- Missing-node matching prefers curated rules over generated signatures.
- Generated exact signatures can produce verified exact matches against core targets.
- Generated partial matches remain unverified.
- Metadata-only entries do not produce candidates.
Add generator tests with small fixture repositories:
- Extracts static `NODE_CLASS_MAPPINGS` and `INPUT_TYPES`.
- Extracts `RETURN_TYPES` and optional `RETURN_NAMES`.
- Skips dynamic or unsupported `INPUT_TYPES` without failing the whole run.
- Produces deterministic JSON ordering.
Manual verification should include opening a workflow with a missing node whose type exists in the generated artifact and confirming the preview offers the expected replacement without installing the original pack.
## Rollout
1. Implement the generator and backend loader behind the generated artifact.
2. Add tests using small fixtures before generating the large dataset.
3. Generate an initial artifact from a limited sample and verify behavior.
4. Expand to up to 1000 ranked entries once extraction and matching are stable.
5. Document the refresh command and the trust model in `README.md`.
The initial commit may include a smaller sample artifact if full top-1000 extraction exposes parser or network edge cases. The runtime code should not depend on the artifact being complete.
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
File diff suppressed because it is too large Load Diff
+465
View File
@@ -0,0 +1,465 @@
import json
import tempfile
import unittest
from collections import defaultdict
from pathlib import Path
import utfcn_core
def _empty_generated():
return {"sigs": {}, "meta": {}, "by_out": defaultdict(list)}
class GeneratedSignatureLoaderTests(unittest.TestCase):
def test_missing_generated_file_returns_empty_indexes(self):
with tempfile.TemporaryDirectory() as tmp:
generated = utfcn_core.load_generated_signatures(tmp)
self.assertEqual({}, generated["sigs"])
self.assertEqual({}, generated["meta"])
self.assertEqual({}, dict(generated["by_out"]))
def test_loads_usable_static_signature(self):
payload = {
"schema_version": 1,
"generated_at": "2026-07-02T00:00:00Z",
"sources": {"limit": 1},
"packs": {
"sample-pack": {
"title": "Sample Pack",
"repository": "https://github.com/example/sample-pack",
}
},
"nodes": {
"SampleImageSize": {
"type": "SampleImageSize",
"display": "Sample Image Size",
"pack": "sample-pack",
"repository": "https://github.com/example/sample-pack",
"inputs": {"image": "IMAGE"},
"required": ["image"],
"outputs": ["INT", "INT"],
"output_names": ["width", "height"],
"confidence": "static_exact",
}
},
}
with tempfile.TemporaryDirectory() as tmp:
Path(tmp, "popular_node_signatures.json").write_text(
json.dumps(payload),
encoding="utf-8",
)
generated = utfcn_core.load_generated_signatures(tmp)
self.assertEqual({"image": "IMAGE"}, generated["sigs"]["SampleImageSize"]["inputs"])
self.assertEqual({"image"}, generated["sigs"]["SampleImageSize"]["required"])
self.assertEqual(["INT", "INT"], generated["sigs"]["SampleImageSize"]["outputs"])
self.assertEqual(["width", "height"], generated["sigs"]["SampleImageSize"]["output_names"])
self.assertEqual("sample-pack", generated["meta"]["SampleImageSize"]["pack"])
self.assertEqual("Sample Image Size", generated["meta"]["SampleImageSize"]["display"])
self.assertEqual(["SampleImageSize"], generated["by_out"]["INT"])
def test_rejects_metadata_only_entries_for_matching(self):
payload = {
"schema_version": 1,
"generated_at": "2026-07-02T00:00:00Z",
"sources": {},
"packs": {},
"nodes": {
"NameOnlyNode": {
"type": "NameOnlyNode",
"display": "Name Only",
"pack": "name-only",
"repository": "https://github.com/example/name-only",
"inputs": {},
"required": [],
"outputs": [],
"output_names": [],
"confidence": "metadata_only",
}
},
}
with tempfile.TemporaryDirectory() as tmp:
Path(tmp, "popular_node_signatures.json").write_text(
json.dumps(payload),
encoding="utf-8",
)
generated = utfcn_core.load_generated_signatures(tmp)
self.assertNotIn("NameOnlyNode", generated["sigs"])
self.assertNotIn("NameOnlyNode", generated["meta"])
self.assertEqual({}, dict(generated["by_out"]))
def test_malformed_generated_file_returns_empty_indexes(self):
with tempfile.TemporaryDirectory() as tmp:
Path(tmp, "popular_node_signatures.json").write_text("{broken", encoding="utf-8")
generated = utfcn_core.load_generated_signatures(tmp)
self.assertEqual({}, generated["sigs"])
self.assertEqual({}, generated["meta"])
self.assertEqual({}, dict(generated["by_out"]))
def test_unsupported_schema_returns_empty_indexes(self):
payload = {
"schema_version": 99,
"generated_at": "2026-07-02T00:00:00Z",
"sources": {},
"packs": {},
"nodes": {},
}
with tempfile.TemporaryDirectory() as tmp:
Path(tmp, "popular_node_signatures.json").write_text(
json.dumps(payload),
encoding="utf-8",
)
generated = utfcn_core.load_generated_signatures(tmp)
self.assertEqual({}, generated["sigs"])
self.assertEqual({}, generated["meta"])
self.assertEqual({}, dict(generated["by_out"]))
def test_repository_artifact_loads_when_present(self):
repo_dir = Path(__file__).resolve().parents[1]
generated = utfcn_core.load_generated_signatures(str(repo_dir))
node_type = "RGB_HexToHSV //Inspire"
self.assertEqual({"rgb_hex": "STRING"}, generated["sigs"][node_type]["inputs"])
self.assertEqual({"rgb_hex"}, generated["sigs"][node_type]["required"])
self.assertEqual(["FLOAT", "FLOAT", "FLOAT"], generated["sigs"][node_type]["outputs"])
self.assertEqual(["hue", "saturation", "value"], generated["sigs"][node_type]["output_names"])
self.assertEqual("inspire", generated["meta"][node_type]["pack"])
self.assertEqual("RGB Hex To HSV (Inspire)", generated["meta"][node_type]["display"])
self.assertEqual(
"https://github.com/ltdrdata/ComfyUI-Inspire-Pack",
generated["meta"][node_type]["repository"],
)
self.assertIn(node_type, generated["by_out"]["FLOAT"])
class GeneratedSignatureMatchingTests(unittest.TestCase):
def _ctx(self, rules=None, generated=None):
live_sigs = {
"CoreImageSize": {
"inputs": {"image": "IMAGE"},
"required": {"image"},
"outputs": ["INT", "INT"],
"output_names": ["width", "height"],
},
"CoreMaskInvert": {
"inputs": {"mask": "MASK"},
"required": {"mask"},
"outputs": ["MASK"],
"output_names": ["mask"],
},
"CoreImagePassthrough": {
"inputs": {"image": "IMAGE"},
"required": {"image"},
"outputs": ["IMAGE"],
"output_names": ["image"],
},
"ImageBlur": {
"inputs": {"image": "IMAGE", "radius": "INT"},
"required": {"image", "radius"},
"outputs": ["IMAGE"],
"output_names": ["image"],
},
"ImageResize": {
"inputs": {"image": "IMAGE", "width": "INT", "height": "INT"},
"required": {"image", "width", "height"},
"outputs": ["IMAGE"],
"output_names": ["image"],
},
"PrimitiveString": {
"inputs": {"value": "STRING"},
"required": {"value"},
"outputs": ["STRING"],
"output_names": ["text"],
},
"TextTruncate": {
"inputs": {"text": "STRING", "max_length": "INT"},
"required": {"text", "max_length"},
"outputs": ["STRING"],
"output_names": ["text"],
},
"CuratedTarget": {
"inputs": {"image": "IMAGE"},
"required": {"image"},
"outputs": ["INT", "INT"],
"output_names": ["width", "height"],
},
}
sources = {
"CoreImageSize": {"source": "core", "pack": "nodes", "display": "Core Image Size"},
"CoreMaskInvert": {"source": "core", "pack": "nodes", "display": "Core Mask Invert"},
"CoreImagePassthrough": {"source": "core", "pack": "nodes", "display": "Core Image Passthrough"},
"ImageBlur": {"source": "core", "pack": "nodes", "display": "Image Blur"},
"ImageResize": {"source": "core", "pack": "nodes", "display": "Image Resize"},
"PrimitiveString": {"source": "core", "pack": "nodes", "display": "String"},
"TextTruncate": {"source": "core", "pack": "nodes", "display": "Text Truncate"},
"CuratedTarget": {"source": "core", "pack": "nodes", "display": "Curated Target"},
}
by_out = defaultdict(list)
for name, sig in live_sigs.items():
by_out[sig["outputs"][0]].append(name)
return {
"sources": sources,
"sigs": live_sigs,
"by_out": by_out,
"rules": rules or {},
"generated": generated or _empty_generated(),
}
def test_generated_exact_signature_matches_missing_node_as_verified(self):
generated = _empty_generated()
generated["sigs"]["SampleImageSize"] = {
"inputs": {"image": "IMAGE"},
"required": {"image"},
"outputs": ["INT", "INT"],
"output_names": ["width", "height"],
}
generated["meta"]["SampleImageSize"] = {
"source": "generated",
"pack": "sample-pack",
"display": "Sample Image Size",
"repository": "https://github.com/example/sample-pack",
"confidence": "static_exact",
}
generated["by_out"]["INT"].append("SampleImageSize")
result = utfcn_core.match(self._ctx(generated=generated), [{"type": "SampleImageSize"}])
self.assertEqual("CoreImageSize", result["SampleImageSize"][0]["to"])
self.assertEqual("exact", result["SampleImageSize"][0]["tier"])
self.assertTrue(result["SampleImageSize"][0]["verified"])
def test_curated_rule_stays_first_before_generated_exact_match(self):
generated = _empty_generated()
generated["sigs"]["SampleImageSize"] = {
"inputs": {"image": "IMAGE"},
"required": {"image"},
"outputs": ["INT", "INT"],
"output_names": ["width", "height"],
}
generated["meta"]["SampleImageSize"] = {
"source": "generated",
"pack": "sample-pack",
"display": "Sample Image Size",
"repository": "https://github.com/example/sample-pack",
"confidence": "static_exact",
}
generated["by_out"]["INT"].append("SampleImageSize")
rules = {
"SampleImageSize": [
{
"to": "CuratedTarget",
"note": "Curated replacement wins over generated exact signature.",
}
]
}
result = utfcn_core.match(self._ctx(rules=rules, generated=generated), [{"type": "SampleImageSize"}])
self.assertEqual("CuratedTarget", result["SampleImageSize"][0]["to"])
self.assertEqual("curated", result["SampleImageSize"][0]["tier"])
self.assertTrue(result["SampleImageSize"][0]["verified"])
def test_generated_partial_signature_matches_but_is_not_verified(self):
generated = _empty_generated()
generated["sigs"]["SampleMaskInvert"] = {
"inputs": {"masks": "MASK"},
"required": {"masks"},
"outputs": ["MASK"],
"output_names": ["mask"],
}
generated["meta"]["SampleMaskInvert"] = {
"source": "generated",
"pack": "sample-pack",
"display": "Sample Mask Invert",
"repository": "https://github.com/example/sample-pack",
"confidence": "static_exact",
}
generated["by_out"]["MASK"].append("SampleMaskInvert")
result = utfcn_core.match(self._ctx(generated=generated), [{"type": "SampleMaskInvert"}])
self.assertEqual("CoreMaskInvert", result["SampleMaskInvert"][0]["to"])
self.assertEqual("partial", result["SampleMaskInvert"][0]["tier"])
self.assertFalse(result["SampleMaskInvert"][0]["verified"])
def test_contradictory_generated_signature_falls_back_to_serialized_signature(self):
generated = _empty_generated()
generated["sigs"]["SampleMaskInvert"] = {
"inputs": {"image": "IMAGE"},
"required": {"image"},
"outputs": ["IMAGE"],
"output_names": ["image"],
}
generated["meta"]["SampleMaskInvert"] = {
"source": "generated",
"pack": "sample-pack",
"display": "Sample Mask Invert",
"repository": "https://github.com/example/sample-pack",
"confidence": "static_exact",
}
generated["by_out"]["IMAGE"].append("SampleMaskInvert")
result = utfcn_core.match(
self._ctx(generated=generated),
[
{
"type": "SampleMaskInvert",
"inputs": {"masks": "MASK"},
"outputs": ["MASK"],
"output_names": ["mask"],
}
],
)
self.assertEqual("CoreMaskInvert", result["SampleMaskInvert"][0]["to"])
self.assertEqual("partial", result["SampleMaskInvert"][0]["tier"])
self.assertFalse(result["SampleMaskInvert"][0]["verified"])
def test_generated_signature_with_different_input_name_falls_back_to_serialized_signature(self):
generated = _empty_generated()
generated["sigs"]["SampleMaskInvert"] = {
"inputs": {"mask": "MASK"},
"required": {"mask"},
"outputs": ["MASK"],
"output_names": ["mask"],
}
generated["meta"]["SampleMaskInvert"] = {
"source": "generated",
"pack": "sample-pack",
"display": "Sample Mask Invert",
"repository": "https://github.com/example/sample-pack",
"confidence": "static_exact",
}
generated["by_out"]["MASK"].append("SampleMaskInvert")
result = utfcn_core.match(
self._ctx(generated=generated),
[
{
"type": "SampleMaskInvert",
"inputs": {"masks": "MASK"},
"outputs": ["MASK"],
"output_names": ["mask"],
}
],
)
self.assertEqual("CoreMaskInvert", result["SampleMaskInvert"][0]["to"])
self.assertEqual("partial", result["SampleMaskInvert"][0]["tier"])
self.assertFalse(result["SampleMaskInvert"][0]["verified"])
def test_malformed_generated_context_falls_back_without_raising(self):
result = utfcn_core.match(
self._ctx(generated={"sigs": "bad", "meta": "bad"}),
[
{
"type": "SerializedMaskInvert",
"inputs": {"masks": "MASK"},
"outputs": ["MASK"],
"output_names": ["mask"],
}
],
)
self.assertEqual("CoreMaskInvert", result["SerializedMaskInvert"][0]["to"])
self.assertEqual("partial", result["SerializedMaskInvert"][0]["tier"])
self.assertFalse(result["SerializedMaskInvert"][0]["verified"])
def test_serialized_signature_fallback_still_handles_unknown_generated_node(self):
result = utfcn_core.match(
self._ctx(),
[
{
"type": "SerializedMaskInvert",
"inputs": {"masks": "MASK"},
"outputs": ["MASK"],
"output_names": ["mask"],
}
],
)
self.assertEqual("CoreMaskInvert", result["SerializedMaskInvert"][0]["to"])
self.assertEqual("partial", result["SerializedMaskInvert"][0]["tier"])
self.assertFalse(result["SerializedMaskInvert"][0]["verified"])
def test_text_entry_node_does_not_match_text_transform_candidate(self):
result = utfcn_core.match(
self._ctx(),
[
{
"type": "SomeCustomTextBox",
"display": "Text Box",
"outputs": ["STRING"],
"output_names": ["text"],
}
],
)
self.assertEqual(["PrimitiveString"], [cand["to"] for cand in result["SomeCustomTextBox"]])
def test_generated_text_entry_display_does_not_match_text_transform_candidate(self):
generated = _empty_generated()
generated["sigs"]["OpaqueCustomNode"] = {
"inputs": {},
"required": set(),
"outputs": ["STRING"],
"output_names": ["text"],
}
generated["meta"]["OpaqueCustomNode"] = {
"source": "generated",
"pack": "text-pack",
"display": "Text Box",
"repository": "https://github.com/example/text-pack",
"confidence": "static_exact",
}
generated["by_out"]["STRING"].append("OpaqueCustomNode")
result = utfcn_core.match(
self._ctx(generated=generated),
[{"type": "OpaqueCustomNode"}],
)
self.assertEqual(["PrimitiveString"], [cand["to"] for cand in result["OpaqueCustomNode"]])
def test_text_transform_node_can_match_same_transform_feature(self):
result = utfcn_core.match(
self._ctx(),
[
{
"type": "LegacyTextTruncate",
"display": "Text Truncate",
"inputs": {"text": "STRING"},
"outputs": ["STRING"],
"output_names": ["text"],
}
],
)
self.assertEqual("TextTruncate", result["LegacyTextTruncate"][0]["to"])
self.assertEqual("partial", result["LegacyTextTruncate"][0]["tier"])
self.assertFalse(result["LegacyTextTruncate"][0]["verified"])
def test_image_transform_node_does_not_match_different_transform_feature(self):
result = utfcn_core.match(
self._ctx(),
[
{
"type": "LegacyImageBlur",
"display": "Image Blur",
"inputs": {"image": "IMAGE"},
"outputs": ["IMAGE"],
"output_names": ["image"],
}
],
)
self.assertEqual(["ImageBlur"], [cand["to"] for cand in result["LegacyImageBlur"]])
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -0,0 +1 @@
"""Utility scripts for UTFCN development."""
File diff suppressed because it is too large Load Diff
+311 -16
View File
@@ -17,9 +17,9 @@ We answer it in three tiers, from most to least trustworthy:
exact the candidate's signature (input name→type map + ordered output exact the candidate's signature (input name→type map + ordered output
types) is IDENTICAL to the source's. Safe to remap by name. types) is IDENTICAL to the source's. Safe to remap by name.
Verified. Verified.
partial the candidate can structurally accept every input the source has partial the candidate can structurally accept every input the source has,
and provides every output type the source has, but names / extra provides every output type the source has, and matches the same
slots differ. A *suggestion* only — never auto-applied. feature intent. A *suggestion* only — never auto-applied.
The frontend consumes the result: `verified` candidates power auto-replace, The frontend consumes the result: `verified` candidates power auto-replace,
`partial` ones are shown for the user to confirm. `partial` ones are shown for the user to confirm.
@@ -27,6 +27,7 @@ The frontend consumes the result: `verified` candidates power auto-replace,
import json import json
import os import os
import re
from collections import Counter, defaultdict from collections import Counter, defaultdict
# Top-level python modules we consider "core" (shipped with ComfyUI itself). # Top-level python modules we consider "core" (shipped with ComfyUI itself).
@@ -37,6 +38,48 @@ CORE_TOPLEVEL = ("nodes", "comfy_extras", "comfy_api_nodes", "comfy_api")
# so they matter for widget-value transfer but not for link compatibility. # so they matter for widget-value transfer but not for link compatibility.
WIDGET_TYPES = frozenset({"INT", "FLOAT", "STRING", "BOOLEAN", "COMBO"}) WIDGET_TYPES = frozenset({"INT", "FLOAT", "STRING", "BOOLEAN", "COMBO"})
_TEXT_TYPES = frozenset({"STRING", "STRING_LIST"})
_TEXT_NEUTRAL_TOKENS = frozenset(
{
"any",
"box",
"constant",
"input",
"literal",
"multi",
"multiline",
"note",
"primitive",
"prompt",
"string",
"text",
"textarea",
"value",
"widget",
}
)
_ACTION_GROUPS = (
("blur", frozenset({"blur", "smooth"})),
("crop", frozenset({"crop"})),
("geometry", frozenset({"downscale", "resize", "rescale", "scale", "upscale"})),
("invert", frozenset({"invert", "inversion"})),
("passthrough", frozenset({"identity", "pass", "passthrough", "reroute"})),
("preview", frozenset({"display", "preview", "show", "view"})),
("size", frozenset({"dimension", "dimensions", "height", "resolution", "size", "width"})),
("concat", frozenset({"append", "combine", "concat", "concatenate", "join", "merge"})),
("convert", frozenset({"cast", "convert", "float", "int", "number"})),
("encode", frozenset({"clip", "conditioning", "encode", "encoder", "tokenize", "tokenizer"})),
("extract", frozenset({"extract", "find", "parse", "regex", "regexp", "select"})),
("format", frozenset({"format", "template"})),
("io", frozenset({"file", "load", "path", "read", "save", "url", "write"})),
("replace", frozenset({"remove", "replace", "substitute"})),
("split", frozenset({"separate", "split", "splitter"})),
("strip", frozenset({"clean", "lstrip", "rstrip", "sanitize", "strip", "trim"})),
("translate", frozenset({"translate", "translator"})),
("truncate", frozenset({"chop", "slice", "substring", "truncate"})),
("case", frozenset({"case", "lower", "upper"})),
)
def _module_of(cls): def _module_of(cls):
return getattr(cls, "RELATIVE_PYTHON_MODULE", "nodes") or "nodes" return getattr(cls, "RELATIVE_PYTHON_MODULE", "nodes") or "nodes"
@@ -119,11 +162,168 @@ def _score(src, cand):
return min(1.0, base + name_bonus) return min(1.0, base + name_bonus)
def _semantic_tokens(*parts):
text = " ".join(str(part or "") for part in parts)
text = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", text)
text = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", text)
return {
token
for token in re.split(r"[^A-Za-z0-9]+", text.lower())
if token
}
def _identity_tokens(name, meta, sig):
if not isinstance(meta, dict):
meta = {}
terms = [name, meta.get("display")]
terms.extend(sig.get("inputs", {}).keys())
terms.extend(sig.get("output_names") or [])
return _semantic_tokens(*terms)
def _action_groups(tokens):
groups = {
group
for group, group_tokens in _ACTION_GROUPS
if tokens & group_tokens
}
if "to" in tokens and tokens & {"bool", "boolean", "float", "int", "number"}:
groups.add("convert")
return groups
def _text_signature_kind(sig):
values = set(sig.get("inputs", {}).values()) | set(sig.get("outputs", []))
return bool(values & _TEXT_TYPES)
def _text_value_like(tokens, sig):
outputs = sig.get("outputs", [])
inputs = sig.get("inputs", {})
if not outputs or not set(outputs) <= _TEXT_TYPES:
return False
if _action_groups(tokens):
return False
if len(inputs) > 1:
return False
if inputs:
name, typ = next(iter(inputs.items()))
if typ not in _TEXT_TYPES and typ != "COMBO":
return False
if not (_semantic_tokens(name) & _TEXT_NEUTRAL_TOKENS):
return False
return bool(tokens & _TEXT_NEUTRAL_TOKENS)
def _features_compatible(src_name, src_sig, src_meta, cand_name, cand_sig, cand_meta):
"""
Structural compatibility is too weak for primitive text nodes: a missing
text box serializes as STRING output only, which otherwise matches every
STRING utility. Gate text candidates by identity tokens so text-entry
sources do not suggest transforms such as truncate/split/replace.
"""
src_tokens = _identity_tokens(src_name, src_meta, src_sig)
cand_tokens = _identity_tokens(cand_name, cand_meta, cand_sig)
src_actions = _action_groups(src_tokens)
cand_actions = _action_groups(cand_tokens)
if _text_signature_kind(src_sig) and _text_signature_kind(cand_sig) and _text_value_like(src_tokens, src_sig):
return not cand_actions and _text_value_like(cand_tokens, cand_sig)
if src_actions or cand_actions:
return bool(src_actions & cand_actions)
return True
# score below which a partial match isn't worth surfacing # score below which a partial match isn't worth surfacing
_PARTIAL_THRESHOLD = 0.5 _PARTIAL_THRESHOLD = 0.5
# max candidates returned per source node # max candidates returned per source node
_MAX_CANDIDATES = 6 _MAX_CANDIDATES = 6
_GENERATED_SCHEMA_VERSION = 1
_GENERATED_SIGNATURES_FILE = "popular_node_signatures.json"
def _empty_generated_signatures():
return {"sigs": {}, "meta": {}, "by_out": defaultdict(list)}
def _normalise_generated_signature(node_type, entry):
if not isinstance(entry, dict):
return None
if str(entry.get("confidence") or "") == "metadata_only":
return None
inputs_raw = entry.get("inputs") or {}
if not isinstance(inputs_raw, dict):
return None
outputs_raw = entry.get("outputs") or []
if not isinstance(outputs_raw, list):
return None
inputs = {str(k): str(v) for k, v in inputs_raw.items() if k is not None}
outputs = [str(v) for v in outputs_raw if v is not None]
if not inputs and not outputs:
return None
required_raw = entry.get("required") or []
if not isinstance(required_raw, list):
required_raw = []
output_names_raw = entry.get("output_names") or []
if not isinstance(output_names_raw, list):
output_names_raw = []
sig = {
"inputs": inputs,
"required": {str(v) for v in required_raw if str(v) in inputs},
"outputs": outputs,
"output_names": [str(v) for v in output_names_raw],
}
meta = {
"source": "generated",
"pack": str(entry.get("pack") or ""),
"display": str(entry.get("display") or entry.get("type") or node_type),
"repository": str(entry.get("repository") or ""),
"confidence": str(entry.get("confidence") or ""),
}
return sig, meta
def load_generated_signatures(base_dir):
path = os.path.join(base_dir, _GENERATED_SIGNATURES_FILE)
generated = _empty_generated_signatures()
if not os.path.isfile(path):
return generated
try:
with open(path, "r", encoding="utf-8") as f:
raw = json.load(f)
except Exception as e:
print(f"[UTFCN] failed to read {_GENERATED_SIGNATURES_FILE}: {e}")
return generated
if not isinstance(raw, dict) or raw.get("schema_version") != _GENERATED_SCHEMA_VERSION:
print(f"[UTFCN] ignored {_GENERATED_SIGNATURES_FILE}: unsupported schema")
return generated
nodes = raw.get("nodes") or {}
if not isinstance(nodes, dict):
print(f"[UTFCN] ignored {_GENERATED_SIGNATURES_FILE}: nodes must be an object")
return generated
for node_type, entry in nodes.items():
normalised = _normalise_generated_signature(str(node_type), entry)
if normalised is None:
continue
sig, meta = normalised
generated["sigs"][str(node_type)] = sig
generated["meta"][str(node_type)] = meta
generated["by_out"][_first_output_type(sig)].append(str(node_type))
return generated
def _normalise_rules(raw): def _normalise_rules(raw):
"""Accept both {source: {...single...}} and {source: [ {...}, {...} ]} shapes.""" """Accept both {source: {...single...}} and {source: [ {...}, {...} ]} shapes."""
@@ -149,7 +349,7 @@ def load_rules(base_dir):
return merged return merged
def build_context(rules): def build_context(rules, generated=None):
""" """
Snapshot the live node registry once (signatures + source of every node). Snapshot the live node registry once (signatures + source of every node).
@@ -176,10 +376,16 @@ def build_context(rules):
for name in classes: for name in classes:
by_out[_first_output_type(sigs[name])].append(name) by_out[_first_output_type(sigs[name])].append(name)
return {"sources": sources, "sigs": sigs, "by_out": by_out, "rules": rules} return {
"sources": sources,
"sigs": sigs,
"by_out": by_out,
"rules": rules,
"generated": generated or _empty_generated_signatures(),
}
def _candidates_for(src_name, src_sig, src_pack, ctx): def _candidates_for(src_name, src_sig, src_pack, ctx, src_meta=None):
""" """
Rank replacement candidates for one source node. Rank replacement candidates for one source node.
@@ -189,6 +395,8 @@ def _candidates_for(src_name, src_sig, src_pack, ctx):
`src_pack` is None for uninstalled/unknown sources (skips same-pack exclusion). `src_pack` is None for uninstalled/unknown sources (skips same-pack exclusion).
""" """
sources, sigs, by_out, rules = ctx["sources"], ctx["sigs"], ctx["by_out"], ctx["rules"] sources, sigs, by_out, rules = ctx["sources"], ctx["sigs"], ctx["by_out"], ctx["rules"]
if not isinstance(src_meta, dict):
src_meta = sources.get(src_name, {})
found, seen = [], set() found, seen = [], set()
# --- tier 1: curated rules (ordered preference; core-first is the author's job) --- # --- tier 1: curated rules (ordered preference; core-first is the author's job) ---
@@ -212,6 +420,8 @@ def _candidates_for(src_name, src_sig, src_pack, ctx):
cand_sig = sigs[cand_name] cand_sig = sigs[cand_name]
if not _feasible(src_sig, cand_sig): if not _feasible(src_sig, cand_sig):
continue continue
if not _features_compatible(src_name, src_sig, src_meta, cand_name, cand_sig, cand_meta):
continue
if _is_exact(src_sig, cand_sig): if _is_exact(src_sig, cand_sig):
ranked.append((cand_name, "exact", 1.0)) ranked.append((cand_name, "exact", 1.0))
else: else:
@@ -274,6 +484,71 @@ def build_index(ctx):
return {"sources": sources, "candidates": candidates, "stats": stats} return {"sources": sources, "candidates": candidates, "stats": stats}
def _signature_from_item(it):
inputs_raw = it.get("inputs") or {}
if not isinstance(inputs_raw, dict):
inputs_raw = {}
outputs_raw = it.get("outputs") or []
if not isinstance(outputs_raw, list):
outputs_raw = []
output_names_raw = it.get("output_names") or []
if not isinstance(output_names_raw, list):
output_names_raw = []
inputs = {str(k): str(v) for k, v in inputs_raw.items() if k is not None}
return {
"inputs": inputs,
"required": set(inputs),
"outputs": [str(x) for x in outputs_raw],
"output_names": [str(x) for x in output_names_raw],
}
def _generated_signature_usable(sig):
return isinstance(sig, dict) and isinstance(sig.get("inputs"), dict) and isinstance(sig.get("outputs"), list)
def _normalised_generated_signature(sig):
if not _generated_signature_usable(sig):
return None
try:
inputs = {str(k): str(v) for k, v in sig["inputs"].items() if k is not None}
outputs = [str(x) for x in sig["outputs"]]
required_raw = sig.get("required") or []
if not isinstance(required_raw, (list, set, tuple)):
required_raw = []
output_names_raw = sig.get("output_names") or []
if not isinstance(output_names_raw, list):
output_names_raw = []
return {
"inputs": inputs,
"required": {str(v) for v in required_raw if str(v) in inputs},
"outputs": outputs,
"output_names": [str(x) for x in output_names_raw],
}
except Exception:
return None
def _generated_signature_conflicts(serialized_sig, generated_sig):
if not serialized_sig["inputs"] and not serialized_sig["outputs"]:
return False
generated_inputs = generated_sig["inputs"]
for name, typ in serialized_sig["inputs"].items():
if name in generated_inputs:
if generated_inputs[name] != typ:
return True
else:
return True
if Counter(serialized_sig["outputs"]) - Counter(generated_sig["outputs"]):
return True
return False
def match(ctx, items): def match(ctx, items):
""" """
Match a batch of nodes given only their (possibly serialized) signature — Match a batch of nodes given only their (possibly serialized) signature —
@@ -281,23 +556,43 @@ def match(ctx, items):
`items`: [ {"type": str, "inputs": {name: TYPE}, "outputs": [TYPE], "output_names": [..]} ]. `items`: [ {"type": str, "inputs": {name: TYPE}, "outputs": [TYPE], "output_names": [..]} ].
Serialized nodes only carry link slots (not widget values), so 'exact' rarely Serialized nodes only carry link slots (not widget values), so 'exact' rarely
fires; curated rules (by type name) and 'partial' link-type matches do. fires; curated rules (by type name), bundled generated signatures, and
feature-gated partial link-type matches do.
Returns { type: [candidate, ...] }. Returns a mapping from source node type to candidate list.
""" """
out = {} out = {}
generated = ctx.get("generated") or {}
if not isinstance(generated, dict):
generated = {}
generated_sigs = generated.get("sigs") or {}
if not isinstance(generated_sigs, dict):
generated_sigs = {}
generated_meta = generated.get("meta") or {}
if not isinstance(generated_meta, dict):
generated_meta = {}
for it in items: for it in items:
if not isinstance(it, dict):
continue
t = it.get("type") t = it.get("type")
if not t or t in out: if not t or t in out:
continue continue
inputs = {k: str(v) for k, v in (it.get("inputs") or {}).items()}
sig = { sig = _signature_from_item(it)
"inputs": inputs, gen_sig = _normalised_generated_signature(generated_sigs.get(t))
"required": set(inputs), if gen_sig is not None and not _generated_signature_conflicts(sig, gen_sig):
"outputs": [str(x) for x in (it.get("outputs") or [])], gen_meta = generated_meta.get(t) or {}
"output_names": list(it.get("output_names") or []), if not isinstance(gen_meta, dict):
} gen_meta = {}
found = _candidates_for(t, sig, None, ctx) gen_pack = gen_meta.get("pack")
found = _candidates_for(t, gen_sig, gen_pack, ctx, gen_meta)
if found:
out[t] = found
continue
item_meta = {"display": it.get("display") or t}
found = _candidates_for(t, sig, None, ctx, item_meta)
if found: if found:
out[t] = found out[t] = found
return out return out
+8 -2
View File
@@ -6,7 +6,7 @@ import { app } from "../../scripts/app.js";
* The backend (/utfcn/scan) tells us, for every custom node type, which core (or * The backend (/utfcn/scan) tells us, for every custom node type, which core (or
* other-pack) nodes could stand in for it, split into: * other-pack) nodes could stand in for it, split into:
* verified — curated rule or an identical signature; safe to auto-apply. * verified — curated rule or an identical signature; safe to auto-apply.
* partial — structurally compatible but looser; a suggestion to confirm. * partial — structurally and semantically compatible but looser; confirm first.
* *
* This file turns that into three things: * This file turns that into three things:
* 1. a toast tip when you interactively drop a replaceable custom node; * 1. a toast tip when you interactively drop a replaceable custom node;
@@ -61,7 +61,13 @@ async function matchMissing() {
seen.add(t); seen.add(t);
const inputs = {}; const inputs = {};
(s.inputs || []).forEach((inp) => { if (inp?.name) inputs[inp.name] = inp.type; }); (s.inputs || []).forEach((inp) => { if (inp?.name) inputs[inp.name] = inp.type; });
items.push({ type: t, inputs, outputs: (s.outputs || []).map((o) => o.type), output_names: (s.outputs || []).map((o) => o.name) }); items.push({
type: t,
display: s.title || n.title || t,
inputs,
outputs: (s.outputs || []).map((o) => o.type),
output_names: (s.outputs || []).map((o) => o.name),
});
} }
if (!items.length) return; if (!items.length) return;
try { try {