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(); } } } |