comfyui-mcp
Version:
Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),
392 lines (364 loc) • 17.4 kB
JavaScript
/**
* #796 — lint the "could not determine" -> "determined not" collapse.
*
* The tracked defect class: a state meaning "I could not observe this" is folded
* into a state meaning "I observed that it is not so." Absence of evidence becomes
* evidence of absence, and the user is told a confident wrong answer.
*
* Twenty-seven instances have been fixed across two months. Every one of them had
* tests, and every one tested a real negative rather than a failed observation.
* The issue asks for a lint because the most common instances share a greppable
* shape: a `catch` that answers with a constructed empty value.
*
* .catch(() => []) // "the request failed" becomes "there are none"
* .catch(() => null) // "the lookup failed" becomes "there is none"
* .catch(() => false) // "could not check" becomes "no"
*
* WHAT THIS DOES NOT FLAG, and why it matters more than what it does.
*
* `await client.close().catch(() => {})` is not this defect. Nothing reads the
* value; the catch exists so a cleanup failure cannot mask the real error on the
* way out. Flagging those would bury the real hits — on this repo they are 40 of
* 99 raw matches. So a match counts only when the value is CONSUMED: assigned,
* returned, awaited into a binding, or passed onward. A discarded catch is
* ignored no matter what it returns.
*
* DIRECTION OF THE RATCHET. The baseline is the set of sites that already existed
* when this gate landed. It may SHRINK and never grow: fixing a site means
* deleting its line, and a site the scanner no longer finds is reported so the
* line gets removed. A new consuming catch fails the build. This is the opposite
* of the vocabulary baseline, which is append-only history — here the file is a
* debt list, and debt lists must only pay down.
*
* THE ESCAPE HATCH IS THE POINT. Plenty of these are correct: sometimes a failed
* read really should read as empty, and saying so once is cheaper than arguing it
* every review. Mark the line and give the reason:
*
* // unknown-ok: the cache is advisory; a failed read means "not cached yet",
* // which is exactly what an empty result should mean here.
* const cached = await readCache().catch(() => null);
*
* A bare `// unknown-ok` with no reason is rejected. The reason is the deliverable
* — it is what a reviewer reads instead of re-deriving whether the collapse is
* safe, and writing it is where an author notices that it is not.
*
* Usage:
* node scripts/check-unknown-collapse.mjs # gate; non-zero on failure
* node scripts/check-unknown-collapse.mjs --list # every consuming site
* node scripts/check-unknown-collapse.mjs --baseline # rewrite the baseline
*/
import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
/** `--src` and `--baseline-file` exist so the gate can be tested against fixture
* trees. A gate nobody can test is a gate nobody trusts, and this one makes
* claims about false positives that have to be demonstrable rather than
* asserted. Both default to the real repo layout. */
function flagValue(name, fallback) {
const i = process.argv.indexOf(name);
return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
}
const SRC = flagValue("--src", join(ROOT, "src"));
const BASELINE = flagValue(
"--baseline-file",
join(ROOT, "docs", "design", "unknown-collapse-baseline.txt"),
);
/** Values that assert a negative. An empty BLOCK body (`() => {}`) is deliberately
* absent: it returns undefined but is the idiom for "I do not care", and it is
* caught by the consumed-value test below when it genuinely is cared about. */
const EMPTY_ANSWERS = [
"[]",
"null",
"false",
"undefined",
'""',
"''",
"``",
"0",
"({})",
"{}",
"new Map()",
"new Set()",
];
const SKIP_DIRS = new Set(["node_modules", "dist", "build", ".git", "__tests__", "coverage"]);
function walk(dir, out = []) {
for (const name of readdirSync(dir)) {
if (SKIP_DIRS.has(name)) continue;
const p = join(dir, name);
const st = statSync(p);
if (st.isDirectory()) walk(p, out);
else if (/\.m?ts$/.test(name) && !/\.d\.ts$/.test(name) && !/\.test\.ts$/.test(name)) out.push(p);
}
return out;
}
/**
* Is the value produced by this `.catch(...)` READ by anything?
*
* The whole usefulness of the gate rests here. Approximated from the statement
* text preceding the match on its own line, which is where these are written in
* practice; a chained expression spanning lines is treated as consumed, because
* the cautious direction for a gate is to ask rather than to stay quiet.
*/
/**
* Walk back to where the statement containing this `.catch` BEGINS, returning the
* joined text and the line it starts on.
*
* Both consumers need the same answer. `valueIsConsumed` needs the text, since a
* binding can be several lines up and mid-line. The waiver needs the LINE, because
* a reader writes the explanation above the statement — not above whichever
* fragment the catch happens to be glued to:
*
* // unknown-ok: <reason>
* const seen = job.viaManager
* ? await verify(a, b, c)
* .catch(() => undefined) <- the match is here
* : undefined;
*
* Looking for the comment directly above the match would find `? await verify(...)`
* and conclude the site was unmarked.
*/
function balance(text) {
let n = 0;
for (const c of text) {
if (c === "(" || c === "[" || c === "{") n++;
else if (c === ")" || c === "]" || c === "}") n--;
}
return n;
}
function readStatement(line, matchIndex, lines, i) {
let stmt = line.slice(0, matchIndex);
let start = i;
for (let j = i - 1; j >= 0 && j >= i - 12; j--) {
// Two things mark text as not-yet-a-statement, and both are needed.
//
// UNBALANCED CLOSERS. `).catch(() => undefined)` is how a catch attaches to a
// call whose arguments were wrapped, and the argument lines above it are part
// of the same statement while looking like ordinary code. Counting brackets
// is what distinguishes them from the previous statement — while more have
// been closed than opened, we are still inside the call that opened them.
//
// A LEADING CHAIN LINK. `.then(...)` / `?.foo` continue a statement whose
// brackets are already balanced, so balance alone would stop one line short.
if (balance(stmt) >= 0 && !/^\s*[.?]/.test(stmt) && stmt.trim() !== "") break;
const prev = lines[j];
if (prev === undefined || prev.trim() === "") break;
// A comment line inside a chain is not where the statement begins. Including
// it made `stmt` start with `//`, which ends the walk and leaves `head` as a
// comment — no binding, no return, so the site vanished from the scan
// ENTIRELY. That is worse than a missed waiver: any comment placed inside a
// chain silently switched the gate off for that site. Found by mutating a
// waiver down to a bare marker and getting 0 sites instead of a failure.
if (/^\s*(\/\/|\*|\/\*)/.test(prev)) {
start = j;
continue;
}
stmt = prev + " " + stmt;
start = j;
}
return { stmt, start };
}
function valueIsConsumed(line, matchIndex, matchEnd, lines, i) {
// Sliced at the regex's own match END. Re-deriving it by pattern cannot work:
// `.catch(() => {})` has nested parens, and a naive `\([^)]*\)` stops at the
// first one, leaving `=> {});` as the supposed tail — which reads as consumed
// and flags every fire-and-forget cleanup in the repo.
const after = line.slice(matchEnd);
// Reconstruct the whole logical statement, because a chain broken across lines
// puts the binding several lines up and mid-line:
// const restored = await downloadCacheFs
// .rename(backup, targetPath)
// .then(() => true)
// .catch(() => false);
// Testing only the text on the `.catch` line sees leading whitespace and calls
// it discarded — which hides exactly the long chains most worth looking at.
const { start } = readStatement(line, matchIndex, lines, i);
// Judge consumption from the line the STATEMENT BEGINS ON, not from all the
// text swept up on the way there. A multi-line chain can enclose a callback
// .then((r) => {
// if (!r.ok) return; <- belongs to the CALLBACK
// ...
// })
// .catch(() => {});
// and reading the joined text finds that `return` and calls the outer statement
// consumed. It is not: this is fire-and-forget, and the whole gate depends on
// not flagging it.
const head = start === i ? line.slice(0, matchIndex) : lines[start];
// The value leaves the statement.
if (/\b(return|yield)\b/.test(head)) return true;
// Bound to a name — `const x =`, `let [a,b] =`, `this.x =`, `x ||=`, `x ??=`.
if (/\b(const|let|var)\s+[\w{}[\],\s:]+=[^=]/.test(head)) return true;
if (/[\w\])]\s*(\|\||\?\?|&&)?=[^=>]/.test(head)) return true;
// Chained onward: `.catch(() => []).length`, `.catch(() => null)?.id`
if (/\)\s*[.?[]/.test(after)) return true;
// What comes AFTER the catch settles the remaining cases, and it settles them
// better than looking at what comes before. An argument or an array element is
// await load().catch(() => null),
// and a discarded statement is
// await rm(tmp).catch(() => undefined);
// Both begin with `await`, so the leading text cannot tell them apart — the
// trailing punctuation can. A terminating `;` on a statement that binds nothing
// is the one shape where the value provably goes nowhere.
// A trailing line comment is not part of the statement. Without stripping it,
// void p.catch(() => {}); // self-terminates at the deadline
// has a tail of `; // self-terminates…` rather than `;`, and a plainly discarded
// fire-and-forget reads as consumed. Found by sweeping the baseline: it was the
// one entry that could not be explained by the code it pointed at.
const tail = after.replace(/\/\/.*$/, "").replace(/\/\*[^]*$/, "").trim();
// Nothing after the catch means the STATEMENT CONTINUES on the next line, so
// the value has somewhere to go — a discarded call would have terminated.
return tail !== ";";
}
/** Stable across edits: path + exact source text + which occurrence in the file.
* Line numbers were rejected as a key — they churn on every unrelated edit and
* would make the baseline unreviewable. */
function keyFor(rel, snippet, ordinal) {
return `${rel}\t${snippet}\t#${ordinal}`;
}
/** The comment block directly above the site, plus the site's own line.
*
* Walking the contiguous comment lines rather than a fixed lookback is what lets
* a reason WRAP. A good reason is usually two or three lines, which puts the
* `unknown-ok:` marker further above the code than any fixed window would allow
* — and shortening the reason to fit a linter would defeat the point of it. */
function waiverContext(lines, i, start) {
// Include every line of the statement, so a marker on the `.catch` line
// itself still counts, then the contiguous comments above where it BEGINS.
const block = lines.slice(start, i + 1);
for (let j = start - 1; j >= 0; j--) {
const l = lines[j];
if (typeof l !== "string" || !/^\s*(\/\/|\*|\/\*)/.test(l)) break;
block.unshift(l);
}
return block;
}
function findWaiver(lines, i, start) {
const block = waiverContext(lines, i, start);
const at = block.findIndex((l) => /unknown-ok/.test(l));
if (at === -1) return { marked: false, reasoned: false };
// Everything from the marker to the end of the comment block is the reason, so
// a wrapped explanation counts in full rather than only its first line.
const m = /unknown-ok:?(.*)$/.exec(block[at]);
const rest = block
.slice(at + 1)
.filter((l) => /^\s*(\/\/|\*)/.test(l))
.join(" ");
const reason = ((m?.[1] ?? "") + " " + rest).replace(/[*/]/g, " ").trim();
// A reason must be words — not punctuation, and not a ticket number alone.
const words = reason.split(/\s+/).filter((w) => /[a-z]{3}/i.test(w));
return { marked: true, reasoned: words.length >= 4 };
}
function scan() {
const hits = [];
const bare = [];
for (const file of walk(SRC)) {
const rel = relative(SRC, file).split(sep).join("/");
const text = readFileSync(file, "utf8");
const lines = text.split(/\r?\n/);
const counts = new Map();
lines.forEach((line, i) => {
const re = /\.catch\(\s*\(\s*(?:_\w*)?\s*\)\s*=>\s*([^)]*?)\s*\)/g;
let m;
while ((m = re.exec(line))) {
const answer = m[1].trim();
if (!EMPTY_ANSWERS.includes(answer)) continue;
if (!valueIsConsumed(line, m.index, m.index + m[0].length, lines, i)) continue;
const snippet = `.catch(() => ${answer})`;
const ordinal = (counts.get(snippet) ?? 0) + 1;
counts.set(snippet, ordinal);
const rec = { key: keyFor(rel, snippet, ordinal), rel, line: i + 1, text: line.trim() };
const waiver = findWaiver(lines, i, readStatement(line, m.index, lines, i).start);
if (waiver.reasoned) continue;
// A bare marker is not a waiver — the reason is the deliverable.
if (waiver.marked) bare.push(rec);
hits.push(rec);
}
});
}
return { hits, bare };
}
function readBaseline() {
let raw = "";
try {
raw = readFileSync(BASELINE, "utf8");
} catch {
// Genuinely absent is different from unreadable, and this gate of all things
// should not confuse them: an unreadable baseline throws rather than silently
// becoming an empty one, which would fail every pre-existing site at once.
return new Set();
}
return new Set(
raw
.split(/\r?\n/)
.map((l) => l.replace(/\r$/, ""))
.filter((l) => l && !l.startsWith("#")),
);
}
const args = process.argv.slice(2);
const { hits, bare } = scan();
if (args.includes("--list")) {
for (const h of hits) console.log(`${h.rel}:${h.line}\t${h.text}`);
console.log(`\n${hits.length} consuming sites`);
process.exit(0);
}
if (args.includes("--baseline")) {
const header = [
"# #796 — sites where a failed observation is answered with an empty value.",
"#",
"# THIS LIST IS EMPTY, AND A TEST ASSERTS IT STAYS EMPTY. All 38 original",
"# entries have been read: one was a real defect (runpod-ssh reported a sha256",
"# MISMATCH when hashing had FAILED), one was a false positive in this gate",
"# itself, and the other 36 were already correct and now carry a reasoned",
"# `// unknown-ok:` at the site explaining why.",
"#",
"# So there is no debt left to grandfather, and adding a line here is no longer",
"# a slow bypass — it is the only bypass. Clear a new site by fixing it, or by",
"# writing the reason at the site where the next reader will actually see it.",
"#",
"# Regenerate deliberately: node scripts/check-unknown-collapse.mjs --baseline",
"",
].join("\n");
writeFileSync(BASELINE, header + [...hits.map((h) => h.key)].sort().join("\n") + "\n", "utf8");
console.log(`baseline written: ${hits.length} sites`);
process.exit(0);
}
const baseline = readBaseline();
const seen = new Set(hits.map((h) => h.key));
const added = hits.filter((h) => !baseline.has(h.key));
const fixed = [...baseline].filter((k) => !seen.has(k));
let failed = false;
if (added.length) {
failed = true;
console.error(`\n#796 — ${added.length} new site(s) answer a failed observation with an empty value.\n`);
console.error(
"A caught failure means YOU DO NOT KNOW. Returning [] / null / false says you\n" +
"do know, and that the answer is no. Whatever reads this cannot tell the two\n" +
"apart, so it will report a confident wrong answer to a user.\n",
);
for (const h of added) console.error(` ${h.rel}:${h.line}\n ${h.text}`);
console.error(
"\nFix it by giving 'unknown' its own representation — a third state, a tagged\n" +
"result, or a thrown error the caller must handle. If the collapse really is\n" +
"correct here, say why at the site:\n\n" +
" // unknown-ok: <why a failed observation and a genuine negative should\n" +
" // lead to the same behaviour at this call site>\n",
);
}
if (bare.length) {
failed = true;
console.error(`\n#796 — ${bare.length} bare 'unknown-ok' marker(s) with no reason.\n`);
console.error("The reason is the deliverable. A marker alone only hides the question.\n");
for (const h of bare) console.error(` ${h.rel}:${h.line}`);
}
if (fixed.length) {
// Not a failure — a paid debt. But the line has to go, or the list stops
// meaning anything and a re-introduced defect would land pre-approved.
console.error(`\n#796 — ${fixed.length} baseline entr(ies) no longer exist. Delete them:\n`);
for (const k of fixed) console.error(` ${k.replace(/\t/g, " ")}`);
console.error(`\n node scripts/check-unknown-collapse.mjs --baseline\n`);
failed = true;
}
if (!failed) console.log(`#796 gate: ${hits.length} known site(s), no new collapses.`);
process.exit(failed ? 1 : 0);