← All source files

V11 / ca6f28eff240

ProjectGeneration.cs

Lilith/Core/Projects/ProjectGeneration.cs

Changed in this version
CS
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Text.Json;
using Lilith.Core.Workspace;
using Lilith.Core.Workspace.Extensions;

namespace Lilith.Core.Projects;

/// <summary>One scenario tree's private drafts; final publication uses an exact workspace proposal.</summary>
public sealed class ProjectGeneration(string evidenceRoot, WorkspaceSession workspace)
{
    private string? _id, _name, _kind, _prd;
    private ProjectArchitecture? _architecture;
    private ProjectTask[] _tasks = [];
    private readonly Dictionary<string, string> _components = new(StringComparer.Ordinal);
    private ProjectBundle? _bundle;
    private ProjectValidation? _validation;
    private string _diagnostics = "Not yet validated.";
    private string _reviewDiagnostics = "Independent review has not passed for the current source.";
    private bool _reviewPassed;
    private int _reviewNumber;
    internal ProjectBundle CurrentBundle => _bundle ?? throw new InvalidOperationException("Assemble the project before integration.");
    internal string CurrentDisplay => ProjectIntegrationCodec.Display(CurrentBundle);
    private string Folder => Path.Combine(evidenceRoot, "Projects", _id ?? throw new InvalidOperationException("Prepare a project first."));

    public string Prepare(string name, string kind, string prd, string architecture, bool validateOnly = false)
    {
        if (_id is not null) throw new InvalidOperationException("Only one generation per scenario tree.");
        if (!Regex.IsMatch(name, "^[A-Za-z][A-Za-z0-9_-]{0,47}$") || kind is not ("website" or "console") || prd.Length is < 30 or > 16000)
            throw new ArgumentException("Use a project slug, website/console kind and a detailed PRD of 30-16000 characters.");
        string destination = workspace.Options.ResolvePath(name);
        if (File.Exists(destination) || Directory.Exists(destination)) throw new IOException("Project target already exists. Choose a new project name; V11 does not edit projects.");
        ProjectArchitecture plan;
        if (kind == "website")
        {
            string contract = CleanCode(architecture);
            if (contract.Length < 80) throw new InvalidDataException("Website architecture needs a detailed shared interface contract.");
            plan = new(contract, [new("index.html", "", []), new("styles.css", "", []), new("app.js", "", [])], []);
        }
        else plan = ProjectJson.Architecture(architecture);
        if (string.IsNullOrWhiteSpace(plan.Contract) || plan.Contract.Length > 12000 || plan.Files is null || plan.Files.Length is < 1 or > 12)
            throw new InvalidDataException("Architecture needs a shared contract and 1-12 files.");
        if (kind == "console")
            plan = plan with { Files = plan.Files.Select(file =>
            {
                string skeleton = file.Skeleton ?? "";
                foreach (var method in file.Methods ?? [])
                {
                    if (string.IsNullOrWhiteSpace(method.Signature)) continue;
                    // Normalize only empty declarations adjacent to a slot, never replace implemented code.
                    skeleton = Regex.Replace(skeleton, @"/\*METHOD:[^*]+\*/\s*" + Regex.Escape(method.Signature) + @"\s*(?:;|\{\s*(?:/\*METHOD_BODY\*/)?\s*\})",
                        _ => "/*METHOD:" + method.Id + "*/", RegexOptions.CultureInvariant);
                }
                return file with { Skeleton = skeleton };
            }).ToArray() };
        var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var tasks = new List<ProjectTask>();
        foreach (var file in plan.Files)
        {
            ValidateSourcePath(file.Path, kind);
            if (!paths.Add(file.Path) || file.Skeleton is null || file.Skeleton.Length > 16000 || file.Methods is null)
                throw new InvalidDataException("Invalid/duplicate architecture file.");
            if (kind == "website")
            {
                if (file.Methods.Length != 0) throw new InvalidDataException("Website tasks are whole files.");
                tasks.Add(new("file_" + tasks.Count, file.Path, "", plan.Contract));
            }
            else
            {
                foreach (var method in file.Methods)
                {
                    if (!Regex.IsMatch(method.Id, "^[A-Za-z][A-Za-z0-9_]{0,47}$") || tasks.Any(t => t.Id == method.Id) ||
                        string.IsNullOrWhiteSpace(method.Signature) || method.Signature.Length > 500 || method.Signature.IndexOfAny(['{', '}', ';']) >= 0 ||
                        !method.Signature.Contains('(') || !method.Signature.Contains(')') || string.IsNullOrWhiteSpace(method.Requirements) ||
                        file.Skeleton.Split("/*METHOD:" + method.Id + "*/", StringSplitOptions.None).Length != 2 || file.Skeleton.Contains(method.Signature, StringComparison.Ordinal))
                        throw new InvalidDataException("Each method requires a unique ID, signature, requirements and exactly one skeleton slot.");
                    tasks.Add(new(method.Id, file.Path, method.Signature, method.Requirements));
                }
                if (Regex.Matches(file.Skeleton, @"/\*METHOD:[^*]+\*/").Count != file.Methods.Length)
                    throw new InvalidDataException("Undeclared method slot.");
            }
        }
        if (tasks.Count is < 1 or > 20 || kind == "website" && !paths.SetEquals(["index.html", "styles.css", "app.js"]))
            throw new InvalidDataException("Use 1-20 tasks; websites require index.html, styles.css and app.js.");
        ValidatePackages(plan.Packages ?? [], kind);
        if (validateOnly) return ProjectJson.Serialize(new { valid = true, diagnostics = "Architecture contract validated." });
        _id = Guid.NewGuid().ToString("N"); _name = name; _kind = kind; _prd = prd;
        _architecture = plan with { Packages = plan.Packages ?? [] }; _tasks = tasks.ToArray();
        Folder.EnsureNoLinks(); Directory.CreateDirectory(Folder);
        File.WriteAllText(Path.Combine(Folder, "architecture.json"), ProjectJson.Serialize(_architecture));
        File.WriteAllText(Path.Combine(Folder, "prd.txt"), prd);
        return ProjectJson.Serialize(new { generation_id = _id,
            contract = plan.Contract + "\nFrozen file/class/method interfaces:\n" + ProjectJson.Serialize(plan.Files), tasks = _tasks,
            repairs = new[] { new { attempt = 1 }, new { attempt = 2 }, new { attempt = 3 } } });
    }

