V17 / d6d373561692
ProjectValidator.cs
Lilith/Core/Projects/ProjectValidator.cs
Unchanged from the preceding versionCS
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();
}
}