V11 / ca6f28eff240
ProjectGeneration.cs
Lilith/Core/Projects/ProjectGeneration.cs
Changed in this versionCS
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.");
}
}