    public string Store(string generationId, string taskId, string content)
    {
        Require(generationId);
        _validation = null;
        _reviewPassed = false;
        var task = _tasks.SingleOrDefault(t => t.Id == taskId) ?? throw new ArgumentException("Unknown component task.");
        content = CleanCode(content);
        if (_kind == "website" && content.Split('\n', 2)[0].Trim() == task.Path)
            content = content[(content.IndexOf('\n') + 1)..].Trim();
        if (_components.ContainsKey(taskId) || content.Length is < 1 or > 16000) throw new InvalidDataException("Duplicate or oversized component.");
        if (_kind == "console" && (!content.StartsWith('{') || !content.EndsWith('}')))
            content = "{\n" + content + "\n}";
        _components.Add(task.Id, content);
        File.WriteAllText(Path.Combine(Folder, taskId + ".txt"), content);
        return ProjectJson.Serialize(new { stored = true, task_id = taskId });
    }

    public string Assemble(string generationId)
    {
        Require(generationId);
        _validation = null;
        _reviewPassed = false;
        if (_tasks.Any(t => !_components.ContainsKey(t.Id))) throw new InvalidOperationException("Every component must be reviewed and stored first.");
        var files = _architecture!.Files.Select(file =>
        {
            string content = _kind == "website" ? _components[_tasks.Single(t => t.Path == file.Path).Id] : file.Skeleton;
            if (_kind == "console") foreach (var method in file.Methods)
                content = content.Replace("/*METHOD:" + method.Id + "*/", method.Signature + "\n" + _components[method.Id], StringComparison.Ordinal);
            return new ProjectSource(file.Path, content);
        }).ToArray();
        _bundle = new(files);
        return ProjectJson.Serialize(new { display = CurrentDisplay });
    }

