The client reads 161 ACDREAM_* environment variables across 79 files. Only about 25 were written down, and the audit found the documentation drifting in both directions: CLAUDE.md still advertised ACDREAM_RUN_SKILL / ACDREAM_JUMP_SKILL (deleted; skills are server-authoritative now, and the jump fallback is 300, not the documented 200), while flags with real side effects had no description at all. docs/launch-options.md documents every one by lifecycle — production, command line, measurement, automation, permanent diagnostics, temporary probes, deprecated, retired — with a mandatory side-effects column. That column is the point: #432 cost three days of taxed measurements because ACDREAM_AUTOMATION_ARTIFACT_DIR reads like an output path and also builds a per-frame diagnostics referee, and ACDREAM_STREAM_RADIUS silently measures a streaming window production never uses. Rows now say so. Other surprises the audit surfaced and recorded: ACDREAM_DUMP_SCENERY_Z swaps in a duplicate scenery-placement path rather than only logging, ACDREAM_PROBE_VIS silently also enables ACDREAM_PROBE_ENVCELL, and ACDREAM_DUMP_ENTITY's id list doubles as an unrelated probe's watchlist. LaunchOptionsDocumentationTests enforces it, because a hand-maintained list of 161 flags is stale within a week: an undocumented flag fails, and so does a documented row whose read site was deleted. It scans string literals rather than GetEnvironmentVariable call shapes — the startup path reads through an injected delegate, so a call-shaped pattern silently missed ACDREAM_LIVE, ACDREAM_PAK_PATH and every other production flag. A third test freezes per-file direct-read debt by exact count (20 files outside the owner classes), so structure rules 4 and 5 can be paid down but not regressed. CLAUDE.md's 94-line env-var section becomes a 16-line pointer, and its stale test-character paragraph is corrected. Also fixed, all doc-vs-code mismatches the audit proved: - RenderingDiagnostics.FrameProfEnabled described a GPU-query self-disable that Campaign V slice V11 deleted. - Two comments named ACDREAM_RENDER_BACKEND as a live co-requisite; it died with the OpenGL backend. - EnvCellRenderer.CollectCellAuditLines and its ACDREAM_A8_AUDIT doc: the method had no caller anywhere and its documented caller never existed. Filed rather than fixed, to keep this a documentation change: #434 (the DebugPanel/DebugVM surface is never constructed, so ~40 "runtime-toggleable" comments are false and 35 env reads are unreachable) and #435 (17 temporary probes outlived their closed investigations; 14 more name no owner). Full hermetic suite 12,202 passed / 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
245 lines
11 KiB
C#
245 lines
11 KiB
C#
using System.Text.RegularExpressions;
|
|
|
|
namespace AcDream.App.Tests.Diagnostics;
|
|
|
|
/// <summary>
|
|
/// Keeps <c>docs/launch-options.md</c> honest: every environment variable the
|
|
/// shipped client reads must have a documented row, and every documented row
|
|
/// must name a variable something actually reads.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A hand-maintained list of ~170 flags goes stale within a week. #432 is what
|
|
/// that costs: <c>ACDREAM_AUTOMATION_ARTIFACT_DIR</c> read like an output-path
|
|
/// setting but also constructed a per-frame diagnostics referee worth ~6 MB and
|
|
/// ~14 ms every frame, and three days of measurements were taxed before anyone
|
|
/// noticed. The same audit found CLAUDE.md still advertising
|
|
/// <c>ACDREAM_RUN_SKILL</c> / <c>ACDREAM_JUMP_SKILL</c> after their read sites
|
|
/// were deleted. Both directions of drift are failures, so both fail here.
|
|
/// </para>
|
|
/// <para>
|
|
/// Scope is <c>src/</c> only. Test-owned variables, shader-compiler macro
|
|
/// tokens under <c>tools/</c>, and historical mentions in dated research
|
|
/// documents are deliberately out of scope — the doc describes what a launched
|
|
/// client reads today.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class LaunchOptionsDocumentationTests
|
|
{
|
|
private const string DocRelativePath = "docs/launch-options.md";
|
|
|
|
/// <summary>
|
|
/// Structure rule 5 wants runtime flags behind diagnostic owner classes and
|
|
/// rule 4 wants startup configuration in <c>RuntimeOptions</c>. These files
|
|
/// still read the environment directly, with the exact number of distinct
|
|
/// flags each one reads today.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The counts are FROZEN, not merely the file names: promoting a stray to
|
|
/// its subsystem's owner lowers a number (update it here), and adding a new
|
|
/// direct read raises one and fails. A file that reaches zero leaves the
|
|
/// table entirely.
|
|
/// </remarks>
|
|
private static readonly IReadOnlyDictionary<string, int> DirectReadDebt =
|
|
new Dictionary<string, int>(StringComparer.Ordinal)
|
|
{
|
|
["src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs"] = 2,
|
|
["src/AcDream.App/Physics/RemoteServerControlledVelocityCycle.cs"] = 1,
|
|
["src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs"] = 1,
|
|
["src/AcDream.App/Rendering/GameWindow.cs"] = 1,
|
|
["src/AcDream.App/Rendering/PortalVisibilityBuilder.cs"] = 1,
|
|
["src/AcDream.App/Rendering/Sky/SkyRenderer.cs"] = 1,
|
|
["src/AcDream.App/Rendering/TextureCache.cs"] = 1,
|
|
["src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs"] = 2,
|
|
["src/AcDream.Core/Physics/PhysicsEngine.cs"] = 1,
|
|
["src/AcDream.Core/Physics/TransitionTypes.cs"] = 3,
|
|
["src/AcDream.Core/Vfx/PhysicsScriptRunner.cs"] = 1,
|
|
["src/AcDream.Core/World/SkyDescLoader.cs"] = 2,
|
|
["src/AcDream.Core.Net/GameEventWiring.cs"] = 1,
|
|
["src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs"] = 1,
|
|
["src/AcDream.Core.Net/Messages/UpdateMotion.cs"] = 1,
|
|
["src/AcDream.Core.Net/WorldSession.cs"] = 3,
|
|
["src/AcDream.Platform/ApplicationPathSet.cs"] = 3,
|
|
["src/AcDream.Platform/BakePublicationGuardPaths.cs"] = 1,
|
|
["src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs"] = 1,
|
|
["src/AcDream.UI.Abstractions/Settings/QualityPreset.cs"] = 6,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Any <c>ACDREAM_*</c> string literal in <c>src/</c>. Matching the literal
|
|
/// rather than a <c>GetEnvironmentVariable</c> call is deliberate: the
|
|
/// startup path reads through an injected <c>env</c> delegate
|
|
/// (<c>RuntimeOptions.Parse</c>) so a call-shaped pattern silently missed
|
|
/// ACDREAM_LIVE, ACDREAM_PAK_PATH and every other production flag. Comment
|
|
/// mentions (shader macros such as ACDREAM_SAMPLE_2D, prefix fragments)
|
|
/// carry no quotes and stay out.
|
|
/// </summary>
|
|
private static readonly Regex EnvironmentRead = new(
|
|
@"""(ACDREAM_[A-Z0-9_]+)""",
|
|
RegexOptions.Compiled);
|
|
|
|
private static readonly Regex DocumentedRow = new(
|
|
@"^\|\s*`(ACDREAM_[A-Z0-9_]+)`",
|
|
RegexOptions.Compiled | RegexOptions.Multiline);
|
|
|
|
[Fact]
|
|
public void EveryEnvironmentVariableTheClientReadsIsDocumented()
|
|
{
|
|
IReadOnlySet<string> read = ReadFlags();
|
|
IReadOnlySet<string> documented = DocumentedFlags();
|
|
|
|
List<string> undocumented = read.Except(documented).Order(StringComparer.Ordinal).ToList();
|
|
Assert.True(
|
|
undocumented.Count == 0,
|
|
$"{DocRelativePath} is missing a row for flags the client reads: "
|
|
+ string.Join(", ", undocumented)
|
|
+ ". Add the row in the same commit that adds the read site.");
|
|
}
|
|
|
|
[Fact]
|
|
public void EveryDocumentedEnvironmentVariableStillExists()
|
|
{
|
|
IReadOnlySet<string> read = ReadFlags();
|
|
IReadOnlySet<string> documented = DocumentedFlags();
|
|
|
|
List<string> phantom = documented.Except(read).Order(StringComparer.Ordinal).ToList();
|
|
Assert.True(
|
|
phantom.Count == 0,
|
|
$"{DocRelativePath} documents flags nothing in src/ reads: "
|
|
+ string.Join(", ", phantom)
|
|
+ ". Delete the row (or move it to the retired section with its "
|
|
+ "removal commit) in the same commit that deletes the read site.");
|
|
}
|
|
|
|
[Fact]
|
|
public void DirectEnvironmentReadsOutsideOwnerClassesDoNotGrow()
|
|
{
|
|
Dictionary<string, int> actual = SourceFiles()
|
|
.Where(file => !IsOwnerClass(file.RelativePath))
|
|
.Select(file => (
|
|
file.RelativePath,
|
|
Count: DistinctFlagsRead(File.ReadAllText(file.Path))))
|
|
.Where(file => file.Count > 0)
|
|
.ToDictionary(file => file.RelativePath, file => file.Count, StringComparer.Ordinal);
|
|
|
|
List<string> problems = [];
|
|
foreach ((string path, int count) in actual.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
|
{
|
|
if (!DirectReadDebt.TryGetValue(path, out int frozen))
|
|
{
|
|
problems.Add(
|
|
$"{path} reads {count} ACDREAM_* variable(s) directly but is "
|
|
+ "not an owner class. Add the property to the subsystem's "
|
|
+ "diagnostics owner (or RuntimeOptions) and read it there.");
|
|
}
|
|
else if (count > frozen)
|
|
{
|
|
problems.Add(
|
|
$"{path} grew from {frozen} to {count} direct reads. New "
|
|
+ "flags belong in an owner class, not here.");
|
|
}
|
|
}
|
|
|
|
foreach ((string path, int frozen) in DirectReadDebt.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
|
{
|
|
int count = actual.GetValueOrDefault(path, 0);
|
|
if (count < frozen)
|
|
{
|
|
problems.Add(
|
|
$"{path} is down to {count} direct read(s) from {frozen}. "
|
|
+ "Lower the frozen count (or drop the entry at zero) so the "
|
|
+ "debt cannot silently grow back.");
|
|
}
|
|
}
|
|
|
|
Assert.True(problems.Count == 0, string.Join("\n", problems));
|
|
}
|
|
|
|
private static bool IsOwnerClass(string relativePath)
|
|
{
|
|
// The intended homes for environment reads: one static diagnostics
|
|
// class per subsystem, the typed startup options objects, the
|
|
// executables' own entry points, and the single-purpose probe/capture
|
|
// owners (a file that exists only to own one probe already satisfies
|
|
// the rule the *Diagnostics.cs suffix encodes).
|
|
string name = Path.GetFileName(relativePath);
|
|
return name.EndsWith("Diagnostics.cs", StringComparison.Ordinal)
|
|
|| name.EndsWith("Options.cs", StringComparison.Ordinal)
|
|
|| name.EndsWith("Probe.cs", StringComparison.Ordinal)
|
|
|| name.EndsWith("Capture.cs", StringComparison.Ordinal)
|
|
|| name == "Program.cs";
|
|
}
|
|
|
|
private static int DistinctFlagsRead(string source)
|
|
{
|
|
var flags = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (Match match in EnvironmentRead.Matches(source))
|
|
flags.Add(match.Groups[1].Value);
|
|
return flags.Count;
|
|
}
|
|
|
|
private static IReadOnlySet<string> ReadFlags()
|
|
{
|
|
var flags = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach ((string path, _) in SourceFiles())
|
|
{
|
|
foreach (Match match in EnvironmentRead.Matches(File.ReadAllText(path)))
|
|
flags.Add(match.Groups[1].Value);
|
|
}
|
|
|
|
Assert.True(
|
|
flags.Count > 100,
|
|
$"Only found {flags.Count} environment reads in src/; the scanner "
|
|
+ "is probably broken rather than the codebase suddenly clean.");
|
|
return flags;
|
|
}
|
|
|
|
private static IReadOnlySet<string> DocumentedFlags()
|
|
{
|
|
string doc = Path.Combine(FindRepoRoot(), DocRelativePath.Replace('/', Path.DirectorySeparatorChar));
|
|
Assert.True(File.Exists(doc), $"{DocRelativePath} is missing.");
|
|
|
|
string text = File.ReadAllText(doc);
|
|
// Rows below the retired marker describe flags that are deliberately
|
|
// gone; they document history and must not resurrect the read-site
|
|
// requirement.
|
|
int retired = text.IndexOf("<!-- retired -->", StringComparison.Ordinal);
|
|
if (retired >= 0)
|
|
text = text[..retired];
|
|
|
|
var flags = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (Match match in DocumentedRow.Matches(text))
|
|
flags.Add(match.Groups[1].Value);
|
|
return flags;
|
|
}
|
|
|
|
private static IEnumerable<(string Path, string RelativePath)> SourceFiles()
|
|
{
|
|
string root = FindRepoRoot();
|
|
string src = Path.Combine(root, "src");
|
|
foreach (string path in Directory.EnumerateFiles(src, "*.cs", SearchOption.AllDirectories))
|
|
{
|
|
string relative = Path.GetRelativePath(root, path).Replace('\\', '/');
|
|
if (relative.Contains("/bin/", StringComparison.Ordinal)
|
|
|| relative.Contains("/obj/", StringComparison.Ordinal))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
yield return (path, relative);
|
|
}
|
|
}
|
|
|
|
private static string FindRepoRoot()
|
|
{
|
|
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
|
while (directory is not null)
|
|
{
|
|
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
|
return directory.FullName;
|
|
directory = directory.Parent;
|
|
}
|
|
|
|
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
|
}
|
|
}
|