Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@ declared `ProjectReference` graph out of the csproj files, and it fails on any p
present on disk but absent from the declared graph — an undeclared project must not
silently escape enforcement.

The per-assembly `ArchitectureTests` are a second net and are **not yet load-bearing**: the
C# compiler omits assembly references a compilation does not actually use, so while no
assembly consumes another, `GetReferencedAssemblies()` returns nothing and those assertions
pass vacuously. They begin catching real violations as soon as code crosses an assembly
boundary. Until then the declared-graph check is the enforcement.
The per-assembly `ArchitectureTests` are a second net, read from the build rather than from
text. They read the `.deps.json` the SDK writes beside each test assembly, which records every
project in the resolved graph and what it depends on, whether or not the code uses it yet. They
used to call `GetReferencedAssemblies()` instead. That passed vacuously, because the compiler
drops references the code never uses (#54). A `ProjectReference` from `RulesKernel` to a
sibling fails them today, before any code calls across it.

`RulesKernel.Testing` lives under `tests/` because it is test-support, not a layer of an
engine. It is packaged anyway, because an engine built on this kernel needs the same
Expand Down
8 changes: 8 additions & 0 deletions probes/RegulatoryProbe.Tests/ProbeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ public void This_assembly_references_the_kernel_and_never_the_randomness_package

Assert.Contains("RulesKernel", referenced);
Assert.DoesNotContain("RulesKernel.Randomness", referenced);

// The compiler drops a reference the code never uses, so the line above cannot see a
// declared dependency nothing has called yet (issue #54). The build's dependency graph
// can: it lists every package and project this test run resolved.
string depsJson = System.IO.File.ReadAllText(System.IO.Path.Combine(
AppContext.BaseDirectory, typeof(DeferralLimitEngine).Assembly.GetName().Name + ".deps.json"));
Assert.DoesNotContain("\"RulesKernel.Randomness/", depsJson, StringComparison.Ordinal);
Assert.Contains("\"RulesKernel/", depsJson, StringComparison.Ordinal);
}

// ------------------------------------------------------------------ the temporal axis
Expand Down
50 changes: 38 additions & 12 deletions tests/RulesKernel.Randomness.Tests/ArchitectureTests.cs
Original file line number Diff line number Diff line change
@@ -1,26 +1,52 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;

namespace RulesKernel.Randomness.Tests;

/// <summary>
/// <c>RulesKernel.Randomness</c> sits directly on the kernel and nothing else. See the
/// note in the kernel's own architecture tests on why this direction only.
/// <c>RulesKernel.Randomness</c> sits directly on the kernel and nothing else, and
/// <c>RulesKernel.Testing</c> directly on <c>RulesKernel.Randomness</c>. Read from this test
/// run's <c>.deps.json</c>, which records declared project dependencies whether or not the code
/// uses them yet; see the kernel's own <c>ArchitectureTests</c> for why (issue #54).
/// </summary>
public sealed class ArchitectureTests
{
private static readonly string[] Allowed = ["RulesKernel"];
[Theory]
[InlineData("RulesKernel", new string[0])]
[InlineData("RulesKernel.Randomness", new[] { "RulesKernel" })]
[InlineData("RulesKernel.Testing", new[] { "RulesKernel.Randomness" })]
public void Each_layer_depends_only_on_the_layer_below(string project, string[] allowed)
{
var graph = OfThisTestRun();

Assert.True(graph.ContainsKey(project), $"{project} is not in this test run's dependency graph");
Assert.Empty(graph[project].Except(allowed, StringComparer.Ordinal));
}

[Fact]
public void Randomness_references_only_the_kernel()
private static Dictionary<string, string[]> OfThisTestRun()
{
string[] referenced = AssemblyMarker.Assembly
.GetReferencedAssemblies()
.Select(a => a.Name ?? string.Empty)
.Where(n => n.StartsWith("RulesKernel", StringComparison.Ordinal))
.OrderBy(n => n, StringComparer.Ordinal)
.ToArray();
string path = Path.Combine(AppContext.BaseDirectory, typeof(ArchitectureTests).Assembly.GetName().Name + ".deps.json");
using var document = JsonDocument.Parse(File.ReadAllText(path));
JsonElement target = document.RootElement.GetProperty("targets").EnumerateObject().Single().Value;

var graph = new Dictionary<string, string[]>(StringComparer.Ordinal);
foreach (JsonProperty library in target.EnumerateObject())
{
string name = library.Name.Split('/')[0];
if (name.StartsWith("RulesKernel", StringComparison.Ordinal))
{
graph[name] = library.Value.TryGetProperty("dependencies", out JsonElement dependencies)
? dependencies.EnumerateObject()
.Select(dependency => dependency.Name)
.Where(dependency => dependency.StartsWith("RulesKernel", StringComparison.Ordinal))
.ToArray()
: [];
}
}

Assert.Empty(referenced.Except(Allowed, StringComparer.Ordinal));
return graph;
}
}
64 changes: 48 additions & 16 deletions tests/RulesKernel.Tests/ArchitectureTests.cs
Original file line number Diff line number Diff line change
@@ -1,33 +1,65 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;

