From f855ee15b7bda427524ac20fb989c1c199b638ef Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Wed, 21 Oct 2026 22:17:50 +0000 Subject: [PATCH] split tooling: line-map splitter, post edits, node checks, harness fragments as modules over window.g, a workflow that performs a step The harness fragments now run as inline modules (after app.js) and stub the renderer through render.setFetcher; harness.sh's loop mode goes over http + DevTools like resume (file:// cannot load modules; --dump-dom also hangs on current Chrome). No page change yet. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG --- .gitea/workflows/split.yml | 46 ++++++++++++++++++++++++++ tools/check-pure.mjs | 12 +++++++ tools/harness-resume.html | 8 +++-- tools/harness.html | 8 +++-- tools/harness.sh | 48 ++++++++++----------------- tools/split-globals.txt | 5 +++ tools/split-map.json | 1 + tools/split-post.py | 26 +++++++++++++++ tools/split.js | 67 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 184 insertions(+), 37 deletions(-) create mode 100644 .gitea/workflows/split.yml create mode 100644 tools/check-pure.mjs create mode 100644 tools/split-globals.txt create mode 100644 tools/split-map.json create mode 100644 tools/split-post.py create mode 100644 tools/split.js diff --git a/.gitea/workflows/split.yml b/.gitea/workflows/split.yml new file mode 100644 index 0000000..f8a6f97 --- /dev/null +++ b/.gitea/workflows/split.yml @@ -0,0 +1,46 @@ +name: split +# One step of the module split, performed on the runner: rebuild from main's index.html with tools/split.js (the +# line map), apply tools/split-post.py, check, commit to the branch the workflow was dispatched on. Throwaway once +# the split has landed. +on: + workflow_dispatch: + inputs: + step: + description: "comma list of modules to extract (the rest stays inline), or all" + required: true + message: + description: "commit message (first line)" + required: true +permissions: + contents: write +jobs: + split: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: rebuild from main's index.html + env: + STEP_IN: ${{ github.event.inputs.step }} + run: | + set -eu + git show origin/main:index.html > index.html + if [ "$STEP_IN" = all ]; then WRITE=1 node tools/split.js > /dev/null; else STEP="$STEP_IN" WRITE=1 node tools/split.js > /dev/null; fi + python3 tools/split-post.py + node --check $(ls *.js ui/*.js 2>/dev/null | grep -v -e brain.js -e sw.js) + if [ -f covers.js ] && [ -f render.js ]; then node tools/check-pure.mjs; fi + wc -l index.html *.js ui/*.js 2>/dev/null || true + - name: commit + env: + MSG: ${{ github.event.inputs.message }} + run: | + set -eu + git add -A + if git diff --cached --quiet; then echo "nothing changed"; exit 0; fi + git -c user.name='Claude Fable 5.1' -c user.email='noreply@anthropic.com' commit -q -m "$MSG" -m "Co-Authored-By: Claude Fable 5.1 " -m "Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG" + git push -q origin "HEAD:${GITHUB_REF_NAME}" diff --git a/tools/check-pure.mjs b/tools/check-pure.mjs new file mode 100644 index 0000000..3636e84 --- /dev/null +++ b/tools/check-pure.mjs @@ -0,0 +1,12 @@ +// tools/check-pure.mjs: covers.js and render.js's generators load and run in node (a canvas stub stands in for the DOM). +const noop = () => {}; const ctx = new Proxy({}, { get: (_, k) => k === "measureText" ? () => ({ width: 10 }) : k === "createLinearGradient" || k === "createRadialGradient" ? () => ({ addColorStop: noop }) : noop, set: () => true }); +const canvas = () => ({ width: 64, height: 64, getContext: () => ctx }); +globalThis.document = { createElement: canvas, documentElement: {} }; globalThis.getComputedStyle = () => ({ getPropertyValue: () => "" }); +globalThis.localStorage = { getItem: () => null, setItem: noop }; globalThis.Audio = class { constructor() {} }; +const covers = await import("../covers.js"); +for (const look of covers.LOOKS) covers.drawCover(canvas(), { seed: "s", title: "t", tags: "x", bpm: 100, key: "A minor", energy: 0.5, look }); +covers.drawCover(canvas(), { seed: "s", title: "t", tags: "x", bpm: 100, key: "C", energy: 0.5, break: "ident" }); +const render = await import("../render.js"); +const w = render.wavBlob(new Int16Array(80), 8000); if (w.size !== 44 + 160) throw new Error("wavBlob size " + w.size); +const n = await render.makeNoise("pink", 1), t = await render.makeTone({ kind: "binaural", hz: 200, beat: 10 }, 1); +console.log("covers ok, wav", w.size, "noise", n.size, "tone", t.size); diff --git a/tools/harness-resume.html b/tools/harness-resume.html index 42252b2..0812299 100644 --- a/tools/harness-resume.html +++ b/tools/harness-resume.html @@ -9,15 +9,17 @@ link. Expected: the track is loaded but autoplay is refused, so the player waits and the next track cooks behind it. The other half — the tap starting it — needs a real user activation, which headless Chrome cannot fake; it is documented in ARCHITECTURE.md, not asserted here.

-', '') # after the module, which is deferred too +h.write_text(s) +sw = ROOT / "sw.js"; s = sw.read_text() +mods = sorted(str(p.relative_to(ROOT)) for p in list(ROOT.glob("*.js")) + list(ROOT.glob("ui/*.js")) if p.name not in ("brain.js", "sw.js")) +s = re.sub(r'const FILES = \["/", "/index.html",( "/[a-z/]+\.js",)*', 'const FILES = ["/", "/index.html",' + "".join(f' "/{m}",' for m in mods), s) +sw.write_text(s) +print("post ok:", mods) diff --git a/tools/split.js b/tools/split.js new file mode 100644 index 0000000..9ff2e90 --- /dev/null +++ b/tools/split.js @@ -0,0 +1,67 @@ +// tools/split.js — one-off: cut index.html's script into ES modules by line ranges. Analysis mode by default; WRITE=1 writes. +// STEP=a.js,b.js extracts only those modules and leaves the rest as an inline module in index.html (the intermediate commits). +const fs = require("fs"); +const src = fs.readFileSync("index.html", "utf8").split("\n"); +const S = 402, E = 2199; // at E +const cuts = JSON.parse(fs.readFileSync("tools/split-map.json", "utf8")); // [[startLine, "file"], ...] sorted +const body = src.slice(S, E - 1); // lines S+1 .. E-1 (1-based) +const at = (n) => n - (S + 1); // index into body +const chunks = []; +for (let i = 0; i < cuts.length; i++) { + const [a, f] = cuts[i], b = i + 1 < cuts.length ? cuts[i + 1][0] : E; + chunks.push({ file: f, from: a, to: b - 1, lines: body.slice(at(a), at(b)) }); +} +const STEP = process.env.STEP ? new Set(process.env.STEP.split(",")) : null; +for (const c of chunks) if (STEP && !STEP.has(c.file)) c.file = "index.html"; +const mods = {}; +for (const c of chunks) (mods[c.file] ||= { file: c.file, lines: [], decl: new Map(), lets: new Set() }).lines.push(...c.lines, ""); +const declRe = /^(?:async\s+)?function\s+([A-Za-z_$][\w$]*)|^(const|let)\s+(.+?)(?:\s*=|;|$)/; +for (const m of Object.values(mods)) { + m.lines.forEach((ln, i) => { + let r = declRe.exec(ln); if (!r) return; + if (r[1]) m.decl.set(r[1], i); + else { + // const a = 1, b = 2; const { x } = y; const [a, b] = z + const head = ln.slice(r[2].length).trim(); + const names = [...head.matchAll(/(?:^|,\s*)([A-Za-z_$][\w$]*)\s*=/g)].map((x) => x[1]); + if (/^\s*[\[{]/.test(head)) for (const n of head.slice(0, head.indexOf("=")).match(/[A-Za-z_$][\w$]*/g) || []) names.push(n); + if (/=>|function/.test(head)) names.length = 1; + for (const n of names) { m.decl.set(n, i); if (r[2] === "let") m.lets.add(n); } + } + }); +} +const owner = {}; for (const m of Object.values(mods)) for (const n of m.decl.keys()) { if (owner[n]) console.error("DUP", n, owner[n], m.file); owner[n] = m.file; } +const strip = (s) => s.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*|`(?:\\.|[^`\\])*`|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'/g, (t) => t[0] === "`" ? (t.match(/\$\{[^}]*\}/g) || []).map((x) => x.slice(2, -1)).join(" ") : " "); +const GLOBALS = fs.readFileSync("tools/split-globals.txt", "utf8"); +const report = {}; +for (const m of Object.values(mods)) { + const text = strip(m.lines.join("\n") + (m.file === "app.js" || m.file === "index.html" ? GLOBALS : "")); + const used = new Set(text.replace(/(? [f, [...s]])), assignsForeign: m.assignsForeign }; +} +for (const m of Object.values(mods)) report[m.file].exports = [...m.decl.keys()].filter((n) => Object.values(mods).some((o) => o !== m && o.imports[m.file] && o.imports[m.file].has(n))); +// window / brain.js / harness needs +const brain = strip(fs.readFileSync("brain.js", "utf8")); +const bUsed = [...new Set(brain.match(/(? owner[n]); +console.log(JSON.stringify({ report, brainUses: bUsed.map((n) => n + "@" + owner[n]) }, null, 1)); +if (!process.env.WRITE) process.exit(0); +const rel = (from, to) => { const d = from.includes("/") ? "../" : "./"; return d + to; }; +let inline = ""; +for (const m of Object.values(mods)) { + const ex = new Set(report[m.file].exports); + const out = m.lines.map((ln, i) => { for (const [n, li] of m.decl) if (li === i && ex.has(n) && /^(const|let|async function|function)\b/.test(ln)) return "export " + ln; return ln; }); + const head = Object.entries(m.imports).map(([f, s]) => `import { ${[...s].sort().join(", ")} } from "${rel(m.file, f)}";`); + const text = (head.length ? head.join("\n") + "\n" : "") + out.join("\n").replace(/\n+$/, "\n"); + if (m.file === "index.html") { inline = text; continue; } + fs.mkdirSync(require("path").dirname(m.file), { recursive: true }); + fs.writeFileSync(m.file, text + (m.file === "app.js" ? GLOBALS : "")); +} +const tag = inline ? [``] : [``]; +const html = src.slice(0, S - 1).concat(tag, src.slice(E)); +fs.writeFileSync("index.html", html.join("\n")); +console.error("written");