Modules/businessdev.ALbuild.Apps/Resources/CallGraph/CallGraphArchitecture.cs

// Architecture layer of the call graph: parts, descriptions, flows and tour from callgraph.architecture.json,
// the folder-based fallback, the scaffold without AI, the agent lookups and the worksheets.
// Compiled together with CallGraphEngine.cs; C# 5 like it (Windows PowerShell 5.1).
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
 
namespace ALbuild.CallGraph
{
    /// <summary>Glob matching: ** any directories, * within a segment, ? one character, {a,b} alternation.</summary>
    public static class Glob
    {
        static readonly Dictionary<string, Regex> Cache = new Dictionary<string, Regex>(StringComparer.Ordinal);
 
        public static Regex ToRegex(string glob)
        {
            Regex rx;
            if (Cache.TryGetValue(glob, out rx)) return rx;
            string g = glob.Replace('\\', '/').Trim();
            // A pattern without any wildcard also matches as a folder.
            if (g.IndexOfAny(new[] { '*', '?', '{' }) < 0) g = g.TrimEnd('/') + "{,/**}";
            rx = new Regex("^" + Pattern(g) + "$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
            Cache[glob] = rx;
            return rx;
        }
 
        /// <summary>The regex body of a glob; alternatives are translated as they are, without the folder rule.</summary>
        static string Pattern(string g)
        {
            var sb = new StringBuilder();
            for (int i = 0; i < g.Length; i++)
            {
                char c = g[i];
                if (c == '*')
                {
                    if (i + 1 < g.Length && g[i + 1] == '*')
                    {
                        bool slash = i + 2 < g.Length && g[i + 2] == '/';
                        sb.Append(slash ? "(?:.*/)?" : ".*");
                        i += slash ? 2 : 1;
                    }
                    else sb.Append("[^/]*");
                }
                else if (c == '?') sb.Append("[^/]");
                else if (c == '{')
                {
                    int end = g.IndexOf('}', i);
                    if (end < 0) { sb.Append("\\{"); continue; }
                    var alts = g.Substring(i + 1, end - i - 1).Split(',');
                    sb.Append("(?:").Append(string.Join("|", alts.Select(Pattern))).Append(')');
                    i = end;
                }
                else sb.Append(Regex.Escape(c.ToString()));
            }
            return sb.ToString();
        }
 
        public static bool IsMatch(string glob, string text)
        {
            return text != null && ToRegex(glob).IsMatch(text.Replace('\\', '/'));
        }
 
        /// <summary>The literal characters before the first wildcard: the more, the more specific.</summary>
        public static int LiteralPrefix(string glob)
        {
            int i = glob.IndexOfAny(new[] { '*', '?', '{' });
            return i < 0 ? glob.Length : i;
        }
    }
 
    public class Part
    {
        public string Id;
        public string Label;
        public string Kind;
        public string ParentId;
        public string Summary;
        public List<string> Paths = new List<string>();
        public List<string> Objects = new List<string>();
        public int Depth;
    }
 
    public class FlowStep
    {
        public string Node;
        public string Title;
        public string Note;
    }
 
    public class Flow
    {
        public string Id;
        public string Title;
        public string Summary;
        public List<FlowStep> Steps = new List<FlowStep>();
    }
 
    /// <summary>One own object as the architecture layer sees it.</summary>
    public class ObjectInfo
    {
        public string Key; // node id prefix "al . {app} / {type} {id} {name}"
        public string Type;
        public int Id;
        public string Name;
        public string App;
        public string Path; // relative to the workspace root, '/' separated
        public int Line;
        public string Label { get { return Type + " " + Name; } }
    }
 
    /// <summary>
    /// The architecture of a workspace: from callgraph.architecture.json when there is one, otherwise derived from
    /// the folder structure. Assigns every own object to the deepest part that claims it.
    /// </summary>
    public class Architecture
    {
        public const string FileName = "callgraph.architecture.json";
        public const string Schema = "albuild/callgraph-architecture@1";
 
        public string Source = "fallback"; // file | fallback
        public string ProjectName;
        public string ProjectSummary;
        public List<Part> Parts = new List<Part>();
        public Dictionary<string, string> Descriptions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        public List<Flow> Flows = new List<Flow>();
        public List<FlowStep> Tour = new List<FlowStep>();
        public List<string> Warnings = new List<string>();
        public Dictionary<string, string> Assignment = new Dictionary<string, string>(StringComparer.Ordinal); // object key -> part id
        public List<ObjectInfo> Objects = new List<ObjectInfo>();
 
        static readonly string[] Kinds = { "app", "feature", "service", "integration", "data", "ui", "setup", "library", "test", "external" };
 
        // ---------- objects ----------
 
        public static List<ObjectInfo> OwnObjects(Graph g, HashSet<string> own, string workspaceRoot)
        {
            var list = new List<ObjectInfo>();
            foreach (var n in g.Nodes.Values)
            {
                if (!own.Contains(n.App) || (n.Kind != "Object" && n.Kind != "Table")) continue;
                var o = new ObjectInfo();
                o.Key = n.ObjectKey;
                o.Type = n.ObjectType;
                o.Id = n.ObjectId;
                o.Name = n.ObjectName;
                o.App = n.App;
                o.Path = Relative(workspaceRoot, n.SourcePath);
                o.Line = n.Line;
                list.Add(o);
            }
            return list.OrderBy(o => o.Path ?? "", StringComparer.OrdinalIgnoreCase).ThenBy(o => o.Line).ToList();
        }
 
