diff --git a/src/Sharpy.Cli.Tests/E2E/HostAssemblyVisibilityE2ETests.cs b/src/Sharpy.Cli.Tests/E2E/HostAssemblyVisibilityE2ETests.cs index 4573a72e6..3721e13d8 100644 --- a/src/Sharpy.Cli.Tests/E2E/HostAssemblyVisibilityE2ETests.cs +++ b/src/Sharpy.Cli.Tests/E2E/HostAssemblyVisibilityE2ETests.cs @@ -18,7 +18,11 @@ namespace Sharpy.Cli.Tests.E2E; /// SPY0300 at base 486c9a88a and compiled, then died with FileNotFoundException, at effba7776; /// from system.command_line import RootCommand compiled then died (exit 134, /// FileNotFoundException System.CommandLine) at both. A refusal (SPY0301, the system.* prefix's -/// answer for a namespace that exists nowhere) is the fix by direction. The +/// answer for a namespace that exists nowhere) is the fix by direction. An UNUSED import of a host +/// package (from system.composition import ExportAttribute, never referenced) compiled with +/// SPY0452 at base and is SPY0301 now: worked → rejected, under the owner's 2026-10-15 ruling on the +/// same mechanism (the unused system.reactive.linq cell on #2250) — the base answer depended on +/// which packages the host happened to ship, which is the contract's violation. The /// -r cell is the positive control: the same import with the package given as a reference /// compiles and runs. /// @@ -34,6 +38,11 @@ public class HostAssemblyVisibilityE2ETests : IDisposable "from system.command_line import RootCommand\n\n" + "def main() -> None:\n r = RootCommand(\"hello\")\n print(r.description)\n"; + /// An unused host-package import: the owner-ruled worked → rejected direction (see remarks). + private const string CompositionUnusedProgram = + "from system.composition import ExportAttribute\n\n" + + "def main() -> None:\n print(\"ok\")\n"; + private readonly TempWorkspace _ws = new(); public void Dispose() => _ws.Dispose(); @@ -46,6 +55,7 @@ public class HostAssemblyVisibilityE2ETests : IDisposable [Theory] [InlineData("extensions_primitives", PrimitivesProgram, "SPY0300")] [InlineData("command_line", CommandLineProgram, "SPY0301")] + [InlineData("composition_unused", CompositionUnusedProgram, "SPY0301")] public void HostPackageImport_WithoutReference_IsRefusedAtImport(string cell, string program, string refusal) { _ws.WriteSpy(program); diff --git a/src/Sharpy.Compiler.Tests/Conformance/InProcessIsolationCorpus.cs b/src/Sharpy.Compiler.Tests/Conformance/InProcessIsolationCorpus.cs index 551c8c936..d92e20c73 100644 --- a/src/Sharpy.Compiler.Tests/Conformance/InProcessIsolationCorpus.cs +++ b/src/Sharpy.Compiler.Tests/Conformance/InProcessIsolationCorpus.cs @@ -1,11 +1,27 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Sharpy.Compiler.Tests.Fuzz; using Sharpy.TestInfrastructure.Integration; +using Sharpy.TestInfrastructure.Registry; namespace Sharpy.Compiler.Tests.Conformance; /// One program of the in-process isolation corpus: a stable id (the allowlist key), the /// file name the compile names it by, and its source. -internal sealed record IsolationProgram(string Id, string FileName, string Source); +internal sealed record IsolationProgram(string Id, string FileName, string Source) +{ + /// Assembly paths this program is compiled against, on top of the instance's own (Sharpy.Core). + public IReadOnlyList References { get; init; } = Array.Empty(); + + /// + /// A positive control on the solo oracle, or null: (the fresh answer + /// must be a success) or a diagnostic code the fresh answer must carry — the probe reads what it + /// is about, so a divergence from it is a difference in the thing the seam decides. + /// + public string? OracleMustCarry { get; init; } + + internal const string OracleSucceeds = "success"; +} /// The two operations the sweep drives on every instance kind. internal enum IsolationOperation @@ -20,6 +36,20 @@ internal enum IsolationOperation /// One step of a thread's schedule. internal readonly record struct IsolationStep(IsolationProgram Program, IsolationOperation Operation); +/// +/// The programs one sweep run schedules: the literal roster (every program under both operations), +/// the builtin-shadowing triggers (analyze only — the write the seam refuses happens in analysis), +/// the typed probes that read what a write would change (both operations, and once more after the +/// last trigger), and the referenced-library pair of this run (both operations, first in every +/// schedule so an instance that caches one analysis context per reference set switches contexts +/// before the trigger → probe body, never inside it). +/// +internal sealed record IsolationRoster( + IReadOnlyList Named, + IReadOnlyList LibraryPair, + IReadOnlyList Triggers, + IReadOnlyList Probes); + /// /// The corpus and the schedule generator of (P54, #2250). /// @@ -39,15 +69,35 @@ internal enum IsolationOperation /// imported namespace (the attribute resolver's static presence cache). /// /// plus a stride- sample of the single-file fixture corpus in ordinal -/// test-name order (). The main.spy + Greeter library pair of -/// cell 4 is lane "server"'s (CompileServerE2ETests, RegistryIsolationTests) — building a -/// library here would cost a compile per run for a cell another test pins. +/// test-name order (). +/// +/// Triggers and probes (the frozen-master seam, cells 2–3 and the F1/F2 type-definition +/// twins): a trigger defines a builtin's name — verbatim, +/// plus a generated kind × name × position matrix (: the sibling-cell +/// prober's generator) — and a probe () later USES the builtin in a typed position +/// whose diagnostic differs when the master was written: b: bool = len("abc") reads +/// len's return type (SPY0220 names int32), s._size reads whether the master +/// str gained the block-class's backing field (SPY0203 vanishes), xs._size the same +/// through list and object, b: bool = format(5, ">3") reads format's +/// return type, a user @dataclass reads the master's constructor arity. Each probe's oracle is +/// a positive control (). The regular slice carries +/// generated triggers; the GapDiscovery mode the whole matrix. +/// +/// The referenced-library pair (the visibility seam, cells 4–5): a tiny C# library built +/// per sweep run by Roslyn into a per-run namespace (), and ONE +/// main.spy importing it compiled twice — library/greeter_referenced with the reference +/// (success) and library/greeter_unreferenced with none (SPY0300). A reference another registry +/// loaded must stay invisible; a visibility that scans the process answers the unreferenced compile +/// from the leaked copy. The namespace is unique per run so the unreferenced oracle, taken before any +/// load of THIS run's library, cannot be answered by an earlier run's copy in the same process. /// /// Schedules are seeded: thread t runs drawn from -/// new Random(ThreadSeeds[t]) — every named program under both operations plus -/// draws from the sample, shuffled. The seeds are literals, so a -/// schedule is the same in every mode, every repetition and every process; no unseeded -/// and no GetHashCode. +/// new Random(ThreadSeeds[t]) — the library pair first, then every named program under both +/// operations, every trigger under analyze, every probe under both operations and +/// draws from the sample, shuffled, then every probe once more under +/// analyze (after the schedule's last trigger). The seeds are literals, so a schedule is the same +/// in every mode, every repetition and every process; no unseeded and no +/// GetHashCode. /// internal static class InProcessIsolationCorpus { @@ -90,31 +140,89 @@ internal static class InProcessIsolationCorpus " drive = DriveInfo(\"/\")\n" + " print(drive.name)\n"; + internal const string ReferencedProgramId = "library/greeter_referenced"; + + internal const string UnreferencedProgramId = "library/greeter_unreferenced"; + + /// The code an import of a module no reference declares is refused with. + internal const string ModuleNotFound = "SPY0300"; + private static readonly Lazy> NamedValue = new(BuildNamed); private static readonly Lazy> SampleValue = new(BuildSample); + private static readonly Lazy> ProbesValue = new(BuildProbes); + + private static readonly Lazy> GeneratedTriggersValue = new(BuildGeneratedTriggers); + /// The literal roster, in roster order. internal static IReadOnlyList Named => NamedValue.Value; /// The stride sample of the single-file fixture corpus (named entries excluded). internal static IReadOnlyList Sample => SampleValue.Value; + /// The typed probes, each with its oracle control. + internal static IReadOnlyList Probes => ProbesValue.Value; + + /// The generated trigger matrix (kind × name × position), in generator order. + internal static IReadOnlyList GeneratedTriggers => GeneratedTriggersValue.Value; + + /// How many generated triggers the regular slice carries (the first of the matrix); the full set is the GapDiscovery mode's. + internal const int RegularGeneratedTriggerCount = 8; + + /// The trigger programs of a mode: the verbatim texts, then the generated matrix (whole or its regular head). + internal static IReadOnlyList Triggers(bool full) + { + var verbatim = WarmInstanceTriggers.AllTexts + .Select(t => new IsolationProgram("trigger/" + t.Name, "trigger.spy", t.Text)); + var generated = full ? GeneratedTriggers : GeneratedTriggers.Take(RegularGeneratedTriggerCount); + return verbatim.Concat(generated).ToList(); + } + /// - /// Thread 's schedule: every named program under both operations - /// plus (program, operation) draws from the sample, shuffled — - /// all from new Random(ThreadSeeds[threadIndex]). + /// Every program id any mode can schedule — the allowlist's universe: the literal roster, the + /// sample, the probes, the full trigger set and the library pair's two ids (the pair's TEXT is + /// per run, its ids are constant). A row naming anything else is refused. /// - internal static IReadOnlyList Schedule(int threadIndex) + internal static IReadOnlySet AllIds => AllIdsValue.Value; + + private static readonly Lazy> AllIdsValue = new(() => + Named.Concat(Sample).Concat(Probes).Concat(Triggers(full: true)).Select(p => p.Id) + .Concat(new[] { ReferencedProgramId, UnreferencedProgramId }) + .ToHashSet(StringComparer.Ordinal)); + + /// The roster of one run: the literal roster, this run's library pair, the mode's triggers and the probes. + internal static IsolationRoster Roster(bool full, IReadOnlyList libraryPair) + => new(Named, libraryPair, Triggers(full), Probes); + + /// + /// Thread 's schedule over : the library + /// pair (both operations, in roster order, first), then — shuffled on + /// new Random(ThreadSeeds[threadIndex]) — every named program under both operations, + /// every trigger under analyze, every probe under both operations and + /// (program, operation) draws from the sample; then every probe + /// under analyze, after the last trigger. + /// + internal static IReadOnlyList Schedule(int threadIndex, IsolationRoster roster) { var random = new Random(ThreadSeeds[threadIndex]); var steps = new List(); - foreach (var program in Named) + foreach (var program in roster.LibraryPair) + { + steps.Add(new IsolationStep(program, IsolationOperation.Analyze)); + steps.Add(new IsolationStep(program, IsolationOperation.Compile)); + } + + var head = steps.Count; + foreach (var program in roster.Named.Concat(roster.Probes)) { steps.Add(new IsolationStep(program, IsolationOperation.Analyze)); steps.Add(new IsolationStep(program, IsolationOperation.Compile)); } + foreach (var program in roster.Triggers) + steps.Add(new IsolationStep(program, IsolationOperation.Analyze)); + for (var i = 0; i < SampleDrawsPerThread; i++) { var program = Sample[random.Next(Sample.Count)]; @@ -122,16 +230,207 @@ internal static IReadOnlyList Schedule(int threadIndex) steps.Add(new IsolationStep(program, operation)); } - // Fisher–Yates on the same seeded generator. - for (var i = steps.Count - 1; i > 0; i--) + // Fisher–Yates over the body, on the same seeded generator. + for (var i = steps.Count - 1; i > head; i--) { - var j = random.Next(i + 1); + var j = head + random.Next(i - head + 1); (steps[i], steps[j]) = (steps[j], steps[i]); } + foreach (var program in roster.Probes) + steps.Add(new IsolationStep(program, IsolationOperation.Analyze)); + return steps; } + // ================================================================ + // The referenced-library pair + // ================================================================ + + /// + /// Builds this run's library into and returns the pair: the same + /// main.spy compiled with the reference (, oracle: success) + /// and without it (, oracle: ). + /// is lowercase hex unique to the run: the module is sweeplib_{tag}, + /// the namespace Sweeplib{Tag} (the registry's PascalCase mapping of a non-system module). + /// + internal static IReadOnlyList BuildLibraryPair(string directory, string tag) + { + var moduleName = "sweeplib_" + tag; + var namespaceName = "Sweeplib" + char.ToUpperInvariant(tag[0]) + tag.Substring(1); + var assemblyName = "P54SweepLib_" + tag; + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, assemblyName + ".dll"); + var compilation = CSharpCompilation.Create( + assemblyName, + new[] + { + CSharpSyntaxTree.ParseText( + $"namespace {namespaceName} {{ public static class Greeter {{ public static string Hello() => \"hello\"; }} }}"), + }, + IntegrationTestBase.GetSharedReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var emit = compilation.Emit(path); + if (!emit.Success) + throw new InvalidOperationException("The isolation library did not build (an instrument fault):\n" + string.Join("\n", emit.Diagnostics)); + + var source = + $"from {moduleName} import Greeter\n" + + "\n" + + "\n" + + "def main() -> None:\n" + + " print(Greeter.hello())\n"; + return new[] + { + new IsolationProgram(ReferencedProgramId, "main.spy", source) + { + References = new[] { path }, + OracleMustCarry = IsolationProgram.OracleSucceeds, + }, + new IsolationProgram(UnreferencedProgramId, "main.spy", source) + { + OracleMustCarry = ModuleNotFound, + }, + }; + } + + // ================================================================ + // Triggers and probes + // ================================================================ + + private static IReadOnlyList BuildProbes() + { + IsolationProgram Probe(string name, string control, params string[] lines) + => new("probe/" + name, "probe.spy", string.Join("\n", lines) + "\n") { OracleMustCarry = control }; + + return new[] + { + Probe("builtins_ok", IsolationProgram.OracleSucceeds, + "def main() -> None:", + " print(len(\"abc\"))", + " print(str(5))", + " xs = list[int]()", + " xs.append(1)", + " print(len(xs))", + " d = dict[str, int]()", + " d[\"a\"] = 1", + " print(format(5, \">3\"))", + " o: object = 5", + " print(o)"), + Probe("len_typed", "SPY0220", + "def main() -> None:", + " b: bool = len(\"abc\")"), + Probe("str_no_member", "SPY0203", + "def main() -> None:", + " s = \"abc\"", + " print(s._size)", + " print(s.x)"), + Probe("list_no_member", "SPY0203", + "def main() -> None:", + " xs = list[int]()", + " print(xs._size)", + " print(xs.x)"), + Probe("exception_typed", "SPY0220", + "class MyErr(Exception):", + " pass", + "", + "def main() -> None:", + " try:", + " raise MyErr(\"x\")", + " except Exception as e:", + " b: bool = e", + " print(e)"), + Probe("format_typed", "SPY0220", + "def main() -> None:", + " b: bool = format(5, \">3\")", + " c: bool = print"), + Probe("dataclass_user", "SPY0220", + "@dataclass", + "class P:", + " x: int", + "", + "def main() -> None:", + " p = P(1)", + " b: bool = p.x", + " print(p)"), + Probe("str_upper_control", IsolationProgram.OracleSucceeds, + "def main() -> None:", + " s = \"abc\"", + " print(s.upper())"), + }; + } + + /// The generated matrix: kind × name × position, every cell a builtin's name defined where NameResolver declares no symbol. + private static IReadOnlyList BuildGeneratedTriggers() + { + var kinds = new (string Kind, Func Body)[] + { + ("class", n => new[] + { + $"class {n}:", + " property size: int = 0", + " property set size(self, v: int) -> None:", + " self._size = v", + " def __len__(self) -> int:", + " return 1", + }), + ("def", n => new[] { $"def {n}(x: int) -> int:", " return x" }), + ("dataclass", n => new[] { "@dataclass", $"class {n}:", " x: int" }), + ("struct", n => new[] { $"struct {n}:", " x: int" }), + ("interface", n => new[] { $"interface {n}:", " def probe(self) -> int: ..." }), + ("union", n => new[] { $"union {n}:", " case Probe(value: int)" }), + ("delegate", n => new[] { $"delegate {n}(value: int) -> int" }), + }; + var positions = new (string Position, string[] Before, int Indent, string[] After)[] + { + ("def-body", new[] { "def helper() -> None:" }, 4, new[] { " print('helper')" }), + ("mod-try", new[] { "try:" }, 4, new[] { "except Exception:", " pass" }), + }; + var names = new[] { "list", "format", "str", "len" }; + + // The regular head first: {class, def} × {list, format} × both positions — the member write + // through `list`/`object` and the retyped `format` the prober measured at base. + var cells = new List<(string Kind, string Name, string Position)>(); + foreach (var kind in kinds.Take(2)) + { + foreach (var name in names.Take(2)) + { + foreach (var position in positions) + cells.Add((kind.Kind, name, position.Position)); + } + } + + foreach (var kind in kinds) + { + foreach (var name in names) + { + foreach (var position in positions) + { + if (!cells.Contains((kind.Kind, name, position.Position))) + cells.Add((kind.Kind, name, position.Position)); + } + } + } + + var programs = new List(); + foreach (var (kind, name, position) in cells) + { + var body = kinds.First(k => k.Kind == kind).Body(name); + var layout = positions.First(p => p.Position == position); + var lines = layout.Before + .Concat(body.Select(l => new string(' ', layout.Indent) + l)) + .Concat(layout.After) + .Concat(new[] { "", "def main() -> None:", " print('ok')" }); + programs.Add(new IsolationProgram($"trigger/{kind}_{name}_{position}", "trigger.spy", string.Join("\n", lines) + "\n")); + } + + return programs; + } + + // ================================================================ + // The literal roster and the sample + // ================================================================ + private static IReadOnlyList BuildNamed() { var programs = new List { RegenerateCell1Program() }; diff --git a/src/Sharpy.Compiler.Tests/Conformance/InProcessIsolationSweepTests.cs b/src/Sharpy.Compiler.Tests/Conformance/InProcessIsolationSweepTests.cs index 3ce643ac6..420fd345b 100644 --- a/src/Sharpy.Compiler.Tests/Conformance/InProcessIsolationSweepTests.cs +++ b/src/Sharpy.Compiler.Tests/Conformance/InProcessIsolationSweepTests.cs @@ -1,10 +1,12 @@ using System.Collections.Concurrent; using System.Diagnostics; using System.Text.Json; +using System.Text.RegularExpressions; using Sharpy.Compiler.Diagnostics; using Sharpy.Compiler.Logging; using Sharpy.TestInfrastructure; using Sharpy.TestInfrastructure.Integration; +using Sharpy.TestInfrastructure.Registry; using Xunit; using Xunit.Abstractions; @@ -33,7 +35,15 @@ namespace Sharpy.Compiler.Tests.Conformance; /// not reference the LSP handlers. /// /// Oracle and fingerprint. Before any concurrent phase, every (instance kind, program, -/// operation) a schedule names runs ALONE on the test thread on a FRESH instance. A fingerprint is +/// operation) a schedule names runs ALONE on the test thread on a FRESH instance — the programs that +/// name no reference FIRST, so the oracle of library/greeter_unreferenced is taken before this +/// run's library has been loaded by anything in the process (its namespace is unique to the run, so +/// no earlier run's copy can answer it either); then a bystander holding the +/// library stays alive through the concurrent phase — the plan's cell-4 shape, "another registry in +/// the process has loaded it". Every program with an +/// control is checked against its oracle on every (kind, operation): the probes read what the seams +/// decide (SPY0220/SPY0203 on a fresh instance; success for the referenced compile; SPY0300 for the +/// unreferenced one), or the run is an instrument fault, not a green. A fingerprint is /// success + the sorted multiset of diagnostic (code, line, col) + the used-assembly set /// where the operation exposes one (compile on both kinds — /// and the 's registry — and analyze on ; @@ -53,7 +63,7 @@ namespace Sharpy.Compiler.Tests.Conformance; /// A cell is red in a repetition when any of its observations in that repetition is; k/R is /// the number of red repetitions. /// -/// Ratchet. Rows program sharing threads operation bucket [probabilistic] # #2250 +/// Ratchet. Rows program sharing threads operation bucket [probabilistic] # #NNNN /// reason; reproduced k/R @ sha in Conformance/in-process-isolation-allowlist.txt. A red /// cell without a row fails; a row whose cell was exercised and reproduced in no repetition fails as /// stale (drain on fix) unless it is marked probabilistic — those are reported, not judged. A @@ -65,7 +75,13 @@ public class InProcessIsolationSweepTests { private const string AllowlistFileName = "in-process-isolation-allowlist.txt"; private const string FullModeVariable = "SHARPY_ISOLATION_SWEEP_FULL"; - private const string IssueCite = "#2250"; + /// + /// A row cites an issue by number (#NNNN) — any issue, so the ratchet outlives the class tracker it was + /// born under (#2250 is closed): whether the cited issue is still OPEN is build_tools/check_allowlist_issue_state.sh's + /// question, asked of every tracked allowlist file. + /// + private static readonly Regex IssueCitePattern = new(@"(^|\s)#\d+\b", RegexOptions.Compiled); + private const string IssueCite = "#NNNN"; private const string Probabilistic = "probabilistic"; internal const string Drift = "drift"; @@ -124,15 +140,15 @@ private enum InstanceKind [Fact] [Trait("Category", "GapDiscovery")] public void Sweep_EightThreads_EightRepetitions() - => RunSweep("gap-discovery", new[] { 8 }, repetitions: 8, "in-process-isolation-report.json"); + => RunSweep("gap-discovery", new[] { 8 }, repetitions: 8, fullTriggers: true, "in-process-isolation-report.json"); [Fact] public void Sweep_RegularSlice() { if (Environment.GetEnvironmentVariable(FullModeVariable) == "1") - RunSweep("full", new[] { 1, 2, 8 }, repetitions: 8, "in-process-isolation-slice-report.json"); + RunSweep("full", new[] { 1, 2, 8 }, repetitions: 8, fullTriggers: true, "in-process-isolation-slice-report.json"); else - RunSweep("regular", new[] { 1, 2 }, repetitions: 1, "in-process-isolation-slice-report.json"); + RunSweep("regular", new[] { 1, 2 }, repetitions: 1, fullTriggers: false, "in-process-isolation-slice-report.json"); } // ================================================================ @@ -180,29 +196,60 @@ public void Oracle_DiscriminatesPrograms_PositionsAndBuckets() } } - /// The schedules are pure functions of the literal seeds, and every thread's schedule - /// carries every named program under both operations. + /// The schedules are pure functions of the literal seeds; every thread's schedule opens + /// with the library pair, carries every named program and probe under both operations and every + /// trigger under analyze, and reads every probe once more after its last trigger; the regular + /// trigger set is a head of the full one; every probe and the pair carry an oracle control. [Fact] public void Schedules_AreSeededAndCarryTheRoster() { Assert.Equal(8, InProcessIsolationCorpus.ThreadSeeds.Distinct().Count()); Assert.StartsWith(InProcessIsolationCorpus.Cell1FirstLine, InProcessIsolationCorpus.RegenerateCell1Program().Source, StringComparison.Ordinal); Assert.True(InProcessIsolationCorpus.Sample.Count > 100, $"stride sample has {InProcessIsolationCorpus.Sample.Count} programs"); - for (var t = 0; t < InProcessIsolationCorpus.ThreadSeeds.Length; t++) + + var pair = new[] { - var first = InProcessIsolationCorpus.Schedule(t).Select(s => $"{s.Program.Id}/{s.Operation}").ToList(); - var second = InProcessIsolationCorpus.Schedule(t).Select(s => $"{s.Program.Id}/{s.Operation}").ToList(); - Assert.Equal(first, second); - foreach (var program in InProcessIsolationCorpus.Named) + new IsolationProgram(InProcessIsolationCorpus.ReferencedProgramId, "main.spy", "") { References = new[] { "x.dll" }, OracleMustCarry = IsolationProgram.OracleSucceeds }, + new IsolationProgram(InProcessIsolationCorpus.UnreferencedProgramId, "main.spy", "") { OracleMustCarry = InProcessIsolationCorpus.ModuleNotFound }, + }; + var regular = InProcessIsolationCorpus.Roster(full: false, pair); + var full = InProcessIsolationCorpus.Roster(full: true, pair); + Assert.Equal(WarmInstanceTriggers.AllTexts.Length + InProcessIsolationCorpus.RegularGeneratedTriggerCount, regular.Triggers.Count); + Assert.Equal(regular.Triggers.Select(t => t.Id), full.Triggers.Take(regular.Triggers.Count).Select(t => t.Id)); + Assert.True(full.Triggers.Count > regular.Triggers.Count, $"full carries {full.Triggers.Count} triggers"); + Assert.Equal(full.Triggers.Count, full.Triggers.Select(t => t.Id).Distinct().Count()); + Assert.All(full.Probes.Concat(pair), p => Assert.NotNull(p.OracleMustCarry)); + Assert.Contains(full.Probes, p => p.OracleMustCarry == "SPY0220"); + Assert.Contains(full.Probes, p => p.OracleMustCarry == "SPY0203"); + + foreach (var roster in new[] { regular, full }) + { + for (var t = 0; t < InProcessIsolationCorpus.ThreadSeeds.Length; t++) { - Assert.Contains($"{program.Id}/{IsolationOperation.Analyze}", first); - Assert.Contains($"{program.Id}/{IsolationOperation.Compile}", first); + var first = InProcessIsolationCorpus.Schedule(t, roster).Select(s => $"{s.Program.Id}/{s.Operation}").ToList(); + var second = InProcessIsolationCorpus.Schedule(t, roster).Select(s => $"{s.Program.Id}/{s.Operation}").ToList(); + Assert.Equal(first, second); + Assert.Equal( + pair.SelectMany(p => new[] { $"{p.Id}/{IsolationOperation.Analyze}", $"{p.Id}/{IsolationOperation.Compile}" }), + first.Take(4)); + foreach (var program in roster.Named.Concat(roster.Probes)) + { + Assert.Contains($"{program.Id}/{IsolationOperation.Analyze}", first); + Assert.Contains($"{program.Id}/{IsolationOperation.Compile}", first); + } + + var lastTrigger = first.FindLastIndex(s => s.StartsWith("trigger/", StringComparison.Ordinal)); + Assert.True(lastTrigger >= 0, "the schedule carries no trigger"); + foreach (var program in roster.Triggers) + Assert.Contains($"{program.Id}/{IsolationOperation.Analyze}", first); + foreach (var program in roster.Probes) + Assert.Contains($"{program.Id}/{IsolationOperation.Analyze}", first.Skip(lastTrigger + 1)); } } Assert.NotEqual( - InProcessIsolationCorpus.Schedule(0).Select(s => s.Program.Id), - InProcessIsolationCorpus.Schedule(1).Select(s => s.Program.Id)); + InProcessIsolationCorpus.Schedule(0, regular).Select(s => s.Program.Id), + InProcessIsolationCorpus.Schedule(1, regular).Select(s => s.Program.Id)); } // ================================================================ @@ -213,37 +260,100 @@ private sealed record Observation(int Repetition, Sharing Sharing, int Threads, private sealed record RedCell(string Key, string Bucket, int Reds, int Repetitions, string Oracle, string Observed); - private void RunSweep(string mode, int[] threadCounts, int repetitions, string reportFileName) + private void RunSweep(string mode, int[] threadCounts, int repetitions, bool fullTriggers, string reportFileName) { var wall = Stopwatch.StartNew(); var maxThreads = threadCounts.Max(); - var schedules = Enumerable.Range(0, maxThreads).Select(InProcessIsolationCorpus.Schedule).ToList(); + var runTag = Guid.NewGuid().ToString("N").Substring(0, 8); + var libraryDir = Path.Combine(Path.GetTempPath(), $"sharpy_isolation_lib_{runTag}"); + try + { + var roster = InProcessIsolationCorpus.Roster(fullTriggers, InProcessIsolationCorpus.BuildLibraryPair(libraryDir, runTag)); + var schedules = Enumerable.Range(0, maxThreads).Select(t => InProcessIsolationCorpus.Schedule(t, roster)).ToList(); + + // 1. The solo oracle, before any concurrent phase: a fresh instance per (kind, program, operation) — + // reference-free programs first, so the unreferenced library compile is answered before this run's + // library has been loaded by anything in the process. + var oracle = new Dictionary<(InstanceKind, string, IsolationOperation), Fingerprint>(); + foreach (var referenced in new[] { false, true }) + { + foreach (var kind in new[] { InstanceKind.Compiler, InstanceKind.Api }) + { + foreach (var step in schedules.SelectMany(s => s).Where(s => (s.Program.References.Count > 0) == referenced)) + { + var key = (kind, step.Program.Id, step.Operation); + if (!oracle.ContainsKey(key)) + oracle[key] = Solo(kind, step); + } + } + } - // 1. The solo oracle, before any concurrent phase: a fresh instance per (kind, program, operation). - var oracle = new Dictionary<(InstanceKind, string, IsolationOperation), Fingerprint>(); - foreach (var kind in new[] { InstanceKind.Compiler, InstanceKind.Api }) + var oracleSeconds = wall.Elapsed.TotalSeconds; + AssertOracleControls(oracle, roster); + + // The bystander: a registry that has loaded this run's library and lives through the concurrent phase. + using var bystander = IsolationInstance.Create(InstanceKind.Compiler); + var bystanderReading = bystander.Observe(new IsolationStep(roster.LibraryPair[0], IsolationOperation.Analyze)); + Assert.True(bystanderReading.Success, $"the bystander's referenced analysis must succeed: {bystanderReading}"); + + // 2. The concurrent phase: repetitions × sharing × threads, each a barrier-started set of workers. + var observations = new List(); + for (var repetition = 0; repetition < repetitions; repetition++) + { + foreach (var sharing in AllSharing) + { + foreach (var threads in threadCounts) + observations.AddRange(RunConfiguration(repetition, sharing, threads, schedules)); + } + } + + Judge(mode, threadCounts, repetitions, reportFileName, roster, oracle, observations, oracleSeconds, wall); + } + finally { - foreach (var step in schedules.SelectMany(s => s)) + try { - var key = (kind, step.Program.Id, step.Operation); - if (!oracle.ContainsKey(key)) - oracle[key] = Solo(kind, step); + if (Directory.Exists(libraryDir)) + Directory.Delete(libraryDir, recursive: true); + } + catch + { + // Best-effort cleanup; the loaded library may still be mapped. } } + } - var oracleSeconds = wall.Elapsed.TotalSeconds; - - // 2. The concurrent phase: repetitions × sharing × threads, each a barrier-started set of workers. - var observations = new List(); - for (var repetition = 0; repetition < repetitions; repetition++) + /// + /// Every oracle control holds on every (kind, operation): a probe's fresh answer carries the code + /// it reads for, the referenced compile succeeds, the unreferenced one is refused — else the + /// probe is not reading what the seam decides and the run is an instrument fault. + /// + private static void AssertOracleControls(IReadOnlyDictionary<(InstanceKind, string, IsolationOperation), Fingerprint> oracle, IsolationRoster roster) + { + var problems = new List(); + foreach (var program in roster.Probes.Concat(roster.LibraryPair).Where(p => p.OracleMustCarry != null)) { - foreach (var sharing in AllSharing) + foreach (var kind in new[] { InstanceKind.Compiler, InstanceKind.Api }) { - foreach (var threads in threadCounts) - observations.AddRange(RunConfiguration(repetition, sharing, threads, schedules)); + foreach (var operation in new[] { IsolationOperation.Analyze, IsolationOperation.Compile }) + { + var fingerprint = oracle[(kind, program.Id, operation)]; + var holds = program.OracleMustCarry == IsolationProgram.OracleSucceeds + ? fingerprint.Success && fingerprint.Exception == null + : !fingerprint.Success && fingerprint.Diagnostics.Contains(program.OracleMustCarry + "@", StringComparison.Ordinal); + if (!holds) + problems.Add($"{program.Id} {kind}/{Name(operation)}: the solo oracle must carry {program.OracleMustCarry}; observed {fingerprint}"); + } } } + Assert.True(problems.Count == 0, "oracle control(s) failed (an instrument fault, not a cell):\n " + string.Join("\n ", problems)); + } + + private void Judge(string mode, int[] threadCounts, int repetitions, string reportFileName, IsolationRoster roster, + Dictionary<(InstanceKind, string, IsolationOperation), Fingerprint> oracle, List observations, double oracleSeconds, Stopwatch wall) + { + // 3. Classification: a cell is (program, sharing, threads, operation); red in a repetition when any observation is. var exercised = new HashSet(StringComparer.Ordinal); var redByKey = new Dictionary>(StringComparer.Ordinal); @@ -278,7 +388,7 @@ private void RunSweep(string mode, int[] threadCounts, int repetitions, string r .ToList(); // 4. The ratchet. - var allowlist = LoadAllowlist(); + var allowlist = LoadAllowlist(InProcessIsolationCorpus.AllIds); var unlisted = reds.Where(r => !allowlist.ContainsKey(r.Key)).ToList(); var notExercised = allowlist.Keys.Where(k => !exercised.Contains(CellOf(k))).OrderBy(k => k, StringComparer.Ordinal).ToList(); var silent = allowlist @@ -290,7 +400,7 @@ private void RunSweep(string mode, int[] threadCounts, int repetitions, string r var bucketCounts = new[] { Drift, Crash, Order }.ToDictionary(b => b, b => reds.Count(r => r.Bucket == b)); var census = $"ISOLATION-CENSUS mode={mode} threads={{{string.Join(",", threadCounts)}}} R={repetitions} " - + $"corpus={InProcessIsolationCorpus.Named.Count}+{InProcessIsolationCorpus.Sample.Count} " + + $"corpus={roster.Named.Count}+{roster.LibraryPair.Count}+{roster.Triggers.Count}+{roster.Probes.Count}+{InProcessIsolationCorpus.Sample.Count} " + $"oracle={oracle.Count} observations={observations.Count} compared={observed} cancelled={cancelled} " + $"cells={exercised.Count} red={reds.Count} drift={bucketCounts[Drift]} crash={bucketCounts[Crash]} order={bucketCounts[Order]} " + $"allowlisted={allowlist.Count} oracle-elapsed={oracleSeconds:F0}s elapsed={wall.Elapsed.TotalSeconds:F0}s"; @@ -478,44 +588,62 @@ public void Dispose() /// /// One for the instance's life — its module registry is built once, in the - /// constructor, and serves every program of the schedule. Sharpy.Core is referenced (without it - /// the C# → IL emit of print(1) is SPY0908 — measured), and the output path makes - /// Compile reach IL. + /// constructor, and serves every reference-free program of the schedule; a program that names a + /// reference gets a sibling on the same instance (one per distinct + /// reference set, built on first use — a binds its references at + /// construction), so the leak the library pair guards flows between two registries on one + /// thread as well as across threads. Sharpy.Core is referenced (without it the C# → IL emit of + /// print(1) is SPY0908 — measured), and the output path makes Compile reach IL. /// private sealed class CompilerInstance : IsolationInstance { - private readonly Compiler _compiler; + private readonly Dictionary _compilers = new(StringComparer.Ordinal); + private readonly object _gate = new(); public CompilerInstance() { Directory.CreateDirectory(Root); - _compiler = new Compiler(CompilerOptionsFactory.ForCli( - references: new[] { SharpyCoreReference.Location }, + _compilers[""] = Build(Array.Empty()); + } + + private Compiler Build(IReadOnlyList references) + => new(CompilerOptionsFactory.ForCli( + references: new[] { SharpyCoreReference.Location }.Concat(references).ToArray(), assemblyName: AssemblyName, outputAssemblyPath: Path.Combine(Root, AssemblyName + ".dll")), NullLogger.Instance); + + private Compiler For(IsolationProgram program) + { + var key = string.Join(";", program.References); + lock (_gate) + { + if (!_compilers.TryGetValue(key, out var compiler)) + _compilers[key] = compiler = Build(program.References); + return compiler; + } } protected override Fingerprint Analyze(IsolationProgram program, CancellationToken cancellationToken) { - var result = _compiler.Analyze(program.Source, program.FileName, cancellationToken); + var result = For(program).Analyze(program.Source, program.FileName, cancellationToken); return Fingerprint.Of(result.Success, result.Diagnostics.GetAll(), result.ModuleRegistry?.GetUsedAssemblyPaths()); } protected override Fingerprint Compile(IsolationProgram program, CancellationToken cancellationToken) { - var result = _compiler.Compile(program.Source, program.FileName, cancellationToken); + var result = For(program).Compile(program.Source, program.FileName, cancellationToken); return Fingerprint.Of(result.Success, result.Diagnostics.GetAll(), result.ModuleRegistry?.GetUsedAssemblyPaths()); } } - /// One referencing Sharpy.Core; each Compile writes into its own directory. + /// One referencing Sharpy.Core; a program's own references ride on its options; each Compile writes into its own directory. private sealed class ApiInstance : IsolationInstance { private readonly CompilerApi _api = new(NullLogger.Instance, new[] { SharpyCoreReference.Location }); protected override Fingerprint Analyze(IsolationProgram program, CancellationToken cancellationToken) { - var result = _api.AnalyzeDocument(program.Source, program.FileName, CompilerOptionsFactory.ForCli(), + var result = _api.AnalyzeDocument(program.Source, program.FileName, CompilerOptionsFactory.ForCli(references: program.References.ToArray()), stageMetrics: null, cancellationToken); return Fingerprint.Of(result.Success, result.Diagnostics, usedAssemblies: null); } @@ -526,6 +654,7 @@ protected override Fingerprint Compile(IsolationProgram program, CancellationTok try { var options = CompilerOptionsFactory.ForCli( + references: program.References.ToArray(), assemblyName: AssemblyName, outputAssemblyPath: Path.Combine(callDir, AssemblyName + ".dll")); var result = _api.Compile(program.Source, options, program.FileName, cancellationToken); @@ -553,11 +682,12 @@ protected override Fingerprint Compile(IsolationProgram program, CancellationTok private sealed record AllowlistRow(bool Probabilistic, string Comment); /// - /// program sharing threads operation bucket [probabilistic] # #2250 reason; reproduced k/R @ sha. - /// A row that does not parse, names an unknown sharing/operation/bucket, or does not cite #2250 is - /// refused (an instrument fault), never silently skipped. + /// program sharing threads operation bucket [probabilistic] # #NNNN reason; reproduced k/R @ sha. + /// A row that does not parse, names a program that is no corpus id (: + /// a typo would park a red cell as "not exercised"), names an unknown sharing/operation/bucket, or + /// cites no issue number is refused (an instrument fault), never silently skipped. /// - private static Dictionary LoadAllowlist() + private static Dictionary LoadAllowlist(IReadOnlySet corpusIds) { var path = Path.Combine(FixtureRoots.RepositoryRoot, "src", "Sharpy.Compiler.Tests", "Conformance", AllowlistFileName); if (!File.Exists(path)) @@ -577,11 +707,13 @@ private static Dictionary LoadAllowlist() var probabilistic = fields.Length == 6 && fields[5] == Probabilistic; if (fields.Length != 5 && !probabilistic) throw new InvalidOperationException($"Conformance/{AllowlistFileName}: '{line}' — expected `program sharing threads operation bucket [{Probabilistic}] # {IssueCite} …`."); + if (!corpusIds.Contains(fields[0])) + throw new InvalidOperationException($"Conformance/{AllowlistFileName}: '{line}' — '{fields[0]}' is not a corpus program id (InProcessIsolationCorpus.AllIds)."); if (!sharingNames.Contains(fields[1]) || fields[2] is not ("1" or "2" or "8") || !operationNames.Contains(fields[3]) || fields[4] is not (Drift or Crash or Order)) throw new InvalidOperationException($"Conformance/{AllowlistFileName}: '{line}' — unknown sharing, thread count, operation or bucket."); - if (!comment.Contains(IssueCite, StringComparison.Ordinal)) - throw new InvalidOperationException($"Conformance/{AllowlistFileName}: '{line}' — every row cites {IssueCite}."); + if (!IssueCitePattern.IsMatch(comment)) + throw new InvalidOperationException($"Conformance/{AllowlistFileName}: '{line}' — every row cites an issue by number ({IssueCite})."); var key = string.Join(' ', fields.Take(5)); if (!rows.TryAdd(key, new AllowlistRow(probabilistic, comment))) throw new InvalidOperationException($"Conformance/{AllowlistFileName}: '{key}' is listed twice."); diff --git a/src/Sharpy.Compiler.Tests/Conformance/ProcessWideResolutionScanTests.cs b/src/Sharpy.Compiler.Tests/Conformance/ProcessWideResolutionScanTests.cs index 8a88373d4..d72722fb6 100644 --- a/src/Sharpy.Compiler.Tests/Conformance/ProcessWideResolutionScanTests.cs +++ b/src/Sharpy.Compiler.Tests/Conformance/ProcessWideResolutionScanTests.cs @@ -89,9 +89,9 @@ public class ProcessWideResolutionScanTests "assembly qualifier binds against the visible set by simple name and an unqualified name is " + "searched in the visible set, so nothing binds against the process."), ["Discovery/AssemblyVisibility.cs::AssemblyVisibility.LoadFrameworkAssembly::Assembly.Load"] = (1, - "the one seam: Assembly.Load(AssemblyName) of a trusted-platform System.*/Microsoft.*/netstandard/" + - "mscorlib name (or a compiler dependency), once per process behind the framework Lazy — the " + - "default context's TPA binding, a process constant."), + "the one seam: Assembly.Load(AssemblyName) of a shared-framework name (a .dll in the " + + "Microsoft.NETCore.App runtime directory CoreLib was loaded from) or Sharpy.Core, once per " + + "process behind the framework Lazy — the default context's TPA binding, a process constant."), ["Discovery/AssemblyVisibility.cs::AssemblyVisibility.IsProcessStable::AssemblyLoadContext.Default"] = (1, "the one seam: Design Decision 3's gate — a static Type-keyed cache holds a type only when it is " + "wholly in the default context, so no static cache pins a per-registry load context."), diff --git a/src/Sharpy.Compiler.Tests/Conformance/TpaNamespaceGateTests.cs b/src/Sharpy.Compiler.Tests/Conformance/TpaNamespaceGateTests.cs index 43ca0d52e..27203fe7f 100644 --- a/src/Sharpy.Compiler.Tests/Conformance/TpaNamespaceGateTests.cs +++ b/src/Sharpy.Compiler.Tests/Conformance/TpaNamespaceGateTests.cs @@ -84,7 +84,7 @@ public void KnownRed_RowsNameRealCellsAndCiteTheIssue() foreach (var (cell, reason) in KnownRed) { Assert.Contains(cell, AllCells); - Assert.Contains("#2250", reason, StringComparison.Ordinal); + Assert.Matches(@"(^|\s)#\d+\b", reason); } Assert.True(AllCells.Count(c => !KnownRed.ContainsKey(c)) >= 2, "each probe needs a green positive-control cell; the oracle is not 'always red'"); diff --git a/src/Sharpy.Compiler.Tests/Conformance/in-process-isolation-allowlist.txt b/src/Sharpy.Compiler.Tests/Conformance/in-process-isolation-allowlist.txt index 767b793da..e3c1820c2 100644 --- a/src/Sharpy.Compiler.Tests/Conformance/in-process-isolation-allowlist.txt +++ b/src/Sharpy.Compiler.Tests/Conformance/in-process-isolation-allowlist.txt @@ -1,6 +1,6 @@ # In-process isolation sweep allowlist (P54, #2250) — read by InProcessIsolationSweepTests. # -# One row per red cell: `program sharing threads operation bucket [probabilistic] # #2250 reason; reproduced k/R @ sha` +# One row per red cell: `program sharing threads operation bucket [probabilistic] # #NNNN reason; reproduced k/R @ sha` # program a corpus id (InProcessIsolationCorpus: a named roster id or a fixture test name) # sharing compiler-per-thread | api-per-thread | api-shared # threads 1 | 2 | 8 @@ -10,4 +10,5 @@ # # A red cell without a row fails. A row whose cell was exercised and reproduced in no repetition fails # as stale — drain it when the fix lands — unless it is marked `probabilistic` (reported, not judged). -# Every row cites #2250; the k/R is the measured reproduction count at the named sha. +# Every row cites an OPEN issue by number (check_allowlist_issue_state.sh gates the state); the k/R is the measured +# reproduction count at the named sha. diff --git a/src/Sharpy.Compiler.Tests/Diagnostics/CrashBundleExclusivityTests.cs b/src/Sharpy.Compiler.Tests/Diagnostics/CrashBundleExclusivityTests.cs index 7a3874f01..7e4e4ca65 100644 --- a/src/Sharpy.Compiler.Tests/Diagnostics/CrashBundleExclusivityTests.cs +++ b/src/Sharpy.Compiler.Tests/Diagnostics/CrashBundleExclusivityTests.cs @@ -85,7 +85,7 @@ public void ConcurrentWriters_AtOneClockTick_EachCrashGetsItsOwnDirectoryAndRepo public void KnownRedRows_CiteTheIssue() { foreach (var (_, reason) in KnownRed) - Assert.Contains("#2250", reason, StringComparison.Ordinal); + Assert.Matches(@"(^|\s)#\d+\b", reason); } private static Outcome RunWriters(int writers, string outputDirectory) diff --git a/src/Sharpy.Compiler.Tests/Diagnostics/StaticStateConformanceTests.cs b/src/Sharpy.Compiler.Tests/Diagnostics/StaticStateConformanceTests.cs index bcdd30dc4..4d36b338e 100644 --- a/src/Sharpy.Compiler.Tests/Diagnostics/StaticStateConformanceTests.cs +++ b/src/Sharpy.Compiler.Tests/Diagnostics/StaticStateConformanceTests.cs @@ -201,10 +201,10 @@ public StaticStateConformanceTests(ITestOutputHelper output) // ---- The visibility seam's framework index (#2250, P54 Design Decision 1) ---- [("Sharpy.Compiler.Discovery.AssemblyVisibility", "s_framework")] = - "Lazy with no key: the framework set — the trusted-platform System.*/Microsoft.*/" + - "netstandard/mscorlib assemblies, Sharpy.Core, the compiler and its direct dependencies — loaded " + - "and indexed once; the trusted-platform list is fixed at process start, so the value is a process " + - "constant. ExecutionAndPublication: a reader blocks until the index is complete. The five " + + "Lazy with no key: the framework set — the shared framework (every trusted-platform " + + "assembly whose .dll lies in the Microsoft.NETCore.App runtime directory CoreLib was loaded from) " + + "plus Sharpy.Core, never the host's own application packages (f5fb7f8e4) — loaded and indexed " + + "once; CoreLib's directory is fixed at process start, so the value is a process constant. ExecutionAndPublication: a reader blocks until the index is complete. The five " + "name-keyed rows it replaced (_originNameDefinitionCache, _displayNameDefinitionCache, " + "s_typePresentCache, _probedNamespaces, _loadedRuntimeNamespaces) drained into per-visibility state.", }; diff --git a/src/Sharpy.Compiler/CodeGen/RoslynEmitter.CompilationUnit.cs b/src/Sharpy.Compiler/CodeGen/RoslynEmitter.CompilationUnit.cs index 6f662db0b..bd5968901 100644 --- a/src/Sharpy.Compiler/CodeGen/RoslynEmitter.CompilationUnit.cs +++ b/src/Sharpy.Compiler/CodeGen/RoslynEmitter.CompilationUnit.cs @@ -587,8 +587,8 @@ private static string ResolveClrTypeName(Symbol? symbol, string fallbackName) /// private static bool IsClrTypeGenericInNamespace(string namespaceName, string typeName) { - // Rule-2 cell (#2250 findings ledger; no tracker yet): this is the emitter asking reflection a - // semantic question, which belongs in semantic analysis as a materialized fact. Until it moves, + // TODO(#2320): Rule-2 cell — this is the emitter asking reflection a semantic question, which + // belongs in semantic analysis as a materialized fact (CodeGenInfo). Until it moves, // it asks the FRAMEWORK visibility — a process constant — instead of every assembly the process // has loaded; its only caller passes a .NET framework namespace (IsNetFrameworkNamespace). var framework = Discovery.AssemblyVisibility.FrameworkOnly; diff --git a/src/Sharpy.Lsp.Tests/Conformance/WarmInstanceParityTests.cs b/src/Sharpy.Lsp.Tests/Conformance/WarmInstanceParityTests.cs index b3da49c03..f657c2a05 100644 --- a/src/Sharpy.Lsp.Tests/Conformance/WarmInstanceParityTests.cs +++ b/src/Sharpy.Lsp.Tests/Conformance/WarmInstanceParityTests.cs @@ -154,7 +154,7 @@ public void KnownRedRows_NameCellsOfTheMatrix() { Assert.True(cells.Contains(cell), $"KnownRed row '{cell}' names no cell of the matrix"); Assert.False(cell.Contains($" {Fresh} ", StringComparison.Ordinal), $"'{cell}': the fresh arm is the positive control; it carries no KnownRed row"); - Assert.Contains("#2250", row.Row, StringComparison.Ordinal); + Assert.Matches(@"(^|\s)#\d+\b", row.Row); Assert.Matches(@"\d+/\d+[^@]* @ [0-9a-f]{7,}", row.Row); } } diff --git a/src/Sharpy.Stdlib.Tests/Conformance/StdlibModuleGlobalConcurrencyTests.cs b/src/Sharpy.Stdlib.Tests/Conformance/StdlibModuleGlobalConcurrencyTests.cs index 2803fc195..071fc6e92 100644 --- a/src/Sharpy.Stdlib.Tests/Conformance/StdlibModuleGlobalConcurrencyTests.cs +++ b/src/Sharpy.Stdlib.Tests/Conformance/StdlibModuleGlobalConcurrencyTests.cs @@ -120,7 +120,7 @@ public void EverySkippedOperation_NamesItsReason() Exercisers.Where(e => e.NotApplicable.Count == 0).Select(e => e.Module).OrderBy(m => m, StringComparer.Ordinal)); } - /// Every KnownRed row names a real cell and cites #2250 with a measured reproduction. + /// Every KnownRed row names a real cell and cites an issue by number with a measured reproduction. [Fact] public void EveryKnownRedRow_IsARealCellCitingTheIssue() { @@ -130,7 +130,7 @@ public void EveryKnownRedRow_IsARealCellCitingTheIssue() foreach (var (cell, row) in KnownRed) { Assert.Contains(cell, cells); - Assert.Contains("#2250", row.Evidence, StringComparison.Ordinal); + Assert.Matches(@"(^|\s)#\d+\b", row.Evidence); Assert.Contains(" @ ", row.Evidence, StringComparison.Ordinal); } }