diff --git a/app/boot.js b/app/boot.js index 1092cf3c..908c5af8 100644 --- a/app/boot.js +++ b/app/boot.js @@ -1,10 +1,40 @@ -// Entry point. Increment 2: the kit's internals exist and the module graph -// loads; no elements are defined yet, so there is nothing to render. +// Entry point. +// +// The whole of what the app does at startup: build the regions, ask the server +// what exists, and mount whatever announced itself. Nothing here names a module. -import { entries } from "./composer/composer.js"; +import { build } from "./shell/layout.js"; +import { fetchManifest } from "./shell/manifest.js"; +import { mountAll } from "./shell/panels.js"; +import { all as allValues } from "./shell/values.js"; +import { composer } from "./composer/composer.js"; -const count = [...entries()].length; -document.querySelector("#app").textContent = - count === 0 - ? "FunPack v5 — Composer internals loaded, no elements defined yet" - : `FunPack v5 — ${count} elements registered`; +const root = document.querySelector("#app"); + +async function start() { + build(root); + + let manifest; + try { + manifest = await fetchManifest(); + } catch (err) { + // The one failure the user must see: with no manifest there is no app, so + // silence here would be an empty window with no explanation. + root.replaceChildren(composer.emptyState.default({ + icon: "▲", + title: "Could not reach FunPack", + hint: `${err.message}. Is ComfyUI running?`, + }).node); + return; + } + + const { mounted, hidden } = await mountAll(manifest); + console.info(`[FunPack] ${mounted.length} module(s) mounted`, + hidden.length ? `· ${hidden.length} hidden` : ""); + for (const { id, why } of hidden) console.warn(`[FunPack] ${id} is hidden: ${why}`); + + // Handy while there is no generate button to send them anywhere. + window.FunPack = { manifest, values: allValues, mounted: mounted.map((m) => m.id), hidden }; +} + +start(); diff --git a/app/catalogue/catalogue.css b/app/catalogue/catalogue.css index ee4f4b34..78f77a22 100644 --- a/app/catalogue/catalogue.css +++ b/app/catalogue/catalogue.css @@ -75,3 +75,10 @@ background: var(--accent-soft); color: var(--accent-hi); font-family: var(--font-mono); font-size: var(--fs-6); cursor: pointer; } + +/* hide.html */ +.cat-mount { display: flex; flex-direction: column; gap: var(--sp-5); } +.cat-ledger { display: grid; grid-template-columns: 160px 1fr 1fr; gap: var(--sp-3) var(--sp-5); + font-size: var(--fs-6); align-items: baseline; } +.cat-why { color: var(--muted); } +.cat-caught { color: var(--faint); font-family: var(--font-mono); font-size: var(--fs-7); } diff --git a/app/catalogue/elements.html b/app/catalogue/elements.html index 6b43e31a..60645156 100644 --- a/app/catalogue/elements.html +++ b/app/catalogue/elements.html @@ -13,6 +13,7 @@

Composer elements

Tokens → + Hide-don't-warn diff --git a/app/catalogue/fixtures.js b/app/catalogue/fixtures.js new file mode 100644 index 00000000..95d5889d --- /dev/null +++ b/app/catalogue/fixtures.js @@ -0,0 +1,63 @@ +// Hide, don't warn -- shown rather than asserted. +// +// One good module and five broken ones, put through the SAME shell code the app +// uses. The good one's panel appears. The broken ones do not appear at all: no +// placeholder, no greyed row, no warning chip. What you see on this page is the +// whole of what a user would see. +// +// These mirror core/tests/fixtures/fixture_modules, so the browser and the +// Python suite are looking at the same five ways of being wrong. + +export const GOOD = { + id: "audio_clock", + title: "Audio clock", + mount: "demo.panel", + stage: "conditioning", + status: "proven", + requires: ["audio_stream"], + settings: { + enabled: { type: "bool", default: true, label: "Sync to audio clock", + hint: "Locks frame timing to the audio stream." }, + strength: { type: "float", default: 0.65, min: 0, max: 1, step: 0.05, + label: "Lock strength", unit: "x", ui: "slider", when: { enabled: true } }, + mode: { type: "enum", default: "beat", label: "Alignment", ui: "segmented", + options: [{ value: "beat", label: "Beat grid" }, + { value: "onset", label: "Onset" }, + { value: "flat", label: "Flat" }], + when: { enabled: true } }, + }, +}; + +// Each of these is refused somewhere different, which is the point: they are +// caught at the earliest layer that can catch them. +export const BROKEN = [ + { + why: "raises on import", + caughtBy: "core: the scan imports it in a try", + spec: { id: "explodes", title: "Explodes", mount: "demo.panel", settings: {}, ui: "/nope/missing.js" }, + }, + { + why: "asks for a renderer its type does not have", + caughtBy: "core: schema validation, before the manifest", + spec: { id: "bad_ui", title: "Bad renderer", mount: "demo.panel", + settings: { on: { type: "bool", default: true, label: "On", ui: "hologram" } } }, + }, + { + why: "a label that is not text", + caughtBy: "core: schema validation; the kit would refuse it too", + spec: { id: "bad_label", title: "Bad label", mount: "demo.panel", + settings: { on: { type: "bool", default: true, label: { markup: "no" } } } }, + }, + { + why: "no default, so a headless run has no value", + caughtBy: "core: schema validation", + spec: { id: "no_default", title: "No default", mount: "demo.panel", + settings: { strength: { type: "float", min: 0, max: 1, label: "Strength" } } }, + }, + { + why: "names a mount point no region offers", + caughtBy: "shell: mounts.js has no host for it", + spec: { id: "lost", title: "Lost module", mount: "somewhere.else", + settings: { on: { type: "bool", default: true, label: "On" } } }, + }, +]; diff --git a/app/catalogue/hide.html b/app/catalogue/hide.html new file mode 100644 index 00000000..67702060 --- /dev/null +++ b/app/catalogue/hide.html @@ -0,0 +1,23 @@ + + + + + + Hide, don't warn + + + + + +
+

Hide, don't warn

+
+ Tokens + Elements + + +
+
+ + + diff --git a/app/catalogue/hide.js b/app/catalogue/hide.js new file mode 100644 index 00000000..a9f37fc8 --- /dev/null +++ b/app/catalogue/hide.js @@ -0,0 +1,86 @@ +// Renders the fixture modules through the real shell. + +import { mountAll } from "../shell/panels.js"; +import { offer, _reset as resetMounts } from "../shell/mounts.js"; +import { _reset as resetValues, all as allValues } from "../shell/values.js"; +import { GOOD, BROKEN } from "./fixtures.js"; + +const el = (tag, cls, text) => { + const n = document.createElement(tag); + if (cls) n.className = cls; + if (text != null) n.textContent = text; + return n; +}; + +async function render(theme) { + resetMounts(); + resetValues(); + + const page = document.querySelector("#catalogue"); + page.textContent = ""; + + const section = el("section", "cat-section"); + section.append(el("h2", null, "What a user sees")); + section.append(el("p", null, + "Six modules go in: one valid, five broken in different ways. Everything below " + + "is the whole of what reaches the screen.")); + + const pane = el("section", "cat-pane"); + pane.setAttribute("data-theme", theme); + pane.append(el("h3", null, theme)); + const host = el("div", "cat-mount"); + pane.append(host); + section.append(pane); + page.append(section); + + offer("demo.panel", host); + + // The broken ones are handed to the shell exactly as the good one is. In the + // real app most never get this far -- core refuses them at import or at + // validation -- so this is the worst case: everything reaching the browser. + const manifest = { modules: [GOOD, ...BROKEN.map((b) => b.spec)] }; + const { mounted, hidden } = await mountAll(manifest); + + if (!host.childElementCount) host.append(el("p", "cat-broke", "nothing mounted at all")); + + // Everything from here down is developer commentary, NOT part of what a user + // would see -- the panel above is. + const ledger = el("section", "cat-section"); + ledger.append(el("h2", null, "Why each one is absent")); + ledger.append(el("p", null, "This table exists for you, not for the user. None of it appears in the app.")); + + const table = el("div", "cat-ledger"); + for (const { id } of mounted) { + table.append(el("div", "cat-ledger-row cat-ok"), el("code", "cat-name", id)); + table.append(el("span", null, "mounted")); + } + for (const { id, why } of hidden) { + const source = BROKEN.find((b) => b.spec.id === id); + table.append(el("code", "cat-name", id)); + table.append(el("span", "cat-why", source ? source.why : "—")); + table.append(el("span", "cat-caught", why)); + } + ledger.append(table); + page.append(ledger); + + const values = el("section", "cat-section"); + values.append(el("h2", null, "Values a headless run would get")); + values.append(el("p", null, + "Seeded from the same declaration the panel renders, so a generation with no " + + "browser open starts from the same numbers.")); + const pre = el("pre", "cx-code-block"); + pre.textContent = JSON.stringify(allValues(), null, 2); + values.append(pre); + page.append(values); +} + +let theme = "dark"; +for (const btn of document.querySelectorAll("[data-theme-set]")) { + btn.addEventListener("click", () => { + theme = btn.getAttribute("data-theme-set"); + window.ComposerTheme.apply(theme === "auto" ? "auto" : theme); + render(theme === "auto" ? window.ComposerTheme.resolved() : theme); + }); +} + +render(theme); diff --git a/app/catalogue/index.html b/app/catalogue/index.html index c5823b50..e6231ca1 100644 --- a/app/catalogue/index.html +++ b/app/catalogue/index.html @@ -13,6 +13,7 @@

Composer catalogue