        public static string Relative(string root, string path)
        {
            if (string.IsNullOrEmpty(path)) return path;
            string p = path.Replace('\\', '/');
            if (string.IsNullOrEmpty(root)) return p;
            string r = root.Replace('\\', '/').TrimEnd('/') + "/";
            return p.StartsWith(r, StringComparison.OrdinalIgnoreCase) ? p.Substring(r.Length) : p;
        }
 
        // ---------- loading ----------
 
        static string Str(Dictionary<string, object> d, string key)
        {
            object v;
            return d != null && d.TryGetValue(key, out v) && v != null ? Convert.ToString(v, CultureInfo.InvariantCulture) : null;
        }
 
        static List<string> StrList(Dictionary<string, object> d, string key)
        {
            object v;
            var result = new List<string>();
            if (d == null || !d.TryGetValue(key, out v) || v == null) return result;
            var list = v as List<object>;
            if (list == null) { result.Add(Convert.ToString(v, CultureInfo.InvariantCulture)); return result; }
            foreach (var item in list) if (item != null) result.Add(Convert.ToString(item, CultureInfo.InvariantCulture));
            return result;
        }
 
        static List<Dictionary<string, object>> ObjList(Dictionary<string, object> d, string key)
        {
            object v;
            var result = new List<Dictionary<string, object>>();
            if (d == null || !d.TryGetValue(key, out v)) return result;
            var list = v as List<object>;
            if (list == null) return result;
            foreach (var item in list) { var o = item as Dictionary<string, object>; if (o != null) result.Add(o); }
            return result;
        }
 
        static List<FlowStep> Steps(List<Dictionary<string, object>> items)
        {
            return items.Select(s => new FlowStep { Node = Str(s, "node"), Title = Str(s, "title"), Note = Str(s, "note") }).ToList();
        }
 
        public static Architecture Load(string path)
        {
            var a = new Architecture();
            a.Source = "file";
            Dictionary<string, object> doc;
            try { doc = Json.Parse(File.ReadAllText(path)) as Dictionary<string, object>; }
            // The parser runs off the end of a truncated document instead of naming it.
            catch (IndexOutOfRangeException) { throw new FormatException(FileName + " is not valid JSON: it ends too early (a bracket, brace or quote is not closed)."); }
            catch (Exception ex) { throw new FormatException(FileName + " is not valid JSON: " + ex.Message); }
            if (doc == null) throw new FormatException(FileName + " must be a JSON object.");
            object project;
            if (doc.TryGetValue("project", out project))
            {
                a.ProjectName = Str(project as Dictionary<string, object>, "name");
                a.ProjectSummary = Str(project as Dictionary<string, object>, "summary");
            }
            foreach (var p in ObjList(doc, "parts"))
            {
                var part = new Part();
                part.Id = Str(p, "id");
                part.Label = Str(p, "label") ?? part.Id;
                part.Kind = Str(p, "kind") ?? "feature";
                part.ParentId = Str(p, "parent");
                part.Summary = Str(p, "summary");
                part.Paths = StrList(p, "paths");
                part.Objects = StrList(p, "objects");
                a.Parts.Add(part);
            }
            // A wrong shape is reported, not skipped: silently dropping a section reads as "nothing to describe".
            foreach (var key in new[] { "parts", "flows", "tour" })
            {
                object section;
                if (doc.TryGetValue(key, out section) && section != null && !(section is List<object>))
                    throw new FormatException(FileName + ": '" + key + "' must be a JSON array.");
            }
            object descriptions;
            if (doc.TryGetValue("objects", out descriptions) && descriptions != null)
            {
                var map = descriptions as Dictionary<string, object>;
                if (map == null) throw new FormatException(FileName + ": 'objects' must be a JSON object of \"Type Name\": \"description\".");
                foreach (var kv in map)
                    if (kv.Value != null) a.Descriptions[kv.Key] = Convert.ToString(kv.Value, CultureInfo.InvariantCulture);
            }
            foreach (var f in ObjList(doc, "flows"))
                a.Flows.Add(new Flow { Id = Str(f, "id"), Title = Str(f, "title") ?? Str(f, "id"), Summary = Str(f, "summary"), Steps = Steps(ObjList(f, "steps")) });
            a.Tour = Steps(ObjList(doc, "tour"));
            return a;
        }
 
        // ---------- the folder-based fallback ----------
 
        static string Slug(string s)
        {
            var sb = new StringBuilder();
            foreach (char c in (s ?? "").ToLowerInvariant())
                sb.Append(char.IsLetterOrDigit(c) ? c : '-');
            return Regex.Replace(sb.ToString(), "-+", "-").Trim('-');
        }
 
