diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 1e44fe58..544b6a95 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -724,9 +724,85 @@ jobs: overwrite: true retention-days: ${{ env.RETENTION_INTERMEDIATE_ASSETS }} + # + # The quality gate. Deliberately without an `if` on build_enabled: a pull request only builds + # when somebody sets the run-pipeline label, and a gate which is closed exactly while nobody is + # looking is not a gate. It is cheap for the same reason it is unconditional -- one platform, no + # Tauri bundle, no signing, no artifacts. + # + verify: + name: Verify + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Read the toolchain versions from the metadata + run: | + # The .NET SDK version. The format is '9.0.205 (commit 3e1383b780)', + # so we extract the version number alone: + dotnet_sdk_version=$(sed -n '4p' metadata.txt | sed 's/[^0-9.]*\([0-9.]*\).*/\1/') + + # The Rust version, written the same way: + rust_version=$(sed -n '6p' metadata.txt | sed 's/[^0-9.]*\([0-9.]*\).*/\1/') + + echo "DOTNET_SDK_VERSION=${dotnet_sdk_version}" >> $GITHUB_ENV + echo "RUST_VERSION=${rust_version}" >> $GITHUB_ENV + + echo ".NET SDK version: '${dotnet_sdk_version}'" + echo "Rust version: '${rust_version}'" + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_SDK_VERSION }} + cache: true + cache-dependency-path: 'app/MindWork AI Studio/packages.lock.json' + + - name: Cache Rust + uses: actions/cache@v4 + with: + path: | + ~/.cargo/git/db/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + runtime/target + + key: verify-linux-x64-rust-${{ env.RUST_VERSION }} + + - name: Setup Rust (stable) + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_VERSION }} + components: clippy + + - name: Setup dependencies (Ubuntu-specific) + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libfuse2 xdg-utils gstreamer1.0-plugins-base gstreamer1.0-plugins-good + + - name: Place a stand-in for the .NET sidecar + run: | + # Tauri's build script copies the .NET app in as a sidecar and refuses to run while that + # file is missing, so nothing Rust compiles without it. This job never bundles anything + # and never starts the app; it compiles, tests and lints the Rust code. An empty file is + # therefore enough, and publishing the real one would cost minutes for a binary nobody + # here runs. + mkdir -p "app/MindWork AI Studio/bin/dist" + touch "app/MindWork AI Studio/bin/dist/mindworkAIStudioServer-x86_64-unknown-linux-gnu" + chmod +x "app/MindWork AI Studio/bin/dist/mindworkAIStudioServer-x86_64-unknown-linux-gnu" + + - name: Run the quality gate + run: | + cd "app/Build" + dotnet run verify + build_main: name: Build app (${{ matrix.dotnet_runtime }}) - needs: [determine_run_mode, read_metadata] + needs: [determine_run_mode, read_metadata, verify] if: needs.determine_run_mode.outputs.build_enabled == 'true' permissions: contents: read diff --git a/app/Build/Commands/UpdateMetadataCommands.cs b/app/Build/Commands/UpdateMetadataCommands.cs index 1447a64a..26fd9641 100644 --- a/app/Build/Commands/UpdateMetadataCommands.cs +++ b/app/Build/Commands/UpdateMetadataCommands.cs @@ -87,8 +87,10 @@ public sealed partial class UpdateMetadataCommands await new CollectI18NKeysCommand().CollectI18NKeys(); // Build the final release, where Rust knows the updated metadata, the .NET - // artifacts are already in place, and .NET knows the updated web assets, etc.: - await this.Build(offline); + // artifacts are already in place, and .NET knows the updated web assets, etc. + // The gate already ran in the first build; running it a second time on the same + // sources would only add minutes: + await this.Build(offline, skipVerify: true); } [Command("update-metainfo", Description = "Update the AppStream metainfo entry of one release from its changelog")] @@ -221,11 +223,21 @@ public sealed partial class UpdateMetadataCommands [Command("build", Description = "Build MindWork AI Studio")] public async Task Build( - [Option("offline", Description = "Skip downloads and use locally available build dependencies")] bool offline = false) + [Option("offline", Description = "Skip downloads and use locally available build dependencies")] bool offline = false, + [Option("skip-verify", Description = "Skip the quality gate which otherwise runs before anything is built")] bool skipVerify = false) { if(!Environment.IsWorkingDirectoryValid()) return; - + + // + // The gate runs before anything is built, and the build stops when it does not pass. That + // way the same command answers both questions a person has -- is it sound, and does it + // build -- and answers them in that order, because building something the tests reject + // takes minutes to produce an artifact nobody should use. + // + if (!skipVerify && await new VerifyCommand().Verify() is not 0) + throw new CommandExitedException(1); + // // Build the .NET project: // @@ -596,7 +608,7 @@ public sealed partial class UpdateMetadataCommands // Drop any earlier entry of this version, so that the version stays unique and moves to the top. // We remove from the back, so that the index of the remaining matches stays valid: - foreach (var previousRelease in ReleaseBlockRegex().Matches(metainfo).Cast().Where(match => ReleaseTagHasVersion(match.Value, appVersion)).Reverse()) + foreach (var previousRelease in ReleaseBlockRegex().Matches(metainfo).Where(match => ReleaseTagHasVersion(match.Value, appVersion)).Reverse()) metainfo = metainfo.Remove(previousRelease.Index, previousRelease.Length); var lineEnding = metainfo.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; diff --git a/app/Build/Commands/VerifyCommand.cs b/app/Build/Commands/VerifyCommand.cs new file mode 100644 index 00000000..2e65ba72 --- /dev/null +++ b/app/Build/Commands/VerifyCommand.cs @@ -0,0 +1,78 @@ +using Build.Tools; + +// ReSharper disable ClassNeverInstantiated.Global +// ReSharper disable UnusedType.Global +// ReSharper disable UnusedMember.Global +namespace Build.Commands; + +/// +/// The quality gate: one command, the same one locally and in the pipeline. +/// +/// +/// Every check runs, even after one of them has failed. A gate which stops at the first failure +/// tells you one thing per run, and the next run costs the same minutes again -- while the point of +/// running the whole thing is to learn everything which is wrong in one go. +/// +public sealed class VerifyCommand +{ + /// + /// How the .NET app is named once it lies where Tauri expects it. + /// + private const string SIDECAR_PREFIX = "mindworkAIStudioServer-"; + + [Command("verify", Description = "Run the quality gate: .NET tests, Rust tests, Clippy, and the model sources")] + public async Task Verify() + { + if(!Environment.IsWorkingDirectoryValid()) + return 1; + + Console.WriteLine("=============================="); + Console.WriteLine("- Quality gate: every check runs, so that the first failure does not hide the next ..."); + + var results = new List<(string What, int ExitCode)> + { + (".NET tests", await CommandRunner.RunAsync(Environment.GetTestsDirectory(), "dotnet", "test --nologo")), + }; + + var runtimeDirectory = Environment.GetRustRuntimeDirectory(); + if (HasSidecar()) + { + results.Add(("Rust tests", await CommandRunner.RunAsync(runtimeDirectory, "cargo", "test"))); + results.Add(("Clippy", await CommandRunner.RunAsync(runtimeDirectory, "cargo", "clippy --all-targets -- -D warnings"))); + } + else + { + // + // Tauri's build script copies the .NET app in as a sidecar and refuses to run at all + // while that file is missing, so nothing Rust compiles without it. Failing here would + // be a trap rather than a gate: the way to produce the sidecar is `dotnet run build`, + // and that command runs this gate first -- a fresh clone would never get past it. + // + Console.WriteLine("- Skipping the Rust tests and Clippy: the .NET sidecar is missing, and Tauri's build script needs it before anything Rust compiles."); + Console.WriteLine(" Run 'dotnet run build --skip-verify' once. From then on, this part of the gate runs with the rest."); + } + + results.Add(("Model sources", new VerifyModelsCommand().VerifyModels())); + + Console.WriteLine("=============================="); + Console.WriteLine("- Quality gate:"); + foreach (var (what, exitCode) in results) + Console.WriteLine($" - {what}: {(exitCode is 0 ? "passed" : $"failed, exit code {exitCode}")}"); + + var failed = results.Count(result => result.ExitCode is not 0); + if (failed is 0) + { + Console.WriteLine($"- All {results.Count} checks passed."); + return 0; + } + + Console.WriteLine($"- {failed} of {results.Count} checks failed."); + return 1; + } + + private static bool HasSidecar() + { + var distributionDirectory = Path.Combine(Environment.GetAIStudioDirectory(), "bin", "dist"); + return Directory.Exists(distributionDirectory) && Directory.EnumerateFiles(distributionDirectory, $"{SIDECAR_PREFIX}*").Any(); + } +} \ No newline at end of file diff --git a/app/Build/Commands/VerifyModelsCommand.cs b/app/Build/Commands/VerifyModelsCommand.cs new file mode 100644 index 00000000..30052d7d --- /dev/null +++ b/app/Build/Commands/VerifyModelsCommand.cs @@ -0,0 +1,178 @@ +using System.Text.RegularExpressions; + +// ReSharper disable ClassNeverInstantiated.Global +// ReSharper disable UnusedType.Global +// ReSharper disable UnusedMember.Global +namespace Build.Commands; + +/// +/// Reports how long ago somebody last read the pages the model rules were written from. +/// +/// +/// Everything a rule set can be asked about itself is asked by the test project, against the +/// registry as it is really built: whether two rules claim the same names with the same right, +/// whether every family and every host names a page and a day, whether every pattern is written the +/// way model names arrive, whether a family reaches for one of the three reasoning words, and +/// whether a rank was set without saying what it moves past. Those belong there and not here -- +/// asking them a second time in this command would be a second implementation of the same +/// judgement, and two implementations of one judgement drift apart. +/// +/// The one question a test cannot ask is this one, because its answer changes with the calendar +/// rather than with the code: a family nobody touched would turn red on some Tuesday six months +/// after it was written. That is why it reports instead of failing, and why it is a command of its +/// own rather than a test or an analyzer. +/// +/// It fails on exactly one thing: when it can no longer read the sources at all. A check which +/// quietly reads nothing reports that everything is fine. +/// +public sealed partial class VerifyModelsCommand +{ + /// + /// How long a page may go unread before it is worth mentioning. + /// + private const int DEFAULT_MONTHS = 6; + + /// + /// The part of a source statement which is there in every spelling of it. + /// + /// + /// Counting these and comparing the count with what the pattern below actually read is how this + /// command notices that it has gone blind, rather than reporting an empty list of old sources. + /// + private const string DAY_MARKER = "new DateOnly("; + + [Command("verify-models", Description = "Report how long ago the pages behind the model rules were read")] + public int VerifyModels( + [Option("months", Description = "How long a page may go unread before it is reported")] int months = DEFAULT_MONTHS) + { + if(!Environment.IsWorkingDirectoryValid()) + return 1; + + if (months < 1) + { + Console.WriteLine("- Error: The number of months has to be at least 1."); + return 1; + } + + var modelsDirectory = Path.Combine(Environment.GetAIStudioDirectory(), "Models"); + if (!Directory.Exists(modelsDirectory)) + { + Console.WriteLine($"- Error: The models directory '{modelsDirectory}' does not exist. Either it moved, or this command looks in the wrong place."); + return 1; + } + + Console.WriteLine("=============================="); + Console.WriteLine("- Reading the sources behind the model rules ..."); + + var repository = Environment.GetRepositoryDirectory(); + var files = Directory.EnumerateFiles(modelsDirectory, "*.cs", SearchOption.AllDirectories).Order(StringComparer.Ordinal).ToArray(); + var sources = new List(); + var unreadable = new List(); + + foreach (var file in files) + { + var place = RelativeTo(repository, file); + var lines = File.ReadAllLines(file); + for (var index = 0; index < lines.Length; index++) + { + var line = lines[index]; + var stated = CountOccurrences(line, DAY_MARKER); + if (stated is 0) + continue; + + var read = SourceStatement().Matches(line); + foreach (Match statement in read) + sources.Add(new(place, index + 1, statement.Groups["url"].Value, new(int.Parse(statement.Groups["year"].ValueSpan), int.Parse(statement.Groups["month"].ValueSpan), int.Parse(statement.Groups["day"].ValueSpan)))); + + for (var missed = read.Count; missed < stated; missed++) + unreadable.Add($"{place}:{index + 1}"); + } + } + + if (sources.Count is 0) + { + Console.WriteLine($"- Error: Not one source was found in the {files.Length} files under '{RelativeTo(repository, modelsDirectory)}'."); + Console.WriteLine(" Every family and every host states one, so finding none means this command can no longer read them."); + Console.WriteLine(" A check which reads nothing reports that everything is fine, which is why this is an error rather than an empty report."); + return 1; + } + + if (unreadable.Count > 0) + { + Console.WriteLine($"- Error: {unreadable.Count} source(s) are written in a shape this command cannot read:"); + foreach (var place in unreadable) + Console.WriteLine($" - {place}"); + + Console.WriteLine(" A source whose day cannot be read never grows old, and would stay out of the report below without anybody noticing."); + Console.WriteLine(""" Write it as new("", new DateOnly(, , ), "") on one line, or teach this command the new shape."""); + return 1; + } + + var oldest = sources.MinBy(source => source.CheckedOn); + var newest = sources.MaxBy(source => source.CheckedOn); + Console.WriteLine($"- Read {sources.Count} sources in {files.Length} files under '{RelativeTo(repository, modelsDirectory)}'."); + Console.WriteLine($" - Oldest: {oldest.CheckedOn:yyyy-MM-dd}, in {oldest.Place}:{oldest.Line}"); + Console.WriteLine($" - Newest: {newest.CheckedOn:yyyy-MM-dd}, in {newest.Place}:{newest.Line}"); + + var lastAcceptableDay = DateOnly.FromDateTime(DateTime.Today).AddMonths(-months); + var stale = sources.Where(source => source.CheckedOn < lastAcceptableDay).OrderBy(source => source.CheckedOn).ToArray(); + if (stale.Length is 0) + { + Console.WriteLine($"- Every source was read on {lastAcceptableDay:yyyy-MM-dd} or later, so none of them is older than {months} months."); + return 0; + } + + var insideActions = string.Equals(global::System.Environment.GetEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase); + Console.WriteLine($"- {stale.Length} source(s) have not been read since {lastAcceptableDay:yyyy-MM-dd}:"); + foreach (var source in stale) + { + Console.WriteLine($" - {source.Place}:{source.Line}, last read on {source.CheckedOn:yyyy-MM-dd}: {source.Url}"); + if (insideActions) + Console.WriteLine($"::warning file={source.Place},line={source.Line}::This model source was last read on {source.CheckedOn:yyyy-MM-dd}: {source.Url}"); + } + + Console.WriteLine("- This is a report and never a failure: a page nobody has looked at for a while is not a page which changed."); + return 0; + } + + /// + /// How a source statement is written, in the one spelling the whole model namespace uses. + /// + /// + /// Both the family and the host state it as a target-typed new, so the type name itself is + /// nowhere in the line. What is always there is the page, then the day. + /// + [GeneratedRegex("""new\("(?[^"]*)",\s*new DateOnly\((?\d{4}),\s*(?\d{1,2}),\s*(?\d{1,2})\)""")] + private static partial Regex SourceStatement(); + + private static int CountOccurrences(string line, string marker) + { + var found = 0; + var at = line.IndexOf(marker, StringComparison.Ordinal); + while (at >= 0) + { + found++; + at = line.IndexOf(marker, at + marker.Length, StringComparison.Ordinal); + } + + return found; + } + + /// + /// A path as GitHub reads it: relative to the checkout, with forward slashes. + /// + /// + /// An annotation carrying an absolute path of somebody's machine lands nowhere, and it does so + /// without saying that it did. + /// + private static string RelativeTo(string repository, string path) => Path.GetRelativePath(repository, path).Replace('\\', '/'); + + /// + /// One page a rule was written from, and the day somebody last read it. + /// + /// The file it is stated in, relative to the repository. + /// The line it is stated on. + /// The page. + /// The day somebody last read it. + private readonly record struct ReadSource(string Place, int Line, string Url, DateOnly CheckedOn); +} \ No newline at end of file diff --git a/app/Build/Program.cs b/app/Build/Program.cs index f56078de..999e30b2 100644 --- a/app/Build/Program.cs +++ b/app/Build/Program.cs @@ -7,4 +7,6 @@ app.AddCommands(); app.AddCommands(); app.AddCommands(); app.AddCommands(); +app.AddCommands(); +app.AddCommands(); app.Run(); diff --git a/app/Build/Tools/CommandRunner.cs b/app/Build/Tools/CommandRunner.cs new file mode 100644 index 00000000..51092a11 --- /dev/null +++ b/app/Build/Tools/CommandRunner.cs @@ -0,0 +1,62 @@ +using System.ComponentModel; +using System.Diagnostics; + +namespace Build.Tools; + +/// +/// Runs one external tool and lets it write straight to the terminal. +/// +/// +/// The output is deliberately not captured. A gate which swallows the output of a failing test run +/// and then prints "failed" leaves the person who has to fix it with nothing to go on, while the +/// tools it runs already say everything worth saying -- which test, which line, which lint. +/// +public static class CommandRunner +{ + /// + /// What a tool which could not be started at all reports. + /// + /// + /// Anything but zero counts as a failure, so the exact number matters only in that it is not + /// one a tool would plausibly return itself. + /// + public const int COULD_NOT_START = 127; + + /// + /// Runs a tool and waits for it. + /// + /// Where the tool should run. + /// The tool, as it is called on the PATH. + /// What to pass it. + /// The exit code of the tool, or COULD_NOT_START when it never ran. + public static async Task RunAsync(string workingDirectory, string fileName, string arguments) + { + Console.WriteLine($"- Running '{fileName} {arguments}' in '{workingDirectory}' ..."); + var startInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + WorkingDirectory = workingDirectory, + UseShellExecute = false, + }; + + try + { + using var process = Process.Start(startInfo); + if (process is null) + { + Console.WriteLine($"- Error: '{fileName}' did not start, and the system did not say why."); + return COULD_NOT_START; + } + + await process.WaitForExitAsync(); + return process.ExitCode; + } + catch (Win32Exception exception) + { + Console.WriteLine($"- Error: '{fileName}' could not be started: {exception.Message}"); + Console.WriteLine($" Is '{fileName}' installed and on the PATH?"); + return COULD_NOT_START; + } + } +} \ No newline at end of file diff --git a/app/Build/Tools/Environment.cs b/app/Build/Tools/Environment.cs index 39c383f1..4d77b916 100644 --- a/app/Build/Tools/Environment.cs +++ b/app/Build/Tools/Environment.cs @@ -34,6 +34,28 @@ public static class Environment return Path.GetFullPath(directory); } + public static string GetTestsDirectory() + { + var currentDirectory = Directory.GetCurrentDirectory(); + var directory = Path.Combine(currentDirectory, "..", "Tests"); + return Path.GetFullPath(directory); + } + + /// + /// The root of the git repository, which is what a path in a report is written relative to. + /// + /// + /// GitHub resolves the file of an annotation against the checkout, not against wherever a tool + /// happened to run. An absolute path of somebody's machine would therefore land the annotation + /// nowhere, without saying so. + /// + public static string GetRepositoryDirectory() + { + var currentDirectory = Directory.GetCurrentDirectory(); + var directory = Path.Combine(currentDirectory, "..", ".."); + return Path.GetFullPath(directory); + } + public static string GetRustRuntimeDirectory() { var currentDirectory = Directory.GetCurrentDirectory(); diff --git a/app/MindWork AI Studio/Models/ModelRuleBuilder.cs b/app/MindWork AI Studio/Models/ModelRuleBuilder.cs index c423693e..ecd4c755 100644 --- a/app/MindWork AI Studio/Models/ModelRuleBuilder.cs +++ b/app/MindWork AI Studio/Models/ModelRuleBuilder.cs @@ -265,7 +265,15 @@ public sealed class ModelRuleBuilder(string patternText, ModelRuleKind ruleKind, /// The rule, to go on stating. public ModelRuleBuilder Rank(int rank, string reason) { - _ = reason; + // + // Asking for a reason is what the second parameter does; insisting that it says something + // is what keeps an empty string from passing for one. Without this, the way to write a rank + // nobody can account for is still open, and it is the one thing the computed specificity + // exists to get rid of. + // + if (string.IsNullOrWhiteSpace(reason)) + throw new ArgumentException($"The rule \"{patternText}\" of {origin} sets the rank {rank} without saying what the computed specificity gets wrong here.", nameof(reason)); + this.explicitRank = rank; return this; } diff --git a/app/Tests/Models/ModelFamilyTests.cs b/app/Tests/Models/ModelFamilyTests.cs index b60af192..3f1c47a2 100644 --- a/app/Tests/Models/ModelFamilyTests.cs +++ b/app/Tests/Models/ModelFamilyTests.cs @@ -125,6 +125,21 @@ public sealed class ModelFamilyTests Assert.That(refused?.Message, Does.Contain("more than once")); } + [Test] + public void ARankNobodyAccountedForIsRefused() + { + // + // The rank is the way past everything the specificity computes, and the sentence next to it + // is the only thing keeping it accountable. The compiler asks for that sentence; this is + // what keeps an empty one from passing for it, because a number without an explanation + // reads as noise to whoever comes next -- and noise is what the computation replaced. + // + var family = new FamilyRankingWithoutSayingWhy(); + var refused = Assert.Throws(() => _ = family.Rules); + + Assert.That(refused?.Message, Does.Contain("specificity gets wrong")); + } + [Test] public void AFamilyWhichAdjustsRatherThanChoosesStatesAModifier() { @@ -250,6 +265,15 @@ public sealed class ModelFamilyTests } } + private sealed class FamilyRankingWithoutSayingWhy : ModelFamily + { + public override ModelVendor Vendor => ModelVendor.UNKNOWN; + + public override ModelSource Source => new("https://example.invalid/rank", new DateOnly(2026, 9, 13), "A family moving one of its rules by hand without saying what it moves it past."); + + protected override void Declare(ModelFamilyBuilder builder) => builder.Rule("thing").Rank(1, " ").Capabilities(Capability.TEXT_INPUT); + } + private sealed class FamilyWithAModifier : ModelFamily { public override ModelVendor Vendor => ModelVendor.UNKNOWN; diff --git a/documentation/Build.md b/documentation/Build.md index 81b0c271..7bae38d9 100644 --- a/documentation/Build.md +++ b/documentation/Build.md @@ -23,6 +23,21 @@ Regardless of whether you want to build the app locally for yourself (not trusti This is necessary because the build script and the Tauri framework assume that the .NET app is available as a so-called "sidecar." Although the sidecar is only necessary for the final release and shipping, Tauri requires it to be present during development as well. +## The quality gate +One command checks that what you are about to build is sound: + +1. Open a terminal. +2. Navigate to the `/app/Build` directory within the repository. +3. Run `dotnet run verify`. + +It runs the .NET tests, the Rust tests, Clippy (`cargo clippy --all-targets -- -D warnings`), and a report on the pages the model rules were written from. Every check runs, even after one of them has failed, so that a single run tells you everything that is wrong instead of the first thing. + +`dotnet run build` runs the gate first and stops when it does not pass. For the quick loop while you are working on something, use `dotnet run build --skip-verify`, and let the gate run before you open a pull request. The same command runs in our GitHub workflow as the `verify` job, on every pull request — including those without the `run-pipeline` label, because a gate which is closed exactly while nobody is looking is not a gate. + +Two notes: +- The Rust half of the gate needs the .NET sidecar (see "One-time mandatory steps" above). While that file is missing, the gate skips the Rust tests and Clippy and says so rather than failing, because the command which produces the sidecar is `dotnet run build` itself. +- `dotnet run verify-models` reports how long ago somebody last read the pages behind the model rules, and names everything older than six months. It is a report and never a failure: a page nobody has looked at for a while is not a page which changed. Everything else about the model rules — whether two rules claim the same names, whether every family names a page and a day, whether every pattern is written the way model names arrive — is checked by the test project, and therefore by `dotnet test`. + ## Build AI Studio from source In order to build MindWork AI Studio from source instead of using the pre-built binaries, follow these steps: 1. Ensure you have met all the prerequisites.