Elements → + Hide-don't-warn diff --git a/app/composer/elements/choice.css b/app/composer/elements/choice.css index ab2a62c7..6e600cac 100644 --- a/app/composer/elements/choice.css +++ b/app/composer/elements/choice.css @@ -19,20 +19,24 @@ transform: rotate(-45deg) translateY(-1px) scale(.6); opacity: 0; transition: opacity var(--dur-1) var(--ease-standard), transform var(--dur-1) var(--ease-standard); } -.cx-check-input:checked + .cx-check-box { background: var(--accent); border-color: var(--accent); } -.cx-check-input:checked + .cx-check-box::after { opacity: 1; transform: rotate(-45deg) translateY(-1px) scale(1); } -.cx-check-input:indeterminate + .cx-check-box { background: var(--accent); border-color: var(--accent); } -.cx-check-input:indeterminate + .cx-check-box::after { +.cx-check-input:checked + .cx-check-face .cx-check-box { background: var(--accent); border-color: var(--accent); } +.cx-check-input:checked + .cx-check-face .cx-check-box::after { opacity: 1; transform: rotate(-45deg) translateY(-1px) scale(1); } +.cx-check-input:indeterminate + .cx-check-face .cx-check-box { background: var(--accent); border-color: var(--accent); } +.cx-check-input:indeterminate + .cx-check-face .cx-check-box::after { opacity: 1; width: 8px; height: 0; border-bottom: 2px solid var(--on-accent); border-left: 0; transform: none; } -.cx-check-input:focus-visible + .cx-check-box { border-color: var(--accent); box-shadow: var(--focus-ring); } -.cx-check-input:disabled + .cx-check-box { opacity: var(--disabled-opacity); } +.cx-check-input:focus-visible + .cx-check-face .cx-check-box { border-color: var(--accent); box-shadow: var(--focus-ring); } +.cx-check-input:disabled + .cx-check-face .cx-check-box { opacity: var(--disabled-opacity); } -.cx-check-row, .cx-radio, .cx-toggle-row { +/* The input sits alongside its label, not inside it -- see choice.js. The row + * is the positioning context; the .cx-check-face label is what you click. */ +.cx-check-row, .cx-radio, .cx-toggle-row { position: relative; } +.cx-check-face { display: flex; align-items: flex-start; gap: var(--sp-4); cursor: pointer; padding: var(--sp-2) 0; } +.cx-toggle-face { align-items: center; justify-content: space-between; gap: var(--sp-6); } .cx-check-text { display: flex; flex-direction: column; gap: 2px; } .cx-check-label { font-size: var(--fs-4); line-height: var(--lh-4); color: var(--text); } .cx-checklist { @@ -54,9 +58,9 @@ background: var(--accent); transform: scale(0); transition: transform var(--dur-1) var(--ease-standard); } -.cx-radio-input:checked + .cx-radio-dot { border-color: var(--accent); } -.cx-radio-input:checked + .cx-radio-dot::after { transform: scale(1); } -.cx-radio-input:focus-visible + .cx-radio-dot { border-color: var(--accent); box-shadow: var(--focus-ring); } +.cx-radio-input:checked + .cx-check-face .cx-radio-dot { border-color: var(--accent); } +.cx-radio-input:checked + .cx-check-face .cx-radio-dot::after { transform: scale(1); } +.cx-radio-input:focus-visible + .cx-check-face .cx-radio-dot { border-color: var(--accent); box-shadow: var(--focus-ring); } .cx-select-md { height: var(--ctl-h-lg); padding: 0 var(--ctl-pad-x-md); font-size: var(--fs-4); } .cx-select-sm { height: var(--ctl-h-md); padding: 0 var(--ctl-pad-x-sm); font-size: var(--fs-5); } @@ -77,7 +81,7 @@ .cx-segment:hover { background: var(--hover); color: var(--text); } .cx-segment.cx-on { background: var(--accent-soft); color: var(--accent-hi); } -.cx-toggle-row { align-items: center; justify-content: space-between; gap: var(--sp-6); } + .cx-toggle-track { width: 34px; height: 20px; flex: none; border-radius: var(--r-pill); background: var(--line); position: relative; @@ -88,6 +92,6 @@ border-radius: var(--r-pill); background: var(--elev-1); transition: transform var(--dur-1) var(--ease-standard); } -.cx-toggle-input:checked + .cx-toggle-track { background: var(--accent); } -.cx-toggle-input:checked + .cx-toggle-track .cx-toggle-knob { transform: translateX(14px); } -.cx-toggle-input:focus-visible + .cx-toggle-track { box-shadow: var(--focus-ring); } +.cx-toggle-input:checked + .cx-check-face .cx-toggle-track { background: var(--accent); } +.cx-toggle-input:checked + .cx-check-face .cx-toggle-track .cx-toggle-knob { transform: translateX(14px); } +.cx-toggle-input:focus-visible + .cx-check-face .cx-toggle-track { box-shadow: var(--focus-ring); } diff --git a/app/composer/elements/choice.js b/app/composer/elements/choice.js index b0152a8e..e0742a76 100644 --- a/app/composer/elements/choice.js +++ b/app/composer/elements/choice.js @@ -50,7 +50,16 @@ define("checkboxRow", "default", ({ label, hint, checked, disabled, onChange } = hint ? el("span", { cls: "cx-hint", text: hint, attrs: { id: hintId } }) : null, ].filter(Boolean) }); - const node = el("label", { cls: "cx-check-row", attrs: { for: id }, children: [input, box, text] }); + // The input is a SIBLING of its label, not a child of it. + // + // Both forms are valid, and browsers guard a nested control against being + // activated twice by its own label. Keeping them separate means never relying + // on that guard -- the CSS reaches the box through the label instead of past + // it, which costs one selector and removes a whole class of subtlety. + const node = el("div", { cls: "cx-check-row", children: [ + input, + el("label", { cls: "cx-check-face", attrs: { for: id }, children: [box, text] }), + ] }); return { node, get value() { return input.checked; }, @@ -69,8 +78,11 @@ define("checklist", "default", ({ items = [], values = [], onChange, label } = { input.checked ? chosen.add(item.value) : chosen.delete(item.value); if (onChange) onChange([...chosen]); }); - node.append(el("label", { cls: "cx-check-row", attrs: { for: id }, children: [ - input, box, el("span", { cls: "cx-check-label", text: item.label }), + node.append(el("div", { cls: "cx-check-row", children: [ + input, + el("label", { cls: "cx-check-face", attrs: { for: id }, children: [ + box, el("span", { cls: "cx-check-label", text: item.label }), + ] }), ] })); } return { @@ -105,13 +117,15 @@ define("radioGroup", "default", ({ options = [], value, onChange, orientation = current = option.value; if (onChange) onChange(current); }); - node.append(el("label", { cls: "cx-radio", attrs: { for: id }, children: [ + node.append(el("div", { cls: "cx-radio", children: [ input, - el("span", { cls: "cx-radio-dot", attrs: { "aria-hidden": "true" } }), - el("span", { cls: "cx-check-text", children: [ - el("span", { cls: "cx-check-label", text: option.label }), - option.hint ? el("span", { cls: "cx-hint", text: option.hint }) : null, - ].filter(Boolean) }), + el("label", { cls: "cx-check-face", attrs: { for: id }, children: [ + el("span", { cls: "cx-radio-dot", attrs: { "aria-hidden": "true" } }), + el("span", { cls: "cx-check-text", children: [ + el("span", { cls: "cx-check-label", text: option.label }), + option.hint ? el("span", { cls: "cx-hint", text: option.hint }) : null, + ].filter(Boolean) }), + ] }), ] })); return { option, input }; }); @@ -190,14 +204,16 @@ define("toggle", "default", ({ label, hint, checked, disabled, onChange } = {}) input.checked = Boolean(checked); if (onChange) input.addEventListener("change", () => onChange(input.checked)); - const node = el("label", { cls: "cx-toggle-row", attrs: { for: id }, children: [ - el("span", { cls: "cx-check-text", children: [ - el("span", { cls: "cx-check-label", text: label }), - hint ? el("span", { cls: "cx-hint", text: hint }) : null, - ].filter(Boolean) }), + const node = el("div", { cls: "cx-toggle-row", children: [ input, - el("span", { cls: "cx-toggle-track", attrs: { "aria-hidden": "true" }, - children: el("span", { cls: "cx-toggle-knob" }) }), + el("label", { cls: "cx-check-face cx-toggle-face", attrs: { for: id }, children: [ + el("span", { cls: "cx-check-text", children: [ + el("span", { cls: "cx-check-label", text: label }), + hint ? el("span", { cls: "cx-hint", text: hint }) : null, + ].filter(Boolean) }), + el("span", { cls: "cx-toggle-track", attrs: { "aria-hidden": "true" }, + children: el("span", { cls: "cx-toggle-knob" }) }), + ] }), ] }); return { diff --git a/app/composer/panel.js b/app/composer/panel.js new file mode 100644 index 00000000..1dc5cc73 --- /dev/null +++ b/app/composer/panel.js @@ -0,0 +1,173 @@ +// A module's settings declaration, rendered. +// +// This is the bridge the whole architecture turns on: one declaration renders +// the panel, carries the defaults and names the values that come back. A module +// writes no DOM and reads no DOM -- it says what it needs and gets values. +// +// The panel builds into a DETACHED fragment. If any part of it throws -- an +// unknown renderer, a malformed option -- nothing has been appended, so the +// module is absent rather than half-rendered. That is "hide, don't warn" at the +// level where it actually has to hold. + +import { composer } from "./internals/register.js"; +import { el, frag } from "./internals/el.js"; + +/** The renderer a type gets when the declaration does not ask for one. */ +export const DEFAULT_UI = { + bool: "checkboxRow", + int: "number", + float: "number", + enum: "select", + text: "input", + multiline: "textarea", + path: "filterList", +}; + +// Renderers that carry their own visible label, so wrapping them in a settings +// row would print the label twice. +const SELF_LABELLING = new Set(["checkboxRow", "toggle"]); + +const RENDERERS = { + checkboxRow: (s, v, on) => composer.checkboxRow.default({ label: s.label, hint: s.hint, checked: v, onChange: on }), + toggle: (s, v, on) => composer.toggle.default({ label: s.label, hint: s.hint, checked: v, onChange: on }), + + number: (s, v, on) => composer.number.md({ + value: v, min: s.min, max: s.max, step: s.step, unit: s.unit, + precision: s.type === "int" ? 0 : s.precision, label: s.label, onChange: on }), + stepper: (s, v, on) => composer.stepper.md({ + value: v, min: s.min, max: s.max, step: s.step, label: s.label, onChange: on }), + slider: (s, v, on) => composer.slider.readout({ + value: v, min: s.min ?? 0, max: s.max ?? 1, step: s.step ?? 0.01, + unit: s.unit ?? "", precision: s.type === "int" ? 0 : 2, label: s.label, onCommit: on, onChange: on }), + macroSlider: (s, v, on) => composer.slider.macro({ + value: v, min: s.min ?? 0, max: s.max ?? 1, step: s.step ?? 0.01, + unit: s.unit ?? "", presets: s.presets ?? [], label: s.label, onCommit: on, onChange: on }), + + select: (s, v, on) => composer.select.md({ options: s.options, value: v, label: s.label, onChange: on }), + segmented: (s, v, on) => composer.segmented.md({ options: s.options, value: v, label: s.label, onChange: on }), + radioGroup: (s, v, on) => composer.radioGroup.default({ options: s.options, value: v, label: s.label, onChange: on }), + filterList: (s, v, on) => composer.filterList.md({ + items: (s.options || []).map((o) => ({ id: o.value, label: o.label, hint: o.hint })), + value: v, placeholder: s.placeholder, onChange: on }), + + // An overlay, so the row holds a button showing the current choice. Opening a + // wheel from a settings panel is unusual; being able to is what lets a module + // ask for one without writing any JavaScript. + wheel: (s, v, on) => { + const labelFor = (value) => (s.options.find((o) => o.value === value) || {}).label ?? String(value); + const button = composer.button.md({ + label: labelFor(v), + onClick: () => composer.wheel.picker({ + items: s.options.map((o) => ({ label: o.label, icon: o.icon, value: o.value })), + onPick: (item) => { button.setLabel(labelFor(item.value)); on(item.value); }, + }), + }); + return button; + }, + + input: (s, v, on) => composer.input.md({ value: v, placeholder: s.placeholder, label: s.label, onCommit: on }), + search: (s, v, on) => composer.search.md({ value: v, placeholder: s.placeholder, label: s.label, onCommit: on }), + textarea: (s, v, on) => composer.textarea.md({ value: v, placeholder: s.placeholder, label: s.label, onCommit: on }), + autoTextarea: (s, v, on) => composer.textarea.md({ + value: v, placeholder: s.placeholder, label: s.label, autoGrow: true, onCommit: on }), +}; + +/** Is this setting's `when` satisfied by the current values? */ +export function visible(setting, values) { + const when = setting.when; + if (!when) return true; + return Object.entries(when).every(([key, expected]) => + (Array.isArray(expected) ? expected : [expected]).includes(values[key])); +} + +export function rendererFor(setting) { + // Core refuses a setting with no default long before the browser sees it, so + // this is belt and braces -- but the panel is the layer that cannot proceed + // without one: it would render a control holding `undefined` and report that + // as the value the moment anything read it. + if (!Object.prototype.hasOwnProperty.call(setting, "default")) { + throw new TypeError(`Setting has no default, so there is nothing to render it with.`); + } + const name = setting.ui || DEFAULT_UI[setting.type]; + const render = RENDERERS[name]; + if (!render) { + // Refusing here rather than falling back to something plausible: a panel + // that silently renders a different control is worse than one that is + // absent, because nobody notices it is wrong. + throw new TypeError(`No renderer for "${name}" (setting type "${setting.type}").`); + } + return render; +} + +/** + * renderPanel(spec, { values, onChange }) -> { node, values, destroy } + * + * `values` starts from the module's declared defaults, so a panel and a + * headless run begin from the same numbers. + */ +export function renderPanel(spec, { values: initial, onChange } = {}) { + const settings = spec.settings || {}; + const values = { ...defaultsOf(spec), ...(initial || {}) }; + + const rows = el("div", { cls: "cx-group-rows" }); + const handles = new Map(); + + function build() { + // Into a fragment first: a row that throws must not leave the previous + // half of the panel on screen. + const built = frag(); + for (const [key, setting] of Object.entries(settings)) { + if (!visible(setting, values)) continue; + const render = rendererFor(setting); + const control = render(setting, values[key], (next) => { + values[key] = next; + if (onChange) onChange(key, next, { ...values }); + // Another setting's visibility may depend on this one. + if (dependents.has(key)) redraw(); + }); + handles.set(key, control); + built.appendChild( + SELF_LABELLING.has(setting.ui || DEFAULT_UI[setting.type]) + ? control.node + : composer.settingsRow.default({ label: setting.label, hint: setting.hint, control }).node + ); + } + return built; + } + + // Which keys other rows watch, so a change only redraws when it can matter. + const dependents = new Set(); + for (const setting of Object.values(settings)) { + for (const key of Object.keys(setting.when || {})) dependents.add(key); + } + + function redraw() { + for (const handle of handles.values()) if (handle.destroy) handle.destroy(); + handles.clear(); + rows.replaceChildren(build()); + } + + rows.appendChild(build()); + + const node = el("section", { cls: "cx-group", children: [ + el("div", { cls: "cx-eyebrow", text: spec.title }), + rows, + ] }); + + return { + node, + get values() { return { ...values }; }, + setValue(key, next) { values[key] = next; redraw(); }, + destroy() { + for (const handle of handles.values()) if (handle.destroy) handle.destroy(); + node.remove(); + }, + }; +} + +/** The same defaults core derives, computed in the browser from the same source. */ +export function defaultsOf(spec) { + const out = {}; + for (const [key, setting] of Object.entries(spec.settings || {})) out[key] = setting.default; + return out; +} diff --git a/app/composer/tests/elements_behaviour.test.js b/app/composer/tests/elements_behaviour.test.js index 7ceb9fdc..1d2d94b8 100644 --- a/app/composer/tests/elements_behaviour.test.js +++ b/app/composer/tests/elements_behaviour.test.js @@ -88,7 +88,10 @@ test("a checkbox row is labelled by its visible text, not by aria-label", () => const c = composer.checkboxRow.default({ label: "Sync", hint: "Locks timing" }); const input = c.node.querySelector("input"); assert.equal(input.getAttribute("aria-label"), null); - assert.equal(c.node.getAttribute("for"), input.id); + const face = c.node.querySelector("label.cx-check-face"); + assert.equal(face.getAttribute("for"), input.id, + "the label points at the input rather than wrapping it"); + assert.equal(face.contains(input), false); assert.ok(input.getAttribute("aria-describedby"), "the hint is wired as the description"); }); diff --git a/app/composer/tests/lint_fixtures/clean/ui.js b/app/composer/tests/lint_fixtures/clean/ui.js new file mode 100644 index 00000000..c59a0bff --- /dev/null +++ b/app/composer/tests/lint_fixtures/clean/ui.js @@ -0,0 +1,7 @@ +export function setup({ composer, values }) { + const button = composer.button.md({ + label: "Re-key", + onClick: () => values.set("enabled", true), + }); + return button; +} diff --git a/app/composer/tests/lint_fixtures/offenders/ui.js b/app/composer/tests/lint_fixtures/offenders/ui.js new file mode 100644 index 00000000..e6ff8691 --- /dev/null +++ b/app/composer/tests/lint_fixtures/offenders/ui.js @@ -0,0 +1,12 @@ +export function setup({ composer }) { + const b = composer.button.md({ label: "x" }); + b.node.style.color = "#ff0000"; + b.node.classList.add("mine"); + b.node.innerHTML = "hi"; + const smuggled = '
still styling
'; + const raw = document.createElement("div"); + raw.setAttribute("style", "margin: 12px"); + document.head.appendChild(raw); + void smuggled; + return new Function("return 1")(); +} diff --git a/app/composer/tests/panel_schema.test.js b/app/composer/tests/panel_schema.test.js new file mode 100644 index 00000000..0f0f5a6d --- /dev/null +++ b/app/composer/tests/panel_schema.test.js @@ -0,0 +1,181 @@ +// The bridge: a declaration in, a panel out. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { setupDom, teardownDom, fire } from "./_dom.js"; + +let renderPanel, defaultsOf, visible, rendererFor, DEFAULT_UI; +test.before(async () => { + setupDom(); + await import("../composer.js"); // elements register themselves + ({ renderPanel, defaultsOf, visible, rendererFor, DEFAULT_UI } = await import("../panel.js")); +}); +test.after(() => teardownDom()); + +// Panels are built detached and then mounted; jsdom only runs a control's +// activation behaviour once it is in the document, so anything that clicks has +// to mount first -- which is what the shell does anyway. +const mount = (panel) => { document.body.appendChild(panel.node); return panel; }; + +const SPEC = { + id: "audio_clock", + title: "Audio clock", + settings: { + enabled: { type: "bool", default: true, label: "Sync to audio clock", hint: "Locks frame timing." }, + strength: { type: "float", default: 0.65, min: 0, max: 1, step: 0.05, + label: "Lock strength", unit: "x", ui: "slider", when: { enabled: true } }, + mode: { type: "enum", default: "beat", label: "Alignment", ui: "segmented", + options: [{ value: "beat", label: "Beat grid" }, { value: "onset", label: "Onset" }], + when: { enabled: true } }, + }, +}; + +test("defaults come from the declaration, not from the controls", () => { + assert.deepEqual(defaultsOf(SPEC), { enabled: true, strength: 0.65, mode: "beat" }); +}); + +test("a panel renders one row per visible setting", () => { + const panel = renderPanel(SPEC); + assert.equal(panel.node.querySelectorAll(".cx-check-row, .cx-settings-row").length, 3); + assert.match(panel.node.textContent, /Audio clock/); + panel.destroy(); +}); + +test("the declared renderer is used, not the default for the type", () => { + const panel = renderPanel(SPEC); + assert.ok(panel.node.querySelector(".cx-slider"), "strength asked for a slider"); + assert.ok(panel.node.querySelector(".cx-segmented"), "mode asked for segmented"); + assert.equal(panel.node.querySelector("select"), null, "and not the enum default"); + panel.destroy(); +}); + +test("a type with no declared renderer gets its default", () => { + const panel = renderPanel({ title: "T", settings: { + n: { type: "float", default: 1, label: "N" }, + t: { type: "text", default: "x", label: "T" }, + } }); + assert.equal(DEFAULT_UI.float, "number"); + assert.ok(panel.node.querySelector("input[type=number]")); + assert.ok(panel.node.querySelector("input[type=text]")); + panel.destroy(); +}); + +test("a self-labelling control is not wrapped in a second label", () => { + const panel = renderPanel({ title: "T", settings: { + on: { type: "bool", default: true, label: "Enabled" }, + } }); + const labels = [...panel.node.querySelectorAll("*")] + .filter((n) => n.textContent.trim() === "Enabled" && !n.children.length); + assert.equal(labels.length, 1, "the label appears once, not once per wrapper"); + panel.destroy(); +}); + +// --- conditional rows ------------------------------------------------------ + +test("a row whose condition fails is absent, not disabled", () => { + // Hide, don't warn, one level down: a greyed control invites clicking on + // something that cannot do anything. + const panel = renderPanel(SPEC, { values: { enabled: false } }); + assert.match(panel.node.textContent, /Sync to audio clock/); + assert.doesNotMatch(panel.node.textContent, /Lock strength/); + assert.equal(panel.node.querySelector("[disabled]"), null); + panel.destroy(); +}); + +test("changing the key a row depends on brings it back", () => { + const panel = mount(renderPanel(SPEC, { values: { enabled: false } })); + assert.doesNotMatch(panel.node.textContent, /Lock strength/); + panel.node.querySelector("input[type=checkbox]").click(); + assert.match(panel.node.textContent, /Lock strength/); + panel.destroy(); +}); + +test("visible() takes a list as any-of", () => { + const setting = { when: { mode: ["beat", "onset"] } }; + assert.equal(visible(setting, { mode: "onset" }), true); + assert.equal(visible(setting, { mode: "flat" }), false); +}); + +test("a setting with no condition is always visible", () => { + assert.equal(visible({}, {}), true); +}); + +// --- values ---------------------------------------------------------------- + +test("edits are reported with the key and the whole value set", () => { + const seen = []; + const panel = mount(renderPanel(SPEC, { onChange: (key, value, all) => seen.push([key, value, all.mode]) })); + panel.node.querySelector("input[type=checkbox]").click(); + assert.deepEqual(seen[0].slice(0, 2), ["enabled", false]); + assert.equal(seen[0][2], "beat", "the rest of the values come along"); + panel.destroy(); +}); + +test("the panel's values start from the defaults and track edits", () => { + const panel = mount(renderPanel(SPEC)); + assert.deepEqual(panel.values, { enabled: true, strength: 0.65, mode: "beat" }); + + const slider = panel.node.querySelector(".cx-slider input"); + slider.value = "0.3"; + fire(slider, "input"); + assert.equal(panel.values.strength, 0.3); + panel.destroy(); +}); + +test("supplied values override the defaults", () => { + const panel = renderPanel(SPEC, { values: { strength: 0.2 } }); + assert.equal(panel.values.strength, 0.2); + assert.equal(panel.values.mode, "beat", "and the rest still come from the declaration"); + panel.destroy(); +}); + +// --- refusing rather than approximating ------------------------------------ + +test("a setting with no default is refused by the panel too", () => { + // Core catches this first; the panel is the layer that cannot proceed without + // one, since it would render a control holding undefined. + assert.throws(() => rendererFor({ type: "float", min: 0, max: 1 }), /no default/); +}); + +test("an unknown renderer throws instead of falling back", () => { + // A panel that silently renders a different control is worse than an absent + // one, because nobody notices it is wrong. + assert.throws(() => rendererFor({ type: "bool", ui: "hologram" }), TypeError); +}); + +test("a panel that cannot be built appends nothing", () => { + const host = document.createElement("div"); + document.body.appendChild(host); + try { + const panel = renderPanel({ title: "Broken", settings: { + ok: { type: "bool", default: true, label: "Fine" }, + bad: { type: "bool", default: true, label: "Bad", ui: "hologram" }, + } }); + host.appendChild(panel.node); + assert.fail("should have thrown"); + } catch (err) { + assert.ok(err instanceof TypeError); + assert.equal(host.childElementCount, 0, "nothing half-built reached the document"); + } +}); + +test("every renderer named by the contract exists", async () => { + // The contract and the kit have to agree: a hint the schema accepts but the + // kit cannot render would make every module using it hide itself. + const HINTS = { + bool: ["checkboxRow", "toggle"], + int: ["number", "slider", "stepper", "macroSlider"], + float: ["number", "slider", "stepper", "macroSlider"], + enum: ["select", "segmented", "radioGroup", "filterList", "wheel"], + text: ["input", "search"], + multiline: ["textarea", "autoTextarea"], + path: ["filterList"], + }; + for (const [type, hints] of Object.entries(HINTS)) { + for (const ui of hints) { + assert.doesNotThrow(() => rendererFor({ type, ui, default: null }), `${type} + ${ui}`); + } + assert.doesNotThrow(() => rendererFor({ type, default: null }), `${type} default`); + } +}); diff --git a/app/composer/tests/rule_no_module_styling.test.js b/app/composer/tests/rule_no_module_styling.test.js new file mode 100644 index 00000000..f0467bee --- /dev/null +++ b/app/composer/tests/rule_no_module_styling.test.js @@ -0,0 +1,72 @@ +// Rule 1: a module never styles anything. +// +// The runtime half is stronger than this -- a module is handed { composer, +// values, on } and nothing that could style or build -- but a scanner catches +// the attempt in the file rather than at the moment it would have run. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { readFileSync } from "node:fs"; + +import { scan, scanSource, code } from "../../../tools/kit_lint/scan.js"; +import { MODULE_RULES, KIT_RULES } from "../../../tools/kit_lint/rules.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repo = join(here, "..", "..", ".."); +const FIXTURES = join(here, "lint_fixtures"); + +test("every module in the tree is clean", () => { + // Empty today; the value is that it stays empty as modules arrive. + const violations = scan(join(repo, "modules"), MODULE_RULES); + assert.deepEqual(violations, [], + violations.map((v) => `${v.file}:${v.line} ${v.rule} -- ${v.why}`).join("\n")); +}); + +test("the kit never interprets content", () => { + // The kit may style and may build elements; that is its job. What it may not + // do is parse anything a module gave it. + const violations = scan(join(repo, "app", "composer"), KIT_RULES); + assert.deepEqual(violations, [], + violations.map((v) => `${v.file}:${v.line} ${v.rule}`).join("\n")); +}); + +test("the shell never interprets content either", () => { + assert.deepEqual(scan(join(repo, "app", "shell"), KIT_RULES), []); +}); + +// --- the scanner has to actually catch things ------------------------------ + +test("a clean module passes", () => { + const source = readFileSync(join(FIXTURES, "clean", "ui.js"), "utf8"); + assert.deepEqual(scanSource(source, MODULE_RULES), []); +}); + +test("the scanner catches every rule it claims to", () => { + // Without this, "zero violations" could mean "the scanner is broken" -- which + // is the failure mode of every lint nobody tests. + const source = readFileSync(join(FIXTURES, "offenders", "ui.js"), "utf8"); + const caught = new Set(scanSource(source, MODULE_RULES).map((v) => v.rule)); + for (const rule of ["inline-style", "style-attribute", "class-manipulation", + "raw-element", "markup", "stylesheet", "hardcoded-colour", + "hardcoded-size", "eval"]) { + assert.ok(caught.has(rule), `the offender fixture was not caught by "${rule}"`); + } +}); + +test("prose is not code", () => { + // Otherwise every rule becomes unmentionable in the comment explaining it. + const commented = ` + // never write innerHTML here + /* and #ff0000 and 12px are fine to discuss */ + export const ok = 1; + `; + assert.deepEqual(scanSource(commented, MODULE_RULES), []); + assert.ok(!code(commented).includes("innerHTML")); +}); + +test("a url containing a hash is not a colour", () => { + assert.deepEqual(scanSource('export const a = "#abc";', MODULE_RULES).map((v) => v.rule), + ["hardcoded-colour"], "a real colour is caught"); +}); diff --git a/app/composer/tests/rule_unknown_element_hides.test.js b/app/composer/tests/rule_unknown_element_hides.test.js new file mode 100644 index 00000000..f56b1710 --- /dev/null +++ b/app/composer/tests/rule_unknown_element_hides.test.js @@ -0,0 +1,121 @@ +// Rule 3: an element the kit does not have makes its module hide. +// +// The alternative is a panel that renders something approximate, which is worse +// than nothing: it looks like it works. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { setupDom, teardownDom } from "./_dom.js"; + +let composer, UnknownElement, renderPanel, mountAll, mounts; +test.before(async () => { + setupDom(); + ({ composer, UnknownElement } = await import("../composer.js")); + ({ renderPanel } = await import("../panel.js")); + ({ mountAll } = await import("../../shell/panels.js")); + mounts = await import("../../shell/mounts.js"); +}); +test.after(() => teardownDom()); + +test.beforeEach(() => { + mounts._reset(); + document.body.replaceChildren(); +}); + +const spec = (id, settings) => ({ id, title: id, mount: "test.region", settings }); + +test("asking the kit for something it does not have throws", () => { + assert.throws(() => composer.hologram, UnknownElement); + assert.throws(() => composer.button.hologram, UnknownElement); +}); + +test("a panel naming an unknown renderer appends nothing", () => { + const host = document.createElement("div"); + document.body.appendChild(host); + assert.throws(() => { + const panel = renderPanel(spec("bad", { + good: { type: "bool", default: true, label: "Fine" }, + bad: { type: "bool", default: true, label: "Bad", ui: "hologram" }, + })); + host.appendChild(panel.node); + }); + assert.equal(host.childElementCount, 0, + "not even the rows that built successfully reached the document"); +}); + +test("a broken module is absent, and its neighbours still mount", async () => { + const host = document.createElement("div"); + document.body.appendChild(host); + mounts.offer("test.region", host); + + const { mounted, hidden } = await mountAll({ modules: [ + spec("first", { on: { type: "bool", default: true, label: "First" } }), + spec("broken", { on: { type: "bool", default: true, label: "Broken", ui: "hologram" } }), + spec("third", { on: { type: "bool", default: true, label: "Third" } }), + ] }); + + assert.deepEqual(mounted.map((m) => m.id), ["first", "third"]); + assert.deepEqual(hidden.map((h) => h.id), ["broken"]); + assert.match(host.textContent, /First/); + assert.match(host.textContent, /Third/); + assert.doesNotMatch(host.textContent, /Broken/); +}); + +test("nothing about a hidden module reaches the screen", async () => { + // No placeholder, no greyed row, no warning chip: absent is absent. + const host = document.createElement("div"); + document.body.appendChild(host); + mounts.offer("test.region", host); + + await mountAll({ modules: [spec("broken", { + on: { type: "bool", default: true, label: "Broken", ui: "hologram" }, + })] }); + + assert.equal(host.childElementCount, 0); + assert.equal(host.textContent, ""); +}); + +test("a module whose ui.js fails to load is hidden, not half-mounted", async () => { + const host = document.createElement("div"); + document.body.appendChild(host); + mounts.offer("test.region", host); + + const { mounted, hidden } = await mountAll( + { modules: [{ ...spec("has_ui", { on: { type: "bool", default: true, label: "On" } }), ui: "/missing.js" }] }, + { load: () => { throw new Error("404"); } }, + ); + + assert.deepEqual(mounted, []); + assert.equal(hidden[0].id, "has_ui"); + assert.equal(host.childElementCount, 0, "its panel did not stay behind"); +}); + +test("a module naming a mount point nobody offers is absent", async () => { + // Hide, don't warn, one level up: a typo'd mount point makes a module vanish + // rather than landing somewhere arbitrary. + const { mounted, hidden } = await mountAll({ modules: [ + { ...spec("lost", { on: { type: "bool", default: true, label: "On" } }), mount: "nowhere" }, + ] }); + assert.deepEqual(mounted, []); + assert.match(hidden[0].why, /no region offers/); +}); + +test("a module is handed no way to touch the document", async () => { + const host = document.createElement("div"); + document.body.appendChild(host); + mounts.offer("test.region", host); + + let context = null; + await mountAll( + { modules: [{ ...spec("m", { on: { type: "bool", default: true, label: "On" } }), ui: "/ui.js" }] }, + { load: async () => ({ setup: (ctx) => { context = ctx; } }) }, + ); + + assert.deepEqual(Object.keys(context).sort(), ["composer", "on", "shell", "values"]); + for (const value of Object.values(context)) { + assert.equal(value instanceof globalThis.Node, false, "no DOM node is reachable from a module"); + } + // The service surface is named one by one, so it can be reviewed. + assert.deepEqual(Object.keys(context.shell).sort(), ["density", "theme"]); +}); diff --git a/app/index.html b/app/index.html index 24291018..c1e161de 100644 --- a/app/index.html +++ b/app/index.html @@ -1,9 +1,18 @@ - + FunPack + + +
diff --git a/app/shell/layout.js b/app/shell/layout.js new file mode 100644 index 00000000..34dcf746 --- /dev/null +++ b/app/shell/layout.js @@ -0,0 +1,35 @@ +// The app's regions, and the mount points each one offers. +// +// A region declares what it can host. Core never learns these names, and no +// list of them exists anywhere else -- a module asks for one by name, and gets +// it or does not. + +import { composer } from "../composer/composer.js"; +import { offer } from "./mounts.js"; + +export function build(root) { + const general = composer.panel.default({ + title: "Settings", + body: composer.hint.default({ text: "Modules that announced a settings.general mount point." }), + }); + const generation = composer.panel.default({ + title: "Generation", + body: composer.hint.default({ text: "Modules that announced a generation.* mount point." }), + }); + + const page = composer.splitPane.h({ + size: 46, + label: "Settings and generation", + panes: [general, generation], + }); + + root.replaceChildren(page.node); + + // The mount point IS the contract with modules; the region behind it can be + // rearranged freely as long as the name survives. + offer("settings.general", general.body); + offer("generation.timing", generation.body); + offer("generation.model", generation.body); + + return { general, generation, page }; +} diff --git a/app/shell/manifest.js b/app/shell/manifest.js new file mode 100644 index 00000000..218c4198 --- /dev/null +++ b/app/shell/manifest.js @@ -0,0 +1,10 @@ +// What the server says exists. + +const ENDPOINT = "/funpack/api/modules"; + +export async function fetchManifest(traits) { + const url = traits ? `${ENDPOINT}?traits=${encodeURIComponent(traits.join(","))}` : ENDPOINT; + const response = await fetch(url); + if (!response.ok) throw new Error(`${ENDPOINT} answered ${response.status}`); + return response.json(); +} diff --git a/app/shell/mounts.js b/app/shell/mounts.js new file mode 100644 index 00000000..00a0ebfb --- /dev/null +++ b/app/shell/mounts.js @@ -0,0 +1,24 @@ +// Mount points are ANNOUNCED by the regions that offer them. +// +// A fixed list in core would be a global list -- the one thing the architecture +// avoids -- and it would have to be edited every time the shell grows a region. +// Instead a region says what it can host, and a module naming something no +// region offers is simply absent. That is "hide, don't warn" one level up. + +const regions = new Map(); // mount point -> host element + +export function offer(mount, host) { + if (regions.has(mount)) { + throw new Error(`Mount point "${mount}" is already offered by another region.`); + } + regions.set(mount, host); + return () => regions.delete(mount); +} + +export const hostFor = (mount) => regions.get(mount) || null; +export const offered = () => [...regions.keys()].sort(); +export const claimed = (mount) => regions.has(mount); + +export function _reset() { + regions.clear(); +} diff --git a/app/shell/panels.js b/app/shell/panels.js new file mode 100644 index 00000000..cbe68596 --- /dev/null +++ b/app/shell/panels.js @@ -0,0 +1,76 @@ +// Turning announcements into panels. +// +// Everything a module can reach is decided here, and it is deliberately little: +// { composer, values, on }. No DOM node, no document, no kit internals. A module +// cannot style anything because it is never handed anything that could. + +import { composer } from "../composer/composer.js"; +import { renderPanel, defaultsOf } from "../composer/panel.js"; +import { hostFor, offered } from "./mounts.js"; +import * as values from "./values.js"; +import { services } from "./services.js"; + +/** + * mountAll(manifest) -> { mounted, hidden } + * + * `hidden` is diagnostic only. Nothing about a hidden module reaches the screen: + * no placeholder, no greyed row, no warning chip. A module that failed is + * indistinguishable from one that was never installed, which is the point -- + * the alternative is a UI full of things that cannot be used. + */ +export async function mountAll(manifest, { load = (path) => import(path) } = {}) { + const mounted = []; + const hidden = []; + + for (const spec of manifest.modules || []) { + const host = hostFor(spec.mount); + if (!host) { + hidden.push({ id: spec.id, why: `no region offers "${spec.mount}" (offered: ${offered().join(", ") || "none"})` }); + continue; + } + + try { + // Values first: a module's ui.js may read them during setup, and a + // headless run has them without any of this having happened. + values.seed(spec.id, defaultsOf(spec)); + + let ui = null; + if (spec.ui) ui = await load(spec.ui); + + const panel = renderPanel(spec, { + values: values.valuesOf(spec.id), + onChange: (key, value) => values.set(spec.id, key, value), + }); + + // Only now, with a complete panel in hand, does anything reach the DOM. + host.appendChild(panel.node); + + if (ui && typeof ui.setup === "function") { + ui.setup({ + composer, + values: { + get: () => values.valuesOf(spec.id), + set: (key, value) => values.set(spec.id, key, value), + }, + // Scoped to this module: a module hearing about everyone else's + // edits would have to filter, and filtering needs an id it should + // not need to know. + on: (fn) => values.onChange((id, key, value) => { + if (id === spec.id) fn(key, value); + }), + // The app services a module may use, named explicitly. A short, + // reviewable list beats reaching for a global, which is how a module + // ends up depending on something nobody meant to expose. + shell: services, + }); + } + + mounted.push({ id: spec.id, panel }); + } catch (err) { + hidden.push({ id: spec.id, why: `${err.name}: ${err.message}` }); + console.warn(`[FunPack] ${spec.id} did not mount: ${err.message}`); + } + } + + return { mounted, hidden }; +} diff --git a/app/shell/services.js b/app/shell/services.js new file mode 100644 index 00000000..b50bc107 --- /dev/null +++ b/app/shell/services.js @@ -0,0 +1,24 @@ +// What the app offers modules beyond the kit. +// +// Deliberately tiny and named one by one. The alternative -- letting a module +// reach for a global -- means the surface is "whatever happens to be on window", +// which nobody can review and nothing can keep stable. + +import { applyDensity, normaliseCols, rememberCols } from "../composer/internals/density.js"; + +export const services = { + theme: { + set(choice) { window.ComposerTheme.apply(choice); }, + get: () => window.ComposerTheme.get(), + resolved: () => window.ComposerTheme.resolved(), + }, + density: { + /** Column count for every adaptive grid on the page. */ + set(cols) { + const n = normaliseCols(cols); + for (const grid of document.querySelectorAll(".cx-gallery")) applyDensity(grid, n); + rememberCols("app", n); + return n; + }, + }, +}; diff --git a/app/shell/values.js b/app/shell/values.js new file mode 100644 index 00000000..cc3fe00c --- /dev/null +++ b/app/shell/values.js @@ -0,0 +1,36 @@ +// The live value of every setting, keyed exactly as the declaration keys them. +// +// This is the only thing in the app that reads the UI. A module never does: it +// declares what it needs and is handed values, so the same module works with a +// panel on screen and with no browser at all. + +const store = new Map(); // moduleId -> { key: value } +const listeners = new Set(); + +export function seed(moduleId, defaults) { + store.set(moduleId, { ...defaults }); +} + +export function set(moduleId, key, value) { + const current = store.get(moduleId) || {}; + current[key] = value; + store.set(moduleId, current); + for (const fn of listeners) { + try { fn(moduleId, key, value); } catch { /* a listener must not break the edit */ } + } +} + +export const valuesOf = (moduleId) => ({ ...(store.get(moduleId) || {}) }); + +/** Everything, as it would be sent with a generation request. */ +export const all = () => Object.fromEntries([...store].map(([id, v]) => [id, { ...v }])); + +export function onChange(fn) { + listeners.add(fn); + return () => listeners.delete(fn); +} + +export function _reset() { + store.clear(); + listeners.clear(); +} diff --git a/core/contract.py b/core/contract.py new file mode 100644 index 00000000..57254363 --- /dev/null +++ b/core/contract.py @@ -0,0 +1,76 @@ +"""What a module declares, and the vocabulary it may use. + +Core holds no list of features. A module announces itself and this file says +what a valid announcement looks like -- nothing here names an implementation. +""" + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +# Bumped when the shape of an announcement changes. A module declaring an older +# version is not quietly adapted: it is refused, because guessing at what an old +# declaration meant is how silent behaviour changes happen. +CONTRACT_VERSION = 1 + +# Closed set. A type outside it has no renderer and no validation, so accepting +# one would mean rendering something approximate. +TYPES = frozenset({"bool", "int", "float", "enum", "text", "multiline", "path"}) + +NUMERIC = frozenset({"int", "float"}) + +# Which renderers each type allows, first being the default. A hint outside its +# type's list is refused at import: the alternative is a panel that silently +# renders something other than what was asked for. +UI_HINTS: Dict[str, List[str]] = { + "bool": ["checkboxRow", "toggle"], + "int": ["number", "slider", "stepper", "macroSlider"], + "float": ["number", "slider", "stepper", "macroSlider"], + "enum": ["select", "segmented", "radioGroup", "filterList", "wheel"], + "text": ["input", "search"], + "multiline": ["textarea", "autoTextarea"], + "path": ["filterList"], +} + +# Ordering is coarse-grained by stage, then by declared relations within it. +# Numbers are deliberately absent: a priority number is a global namespace, so +# inserting one module means renumbering the others. +STAGES: List[str] = ["load", "conditioning", "latent", "guidance", "sampling", "post"] + + +@dataclass(frozen=True) +class ModuleSpec: + """One module's announcement, after validation.""" + + id: str + title: str + mount: str + settings: Dict[str, dict] = field(default_factory=dict) + requires: List[str] = field(default_factory=list) # model traits + after: List[str] = field(default_factory=list) # module ids + before: List[str] = field(default_factory=list) + stage: str = "sampling" + ui: Optional[str] = None # served path to its ui.js + status: str = "experimental" # or "proven" + source: str = "" # dotted import path + + def defaults(self) -> Dict[str, Any]: + """The values a headless run gets when no panel has been rendered. + + Derived from the same declaration the panel renders, so the two cannot + disagree about what a setting means when nobody has touched it. + """ + return {key: spec["default"] for key, spec in self.settings.items()} + + def to_manifest(self) -> dict: + return { + "id": self.id, + "title": self.title, + "mount": self.mount, + "settings": self.settings, + "requires": list(self.requires), + "after": list(self.after), + "before": list(self.before), + "stage": self.stage, + "ui": self.ui, + "status": self.status, + } diff --git a/core/registry.py b/core/registry.py new file mode 100644 index 00000000..68e305e4 --- /dev/null +++ b/core/registry.py @@ -0,0 +1,99 @@ +"""Finding modules. + +Core scans a folder and imports what it finds. It holds no list, so adding a +module is adding a folder; and each import is guarded, so a module that raises +is absent and logged rather than taking the app down with it. +""" + +import importlib +import importlib.util +import pkgutil +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +from . import config, log +from .contract import ModuleSpec +from .schema import SchemaError, validate + +# Read off the module after import. Uppercase because they are declarations, not +# behaviour -- a module's announcement should be legible without running it. +FIELDS = { + "id": "ID", "title": "TITLE", "mount": "MOUNT", "settings": "SETTINGS", + "requires": "REQUIRES", "after": "AFTER", "before": "BEFORE", + "stage": "STAGE", "status": "STATUS", +} + + +class Registry: + def __init__(self) -> None: + self.specs: Dict[str, ModuleSpec] = {} + self.failed: List[Tuple[str, str]] = [] # (where, why) + + @property + def ids(self) -> List[str]: + return sorted(self.specs) + + def add(self, spec: ModuleSpec) -> None: + if spec.id in self.specs: + # Two modules answering to one id means one wins by import order, + # and which one is nobody's decision. + self.failed.append((spec.source, f"duplicate id {spec.id!r}, already declared by " + f"{self.specs[spec.id].source}")) + return + self.specs[spec.id] = spec + + +def announcement_of(module, source: str) -> dict: + raw = {} + for key, const in FIELDS.items(): + if hasattr(module, const): + raw[key] = getattr(module, const) + return raw + + +def _feature_dirs(root: Path) -> List[Path]: + """modules/// -- two levels, each a package.""" + if not root.is_dir(): + return [] + found = [] + for domain in sorted(p for p in root.iterdir() if p.is_dir() and not p.name.startswith((".", "_"))): + for feature in sorted(p for p in domain.iterdir() if p.is_dir() and not p.name.startswith((".", "_"))): + if (feature / "__init__.py").is_file(): + found.append(feature) + return found + + +def scan(root: Optional[Path] = None, package: str = "modules") -> Registry: + """Import every module under `root` and collect the valid announcements.""" + root = Path(root or config.MODULES_DIR) + registry = Registry() + + for feature in _feature_dirs(root): + rel = feature.relative_to(root) + dotted = f"{package}." + ".".join(rel.parts) + try: + module = importlib.import_module(dotted) + except Exception as exc: # noqa: BLE001 -- any failure is absence + log.failed(dotted, exc) + registry.failed.append((dotted, f"{type(exc).__name__}: {exc}")) + continue + + raw = announcement_of(module, dotted) + if not raw.get("id"): + # A folder that is not a module is not an error -- shared helpers + # live in packages too. + continue + + # A module's ui.js is optional, and the default is none: most modules + # are a settings schema and no JavaScript at all. + ui_file = feature / "ui.js" + if ui_file.is_file(): + raw["ui"] = f"{config.UI_PREFIX}/modules/{'/'.join(rel.parts)}/ui.js" + + try: + registry.add(validate(raw, source=dotted)) + except SchemaError as exc: + log.note(f"{dotted} declares an invalid module: {exc}") + registry.failed.append((dotted, str(exc))) + + return registry diff --git a/core/relations.py b/core/relations.py new file mode 100644 index 00000000..f0e7ef8d --- /dev/null +++ b/core/relations.py @@ -0,0 +1,65 @@ +"""Ordering by declared relations, never by priority numbers. + +A number is a global namespace: inserting one module between two others means +renumbering everything after it, and the numbers live in files that know nothing +about each other. A relation is local -- a module names only the neighbours it +actually cares about, and everything else sorts itself. +""" + +from typing import Dict, Iterable, List, Tuple + +from .contract import STAGES, ModuleSpec + + +class CycleError(ValueError): + """Relations that cannot be satisfied in any order.""" + + +def order(specs: Iterable[ModuleSpec]) -> Tuple[List[ModuleSpec], List[Tuple[ModuleSpec, str]]]: + """(ordered, rejected) -- rejected carries the reason it could not be placed. + + A module in a cycle is dropped rather than placed arbitrarily: an arbitrary + order is one that works until it does not, and by then nobody remembers the + order was never real. + """ + specs = list(specs) + by_id = {spec.id: spec for spec in specs} + rejected: List[Tuple[ModuleSpec, str]] = [] + + # Edges point from "runs earlier" to "runs later". `before` is expressed as + # the same edge from the other end, so the two spellings cannot disagree. + edges: Dict[str, set] = {spec.id: set() for spec in specs} + for spec in specs: + for earlier in spec.after: + if earlier in by_id: + edges[spec.id].add(earlier) + for later in spec.before: + if later in by_id: + edges[later].add(spec.id) + + # A relation naming an absent module is not an error: modules come and go, + # and "after audio_clock" simply has nothing to wait for when audio_clock + # failed to load or was never installed. + + stage_of = {spec.id: STAGES.index(spec.stage) for spec in specs} + ordered: List[ModuleSpec] = [] + placed: set = set() + + # Stage first, then declaration order, so a run without any relations is + # stable and readable rather than dependent on filesystem order. + remaining = sorted(specs, key=lambda s: (stage_of[s.id], s.id)) + + while remaining: + ready = [s for s in remaining if edges[s.id] <= placed] + if not ready: + for spec in remaining: + unmet = sorted(edges[spec.id] - placed) + rejected.append((spec, f"ordering cycle with {', '.join(unmet)}")) + break + # Among the ready, respect stage so a later stage never jumps ahead. + nxt = min(ready, key=lambda s: (stage_of[s.id], s.id)) + ordered.append(nxt) + placed.add(nxt.id) + remaining.remove(nxt) + + return ordered, rejected diff --git a/core/routes.py b/core/routes.py index 18707093..a1b3fd81 100644 --- a/core/routes.py +++ b/core/routes.py @@ -5,7 +5,10 @@ so the real routes can be mounted on a throwaway app in a test. The handlers are thin adapters over pure functions in `serve`. """ -from . import config, log, serve as static +from . import config, log, registry as registry_mod, serve as static +from .contract import CONTRACT_VERSION +from .relations import order +from .traits import split # Two independent imports, two independent guards: aiohttp is installable on its # own, and folding them together nulled `web` whenever ComfyUI's `server` was @@ -40,6 +43,47 @@ def _serve_under(req, root, allowed): return _respond(static.serve(root, req.match_info["tail"], allowed)) +# Scanned once at startup. A module list that changed under the UI mid-session +# would mean a panel could refer to something that is no longer there. +_registry = None + + +def modules(rescan=False): + global _registry + if _registry is None or rescan: + _registry = registry_mod.scan() + return _registry + + +def manifest(traits=None): + """What the browser is told: only modules that loaded and validated. + + A module that failed is absent from `modules` entirely -- the UI renders + what announced itself, so absence here is what makes it absent on screen. + `failed` is carried alongside for the modules dump, never for rendering. + """ + reg = modules() + specs = list(reg.specs.values()) + + incompatible = [] + if traits is not None: + specs, incompatible = split(specs, traits) + + ordered, rejected = order(specs) + + return { + "contract": CONTRACT_VERSION, + "modules": [spec.to_manifest() for spec in ordered], + "failed": ( + [{"where": where, "why": why} for where, why in reg.failed] + + [{"where": spec.source, "why": why} for spec, why in rejected] + ), + "incompatible": [ + {"id": spec.id, "requires": spec.requires} for spec in incompatible + ], + } + + def register(routes, prefix=None): """Attach FunPack's routes to an aiohttp route table.""" P = config.UI_PREFIX if prefix is None else prefix @@ -48,6 +92,12 @@ def register(routes, prefix=None): async def _health(_req): return web.json_response({"ok": True}) + @routes.get(P + "/api/modules") + async def _modules(req): + raw = req.query.get("traits") + traits = [t for t in raw.split(",") if t] if raw is not None else None + return web.json_response(manifest(traits)) + @routes.get(P + "/app/{tail:.*}") async def _app_asset(req): return _serve_under(req, config.APP_DIR, config.APP_EXTS) diff --git a/core/schema.py b/core/schema.py new file mode 100644 index 00000000..3eb81b22 --- /dev/null +++ b/core/schema.py @@ -0,0 +1,172 @@ +"""Validating an announcement. + +Everything here refuses rather than repairs. A module whose declaration is wrong +does not get a best-effort panel -- it does not load at all, and says why once. +That is the same rule the UI follows: absent, not approximate. +""" + +from typing import Any, Dict + +from .contract import NUMERIC, STAGES, TYPES, UI_HINTS, ModuleSpec + + +class SchemaError(ValueError): + """A declaration core will not accept.""" + + +SCALARS = (str, int, float, bool) + +# Content the UI renders as text. Anything non-scalar here would have to be +# interpreted to be displayed, and interpreting module-supplied content is the +# one thing the kit will not do. +TEXT_KEYS = ("label", "hint", "unit", "placeholder") + + +def _require_text(value: Any, where: str) -> None: + if not isinstance(value, str): + raise SchemaError(f"{where} must be a string; got {type(value).__name__}.") + + +def validate_setting(key: str, spec: Any, siblings: Dict[str, dict]) -> dict: + where = f"setting {key!r}" + if not isinstance(spec, dict): + raise SchemaError(f"{where} must be a dict; got {type(spec).__name__}.") + + kind = spec.get("type") + if kind not in TYPES: + raise SchemaError(f"{where} has unknown type {kind!r}. Known: {', '.join(sorted(TYPES))}.") + + if "default" not in spec: + # Without one there is no value for a headless run, and the panel would + # have to invent a starting point that apply() never agreed to. + raise SchemaError(f"{where} has no default.") + + for text_key in TEXT_KEYS: + if text_key in spec: + _require_text(spec[text_key], f"{where}'s {text_key}") + + hint = spec.get("ui") + if hint is not None and hint not in UI_HINTS[kind]: + raise SchemaError( + f"{where} asks for ui {hint!r}, which is not available for a {kind}. " + f"Allowed: {', '.join(UI_HINTS[kind])}." + ) + + if kind == "enum": + _validate_enum(spec, where) + elif kind in NUMERIC: + _validate_numeric(spec, where, kind) + elif kind == "bool": + if not isinstance(spec["default"], bool): + raise SchemaError(f"{where} is a bool but its default is {spec['default']!r}.") + else: + if not isinstance(spec["default"], str): + raise SchemaError(f"{where} is a {kind} but its default is not a string.") + + if "when" in spec: + _validate_when(spec["when"], where, siblings, key) + + return spec + + +def _validate_enum(spec: dict, where: str) -> None: + options = spec.get("options") + if not isinstance(options, (list, tuple)) or not options: + raise SchemaError(f"{where} is an enum with no options.") + + values = [] + for i, option in enumerate(options): + if not isinstance(option, dict): + raise SchemaError(f"{where} option {i} must be a dict with value and label.") + if "value" not in option or "label" not in option: + raise SchemaError(f"{where} option {i} needs both a value and a label.") + if not isinstance(option["value"], SCALARS): + raise SchemaError(f"{where} option {i}'s value must be a scalar.") + _require_text(option["label"], f"{where} option {i}'s label") + values.append(option["value"]) + + if len(set(values)) != len(values): + raise SchemaError(f"{where} has duplicate option values.") + if spec["default"] not in values: + raise SchemaError(f"{where}'s default {spec['default']!r} is not one of its options.") + + +def _validate_numeric(spec: dict, where: str, kind: str) -> None: + default = spec["default"] + if isinstance(default, bool) or not isinstance(default, (int, float)): + raise SchemaError(f"{where} is a {kind} but its default is {default!r}.") + if kind == "int" and not isinstance(default, int): + raise SchemaError(f"{where} is an int but its default is a float.") + + low, high = spec.get("min"), spec.get("max") + for name, bound in (("min", low), ("max", high)): + if bound is not None and (isinstance(bound, bool) or not isinstance(bound, (int, float))): + raise SchemaError(f"{where}'s {name} must be a number.") + if low is not None and high is not None and low > high: + raise SchemaError(f"{where} has min {low} above max {high}.") + if low is not None and default < low: + raise SchemaError(f"{where}'s default {default} is below its min {low}.") + if high is not None and default > high: + raise SchemaError(f"{where}'s default {default} is above its max {high}.") + + step = spec.get("step") + if step is not None and (isinstance(step, bool) or not isinstance(step, (int, float)) or step <= 0): + raise SchemaError(f"{where}'s step must be a positive number.") + + +def _validate_when(when: Any, where: str, siblings: Dict[str, dict], key: str) -> None: + if not isinstance(when, dict) or not when: + raise SchemaError(f"{where}'s when must be a non-empty dict of sibling conditions.") + for other, expected in when.items(): + if other == key: + raise SchemaError(f"{where}'s when refers to itself.") + if other not in siblings: + # A condition on a key that does not exist is always false, so the + # row would simply never appear -- a typo that reads as a decision. + raise SchemaError(f"{where}'s when refers to {other!r}, which this module does not declare.") + if not isinstance(expected, SCALARS) and not isinstance(expected, (list, tuple)): + raise SchemaError(f"{where}'s when value for {other!r} must be a scalar or a list.") + + +def validate(announcement: Dict[str, Any], source: str = "") -> ModuleSpec: + """A validated ModuleSpec, or SchemaError naming what is wrong.""" + for required in ("id", "title", "mount"): + value = announcement.get(required) + if not isinstance(value, str) or not value.strip(): + raise SchemaError(f"module {source or '?'} has no {required}.") + + stage = announcement.get("stage", "sampling") + if stage not in STAGES: + raise SchemaError(f"module {announcement['id']!r} has unknown stage {stage!r}. Known: {', '.join(STAGES)}.") + + status = announcement.get("status", "experimental") + if status not in ("proven", "experimental"): + raise SchemaError(f"module {announcement['id']!r} has unknown status {status!r}.") + + raw = announcement.get("settings") or {} + if not isinstance(raw, dict): + raise SchemaError(f"module {announcement['id']!r} has a non-dict settings block.") + settings = {key: validate_setting(key, spec, raw) for key, spec in raw.items()} + + def _ids(name: str) -> list: + value = announcement.get(name) or [] + if isinstance(value, str) or not isinstance(value, (list, tuple)): + raise SchemaError(f"module {announcement['id']!r}'s {name} must be a list.") + for item in value: + if not isinstance(item, str): + raise SchemaError(f"module {announcement['id']!r}'s {name} must contain strings.") + return list(value) + + return ModuleSpec( + id=announcement["id"], + title=announcement["title"], + mount=announcement["mount"], + settings=settings, + requires=_ids("requires"), + after=_ids("after"), + before=_ids("before"), + stage=stage, + ui=announcement.get("ui"), + status=status, + source=source, + ) diff --git a/core/tests/fixtures/fixture_modules/__init__.py b/core/tests/fixtures/fixture_modules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/core/tests/fixtures/fixture_modules/broken/__init__.py b/core/tests/fixtures/fixture_modules/broken/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/core/tests/fixtures/fixture_modules/broken/bad_label/__init__.py b/core/tests/fixtures/fixture_modules/broken/bad_label/__init__.py new file mode 100644 index 00000000..75ed0aba --- /dev/null +++ b/core/tests/fixtures/fixture_modules/broken/bad_label/__init__.py @@ -0,0 +1,6 @@ +"""A label that is not text. The UI would have to interpret it to show it.""" + +ID = "bad_label" +TITLE = "Bad label" +MOUNT = "generation.timing" +SETTINGS = {"enabled": {"type": "bool", "default": True, "label": {"markup": "no"}}} diff --git a/core/tests/fixtures/fixture_modules/broken/bad_ui/__init__.py b/core/tests/fixtures/fixture_modules/broken/bad_ui/__init__.py new file mode 100644 index 00000000..f61eb543 --- /dev/null +++ b/core/tests/fixtures/fixture_modules/broken/bad_ui/__init__.py @@ -0,0 +1,6 @@ +"""Asks for a renderer its type does not have.""" + +ID = "bad_ui" +TITLE = "Bad renderer" +MOUNT = "generation.timing" +SETTINGS = {"enabled": {"type": "bool", "default": True, "label": "On", "ui": "slider"}} diff --git a/core/tests/fixtures/fixture_modules/broken/no_default/__init__.py b/core/tests/fixtures/fixture_modules/broken/no_default/__init__.py new file mode 100644 index 00000000..3f353733 --- /dev/null +++ b/core/tests/fixtures/fixture_modules/broken/no_default/__init__.py @@ -0,0 +1,6 @@ +"""No default: a headless run would have no value to pass to apply().""" + +ID = "no_default" +TITLE = "No default" +MOUNT = "generation.timing" +SETTINGS = {"strength": {"type": "float", "min": 0.0, "max": 1.0, "label": "Strength"}} diff --git a/core/tests/fixtures/fixture_modules/broken/raises/__init__.py b/core/tests/fixtures/fixture_modules/broken/raises/__init__.py new file mode 100644 index 00000000..ea59db92 --- /dev/null +++ b/core/tests/fixtures/fixture_modules/broken/raises/__init__.py @@ -0,0 +1,7 @@ +"""Fails on import. Must be absent, not fatal.""" + +ID = "explodes" +TITLE = "Explodes" +MOUNT = "generation.timing" + +raise RuntimeError("this module cannot load") diff --git a/core/tests/fixtures/fixture_modules/cycle/__init__.py b/core/tests/fixtures/fixture_modules/cycle/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/core/tests/fixtures/fixture_modules/cycle/first/__init__.py b/core/tests/fixtures/fixture_modules/cycle/first/__init__.py new file mode 100644 index 00000000..ac3a6fd7 --- /dev/null +++ b/core/tests/fixtures/fixture_modules/cycle/first/__init__.py @@ -0,0 +1,5 @@ +ID = "cycle_first" +TITLE = "First" +MOUNT = "generation.timing" +AFTER = ["cycle_second"] +SETTINGS = {"enabled": {"type": "bool", "default": True, "label": "On"}} diff --git a/core/tests/fixtures/fixture_modules/cycle/second/__init__.py b/core/tests/fixtures/fixture_modules/cycle/second/__init__.py new file mode 100644 index 00000000..62aa4c29 --- /dev/null +++ b/core/tests/fixtures/fixture_modules/cycle/second/__init__.py @@ -0,0 +1,5 @@ +ID = "cycle_second" +TITLE = "Second" +MOUNT = "generation.timing" +AFTER = ["cycle_first"] +SETTINGS = {"enabled": {"type": "bool", "default": True, "label": "On"}} diff --git a/core/tests/fixtures/fixture_modules/model/__init__.py b/core/tests/fixtures/fixture_modules/model/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/core/tests/fixtures/fixture_modules/model/needs_trait/__init__.py b/core/tests/fixtures/fixture_modules/model/needs_trait/__init__.py new file mode 100644 index 00000000..c1c6abad --- /dev/null +++ b/core/tests/fixtures/fixture_modules/model/needs_trait/__init__.py @@ -0,0 +1,7 @@ +"""Valid, but wants a trait the current model may not have.""" + +ID = "needs_trait" +TITLE = "Needs a trait" +MOUNT = "generation.model" +REQUIRES = ["cfg_free", "audio_stream"] +SETTINGS = {"enabled": {"type": "bool", "default": True, "label": "On"}} diff --git a/core/tests/fixtures/fixture_modules/timing/__init__.py b/core/tests/fixtures/fixture_modules/timing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/core/tests/fixtures/fixture_modules/timing/audio_clock/__init__.py b/core/tests/fixtures/fixture_modules/timing/audio_clock/__init__.py new file mode 100644 index 00000000..c5ecd7d1 --- /dev/null +++ b/core/tests/fixtures/fixture_modules/timing/audio_clock/__init__.py @@ -0,0 +1,30 @@ +"""A good module: the shape everything else is measured against.""" + +ID = "audio_clock" +TITLE = "Audio clock" +MOUNT = "generation.timing" +STAGE = "conditioning" +STATUS = "proven" +REQUIRES = ["audio_stream"] + +SETTINGS = { + "enabled": { + "type": "bool", "default": True, + "label": "Sync to audio clock", + "hint": "Locks frame timing to the audio stream.", + }, + "strength": { + "type": "float", "default": 0.65, "min": 0.0, "max": 1.0, "step": 0.05, + "label": "Lock strength", "unit": "x", "ui": "slider", + "when": {"enabled": True}, + }, + "mode": { + "type": "enum", "default": "beat", "label": "Alignment", "ui": "segmented", + "options": [ + {"value": "beat", "label": "Beat grid"}, + {"value": "onset", "label": "Onset"}, + {"value": "flat", "label": "Flat"}, + ], + "when": {"enabled": True}, + }, +} diff --git a/core/tests/fixtures/fixture_modules/timing/audio_clock/ui.js b/core/tests/fixtures/fixture_modules/timing/audio_clock/ui.js new file mode 100644 index 00000000..ff8b4c56 --- /dev/null +++ b/core/tests/fixtures/fixture_modules/timing/audio_clock/ui.js @@ -0,0 +1 @@ +export default {}; diff --git a/core/tests/fixtures/fixture_modules/timing/momentum/__init__.py b/core/tests/fixtures/fixture_modules/timing/momentum/__init__.py new file mode 100644 index 00000000..bb523265 --- /dev/null +++ b/core/tests/fixtures/fixture_modules/timing/momentum/__init__.py @@ -0,0 +1,8 @@ +"""Declares a relation, so ordering has something to sort.""" + +ID = "momentum" +TITLE = "Momentum" +MOUNT = "generation.timing" +STAGE = "conditioning" +AFTER = ["audio_clock"] +SETTINGS = {"enabled": {"type": "bool", "default": False, "label": "Smooth motion"}} diff --git a/core/tests/test_registry.py b/core/tests/test_registry.py new file mode 100644 index 00000000..3629bc17 --- /dev/null +++ b/core/tests/test_registry.py @@ -0,0 +1,160 @@ +"""Scanning a real tree of modules, most of them deliberately broken. + +The fixtures under fixtures/fixture_modules are the point: one good module and +five ways of being wrong, so "a broken module is absent, not fatal" is a thing +the suite demonstrates rather than a claim in a comment. +""" + +from pathlib import Path + +import pytest + +from core import registry as registry_mod +from core.relations import order +from core.traits import missing_for, split + +FIXTURES = Path(__file__).parent / "fixtures" / "fixture_modules" +PACKAGE = "core.tests.fixtures.fixture_modules" + + +@pytest.fixture(scope="module") +def scanned(): + return registry_mod.scan(FIXTURES, package=PACKAGE) + + +def test_the_good_module_loads(scanned): + assert "audio_clock" in scanned.specs + spec = scanned.specs["audio_clock"] + assert spec.title == "Audio clock" + assert spec.stage == "conditioning" + assert spec.status == "proven" + + +def test_a_module_that_raises_on_import_is_absent_not_fatal(scanned): + # The scan completed and returned other modules, which is the guarantee. + assert "explodes" not in scanned.specs + assert any("raises" in where for where, _ in scanned.failed) + assert scanned.specs, "one bad module did not take the rest down with it" + + +@pytest.mark.parametrize("bad_id", ["bad_ui", "bad_label", "no_default"]) +def test_an_invalid_declaration_never_reaches_the_manifest(scanned, bad_id): + assert bad_id not in scanned.specs + + +def test_every_failure_says_why(scanned): + # A module that is silently missing is indistinguishable from one that was + # never installed; the log line is what makes it debuggable. + assert scanned.failed + for where, why in scanned.failed: + assert where and why, f"failure recorded with no reason: {where!r} {why!r}" + + +def test_a_module_with_a_ui_file_advertises_its_path(scanned): + assert scanned.specs["audio_clock"].ui.endswith("/modules/timing/audio_clock/ui.js") + + +def test_a_module_without_a_ui_file_advertises_none(scanned): + # The default is no JavaScript at all: a settings schema is a whole module. + assert scanned.specs["momentum"].ui is None + + +def test_defaults_are_available_without_any_ui(scanned): + assert scanned.specs["audio_clock"].defaults() == { + "enabled": True, "strength": 0.65, "mode": "beat", + } + + +def test_a_duplicate_id_is_refused_rather_than_decided_by_import_order(): + reg = registry_mod.Registry() + from core.schema import validate + a = validate({"id": "x", "title": "A", "mount": "m"}, source="one") + b = validate({"id": "x", "title": "B", "mount": "m"}, source="two") + reg.add(a) + reg.add(b) + assert reg.specs["x"].title == "A" + assert any("duplicate id" in why for _, why in reg.failed) + + +# --- ordering -------------------------------------------------------------- + +def test_relations_decide_the_order(scanned): + ordered, rejected = order([scanned.specs["audio_clock"], scanned.specs["momentum"]]) + ids = [s.id for s in ordered] + assert ids.index("audio_clock") < ids.index("momentum") + assert rejected == [] + + +def test_before_is_the_same_edge_from_the_other_end(): + from core.schema import validate + a = validate({"id": "a", "title": "A", "mount": "m", "before": ["b"]}) + b = validate({"id": "b", "title": "B", "mount": "m"}) + ordered, _ = order([b, a]) + assert [s.id for s in ordered] == ["a", "b"] + + +def test_a_relation_naming_an_absent_module_is_not_an_error(): + # Modules come and go; "after audio_clock" has nothing to wait for when + # audio_clock was never installed. + from core.schema import validate + solo = validate({"id": "solo", "title": "S", "mount": "m", "after": ["not_installed"]}) + ordered, rejected = order([solo]) + assert [s.id for s in ordered] == ["solo"] + assert rejected == [] + + +def test_a_cycle_is_dropped_rather_than_ordered_arbitrarily(scanned): + # An arbitrary order is one that works until it does not, and by then nobody + # remembers it was never real. + pair = [scanned.specs["cycle_first"], scanned.specs["cycle_second"]] + ordered, rejected = order(pair) + assert ordered == [] + assert {spec.id for spec, _ in rejected} == {"cycle_first", "cycle_second"} + assert all("cycle" in why for _, why in rejected) + + +def test_a_cycle_does_not_take_the_healthy_modules_with_it(scanned): + everything = list(scanned.specs.values()) + ordered, rejected = order(everything) + assert "audio_clock" in [s.id for s in ordered] + assert {s.id for s, _ in rejected} == {"cycle_first", "cycle_second"} + + +def test_stages_run_in_declared_order(): + from core.schema import validate + late = validate({"id": "late", "title": "L", "mount": "m", "stage": "post"}) + early = validate({"id": "early", "title": "E", "mount": "m", "stage": "load"}) + ordered, _ = order([late, early]) + assert [s.id for s in ordered] == ["early", "late"] + + +def test_ordering_is_stable_without_any_relations(): + from core.schema import validate + specs = [validate({"id": i, "title": i, "mount": "m"}) for i in ("c", "a", "b")] + assert [s.id for s in order(specs)[0]] == ["a", "b", "c"] + + +# --- traits ---------------------------------------------------------------- + +def test_a_module_is_kept_only_when_the_model_has_what_it_needs(scanned): + specs = list(scanned.specs.values()) + ok, no = split(specs, ["audio_stream", "flow_match"]) + assert "audio_clock" in [s.id for s in ok] + assert "needs_trait" in [s.id for s in no], "cfg_free was not on offer" + + +def test_a_module_with_no_requirements_fits_anything(scanned): + ok, _ = split([scanned.specs["momentum"]], []) + assert [s.id for s in ok] == ["momentum"] + + +def test_the_missing_traits_are_nameable(scanned): + assert missing_for(scanned.specs["needs_trait"], ["audio_stream"]) == ["cfg_free"] + + +def test_traits_are_names_the_model_offers_not_model_names(scanned): + # A module listing model names has to be edited whenever a model ships, and + # the one nobody edits is the one that silently stops appearing. + for spec in scanned.specs.values(): + for trait in spec.requires: + assert trait.islower() and " " not in trait diff --git a/core/tests/test_routes_manifest.py b/core/tests/test_routes_manifest.py new file mode 100644 index 00000000..7790118b --- /dev/null +++ b/core/tests/test_routes_manifest.py @@ -0,0 +1,70 @@ +"""The manifest: only what loaded and validated reaches the browser.""" + +from pathlib import Path + +import pytest + +from core import routes as routes_mod +from core import registry as registry_mod + +FIXTURES = Path(__file__).parent / "fixtures" / "fixture_modules" +PACKAGE = "core.tests.fixtures.fixture_modules" + + +@pytest.fixture() +def manifest(monkeypatch): + reg = registry_mod.scan(FIXTURES, package=PACKAGE) + monkeypatch.setattr(routes_mod, "modules", lambda rescan=False: reg) + return routes_mod.manifest + + +def test_only_valid_modules_are_listed(manifest): + ids = [m["id"] for m in manifest()["modules"]] + assert "audio_clock" in ids + for broken in ("explodes", "bad_ui", "bad_label", "no_default"): + assert broken not in ids + + +def test_a_cycle_is_reported_but_not_listed(manifest): + result = manifest() + ids = [m["id"] for m in result["modules"]] + assert "cycle_first" not in ids and "cycle_second" not in ids + assert any("cycle" in entry["why"] for entry in result["failed"]) + + +def test_failures_are_carried_for_the_dump_not_for_rendering(manifest): + # The UI renders `modules`. `failed` exists so a person can find out why + # something is missing without reading the server log. + result = manifest() + assert result["failed"] + assert all({"where", "why"} == set(entry) for entry in result["failed"]) + + +def test_the_manifest_is_ordered_by_relation(manifest): + ids = [m["id"] for m in manifest()["modules"]] + assert ids.index("audio_clock") < ids.index("momentum") + + +def test_traits_filter_what_is_offered(manifest): + with_audio = manifest(["audio_stream"]) + assert "audio_clock" in [m["id"] for m in with_audio["modules"]] + assert "needs_trait" in [m["id"] for m in with_audio["incompatible"]] + + without = manifest([]) + assert "audio_clock" not in [m["id"] for m in without["modules"]] + assert "momentum" in [m["id"] for m in without["modules"]], "no requirements, fits anything" + + +def test_no_traits_means_no_filtering(manifest): + assert manifest()["incompatible"] == [] + + +def test_the_contract_version_travels_with_it(manifest): + from core.contract import CONTRACT_VERSION + assert manifest()["contract"] == CONTRACT_VERSION + + +def test_the_import_path_is_not_published(manifest): + # Where a module lives on disk is core's business. + for entry in manifest()["modules"]: + assert "source" not in entry diff --git a/core/tests/test_schema.py b/core/tests/test_schema.py new file mode 100644 index 00000000..a5acbd90 --- /dev/null +++ b/core/tests/test_schema.py @@ -0,0 +1,208 @@ +"""Validation refuses rather than repairs. + +Every case here is a module that would otherwise load and then behave in a way +nobody declared -- a panel rendering the wrong control, a headless run with an +invented default, a row that can never appear. +""" + +import pytest + +from core.contract import CONTRACT_VERSION, ModuleSpec +from core.schema import SchemaError, validate + + +def announce(**overrides): + base = { + "id": "audio_clock", "title": "Audio clock", "mount": "generation.timing", + "settings": {"enabled": {"type": "bool", "default": True, "label": "On"}}, + } + base.update(overrides) + return base + + +def test_a_valid_declaration_becomes_a_spec(): + spec = validate(announce()) + assert isinstance(spec, ModuleSpec) + assert spec.id == "audio_clock" + assert spec.stage == "sampling" + assert spec.status == "experimental" + + +def test_defaults_come_from_the_same_declaration_the_panel_renders(): + # This is the whole point of one declaration doing three jobs: a headless + # run and a rendered panel cannot disagree about a starting value. + spec = validate(announce(settings={ + "enabled": {"type": "bool", "default": True, "label": "On"}, + "strength": {"type": "float", "default": 0.65, "min": 0, "max": 1, "label": "Strength"}, + })) + assert spec.defaults() == {"enabled": True, "strength": 0.65} + + +@pytest.mark.parametrize("missing", ["id", "title", "mount"]) +def test_the_essentials_are_required(missing): + raw = announce() + del raw[missing] + with pytest.raises(SchemaError, match=missing): + validate(raw) + + +def test_a_blank_id_is_not_an_id(): + with pytest.raises(SchemaError): + validate(announce(id=" ")) + + +# --- types and renderers --------------------------------------------------- + +def test_unknown_type_is_refused(): + with pytest.raises(SchemaError, match="unknown type"): + validate(announce(settings={"x": {"type": "colour", "default": "#fff", "label": "C"}})) + + +def test_unknown_ui_hint_is_refused(): + # Accepting it would render something other than what was asked for. + with pytest.raises(SchemaError, match="not available for a bool"): + validate(announce(settings={"x": {"type": "bool", "default": True, "label": "X", "ui": "slider"}})) + + +def test_a_valid_ui_hint_passes(): + spec = validate(announce(settings={"x": {"type": "bool", "default": True, "label": "X", "ui": "toggle"}})) + assert spec.settings["x"]["ui"] == "toggle" + + +def test_enum_may_ask_for_the_wheel(): + # How a module gets a radial picker without writing any JavaScript. + spec = validate(announce(settings={"x": { + "type": "enum", "default": "a", "label": "X", "ui": "wheel", + "options": [{"value": "a", "label": "A"}, {"value": "b", "label": "B"}], + }})) + assert spec.settings["x"]["ui"] == "wheel" + + +# --- defaults -------------------------------------------------------------- + +def test_a_setting_without_a_default_is_refused(): + with pytest.raises(SchemaError, match="no default"): + validate(announce(settings={"x": {"type": "float", "min": 0, "max": 1, "label": "X"}})) + + +@pytest.mark.parametrize("spec", [ + {"type": "bool", "default": "yes", "label": "X"}, + {"type": "int", "default": 1.5, "label": "X"}, + {"type": "float", "default": "0.5", "label": "X"}, + {"type": "text", "default": 3, "label": "X"}, + {"type": "bool", "default": 1, "label": "X"}, +]) +def test_a_default_of_the_wrong_type_is_refused(spec): + with pytest.raises(SchemaError): + validate(announce(settings={"x": spec})) + + +def test_a_default_outside_its_own_bounds_is_refused(): + with pytest.raises(SchemaError, match="below its min"): + validate(announce(settings={"x": {"type": "float", "default": -1, "min": 0, "max": 1, "label": "X"}})) + with pytest.raises(SchemaError, match="above its max"): + validate(announce(settings={"x": {"type": "float", "default": 9, "min": 0, "max": 1, "label": "X"}})) + + +def test_inverted_bounds_are_refused(): + with pytest.raises(SchemaError, match="above max"): + validate(announce(settings={"x": {"type": "int", "default": 0, "min": 5, "max": 1, "label": "X"}})) + + +def test_a_non_positive_step_is_refused(): + with pytest.raises(SchemaError, match="step"): + validate(announce(settings={"x": {"type": "float", "default": 0.5, "step": 0, "label": "X"}})) + + +# --- content is text ------------------------------------------------------- + +@pytest.mark.parametrize("key", ["label", "hint", "unit"]) +def test_content_must_be_text(key): + # The UI renders these as text and will not interpret anything else. + with pytest.raises(SchemaError, match=key): + validate(announce(settings={"x": {"type": "bool", "default": True, key: {"markup": ""}}})) + + +def test_option_labels_must_be_text_and_values_scalar(): + with pytest.raises(SchemaError, match="label"): + validate(announce(settings={"x": {"type": "enum", "default": "a", "label": "X", + "options": [{"value": "a", "label": ["a"]}]}})) + with pytest.raises(SchemaError, match="scalar"): + validate(announce(settings={"x": {"type": "enum", "default": "a", "label": "X", + "options": [{"value": {"a": 1}, "label": "A"}]}})) + + +# --- enums ----------------------------------------------------------------- + +def test_an_enum_needs_options(): + with pytest.raises(SchemaError, match="no options"): + validate(announce(settings={"x": {"type": "enum", "default": "a", "label": "X", "options": []}})) + + +def test_an_enum_default_must_be_one_of_its_options(): + with pytest.raises(SchemaError, match="not one of its options"): + validate(announce(settings={"x": {"type": "enum", "default": "z", "label": "X", + "options": [{"value": "a", "label": "A"}]}})) + + +def test_duplicate_option_values_are_refused(): + with pytest.raises(SchemaError, match="duplicate"): + validate(announce(settings={"x": {"type": "enum", "default": "a", "label": "X", + "options": [{"value": "a", "label": "A"}, {"value": "a", "label": "B"}]}})) + + +# --- conditional visibility ------------------------------------------------ + +def test_when_may_only_name_siblings(): + # A condition on a key that does not exist is always false, so the row would + # simply never appear -- a typo that reads as a deliberate decision. + with pytest.raises(SchemaError, match="does not declare"): + validate(announce(settings={ + "a": {"type": "bool", "default": True, "label": "A"}, + "b": {"type": "bool", "default": True, "label": "B", "when": {"nope": True}}, + })) + + +def test_when_may_not_name_itself(): + with pytest.raises(SchemaError, match="itself"): + validate(announce(settings={"a": {"type": "bool", "default": True, "label": "A", "when": {"a": True}}})) + + +def test_a_valid_when_survives_into_the_spec(): + spec = validate(announce(settings={ + "enabled": {"type": "bool", "default": True, "label": "On"}, + "strength": {"type": "float", "default": 0.5, "label": "S", "when": {"enabled": True}}, + })) + assert spec.settings["strength"]["when"] == {"enabled": True} + + +# --- the rest of the announcement ------------------------------------------ + +def test_unknown_stage_is_refused(): + with pytest.raises(SchemaError, match="unknown stage"): + validate(announce(stage="whenever")) + + +def test_unknown_status_is_refused(): + with pytest.raises(SchemaError, match="unknown status"): + validate(announce(status="probably-fine")) + + +def test_relations_must_be_lists_of_ids(): + with pytest.raises(SchemaError, match="after"): + validate(announce(after="audio_clock")) # a bare string is a common slip + with pytest.raises(SchemaError, match="requires"): + validate(announce(requires=[1, 2])) + + +def test_the_manifest_carries_what_the_ui_needs(): + spec = validate(announce(requires=["audio_stream"], after=["x"], stage="latent")) + manifest = spec.to_manifest() + assert manifest["id"] == "audio_clock" + assert manifest["requires"] == ["audio_stream"] + assert manifest["stage"] == "latent" + assert "source" not in manifest, "the import path is core's business, not the browser's" + + +def test_the_contract_has_a_version(): + assert isinstance(CONTRACT_VERSION, int) diff --git a/core/traits.py b/core/traits.py new file mode 100644 index 00000000..348d9b10 --- /dev/null +++ b/core/traits.py @@ -0,0 +1,24 @@ +"""Compatibility by trait, never by model name. + +A module listing model names has to be edited every time a model ships, and the +one nobody remembers to edit is the one that silently stops appearing. A model +says what it IS; a module says what it NEEDS. +""" + +from typing import Iterable, List, Sequence, Set, Tuple + +from .contract import ModuleSpec + + +def split(specs: Iterable[ModuleSpec], available: Sequence[str]) -> Tuple[List[ModuleSpec], List[ModuleSpec]]: + """(compatible, incompatible) for the traits currently on offer.""" + have: Set[str] = set(available or ()) + compatible, incompatible = [], [] + for spec in specs: + (compatible if set(spec.requires) <= have else incompatible).append(spec) + return compatible, incompatible + + +def missing_for(spec: ModuleSpec, available: Sequence[str]) -> List[str]: + """Which traits a module wanted and did not get. For the modules dump.""" + return sorted(set(spec.requires) - set(available or ())) diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 00000000..f4337080 --- /dev/null +++ b/modules/__init__.py @@ -0,0 +1,5 @@ +"""Feature modules, grouped by domain. + +Nothing imports from here directly: core scans the folder. A module is a folder +with an __init__.py that declares ID, TITLE, MOUNT and SETTINGS. +""" diff --git a/modules/system/__init__.py b/modules/system/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/system/appearance/__init__.py b/modules/system/appearance/__init__.py new file mode 100644 index 00000000..1151105b --- /dev/null +++ b/modules/system/appearance/__init__.py @@ -0,0 +1,39 @@ +"""Appearance: theme and grid density. + +The first real vertical slice, and deliberately the smallest one that exercises +the whole path -- announcement, schema, panel, values -- with no model, no +sampler and nothing that needs a GPU to be seen working. +""" + +ID = "appearance" +TITLE = "Appearance" +MOUNT = "settings.general" +STAGE = "load" +STATUS = "proven" + +SETTINGS = { + "theme": { + "type": "enum", "default": "auto", + "label": "Colour scheme", "ui": "segmented", + "options": [ + {"value": "dark", "label": "Dark"}, + {"value": "light", "label": "Light"}, + {"value": "auto", "label": "Auto"}, + ], + }, + "density": { + "type": "int", "default": 0, "min": 0, "max": 4, "step": 1, + "label": "Media columns", + "hint": "0 fits as many as the panel allows.", + "ui": "stepper", + }, +} + + +def apply(values, context=None): + """Nothing to do at generation time: appearance is the browser's business. + + Declared anyway so the module has the same shape as every other one -- a + module core has to special-case is a module that will be forgotten. + """ + return context diff --git a/modules/system/appearance/ui.js b/modules/system/appearance/ui.js new file mode 100644 index 00000000..8630951a --- /dev/null +++ b/modules/system/appearance/ui.js @@ -0,0 +1,15 @@ +// Appearance needs a little JavaScript, because its settings change the app +// rather than the generation. Most modules have no ui.js at all. + +export function setup({ values, on, shell }) { + const apply = (key, value) => { + if (key === "theme") shell.theme.set(value); + if (key === "density") shell.density.set(value); + }; + + // Apply what is already stored, then follow every later edit. + const current = values.get(); + for (const key of Object.keys(current)) apply(key, current[key]); + + return on(apply); +} diff --git a/tools/devserver.py b/tools/devserver.py index c978fda1..37e2b444 100644 --- a/tools/devserver.py +++ b/tools/devserver.py @@ -6,23 +6,32 @@ exercised every time you look at the catalogue. python tools/devserver.py [port] """ +import json import sys from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path -from urllib.parse import unquote, urlparse +from urllib.parse import parse_qs, unquote, urlparse sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from core import config, serve as static # noqa: E402 +from core import config, routes, serve as static # noqa: E402 P = config.UI_PREFIX class Handler(BaseHTTPRequestHandler): def do_GET(self): - path = unquote(urlparse(self.path).path) + parsed = urlparse(self.path) + path = unquote(parsed.path) - if path in (P, P + "/"): + # The same manifest the aiohttp route serves, so what you see in the + # browser here is what ComfyUI would send. + if path == P + "/api/modules": + raw = parse_qs(parsed.query).get("traits") + traits = [t for t in raw[0].split(",") if t] if raw else None + body = json.dumps(routes.manifest(traits)).encode() + served = static.Served(200, body, "application/json") + elif path in (P, P + "/"): served = static.serve(config.APP_DIR, "index.html", config.APP_EXTS) elif path.startswith(P + "/app/"): served = static.serve(config.APP_DIR, path[len(P) + 5:], config.APP_EXTS) diff --git a/tools/kit_lint/rules.js b/tools/kit_lint/rules.js new file mode 100644 index 00000000..1e1981f7 --- /dev/null +++ b/tools/kit_lint/rules.js @@ -0,0 +1,36 @@ +// What a module may not do, and what the kit may not do. +// +// These are the static half of rules 1 and 4. The runtime half is stronger -- +// a module is never handed anything that could style or parse -- but a scanner +// catches the attempt in the file rather than at the moment it would have run, +// which is the difference between a failing test and a support ticket. + +export const MODULE_RULES = [ + { name: "inline-style", why: "presentation belongs to the kit", + test: /\.style\s*[.[]|setAttribute\s*\(\s*['"`]style/ }, + { name: "style-attribute", why: "presentation belongs to the kit", + test: /\bstyle\s*=\s*['"`]/ }, + { name: "class-manipulation", why: "kit classes are the kit's; a module names elements, not classes", + test: /\bclassList\b|setAttribute\s*\(\s*['"`]class/ }, + { name: "raw-element", why: "every element comes from composer..()", + test: /document\s*\.\s*createElement|createElementNS/ }, + { name: "markup", why: "content is data; the kit renders it as text", + test: /\binnerHTML\b|\bouterHTML\b|insertAdjacentHTML|document\s*\.\s*write/ }, + { name: "stylesheet", why: "a module cannot ship styling, and cannot inject it either", + test: /adoptedStyleSheets|document\s*\.\s*head|<\s*(link|style)\b/ }, + { name: "hardcoded-colour", why: "colour comes from the theme tokens", + test: /#[0-9a-fA-F]{3,8}\b/ }, + { name: "hardcoded-size", why: "sizes come from the spacing and control scales", + test: /\b\d+(\.\d+)?px\b/ }, + { name: "eval", why: "nothing in a module is compiled at runtime", + test: /\beval\s*\(|new\s+Function\s*\(/ }, +]; + +// The kit itself may style and may build elements -- that is its job -- but it +// must never interpret content. +export const KIT_RULES = [ + { name: "markup", why: "the kit renders content as text and never parses it", + test: /\binnerHTML\b|\bouterHTML\b|insertAdjacentHTML|document\s*\.\s*write/ }, + { name: "eval", why: "nothing in the kit is compiled at runtime", + test: /\beval\s*\(|new\s+Function\s*\(/ }, +]; diff --git a/tools/kit_lint/scan.js b/tools/kit_lint/scan.js new file mode 100644 index 00000000..b8aa4bc8 --- /dev/null +++ b/tools/kit_lint/scan.js @@ -0,0 +1,51 @@ +// A source scanner for the rule tests. +// +// Deliberately dumb: it reads text and matches patterns. Something cleverer +// would need a parser, and a parser is a thing that can be wrong in ways nobody +// notices. A false positive here is a comment away from being fixed; a false +// negative is a rule that quietly stopped being enforced. + +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const SKIP = new Set(["node_modules", ".git", "__pycache__", "tests"]); + +export function jsFilesIn(root) { + const out = []; + let entries; + try { entries = readdirSync(root); } catch { return out; } + for (const name of entries) { + if (SKIP.has(name) || name.startsWith(".")) continue; + const path = join(root, name); + if (statSync(path).isDirectory()) out.push(...jsFilesIn(path)); + else if (name.endsWith(".js") && !name.endsWith(".test.js")) out.push(path); + } + return out; +} + +/** Strip comments so a rule quoted in prose is not a violation. */ +export function code(source) { + return source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +export function scanSource(source, rules) { + const body = code(source); + const found = []; + for (const rule of rules) { + const line = body.split("\n").findIndex((l) => rule.test.test(l)); + if (line !== -1) found.push({ rule: rule.name, why: rule.why, line: line + 1 }); + } + return found; +} + +export function scan(root, rules) { + const violations = []; + for (const file of jsFilesIn(root)) { + for (const found of scanSource(readFileSync(file, "utf8"), rules)) { + violations.push({ file, ...found }); + } + } + return violations; +}