        /// <summary>
        /// Parts derived from the folders: one part per first folder level of the app's source (below an optional
        /// src/), split one level further when a folder holds many objects. A repository with a single product app
        /// shows that app's folders on top; test apps get one part each.
        /// </summary>
        public static Architecture Fallback(List<ObjectInfo> objects, IList<AppInfo> apps, HashSet<string> testApps)
        {
            var a = new Architecture();
            a.Source = "fallback";
            var ownApps = apps.Where(x => x.Role == "own").ToList();
            var productApps = ownApps.Where(x => !testApps.Contains((x.Id ?? "").ToLowerInvariant())).ToList();
            bool flat = productApps.Count == 1;
            a.ProjectName = string.Join(", ", productApps.Select(x => x.Name));
            foreach (var app in ownApps)
            {
                string appId = (app.Id ?? "").ToLowerInvariant();
                bool isTest = testApps.Contains(appId);
                var appObjects = objects.Where(o => o.App == appId).ToList();
                if (appObjects.Count == 0) continue;
                string appPart = null;
                string folder = CommonFolder(appObjects.Select(o => o.Path));
                if (isTest || !flat)
                {
                    appPart = Slug(app.Name);
                    a.Parts.Add(new Part { Id = appPart, Label = app.Name, Kind = isTest ? "test" : "app", Summary = null, Paths = new List<string> { folder.Length > 0 ? folder + "/**" : "**" } });
                    if (isTest) continue;
                }
                // Folder levels below the app folder (and below 'src').
                AddFolderParts(a, appObjects, folder, 0, appPart, null);
                // Shown flat, the files at the top of the source would belong to no part: they get the app's.
                var loose = appObjects.Where(o => !string.IsNullOrEmpty(o.Path) && FolderSegments(o.Path, folder).Count == 0).ToList();
                if (appPart == null && loose.Count > 0)
                    a.Parts.Add(new Part { Id = Slug(app.Name), Label = app.Name, Kind = "app",
                        Paths = loose.Select(o => (o.Path.Contains('/') ? o.Path.Substring(0, o.Path.LastIndexOf('/')) + "/" : "") + "*").Distinct(StringComparer.OrdinalIgnoreCase).ToList() });
            }
            return a;
        }
 
        /// <summary>One part per folder at this level; a folder with many objects is split one level further (up to three).</summary>
        static void AddFolderParts(Architecture a, List<ObjectInfo> objects, string appFolder, int level, string parentId, string parentFolder)
        {
            foreach (var grp in objects.GroupBy(o => FolderSegments(o.Path, appFolder).Skip(level).FirstOrDefault() ?? "").Where(x => x.Key.Length > 0))
            {
                if (level > 0 && grp.Count() < 3) continue; // too small for a part of its own: stays on the parent
                string baseFolder = parentFolder != null ? parentFolder + "/" + grp.Key : Prefix(grp.First().Path, appFolder, grp.Key);
                string id = (parentId != null ? parentId + "." : "") + Slug(grp.Key);
                a.Parts.Add(new Part { Id = id, Label = Pretty(grp.Key), Kind = "feature", ParentId = parentId, Paths = new List<string> { baseFolder + "/**" } });
                if (grp.Count() > 60 && level < 2) AddFolderParts(a, grp.ToList(), appFolder, level + 1, id, baseFolder);
            }
        }
 
        static string CommonFolder(IEnumerable<string> paths)
        {
            string common = null;
            foreach (var p in paths.Where(x => !string.IsNullOrEmpty(x)))
            {
                string dir = p.Contains('/') ? p.Substring(0, p.LastIndexOf('/')) : "";
                if (common == null) { common = dir; continue; }
                while (common.Length > 0 && !(dir + "/").StartsWith(common + "/", StringComparison.OrdinalIgnoreCase))
                    common = common.Contains('/') ? common.Substring(0, common.LastIndexOf('/')) : "";
            }
            return common ?? "";
        }
 
        /// <summary>The folders of a path below the app folder, without a leading 'src'.</summary>
        static List<string> FolderSegments(string path, string appFolder)
        {
            string rel = path ?? "";
            if (appFolder.Length > 0 && rel.StartsWith(appFolder + "/", StringComparison.OrdinalIgnoreCase)) rel = rel.Substring(appFolder.Length + 1);
            var segs = rel.Split('/').ToList();
            segs.RemoveAt(segs.Count - 1); // the file
            if (segs.Count > 0 && segs[0].Equals("src", StringComparison.OrdinalIgnoreCase)) segs.RemoveAt(0);
            return segs;
        }
 
        static string Prefix(string path, string appFolder, string segment)
        {
            string p = path;
            int i = p.IndexOf("/" + segment + "/", StringComparison.OrdinalIgnoreCase);
            if (p.StartsWith(segment + "/", StringComparison.OrdinalIgnoreCase)) return segment;
            return i < 0 ? appFolder + "/" + segment : p.Substring(0, i + 1 + segment.Length);
        }
 
        static string Pretty(string folder)
        {
            string s = Regex.Replace(folder, "([a-z])([A-Z])", "$1 $2").Replace('_', ' ').Replace('-', ' ');
            return s.Length > 0 ? char.ToUpperInvariant(s[0]) + s.Substring(1) : s;
        }
 
        // ---------- assignment and validation ----------
 
