Files
DemonGatanjieu-Anomalous_Mo…/tests/module_cycles.mjs
T
DemonGatanjieuandClaude Opus 5.5 b9fd30ad72 Fix Anomalous sometimes not starting
ComfyUI imports every script under web/ on its own and at once, so any module
of the big import cycle can run first. ui_ai_workbench.js wrote to a const of
ui_ai_results.js as it loaded, which failed when ui_ai_results.js or
ui_ai_step_body.js was entered first, and the whole plugin went with it until
a reload. The value now lives in a module that imports nothing, and
tests/module_cycles.mjs refuses the pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:52:28 +08:00

70 lines
3.2 KiB
JavaScript

// ComfyUI imports every .js under web/ on its own, all at once, so any module of an import cycle can happen
// to run first. A module that uses, as it loads, a `const` of a module in its own cycle then fails now and
// then, and AMB with it: no button, no window (docs/architecture/lessons.md). Such a value goes in a module
// outside the cycle.
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const folder = join(dirname(fileURLToPath(import.meta.url)), '..', 'web', 'modules');
const IMPORT = /^import\s+\{([^}]*)\}\s+from\s+'\.\/([\w.]+\.js)'/gm;
const modules = new Map(readdirSync(folder).filter(name => name.endsWith('.js')).map((name) => {
const text = readFileSync(join(folder, name), 'utf8').replace(/\r\n/g, '\n');
const imports = [...text.matchAll(IMPORT)].map(([, names, from]) => ({ from, names: names.split(',').map(item => item.trim().split(/\s+as\s+/)).filter(([first]) => first) }));
return [name, { text, imports, to: new Set(imports.map(item => item.from)) }];
}));
/** The modules in cycles, each with the set of those in the same cycle (Tarjan, on the imports). */
function cycles() {
const index = new Map();
const low = new Map();
const stack = [];
const groups = new Map();
const visit = (name) => {
index.set(name, index.size);
low.set(name, index.get(name));
stack.push(name);
for (const next of modules.get(name)?.to || []) {
if (!modules.has(next)) continue;
if (!index.has(next)) {
visit(next);
low.set(name, Math.min(low.get(name), low.get(next)));
} else if (stack.includes(next)) low.set(name, Math.min(low.get(name), index.get(next)));
}
if (low.get(name) !== index.get(name)) return;
const group = new Set();
let member;
do {
member = stack.pop();
group.add(member);
} while (member !== name);
for (const item of group) groups.set(item, group);
};
for (const name of modules.keys()) if (!index.has(name)) visit(name);
return groups;
}
/** Is `local` used by a line at the top of `text`, that is one run as the module loads? */
function usedAsItLoads(text, local) {
return text.split('\n').some(line => /^[\w(]/.test(line)
&& !/^(import|function|async function|export (async )?function|export default function|class)\b/.test(line)
&& new RegExp(`\\b${local}\\b`).test(line.split('=>')[0]));
}
const groups = cycles();
const found = [];
for (const [name, { text, imports }] of modules) {
for (const { from, names } of imports) {
if (!groups.get(name)?.has(from) || from === name) continue;
const source = modules.get(from).text;
for (const [exported, local = exported] of names) {
const isValue = new RegExp(`^export (const|let) ${exported}\\b`, 'm').test(source);
if (isValue && usedAsItLoads(text, local)) found.push(`${name} uses ${exported} of ${from} as it loads`);
}
}
}
assert.deepEqual(found, []);
console.log('Module cycles: nothing reads a value of its own cycle as it loads.');