#!/usr/bin/env python3 """Static dependency check for a folder of NeoForge mod jars. Reads META-INF/neoforge.mods.toml from every jar (and from jar-in-jar files under META-INF/jarjar/), then checks that each required dependency is present and inside its declared version range. Needs no server and no EULA, so it runs anywhere; the server smoke test is still the real proof. Version ranges use Maven syntax ("[1.8.4,1.9)", "[21.1,)", "1.0" = at least). Build metadata after "+" is ignored, so Cobblemon "1.8.4+1.21.1" compares as 1.8.4; qualifiers follow Maven (21-beta < 21 < 21.1.265). A mismatch on the `minecraft` dependency is only a warning: every JEI build for 1.21.1 declares "[1.21, 1.21.1)" and NeoForge 21.1 still loads it (checked on the test server, 2026-10-28), so this check is stricter than the loader there. A dependency limited to the other side (`side = "CLIENT"` in a server folder, e.g. Cobbreeding's Cloth Config) is skipped. Usage: tools/check_deps.py [--neoforge VER] [--minecraft VER] [--side server|client] """ import argparse import io import re import sys import tomllib import zipfile from pathlib import Path PLATFORM = {"neoforge", "minecraft", "javafml", "fml", "kotlinforforge_loader"} QUALIFIERS = {"alpha": 1, "a": 1, "beta": 2, "b": 2, "milestone": 3, "m": 3, "rc": 4, "cr": 4, "snapshot": 5} RELEASE = (0, 10, "") # pads the shorter version: a release sorts above its own alpha/beta/rc def key(version): """Maven-like ordering: numbers sort above qualifiers, so 21.1.265 > 21-beta > 21-alpha.""" parts = re.split(r"[.\-]", version.split("+", 1)[0]) return [(1, int(p), "") if p.isdigit() else (0, QUALIFIERS.get(p.lower(), 6), p.lower()) for p in parts if p] def compare(a, b): ka, kb = key(a), key(b) n = max(len(ka), len(kb)) ka, kb = ka + [RELEASE] * (n - len(ka)), kb + [RELEASE] * (n - len(kb)) return (ka > kb) - (ka < kb) def in_range(version, spec): spec = spec.strip() if spec in ("", "*"): return True if spec[0] not in "[(": return compare(version, spec) >= 0 for part in re.findall(r"[\[(][^\])]*[\])]", spec): lo_inc, hi_inc = part[0] == "[", part[-1] == "]" bounds = part[1:-1].split(",") if len(bounds) == 1: if compare(version, bounds[0]) == 0: return True continue lo, hi = (b.strip() for b in bounds) if lo and (compare(version, lo) < 0 or (compare(version, lo) == 0 and not lo_inc)): continue if hi and (compare(version, hi) > 0 or (compare(version, hi) == 0 and not hi_inc)): continue return True return False def read_mods(data, name, found, deps, side): z = zipfile.ZipFile(io.BytesIO(data)) names = set(z.namelist()) if "META-INF/neoforge.mods.toml" in names: toml = tomllib.loads(z.read("META-INF/neoforge.mods.toml").decode("utf-8", "replace")) manifest = z.read("META-INF/MANIFEST.MF").decode("utf-8", "replace") if "META-INF/MANIFEST.MF" in names else "" jar_version = (re.search(r"Implementation-Version: *(\S+)", manifest) or [None, "0"])[1] for mod in toml.get("mods", []): version = str(mod.get("version", "0")).replace("${file.jarVersion}", jar_version) found[mod["modId"]] = (version, name) for mod_id, entries in toml.get("dependencies", {}).items(): for dep in entries: required = dep.get("type", "required" if dep.get("mandatory", True) else "optional") == "required" if required and dep.get("side", "BOTH").upper() in ("BOTH", side.upper()): deps.append((mod_id, dep["modId"], dep.get("versionRange", "*"), name)) for inner in (n for n in names if n.startswith("META-INF/jarjar/") and n.endswith(".jar")): read_mods(z.read(inner), f"{name}!{inner.rsplit('/', 1)[-1]}", found, deps, side) def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("mods_dir", type=Path) parser.add_argument("--neoforge") parser.add_argument("--minecraft", default="1.21.1") parser.add_argument("--side", choices=["server", "client"], default="server") args = parser.parse_args() found, deps = {}, [] for jar in sorted(args.mods_dir.glob("*.jar")): read_mods(jar.read_bytes(), jar.name, found, deps, args.side) if args.neoforge: found["neoforge"] = (args.neoforge, "loader") found["minecraft"] = (args.minecraft, "game") problems, warnings = [], [] for owner, dep, spec, jar in deps: if dep not in found: if dep not in PLATFORM: problems.append(f"{owner} ({jar}) needs {dep} {spec}: not installed") elif not in_range(found[dep][0], spec): (warnings if dep == "minecraft" else problems).append( f"{owner} ({jar}) needs {dep} {spec}: found {found[dep][0]}") print(f"{len(found)} mods, {len(deps)} required dependencies checked") for w in warnings: print(f" warning: {w}") if problems: print("\n".join(f" - {p}" for p in problems)) sys.exit(1) print("all required dependencies satisfied") if __name__ == "__main__": main()