        public void Assign(List<ObjectInfo> objects)
        {
            Objects = objects;
            var byId = Parts.Where(p => p.Id != null).GroupBy(p => p.Id).ToDictionary(x => x.Key, x => x.First(), StringComparer.Ordinal);
            foreach (var p in Parts)
            {
                int depth = 0;
                var cur = p;
                var seen = new HashSet<string>(StringComparer.Ordinal);
                while (cur.ParentId != null && byId.ContainsKey(cur.ParentId) && seen.Add(cur.Id)) { depth++; cur = byId[cur.ParentId]; }
                p.Depth = depth;
            }
            Assignment.Clear();
            foreach (var o in objects)
            {
                Part best = null;
                int bestPrefix = -1;
                foreach (var p in Parts)
                {
                    int prefix = -1;
                    foreach (var g in p.Paths) if (Glob.IsMatch(g, o.Path)) prefix = Math.Max(prefix, Glob.LiteralPrefix(g));
                    foreach (var g in p.Objects) if (Glob.IsMatch(g, o.Label) || Glob.IsMatch(g, o.Type + " " + o.Id)) prefix = Math.Max(prefix, 1000 + Glob.LiteralPrefix(g));
                    if (prefix < 0) continue;
                    if (best == null || p.Depth > best.Depth || (p.Depth == best.Depth && prefix > bestPrefix)) { best = p; bestPrefix = prefix; }
                }
                if (best != null) Assignment[o.Key] = best.Id;
            }
        }
 
        public void Validate(Dictionary<string, HashSet<string>> objectEdges)
        {
            var ids = new HashSet<string>(StringComparer.Ordinal);
            foreach (var p in Parts)
            {
                if (string.IsNullOrEmpty(p.Id)) { Warnings.Add("A part has no id."); continue; }
                if (!ids.Add(p.Id)) Warnings.Add("Part '" + p.Id + "' is defined twice.");
                if (Array.IndexOf(Kinds, p.Kind) < 0) Warnings.Add("Part '" + p.Id + "': unknown kind '" + p.Kind + "' (use " + string.Join(", ", Kinds) + ").");
                if (p.Paths.Count == 0 && p.Objects.Count == 0 && !Parts.Any(c => c.ParentId == p.Id) && p.Kind != "external")
                    Warnings.Add("Part '" + p.Id + "' claims no objects (no paths or objects patterns, no children).");
            }
            foreach (var p in Parts.Where(x => x.ParentId != null && !ids.Contains(x.ParentId)))
                Warnings.Add("Part '" + p.Id + "': parent '" + p.ParentId + "' does not exist" + Suggest(p.ParentId, ids) + ".");
            var counts = Assignment.Values.GroupBy(v => v).ToDictionary(x => x.Key, x => x.Count());
            foreach (var p in Parts.Where(x => (x.Paths.Count > 0 || x.Objects.Count > 0) && !counts.ContainsKey(x.Id) && !Parts.Any(c => c.ParentId == x.Id)))
                Warnings.Add("Part '" + p.Id + "': its patterns match no object.");
            var labels = new HashSet<string>(Objects.Select(o => o.Label), StringComparer.OrdinalIgnoreCase);
            foreach (var key in Descriptions.Keys.Where(k => !labels.Contains(k)))
                Warnings.Add("Description for unknown object '" + key + "'" + Suggest(key, labels) + ".");
            var nodes = new HashSet<string>(ids.Concat(labels), StringComparer.OrdinalIgnoreCase);
            foreach (var flow in Flows)
            {
                for (int i = 0; i < flow.Steps.Count; i++)
                {
                    var set = Resolve(flow.Steps[i].Node);
                    if (set == null) { Warnings.Add("Flow '" + flow.Id + "', step " + (i + 1) + ": unknown node '" + flow.Steps[i].Node + "'" + Suggest(flow.Steps[i].Node ?? "", nodes) + "."); continue; }
                    if (i == 0) continue;
                    var prev = Resolve(flow.Steps[i - 1].Node);
                    if (prev != null && !Connected(prev, set, objectEdges))
                        Warnings.Add("Flow '" + flow.Id + "', step " + i + " -> " + (i + 1) + ": nothing in '" + flow.Steps[i - 1].Node + "' calls '" + flow.Steps[i].Node + "'.");
                }
            }
            foreach (var t in Tour.Where(t => Resolve(t.Node) == null))
                Warnings.Add("Tour stop '" + t.Node + "' is neither a part nor an object" + Suggest(t.Node ?? "", nodes) + ".");
        }
 
        static string Suggest(string wrong, IEnumerable<string> candidates)
        {
            string best = null;
            int bestDistance = int.MaxValue;
            foreach (var c in candidates)
            {
                int d = Distance(wrong.ToLowerInvariant(), c.ToLowerInvariant());
                if (d < bestDistance) { bestDistance = d; best = c; }
            }
            return best != null && bestDistance <= Math.Max(2, wrong.Length / 3) ? " (did you mean '" + best + "'?)" : "";
        }
 
        static int Distance(string a, string b)
        {
            var d = new int[a.Length + 1, b.Length + 1];
            for (int i = 0; i <= a.Length; i++) d[i, 0] = i;
            for (int j = 0; j <= b.Length; j++) d[0, j] = j;
            for (int i = 1; i <= a.Length; i++)
                for (int j = 1; j <= b.Length; j++)
                    d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), d[i - 1, j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1));
            return d[a.Length, b.Length];
        }
 
