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>
299 lines
13 KiB
C#
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.");
|
|
}
|
|
}
|