Effigy.Tests/KernelSyncTests.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
namespace Effigy.Tests;
/// <summary>
/// Guards the one piece of this repo that duplication is unavoidable in.
///
/// s&box compiles a library's `Code/` into a game assembly and its `Editor/` into an editor
/// assembly, and nothing else - a top-level `Effigy/` is invisible to it. The kernel cannot live in `Code/`
/// either, because ObjWriter and SmdWriter call File.WriteAllText and the game assembly's sandbox
/// whitelist does not allow it. So the editor assembly needs its own copy of the kernel, and
/// `Editor/Effigy/` is that copy.
///
/// `Effigy/` is canonical: it is what this test project compiles, and keeping it out of any engine
/// folder is what keeps the Godot option open.
///
/// The failure mode this exists to catch is silent: edit the canonical kernel, forget to mirror,
/// and the tests keep passing against source the editor is not running. That already happened once
/// - the mirror was committed with a stray blank line in SolidFeatures.cs, which is a diff of no
/// consequence and proof that nothing was checking. Run tools/sync-kernel.sh to fix a failure here.
/// </summary>
public static class KernelSyncTests
{
public static void Run()
{
Report.Section( "Geppetto's Editor/Effigy mirrors the canonical kernel" );
if ( FindRepoRoot() is not { } root )
{
// Not a failure: the runner is allowed to be pointed at the kernel from somewhere with
// no repo around it. Saying so beats a green tick that checked nothing.
Report.Check( "repo root located", true, "skipped - no repo layout around the runner" );
return;
}
var src = Path.Combine( root, "Effigy" );
// s&box installs the package under its ident (`pooh.geppetto`); a plain clone has it as
// `Geppetto`. Same mirror either way, so check whichever this checkout has - otherwise the
// test reports a missing mirror on a working tree where the editor is perfectly happy.
var lib = LibraryDir( root );
if ( lib is null )
{
Report.Check( "Geppetto project found", false,
"no Geppetto project beside this repo - set GEPPETTO_DIR if it lives elsewhere" );
return;
}
var dst = Path.Combine( lib, "Editor", "Effigy" );
if ( !Directory.Exists( dst ) )
{
Report.Check( "the library's Editor/Effigy exists", false, $"{dst} is missing - run tools/sync-kernel.sh" );
return;
}
var srcFiles = RelativeCsFiles( src );
var dstFiles = RelativeCsFiles( dst );
// THE RUNTIME SUBSET IS DELIBERATELY NOT IN THE EDITOR MIRROR. These four go to Code/ for
// the game assembly, the editor assembly references that assembly, and a type declared in
// both is CS0436 on every use - 1857 warnings, and a Vec2 from one side that will not go
// where a Vec2 from the other is expected. The editor gets them from the reference.
//
// tools/sync-kernel.sh keeps this list; it is repeated here rather than parsed out of the
// shell so that changing one without the other fails the suite instead of quietly
// reintroducing the duplicate.
var runtimeSubset = new[] { "Vec.cs", "Xform.cs", "Rig/Skeleton.cs", "Rig/SoftBone.cs" };
var mirrored = srcFiles.Where( f => !runtimeSubset.Contains( f ) ).ToList();
var missing = mirrored.Except( dstFiles ).ToList();
var extra = dstFiles.Except( srcFiles ).ToList();
var duplicated = dstFiles.Where( f => runtimeSubset.Contains( f ) ).ToList();
Report.Check( "no kernel file missing from the mirror", missing.Count == 0, string.Join( ", ", missing ) );
Report.Check( "the runtime subset is not duplicated into the editor mirror",
duplicated.Count == 0,
duplicated.Count == 0
? ""
: string.Join( ", ", duplicated ) + " - these come from the game assembly; "
+ "mirroring them too is what CS0436 complains about" );
Report.Check( "no stale file left in the mirror", extra.Count == 0, string.Join( ", ", extra ) );
// Byte-for-byte rather than token-for-token. A whitespace-only difference is harmless in
// itself and is exactly the signal that the two are being maintained by hand.
foreach ( var file in srcFiles.Intersect( dstFiles ).OrderBy( f => f, StringComparer.Ordinal ) )
{
var same = File.ReadAllBytes( Path.Combine( src, file ) )
.SequenceEqual( File.ReadAllBytes( Path.Combine( dst, file ) ) );
Report.Check( $"{file} matches", same, same ? null : "run tools/sync-kernel.sh" );
}
CheckRuntimeSubset( src, lib );
}
/// <summary>
/// The much smaller mirror that goes to the GAME assembly.
///
/// The whole kernel cannot go there - the writers call File.WriteAllText and the sandbox
/// refuses - but it does not follow that a game can have none of it. SoftSolver is arithmetic
/// with no filesystem near it, and Code/Effigy is the subset that carries it.
///
/// THE SUBSET MUST NOT REACH OUTSIDE ITSELF, and that is what the second check here is for. A
/// game assembly compiles these four files and nothing else from the kernel, so a new reference
/// to a type living elsewhere in Effigy does not fail here, it fails in somebody's game with a
/// missing-type error against a file they did not write. Cheap to check, miserable to diagnose.
/// </summary>
static void CheckRuntimeSubset( string src, string lib )
{
string[] subset = { "Vec.cs", "Xform.cs", "Rig/Skeleton.cs", "Rig/SoftBone.cs" };
var dst = Path.Combine( lib, "Code", "Effigy" );
if ( !Directory.Exists( dst ) )
{
Report.Check( "the library's Code/Effigy exists", false, $"{dst} is missing - run tools/sync-kernel.sh" );
return;
}
var present = RelativeCsFiles( dst );
Report.Check( "the runtime subset holds exactly the files it should",
present.SequenceEqual( subset.OrderBy( f => f, StringComparer.Ordinal ) ),
string.Join( ", ", present ) );
foreach ( var file in subset )
{
var a = Path.Combine( src, file );
var b = Path.Combine( dst, file );
if ( !File.Exists( b ) )
{
Report.Check( $"runtime {file} present", false, "run tools/sync-kernel.sh" );
continue;
}
var same = File.ReadAllBytes( a ).SequenceEqual( File.ReadAllBytes( b ) );
Report.Check( $"runtime {file} matches", same, same ? null : "run tools/sync-kernel.sh" );
}
// Nothing in the subset may name a type that only exists outside it.
var outside = DeclaredTypes( src, subset );
var leaked = new List<string>();
foreach ( var file in subset )
{
var path = Path.Combine( src, file );
if ( !File.Exists( path ) ) continue;
var code = StripComments( File.ReadAllText( path ) );
foreach ( var type in outside )
{
if ( Regex.IsMatch( code, $@"{Regex.Escape( type )}" ) )
leaked.Add( $"{file} -> {type}" );
}
}
Report.Check( "the runtime subset references nothing outside itself",
leaked.Count == 0, string.Join( ", ", leaked.Distinct() ) );
// And the reason the subset exists at all: no filesystem in a game assembly.
var io = subset
.Where( f => File.Exists( Path.Combine( src, f ) ) )
.Where( f => Regex.IsMatch( StripComments( File.ReadAllText( Path.Combine( src, f ) ) ),
@"System\.IO|File\.|Directory\." ) )
.ToList();
Report.Check( "the runtime subset touches no filesystem", io.Count == 0, string.Join( ", ", io ) );
}
/// <summary>Every top-level type the kernel declares OUTSIDE the given files.</summary>
static List<string> DeclaredTypes( string src, string[] exclude )
{
var skip = new HashSet<string>( exclude, StringComparer.Ordinal );
var types = new List<string>();
foreach ( var file in RelativeCsFiles( src ) )
{
if ( skip.Contains( file ) ) continue;
var code = StripComments( File.ReadAllText( Path.Combine( src, file ) ) );
foreach ( Match m in Regex.Matches( code,
@"(?:class|struct|interface|enum|record)\s+([A-Za-z_][A-Za-z0-9_]*)" ) )
{
types.Add( m.Groups[1].Value );
}
}
return types.Distinct().ToList();
}
/// <summary>
/// Comments out, so a type NAMED in prose does not read as a reference to it. This file's own
/// kernel is heavily commented and half the type names in the repo appear in one docstring or
/// another.
/// </summary>
static string StripComments( string code )
{
code = Regex.Replace( code, @"/\*.*?\*/", " ", RegexOptions.Singleline );
code = Regex.Replace( code, @"//[^
]*", " " );
return code;
}
/// <summary>The Geppetto library folder under Libraries/, by either of its two names, or null
/// when neither is present.</summary>
static string LibraryDir( string root )
{
// GEPPETTO IS A SIBLING PROJECT NOW, not a folder under Libraries/. It had to leave: s&box
// auto-mounts any .sbproj it finds under Libraries/ as a local package, so the library was
// compiled from source AND imported as a prebuilt assembly in the same build, and the
// editor would rewrite the folder from the mounted copy.
//
// GEPPETTO_DIR overrides it for a checkout that does not have them side by side.
// One repo: the mirror is this project's own Editor/Effigy. This used to resolve a
// sibling directory, from the brief period when Geppetto and the repo holding its kernel
// were two separate checkouts.
return root;
}
static string[] RelativeCsFiles( string dir ) =>
Directory.EnumerateFiles( dir, "*.cs", SearchOption.AllDirectories )
.Select( f => Path.GetRelativePath( dir, f ).Replace( '\\', '/' ) )
.OrderBy( f => f, StringComparer.Ordinal )
.ToArray();
/// <summary>Walk up from the running binary looking for the layout, so the check works whether
/// the runner was started from the repo root or from Effigy.Tests.</summary>
static string FindRepoRoot()
{
var dir = new DirectoryInfo( AppContext.BaseDirectory );
while ( dir is not null )
{
if ( Directory.Exists( Path.Combine( dir.FullName, "Effigy" ) )
&& Directory.Exists( Path.Combine( dir.FullName, "Effigy.Tests" ) ) )
return dir.FullName;
dir = dir.Parent;
}
return null;
}
}