        static bool Connected(HashSet<string> from, HashSet<string> to, Dictionary<string, HashSet<string>> edges)
        {
            foreach (var f in from)
            {
                HashSet<string> targets;
                if (edges.TryGetValue(f, out targets) && targets.Overlaps(to)) return true;
            }
            return false;
        }
 
        /// <summary>The object keys a node reference stands for: a part (with its descendants) or one object.</summary>
        public HashSet<string> Resolve(string node)
        {
            if (string.IsNullOrEmpty(node)) return null;
            var part = Parts.FirstOrDefault(p => string.Equals(p.Id, node, StringComparison.OrdinalIgnoreCase));
            if (part != null)
            {
                var ids = new HashSet<string>(Descendants(part.Id), StringComparer.Ordinal);
                return new HashSet<string>(Assignment.Where(kv => ids.Contains(kv.Value)).Select(kv => kv.Key), StringComparer.Ordinal);
            }
            var obj = Objects.FirstOrDefault(o => string.Equals(o.Label, node, StringComparison.OrdinalIgnoreCase)
                || string.Equals(o.Type + " " + o.Id, node, StringComparison.OrdinalIgnoreCase));
            return obj == null ? null : new HashSet<string>(new[] { obj.Key }, StringComparer.Ordinal);
        }
 
        public IEnumerable<string> Descendants(string id)
        {
            yield return id;
            foreach (var c in Parts.Where(p => p.ParentId == id))
                foreach (var d in Descendants(c.Id)) yield return d;
        }
 
        // ---------- the scaffold without AI ----------
 
        static readonly Regex SummaryTag = new Regex(@"</?para>|<see cref=""([^""]*)""\s*/>|<[^>]+>", RegexOptions.IgnoreCase);
        static readonly Regex SummaryBlock = new Regex(@"<summary>(.*?)</summary>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
 
        /// <summary>The /// summary directly above an object declaration, as one line of text.</summary>
        public static string DocComment(string[] lines, int line)
        {
            if (lines == null || line < 2 || line > lines.Length) return null;
            var parts = new List<string>();
            for (int i = line - 2; i >= 0; i--)
            {
                string t = lines[i].Trim();
                if (t.StartsWith("///", StringComparison.Ordinal)) { parts.Insert(0, t.Substring(3).Trim()); continue; }
                if (t.StartsWith("[", StringComparison.Ordinal) || t.Length == 0) { if (parts.Count == 0) continue; }
                break;
            }
            // The <summary> only: <remarks> and the rest are for readers of the code, not for the map.
            string raw = string.Join(" ", parts);
            var summary = SummaryBlock.Match(raw);
            if (summary.Success) raw = summary.Groups[1].Value;
            string text = SummaryTag.Replace(raw, m => m.Groups[1].Success ? m.Groups[1].Value : "");
            text = Regex.Replace(text, @"\s+", " ").Trim();
            return text.Length == 0 ? null : text;
        }
 
        /// <summary>The /// summary of every object that has one, by "Type Name".</summary>
        public static Dictionary<string, string> SourceDescriptions(IEnumerable<ObjectInfo> objects, string workspaceRoot)
        {
            var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
            var files = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
            foreach (var o in objects)
            {
                if (string.IsNullOrEmpty(o.Path)) continue;
                string full = string.IsNullOrEmpty(workspaceRoot) ? o.Path : Path.Combine(workspaceRoot, o.Path);
                string[] lines;
                if (!files.TryGetValue(full, out lines))
                {
                    try { lines = File.ReadAllLines(full); } catch (IOException) { lines = null; } catch (UnauthorizedAccessException) { lines = null; }
                    files[full] = lines;
                }
                string text = DocComment(lines, o.Line);
                if (text != null) result[o.Label] = text;
            }
            return result;
        }
 
        /// <summary>
        /// A starting callgraph.architecture.json without AI: the folder-based parts. Descriptions are not copied:
        /// every object's /// summary applies anyway, so "objects" is only for objects without one, or to override it.
        /// Flows and tour are left empty for a person (or an agent) to fill.
        /// </summary>
        public static Dictionary<string, object> Scaffold(Architecture fallback, string workspaceRoot)
        {
            var doc = new Dictionary<string, object>();
            doc["$schema"] = Schema;
            doc["project"] = Obj("name", fallback.ProjectName, "summary", "");
            doc["parts"] = fallback.Parts.Select(p =>
            {
                var d = new Dictionary<string, object>();
                d["id"] = p.Id;
                d["label"] = p.Label;
                d["kind"] = p.Kind;
                if (p.ParentId != null) d["parent"] = p.ParentId;
                d["summary"] = p.Summary ?? "";
                d["paths"] = p.Paths.Cast<object>().ToList();
                return (object)d;
            }).ToList();
            doc["objects"] = new SortedDictionary<string, object>(StringComparer.OrdinalIgnoreCase);
            doc["flows"] = new List<object>();
            doc["tour"] = new List<object>();
            return doc;
        }
 
        static Dictionary<string, object> Obj(params object[] pairs)
        {
            var d = new Dictionary<string, object>();
            for (int i = 0; i + 1 < pairs.Length; i += 2) d[(string)pairs[i]] = pairs[i + 1];
            return d;
        }
    }
 