namespace RulesKernel.Tests;

/// <summary>
/// Mechanical enforcement of the layering in docs/architecture.md. <c>RulesKernel</c> is
/// the floor: it may reference no other assembly in this repository at all.
/// Mechanical enforcement of the layering in docs/architecture.md, read from the build's own
/// dependency graph. <c>RulesKernel</c> is the floor: it may depend on no other assembly in
/// this repository at all.
///
/// <para>
/// This assertion is one-directional on purpose. The C# compiler omits assembly references
/// a compilation does not actually use, so "RulesKernel references X" cannot be asserted
/// until code here consumes X. What can always be asserted -- and what matters -- is that
/// nothing leaks upward. The declared <c>ProjectReference</c> graph is checked separately
/// and exactly by <c>tools/repo-checks.py --only layering</c>, which reads the csproj files
/// rather than compiled output, and that check is the load-bearing one.
/// These tests used to ask the compiled assembly for its referenced assemblies, and passed
/// vacuously: the C# compiler omits a reference the code does not use, so a
/// <c>ProjectReference</c> nobody had called yet was invisible (issue #54). The
/// <c>.deps.json</c> the SDK writes beside this test assembly records every project in the
/// graph and what each declares, used or not. This is a second net under
/// <c>tools/repo-checks.py --only layering</c>, not a copy of it: that check reads project and
/// build files as text, and this one reads what MSBuild actually resolved, so a reference
/// either one misreads the other still sees.
/// </para>
/// </summary>
public sealed class ArchitectureTests
{
[Fact]
public void Kernel_references_no_other_RulesKernel_assembly()
public void Kernel_depends_on_no_other_RulesKernel_project()
{
string[] referenced = AssemblyMarker.Assembly
.GetReferencedAssemblies()
.Select(a => a.Name ?? string.Empty)
.Where(n => n.StartsWith("RulesKernel", StringComparison.Ordinal))
.OrderBy(n => n, StringComparer.Ordinal)
.ToArray();
var graph = ProjectGraph.OfThisTestRun();

Assert.Empty(referenced);
Assert.True(graph.ContainsKey("RulesKernel"), "the kernel is not in this test run's dependency graph");
Assert.Empty(graph["RulesKernel"]);
}
}

/// <summary>The RulesKernel* projects in a test run's <c>.deps.json</c>, and what each depends on.</summary>
internal static class ProjectGraph
{
public static IReadOnlyDictionary<string, string[]> OfThisTestRun()
{
string path = Path.Combine(AppContext.BaseDirectory, typeof(ProjectGraph).Assembly.GetName().Name + ".deps.json");
using var document = JsonDocument.Parse(File.ReadAllText(path));
JsonElement target = document.RootElement.GetProperty("targets").EnumerateObject().Single().Value;

var graph = new Dictionary<string, string[]>(StringComparer.Ordinal);
foreach (JsonProperty library in target.EnumerateObject())
{
string name = library.Name.Split('/')[0];
if (!name.StartsWith("RulesKernel", StringComparison.Ordinal))
{
continue;
}

graph[name] = library.Value.TryGetProperty("dependencies", out JsonElement dependencies)
? dependencies.EnumerateObject()
.Select(dependency => dependency.Name)
.Where(dependency => dependency.StartsWith("RulesKernel", StringComparison.Ordinal))
.ToArray()
: [];
}

return graph;
}
}
8 changes: 4 additions & 4 deletions tools/repo-checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -785,10 +785,10 @@ def _includes(pattern: re.Pattern[str], text: str) -> set[str]:
def check_layering(root: Path) -> CheckResult:
"""The declared project-dependency graph must match the architecture, exactly.

This reads the csproj files rather than compiled output, which makes it exact: the C#
compiler omits references a compilation does not actually use, so the reflective
ArchitectureTests pass vacuously until real code crosses a boundary. This check bites
the moment a reference is written, including on an empty project.
This reads the csproj files rather than compiled output, so it bites the moment a
reference is written, including on an empty project. The ArchitectureTests read the
resolved graph from each test run's .deps.json and bite at the same moment; the two
disagree only when one of them misreads the build, which is the point of having both.

Three edges count as a dependency, not one:
* ProjectReference, in either quoting style;
Expand Down