Files
codebase-memory-mcp/scripts/gen-ui-licenses.py
T
Martin Vogel 9d85701a6d chore(ui): drop sharp and its LGPL libvips; the license gate allows no exception
The v0.12.0 release run's license gate blocked @img/sharp-wasm32@0.35.5
(Apache-2.0 AND LGPL-3.0-or-later AND MIT). sharp sat in graph-ui's tree
only because @huggingface/transformers lists it for its Node image path.
CBM Lens runs Transformers in the browser (a tokenizer and a causal LM on
WebGPU), never processes images, and the Transformers web build already
replaces sharp with an empty object. Yet sharp installed 14 LGPL packages
(10 @img/sharp-libvips-*, 3 @img/sharp-win32-*, @img/sharp-wasm32), and
the gate let them through two ways:
- a reviewed exception for exactly @img/sharp-wasm32@0.35.0 ("installed
  but not shipped"), which the 0.35.5 bump in #2632 tripped;
- collect() skipped every os/cpu-restricted package as "build tooling",
  which hid all ten LGPL libvips binaries.

A non-permissive license has no place in the tree, shipped or not:
- sharp now resolves to graph-ui/stubs/sharp, our own MIT stand-in that
  throws if anything ever calls it (direct dependency file:./stubs/sharp
  plus override "sharp": "$sharp"). The whole sharp family leaves the
  lockfile: 441 -> 411 packages, zero LGPL/GPL left (audited).
- ui_browser_licenses.py loses the exception and keeps only the browser
  build evidence check; license-gate-check-npm.py validates that evidence
  unconditionally, checks platform-restricted packages like any other,
  and has no exclusion path; gen-ui-licenses.py drops the exception
  section.
- tests: the exception tests become guards: an unshipped LGPL package
  blocks, a platform-restricted LGPL package blocks, a platform-restricted
  MIT package passes. Against the old scripts both LGPL guards fail.

The shipped browser bundle is unchanged: rebuilt, it differs from main's
only by the build's own -dirty version marker. License gate stage 3 on
this tree: 211 production packages, all allow-listed.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-13 20:57:12 +02:00

115 lines
4.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""Emit a markdown license appendix for the external graph-UI asset pack.
Usage: gen-ui-licenses.py <graph-ui-dir>
Walks the PRODUCTION dependency tree (npm ls --omit=dev) — the packages
whose code is compiled into the UI bundle — and prints, for each unique
package, its declared license and the verbatim license file text found in
node_modules. Build-time tooling (vite, tailwind, lightningcss, ...) is
excluded: its code does not ship in the bundle.
Called by gen-third-party-notices.sh when node_modules is present (the
with-ui packaging path). Output is deterministic (sorted by name@version).
"""
import json
import os
import subprocess
import sys
def collect(ui_dir, dep_tree, out, excluded):
for name, info in (dep_tree or {}).items():
version = info.get("version")
if not version:
continue # unmet optional peer — not installed, nothing shipped
pkg_dir = os.path.join(ui_dir, "node_modules", *name.split("/"))
try:
with open(os.path.join(pkg_dir, "package.json"), encoding="utf-8") as fh:
meta = json.load(fh)
except (OSError, ValueError):
continue # not physically installed — nothing shipped
# Platform-restricted packages (os/cpu fields) are native build
# tooling (esbuild/rollup/lightningcss/oxide binaries). They cannot
# be part of a browser bundle — exclude them and do not recurse into
# their dependency subtrees (wasm runtime shims etc.).
if meta.get("os") or meta.get("cpu"):
excluded.add((name, version))
continue
out.add((name, version))
collect(ui_dir, info.get("dependencies"), out, excluded)
def license_text(pkg_dir):
if not os.path.isdir(pkg_dir):
return None
for fname in sorted(os.listdir(pkg_dir)):
if fname.upper().startswith(("LICENSE", "LICENCE", "COPYING", "NOTICE")):
try:
with open(os.path.join(pkg_dir, fname), encoding="utf-8", errors="replace") as fh:
return fh.read().strip()
except OSError:
continue
return None
def declared_license(pkg_dir):
try:
with open(os.path.join(pkg_dir, "package.json"), encoding="utf-8") as fh:
d = json.load(fh)
except (OSError, ValueError):
return "UNKNOWN"
lic = d.get("license", "UNKNOWN")
if isinstance(lic, dict):
lic = lic.get("type", "UNKNOWN")
return str(lic)
def main():
ui_dir = sys.argv[1]
# shell=True so the npm shim resolves on every platform (npm.cmd on
# Windows is not found by bare-name exec). Constant command, no injection.
ls = subprocess.run(
"npm ls --omit=dev --all --json",
shell=True, cwd=ui_dir, capture_output=True, text=True, check=False,
)
tree = json.loads(ls.stdout or "{}")
pkgs = set()
excluded = set()
collect(ui_dir, tree.get("dependencies"), pkgs, excluded)
print("## External Graph UI asset pack — bundled npm packages")
print()
print("The `-ui` archives ship a compiled frontend asset pack beside the binary.")
print("The packages below are its production dependency tree; license texts are")
print("reproduced verbatim from the packages as installed at build time.")
print("The whole production dependency tree is covered conservatively.")
print()
for name, version in sorted(pkgs):
pkg_dir = os.path.join(ui_dir, "node_modules", *name.split("/"))
lic = declared_license(pkg_dir)
text = license_text(pkg_dir)
print(f"### {name}@{version} — {lic}")
print()
if text:
print(text)
else:
print(f"(no license file shipped in the package; declared license: {lic})")
print()
if excluded:
print("### Platform-specific build tooling (not part of the bundle)")
print()
print("Resolved in the dependency tree but excluded above: these are")
print("native per-platform build binaries whose code does not ship in")
print("the browser bundle.")
print()
for name, version in sorted(excluded):
print(f"- {name}@{version}")
print()
if __name__ == "__main__":
main()