    /// <summary>Lookups for agents and people: overview, find, show, path.</summary>
    public class Explorer
    {
        readonly Graph g;
        readonly Architecture arch;
        readonly HashSet<string> own;
        readonly Dictionary<string, ObjectInfo> objects;
        readonly Dictionary<string, Dictionary<string, int>> objectCalls = new Dictionary<string, Dictionary<string, int>>(StringComparer.Ordinal);
        readonly Dictionary<string, Dictionary<string, int>> objectCallers = new Dictionary<string, Dictionary<string, int>>(StringComparer.Ordinal);
 
        public Explorer(Graph graph, Architecture architecture, HashSet<string> ownApps)
        {
            g = graph;
            arch = architecture;
            own = ownApps;
            objects = arch.Objects.ToDictionary(o => o.Key, StringComparer.Ordinal);
            foreach (var e in g.Edges)
            {
                if (Graph.AppOf(e.Callee) == Graph.BuiltInApp) continue;
                string a = Graph.ObjectKeyOf(e.Caller), b = Graph.ObjectKeyOf(e.Callee);
                if (a == b) continue;
                Count(objectCalls, a, b);
                Count(objectCallers, b, a);
            }
        }
 
        static void Count(Dictionary<string, Dictionary<string, int>> map, string a, string b)
        {
            Dictionary<string, int> inner;
            if (!map.TryGetValue(a, out inner)) map[a] = inner = new Dictionary<string, int>(StringComparer.Ordinal);
            int n;
            inner.TryGetValue(b, out n);
            inner[b] = n + 1;
        }
 
        public Dictionary<string, HashSet<string>> ObjectEdges()
        {
            return objectCalls.ToDictionary(kv => kv.Key, kv => new HashSet<string>(kv.Value.Keys, StringComparer.Ordinal), StringComparer.Ordinal);
        }
 
        static Dictionary<string, object> Obj(params object[] pairs)
        {
            var d = new Dictionary<string, object>();
            for (int i = 0; i + 1 < pairs.Length; i += 2) d[(string)pairs[i]] = pairs[i + 1];
            return d;
        }
 
        string LabelOf(string objectKey)
        {
            ObjectInfo o;
            if (objects.TryGetValue(objectKey, out o)) return o.Label;
            string type = Graph.ObjectTypeOf(objectKey);
            string rest = objectKey.Substring(objectKey.IndexOf(" / ", StringComparison.Ordinal) + 3 + type.Length).Trim();
            int space = rest.IndexOf(' ');
            return type + " " + (space > 0 ? rest.Substring(space + 1) : rest);
        }
 
        string PartOf(string objectKey)
        {
            string p;
            return arch.Assignment.TryGetValue(objectKey, out p) ? p : (objects.ContainsKey(objectKey) ? "(unassigned)" : "(dependency)");
        }
 
        string Describe(string label)
        {
            string d;
            return arch.Descriptions.TryGetValue(label, out d) ? d : null;
        }
 
        Dictionary<string, object> ObjectRef(string key)
        {
            ObjectInfo o;
            objects.TryGetValue(key, out o);
            string label = LabelOf(key);
            return Obj("kind", "object", "id", label, "part", PartOf(key), "path", o != null ? o.Path : null, "line", o != null ? o.Line : 0, "description", Describe(label));
        }
 
        public Dictionary<string, object> Overview()
        {
            var partEdges = new Dictionary<string, int>(StringComparer.Ordinal);
            foreach (var kv in objectCalls)
                foreach (var t in kv.Value)
                {
                    string a = TopPart(kv.Key), b = TopPart(t.Key);
                    if (a == null || b == null || a == b) continue;
                    int n; partEdges.TryGetValue(a + " -> " + b, out n); partEdges[a + " -> " + b] = n + t.Value;
                }
            return Obj(
                "project", arch.ProjectName, "summary", arch.ProjectSummary, "architecture", arch.Source,
                "objects", arch.Objects.Count, "unassigned", arch.Objects.Count(o => !arch.Assignment.ContainsKey(o.Key)),
                "parts", arch.Parts.Select(p => (object)Obj("id", p.Id, "label", p.Label, "kind", p.Kind, "parent", p.ParentId, "summary", p.Summary,
                    "objects", arch.Assignment.Values.Count(v => arch.Descendants(p.Id).Contains(v)))).ToList(),
                "connections", partEdges.OrderByDescending(kv => kv.Value).Take(40).Select(kv => (object)Obj("between", kv.Key, "calls", kv.Value)).ToList(),
                "flows", arch.Flows.Select(f => (object)Obj("id", f.Id, "title", f.Title, "steps", f.Steps.Count)).ToList());
        }
 
        string TopPart(string objectKey)
        {
            string p;
            if (arch.Assignment.TryGetValue(objectKey, out p))
            {
                var part = arch.Parts.FirstOrDefault(x => x.Id == p);
                while (part != null && part.ParentId != null) { var parent = arch.Parts.FirstOrDefault(x => x.Id == part.ParentId); if (parent == null) break; part = parent; }
                return part != null ? part.Id : p;
            }
            return objects.ContainsKey(objectKey) ? "(unassigned)" : "(dependency) " + LabelOf(objectKey);
        }
 