    public async Task<string> ValidateAsync(string generationId, string bundle, CancellationToken ct)
    {
        Require(generationId);
        _validation = null;
        _reviewPassed = false;
        if (_tasks.Any(t => !_components.ContainsKey(t.Id))) throw new InvalidOperationException("Review and store every component before validation.");
        ProjectBundle candidate;
        if (CleanCode(bundle).StartsWith('<')) candidate = ApplyChanges(CurrentBundle, ProjectIntegrationCodec.ParseXml(bundle));
        else
        {
            var integration = ProjectJson.Parse<JsonElement>(bundle);
            candidate = integration.TryGetProperty("changes", out _)
                ? ApplyChanges(CurrentBundle, ProjectJson.Parse<ProjectIntegration>(bundle))
                : ProjectJson.Parse<ProjectBundle>(bundle);
        }
        ValidateBundle(candidate, _kind!);
        if (!candidate.Files.Select(f => f.Path).ToHashSet(StringComparer.OrdinalIgnoreCase)
            .SetEquals(_architecture!.Files.Select(f => f.Path))) throw new InvalidDataException("Integration cannot add or remove planned files.");
        if (_kind == "console")
            foreach (var file in _architecture.Files)
                foreach (var method in file.Methods)
                    if (!candidate.Files.Single(f => f.Path == file.Path).Content.Contains(method.Signature, StringComparison.Ordinal))
                        throw new InvalidDataException("Integration changed or removed a contracted method signature: " + method.Id);
        _bundle = candidate;
        _validation = await ProjectValidator.ValidateAsync(_kind!, candidate, _architecture.Packages, Folder, ct);
        _diagnostics = string.Join("\n", _validation.Errors) + "\n" + _validation.BuildOutput;
        File.WriteAllText(Path.Combine(Folder, "validation.json"), ProjectJson.Serialize(_validation));
        File.WriteAllText(Path.Combine(Folder, "bundle.json"), ProjectJson.Serialize(candidate));
        return ProjectJson.Serialize(new { passed = _validation.Passed, report = _validation });
    }

