← All source files

V13 / 7879e8b5740b

ProjectValidator.cs

Lilith/Core/Projects/ProjectValidator.cs

Unchanged from the preceding version
CS
using System.Diagnostics;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using OpenQA.Selenium.Chrome;

namespace Lilith.Core.Projects;

internal static class ProjectValidator
{
    internal static string ProjectXml(ProjectPackage[] packages) => new XDocument(
        new XElement("Project", new XAttribute("Sdk", "Microsoft.NET.Sdk"),
            new XElement("PropertyGroup", new XElement("OutputType", "Exe"), new XElement("TargetFramework", "net8.0"),
                new XElement("ImplicitUsings", "enable"), new XElement("Nullable", "enable"),
                new XElement("EnableNETAnalyzers", "false"), new XElement("RunAnalyzers", "false")),
            new XElement("ItemGroup", packages.Select(p => new XElement("PackageReference", new XAttribute("Include", p.Id), new XAttribute("Version", p.Version)))))).ToString();

    internal static async Task<ProjectValidation> ValidateAsync(string kind, ProjectBundle bundle, ProjectPackage[] packages, string folder, CancellationToken ct, bool additionalFiles = false)
    {
        var errors = new List<string>(); var checks = new List<string>(); string output = "";
        string? buildDirectory = null;
        foreach (var file in bundle.Files)
            if (file.Content.Contains("/*METHOD:", StringComparison.Ordinal) || Regex.IsMatch(file.Content, @"\b(NotImplementedException|TODO_IMPLEMENT)\b"))
                errors.Add(file.Path + ": unfinished implementation.");
        checks.Add("Source paths, size bounds and unresolved implementation slots");
        if (kind == "website")
        {
            var byPath = bundle.Files.ToDictionary(f => f.Path, f => f.Content, StringComparer.Ordinal);
            if (!new[] { "index.html", "styles.css", "app.js" }.All(byPath.ContainsKey) || !additionalFiles && byPath.Count != 3) errors.Add("Missing website files.");
            else
            {
                string html = byPath["index.html"];
                if (!Regex.IsMatch(html, @"^\s*<!doctype\s+html\s*>", RegexOptions.IgnoreCase) || !Regex.IsMatch(html, @"<title>[^<]+</title>", RegexOptions.IgnoreCase)) errors.Add("HTML must start with an HTML doctype and include a title.");
                foreach (Match match in Regex.Matches(html, "(?:src|href)\\s*=\\s*[\"']([^\"']+)[\"']", RegexOptions.IgnoreCase))
                {
                    string reference = match.Groups[1].Value;
                    if (reference.StartsWith('#') || reference.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || reference.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) continue;
                    if (!byPath.ContainsKey(reference)) errors.Add("Unresolved local resource: " + reference);
                }
                if (!html.Contains("styles.css", StringComparison.Ordinal) || !html.Contains("app.js", StringComparison.Ordinal)) errors.Add("HTML must load styles.css and app.js.");
                foreach (Match script in Regex.Matches(html, @"<script\b[^>]*>([\s\S]*?)</script\s*>", RegexOptions.IgnoreCase))
                    if (!string.IsNullOrWhiteSpace(script.Groups[1].Value)) errors.Add("Keep executable JavaScript in app.js, not inline HTML scripts.");
                checks.Add("HTML document, local resources and file integration");
                try
                {
                    await Task.Run(() => CheckBrowserSyntax(byPath, errors, ct), ct);
                    checks.Add("Chrome JavaScript compilation, CSS parsing and DOM ID references; generated JavaScript was not executed");
                }
                catch (OperationCanceledException) { throw; }
                catch (Exception ex) { errors.Add("Browser syntax validation unavailable: " + ex.Message); }
            }
        }
        else
        {
            string build = additionalFiles ? Path.Combine(Path.GetTempPath(), "LilithProjects", Guid.NewGuid().ToString("N")[..12]) : Path.Combine(folder, "Build", Guid.NewGuid().ToString("N"));
            buildDirectory = build; Directory.CreateDirectory(build);
            foreach (var file in bundle.Files)
            {
                string path = Path.Combine(build, file.Path); Directory.CreateDirectory(Path.GetDirectoryName(path)!);
                await File.WriteAllTextAsync(path, file.Content, ct);
            }
            await File.WriteAllTextAsync(Path.Combine(build, "Application.csproj"), ProjectXml(packages), ct);
            if (packages.Length == 0)
                await File.WriteAllTextAsync(Path.Combine(build, "NuGet.Config"), "<configuration><packageSources><clear /></packageSources></configuration>", ct);
            // Ignore repository/ancestor custom build logic. Only host-generated project XML is built.
            var start = new ProcessStartInfo("dotnet") { WorkingDirectory = build, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true };
            foreach (string arg in new[] { "build", "Application.csproj", "-c", "Release", "--nologo", "-v", "minimal", "-p:ImportDirectoryBuildProps=false", "-p:ImportDirectoryBuildTargets=false", "-p:NuGetAudit=false" }) start.ArgumentList.Add(arg);
            using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); timeout.CancelAfter(TimeSpan.FromMinutes(3));
            using var process = Process.Start(start) ?? throw new IOException("Could not start the installed .NET SDK.");
            Task<string> stdout = process.StandardOutput.ReadToEndAsync(), stderr = process.StandardError.ReadToEndAsync();
            try { await process.WaitForExitAsync(timeout.Token); }
            catch { try { process.Kill(true); } catch (InvalidOperationException) { } throw; }
            output = await stdout + "\n" + await stderr;
            if (output.Length > 12000) output = output[..12000];
            if (process.ExitCode != 0) errors.Add("dotnet build failed; see build output.");
            checks.Add("Actual Release compilation using host-generated project and declared exact-version NuGet references; application not run");
        }
        checks.Add("Functional/runtime acceptance tests require the user's separate run decision");
        return new(errors.Count == 0, kind, checks.ToArray(), errors.ToArray(), output, buildDirectory);
    }

    private static void CheckBrowserSyntax(Dictionary<string, string> files, List<string> errors, CancellationToken ct)
    {
        ct.ThrowIfCancellationRequested();
        var research = WebResearch.BrowserResearchOptions.Load();
        string? binary = research.ChromeBinary ?? Environment.GetEnvironmentVariable("LILITH_CHROME_BINARY") ?? new[] {
            Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Google/Chrome/Application/chrome.exe"),
            Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Google/Chrome/Application/chrome.exe") }.FirstOrDefault(File.Exists);
        if (binary is null) throw new IOException("Existing Chrome is required for website syntax checks.");
        string? driverDirectory = research.DriverDirectory;
        if (string.IsNullOrWhiteSpace(driverDirectory))
        {
            string cache = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "selenium", "chromedriver");
            driverDirectory = Directory.Exists(cache) ? Directory.GetFiles(cache, "chromedriver.exe", SearchOption.AllDirectories).OrderByDescending(File.GetLastWriteTimeUtc).Select(Path.GetDirectoryName).FirstOrDefault() : null;
        }
        if (driverDirectory is null || !File.Exists(Path.Combine(driverDirectory, "chromedriver.exe"))) throw new IOException("An existing ChromeDriver is required; no driver will be downloaded.");
        var options = new ChromeOptions { BinaryLocation = binary };
        options.AddArguments("--headless=new", "--disable-background-networking", "--no-first-run", "--host-resolver-rules=MAP * ~NOTFOUND");
        using var service = ChromeDriverService.CreateDefaultService(driverDirectory);
        service.HideCommandPromptWindow = true; service.SuppressInitialDiagnosticInformation = true;
        using var driver = new ChromeDriver(service, options, TimeSpan.FromSeconds(20));
        // All generated text is passed as data; no navigation to generated HTML and no call to generated code.
        foreach (var script in files.Where(f => f.Key.EndsWith(".js", StringComparison.Ordinal)))
        {
        var compilation = driver.ExecuteCdpCommand("Runtime.compileScript", new Dictionary<string, object?> {
            ["expression"] = script.Value, ["sourceURL"] = script.Key, ["persistScript"] = false });
        var compiled = System.Text.Json.JsonSerializer.SerializeToElement(compilation ?? throw new IOException("Missing JavaScript compiler result."));
        if (compiled.TryGetProperty("exceptionDetails", out var exception))
        {
            int line = exception.TryGetProperty("lineNumber", out var number) ? number.GetInt32() : 0;
            string message = exception.TryGetProperty("exception", out var detail) && detail.TryGetProperty("description", out var description)
                ? description.GetString() ?? "Syntax error" : "Syntax error";
            var lines = script.Value.Replace("\r\n", "\n").Split('\n');
            string context = string.Join("\n", Enumerable.Range(Math.Max(0, line - 2), Math.Max(0, Math.Min(lines.Length, line + 3) - Math.Max(0, line - 2))).Select(i => $"{i + 1}: {lines[i]}"));
            errors.Add($"{script.Key} line {line + 1}: {message}\nSource context (numbers are not part of source):\n{context}");
        }
        }
        string css = driver.ExecuteScript("try { const s = new CSSStyleSheet(); s.replaceSync(arguments[0]); return s.cssRules.length ? '' : 'No valid CSS rules'; } catch(e) { return String(e); }", files["styles.css"]) as string ?? throw new IOException("Missing CSS parser result.");
        if (css.Length > 0) errors.Add("styles.css: " + css);
        string domIds = driver.ExecuteScript("const d = new DOMParser().parseFromString(arguments[0], 'text/html'); return JSON.stringify(Array.from(d.querySelectorAll('[id]'), e => e.id));", files["index.html"]) as string ?? throw new IOException("Missing HTML parser result.");
        var ids = ProjectJson.Parse<string[]>(domIds);
        if (ids.Distinct(StringComparer.Ordinal).Count() != ids.Length) errors.Add("Duplicate HTML IDs.");
        foreach (Match match in Regex.Matches(files["app.js"], "getElementById\\(\\s*[\"']([^\"']+)[\"']\\s*\\)"))
            if (!ids.Contains(match.Groups[1].Value, StringComparer.Ordinal)) errors.Add("Missing HTML ID used by JavaScript: " + match.Groups[1].Value);
        ct.ThrowIfCancellationRequested();
    }
}