        public List<object> Find(string words, string kind, int limit)
        {
            // PowerShell passes $null to a string parameter as "".
            if (string.IsNullOrEmpty(kind)) kind = null;
            var terms = (words ?? "").ToLowerInvariant().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
            var results = new List<KeyValuePair<int, Dictionary<string, object>>>();
            Func<string, int> score = text =>
            {
                if (string.IsNullOrEmpty(text) || terms.Length == 0) return 0;
                string t = text.ToLowerInvariant();
                return terms.All(w => t.Contains(w)) ? terms.Sum(w => t.Contains(" " + w) || t.StartsWith(w) ? 2 : 1) : 0;
            };
            if (kind == null || kind == "part")
                foreach (var p in arch.Parts)
                {
                    int s = score(p.Id + " " + p.Label) * 3 + score(p.Summary);
                    if (s > 0) results.Add(new KeyValuePair<int, Dictionary<string, object>>(s + 5, Obj("kind", "part", "id", p.Id, "label", p.Label, "summary", p.Summary)));
                }
            if (kind == null || kind == "object")
                foreach (var o in arch.Objects)
                {
                    int s = score(o.Label + " " + o.Id) * 3 + score(Describe(o.Label));
                    if (s > 0) results.Add(new KeyValuePair<int, Dictionary<string, object>>(s + 3, ObjectRef(o.Key)));
                }
            if (kind == null || kind == "member")
                foreach (var n in g.Nodes.Values.Where(x => own.Contains(x.App) && (x.Kind == "Method" || x.Kind == "Trigger")))
                {
                    int s = score(n.Member) * 3;
                    if (s > 0) results.Add(new KeyValuePair<int, Dictionary<string, object>>(s, Obj("kind", "member", "id", LabelOf(n.ObjectKey) + "." + n.Member,
                        "part", PartOf(n.ObjectKey), "path", objects.ContainsKey(n.ObjectKey) ? objects[n.ObjectKey].Path : null, "line", n.Line)));
                }
            return results.OrderByDescending(r => r.Key).Take(Math.Max(1, limit)).Select(r => (object)r.Value).ToList();
        }
 
        /// <summary>A part id, an object ('Codeunit My Mgt' or 'Codeunit 50100') or a member ('Codeunit My Mgt.DoIt').</summary>
        public Dictionary<string, object> Show(string id)
        {
            var part = arch.Parts.FirstOrDefault(p => string.Equals(p.Id, id, StringComparison.OrdinalIgnoreCase));
            if (part != null)
            {
                var keys = arch.Resolve(part.Id);
                return Obj("kind", "part", "id", part.Id, "label", part.Label, "summary", part.Summary, "parent", part.ParentId,
                    "children", arch.Parts.Where(c => c.ParentId == part.Id).Select(c => (object)c.Id).ToList(),
                    "objects", keys.Select(k => (object)LabelOf(k)).OrderBy(x => x).ToList(),
                    "uses", Neighbours(keys, objectCalls), "usedBy", Neighbours(keys, objectCallers));
            }
            string key = FindObject(id);
            if (key != null)
            {
                var r = ObjectRef(key);
                var keys = new HashSet<string>(new[] { key }, StringComparer.Ordinal);
                r["members"] = g.Nodes.Values.Where(n => n.ObjectKey == key && (n.Kind == "Method" || n.Kind == "Trigger")).OrderBy(n => n.Line)
                    .Select(n => (object)Obj("name", n.Member, "kind", n.Kind, "line", n.Line, "access", n.Access)).ToList();
                r["uses"] = Neighbours(keys, objectCalls);
                r["usedBy"] = Neighbours(keys, objectCallers);
                return r;
            }
            int dot = id.LastIndexOf('.');
            if (dot > 0)
            {
                string objectKey = FindObject(id.Substring(0, dot));
                string member = id.Substring(dot + 1);
                var nodes = objectKey == null ? new List<Node>() : g.Nodes.Values.Where(n => n.ObjectKey == objectKey && string.Equals(n.Member, member, StringComparison.OrdinalIgnoreCase)).ToList();
                if (nodes.Count > 0)
                {
                    var ids = new HashSet<string>(nodes.Select(n => n.Id), StringComparer.Ordinal);
                    return Obj("kind", "member", "id", LabelOf(objectKey) + "." + nodes[0].Member, "part", PartOf(objectKey), "overloads", nodes.Count,
                        "lines", nodes.Select(n => (object)n.Line).ToList(),
                        "calls", g.Edges.Where(e => ids.Contains(e.Caller) && Graph.AppOf(e.Callee) != Graph.BuiltInApp).Select(e => MemberLabel(e.Callee) + (e.Kind != "Direct" ? " (" + e.Kind + ")" : "")).Distinct().OrderBy(x => x).Cast<object>().ToList(),
                        "calledBy", g.Edges.Where(e => ids.Contains(e.Callee) && !ids.Contains(e.Caller)).Select(e => MemberLabel(e.Caller)).Distinct().OrderBy(x => x).Cast<object>().ToList());
                }
            }
            return null;
        }
 
