← Commit history

2.14.3: LED row was throwing on every refresh (shortState, busySince); build now gated on behaviour

John Lauer ·92037e6864 ·1mo ago ·parent 4bbefb7
8 files changed +266−10
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.14.2"+version = "2.14.3" edition = "2021" description = "adom-lbr generator / EDA translator: the canonical adom-lbr JSON is the hub, with native converters to Altium (.SchLib/.PcbLib/.IntLib), EAGLE/Fusion (.lbr), and KiCad. Altium output is written natively — no Altium needed at conversion time." 
VERSION+1−1
@@ -1 +1 @@-2.14.2+2.14.3
build.sh+17
@@ -35,6 +35,12 @@ if [ "$V" != "$CARGO_V" ] || [ "$V" != "$PKG_V" ]; then fi echo "[build] version $V agrees across VERSION, Cargo.toml and package.json" +# A missing function is invisible to a byte-check. 2.14.0 shipped with api() deleted by an+# over-greedy regex: the markup and CSS were correct, every call site swallowed its own+# error, and the dashboard rendered an empty panel instead of failing. This gate fails the+# build on any identifier that is CALLED in manage.html and DEFINED nowhere.+python3 tools/js-call-check.py src/manage.html || exit 1+ echo "[build] test" cargo test --release --quiet @@ -57,6 +63,17 @@ HITS="$(grep -a -c BABYLON bin/adom-lbr || true)" [ "${HITS:-0}" -lt 10 ] || { echo "[build] FAIL the viewer is back INSIDE the binary (BABYLON hits=$HITS); it should ship in assets/" >&2; exit 1; } grep -q 'assets/\*\*' package.json || { echo "[build] FAIL package.json files[] does not ship assets/**" >&2; exit 1; } +# Behaviour, not bytes: run the REAL page against the REAL binary and assert what a person+# would look at (prompt rows, the two icon buttons, the LED row, the demo button), with the+# page's own swallowing catches made to confess. Byte-checks passed while 2.14.0 and 2.14.2+# shipped broken; this is the check that would have caught both.+if [ -d tools/node_modules ]; then+  NODE_PATH=tools/node_modules node tools/page-smoke.js || exit 1+else+  echo "[smoke] NOT RUN: tools/node_modules is missing, so the page's BEHAVIOUR is unverified." >&2+  echo "        run: (cd tools && npm install)" >&2+fi+ printf '[build] OK  %s  binary %.2f MB + assets %.2f MB (was one %.2f MB binary)\n' "$V" \   "$(stat -c%s bin/adom-lbr | awk '{print $1/1048576}')" \   "$(du -sb assets | awk '{print $1/1048576}')" \
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.14.2",+  "version": "2.14.3",   "type": "app",   "description": "adom-lbr \u2014 the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad \u21c4 Altium \u21c4 EAGLE/Fusion) through one canonical adom-lbr JSON \u2014 symbol + footprint + 3D + pin\u2194pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.",   "dependencies": {},
src/manage.html+29−7
@@ -60,7 +60,7 @@   #say .say-sub{margin:4px 0 14px;font:13.5px var(--brand-body);color:var(--muted);max-width:70ch}   #say .say-sub b{color:var(--teal);font-weight:700}   .say-close{position:absolute;top:12px;right:12px;width:28px;height:28px}-  .say-cols{display:grid;grid-template-columns:minmax(180px,220px) 1fr;gap:24px;align-items:start}+  .say-cols{display:grid;grid-template-columns:clamp(180px,30%,220px) 1fr;gap:24px;align-items:start}   .say-right{border-left:1px solid var(--line);padding-left:24px;min-width:0}   #btnDemo{display:inline-flex;align-items:center;gap:8px;background:var(--teal);color:var(--on-accent);border:none;border-radius:8px;padding:10px 18px;font:600 14px var(--brand-heading);cursor:pointer;transition:filter .12s,transform .06s}   #btnDemo:hover:not(:disabled){filter:brightness(1.08)}@@ -79,12 +79,18 @@   .actbtn:hover{color:var(--teal);border-color:var(--line);background:rgba(0,184,177,.08)}   .actbtn:active{transform:translateY(1px)}   .actbtn:focus-visible{outline:2px solid var(--teal);outline-offset:2px}-  /* 620px, not 760px (adom-ui-design 1.25.0): John's dock pane measures 744px,-     inside the 744-853 band, so a 760px breakpoint collapsed the two columns in-     the very pane the panel was designed for. 620 clears the band and still-     collapses on a phone. A breakpoint bug is invisible to any test matrix-     whose widths straddle it, which is why 744 is now in mine. */-  @media (max-width:620px){+  /* 700px, the fleet number (adom-ui-design 1.34.0). I shipped 620 earlier, derived+     against John's 744px rail-CLOSED pane; that was a unilateral divergence made from a+     stale reading of the skill. It also manufactured the worst layout in the matrix: his+     pane is 632px with the dock rail OPEN, and 620 kept TWO columns there, squeezing the+     phrase cell to 234px. At 700 that state collapses to one column and the phrase cell+     recovers to 478px, because a single column reclaims the left track AND the 24px gap.+     The narrowest phrase cell anywhere is just ABOVE the threshold (338px at a 702px+     viewport), so the truncation risk lives at the BOTTOM of the two-column band, not the+     top; John's resting 744px sits 44px into it at 367px. Widths 632 and 744 are both in+     my matrix now, with the rail state recorded, since a breakpoint bug is invisible to+     any matrix whose widths straddle it. */+  @media (max-width:700px){     .say-cols{grid-template-columns:1fr}     .say-right{border-left:none;border-top:1px solid var(--line);padding-left:0;padding-top:16px}     #btnDemo{width:100%;justify-content:center}@@ -758,6 +764,22 @@ function toast(msg,err,kind){   t.addEventListener('mouseenter',()=>clearTimeout(timer));   function fade(el){el.classList.add('out');setTimeout(()=>el.remove(),160);} }+// The LED's short label comes from the server (dock.rs led_short), which sends a `short`+// on every LED: "v2.14.3", "1525 parts", "not answering". This helper went missing in an+// edit and paintLeds called it anyway, so the whole LED row threw on every refresh. Found+// by tools/js-call-check.py, not by eye.+function shortState(l){return l.short||l.state||'';}+// How long an LED has been amber, so a warn that never clears can say "stuck?" instead of+// pretending to be a normal busy state. Declared here because it is state that must SURVIVE+// between refreshes; it was referenced but never declared, which threw inside paintLeds on+// every single status refresh and left the header showing one red "dashboard not answering"+// pill while the server was perfectly healthy.+let busySince={};+// The last /api/status payload, read by the settings dialog. It used to be an IMPLICIT+// global created by its own assignment, which works only while that assignment runs first:+// when the status call threw, the settings dialog hit a ReferenceError instead of simply+// finding nothing. Declared, so it is null rather than absent.+let lastStatus=null; function paintLeds(st){   const row=document.getElementById('ledrow');row.innerHTML='';   (st.leds||[]).forEach(l=>{
tools/js-call-check.pyadded+122
@@ -0,0 +1,122 @@+#!/usr/bin/env python3+"""Catch the 2.14.0 bug class: a function that is CALLED but no longer DEFINED.++An over-greedy regex during the toast rewrite deleted the api() helper. Every byte-check+passed, because the markup and CSS were genuinely correct, and every call site swallowed+its own error, so the page rendered an empty panel instead of failing. Only a read-only+eval inside John's live webview found it. This script finds it at build time instead.++It is deliberately conservative: it only reports a name that is called like a function+and is defined NOWHERE in the page and is not a known global. False negatives are fine;+a false positive that blocks a build is not.+"""+import re, sys, pathlib++src = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "src/manage.html").read_text()+scripts = re.findall(r"<script\b[^>]*>(.*?)</script>", src, re.S)+if not scripts:+    print("[jscheck] FAIL no <script> blocks found"); sys.exit(1)+js = "\n".join(scripts)++# Strip comments and strings with a single left-to-right scanner. Per-quote regex+# stripping is WRONG: an apostrophe inside a double-quoted string ("don't") makes the+# single-quote pass eat everything up to the next apostrophe, deleting real definitions+# and reporting them as missing. That false-positive storm is exactly how this script+# first failed, so the scanner walks the text once and honours whichever quote opened.+def strip_js(t):+    out = []; i = 0; n = len(t)+    while i < n:+        c = t[i]+        if c == "/" and i + 1 < n and t[i+1] == "/":+            while i < n and t[i] != "\n": i += 1+        elif c == "/" and i + 1 < n and t[i+1] == "*":+            i += 2+            while i + 1 < n and not (t[i] == "*" and t[i+1] == "/"): i += 1+            i += 2+        elif c in "'\"`":+            q = c; i += 1+            while i < n:+                if t[i] == "\\": i += 2; continue+                if t[i] == q: i += 1; break+                # a ${ } inside a template literal holds real code: keep it+                if q == "`" and t[i] == "$" and i + 1 < n and t[i+1] == "{":+                    depth = 1; i += 2; start = i+                    while i < n and depth:+                        if t[i] == "{": depth += 1+                        elif t[i] == "}": depth -= 1+                        i += 1+                    out.append(strip_js(t[start:i-1]))+                    continue+                i += 1+            out.append('""')+        else:+            out.append(c); i += 1+    return "".join(out)++stripped = strip_js(js)++defined = set()+defined |= set(re.findall(r"function\s+([A-Za-z_$][\w$]*)", stripped))+defined |= set(re.findall(r"(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function|\()", stripped))+# a declaration list declares EVERY name in it: `let sx,sy,ox,oy,drag=false` declares five,+# and matching only the first is how this script once reported four real declarations as+# implicit globals+for m in re.finditer(r"\b(?:const|let|var)\s+", stripped):+    i = m.end(); depth = 0; buf = []+    while i < len(stripped):+        c = stripped[i]+        if c in "([{": depth += 1+        elif c in ")]}":+            if depth == 0: break+            depth -= 1+        elif c == ";" and depth == 0: break+        elif c == "\n" and depth == 0 and not buf[-1:] == [","]: pass+        buf.append(c); i += 1+    for part in re.split(r",(?![^([{]*[)\]}])", "".join(buf)):+        name = re.match(r"\s*([A-Za-z_$][\w$]*)", part.split("=")[0])+        if name: defined.add(name.group(1))+        # destructuring: `const {a,b} = x` / `const [a,b] = x`+        defined |= set(re.findall(r"[A-Za-z_$][\w$]*", part.split("=")[0])) if part.strip()[:1] in "{[" else set()+defined |= set(re.findall(r"([A-Za-z_$][\w$]*)\s*:\s*(?:async\s*)?function", stripped))+defined |= set(re.findall(r"function\s*\(([^)]*)\)", stripped) and [])+for params in re.findall(r"function[^(]*\(([^)]*)\)", stripped):+    defined |= {p.strip().split("=")[0].strip() for p in params.split(",") if p.strip()}+for params in re.findall(r"\(([^()]*)\)\s*=>", stripped):+    defined |= {p.strip().split("=")[0].strip() for p in params.split(",") if p.strip()}+defined |= set(re.findall(r"([A-Za-z_$][\w$]*)\s*=>", stripped))+defined |= set(re.findall(r"catch\s*\(\s*([A-Za-z_$][\w$]*)", stripped))+# object-literal method shorthand -- `log(line){...}` inside an object is a DEFINITION,+# and it reads exactly like a call site if you only pattern-match on NAME(+defined |= set(re.findall(r"[{,]\s*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{", stripped))++GLOBALS = set("""+window document console fetch setTimeout clearTimeout setInterval clearInterval+JSON Math Date Object Array String Number Boolean Promise Error Map Set RegExp+parseInt parseFloat isNaN encodeURIComponent decodeURIComponent alert confirm prompt+AbortController FormData URL URLSearchParams Image Audio Blob File FileReader+requestAnimationFrame cancelAnimationFrame getComputedStyle matchMedia structuredClone+addEventListener removeEventListener dispatchEvent+localStorage sessionStorage navigator location history CustomEvent Event MutationObserver+IntersectionObserver ResizeObserver TextEncoder TextDecoder btoa atob queueMicrotask+Intl Symbol Proxy Reflect BigInt WeakMap WeakSet ArrayBuffer Uint8Array+if for while switch catch return typeof function do else new delete void await yield+async of in instanceof throw case try finally+""".split())++called = set(re.findall(r"(?<![.\w$])([A-Za-z_$][\w$]*)\s*\(", stripped))+missing = sorted(n for n in called if n not in defined and n not in GLOBALS)++# Second class: an IMPLICIT GLOBAL, a bare `name = value` with no declaration anywhere.+# It works only while its assignment happens to run before every read, so it fails exactly+# when something upstream has already gone wrong, turning one fault into two.+assigns = set(re.findall(r"(?:^|[;{}\n])\s*([A-Za-z_$][\w$]*)\s*=(?!=)", stripped))+implicit = sorted(n for n in assigns if n not in defined and n not in GLOBALS)+if implicit:+    print("[jscheck] FAIL assigned but never declared (implicit global): " + ", ".join(implicit))+    sys.exit(1)++if missing:+    print("[jscheck] FAIL called but never defined: " + ", ".join(missing))+    print("[jscheck] this is the 2.14.0 bug class (a helper deleted by an edit).")+    sys.exit(1)+print(f"[jscheck] OK  {len(called)} distinct call targets, all defined")
tools/package.jsonadded+6
@@ -0,0 +1,6 @@+{+  "name": "adom-lbr-tools",+  "private": true,+  "description": "Build-time checks for adom-lbr. jsdom runs the real dashboard page so a missing function fails the build instead of shipping as an empty panel.",+  "devDependencies": { "jsdom": "^22.1.0" }+}
tools/page-smoke.jsadded+89
@@ -0,0 +1,89 @@+#!/usr/bin/env node+/* Behavioural smoke test for the dashboard page.+ *+ * WHY THIS EXISTS. 2.14.0 shipped with api() deleted by an over-greedy regex and 2.14.2+ * shipped with shortState() and busySince missing. All three were invisible to 22 byte+ * checks, because the markup and the CSS were genuinely correct. They were invisible to+ * review. And they were invisible to the user too, because every call site catches its own+ * error: the page answered with an empty prompts panel and a single red "dashboard not+ * answering" pill while the server was serving 200s in four milliseconds.+ *+ * A structural audit cannot see a missing function. This runs the REAL page against the+ * REAL server and asserts what a person would look at.+ *+ * Requires jsdom (npm i jsdom@22 inside tools/). No layout engine, so this proves+ * BEHAVIOUR, not pixels; widths still have to be measured in a real webview.+ */+const { JSDOM, VirtualConsole } = require('jsdom');+const { spawn, execSync } = require('child_process');++const PORT = process.env.SMOKE_PORT || '8971';+const URL = `http://127.0.0.1:${PORT}/`;+const BIN = process.env.SMOKE_BIN || './target/release/adom-lbr';++const EXPECT = {+  sayRows: n => n >= 3 && n <= 6,          // newbie-panel: 3 to 6 rows, never zero+  copyButtons: n => n >= 6,                 // two icon buttons per row+  ledPills: n => n >= 5,                    // Binary, Wiki, Identity, Library, 3D viewer, Dock+  demoButton: v => v === true,+};++(async () => {+  const srv = spawn(BIN, ['dashboard', 'serve', '--port', String(PORT), '--foreground'],+                    { stdio: 'ignore', detached: true });+  const done = code => { try { process.kill(-srv.pid); } catch (e) {} process.exit(code); };++  for (let i = 0; i < 40; i++) {+    try { await fetch(URL); break; } catch (e) { await new Promise(r => setTimeout(r, 250)); }+  }++  const errs = [];+  const vc = new VirtualConsole();+  vc.on('jsdomError', e => errs.push(String(e.message || e)));++  let html = await (await fetch(URL)).text();+  // Make the page's own swallowing catches confess, in this COPY only. A caught error is+  // still a defect; the user just never gets told about it.+  html = html.replace(/\}catch\((\w+)\)\{/g, '}catch($1){ if(typeof console!=="undefined")console.error("SWALLOWED >>> "+($1&&$1.stack?$1.stack:$1));');+  vc.on('error', (...a) => { const s = a.join(' '); if (/SWALLOWED/.test(s)) errs.push(s.split('\n')[0]); });++  const dom = new JSDOM(html, {+    url: URL, runScripts: 'dangerously', pretendToBeVisual: true, virtualConsole: vc,+    beforeParse(w) {+      w.fetch = (p, o) => fetch(new w.URL(p, URL).href, o);+      w.scrollTo = () => {};+      w.matchMedia = w.matchMedia || (q => ({ matches: false, media: q, addListener(){}, removeListener(){}, addEventListener(){}, removeEventListener(){} }));+    }+  });+  const d = dom.window.document;+  await new Promise(r => setTimeout(r, 4000));++  const got = {+    sayRows: d.querySelectorAll('.say-row').length,+    copyButtons: d.querySelectorAll('.actbtn').length,+    ledPills: d.querySelectorAll('.led-pill').length,+    demoButton: !!d.getElementById('btnDemo'),+  };+  const ledText = [...d.querySelectorAll('.led-pill')].map(p => p.textContent.replace(/\s+/g, ' ').trim());++  let bad = 0;+  for (const [k, test] of Object.entries(EXPECT)) {+    const ok = test(got[k]);+    if (!ok) bad++;+    console.log(`[smoke] ${ok ? 'ok  ' : 'FAIL'} ${k} = ${got[k]}`);+  }+  // The specific shape of the 2.14.2 bug: the LED row falls back to one red pill.+  if (ledText.length === 1 && /not answering/.test(ledText[0])) {+    console.log('[smoke] FAIL LED row is the error fallback: ' + ledText[0]); bad++;+  }+  const real = errs.filter(e => /ReferenceError|TypeError|SyntaxError/.test(e));+  if (real.length) {+    bad++;+    console.log('[smoke] FAIL javascript errors on load (including ones the page swallows):');+    [...new Set(real)].slice(0, 6).forEach(e => console.log('        ' + e.slice(0, 160)));+  }+  console.log('[smoke] leds: ' + (ledText.join(' | ') || '(none)'));+  console.log(bad ? `[smoke] FAILED ${bad} check(s)` : '[smoke] OK  page renders and answers');+  dom.window.close();+  done(bad ? 1 : 0);+})().catch(e => { console.log('[smoke] FAIL ' + e.message); process.exit(1); });