← All source files

V13 / 7879e8b5740b

ProjectDraftStore.cs

Lilith/Core/Projects/ProjectDraftStore.cs

Unchanged from the preceding version
CS
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using Lilith.Core.Workspace;
using Lilith.Core.Workspace.Extensions;

namespace Lilith.Core.Projects;

/// <summary>Persistent private copies. Only the explicit host testing command grants execution.</summary>
public sealed class ProjectDraftStore(WorkspaceSession workspace)
{
    internal WorkspaceSession Workspace => workspace;
    internal string Root => Path.Combine(workspace.Options.RootDirectory, ".lilith", "project-drafts");
    internal string Folder(string id)
    {
        if (!Regex.IsMatch(id, "^[a-f0-9]{32}$")) throw new ArgumentException("Invalid project draft ID.");
        string folder = Path.Combine(Root, id); folder.EnsureNoLinks(); return folder;
    }
    internal static string Hash(ProjectSource[] files) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(ProjectJson.Serialize(files.OrderBy(f => f.Path, StringComparer.Ordinal)))));
    internal static string PlanHash(ProjectTestCase[] tests) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(ProjectJson.Serialize(tests))));
    internal static void SourcePath(string path, string kind)
    {
        if (string.IsNullOrWhiteSpace(path) || path.Length > 100 || path.Contains('\\') || path.StartsWith('/') ||
            path is "lilith-project.json" or "PRD.txt" || path.Split('/').Any(p => !Regex.IsMatch(p, "^[A-Za-z][A-Za-z0-9_.-]*$") || p.StartsWith('.') || p.EndsWith('.') || Regex.IsMatch(p, @"^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\.|$)", RegexOptions.IgnoreCase)) ||
            (kind == "console" ? !path.EndsWith(".cs", StringComparison.Ordinal) : Path.GetExtension(path).ToLowerInvariant() is not (".html" or ".css" or ".js" or ".json" or ".txt")))
            throw new InvalidDataException("Invalid managed source path.");
    }
    internal static void ValidateFiles(ProjectSource[] files, string kind)
    {
        if (kind is not ("website" or "console") || files.Length is < 1 or > 12 || files.Select(f => f.Path).Distinct(StringComparer.OrdinalIgnoreCase).Count() != files.Length ||
            files.Any(f => f.Content is null || f.Content.Length > 16000) || files.Sum(f => f.Content.Length) > 40000) throw new InvalidDataException("Invalid candidate source bundle.");
        foreach (var file in files) SourcePath(file.Path, kind);
        if (kind == "website" && !new[] { "index.html", "styles.css", "app.js" }.All(p => files.Any(f => f.Path == p))) throw new InvalidDataException("Website requires index.html, styles.css and app.js.");
    }
    public ProjectDraft Load(string id)
    {
        string path = Path.Combine(Folder(id), "draft.json");
        path.EnsureNoLinks();
        if (new FileInfo(path).Length > 2000000) throw new InvalidDataException("Oversized draft.");
        var draft = ProjectJson.Parse<ProjectDraft>(File.ReadAllText(path));
        if (draft.SchemaVersion != 1 || draft.Id != id || !Regex.IsMatch(draft.Name, "^[A-Za-z][A-Za-z0-9_-]{0,47}$")) throw new InvalidDataException("Invalid draft identity.");
        ValidateFiles(draft.Files, draft.Kind); ValidateTests(draft.Tests, draft.Kind, allowEmpty: true);
        _ = workspace.Options.ResolvePath(draft.Name);
        if (draft.State == "awaiting_publication" && draft.ProposalId is { } proposal)
        {
            string receipt = workspace.Options.GetMetadataPath("receipts", proposal) + ".json";
            if (File.Exists(receipt)) { draft = draft with { State = "published" }; Save(draft); }
            else if (!File.Exists(workspace.Options.GetMetadataPath("pending", proposal) + ".json")) { draft = draft with { State = "publication_rejected" }; Save(draft); }
        }
        return draft;
    }
    internal void Save(ProjectDraft draft)
    {
        string folder = Folder(draft.Id); Directory.CreateDirectory(folder);
        string temporary = Path.Combine(folder, "draft-" + Guid.NewGuid().ToString("N") + ".tmp");
        File.WriteAllText(temporary, ProjectJson.Serialize(draft)); File.Move(temporary, Path.Combine(folder, "draft.json"), true);
    }
    public object[] List() => Directory.Exists(Root) ? Directory.EnumerateDirectories(Root).Select(Path.GetFileName).OfType<string>().Where(id => Regex.IsMatch(id, "^[a-f0-9]{32}$")).Select(id => { var d = Load(id); return (object)new { d.Id, d.Name, d.State, d.Revision, d.Rounds }; }).ToArray() : [];
    internal ProjectDraft Create(string name, string kind, string prd, string request, ProjectArchitecture architecture, ProjectSource[] files, string? originalSignature, string[] originalPaths)
    {
        if (!Regex.IsMatch(name, "^[A-Za-z][A-Za-z0-9_-]{0,47}$") || request.Length > 16000) throw new ArgumentException("Invalid project name/request.");
        ValidateFiles(files, kind);
        var draft = new ProjectDraft(1, Guid.NewGuid().ToString("N"), name, kind, prd, request, architecture, files, originalSignature, originalPaths, [], 0, "awaiting_test_plan", 0, null);
        Save(draft); return draft;
    }
    internal ProjectDraft Import(string name, string request)
    {
        if (!Regex.IsMatch(name, "^[A-Za-z][A-Za-z0-9_-]{0,47}$")) throw new ArgumentException("Use a project slug.");
        string target = workspace.Options.ResolvePath(name); target.EnsureNoLinks();
        string signature = target.ComputeSignature();
        var manifest = ProjectJson.Parse<ProjectManifest>(File.ReadAllText(Path.Combine(target, "lilith-project.json")));
        var files = new List<ProjectSource>();
        foreach (string path in manifest.Hashes.Keys.Where(p => p is not ("Application.csproj" or "PRD.txt")))
        {
            SourcePath(path, manifest.Kind);
            string full = workspace.Options.ResolvePath(name + "/" + path);
            files.Add(new(path, File.ReadAllText(full)));
        }
        foreach (string path in target.EnumerateEntries(10000, includeHidden: true).Where(File.Exists))
        {
            string relative = Path.GetRelativePath(target, path).Replace('\\', '/');
            if (relative.Split('/').Any(p => p.StartsWith('.') || p is "bin" or "obj") || relative is "Application.csproj" or "PRD.txt" or "lilith-project.json") continue;
            bool source = manifest.Kind == "console" ? relative.EndsWith(".cs", StringComparison.Ordinal) : Path.GetExtension(relative) is ".html" or ".css" or ".js" or ".json" or ".txt";
            if (source && files.All(f => !string.Equals(f.Path, relative, StringComparison.OrdinalIgnoreCase))) { SourcePath(relative, manifest.Kind); files.Add(new(relative, File.ReadAllText(path))); }
        }
        // Import actual current contents (including user edits), then protect that exact baseline.
        return Create(name, manifest.Kind, manifest.Prd, request, new(manifest.Contract, manifest.Architecture, manifest.Packages), files.ToArray(), signature, manifest.Hashes.Keys.Concat(files.Select(f => f.Path)).Append("lilith-project.json").Distinct(StringComparer.OrdinalIgnoreCase).ToArray());
    }
    internal ProjectDraft Apply(string id, string json)
    {
        var draft = Load(id);
        if (draft.State is "published" or "awaiting_publication") throw new InvalidOperationException("Start a new edit session after publication preparation.");
        var patch = ProjectJson.Parse<ProjectEditSet>(json);
        if (patch.Changes is null || patch.Changes.Length > 20) throw new InvalidDataException("Use at most 20 source edits.");
        var files = draft.Files.ToDictionary(f => f.Path, f => f.Content, StringComparer.Ordinal);
        foreach (var change in patch.Changes)
        {
            SourcePath(change.Path, draft.Kind);
            switch (change.Action)
            {
                case "write": if (!files.ContainsKey(change.Path) || change.Content is null) throw new InvalidDataException("Write requires an existing file and its complete new content."); files[change.Path] = change.Content; break;
                case "add": if (files.ContainsKey(change.Path) || change.Content is null) throw new InvalidDataException("Add requires a new file and complete content."); files.Add(change.Path, change.Content); break;
                case "replace":
                    if (!files.TryGetValue(change.Path, out var source) || string.IsNullOrEmpty(change.Old) || change.Content is null) throw new InvalidDataException("Replace requires current exact old text and new content.");
                    int at = source.IndexOf(change.Old, StringComparison.Ordinal);
                    if (at < 0 || source.IndexOf(change.Old, at + 1, StringComparison.Ordinal) >= 0) throw new InvalidDataException("Old text must match exactly once.");
                    files[change.Path] = source[..at] + change.Content + source[(at + change.Old.Length)..]; break;
                case "delete": if (!files.Remove(change.Path)) throw new InvalidDataException("Delete requires an existing managed file."); break;
                case "rename":
                    SourcePath(change.Destination ?? "", draft.Kind);
                    if (!files.Remove(change.Path, out var renamed) || !files.TryAdd(change.Destination!, renamed)) throw new InvalidDataException("Invalid rename."); break;
                default: throw new InvalidDataException("Unknown source edit action.");
            }
        }
        var candidate = files.Select(f => new ProjectSource(f.Key, f.Value)).ToArray(); ValidateFiles(candidate, draft.Kind);
        File.WriteAllText(Path.Combine(Folder(id), $"revision-{draft.Revision}.json"), ProjectJson.Serialize(draft));
        draft = draft with { Files = candidate, Revision = draft.Revision + 1, State = draft.Tests.Length == 0 ? "awaiting_test_plan" : "awaiting_test_approval", Runtime = null, SourceReview = "Not reviewed" };
        Save(draft); return draft;
    }
    internal object[] Backups(string name)
    {
        if (!Regex.IsMatch(name, "^[A-Za-z][A-Za-z0-9_-]{0,47}$")) throw new ArgumentException("Use a project slug.");
        string root = Path.Combine(workspace.Options.RootDirectory, ".lilith", "project-backups"); root.EnsureNoLinks();
        if (!Directory.Exists(root)) return [];
        return Directory.EnumerateDirectories(root).Select(Path.GetFileName).OfType<string>().Where(id => Regex.IsMatch(id, "^[a-f0-9]{32}$")).Where(id =>
        {
            string receipt = workspace.Options.GetMetadataPath("receipts", id) + ".json";
            if (!File.Exists(receipt)) return false;
            var result = ProjectJson.Parse<System.Text.Json.JsonElement>(File.ReadAllText(receipt));
            return result.TryGetProperty("Data", out var data) && data.TryGetProperty("path", out var path) && path.GetString() == name;
        }).Select(id => (object)new { id, name }).ToArray();
    }
    internal ProjectDraft Restore(string name, string backupId)
    {
        if (!Backups(name).Any(item => System.Text.Json.JsonSerializer.SerializeToElement(item).GetProperty("id").GetString() == backupId)) throw new IOException("No retained backup for this project.");
        string backup = workspace.Options.GetMetadataPath("project-backups", backupId);
        var manifest = ProjectJson.Parse<ProjectManifest>(File.ReadAllText(Path.Combine(backup, "lilith-project.json")));
        var current = Import(name, "Restore managed source from backup " + backupId + ". Preserve current unmanaged files.");
        var sources = manifest.Hashes.Keys.Where(p => p is not ("Application.csproj" or "PRD.txt")).Select(path =>
        {
            SourcePath(path, manifest.Kind); string full = Path.Combine(backup, path); full.EnsureNoLinks(); return new ProjectSource(path, File.ReadAllText(full));
        }).ToArray();
        ValidateFiles(sources, current.Kind);
        current = current with { Files = sources, Prd = manifest.Prd, Architecture = new(manifest.Contract, manifest.Architecture, manifest.Packages) }; Save(current); return current;
    }
    internal ProjectDraft AddTests(string id, string json)
    {
        var draft = Load(id); var plan = ProjectJson.Parse<ProjectTestPlan>(json);
        if (draft.State is "published" or "awaiting_publication") throw new InvalidOperationException("Start a new edit session after publication preparation.");
        ValidateTests(plan.Cases, draft.Kind, allowEmpty: draft.Tests.Length > 0);
        var tests = draft.Tests.ToDictionary(t => t.Id, StringComparer.Ordinal);
        foreach (var test in plan.Cases)
        {
            if (tests.TryGetValue(test.Id, out var old) && ProjectJson.Serialize(old) != ProjectJson.Serialize(test)) throw new InvalidDataException("Existing test cases cannot be changed or weakened; append new IDs.");
            tests[test.Id] = test;
        }
        ValidateTests(tests.Values.ToArray(), draft.Kind);
        draft = draft with { Tests = tests.Values.ToArray(), State = "awaiting_test_approval", Runtime = null }; Save(draft); return draft;
    }
    internal static void ValidateTests(ProjectTestCase[] tests, string kind, bool allowEmpty = false)
    {
        if (tests is null || tests.Any(t => t is null) || tests.Length > 40 || !allowEmpty && tests.Length == 0 || tests.Select(t => t.Id).Distinct(StringComparer.Ordinal).Count() != tests.Length || ProjectJson.Serialize(tests).Length > 64000) throw new InvalidDataException("Use 1-40 distinct tests within 64000 serialized characters.");
        foreach (var test in tests)
        {
            if (string.IsNullOrWhiteSpace(test.Id) || !Regex.IsMatch(test.Id, "^[A-Za-z][A-Za-z0-9_-]{0,63}$") || string.IsNullOrWhiteSpace(test.Requirement) || test.Requirement.Length > 1000 || test.Stdin is null || test.Stdin.Length > 8000) throw new InvalidDataException("Invalid test identity/requirement/input.");
            if (kind == "console") { if ((test.Contains?.Length ?? 0) + (test.NotContains?.Length ?? 0) == 0 || (test.Contains ?? []).Concat(test.NotContains ?? []).Any(s => string.IsNullOrEmpty(s) || s.Length > 2000)) throw new InvalidDataException("Console tests require output assertions."); }
            else
            {
                if (test.Actions is null || test.Actions.Length is < 1 or > 40 || !test.Actions.Any(a => a.Type is "assert_text" or "assert_visible" or "assert_js")) throw new InvalidDataException("Browser cases require bounded actions and assertions.");
                foreach (var action in test.Actions)
                {
                    if (action.Type is not ("click" or "key" or "type" or "wait" or "viewport" or "script" or "assert_text" or "assert_visible" or "assert_js") || action.Value.Length > 4000 || action.Selector.Length > 200 || action.Type == "viewport" && (action.Width is < 320 or > 1920 || action.Height is < 320 or > 1920)) throw new InvalidDataException("Invalid browser action.");
                    if ((action.Type is "click" or "type" or "assert_text" or "assert_visible") && string.IsNullOrWhiteSpace(action.Selector)) throw new InvalidDataException("This browser action requires a CSS selector.");
                    if ((action.Type is "assert_text" or "assert_js" or "script" or "key") && string.IsNullOrWhiteSpace(action.Value)) throw new InvalidDataException("This action requires a nonempty value/assertion; an empty expected string cannot pass a test.");
                    if (action.Type == "wait" && (!int.TryParse(action.Value, out int ms) || ms is < 0 or > 1000)) throw new InvalidDataException("Wait requires 0-1000 milliseconds.");
                }
            }
        }
    }
    internal object Inspect(string id)
    {
        string Clip(string text, int max) => text.Length <= max ? text : text[..max] + " [see full draft evidence]";
        var d = Load(id); return new { d.Id, d.Name, d.Kind, d.Prd, d.Request, d.State, d.Revision, d.Rounds,
            tests = d.Tests.Select(t => new { t.Id, requirement = Clip(t.Requirement, 200) }),
            runtime = d.Runtime is null ? null : new { d.Runtime.Passed, d.Runtime.Evidence, errors = d.Runtime.Errors.Take(3).Select(e => Clip(e, 500)), cases = d.Runtime.Cases.Select(c => new { c.Id, c.Status }) }, diagnostics = Clip(d.SourceReview, 2000),
            editAttempts = new[] { new { attempt = 1 }, new { attempt = 2 }, new { attempt = 3 } },
            sourceHash = Hash(d.Files), planHash = PlanHash(d.Tests), executionApproval = "Use /project-test " + id + ". This executes this project and its bounded repair revisions; workspace approval is separate." };
    }
}