    public async Task<WorkspaceResult> PublishAsync(string generationId, CancellationToken ct = default)
    {
        Require(generationId);
        if (_validation is not { Passed: true } || _bundle is null) throw new InvalidOperationException("Project validation has not passed. Nothing was published.");
        if (!_reviewPassed) throw new InvalidOperationException("Independent source review has not passed for the current revision. Nothing was published.");
        var files = _bundle.Files.ToList();
        if (_kind == "console") files.Add(new("Application.csproj", ProjectValidator.ProjectXml(_architecture!.Packages)));
        files.Add(new("PRD.txt", _prd!));
        var hashes = files.ToDictionary(f => f.Path, f => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(f.Content))), StringComparer.Ordinal);
        var manifest = new ProjectManifest(1, _id!, _name!, _kind!, _prd!, _architecture!.Contract,
            _architecture.Packages, _architecture.Files, _tasks, _validation, hashes, DateTimeOffset.UtcNow, _reviewDiagnostics);
        files.Add(new("lilith-project.json", ProjectJson.Serialize(manifest)));
        string payload = ProjectJson.Serialize(new ProjectBundle(files.ToArray()));
        return await workspace.ExecuteAsync(new("project_create", _name!, payload), ct);
    }

    private void Require(string id)
    {
        if (_id is null || id != _id) throw new InvalidOperationException("Unknown generation in this scenario tree.");
    }
    internal void RecordDiagnostic(string message) => _diagnostics = message;
    internal string Inspect(string id)
    {
        Require(id);
        return ProjectJson.Serialize(new { display = CurrentDisplay, diagnostics = _diagnostics + "\nIndependent source review (not runtime evidence):\n" + _reviewDiagnostics });
    }
    internal string Review(string id, string report)
    {
        Require(id);
        _reviewPassed = false;
        bool reviewValid = false;
        try
        {
            if (report.Length > 12000) throw new InvalidDataException("Review exceeds 12000 characters.");
            var root = ProjectJson.Parse<JsonElement>(report);
            if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("findings", out var findings) || findings.ValueKind != JsonValueKind.Array || findings.GetArrayLength() > 20)
                throw new InvalidDataException("Review must contain a findings array of at most 20 defects.");
            var ids = new HashSet<string>(StringComparer.Ordinal);
            foreach (var finding in findings.EnumerateArray())
            {
                string Text(string key) => finding.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(value.GetString())
                    ? value.GetString()! : throw new InvalidDataException("Review finding requires " + key + ".");
                string findingId = Text("id"), path = Text("path"), evidence = Text("evidence");
                _ = Text("problem"); _ = Text("fix");
                if (!ids.Add(findingId) || !CurrentBundle.Files.Any(f => f.Path == path && f.Content.Contains(evidence, StringComparison.Ordinal)))
                    throw new InvalidDataException("Review evidence must match current source and defect IDs must be unique.");
            }
            _reviewDiagnostics = root.GetRawText();
            reviewValid = true;
            _reviewPassed = findings.GetArrayLength() == 0;
        }
        catch (Exception ex) when (ex is InvalidDataException or JsonException or InvalidOperationException)
        { _reviewDiagnostics = "Invalid independent review: " + ex.Message + " Return the required findings JSON with exact current source evidence."; }
        File.WriteAllText(Path.Combine(Folder, $"review-{++_reviewNumber}.json"), ProjectJson.Serialize(new { sourceHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(CurrentDisplay))), reviewPassed = _reviewPassed, report, diagnostics = _reviewDiagnostics }));
        return ProjectJson.Serialize(new { passed = _validation is { Passed: true } && _reviewPassed, buildPassed = _validation?.Passed == true, reviewPassed = _reviewPassed, reviewValid,
            diagnostics = _diagnostics + "\n" + _reviewDiagnostics, runtimeTested = false });
    }
    private static ProjectBundle ApplyChanges(ProjectBundle bundle, ProjectIntegration integration)
    {
        if (integration.Changes is null || integration.Changes.Length > 20) throw new InvalidDataException("Use at most 20 exact integration changes.");
        var files = bundle.Files.ToDictionary(f => f.Path, f => f.Content, StringComparer.Ordinal);
        foreach (var change in integration.Changes)
        {
            if (change is null || !files.TryGetValue(change.Path, out var source) || string.IsNullOrEmpty(change.Old) || change.New is null || change.Old.Length > 8000 || change.New.Length > 8000)
                throw new InvalidDataException("Integration must change an existing file using bounded exact old/new text.");
            int at = source.IndexOf(change.Old, StringComparison.Ordinal);
            if (at < 0 || source.IndexOf(change.Old, at + 1, StringComparison.Ordinal) >= 0)
                throw new InvalidDataException("Integration old text must match exactly once: " + change.Path);
            files[change.Path] = source[..at] + change.New + source[(at + change.Old.Length)..];
        }
        return new(bundle.Files.Select(f => f with { Content = files[f.Path] }).ToArray());
    }
    internal static string CleanCode(string code)
    {
        code = code.Trim();
        if (!code.StartsWith("```", StringComparison.Ordinal)) return code;
        int first = code.IndexOf('\n');
        if (first < 0 || !code.EndsWith("```", StringComparison.Ordinal)) throw new InvalidDataException("Incomplete code fence.");
        return code[(first + 1)..^3].Trim();
    }
    internal static void ValidateSourcePath(string path, string kind)
    {
        if (string.IsNullOrWhiteSpace(path) || path.Length > 100 || path.Contains('\\') || path.StartsWith('/') ||
            path.Split('/').Any(p => !Regex.IsMatch(p, "^[A-Za-z][A-Za-z0-9_.-]*$") || p.EndsWith('.') ||
                Regex.IsMatch(p, @"^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\.|$)", RegexOptions.IgnoreCase)) ||
            (kind == "website" ? path is not ("index.html" or "styles.css" or "app.js") : !path.EndsWith(".cs", StringComparison.Ordinal)))
            throw new InvalidDataException("Invalid project source path.");
    }
    internal static void ValidateBundle(ProjectBundle bundle, string kind)
    {
        if (bundle.Files is null || bundle.Files.Length is < 1 or > 12 || bundle.Files.Select(f => f.Path).Distinct(StringComparer.OrdinalIgnoreCase).Count() != bundle.Files.Length ||
            bundle.Files.Any(f => f.Content is null || f.Content.Length > 16000) || bundle.Files.Sum(f => f.Content.Length) > 40000)
            throw new InvalidDataException("Invalid project bundle or size limit (40000 characters).");
        foreach (var file in bundle.Files) ValidateSourcePath(file.Path, kind);
    }
    private static void ValidatePackages(ProjectPackage[] packages, string kind)
    {
        if (packages.Length > 8 || kind == "website" && packages.Length != 0 || packages.Select(p => p.Id).Distinct(StringComparer.OrdinalIgnoreCase).Count() != packages.Length ||
            packages.Any(p => !Regex.IsMatch(p.Id, "^[A-Za-z][A-Za-z0-9_.-]{0,99}$") || !Regex.IsMatch(p.Version, @"^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$")))
            throw new InvalidDataException("Use up to eight exact-version NuGet packages. Website libraries use explicit HTTPS URLs in the PRD/contract.");
    }
}