        string MemberLabel(string nodeId)
        {
            Node n;
            return g.Nodes.TryGetValue(nodeId, out n) && n.Member != null ? LabelOf(n.ObjectKey) + "." + n.Member : LabelOf(Graph.ObjectKeyOf(nodeId));
        }
 
        List<object> Neighbours(HashSet<string> keys, Dictionary<string, Dictionary<string, int>> map)
        {
            var counts = new Dictionary<string, int>(StringComparer.Ordinal);
            foreach (var k in keys)
            {
                Dictionary<string, int> inner;
                if (!map.TryGetValue(k, out inner)) continue;
                foreach (var t in inner) { if (keys.Contains(t.Key)) continue; int n; counts.TryGetValue(t.Key, out n); counts[t.Key] = n + t.Value; }
            }
            return counts.OrderByDescending(kv => kv.Value).Take(50).Select(kv => (object)Obj("object", LabelOf(kv.Key), "part", PartOf(kv.Key), "calls", kv.Value)).ToList();
        }
 
        string FindObject(string text)
        {
            if (string.IsNullOrEmpty(text)) return null;
            var o = arch.Objects.FirstOrDefault(x => string.Equals(x.Label, text, StringComparison.OrdinalIgnoreCase) || string.Equals(x.Type + " " + x.Id, text, StringComparison.OrdinalIgnoreCase));
            if (o != null) return o.Key;
            // Dependency objects by label.
            foreach (var k in objectCallers.Keys)
                if (string.Equals(LabelOf(k), text, StringComparison.OrdinalIgnoreCase)) return k;
            return null;
        }
 
        /// <summary>The shortest call path between two objects or parts, object by object.</summary>
        public Dictionary<string, object> Path(string from, string to)
        {
            var start = arch.Resolve(from) ?? (FindObject(from) != null ? new HashSet<string>(new[] { FindObject(from) }) : null);
            var goal = arch.Resolve(to) ?? (FindObject(to) != null ? new HashSet<string>(new[] { FindObject(to) }) : null);
            if (start == null) return Obj("found", false, "error", "Unknown start '" + from + "'.");
            if (goal == null) return Obj("found", false, "error", "Unknown target '" + to + "'.");
            var parent = new Dictionary<string, string>(StringComparer.Ordinal);
            var q = new Queue<string>();
            foreach (var s in start) { parent[s] = null; q.Enqueue(s); }
            string hit = null;
            while (q.Count > 0 && hit == null)
            {
                string cur = q.Dequeue();
                if (goal.Contains(cur) && !start.Contains(cur)) { hit = cur; break; }
                Dictionary<string, int> next;
                if (!objectCalls.TryGetValue(cur, out next)) continue;
                foreach (var n in next.Keys) if (!parent.ContainsKey(n)) { parent[n] = cur; q.Enqueue(n); }
            }
            if (hit == null) return Obj("found", false, "error", "No call path from '" + from + "' to '" + to + "'.");
            var steps = new List<object>();
            for (string c = hit; c != null; c = parent[c]) steps.Insert(0, Obj("object", LabelOf(c), "part", PartOf(c)));
            return Obj("found", true, "steps", steps);
        }
 
        /// <summary>Per part, a Markdown digest an agent (or a person) reads to name and describe the part and its objects.</summary>
        public string Worksheet(Part part)
        {
            var sb = new StringBuilder();
            var keys = arch.Assignment.Where(kv => kv.Value == part.Id).Select(kv => kv.Key).ToList();
            sb.AppendLine("# Part " + part.Id + ": " + part.Label);
            sb.AppendLine();
            sb.AppendLine("Summary: " + (string.IsNullOrEmpty(part.Summary) ? "(write one sentence)" : part.Summary));
            sb.AppendLine("Paths: " + string.Join(", ", part.Paths));
            sb.AppendLine();
            foreach (var key in keys.OrderBy(k => LabelOf(k)))
            {
                ObjectInfo o = objects[key];
                sb.AppendLine("## " + o.Label + " (" + o.Path + ":" + o.Line + ")");
                string d = Describe(o.Label);
                sb.AppendLine("Description: " + (d ?? "(missing)"));
                var members = g.Nodes.Values.Where(n => n.ObjectKey == key && (n.Kind == "Method" || n.Kind == "Trigger")).OrderBy(n => n.Line).Select(n => n.Member).Distinct().Take(40).ToList();
                if (members.Count > 0) sb.AppendLine("Members: " + string.Join(", ", members));
                var single = new HashSet<string>(new[] { key }, StringComparer.Ordinal);
                var uses = Neighbours(single, objectCalls).Take(12).Select(x => (string)((Dictionary<string, object>)x)["object"]).ToList();
                var usedBy = Neighbours(single, objectCallers).Take(12).Select(x => (string)((Dictionary<string, object>)x)["object"]).ToList();
                if (uses.Count > 0) sb.AppendLine("Uses: " + string.Join("; ", uses));
                if (usedBy.Count > 0) sb.AppendLine("Used by: " + string.Join("; ", usedBy));
                sb.AppendLine();
            }
            return sb.ToString();
        }
    }
}