scripts: add Check-Deps pre-flight (mods.toml ground truth)

Adds scripts/Check-Deps.ps1 and wires it into Deploy-Brass.ps1 as a
pre-flight gate when Stage includes Pack. The script parses each lockfile
jar's META-INF/neoforge.mods.toml directly -- the same source the loader
reads at startup -- and refuses to deploy if any [[dependencies]] block
with type=required (default) names a modId that nobody in the pack
provides.

Handles three real edge cases discovered during initial run:

1. Jar-in-jar bundled deps: Create ships Ponder, Registrate, Flywheel
   embedded under META-INF/jarjar/. The script recurses into those and
   counts their modIds as present, since the loader auto-loads them.

2. Language-provider jars (Kotlin for Forge) have no standard mods.toml
   -- they declare via a different mechanism. Tiny slugToImplicitModId
   override map (currently 1 entry) covers them.

3. Non-mod lockfile entries (shaderpacks under shaderpacks/, configs
   under config/, etc.) are skipped explicitly by path prefix check.

Vanilla deps (minecraft, neoforge, forge, fabric, java, javafml, etc.)
are pre-seeded as present.

Jars cached at /tmp/bns-check-deps-cache/<sha1>.jar; second-run cost is
~1.5s. First run downloads ~150MB.

The script would have caught the v0.15.0 -> v0.15.1 incident: Slice &
Dice's jar declares kotlinforforge required in mods.toml even though
Modrinth's dep field only lists Create. The Modrinth-only check (earlier
draft) wouldn't have helped; the mods.toml-based check does.
This commit is contained in:
2026-05-23 11:55:38 +00:00
parent 06d6a5ef45
commit 40dd06b8cf
2 changed files with 278 additions and 0 deletions
+267
View File
@@ -0,0 +1,267 @@
#requires -Version 7
<#
.SYNOPSIS
Audit pack.lock.json for missing required mod dependencies by reading
each jar's mods.toml (and any jar-in-jar bundles) directly. Loader-truth,
not Modrinth-truth.
.DESCRIPTION
Each NeoForge/Forge mod declares its real dependencies in
META-INF/neoforge.mods.toml (or META-INF/mods.toml) inside the jar.
This is what the loader actually checks at startup; the Modrinth
dependency field is metadata authors fill in (or don't).
The script also recurses into META-INF/jarjar/ bundles. Big mods like
Create ship their deps (Registrate, Flywheel, Ponder) embedded as
jar-in-jar -- the loader auto-loads them, so their modIds count as
"present" without explicit lockfile entries.
Slug filter: only entries under "mods/" are treated as mod jars.
Shaderpacks and other managed-but-non-mod files are skipped.
Language-provider jars (e.g. Kotlin for Forge) ship without a standard
mods.toml; their modIds are added via a tiny override map.
Jars are cached in /tmp/bns-check-deps-cache/<sha1>.jar so subsequent
runs are instant.
.PARAMETER LockPath
.PARAMETER CacheDir
.PARAMETER WarnOnly
Print missing deps but exit 0. Deploy-Brass.ps1 calls without it.
#>
[CmdletBinding()]
param(
[string]$LockPath = $(Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) '..\pack\pack.lock.json'),
[string]$CacheDir = '/tmp/bns-check-deps-cache',
[switch]$WarnOnly
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$LockPath = (Resolve-Path $LockPath).Path
$lock = Get-Content $LockPath -Raw | ConvertFrom-Json -Depth 20
if (-not $lock.mods) { throw "pack.lock.json has no 'mods' array." }
if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir | Out-Null }
# Vanilla / loader IDs are always satisfied -- they're never modpack mods.
$vanillaIds = @('minecraft','neoforge','forge','fabric','fabricloader','quilt','java','javafml','lowcodefml')
# Language-provider / library jars that ship without a standard mods.toml.
# Map their lockfile slug to the modId they register at runtime. Add entries
# here as new ones are encountered.
$slugToImplicitModId = @{
'kotlin-for-forge' = 'kotlinforforge'
}
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
function Get-JarPath {
param($mod)
$cached = Join-Path $CacheDir ("$($mod.sha1).jar")
if (Test-Path $cached) { return $cached }
Write-Host " downloading $($mod.slug) ..." -ForegroundColor DarkGray
try { Invoke-WebRequest -Uri $mod.url -OutFile $cached -TimeoutSec 60 }
catch { throw "Failed to download $($mod.url): $($_.Exception.Message)" }
return $cached
}
# Read a specific entry's bytes from an already-open ZipArchive into memory.
function Read-ZipEntry {
param([System.IO.Compression.ZipArchive]$Zip, [string]$EntryName)
$entry = $Zip.GetEntry($EntryName)
if (-not $entry) { return $null }
$ms = New-Object System.IO.MemoryStream
$s = $entry.Open()
try { $s.CopyTo($ms) } finally { $s.Dispose() }
return $ms.ToArray()
}
function Read-ZipEntryText {
param([System.IO.Compression.ZipArchive]$Zip, [string]$EntryName)
$entry = $Zip.GetEntry($EntryName)
if (-not $entry) { return $null }
$reader = New-Object System.IO.StreamReader($entry.Open())
try { return $reader.ReadToEnd() }
finally { $reader.Dispose() }
}
# Tiny TOML reader -- only the bits we need (sections + key="value" lines).
function Parse-ModsToml {
param([string]$Text)
$modIds = @()
$deps = @()
$section = $null
$current = $null
function Flush-Record {
param($refModIds, $refDeps, $sec, $cur)
if (-not $cur) { return }
if ($sec -eq 'mods' -and $cur.modId) {
$refModIds.Value += $cur.modId
} elseif ($sec -like 'dep:*' -and $cur.modId) {
$refDeps.Value += [pscustomobject]@{
parent = $sec.Substring(4)
modId = $cur.modId
type = $cur.type
mandatory = $cur.mandatory
versionRange = $cur.versionRange
}
}
}
$modIdsRef = [ref]$modIds
$depsRef = [ref]$deps
foreach ($raw in ($Text -split "`n")) {
$line = $raw.Trim()
if (-not $line -or $line.StartsWith('#')) { continue }
if ($line.StartsWith('[[')) {
Flush-Record $modIdsRef $depsRef $section $current
$current = $null
if ($line -match '^\[\[mods\]\]') {
$section = 'mods'; $current = @{}
} elseif ($line -match '^\[\[dependencies\.([^\]]+)\]\]') {
$section = "dep:$($Matches[1])"; $current = @{}
} else {
$section = $null
}
continue
}
if (-not $current) { continue }
if ($line -match '^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$') {
$k = $Matches[1]; $v = $Matches[2].Trim()
if ($v.StartsWith('"') -and $v.EndsWith('"')) { $v = $v.Substring(1, $v.Length - 2) }
$current[$k] = $v
}
}
Flush-Record $modIdsRef $depsRef $section $current
return [pscustomobject]@{ ModIds = $modIdsRef.Value; Deps = $depsRef.Value }
}
function Is-Required {
param($dep)
if ($dep.type) { return ($dep.type -eq 'required' -or $dep.type -eq 'mandatory') }
if ($dep.mandatory) { return ($dep.mandatory -eq 'true') }
return $true # NeoForge default
}
# Recursively walk a jar (and its META-INF/jarjar/ embedded jars) collecting
# every modId declared by mods.toml inside. Used to satisfy deps that ship
# bundled (e.g. ponder/registrate/flywheel inside Create).
function Collect-Jar-Identity {
param([string]$JarPath)
$modIds = @()
$deps = @()
$zip = [System.IO.Compression.ZipFile]::OpenRead($JarPath)
try {
# 1) This jar's own mods.toml (if any).
$toml = Read-ZipEntryText -Zip $zip -EntryName 'META-INF/neoforge.mods.toml'
if (-not $toml) { $toml = Read-ZipEntryText -Zip $zip -EntryName 'META-INF/mods.toml' }
if ($toml) {
$parsed = Parse-ModsToml -Text $toml
$modIds += $parsed.ModIds
$deps += $parsed.Deps
}
# 2) jar-in-jar bundles -- recursively extract + collect modIds (we only
# need their modIds; their deps are the parent's problem to satisfy).
$jarjarEntries = $zip.Entries | Where-Object {
$_.FullName -like 'META-INF/jarjar/*.jar'
}
foreach ($e in $jarjarEntries) {
$tmpInner = [System.IO.Path]::GetTempFileName() + '.jar'
$s = $e.Open()
try {
$fs = [System.IO.File]::Open($tmpInner, [System.IO.FileMode]::Create)
try { $s.CopyTo($fs) } finally { $fs.Dispose() }
} finally { $s.Dispose() }
$inner = Collect-Jar-Identity -JarPath $tmpInner
$modIds += $inner.ModIds # bundled deps count as present
# Don't propagate bundled deps -- they're satisfied within this jar
Remove-Item $tmpInner -Force -ErrorAction SilentlyContinue
}
} finally {
$zip.Dispose()
}
return [pscustomobject]@{ ModIds = ($modIds | Select-Object -Unique); Deps = $deps }
}
Write-Host ""
Write-Host "Auditing $($lock.mods.Count) lockfile entries (parsing each jar's mods.toml + jar-in-jar bundles)..."
Write-Host ""
$presentModIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($v in $vanillaIds) { [void]$presentModIds.Add($v) }
# parent modId -> list of dep records (so the missing report blames a modId, not a slug)
$jarDepsByParentModId = @{}
$skipped = @() # informational
$count = 0
foreach ($m in $lock.mods) {
$count++
# Only consider entries that live under mods/ -- shaderpacks, configs etc.
# are managed by the launcher but aren't mods.
if (-not ($m.path -like 'mods/*')) {
$skipped += "$($m.slug): not under mods/ ($($m.path))"
continue
}
# Language-provider override (kotlin-for-forge etc.)
if ($slugToImplicitModId.ContainsKey($m.slug)) {
[void]$presentModIds.Add($slugToImplicitModId[$m.slug])
}
try {
$jar = Get-JarPath -mod $m
$ident = Collect-Jar-Identity -JarPath $jar
foreach ($id in $ident.ModIds) {
[void]$presentModIds.Add($id)
}
foreach ($id in $ident.ModIds) {
$jarDepsByParentModId[$id] = $ident.Deps
}
if (-not $ident.ModIds -and -not $slugToImplicitModId.ContainsKey($m.slug)) {
$skipped += "$($m.slug): no modId discovered (no mods.toml; consider adding to override map)"
}
} catch {
$skipped += "$($m.slug): $($_.Exception.Message)"
}
if ($count % 10 -eq 0) { Write-Host " ...processed $count/$($lock.mods.Count)" -ForegroundColor DarkGray }
}
# Pass 2: check each parent's required deps against the present set.
$missing = @{}
foreach ($parentId in $jarDepsByParentModId.Keys) {
foreach ($dep in $jarDepsByParentModId[$parentId]) {
if (-not (Is-Required $dep)) { continue }
if (-not $presentModIds.Contains($dep.modId)) {
if (-not $missing.ContainsKey($dep.modId)) {
$missing[$dep.modId] = @{ parents = @() }
}
$missing[$dep.modId].parents += $parentId
}
}
}
Write-Host ""
Write-Host ("=" * 60)
if ($missing.Count -eq 0) {
Write-Host "All required deps satisfied. ($($presentModIds.Count) mod IDs in pack, incl. bundled jar-in-jar)" -ForegroundColor Green
} else {
Write-Host "MISSING REQUIRED DEPS: $($missing.Count) unique" -ForegroundColor Red
Write-Host ""
foreach ($depId in ($missing.Keys | Sort-Object)) {
$parents = ($missing[$depId].parents | Sort-Object -Unique) -join ', '
Write-Host " $depId" -ForegroundColor Red
Write-Host " needed by: $parents" -ForegroundColor DarkGray
}
}
if ($skipped.Count -gt 0) {
Write-Host ""
Write-Host "Informational (skipped / unanalysable):" -ForegroundColor DarkGray
foreach ($s in $skipped) { Write-Host " - $s" -ForegroundColor DarkGray }
}
if ($missing.Count -gt 0 -and -not $WarnOnly) {
throw "Missing required dependencies. Add them to pack/pack.lock.json (or pass -WarnOnly to bypass)."
}
+11
View File
@@ -118,6 +118,17 @@ if (($shouldRunPack -or $shouldRunLauncher) -and -not $DryRun -and -not $Force)
} }
} }
# ─── Pre-flight: required-dependency check ─────────────────────────────────
# Walks each Modrinth mod in pack.lock.json, fetches its 'required'
# dependencies, and refuses to deploy if any aren't already in the lockfile.
# Catches the failure mode where adding a mod that hard-deps on (say)
# kotlinforforge crash-loops the daemon at startup. Only runs when pack
# content is changing; -Force skips it (rarely needed).
if ($shouldRunPack -and -not $DryRun -and -not $Force) {
Write-Host "Pre-flight: validating Modrinth dependency graph..." -ForegroundColor DarkGray
& (Join-Path $here 'Check-Deps.ps1')
}
# ─── Pre-flight: was the daemon already running? ─────────────────────────── # ─── Pre-flight: was the daemon already running? ───────────────────────────
# We don't auto-stop it (kicks active players to fix a problem that isn't # We don't auto-stop it (kicks active players to fix a problem that isn't
# actually broken -- the atomic swap is safe with the daemon running). But # actually broken -- the atomic swap is safe with the daemon running). But