V13 / 7879e8b5740b
ProjectDraftStore.cs
Lilith/Core/Projects/ProjectDraftStore.cs
Unchanged from the preceding versionCS
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." };
}
}