Files
Nextcue/source/check_resolve.py
T
christianandClaude Opus 5.5 1a658875bc Resi cue countdowns on ProPresenter and in Companion
The Resi -> ProPresenter bridge, its installer and site kit, and the
Companion module resi-studio-cues, as running at North Attleboro.

Bridge (resi_to_propresenter.py)
- Follows a Resi decoder's playhead and keeps ProPresenter's NextCue timer
  counting down to the next cue; optional cue name as a stage message, sent
  once per cue (ProPresenter flashes it on every send) and off by default.
- Playhead estimate no longer learns a cadence from the partial first gap
  after a start, so a 30s Resi doesn't read as paused half the time.
- Backs off on 429, holds 2 min before retrying a failed startup login, and
  logs Resi's actual reason (wrong password, USER_LOCKED, rate limit).
- --setup asks for the login, decoder, ProPresenter and timer.

install_bridge.sh installs it as a login service (outside ~/Documents,
which launchd may not read); make_kit.sh builds the kit for another site.

Companion module resi-studio-cues 0.1.4 (source/, package committed)
- Same playhead fix; login and request hold-off (15s doubling to 5 min);
  a 401/403 on a fresh session is a permission, not a reason to sign in
  again; init never fails on a Resi problem, so Companion can't restart it
  into a login loop.

Tests: test_playhead.py, test_stage_message.py, test_rate_limit.py and
source/backoff_test.js run offline; test_end_to_end.py drives a real
ProPresenter and refuses to run without an explicit flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:11:01 -04:00

117 lines
4.7 KiB
Python

#!/usr/bin/env python3
"""Walk every .js in a package and check that each require()/import target resolves,
following Node's resolution rules against the vendored node_modules tree."""
import json
import os
import re
import sys
ROOT = os.path.abspath(sys.argv[1])
BUILTIN = set("""assert async_hooks buffer child_process cluster console constants crypto dgram
diagnostics_channel dns domain events fs http http2 https inspector module net os path
perf_hooks process punycode querystring readline repl stream string_decoder sys timers tls
trace_events tty url util v8 vm wasi worker_threads zlib""".split())
SPEC = re.compile(r"""(?:require\(|(?:^|[\s;{}])(?:import|export)[^;\n]*?\bfrom\s*|import\()\s*['"]([^'"]+)['"]""", re.M)
DYNAMIC = re.compile(r"require\(\s*[^'\")\s]")
COMMENTS = re.compile(r"/\*.*?\*/|^\s*//.*$", re.S | re.M)
# Optional at runtime: loaded only under a config or platform this module never uses.
OPTIONAL = {"re2", "expo-random", "tinybench", "uri-js", "neostandard"}
SKIP_DIRS = ("test", "benchmark", "benchmarks", "example", "examples", ".github")
def load_json(p):
try:
with open(p) as fh:
return json.load(fh)
except Exception:
return {}
def resolve_file(base):
for cand in (base, base + ".js", base + ".json", base + ".cjs", base + ".mjs", base + ".node"):
if os.path.isfile(cand):
return cand
if os.path.isdir(base):
pj = load_json(os.path.join(base, "package.json"))
for key in ("main", "module"):
if isinstance(pj.get(key), str):
hit = resolve_file(os.path.join(base, pj[key]))
if hit:
return hit
if pj.get("exports"):
return base # exports map: trust it, too complex to model here
for idx in ("index.js", "index.json", "index.cjs", "index.node"):
if os.path.isfile(os.path.join(base, idx)):
return os.path.join(base, idx)
return None
def resolve_bare(spec, from_dir):
d = from_dir
while True:
hit = resolve_file(os.path.join(d, "node_modules", *spec.split("/")))
if hit:
return hit
# scoped or deep import into a package that exists
parts = spec.split("/")
pkg = "/".join(parts[:2]) if spec.startswith("@") else parts[0]
pkg_dir = os.path.join(d, "node_modules", *pkg.split("/"))
if os.path.isdir(pkg_dir) and len(parts) > (2 if spec.startswith("@") else 1):
sub = resolve_file(os.path.join(pkg_dir, *parts[(2 if spec.startswith("@") else 1):]))
if sub:
return sub
if load_json(os.path.join(pkg_dir, "package.json")).get("exports"):
return pkg_dir
if os.path.dirname(d) == d or len(d) <= len(ROOT) - 1:
return None
d = os.path.dirname(d)
missing, dynamic, files, optional_hits = [], [], 0, set()
for dirpath, dirnames, filenames in os.walk(ROOT):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
if dirpath.endswith((os.sep + "browser", os.sep + "types")):
continue
for fn in filenames:
if not fn.endswith((".js", ".cjs", ".mjs")):
continue
if fn.endswith((".native.js", ".browser.js")) or fn.startswith("eslint.config"):
continue
path = os.path.join(dirpath, fn)
files += 1
try:
src = COMMENTS.sub("", open(path, encoding="utf-8", errors="replace").read())
except OSError:
continue
if DYNAMIC.search(src):
dynamic.append(os.path.relpath(path, ROOT))
for spec in set(SPEC.findall(src)):
if spec.startswith("node:") or spec.split("/")[0] in BUILTIN:
continue
if spec in OPTIONAL:
optional_hits.add((os.path.relpath(path, ROOT), spec))
continue
if spec.startswith("."):
hit = resolve_file(os.path.normpath(os.path.join(dirpath, spec)))
else:
hit = resolve_bare(spec, dirpath)
if not hit:
missing.append((os.path.relpath(path, ROOT), spec))
print("scanned %d js files" % files)
if dynamic:
print("\n%d file(s) use a computed require() (not statically checkable):" % len(dynamic))
for d in sorted(dynamic)[:12]:
print(" ", d)
if optional_hits:
print("\n%d optional dep(s), not loaded on this platform/config:" % len(optional_hits))
for f, spec in sorted(optional_hits):
print(" %-64s -> %s" % (f, spec))
if missing:
print("\nUNRESOLVED (%d):" % len(missing))
for f, s in sorted(set(missing)):
print(" %-70s -> %s" % (f, s))
sys.exit(1)
print("\nevery static require/import resolves")