acdream/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs
Erik c1e6e3da44 fix #435 (part 2, closes it): attribute the unowned probes — delete 7, reclassify 8, restore 1
Part 1 deleted probes whose owning issues were closed. These 14 named no
issue at all, so each was traced to its introducing commit
(git log -S) instead of guessed at. Attribution split them three ways:

DELETED (7, investigations closed): ACDREAM_A8_DUMP_PV and
ACDREAM_DUMP_LIVE_SPAWNS (Phase A8), ACDREAM_DUMP_CLOTHING (#37),
ACDREAM_DUMP_EDGE_SLIDE (#32), ACDREAM_DUMP_STEPUP (L.2.3d-f),
ACDREAM_DUMP_VENDOR (the vendor campaign, 25 call sites across 8 files),
ACDREAM_DUMP_VITALS (#5, four independent read sites). VendorDiagnostics.cs
went entirely.

RECLASSIFIED (8, tools misfiled as probes): the DUMP_CELLS/DUMP_GFXOBJS
fixture-extraction family (replay-harness tooling with a roundtrip test),
PROBE_CELL (standing cell-transit tracer, pair of the permanent
PROBE_RESOLVE), DUMP_SKY and HIDE_PART (generic isolation tools), and
DUMP_STEEP_ROOF — which looked like an L.4 relic but observes LIVE
divergence-register row AD-56; deleting it would have removed the only
runtime lens on an active divergence. All moved to Permanent diagnostics
with their attribution recorded.

RESTORED (1): ACDREAM_DUMP_MOVE_TRUTH was deleted and un-deleted the same
day. It is not a probe — the canonical nine-stop soak
(run-connected-r6-soak.ps1) hard-fails every destination without its
'move-truth OUT' records, with a message that would misdirect the next
operator. Under the no-workarounds rule the gate's mechanism is restored,
not left broken with an IOU (#437, closed). Process lesson recorded on
both issues: a closed owning issue is NOT sufficient to delete a probe —
grep tools/ and the contract tests for consumers first.

Also lands the owner-requested default-off invariant: every diagnostic in
the codebase is inert until its env var is explicitly set. Exactly four
flags default ON and none is a diagnostic — RETAIL_CHASE, CAMERA_COLLIDE,
CAMERA_ALIGN_SLOPE, RETAIL_CLOSE_DEGRADES are retail behaviors wearing an
A/B off-switch. That set is now FROZEN by
LaunchOptionsDocumentationTests.OnlyTheFourRetailBehaviorFlagsDefaultOn;
docs/launch-options.md's Conventions and CLAUDE.md state the rule, and
CLAUDE.md now binds future probes to a documented row in the same commit.

The client reads 137 environment variables (161 at audit start); 40
temporary probes remain, every one attributed. Full hermetic suite 15,322
passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 12:32:42 +02:00

299 lines
13 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/Sky/SkyRenderer.cs"] = 1,
["src/AcDream.App/Rendering/TextureCache.cs"] = 1,
["src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs"] = 2,
["src/AcDream.Core/Vfx/PhysicsScriptRunner.cs"] = 1,
["src/AcDream.Core/World/SkyDescLoader.cs"] = 2,
["src/AcDream.Core.Net/Messages/UpdateMotion.cs"] = 1,
["src/AcDream.Core.Net/WorldSession.cs"] = 2,
["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.");
}
/// <summary>
/// The four flags that default ON. All are retail behaviors wearing an
/// A/B off-switch (<c>=0</c> disables) — none is a diagnostic. FROZEN:
/// a diagnostic that activates without its env var set taxes every run
/// and every measurement silently, so growing this set fails.
/// </summary>
private static readonly IReadOnlySet<string> DefaultOnBehaviorFlags =
new HashSet<string>(StringComparer.Ordinal)
{
"ACDREAM_RETAIL_CHASE",
"ACDREAM_CAMERA_COLLIDE",
"ACDREAM_CAMERA_ALIGN_SLOPE",
"ACDREAM_RETAIL_CLOSE_DEGRADES",
};
/// <summary>
/// A read shaped "anything but the literal 0 enables" — the two
/// default-on idioms in this codebase:
/// <c>GetEnvironmentVariable("X") != "0"</c> and
/// <c>!string.Equals(env("X"), "0", ...)</c>.
/// </summary>
private static readonly Regex DefaultOnRead = new(
@"(?:GetEnvironmentVariable\(\s*""(ACDREAM_[A-Z0-9_]+)""\s*\)\s*!=\s*""0""" +
@"|!string\.Equals\(\s*env\(\s*""(ACDREAM_[A-Z0-9_]+)""\s*\)\s*,\s*""0"")",
RegexOptions.Compiled);
[Fact]
public void OnlyTheFourRetailBehaviorFlagsDefaultOn()
{
var defaultOn = new HashSet<string>(StringComparer.Ordinal);
foreach ((string path, _) in SourceFiles())
{
foreach (Match match in DefaultOnRead.Matches(File.ReadAllText(path)))
{
defaultOn.Add(match.Groups[1].Success
? match.Groups[1].Value
: match.Groups[2].Value);
}
}
List<string> added = defaultOn.Except(DefaultOnBehaviorFlags)
.Order(StringComparer.Ordinal).ToList();
Assert.True(
added.Count == 0,
"New default-ON flag(s): " + string.Join(", ", added)
+ ". Every diagnostic must be OFF until its variable is "
+ "explicitly set; only a retail behavior with an A/B off-switch "
+ "may default on, and adding one means updating this frozen set "
+ "AND the Conventions section of docs/launch-options.md.");
List<string> gone = DefaultOnBehaviorFlags.Except(defaultOn)
.Order(StringComparer.Ordinal).ToList();
Assert.True(
gone.Count == 0,
"Frozen default-ON flag(s) no longer read that way: "
+ string.Join(", ", gone)
+ ". Update this set and the docs in the same commit.");
}
[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.");
}
}